feat: implementa SOE Rule Engine + Apache Superset (Arcádia Insights)

## SOE — Sistema Operacional Empresarial
- Adiciona 3 tabelas ao schema: soe_regras, soe_eventos, soe_lancamentos
- Cria server/soe/rule-engine/ com motor de regras configuráveis
  - Regras fiscais padrão: CFOP, tributação por regime, validações NCM
  - Regras de negócio: aprovação auto, reserva estoque, alertas
- Cria server/soe/event-bus.ts — dispara automações pós-execução
- Cria handlers: contabil-handler.ts (lançamentos automáticos) e
  financial-handler.ts (contas a P/R automáticas)
- Plugar Rule Engine em 3 rotas críticas do server/erp/routes.ts:
  POST /sales-orders, POST /confirm, POST /generate-nfe
- Redireciona /plus → /soe no frontend (Plus fica invisível)

## Apache Superset (Arcádia Insights)
- Expande docker-compose.yml: volumes, init script, config file
- Cria docker/superset/superset_config.py (embedding + CORS + feature flags)
- Cria docker/superset/init.sh (migração DB + admin + registro banco Arcádia)
- Substitui server/metabase/proxy.ts por server/superset/proxy.ts
- Substitui server/metaset/routes.ts por server/superset/routes.ts
  com Guest Token JWT para embedding nativo em qualquer tela
- Instala @superset-ui/embedded-sdk
- Cria client/src/components/SupersetDashboard.tsx — componente
  reutilizável via Guest Token, fallback para iframe
- Atualiza BiWorkspace.tsx: tab MetaSet → Insights (com seletor de dashboards)
- Atualiza .env.example com variáveis Superset completas

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
Claude 2026-03-16 18:45:22 +00:00
parent 5a39fb66c4
commit f0c3ce483e
No known key found for this signature in database
20 changed files with 1622 additions and 44 deletions

View File

@ -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

View File

@ -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";
@ -121,7 +121,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} />

View File

@ -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>
);
}

View File

@ -1,5 +1,5 @@
import { BrowserFrame } from "@/components/Browser/BrowserFrame"; import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -49,6 +49,7 @@ import {
MessageSquare, MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { SupersetDashboard } from "@/components/SupersetDashboard";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -81,6 +82,73 @@ import {
Cell, Cell,
} from "recharts"; } from "recharts";
// ── Componente da aba Insights (Apache Superset) ──────────────────────────────
function SupersetAdvancedTab() {
const [selectedDashboard, setSelectedDashboard] = useState<string>("");
const [dashboards, setDashboards] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
fetch("/api/superset/dashboards", { credentials: "include" })
.then(r => r.json())
.then(data => {
if (Array.isArray(data)) {
setDashboards(data.map((d: any) => ({ id: String(d.id), title: d.dashboard_title || d.title })));
if (data.length > 0) setSelectedDashboard(String(data[0].id));
}
})
.catch(() => setDashboards([]));
}, []);
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-semibold text-[#1f334d]">Arcádia Insights Apache Superset</h2>
<p className="text-sm text-gray-500">SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos</p>
</div>
<div className="flex items-center gap-2">
{dashboards.length > 0 && (
<select
value={selectedDashboard}
onChange={e => setSelectedDashboard(e.target.value)}
className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-[#1f334d] focus:outline-none focus:ring-2 focus:ring-[#c89b3c]"
>
{dashboards.map(d => (
<option key={d.id} value={d.id}>{d.title}</option>
))}
</select>
)}
<a
href="/superset"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
>
<ArrowRight className="w-4 h-4" /> Abrir completo
</a>
</div>
</div>
{selectedDashboard ? (
<SupersetDashboard
dashboardId={selectedDashboard}
height="calc(100vh - 320px)"
showOpenLink={false}
/>
) : (
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: "calc(100vh - 320px)" }}>
<iframe
src="/superset"
className="w-full h-full border-0"
title="Arcádia Insights — Apache Superset"
allow="fullscreen"
/>
</div>
)}
</div>
);
}
interface BiStats { interface BiStats {
dataSources: number; dataSources: number;
datasets: number; datasets: number;
@ -2890,7 +2958,7 @@ export default function BiWorkspace() {
<Layers className="w-4 h-4 mr-2" /> Staging <Layers className="w-4 h-4 mr-2" /> Staging
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70"> <TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
<Settings className="w-4 h-4 mr-2" /> MetaSet <Settings className="w-4 h-4 mr-2" /> Insights
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -2916,32 +2984,7 @@ export default function BiWorkspace() {
<StagingTab /> <StagingTab />
</TabsContent> </TabsContent>
<TabsContent value="advanced" className="mt-0"> <TabsContent value="advanced" className="mt-0">
<div className="space-y-4"> <SupersetAdvancedTab />
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[#1f334d]">MetaSet - Motor de BI</h2>
<p className="text-sm text-gray-500">Acesso completo para criação manual de consultas SQL, gráficos e dashboards</p>
</div>
<a
href="/api/bi/metaset/autologin"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
data-testid="link-open-metaset-external"
>
<ArrowRight className="w-4 h-4" /> Abrir em Nova Aba
</a>
</div>
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: 'calc(100vh - 320px)' }}>
<iframe
src="/api/bi/metaset/autologin"
className="w-full h-full border-0"
title="MetaSet - Arcádia Insights"
data-testid="iframe-metaset-advanced"
allow="fullscreen"
/>
</div>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@ -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

82
docker/superset/init.sh Executable file
View File

@ -0,0 +1,82 @@
#!/bin/bash
# Arcádia Suite — Apache Superset Init Script
# Executado ao iniciar o container
set -e
SUPERSET_ADMIN_USER="${SUPERSET_ADMIN_USER:-admin}"
SUPERSET_ADMIN_EMAIL="${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}"
SUPERSET_ADMIN_PASSWORD="${SUPERSET_ADMIN_PASSWORD:-arcadia2026}"
ARCADIA_DB_URL="${ARCADIA_DATABASE_URL:-postgresql://arcadia:arcadia123@db:5432/arcadia}"
echo "[Superset Init] Aguardando PostgreSQL..."
until python -c "
import psycopg2, os, sys
try:
url = os.environ.get('DATABASE_URL', 'postgresql://arcadia:arcadia123@db:5432/arcadia_superset')
psycopg2.connect(url)
sys.exit(0)
except: sys.exit(1)
" 2>/dev/null; do
sleep 2
done
echo "[Superset Init] PostgreSQL disponível!"
echo "[Superset Init] Rodando migrações do banco..."
superset db upgrade
echo "[Superset Init] Criando admin..."
superset fab create-admin \
--username "${SUPERSET_ADMIN_USER}" \
--firstname "Arcádia" \
--lastname "Admin" \
--email "${SUPERSET_ADMIN_EMAIL}" \
--password "${SUPERSET_ADMIN_PASSWORD}" 2>/dev/null || echo "[Superset Init] Admin já existe."
echo "[Superset Init] Inicializando roles e permissões..."
superset init
echo "[Superset Init] Registrando banco Arcádia Suite..."
python - <<PYEOF
import sys
try:
from superset import create_app
from superset.extensions import db as sdb
from superset.models.core import Database as SupersetDB
app = create_app()
with app.app_context():
existing = sdb.session.query(SupersetDB).filter_by(database_name="Arcádia Suite").first()
if not existing:
new_db = SupersetDB(
database_name="Arcádia Suite",
sqlalchemy_uri="${ARCADIA_DB_URL}",
expose_in_sqllab=True,
allow_run_async=True,
allow_csv_upload=False,
allow_ctas=False,
allow_cvas=False,
allow_dml=False,
extra='{"metadata_params":{},"engine_params":{},"metadata_cache_timeout":{},"schemas_allowed_for_file_upload":[]}',
)
sdb.session.add(new_db)
sdb.session.commit()
print("[Superset Init] Banco 'Arcádia Suite' registrado com sucesso!")
else:
print("[Superset Init] Banco 'Arcádia Suite' já está registrado.")
except Exception as e:
print(f"[Superset Init] Aviso ao registrar banco: {e}")
sys.exit(0)
PYEOF
echo "[Superset Init] Iniciando servidor Superset..."
exec gunicorn \
--bind "0.0.0.0:${SUPERSET_WEBSERVER_PORT:-8088}" \
--access-logfile - \
--error-logfile - \
--workers "${SUPERSET_WORKERS:-4}" \
--worker-class gthread \
--threads 2 \
--timeout 120 \
--limit-request-line 0 \
--limit-request-field_size 0 \
"superset.app:create_app()"

View File

@ -0,0 +1,62 @@
"""
Arcádia Suite Apache Superset Configuration
Habilita embedding nativo via Guest Token JWT
"""
import os
# ── Segurança ─────────────────────────────────────────────────────────────────
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY", "change-in-production-use-openssl-rand-hex-32")
# ── Banco de metadados do Superset ────────────────────────────────────────────
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
"postgresql://arcadia:arcadia123@db:5432/arcadia_superset"
)
# ── CORS — permite o gateway Arcádia (:5000) chamar a API ────────────────────
ENABLE_CORS = True
CORS_OPTIONS = {
"supports_credentials": True,
"allow_headers": ["*"],
"resources": {r"/api/*": {"origins": "*"}},
}
# ── Feature Flags ─────────────────────────────────────────────────────────────
FEATURE_FLAGS = {
"EMBEDDED_SUPERSET": True, # Embedding via Guest Token
"ENABLE_TEMPLATE_PROCESSING": True, # Jinja templates em queries
"ALERT_REPORTS": True, # Alertas automáticos
"DRILL_TO_DETAIL": True, # Drill-down em charts
"DRILL_BY": True, # Drill-by em charts
"DASHBOARD_NATIVE_FILTERS": True, # Filtros nativos em dashboards
"DASHBOARD_CROSS_FILTERS": True, # Cross-filter entre charts
"ENABLE_JAVASCRIPT_CONTROLS": False, # Desabilitado por segurança
}
# ── Cache ─────────────────────────────────────────────────────────────────────
CACHE_CONFIG = {
"CACHE_TYPE": "SimpleCache",
"CACHE_DEFAULT_TIMEOUT": 300, # 5 minutos
}
# ── Timeout de queries ────────────────────────────────────────────────────────
SUPERSET_WEBSERVER_TIMEOUT = 300
SQLLAB_TIMEOUT = 300
SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
# ── Branding Arcádia ──────────────────────────────────────────────────────────
APP_NAME = "Arcádia Insights"
LOGO_TARGET_PATH = "/"
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
# ── Segurança de sessão ───────────────────────────────────────────────────────
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = False # True em prod com HTTPS
WTF_CSRF_ENABLED = True
WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log"]
# ── Guest Token (para embedding) ──────────────────────────────────────────────
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutos — frontend renova automaticamente
GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_ALGO = "HS256"
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"

View File

@ -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",

View File

@ -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" });

View File

@ -47,9 +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 { 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";
@ -70,8 +70,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);
// Metabase BI - Proxy to Metabase instance // Apache Superset - Arcádia Insights (substitui Metabase)
setupMetabaseProxy(app); setupSupersetProxy(app);
registerSupersetRoutes(app);
registerChatRoutes(app); registerChatRoutes(app);
registerSoeRoutes(app); registerSoeRoutes(app);
@ -86,7 +87,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);
registerCommEngineRoutes(app); registerCommEngineRoutes(app);
registerLearningRoutes(app); registerLearningRoutes(app);
app.use("/api/compass", compassRoutes); app.use("/api/compass", compassRoutes);

112
server/soe/event-bus.ts Normal file
View File

@ -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();

View File

@ -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);
}
}

View File

@ -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)
},
};

View File

@ -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,
},
];

View File

@ -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,
},
];

View File

@ -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();

View File

@ -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[];
}

39
server/superset/proxy.ts Normal file
View File

@ -0,0 +1,39 @@
import { Express, Response } from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
const SUPERSET_TIMEOUT = 60000;
export function setupSupersetProxy(app: Express): void {
const target = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
const supersetProxy = createProxyMiddleware({
target,
changeOrigin: true,
timeout: SUPERSET_TIMEOUT,
proxyTimeout: SUPERSET_TIMEOUT,
pathRewrite: { "^/superset": "" },
on: {
error: (err, _req, res) => {
console.error("[Superset Proxy] Error:", err.message);
if (res && typeof (res as Response).status === "function") {
(res as Response).status(502).json({
error: "Superset indisponível",
message: "O Arcádia Insights está iniciando. Tente novamente em alguns segundos.",
target,
});
}
},
proxyRes: (proxyRes) => {
const location = proxyRes.headers["location"];
if (location && typeof location === "string" && location.startsWith("/")) {
proxyRes.headers["location"] = `/superset${location}`;
}
},
},
});
app.use("/superset", supersetProxy);
console.log(`[Superset Proxy] Configurado -> /superset/* => ${target}`);
}

108
server/superset/routes.ts Normal file
View File

@ -0,0 +1,108 @@
import type { Express, Request, Response } from "express";
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
const ADMIN_USER = process.env.SUPERSET_ADMIN_USER || "admin";
const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
// Cache do service token (válido por 50 min)
let cachedToken: string | null = null;
let tokenExpiry = 0;
async function getServiceToken(): Promise<string> {
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS, provider: "db", refresh: true }),
});
if (!resp.ok) throw new Error(`Falha ao autenticar no Superset (${resp.status})`);
const data = await resp.json();
cachedToken = data.access_token;
tokenExpiry = Date.now() + 50 * 60 * 1000;
return cachedToken!;
}
export function registerSupersetRoutes(app: Express): void {
// Gera Guest Token para embedding em qualquer tela
app.post("/api/superset/guest-token", async (req: Request, res: Response) => {
try {
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
const { dashboardId } = req.body;
if (!dashboardId) return res.status(400).json({ error: "dashboardId é obrigatório" });
const token = await getServiceToken();
const user = req.user as any;
const guestResp = await fetch(`${SUPERSET_URL}/api/v1/security/guest_token/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
user: {
username: `arcadia_${user?.id || "guest"}`,
first_name: user?.name?.split(" ")[0] || "Arcádia",
last_name: user?.name?.split(" ").slice(1).join(" ") || "User",
},
resources: [{ type: "dashboard", id: dashboardId }],
rls: [],
}),
});
if (!guestResp.ok) {
const errText = await guestResp.text();
return res.status(502).json({ error: "Falha ao gerar guest token", detail: errText });
}
const { token: guestToken } = await guestResp.json();
res.json({ token: guestToken, supersetUrl: "/superset" });
} catch (err: any) {
console.error("[Superset] guest-token error:", err.message);
res.status(502).json({ error: err.message });
}
});
// Lista dashboards disponíveis (para seletor na UI)
app.get("/api/superset/dashboards", async (req: Request, res: Response) => {
try {
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
const token = await getServiceToken();
const resp = await fetch(
`${SUPERSET_URL}/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)`,
{ headers: { "Authorization": `Bearer ${token}` } }
);
if (!resp.ok) return res.status(502).json({ error: "Superset indisponível", dashboards: [] });
const data = await resp.json();
res.json(data.result || []);
} catch (err: any) {
console.error("[Superset] dashboards error:", err.message);
res.status(502).json({ error: err.message, dashboards: [] });
}
});
// Health check
app.get("/api/superset/health", async (_req: Request, res: Response) => {
try {
const resp = await fetch(`${SUPERSET_URL}/health`, { signal: AbortSignal.timeout(5000) });
const text = await resp.text();
res.json({ online: resp.ok && text === "OK", url: SUPERSET_URL, status: text });
} catch {
res.json({ online: false, url: SUPERSET_URL });
}
});
// Autologin (compatibilidade — redireciona para /superset com sessão)
app.get("/api/bi/superset/autologin", async (req: Request, res: Response) => {
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
res.redirect("/superset/login");
});
}

View File

@ -7315,3 +7315,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>;