feat: SOE Rule Engine + Superset Insights (cherry-pick f0c3ce4)
- SOE event-bus, handlers contabil/financial - SOE rule-engine: tipos, fiscal, business, index - SupersetDashboard component - server/erp/routes atualizado - shared/schema: tabelas SOE (soe_regras, soe_eventos, soe_lancamentos) - server/routes: remove Metabase, consolida Superset routes - package.json: adiciona @superset-ui/embedded-sdk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5d1dd97d0f
commit
75ef36eac9
10
.env.example
10
.env.example
|
|
@ -68,9 +68,15 @@ PLUS_URL=http://localhost:8080
|
||||||
PLUS_PORT=8080
|
PLUS_PORT=8080
|
||||||
PLUS_API_TOKEN=
|
PLUS_API_TOKEN=
|
||||||
|
|
||||||
# ── Superset (BI avançado) ────────────────────────────────────────────────────
|
# ── Superset (BI avançado — Arcádia Insights) ────────────────────────────────
|
||||||
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura
|
SUPERSET_HOST=superset
|
||||||
SUPERSET_PORT=8088
|
SUPERSET_PORT=8088
|
||||||
|
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura # openssl rand -hex 32
|
||||||
|
SUPERSET_ADMIN_USER=admin
|
||||||
|
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
||||||
|
SUPERSET_ADMIN_PASSWORD=troque-senha-forte-em-prod
|
||||||
|
# URL do banco Arcádia para o Superset acessar os dados (idealmente read-only)
|
||||||
|
ARCADIA_DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
|
||||||
# ── Redis ─────────────────────────────────────────────────────────────────────
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Switch, Route } from "wouter";
|
import { Switch, Route, useLocation } from "wouter";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense, useEffect } from "react";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
@ -124,7 +124,7 @@ function Router() {
|
||||||
<ProtectedRoute path="/engineering" component={EngineeringHub} />
|
<ProtectedRoute path="/engineering" component={EngineeringHub} />
|
||||||
<ProtectedRoute path="/development" component={DevelopmentModule} />
|
<ProtectedRoute path="/development" component={DevelopmentModule} />
|
||||||
<ProtectedRoute path="/retail" component={ArcadiaRetail} />
|
<ProtectedRoute path="/retail" component={ArcadiaRetail} />
|
||||||
<ProtectedRoute path="/plus" component={Plus} />
|
<ProtectedRoute path="/plus" component={() => { const [, nav] = useLocation(); useEffect(() => nav("/soe"), []); return null; }} />
|
||||||
<ProtectedRoute path="/super-admin" component={SuperAdmin} />
|
<ProtectedRoute path="/super-admin" component={SuperAdmin} />
|
||||||
<ProtectedRoute path="/marketplace" component={Marketplace} />
|
<ProtectedRoute path="/marketplace" component={Marketplace} />
|
||||||
<ProtectedRoute path="/lms" component={LMS} />
|
<ProtectedRoute path="/lms" component={LMS} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { Loader2, AlertTriangle, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface SupersetDashboardProps {
|
||||||
|
dashboardId: string;
|
||||||
|
height?: string | number;
|
||||||
|
className?: string;
|
||||||
|
showOpenLink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente reutilizável para embedar dashboards do Apache Superset (Arcádia Insights)
|
||||||
|
* em QUALQUER tela do Arcádia Suite via Guest Token JWT.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* <SupersetDashboard dashboardId="financial-overview" />
|
||||||
|
* <SupersetDashboard dashboardId="dre-mensal" height={400} />
|
||||||
|
*/
|
||||||
|
export function SupersetDashboard({
|
||||||
|
dashboardId,
|
||||||
|
height = "calc(100vh - 320px)",
|
||||||
|
className = "",
|
||||||
|
showOpenLink = true,
|
||||||
|
}: SupersetDashboardProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sdkRef = useRef<any>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading");
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
|
||||||
|
const fetchGuestToken = useCallback(async (): Promise<string> => {
|
||||||
|
const resp = await fetch("/api/superset/guest-token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dashboardId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: "Erro desconhecido" }));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const { token } = await resp.json();
|
||||||
|
return token;
|
||||||
|
}, [dashboardId]);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMsg("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verifica se Superset está disponível
|
||||||
|
const health = await fetch("/api/superset/health", { credentials: "include" });
|
||||||
|
const healthData = await health.json();
|
||||||
|
if (!healthData.online) {
|
||||||
|
setStatus("unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importa SDK dinamicamente (só quando necessário)
|
||||||
|
const { embedDashboard } = await import("@superset-ui/embedded-sdk");
|
||||||
|
|
||||||
|
// Limpa embedding anterior se houver
|
||||||
|
if (sdkRef.current) {
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkRef.current = await embedDashboard({
|
||||||
|
id: dashboardId,
|
||||||
|
supersetDomain: window.location.origin + "/superset",
|
||||||
|
mountPoint: containerRef.current,
|
||||||
|
fetchGuestToken,
|
||||||
|
dashboardUiConfig: {
|
||||||
|
hideTitle: true,
|
||||||
|
hideChartControls: false,
|
||||||
|
filters: { visible: true, expanded: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[SupersetDashboard] Erro:", err.message);
|
||||||
|
// Se SDK não estiver instalado, usa iframe como fallback
|
||||||
|
if (err.message?.includes("Cannot find module") || err.message?.includes("embedDashboard")) {
|
||||||
|
setStatus("unavailable");
|
||||||
|
} else {
|
||||||
|
setErrorMsg(err.message);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dashboardId, fetchGuestToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
return () => {
|
||||||
|
if (containerRef.current) containerRef.current.innerHTML = "";
|
||||||
|
};
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const heightStyle = typeof height === "number" ? `${height}px` : height;
|
||||||
|
|
||||||
|
if (status === "unavailable") {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm ${className}`} style={{ height: heightStyle }}>
|
||||||
|
<iframe
|
||||||
|
src="/superset/login"
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title="Arcádia Insights — Apache Superset"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center gap-4 rounded-xl border border-red-100 bg-red-50 ${className}`} style={{ height: heightStyle }}>
|
||||||
|
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium text-red-700 text-sm">Erro ao carregar dashboard</p>
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errorMsg}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadDashboard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" /> Tentar novamente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} style={{ height: heightStyle }}>
|
||||||
|
{status === "loading" && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/90 z-10 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-[#c89b3c] animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-[#1f334d] font-medium">Carregando Arcádia Insights...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showOpenLink && status === "ready" && (
|
||||||
|
<a
|
||||||
|
href="/superset"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" /> Abrir completo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full rounded-xl overflow-hidden border border-[#c89b3c]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -167,12 +167,32 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||||
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia_superset
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia_superset
|
||||||
|
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia
|
||||||
|
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||||
|
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
||||||
|
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-arcadia2026}
|
||||||
|
SUPERSET_WEBSERVER_PORT: 8088
|
||||||
|
PYTHONPATH: /app/pythonpath
|
||||||
|
FLASK_ENV: production
|
||||||
ports:
|
ports:
|
||||||
- "8088:8088"
|
- "8088:8088"
|
||||||
|
volumes:
|
||||||
|
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||||
|
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||||
|
- superset_home:/app/superset_home
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- arcadia
|
||||||
profiles: [bi]
|
profiles: [bi]
|
||||||
|
|
||||||
# ─────────────── PERFIL: ai (Soberania de IA) ────────────────────────────────
|
# ─────────────── PERFIL: ai (Soberania de IA) ────────────────────────────────
|
||||||
|
|
@ -231,3 +251,8 @@ volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
ollama_models:
|
ollama_models:
|
||||||
open_webui_data:
|
open_webui_data:
|
||||||
|
superset_home:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
arcadia:
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@superset-ui/embedded-sdk": "^0.3.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@tiptap/extension-image": "^3.15.3",
|
"@tiptap/extension-image": "^3.15.3",
|
||||||
"@tiptap/extension-link": "^3.15.3",
|
"@tiptap/extension-link": "^3.15.3",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { db } from "../../db/index";
|
||||||
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable } from "@shared/schema";
|
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable } from "@shared/schema";
|
||||||
import { eq, and, sql, desc } from "drizzle-orm";
|
import { eq, and, sql, desc } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { soeRuleEngine } from "../soe/rule-engine/index";
|
||||||
|
import { soeEventBus } from "../soe/event-bus";
|
||||||
|
|
||||||
export function registerErpRoutes(app: Express): void {
|
export function registerErpRoutes(app: Express): void {
|
||||||
app.get("/api/erp/connections", async (req: Request, res: Response) => {
|
app.get("/api/erp/connections", async (req: Request, res: Response) => {
|
||||||
|
|
@ -850,12 +852,24 @@ export function registerErpRoutes(app: Express): void {
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
return res.status(401).json({ error: "Not authenticated" });
|
||||||
}
|
}
|
||||||
const tenantId = req.user?.tenantId || 1;
|
const tenantId = req.user?.tenantId || 1;
|
||||||
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = req.body;
|
const user = req.user as any;
|
||||||
|
|
||||||
|
// SOE Rule Engine — aplica regras fiscais e de negócio antes de criar o pedido
|
||||||
|
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||||
|
"pre_sales_order",
|
||||||
|
{ ...req.body, _tipo: "venda" },
|
||||||
|
{ tenantId, empresaId: req.body.empresaId, userId: user?.id, motor: "local" }
|
||||||
|
);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return res.status(422).json({ error: "Validação falhou", details: validationErrors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = enriched;
|
||||||
const [order] = await db.insert(salesOrders).values({
|
const [order] = await db.insert(salesOrders).values({
|
||||||
tenantId,
|
tenantId,
|
||||||
orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes
|
orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes
|
||||||
}).returning();
|
}).returning();
|
||||||
res.status(201).json(order);
|
res.status(201).json({ ...order, _rulesApplied: enriched._rulesApplied });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating sales order:", error);
|
console.error("Error creating sales order:", error);
|
||||||
res.status(500).json({ error: "Failed to create sales order" });
|
res.status(500).json({ error: "Failed to create sales order" });
|
||||||
|
|
@ -876,7 +890,16 @@ export function registerErpRoutes(app: Express): void {
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return res.status(404).json({ error: "Sales order not found" });
|
return res.status(404).json({ error: "Sales order not found" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, order: updated, message: "Pedido confirmado. Lançamentos contábeis gerados." });
|
|
||||||
|
// SOE Event Bus — dispara automações pós-confirmação
|
||||||
|
soeEventBus.emit("venda_confirmada", {
|
||||||
|
tenantId,
|
||||||
|
empresaId: (req.user as any)?.empresaId,
|
||||||
|
motorOrigem: "local",
|
||||||
|
dados: { id, status: "confirmed", ...updated },
|
||||||
|
}).catch((e) => console.error("[SOE] venda_confirmada error:", e.message));
|
||||||
|
|
||||||
|
res.json({ success: true, order: updated, message: "Pedido confirmado. Automações SOE disparadas." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error confirming sales order:", error);
|
console.error("Error confirming sales order:", error);
|
||||||
res.status(500).json({ error: "Failed to confirm sales order" });
|
res.status(500).json({ error: "Failed to confirm sales order" });
|
||||||
|
|
@ -890,6 +913,18 @@ export function registerErpRoutes(app: Express): void {
|
||||||
}
|
}
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const tenantId = req.user?.tenantId || 1;
|
const tenantId = req.user?.tenantId || 1;
|
||||||
|
const user = req.user as any;
|
||||||
|
|
||||||
|
// SOE Rule Engine — aplica regras fiscais antes de gerar a NF-e
|
||||||
|
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||||
|
"pre_nfe_emissao",
|
||||||
|
{ ...req.body, tipoDocumento: "nfe", _tipo: "venda" },
|
||||||
|
{ tenantId, empresaId: user?.empresaId, userId: user?.id, motor: "local" }
|
||||||
|
);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return res.status(422).json({ error: "Validação fiscal falhou", details: validationErrors });
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db.update(salesOrders)
|
const [updated] = await db.update(salesOrders)
|
||||||
.set({ status: "invoiced" })
|
.set({ status: "invoiced" })
|
||||||
.where(and(eq(salesOrders.id, id), eq(salesOrders.tenantId, tenantId)))
|
.where(and(eq(salesOrders.id, id), eq(salesOrders.tenantId, tenantId)))
|
||||||
|
|
@ -897,7 +932,26 @@ export function registerErpRoutes(app: Express): void {
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return res.status(404).json({ error: "Sales order not found" });
|
return res.status(404).json({ error: "Sales order not found" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, order: updated, message: "NF-e gerada com sucesso." });
|
|
||||||
|
// SOE Event Bus — dispara lançamentos contábeis automáticos
|
||||||
|
soeEventBus.emit("nfe_emitida", {
|
||||||
|
tenantId,
|
||||||
|
empresaId: user?.empresaId,
|
||||||
|
motorOrigem: "local",
|
||||||
|
dados: {
|
||||||
|
...enriched,
|
||||||
|
...updated,
|
||||||
|
valorTotal: updated.total,
|
||||||
|
dataEmissao: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).catch((e) => console.error("[SOE] nfe_emitida error:", e.message));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
order: updated,
|
||||||
|
tributacao: enriched.tributacao,
|
||||||
|
message: "NF-e gerada. Lançamentos contábeis sendo processados.",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating NF-e:", error);
|
console.error("Error generating NF-e:", error);
|
||||||
res.status(500).json({ error: "Failed to generate NF-e" });
|
res.status(500).json({ error: "Failed to generate NF-e" });
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,9 @@ import lmsRoutes from "./lms/routes";
|
||||||
import xosRoutes from "./xos/routes";
|
import xosRoutes from "./xos/routes";
|
||||||
import governanceRoutes from "./governance/routes";
|
import governanceRoutes from "./governance/routes";
|
||||||
import { setupPlusProxy } from "./plus/proxy";
|
import { setupPlusProxy } from "./plus/proxy";
|
||||||
import { setupMetabaseProxy } from "./metabase/proxy";
|
|
||||||
import { setupSupersetProxy } from "./superset/proxy";
|
import { setupSupersetProxy } from "./superset/proxy";
|
||||||
import { registerSupersetRoutes } from "./superset/routes";
|
import { registerSupersetRoutes } from "./superset/routes";
|
||||||
import { registerEngineRoomRoutes } from "./engine-room/routes";
|
import { registerEngineRoomRoutes } from "./engine-room/routes";
|
||||||
import { registerMetaSetRoutes } from "./metaset/routes";
|
|
||||||
import plusSsoRoutes from "./plus/sso";
|
import plusSsoRoutes from "./plus/sso";
|
||||||
import migrationRoutes from "./migration/routes";
|
import migrationRoutes from "./migration/routes";
|
||||||
import { githubRoutes } from "./integrations/github";
|
import { githubRoutes } from "./integrations/github";
|
||||||
|
|
@ -74,10 +72,9 @@ export async function registerRoutes(
|
||||||
// Arcádia Plus - Proxy registered AFTER session but BEFORE auth-protected routes
|
// Arcádia Plus - Proxy registered AFTER session but BEFORE auth-protected routes
|
||||||
await setupPlusProxy(app);
|
await setupPlusProxy(app);
|
||||||
|
|
||||||
// Superset BI - Proxy
|
// Apache Superset - Arcádia Insights (substitui Metabase)
|
||||||
setupSupersetProxy(app);
|
setupSupersetProxy(app);
|
||||||
// Metabase BI - Proxy to Metabase instance
|
registerSupersetRoutes(app);
|
||||||
setupMetabaseProxy(app);
|
|
||||||
|
|
||||||
registerChatRoutes(app);
|
registerChatRoutes(app);
|
||||||
registerSoeRoutes(app);
|
registerSoeRoutes(app);
|
||||||
|
|
@ -94,8 +91,6 @@ export async function registerRoutes(
|
||||||
registerBiRoutes(app);
|
registerBiRoutes(app);
|
||||||
app.use("/api/graph", graphRoutes);
|
app.use("/api/graph", graphRoutes);
|
||||||
registerBiEngineRoutes(app);
|
registerBiEngineRoutes(app);
|
||||||
registerMetaSetRoutes(app);
|
|
||||||
registerSupersetRoutes(app);
|
|
||||||
registerCommEngineRoutes(app);
|
registerCommEngineRoutes(app);
|
||||||
registerLearningRoutes(app);
|
registerLearningRoutes(app);
|
||||||
app.use("/api/compass", compassRoutes);
|
app.use("/api/compass", compassRoutes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
// SOE Event Bus — Dispara eventos após execução do motor
|
||||||
|
// Permite que lançamentos contábeis, alertas e automações
|
||||||
|
// sejam disparados de forma desacoplada após cada operação SOE.
|
||||||
|
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { soeEventos } from "@shared/schema";
|
||||||
|
import { contabilHandler } from "./handlers/contabil-handler";
|
||||||
|
import { financialHandler } from "./handlers/financial-handler";
|
||||||
|
|
||||||
|
export type SoeEventType =
|
||||||
|
| "nfe_emitida"
|
||||||
|
| "nfe_cancelada"
|
||||||
|
| "venda_confirmada"
|
||||||
|
| "venda_faturada"
|
||||||
|
| "compra_recebida"
|
||||||
|
| "pagamento_realizado"
|
||||||
|
| "recebimento_realizado"
|
||||||
|
| "estoque_baixo";
|
||||||
|
|
||||||
|
export interface SoeEventPayload {
|
||||||
|
tenantId?: number;
|
||||||
|
empresaId?: number;
|
||||||
|
motorOrigem?: "plus" | "erpnext" | "local";
|
||||||
|
dados: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandler = (payload: SoeEventPayload) => Promise<void>;
|
||||||
|
|
||||||
|
// Mapa de handlers por evento
|
||||||
|
const HANDLERS: Record<SoeEventType, EventHandler[]> = {
|
||||||
|
nfe_emitida: [
|
||||||
|
contabilHandler.lancamentoVenda,
|
||||||
|
financialHandler.criarContaReceber,
|
||||||
|
],
|
||||||
|
nfe_cancelada: [
|
||||||
|
contabilHandler.estornoLancamentoVenda,
|
||||||
|
],
|
||||||
|
venda_confirmada: [
|
||||||
|
financialHandler.reservarEstoque,
|
||||||
|
],
|
||||||
|
venda_faturada: [
|
||||||
|
contabilHandler.lancamentoVenda,
|
||||||
|
],
|
||||||
|
compra_recebida: [
|
||||||
|
contabilHandler.lancamentoCompra,
|
||||||
|
financialHandler.criarContaPagar,
|
||||||
|
],
|
||||||
|
pagamento_realizado: [
|
||||||
|
contabilHandler.lancamentoPagamento,
|
||||||
|
financialHandler.baixarContaPagar,
|
||||||
|
],
|
||||||
|
recebimento_realizado: [
|
||||||
|
contabilHandler.lancamentoRecebimento,
|
||||||
|
financialHandler.baixarContaReceber,
|
||||||
|
],
|
||||||
|
estoque_baixo: [
|
||||||
|
financialHandler.alertaEstoqueBaixo,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SoeEventBus {
|
||||||
|
private static instance: SoeEventBus;
|
||||||
|
|
||||||
|
static getInstance(): SoeEventBus {
|
||||||
|
if (!SoeEventBus.instance) SoeEventBus.instance = new SoeEventBus();
|
||||||
|
return SoeEventBus.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(evento: SoeEventType, payload: SoeEventPayload): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Registra evento no banco
|
||||||
|
let eventoId: string | undefined;
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(soeEventos)
|
||||||
|
.values({
|
||||||
|
tenantId: payload.tenantId,
|
||||||
|
empresaId: payload.empresaId,
|
||||||
|
evento,
|
||||||
|
motorOrigem: payload.motorOrigem,
|
||||||
|
payloadEntrada: payload.dados,
|
||||||
|
status: "ok",
|
||||||
|
})
|
||||||
|
.returning({ id: soeEventos.id });
|
||||||
|
eventoId = row?.id;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[SoeEventBus] Erro ao registrar evento:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispara handlers em paralelo (falha isolada — não bloqueia o fluxo)
|
||||||
|
const handlers = HANDLERS[evento] ?? [];
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
handlers.map((h) => h({ ...payload, dados: { ...payload.dados, _eventoId: eventoId } }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const erros = results
|
||||||
|
.filter((r) => r.status === "rejected")
|
||||||
|
.map((r) => (r as PromiseRejectedResult).reason?.message);
|
||||||
|
|
||||||
|
if (erros.length > 0) {
|
||||||
|
console.warn(`[SoeEventBus] ${evento}: ${erros.length} handler(s) falharam:`, erros);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SoeEventBus] ${evento} processado em ${Date.now() - start}ms | ` +
|
||||||
|
`${handlers.length} handlers | ${erros.length} erros`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const soeEventBus = SoeEventBus.getInstance();
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
// SOE — Handler Contábil
|
||||||
|
// Gera lançamentos contábeis automáticos a partir de eventos SOE.
|
||||||
|
// Os lançamentos são salvos em soe_lancamentos e depois sincronizados
|
||||||
|
// com o Motor Contábil Python (:8003).
|
||||||
|
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { soeLancamentos } from "@shared/schema";
|
||||||
|
import type { SoeEventPayload } from "../event-bus";
|
||||||
|
|
||||||
|
const CONTABIL_ENGINE_URL =
|
||||||
|
`http://${process.env.CONTABIL_ENGINE_HOST || "localhost"}:${process.env.CONTABIL_PORT || "8003"}`;
|
||||||
|
|
||||||
|
async function inserirLancamento(
|
||||||
|
tenantId: number | undefined,
|
||||||
|
empresaId: number,
|
||||||
|
data: string,
|
||||||
|
contaDebito: string,
|
||||||
|
contaCredito: string,
|
||||||
|
valor: number,
|
||||||
|
historico: string,
|
||||||
|
origemEvento: string,
|
||||||
|
origemEventoId?: string
|
||||||
|
) {
|
||||||
|
if (!valor || valor <= 0) return;
|
||||||
|
try {
|
||||||
|
await db.insert(soeLancamentos).values({
|
||||||
|
tenantId,
|
||||||
|
empresaId,
|
||||||
|
data,
|
||||||
|
contaDebito,
|
||||||
|
contaCredito,
|
||||||
|
valor: String(valor),
|
||||||
|
historico,
|
||||||
|
origemEvento,
|
||||||
|
origemEventoId,
|
||||||
|
periodo: data.substring(0, 7), // 'YYYY-MM'
|
||||||
|
enviado8003: false,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[ContabilHandler] Erro ao inserir lançamento:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contabilHandler = {
|
||||||
|
|
||||||
|
// NF-e de venda emitida → lançamentos de receita + impostos
|
||||||
|
lancamentoVenda: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId, empresaId } = payload;
|
||||||
|
if (!empresaId || !dados.valorTotal) return;
|
||||||
|
|
||||||
|
const data = dados.dataEmissao?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||||
|
const numeroNfe = dados.numero || dados.nfeId || "?";
|
||||||
|
const eventoId = dados._eventoId;
|
||||||
|
|
||||||
|
// D: Clientes / C: Receita Bruta de Vendas
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"1.1.3.01", "3.1.1.01",
|
||||||
|
parseFloat(dados.valorTotal),
|
||||||
|
`Venda NF-e ${numeroNfe}`,
|
||||||
|
"nfe_emitida", eventoId
|
||||||
|
);
|
||||||
|
|
||||||
|
// D: Deduções ICMS / C: ICMS a Recolher
|
||||||
|
if (dados.valorIcms && parseFloat(dados.valorIcms) > 0) {
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"3.1.2.01", "2.1.2.01",
|
||||||
|
parseFloat(dados.valorIcms),
|
||||||
|
`ICMS s/ Venda NF-e ${numeroNfe}`,
|
||||||
|
"nfe_emitida", eventoId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// D: Deduções PIS / C: PIS a Recolher
|
||||||
|
if (dados.valorPis && parseFloat(dados.valorPis) > 0) {
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"3.1.2.02", "2.1.2.02",
|
||||||
|
parseFloat(dados.valorPis),
|
||||||
|
`PIS s/ Venda NF-e ${numeroNfe}`,
|
||||||
|
"nfe_emitida", eventoId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// D: Deduções COFINS / C: COFINS a Recolher
|
||||||
|
if (dados.valorCofins && parseFloat(dados.valorCofins) > 0) {
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"3.1.2.03", "2.1.2.03",
|
||||||
|
parseFloat(dados.valorCofins),
|
||||||
|
`COFINS s/ Venda NF-e ${numeroNfe}`,
|
||||||
|
"nfe_emitida", eventoId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancelamento de NF-e → estorno dos lançamentos
|
||||||
|
estornoLancamentoVenda: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId, empresaId } = payload;
|
||||||
|
if (!empresaId || !dados.valorTotal) return;
|
||||||
|
|
||||||
|
const data = new Date().toISOString().substring(0, 10);
|
||||||
|
const numeroNfe = dados.numero || dados.nfeId || "?";
|
||||||
|
|
||||||
|
// Estorno: inverte débito/crédito
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"3.1.1.01", "1.1.3.01",
|
||||||
|
parseFloat(dados.valorTotal),
|
||||||
|
`Estorno NF-e ${numeroNfe} (cancelamento)`,
|
||||||
|
"nfe_cancelada"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compra recebida (NF-e entrada) → entrada em estoque + fornecedor
|
||||||
|
lancamentoCompra: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId, empresaId } = payload;
|
||||||
|
if (!empresaId || !dados.valorMercadorias) return;
|
||||||
|
|
||||||
|
const data = dados.dataEntrada?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||||
|
const numeroNfe = dados.numero || "?";
|
||||||
|
const fornecedor = dados.fornecedor || "Fornecedor";
|
||||||
|
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"1.1.4.01", "2.1.1.01",
|
||||||
|
parseFloat(dados.valorMercadorias),
|
||||||
|
`Compra NF-e ${numeroNfe} / ${fornecedor}`,
|
||||||
|
"compra_recebida"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pagamento de conta a pagar
|
||||||
|
lancamentoPagamento: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId, empresaId } = payload;
|
||||||
|
if (!empresaId || !dados.valorPago) return;
|
||||||
|
|
||||||
|
const data = dados.dataPagamento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"2.1.1.01", "1.1.1.01",
|
||||||
|
parseFloat(dados.valorPago),
|
||||||
|
`Pagamento: ${dados.descricao || "Conta a pagar"}`,
|
||||||
|
"pagamento_realizado"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recebimento de cliente
|
||||||
|
lancamentoRecebimento: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId, empresaId } = payload;
|
||||||
|
if (!empresaId || !dados.valorRecebido) return;
|
||||||
|
|
||||||
|
const data = dados.dataRecebimento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
await inserirLancamento(
|
||||||
|
tenantId, empresaId, data,
|
||||||
|
"1.1.1.01", "1.1.3.01",
|
||||||
|
parseFloat(dados.valorRecebido),
|
||||||
|
`Recebimento: ${dados.descricao || "Conta a receber"}`,
|
||||||
|
"recebimento_realizado"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sincroniza lançamentos pendentes com Motor Contábil Python (:8003)
|
||||||
|
export async function sincronizarComMotorContabil(empresaId: number, periodo: string) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${CONTABIL_ENGINE_URL}/lancamentos/sincronizar`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ empresaId, periodo }),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("[ContabilHandler] Motor :8003 retornou:", resp.status);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("[ContabilHandler] Motor :8003 indisponível:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
// SOE — Handler Financeiro
|
||||||
|
// Automações financeiras disparadas pelo Event Bus SOE.
|
||||||
|
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { finAccountsReceivable, finAccountsPayable } from "@shared/schema";
|
||||||
|
import type { SoeEventPayload } from "../event-bus";
|
||||||
|
|
||||||
|
export const financialHandler = {
|
||||||
|
|
||||||
|
// Cria conta a receber automaticamente ao emitir NF-e de venda
|
||||||
|
criarContaReceber: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId } = payload;
|
||||||
|
if (!dados.valorTotal || !dados.clienteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verifica se já existe conta a receber para esta NF-e
|
||||||
|
if (dados.nfeId) {
|
||||||
|
const existente = await db.query.finAccountsReceivable?.findFirst?.({
|
||||||
|
where: (t: any, { eq }: any) => eq(t.reference, `nfe:${dados.nfeId}`),
|
||||||
|
});
|
||||||
|
if (existente) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vencimento = dados.vencimento
|
||||||
|
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
await db.insert(finAccountsReceivable).values({
|
||||||
|
tenantId: tenantId ?? 1,
|
||||||
|
customerId: dados.clienteId,
|
||||||
|
description: `NF-e ${dados.numero || dados.nfeId || "?"} — ${dados.clienteNome || "Cliente"}`,
|
||||||
|
amount: String(dados.valorTotal),
|
||||||
|
dueDate: vencimento,
|
||||||
|
status: "pending",
|
||||||
|
category: "vendas",
|
||||||
|
paymentMethod: dados.formaPagamento || "outros",
|
||||||
|
notes: `Gerado automaticamente pelo SOE ao emitir NF-e`,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
console.log("[FinancialHandler] Conta a receber criada para NF-e", dados.nfeId || dados.numero);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[FinancialHandler] criarContaReceber error:", e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Baixa conta a receber após recebimento confirmado
|
||||||
|
baixarContaReceber: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados } = payload;
|
||||||
|
if (!dados.contaReceiverId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(finAccountsReceivable)
|
||||||
|
.set({
|
||||||
|
status: "received",
|
||||||
|
receivedDate: dados.dataRecebimento || new Date().toISOString().substring(0, 10),
|
||||||
|
paymentMethod: dados.formaPagamento || "outros",
|
||||||
|
} as any)
|
||||||
|
.where((finAccountsReceivable as any).id.eq(dados.contaReceiverId));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[FinancialHandler] baixarContaReceber error:", e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cria conta a pagar ao receber mercadoria
|
||||||
|
criarContaPagar: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados, tenantId } = payload;
|
||||||
|
if (!dados.valorTotal || !dados.fornecedorId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vencimento = dados.vencimento
|
||||||
|
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
await db.insert(finAccountsPayable).values({
|
||||||
|
tenantId: tenantId ?? 1,
|
||||||
|
supplierId: dados.fornecedorId,
|
||||||
|
description: `NF-e Entrada ${dados.numero || "?"} — ${dados.fornecedor || "Fornecedor"}`,
|
||||||
|
amount: String(dados.valorTotal),
|
||||||
|
dueDate: vencimento,
|
||||||
|
status: "pending",
|
||||||
|
category: "compras",
|
||||||
|
notes: `Gerado automaticamente pelo SOE ao receber compra`,
|
||||||
|
} as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[FinancialHandler] criarContaPagar error:", e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Baixa conta a pagar após pagamento
|
||||||
|
baixarContaPagar: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados } = payload;
|
||||||
|
if (!dados.contaPayableId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(finAccountsPayable)
|
||||||
|
.set({
|
||||||
|
status: "paid",
|
||||||
|
paidDate: dados.dataPagamento || new Date().toISOString().substring(0, 10),
|
||||||
|
paymentMethod: dados.formaPagamento || "outros",
|
||||||
|
} as any)
|
||||||
|
.where((finAccountsPayable as any).id.eq(dados.contaPayableId));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[FinancialHandler] baixarContaPagar error:", e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reserva estoque ao confirmar venda (notificação — execução no motor)
|
||||||
|
reservarEstoque: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados } = payload;
|
||||||
|
if (!dados.items || dados.motor === "local") return;
|
||||||
|
// Log apenas — a reserva real é feita pelo motor Plus/ERPNext
|
||||||
|
console.log(
|
||||||
|
`[FinancialHandler] Venda ${dados.id} confirmada — ` +
|
||||||
|
`${dados.items?.length || 0} item(s) para reserva de estoque`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alerta de estoque baixo (via console/log — webhook futuro)
|
||||||
|
alertaEstoqueBaixo: async (payload: SoeEventPayload) => {
|
||||||
|
const { dados } = payload;
|
||||||
|
console.warn(
|
||||||
|
`[FinancialHandler] ALERTA: Estoque baixo — ` +
|
||||||
|
`Produto ${dados.produtoId} / ${dados.produtoNome} ` +
|
||||||
|
`(atual: ${dados.estoqueAtual}, mínimo: ${dados.estoqueMinimo})`
|
||||||
|
);
|
||||||
|
// TODO Fase 5: enviar via WhatsApp/Email usando Motor Comunicação (:8006)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// SOE — Regras de Negócio Padrão
|
||||||
|
|
||||||
|
import type { SoeRule } from "./types";
|
||||||
|
|
||||||
|
export const BUSINESS_RULES_PADRAO: SoeRule[] = [
|
||||||
|
// ── Aprovação de pedidos ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "PEDIDO_APROVACAO_AUTO_BAIXO_VALOR",
|
||||||
|
dominio: "business",
|
||||||
|
trigger: "pre_sales_order_confirm",
|
||||||
|
nome: "Aprovação automática para pedidos de baixo valor",
|
||||||
|
condicao: { "totalAmount": { "_lt": 5000 } },
|
||||||
|
acao: { set: { "_aprovacaoAutomatica": true, "status": "confirmed" } },
|
||||||
|
prioridade: 10,
|
||||||
|
ativo: false, // tenant habilita com seu limite
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Estoque ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "RESERVA_ESTOQUE_AO_CONFIRMAR",
|
||||||
|
dominio: "business",
|
||||||
|
trigger: "post_sales_order_confirm",
|
||||||
|
nome: "Reserva estoque ao confirmar pedido",
|
||||||
|
condicao: { "status": "confirmed" },
|
||||||
|
acao: { set: { "_reservarEstoque": true } },
|
||||||
|
prioridade: 10,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Financeiro ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "CRIAR_CONTA_RECEBER_AO_FATURAR",
|
||||||
|
dominio: "business",
|
||||||
|
trigger: "post_nfe_emitida",
|
||||||
|
nome: "Cria conta a receber automaticamente ao faturar",
|
||||||
|
condicao: { "_tipoOperacao": "venda" },
|
||||||
|
acao: { set: { "_criarContaReceber": true } },
|
||||||
|
prioridade: 5,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CRIAR_CONTA_PAGAR_AO_RECEBER_COMPRA",
|
||||||
|
dominio: "business",
|
||||||
|
trigger: "post_purchase_received",
|
||||||
|
nome: "Cria conta a pagar ao receber mercadoria",
|
||||||
|
condicao: {},
|
||||||
|
acao: { set: { "_criarContaPagar": true } },
|
||||||
|
prioridade: 5,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Alertas ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "ALERTA_ESTOQUE_MINIMO",
|
||||||
|
dominio: "business",
|
||||||
|
trigger: "post_sales_order_confirm",
|
||||||
|
nome: "Alerta quando estoque cai abaixo do mínimo",
|
||||||
|
condicao: { "_estoqueAbaixoMinimo": true },
|
||||||
|
acao: { set: { "_enviarAlertaEstoque": true } },
|
||||||
|
prioridade: 15,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
// SOE — Regras Fiscais Padrão
|
||||||
|
// Estas regras enriquecem o payload ANTES de despachar ao motor (Plus/ERPNext)
|
||||||
|
// O motor recebe o payload já correto e só executa.
|
||||||
|
|
||||||
|
import type { SoeRule } from "./types";
|
||||||
|
|
||||||
|
export const FISCAL_RULES_PADRAO: SoeRule[] = [
|
||||||
|
// ── CFOP — Resolução automática por direção da operação ──────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "CFOP_VENDA_DENTRO_ESTADO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_sales_order",
|
||||||
|
nome: "CFOP padrão — venda dentro do estado",
|
||||||
|
condicao: { _cfopNaoDefinido: true, _operacao: "venda" },
|
||||||
|
acao: { set: { "cfopPadrao": "5.102" } },
|
||||||
|
prioridade: 20,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CFOP_VENDA_OUTRO_ESTADO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_sales_order",
|
||||||
|
nome: "CFOP padrão — venda para outro estado",
|
||||||
|
condicao: { _cfopNaoDefinido: true, _operacao: "venda", _interestadual: true },
|
||||||
|
acao: { set: { "cfopPadrao": "6.102" } },
|
||||||
|
prioridade: 19,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CFOP_COMPRA_DENTRO_ESTADO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_purchase_order",
|
||||||
|
nome: "CFOP padrão — compra dentro do estado",
|
||||||
|
condicao: { _cfopNaoDefinido: true },
|
||||||
|
acao: { set: { "cfopPadrao": "1.102" } },
|
||||||
|
prioridade: 20,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tributação por Regime ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "TRIBUTACAO_SIMPLES_NACIONAL",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Simples Nacional — aplica CSOSN e remove destaque PIS/COFINS",
|
||||||
|
condicao: { regimeTributario: "simples_nacional" },
|
||||||
|
acao: {
|
||||||
|
set: {
|
||||||
|
"tributacao.csosnPadrao": "400",
|
||||||
|
"tributacao.destacarPisCofins": false,
|
||||||
|
"tributacao.aliquotaIcms": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prioridade: 5,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TRIBUTACAO_LUCRO_PRESUMIDO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Lucro Presumido — CST 00 com alíquotas padrão",
|
||||||
|
condicao: { regimeTributario: "lucro_presumido" },
|
||||||
|
acao: {
|
||||||
|
set: {
|
||||||
|
"tributacao.cstIcmsPadrao": "00",
|
||||||
|
"tributacao.cstPisPadrao": "01",
|
||||||
|
"tributacao.cstCofinsPadrao": "01",
|
||||||
|
"tributacao.destacarPisCofins": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prioridade: 5,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TRIBUTACAO_LUCRO_REAL",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Lucro Real — CST 00, PIS/COFINS não cumulativo",
|
||||||
|
condicao: { regimeTributario: "lucro_real" },
|
||||||
|
acao: {
|
||||||
|
set: {
|
||||||
|
"tributacao.cstIcmsPadrao": "00",
|
||||||
|
"tributacao.cstPisPadrao": "50",
|
||||||
|
"tributacao.cstCofinsPadrao": "50",
|
||||||
|
"tributacao.destacarPisCofins": true,
|
||||||
|
"tributacao.regimePisCofins": "nao_cumulativo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prioridade: 5,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Validações obrigatórias ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "VALIDAR_NCM_NFE",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Valida NCM obrigatório em NF-e",
|
||||||
|
condicao: { tipoDocumento: ["nfe", "nfce"] },
|
||||||
|
acao: { validar: { campo: "ncm", obrigatorio: true, formato: "^\\d{8}$" } },
|
||||||
|
prioridade: 1,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "VALIDAR_CFOP_OBRIGATORIO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Valida CFOP obrigatório",
|
||||||
|
condicao: {},
|
||||||
|
acao: { validar: { campo: "cfop", obrigatorio: true, formato: "^\\d\\.\\d{3}$" } },
|
||||||
|
prioridade: 1,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "VALIDAR_CNPJ_CPF_DESTINATARIO",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_nfe_emissao",
|
||||||
|
nome: "Valida CNPJ/CPF do destinatário",
|
||||||
|
condicao: { tipoDocumento: "nfe" },
|
||||||
|
acao: { validar: { campo: "destinatario.cpfCnpj", obrigatorio: true } },
|
||||||
|
prioridade: 1,
|
||||||
|
ativo: true,
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Geração automática de NF-e ao faturar ─────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "NFE_AUTO_AO_FATURAR",
|
||||||
|
dominio: "fiscal",
|
||||||
|
trigger: "pre_sales_order_confirm",
|
||||||
|
nome: "Gera NF-e automaticamente ao confirmar pedido (se configurado)",
|
||||||
|
condicao: { "config.nfeAutomatica": true },
|
||||||
|
acao: { set: { "_gerarNfe": true } },
|
||||||
|
prioridade: 10,
|
||||||
|
ativo: false, // desativado por padrão — tenant habilita
|
||||||
|
origemPadrao: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
// SOE Rule Engine — Motor Central de Regras
|
||||||
|
// Intercepta chamadas às rotas SOE antes de despachar ao motor (Plus/ERPNext/local)
|
||||||
|
// e aplica regras configuráveis de negócio, fiscal, contábil e financeiro.
|
||||||
|
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { soeRegras, soeEventos } from "@shared/schema";
|
||||||
|
import { eq, and, isNull, or } from "drizzle-orm";
|
||||||
|
import type { SoeRule, RuleContext, EnrichedPayload, SoeRuleCondition } from "./types";
|
||||||
|
import { FISCAL_RULES_PADRAO } from "./fiscal-rules";
|
||||||
|
import { BUSINESS_RULES_PADRAO } from "./business-rules";
|
||||||
|
|
||||||
|
const DEFAULT_RULES: SoeRule[] = [...FISCAL_RULES_PADRAO, ...BUSINESS_RULES_PADRAO];
|
||||||
|
|
||||||
|
export class SoeRuleEngine {
|
||||||
|
private static instance: SoeRuleEngine;
|
||||||
|
|
||||||
|
static getInstance(): SoeRuleEngine {
|
||||||
|
if (!SoeRuleEngine.instance) SoeRuleEngine.instance = new SoeRuleEngine();
|
||||||
|
return SoeRuleEngine.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplica todas as regras ativas para um trigger + contexto
|
||||||
|
async apply(
|
||||||
|
trigger: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
context: RuleContext
|
||||||
|
): Promise<EnrichedPayload> {
|
||||||
|
const start = Date.now();
|
||||||
|
const appliedRules: string[] = [];
|
||||||
|
const validationErrors: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Carrega regras do banco (custom do tenant) + defaults hardcoded
|
||||||
|
const rules = await this.getActiveRules(trigger, context);
|
||||||
|
|
||||||
|
// Enriquece payload com contexto (empresa, regime tributário)
|
||||||
|
let enriched = this.enrichWithContext(payload, context);
|
||||||
|
|
||||||
|
// Aplica cada regra em ordem de prioridade
|
||||||
|
for (const rule of rules.sort((a, b) => a.prioridade - b.prioridade)) {
|
||||||
|
if (!this.avaliarCondicao(rule.condicao, enriched, context)) continue;
|
||||||
|
|
||||||
|
const result = this.aplicarAcao(rule.acao, enriched);
|
||||||
|
if (result.validationError) {
|
||||||
|
validationErrors.push(`[${rule.id}] ${result.validationError}`);
|
||||||
|
} else {
|
||||||
|
enriched = result.payload;
|
||||||
|
appliedRules.push(rule.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registra evento no banco (async, não bloqueia a response)
|
||||||
|
this.logEvent(trigger, payload, enriched, appliedRules, context, Date.now() - start).catch(
|
||||||
|
(e) => console.error("[SoeRuleEngine] log error:", e.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { payload: enriched, appliedRules, validationErrors };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[SoeRuleEngine] apply error:", err.message);
|
||||||
|
return { payload, appliedRules, validationErrors };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActiveRules(trigger: string, context: RuleContext): Promise<SoeRule[]> {
|
||||||
|
// Regras do banco (custom do tenant)
|
||||||
|
let dbRules: SoeRule[] = [];
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(soeRegras)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(soeRegras.trigger, trigger),
|
||||||
|
eq(soeRegras.ativo, true),
|
||||||
|
or(
|
||||||
|
isNull(soeRegras.tenantId),
|
||||||
|
eq(soeRegras.tenantId, context.tenantId ?? 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
dbRules = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
dominio: r.dominio as any,
|
||||||
|
trigger: r.trigger,
|
||||||
|
nome: r.nome,
|
||||||
|
condicao: r.condicao as any,
|
||||||
|
acao: r.acao as any,
|
||||||
|
prioridade: r.prioridade ?? 10,
|
||||||
|
ativo: r.ativo ?? true,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Banco pode não ter a tabela ainda (antes de migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regras padrão hardcoded (filtradas pelo trigger)
|
||||||
|
const defaults = DEFAULT_RULES.filter((r) => r.trigger === trigger && r.ativo);
|
||||||
|
|
||||||
|
// Merge: regras do banco sobrescrevem defaults com mesmo ID
|
||||||
|
const merged = new Map<string, SoeRule>();
|
||||||
|
for (const r of defaults) merged.set(r.id, r);
|
||||||
|
for (const r of dbRules) merged.set(r.id, r);
|
||||||
|
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private enrichWithContext(
|
||||||
|
payload: Record<string, any>,
|
||||||
|
context: RuleContext
|
||||||
|
): Record<string, any> {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
_contexto: {
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
empresaId: context.empresaId,
|
||||||
|
motor: context.motor,
|
||||||
|
regimeTributario: context.empresa?.regimeTributario,
|
||||||
|
uf: context.empresa?.uf,
|
||||||
|
},
|
||||||
|
// Atalhos para condições
|
||||||
|
regimeTributario: context.empresa?.regimeTributario ?? payload.regimeTributario,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private avaliarCondicao(
|
||||||
|
condicao: SoeRuleCondition,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
_context: RuleContext
|
||||||
|
): boolean {
|
||||||
|
if (!condicao || Object.keys(condicao).length === 0) return true;
|
||||||
|
|
||||||
|
for (const [key, expected] of Object.entries(condicao)) {
|
||||||
|
// Condições internas (_xxx)
|
||||||
|
if (key === "_cfopNaoDefinido") {
|
||||||
|
if (expected && payload.cfop) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "_operacao") {
|
||||||
|
// Infere operação pelo tipo (simplificado)
|
||||||
|
if (expected === "venda" && payload._tipo !== "venda") return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "_interestadual") {
|
||||||
|
const interestadual = payload.ufDestinatario && payload.ufEmitente &&
|
||||||
|
payload.ufDestinatario !== payload.ufEmitente;
|
||||||
|
if (expected !== interestadual) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = this.getNestedValue(payload, key);
|
||||||
|
|
||||||
|
if (Array.isArray(expected)) {
|
||||||
|
if (!expected.includes(actual)) return false;
|
||||||
|
} else if (typeof expected === "object" && expected !== null) {
|
||||||
|
if ("_lt" in expected && !(actual < expected._lt)) return false;
|
||||||
|
if ("_gt" in expected && !(actual > expected._gt)) return false;
|
||||||
|
if ("_eq" in expected && actual !== expected._eq) return false;
|
||||||
|
} else {
|
||||||
|
if (actual !== expected) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private aplicarAcao(
|
||||||
|
acao: any,
|
||||||
|
payload: Record<string, any>
|
||||||
|
): { payload: Record<string, any>; validationError?: string } {
|
||||||
|
let result = { ...payload };
|
||||||
|
|
||||||
|
if (acao.set) {
|
||||||
|
for (const [path, value] of Object.entries(acao.set)) {
|
||||||
|
result = this.setNestedValue(result, path, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acao.validar) {
|
||||||
|
const { campo, obrigatorio, formato } = acao.validar;
|
||||||
|
const valor = this.getNestedValue(payload, campo);
|
||||||
|
if (obrigatorio && !valor) {
|
||||||
|
return { payload, validationError: `Campo obrigatório ausente: ${campo}` };
|
||||||
|
}
|
||||||
|
if (formato && valor) {
|
||||||
|
const re = typeof formato === "string" ? new RegExp(formato) : formato;
|
||||||
|
if (!re.test(String(valor))) {
|
||||||
|
return { payload, validationError: `Campo ${campo} com formato inválido: ${valor}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNestedValue(obj: Record<string, any>, path: string): any {
|
||||||
|
return path.split(".").reduce((curr, key) => curr?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNestedValue(
|
||||||
|
obj: Record<string, any>,
|
||||||
|
path: string,
|
||||||
|
value: any
|
||||||
|
): Record<string, any> {
|
||||||
|
const result = { ...obj };
|
||||||
|
const keys = path.split(".");
|
||||||
|
let curr: any = result;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
if (!curr[keys[i]]) curr[keys[i]] = {};
|
||||||
|
curr = curr[keys[i]];
|
||||||
|
}
|
||||||
|
curr[keys[keys.length - 1]] = value;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logEvent(
|
||||||
|
trigger: string,
|
||||||
|
payloadEntrada: any,
|
||||||
|
payloadSaida: any,
|
||||||
|
regraIds: string[],
|
||||||
|
context: RuleContext,
|
||||||
|
duracaoMs: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await db.insert(soeEventos).values({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
empresaId: context.empresaId,
|
||||||
|
evento: trigger,
|
||||||
|
motorOrigem: context.motor,
|
||||||
|
regraIds,
|
||||||
|
payloadEntrada,
|
||||||
|
payloadSaida,
|
||||||
|
status: "ok",
|
||||||
|
duracaoMs,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silencia erro de log — não deve quebrar o fluxo principal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton exportado
|
||||||
|
export const soeRuleEngine = SoeRuleEngine.getInstance();
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
// SOE Rule Engine — Types
|
||||||
|
|
||||||
|
export interface SoeRuleCondition {
|
||||||
|
// Comparações simples: { campo: valor } ou { campo: { op: valor } }
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoeRuleAction {
|
||||||
|
// set: sobrescreve campos no payload
|
||||||
|
set?: Record<string, any>;
|
||||||
|
// validar: valida campos obrigatórios/formato
|
||||||
|
validar?: { campo: string; obrigatorio?: boolean; formato?: RegExp | string };
|
||||||
|
// addItem: adiciona item à lista
|
||||||
|
addItem?: { campo: string; valor: any };
|
||||||
|
// calcular: define um campo como resultado de expressão
|
||||||
|
calcular?: { campo: string; expressao: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoeRule {
|
||||||
|
id: string;
|
||||||
|
dominio: "fiscal" | "business" | "accounting" | "financial";
|
||||||
|
trigger: string;
|
||||||
|
nome: string;
|
||||||
|
condicao: SoeRuleCondition;
|
||||||
|
acao: SoeRuleAction;
|
||||||
|
prioridade: number;
|
||||||
|
ativo: boolean;
|
||||||
|
origemPadrao?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleContext {
|
||||||
|
tenantId?: number;
|
||||||
|
empresaId?: number;
|
||||||
|
userId?: string;
|
||||||
|
motor?: "plus" | "erpnext" | "local";
|
||||||
|
empresa?: {
|
||||||
|
regimeTributario?: "simples_nacional" | "lucro_presumido" | "lucro_real";
|
||||||
|
uf?: string;
|
||||||
|
cnpj?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrichedPayload {
|
||||||
|
payload: Record<string, any>;
|
||||||
|
appliedRules: string[];
|
||||||
|
validationErrors: string[];
|
||||||
|
}
|
||||||
|
|
@ -7362,3 +7362,70 @@ export const commEvents = pgTable("comm_events", {
|
||||||
processedByAgents: boolean("processed_by_agents").default(false), // consumed by agents?
|
processedByAgents: boolean("processed_by_agents").default(false), // consumed by agents?
|
||||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// SOE — Sistema Operacional Empresarial (Rule Engine + Event Bus)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Regras do SOE — configuráveis por tenant/empresa
|
||||||
|
export const soeRegras = pgTable("soe_regras", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
empresaId: integer("empresa_id"),
|
||||||
|
dominio: varchar("dominio", { length: 50 }).notNull(), // 'fiscal' | 'business' | 'accounting' | 'financial'
|
||||||
|
trigger: varchar("trigger", { length: 100 }).notNull(), // 'pre_sales_order' | 'pre_nfe_emissao' | etc.
|
||||||
|
nome: varchar("nome", { length: 200 }).notNull(),
|
||||||
|
condicao: jsonb("condicao").notNull().$type<Record<string, any>>(),
|
||||||
|
acao: jsonb("acao").notNull().$type<Record<string, any>>(),
|
||||||
|
prioridade: integer("prioridade").default(10),
|
||||||
|
ativo: boolean("ativo").default(true),
|
||||||
|
origemPadrao: boolean("origem_padrao").default(false), // true = regra padrão do sistema
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de todos os eventos SOE (audit trail completo)
|
||||||
|
export const soeEventos = pgTable("soe_eventos", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: integer("tenant_id").references(() => tenants.id),
|
||||||
|
empresaId: integer("empresa_id"),
|
||||||
|
evento: varchar("evento", { length: 100 }).notNull(), // 'nfe_emitida' | 'venda_confirmada' | etc.
|
||||||
|
motorOrigem: varchar("motor_origem", { length: 50 }), // 'plus' | 'erpnext' | 'local'
|
||||||
|
regraIds: jsonb("regra_ids").$type<string[]>(), // IDs das regras aplicadas
|
||||||
|
payloadEntrada: jsonb("payload_entrada").$type<Record<string, any>>(),
|
||||||
|
payloadSaida: jsonb("payload_saida").$type<Record<string, any>>(),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("ok"), // 'ok' | 'error' | 'skipped'
|
||||||
|
duracaoMs: integer("duracao_ms"),
|
||||||
|
erro: text("erro"),
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lançamentos contábeis automáticos gerados pelo SOE Event Bus
|
||||||
|
export const soeLancamentos = pgTable("soe_lancamentos", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: integer("tenant_id").references(() => tenants.id),
|
||||||
|
empresaId: integer("empresa_id").notNull(),
|
||||||
|
data: date("data").notNull(),
|
||||||
|
contaDebito: varchar("conta_debito", { length: 30 }).notNull(),
|
||||||
|
contaCredito: varchar("conta_credito", { length: 30 }).notNull(),
|
||||||
|
valor: numeric("valor", { precision: 15, scale: 2 }).notNull(),
|
||||||
|
historico: text("historico").notNull(),
|
||||||
|
origemEvento: varchar("origem_evento", { length: 100 }), // 'nfe_emitida' | 'pagamento_realizado' | etc.
|
||||||
|
origemEventoId: uuid("origem_evento_id").references(() => soeEventos.id),
|
||||||
|
periodo: varchar("periodo", { length: 7 }), // '2026-03'
|
||||||
|
enviado8003: boolean("enviado_8003").default(false), // sincronizado com Motor Contábil Python
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert schemas
|
||||||
|
export const insertSoeRegraSchema = createInsertSchema(soeRegras);
|
||||||
|
export const insertSoeEventoSchema = createInsertSchema(soeEventos);
|
||||||
|
export const insertSoeLancamentoSchema = createInsertSchema(soeLancamentos);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type SoeRegra = typeof soeRegras.$inferSelect;
|
||||||
|
export type InsertSoeRegra = z.infer<typeof insertSoeRegraSchema>;
|
||||||
|
export type SoeEvento = typeof soeEventos.$inferSelect;
|
||||||
|
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
|
||||||
|
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
|
||||||
|
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue