diff --git a/.env.example b/.env.example index 656b051..bcc19a1 100644 --- a/.env.example +++ b/.env.example @@ -68,9 +68,15 @@ PLUS_URL=http://localhost:8080 PLUS_PORT=8080 PLUS_API_TOKEN= -# ── Superset (BI avançado) ──────────────────────────────────────────────────── -SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura +# ── Superset (BI avançado — Arcádia Insights) ──────────────────────────────── +SUPERSET_HOST=superset 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_URL=redis://localhost:6379 diff --git a/client/src/App.tsx b/client/src/App.tsx index ea75205..f4a16aa 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ -import { Switch, Route } from "wouter"; -import { lazy, Suspense } from "react"; +import { Switch, Route, useLocation } from "wouter"; +import { lazy, Suspense, useEffect } from "react"; import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; @@ -121,7 +121,7 @@ function Router() { - + { const [, nav] = useLocation(); useEffect(() => nav("/soe"), []); return null; }} /> diff --git a/client/src/components/SupersetDashboard.tsx b/client/src/components/SupersetDashboard.tsx new file mode 100644 index 0000000..c465bce --- /dev/null +++ b/client/src/components/SupersetDashboard.tsx @@ -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: + * + * + */ +export function SupersetDashboard({ + dashboardId, + height = "calc(100vh - 320px)", + className = "", + showOpenLink = true, +}: SupersetDashboardProps) { + const containerRef = useRef(null); + const sdkRef = useRef(null); + const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading"); + const [errorMsg, setErrorMsg] = useState(""); + + const fetchGuestToken = useCallback(async (): Promise => { + 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 ( + + + + ); + } + + if (status === "error") { + return ( + + + + Erro ao carregar dashboard + {errorMsg} + + + Tentar novamente + + + ); + } + + return ( + + {status === "loading" && ( + + + + Carregando Arcádia Insights... + + + )} + + {showOpenLink && status === "ready" && ( + + Abrir completo + + )} + + + + ); +} diff --git a/client/src/pages/BiWorkspace.tsx b/client/src/pages/BiWorkspace.tsx index af7341d..2a32d3b 100644 --- a/client/src/pages/BiWorkspace.tsx +++ b/client/src/pages/BiWorkspace.tsx @@ -1,5 +1,5 @@ import { BrowserFrame } from "@/components/Browser/BrowserFrame"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -49,6 +49,7 @@ import { MessageSquare, } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; +import { SupersetDashboard } from "@/components/SupersetDashboard"; import { Dialog, DialogContent, @@ -81,6 +82,73 @@ import { Cell, } from "recharts"; +// ── Componente da aba Insights (Apache Superset) ────────────────────────────── +function SupersetAdvancedTab() { + const [selectedDashboard, setSelectedDashboard] = useState(""); + const [dashboards, setDashboards] = useState>([]); + + 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 ( + + + + Arcádia Insights — Apache Superset + SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos + + + {dashboards.length > 0 && ( + 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 => ( + {d.title} + ))} + + )} + + Abrir completo + + + + + {selectedDashboard ? ( + + ) : ( + + + + )} + + ); +} + interface BiStats { dataSources: number; datasets: number; @@ -2890,7 +2958,7 @@ export default function BiWorkspace() { Staging - MetaSet + Insights @@ -2916,32 +2984,7 @@ export default function BiWorkspace() { - - - - MetaSet - Motor de BI - Acesso completo para criação manual de consultas SQL, gráficos e dashboards - - - Abrir em Nova Aba - - - - - - + diff --git a/docker-compose.yml b/docker-compose.yml index b2d8866..e22222f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,12 +167,32 @@ services: restart: unless-stopped environment: 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: - "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: db: 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] # ─────────────── PERFIL: ai (Soberania de IA) ──────────────────────────────── @@ -231,3 +251,8 @@ volumes: pgdata: ollama_models: open_webui_data: + superset_home: + +networks: + arcadia: + driver: bridge diff --git a/docker/superset/init.sh b/docker/superset/init.sh new file mode 100755 index 0000000..4a500c6 --- /dev/null +++ b/docker/superset/init.sh @@ -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 - < { @@ -850,12 +852,24 @@ export function registerErpRoutes(app: Express): void { return res.status(401).json({ error: "Not authenticated" }); } 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({ tenantId, orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes }).returning(); - res.status(201).json(order); + res.status(201).json({ ...order, _rulesApplied: enriched._rulesApplied }); } catch (error) { console.error("Error creating sales order:", error); res.status(500).json({ error: "Failed to create sales order" }); @@ -876,7 +890,16 @@ export function registerErpRoutes(app: Express): void { if (!updated) { 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) { console.error("Error confirming sales order:", error); 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 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) .set({ status: "invoiced" }) .where(and(eq(salesOrders.id, id), eq(salesOrders.tenantId, tenantId))) @@ -897,7 +932,26 @@ export function registerErpRoutes(app: Express): void { if (!updated) { 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) { console.error("Error generating NF-e:", error); res.status(500).json({ error: "Failed to generate NF-e" }); diff --git a/server/routes.ts b/server/routes.ts index 1b0fe2a..2b52597 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -47,9 +47,9 @@ import lmsRoutes from "./lms/routes"; import xosRoutes from "./xos/routes"; import governanceRoutes from "./governance/routes"; 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 { registerMetaSetRoutes } from "./metaset/routes"; import plusSsoRoutes from "./plus/sso"; import migrationRoutes from "./migration/routes"; import { githubRoutes } from "./integrations/github"; @@ -69,9 +69,10 @@ export async function registerRoutes( // Arcádia Plus - Proxy registered AFTER session but BEFORE auth-protected routes await setupPlusProxy(app); - - // Metabase BI - Proxy to Metabase instance - setupMetabaseProxy(app); + + // Apache Superset - Arcádia Insights (substitui Metabase) + setupSupersetProxy(app); + registerSupersetRoutes(app); registerChatRoutes(app); registerSoeRoutes(app); @@ -86,7 +87,6 @@ export async function registerRoutes( registerBiRoutes(app); app.use("/api/graph", graphRoutes); registerBiEngineRoutes(app); - registerMetaSetRoutes(app); registerCommEngineRoutes(app); registerLearningRoutes(app); app.use("/api/compass", compassRoutes); diff --git a/server/soe/event-bus.ts b/server/soe/event-bus.ts new file mode 100644 index 0000000..bce482f --- /dev/null +++ b/server/soe/event-bus.ts @@ -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; +} + +type EventHandler = (payload: SoeEventPayload) => Promise; + +// Mapa de handlers por evento +const HANDLERS: Record = { + 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 { + 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(); diff --git a/server/soe/handlers/contabil-handler.ts b/server/soe/handlers/contabil-handler.ts new file mode 100644 index 0000000..10cc886 --- /dev/null +++ b/server/soe/handlers/contabil-handler.ts @@ -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); + } +} diff --git a/server/soe/handlers/financial-handler.ts b/server/soe/handlers/financial-handler.ts new file mode 100644 index 0000000..f2eeb35 --- /dev/null +++ b/server/soe/handlers/financial-handler.ts @@ -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) + }, +}; diff --git a/server/soe/rule-engine/business-rules.ts b/server/soe/rule-engine/business-rules.ts new file mode 100644 index 0000000..b3f1c34 --- /dev/null +++ b/server/soe/rule-engine/business-rules.ts @@ -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, + }, +]; diff --git a/server/soe/rule-engine/fiscal-rules.ts b/server/soe/rule-engine/fiscal-rules.ts new file mode 100644 index 0000000..fdf2524 --- /dev/null +++ b/server/soe/rule-engine/fiscal-rules.ts @@ -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, + }, +]; diff --git a/server/soe/rule-engine/index.ts b/server/soe/rule-engine/index.ts new file mode 100644 index 0000000..4f92e30 --- /dev/null +++ b/server/soe/rule-engine/index.ts @@ -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, + context: RuleContext + ): Promise { + 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 { + // 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(); + 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, + context: RuleContext + ): Record { + 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, + _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 + ): { payload: Record; 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, path: string): any { + return path.split(".").reduce((curr, key) => curr?.[key], obj); + } + + private setNestedValue( + obj: Record, + path: string, + value: any + ): Record { + 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(); diff --git a/server/soe/rule-engine/types.ts b/server/soe/rule-engine/types.ts new file mode 100644 index 0000000..c38d030 --- /dev/null +++ b/server/soe/rule-engine/types.ts @@ -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; + // 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; + appliedRules: string[]; + validationErrors: string[]; +} diff --git a/server/superset/proxy.ts b/server/superset/proxy.ts new file mode 100644 index 0000000..c2d33ce --- /dev/null +++ b/server/superset/proxy.ts @@ -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}`); +} diff --git a/server/superset/routes.ts b/server/superset/routes.ts new file mode 100644 index 0000000..729b76e --- /dev/null +++ b/server/superset/routes.ts @@ -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 { + 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"); + }); +} diff --git a/shared/schema.ts b/shared/schema.ts index fbccf1c..52179e2 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -7315,3 +7315,70 @@ export const commEvents = pgTable("comm_events", { processedByAgents: boolean("processed_by_agents").default(false), // consumed by agents? 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>(), + acao: jsonb("acao").notNull().$type>(), + 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(), // IDs das regras aplicadas + payloadEntrada: jsonb("payload_entrada").$type>(), + payloadSaida: jsonb("payload_saida").$type>(), + 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; +export type SoeEvento = typeof soeEventos.$inferSelect; +export type InsertSoeEvento = z.infer; +export type SoeLancamento = typeof soeLancamentos.$inferSelect; +export type InsertSoeLancamento = z.infer;
Erro ao carregar dashboard
{errorMsg}
Carregando Arcádia Insights...
SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos
Acesso completo para criação manual de consultas SQL, gráficos e dashboards