Merge pull request #6 from jonaspachecoometas/claude/analyze-project-0mXjP
Claude/analyze project 0m xj p
This commit is contained in:
commit
bcc0e21fe0
|
|
@ -0,0 +1,787 @@
|
|||
# PLANO BI — Substituição Metabase → Apache Superset
|
||||
## Análise Real + Plano de Migração
|
||||
### Versão 1.0 — Março 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. DIAGNÓSTICO — ESTADO ATUAL
|
||||
|
||||
### 1.1 O que foi descoberto no código
|
||||
|
||||
O documento do Replit referenciava **Metabase** (Java, :8088) como o pilar visual do BI. Ao analisar o código real, a situação é diferente:
|
||||
|
||||
| Componente | Nome no doc | Nome real no código | Status |
|
||||
|---|---|---|---|
|
||||
| Plataforma BI Visual | "Metabase" | **"MetaSet"** (alias) | Metabase por baixo |
|
||||
| Proxy | `/metabase/*` | `server/metabase/proxy.ts` | Funcional |
|
||||
| Auth | Metabase session | `server/metaset/routes.ts` (autologin) | Funcional |
|
||||
| Client | MetabaseClient | `server/metaset/client.ts` | Funcional |
|
||||
| UI | Advanced tab iframe | `/api/bi/metaset/autologin` → `/metabase/` | Funcional |
|
||||
| **Superset** | Não mencionado | **`docker-compose.yml` linha 165** | **JÁ CONFIGURADO** |
|
||||
|
||||
**Descoberta crítica:** O Apache Superset **já está no docker-compose.yml**:
|
||||
```yaml
|
||||
superset:
|
||||
image: apache/superset:4.1.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia_superset
|
||||
ports:
|
||||
- "8088:8088"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: [bi]
|
||||
```
|
||||
|
||||
O Superset está configurado, mas **não integrado ao gateway Node.js**. O que falta é a ponte.
|
||||
|
||||
### 1.2 Arquivos que precisam mudar vs o que fica igual
|
||||
|
||||
**Mudam:**
|
||||
```
|
||||
server/metabase/proxy.ts → substituir por server/superset/proxy.ts
|
||||
server/metaset/routes.ts → substituir por server/superset/routes.ts
|
||||
server/metaset/client.ts → substituir por server/superset/client.ts
|
||||
client/src/pages/BiWorkspace.tsx (Advanced tab apenas)
|
||||
docker-compose.yml (Superset config + init)
|
||||
docker-compose.prod.yml (adicionar Superset)
|
||||
.env.example (novas variáveis)
|
||||
```
|
||||
|
||||
**NÃO mudam (ficam exatamente iguais):**
|
||||
```
|
||||
server/python/bi_engine.py ← Motor Python :8004 continua intacto
|
||||
server/bi/routes.ts ← CRUD de datasets/charts/dashboards
|
||||
server/bi/upload.ts ← ETL pipeline
|
||||
server/bi/staging.ts ← Staging
|
||||
server/bi/engine-proxy.ts ← Proxy para BI Engine Python
|
||||
client/src/pages/BiWorkspace.tsx (7 de 8 tabs ficam iguais)
|
||||
Todas as tabelas do banco (bi_datasets, bi_charts, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. POR QUE SUPERSET É MELHOR PARA O CASO
|
||||
|
||||
### 2.1 Metabase vs Superset
|
||||
|
||||
| Critério | Metabase | Apache Superset |
|
||||
|---|---|---|
|
||||
| Runtime | Java (JVM, 500MB+ RAM) | Python/Flask (mais leve) |
|
||||
| Embedding | Iframe com cookie de sessão | **Guest Token JWT** (seguro, sem credenciais) |
|
||||
| "Qualquer tela" | Iframe estático, limitado | **`@superset-ui/embedded-sdk`** — componente React nativo |
|
||||
| REST API | Limitada e não documentada | **REST API v1 completa e documentada** |
|
||||
| Consistência de stack | Java (fora do padrão) | Python (mesmo stack dos motores existentes) |
|
||||
| Docker image | `metabase/metabase` ~700MB | `apache/superset` ~400MB |
|
||||
| SQL Lab | Básico | **Poderoso** (autocomplete, histórico, salvar queries) |
|
||||
| Alertas | Básico | **Avançado** (webhooks, Slack, email) |
|
||||
| Tipos de chart | ~20 | **50+** (incluindo mapas, sunburst, funnel) |
|
||||
| Banco de metadados | H2/Postgres separado | **Mesmo PostgreSQL** da Arcádia |
|
||||
|
||||
### 2.2 A feature que o usuário pediu: "funcionar em qualquer tela"
|
||||
|
||||
O Superset resolve isso com **Guest Token + Embedded SDK**:
|
||||
|
||||
```
|
||||
Fluxo atual (Metabase):
|
||||
BiWorkspace → iframe → /metabase/ → autologin com cookie
|
||||
❌ Só funciona na tab Advanced do BiWorkspace
|
||||
❌ Cookie expira, precisa re-autenticar
|
||||
❌ Não embeda em outras páginas facilmente
|
||||
|
||||
Fluxo novo (Superset):
|
||||
QUALQUER página React → <EmbeddedDashboard dashboardId="xxx" />
|
||||
✅ Funciona no Dashboard principal (/)
|
||||
✅ Funciona no SOE (/soe tab Financeiro)
|
||||
✅ Funciona no /contabil
|
||||
✅ Funciona no /financeiro
|
||||
✅ Funciona em qualquer tela do Arcádia
|
||||
✅ Guest Token renovado automaticamente (sem re-login)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. ARQUITETURA COM SUPERSET
|
||||
|
||||
### 3.1 Diagrama atualizado
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ USUÁRIO / FRONTEND │
|
||||
│ │
|
||||
│ BiWorkspace.tsx (8 tabs — 7 iguais, Advanced → Superset) │
|
||||
│ + QUALQUER outra página pode embedar um dashboard Superset │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ <SupersetDashboard dashboardId="finance-overview" /> │ │
|
||||
│ │ Componente React reutilizável em QUALQUER rota │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌────────────────┐ ┌──────────────────────┐
|
||||
│ API BI Node │ │ BI Engine Py │ │ Superset (Python) │
|
||||
│ /api/bi/* │ │ /api/bi-engine │ │ /superset/* │
|
||||
│ (sem mudança)│ │ :8004 (igual) │ │ :8088 │
|
||||
│ │ │ │ │ │
|
||||
│ + novo: │ │ │ │ REST API v1 │
|
||||
│ /api/superset│ │ │ │ Guest Token JWT │
|
||||
│ /guest-token │ │ │ │ SQL Lab │
|
||||
│ │ │ │ │ 50+ chart types │
|
||||
└──────────────┘ └────────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ arcadia (dados SOE) │
|
||||
│ arcadia_superset │
|
||||
│ (metadados Superset) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Dois bancos PostgreSQL
|
||||
|
||||
```
|
||||
Banco: arcadia → Dados do SOE (persons, products, sales_orders, etc.)
|
||||
Superset conecta aqui em READ-ONLY para criar charts
|
||||
Banco: arcadia_superset → Metadados do Superset (dashboards, queries, users)
|
||||
Superset usa internamente
|
||||
```
|
||||
|
||||
Ambos no mesmo servidor PostgreSQL, bancos separados. O docker-compose.yml já referencia `arcadia_superset`.
|
||||
|
||||
---
|
||||
|
||||
## 4. O QUE CONSTRUIR — Passo a Passo
|
||||
|
||||
### 4.1 Configuração do Superset (docker-compose.yml)
|
||||
|
||||
O container já existe. Faltam: init script, volumes, configuração de embedding.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — seção superset (expandida)
|
||||
|
||||
superset:
|
||||
image: apache/superset:4.1.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia_superset
|
||||
SUPERSET_WEBSERVER_PORT: 8088
|
||||
PYTHONPATH: /app/pythonpath
|
||||
# Para embedding funcionar:
|
||||
FEATURE_FLAGS: '{"EMBEDDED_SUPERSET": true, "ENABLE_TEMPLATE_PROCESSING": true}'
|
||||
ports:
|
||||
- "8088:8088"
|
||||
volumes:
|
||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py
|
||||
- ./docker/superset/init.sh:/app/docker/init.sh
|
||||
- superset_home:/app/superset_home
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||
profiles: [bi]
|
||||
networks:
|
||||
- arcadia
|
||||
```
|
||||
|
||||
### 4.2 Arquivo de Configuração Superset
|
||||
|
||||
```python
|
||||
# docker/superset/superset_config.py
|
||||
|
||||
import os
|
||||
|
||||
# Chave secreta (mesma do .env)
|
||||
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY", "change-in-production")
|
||||
|
||||
# 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": "*"}},
|
||||
}
|
||||
|
||||
# Embedding habilitado
|
||||
FEATURE_FLAGS = {
|
||||
"EMBEDDED_SUPERSET": True,
|
||||
"ENABLE_TEMPLATE_PROCESSING": True,
|
||||
"ALERT_REPORTS": True, # Alertas automáticos
|
||||
"DRILL_TO_DETAIL": True, # Drill-down em charts
|
||||
}
|
||||
|
||||
# Timeout de queries (30s por default, aumentar para análises pesadas)
|
||||
SUPERSET_WEBSERVER_TIMEOUT = 300
|
||||
|
||||
# Cache (Redis se disponível, senão in-memory)
|
||||
CACHE_CONFIG = {
|
||||
"CACHE_TYPE": "SimpleCache",
|
||||
"CACHE_DEFAULT_TIMEOUT": 300, # 5 min
|
||||
}
|
||||
|
||||
# Tema/branding Arcádia (logo, cores)
|
||||
APP_NAME = "Arcádia Insights"
|
||||
APP_ICON = "/static/arcadia-logo.png"
|
||||
```
|
||||
|
||||
### 4.3 Init Script do Superset
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# docker/superset/init.sh — roda ao iniciar o container
|
||||
|
||||
set -e
|
||||
|
||||
echo "[Superset Init] Iniciando configuração..."
|
||||
|
||||
# Aguarda PostgreSQL
|
||||
until psql "${DATABASE_URL}" -c "SELECT 1" > /dev/null 2>&1; do
|
||||
echo "[Superset Init] Aguardando PostgreSQL..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Cria banco arcadia_superset se não existir
|
||||
psql "postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/postgres" \
|
||||
-c "CREATE DATABASE arcadia_superset OWNER arcadia;" 2>/dev/null || true
|
||||
|
||||
# Inicializa banco do Superset
|
||||
superset db upgrade
|
||||
|
||||
# Cria usuário admin (só se não existir)
|
||||
superset fab create-admin \
|
||||
--username "${SUPERSET_ADMIN_USER:-admin}" \
|
||||
--firstname "Arcádia" \
|
||||
--lastname "Admin" \
|
||||
--email "${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}" \
|
||||
--password "${SUPERSET_ADMIN_PASSWORD:-arcadia2026}" 2>/dev/null || true
|
||||
|
||||
# Carrega exemplos (opcional, comentar em prod)
|
||||
# superset load_examples
|
||||
|
||||
# Inicializa roles e permissões
|
||||
superset init
|
||||
|
||||
# Registra conexão com o banco Arcádia (dados do SOE)
|
||||
python - <<'PYEOF'
|
||||
from superset import create_app
|
||||
from superset.extensions import db
|
||||
from superset.models.core import Database
|
||||
import os
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
existing = db.session.query(Database).filter_by(
|
||||
database_name="Arcádia Suite"
|
||||
).first()
|
||||
if not existing:
|
||||
arcadia_db = Database(
|
||||
database_name="Arcádia Suite",
|
||||
sqlalchemy_uri=os.environ.get(
|
||||
"ARCADIA_DATABASE_URL",
|
||||
"postgresql://arcadia:arcadia123@db:5432/arcadia"
|
||||
),
|
||||
expose_in_sqllab=True,
|
||||
allow_run_async=True,
|
||||
allow_csv_upload=False,
|
||||
)
|
||||
db.session.add(arcadia_db)
|
||||
db.session.commit()
|
||||
print("[Superset Init] Banco 'Arcádia Suite' registrado!")
|
||||
else:
|
||||
print("[Superset Init] Banco 'Arcádia Suite' já existe.")
|
||||
PYEOF
|
||||
|
||||
echo "[Superset Init] Iniciando servidor..."
|
||||
gunicorn \
|
||||
--bind "0.0.0.0:${SUPERSET_WEBSERVER_PORT:-8088}" \
|
||||
--access-logfile "-" \
|
||||
--error-logfile "-" \
|
||||
--workers 4 \
|
||||
--timeout 120 \
|
||||
--limit-request-line 0 \
|
||||
--limit-request-field_size 0 \
|
||||
"superset.app:create_app()"
|
||||
```
|
||||
|
||||
### 4.4 Proxy Superset (substitui server/metabase/proxy.ts)
|
||||
|
||||
```typescript
|
||||
// server/superset/proxy.ts
|
||||
|
||||
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 Superset 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}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Rotas Superset — Guest Token para Embedding
|
||||
|
||||
Esta é a peça-chave para "funcionar em qualquer tela":
|
||||
|
||||
```typescript
|
||||
// server/superset/routes.ts
|
||||
|
||||
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 SUPERSET_ADMIN_USER = process.env.SUPERSET_ADMIN_USER || "admin";
|
||||
const SUPERSET_ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
|
||||
|
||||
// Cache do token de serviço (expira em 50 min)
|
||||
let serviceToken: string | null = null;
|
||||
let serviceTokenExpiry = 0;
|
||||
|
||||
async function getServiceToken(): Promise<string> {
|
||||
if (serviceToken && Date.now() < serviceTokenExpiry) return serviceToken;
|
||||
|
||||
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: SUPERSET_ADMIN_USER,
|
||||
password: SUPERSET_ADMIN_PASS,
|
||||
provider: "db",
|
||||
refresh: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error("Falha ao autenticar no Superset");
|
||||
const data = await resp.json();
|
||||
serviceToken = data.access_token;
|
||||
serviceTokenExpiry = Date.now() + 50 * 60 * 1000; // 50 min
|
||||
return serviceToken!;
|
||||
}
|
||||
|
||||
export function registerSupersetRoutes(app: Express): void {
|
||||
|
||||
// Guest Token — para embedding em qualquer tela
|
||||
// O frontend chama este endpoint para obter um token temporário
|
||||
// e renderiza o dashboard via @superset-ui/embedded-sdk
|
||||
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();
|
||||
|
||||
// Solicita guest token ao Superset
|
||||
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_${req.user?.id}`,
|
||||
first_name: req.user?.name?.split(" ")[0] || "User",
|
||||
last_name: req.user?.name?.split(" ")[1] || "",
|
||||
},
|
||||
resources: [{ type: "dashboard", id: dashboardId }],
|
||||
rls: [], // Row Level Security (futuro: por tenantId)
|
||||
}),
|
||||
});
|
||||
|
||||
if (!guestResp.ok) {
|
||||
const err = await guestResp.text();
|
||||
return res.status(502).json({ error: "Falha ao gerar guest token", detail: err });
|
||||
}
|
||||
|
||||
const { token: guestToken } = await guestResp.json();
|
||||
res.json({ token: guestToken, supersetUrl: "/superset" });
|
||||
} catch (err: any) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Lista dashboards disponíveis (para o 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)`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) return res.status(502).json({ error: "Superset indisponível" });
|
||||
const data = await resp.json();
|
||||
res.json(data.result || []);
|
||||
} catch (err: any) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get("/api/superset/health", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const resp = await fetch(`${SUPERSET_URL}/health`);
|
||||
res.json({ online: resp.ok, url: SUPERSET_URL });
|
||||
} catch {
|
||||
res.json({ online: false, url: SUPERSET_URL });
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 Componente React — SupersetDashboard (reutilizável em QUALQUER tela)
|
||||
|
||||
```typescript
|
||||
// client/src/components/SupersetDashboard.tsx
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||
|
||||
interface SupersetDashboardProps {
|
||||
dashboardId: string;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SupersetDashboard({
|
||||
dashboardId,
|
||||
height = "calc(100vh - 300px)",
|
||||
className = "",
|
||||
}: SupersetDashboardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
embedDashboard({
|
||||
id: dashboardId,
|
||||
supersetDomain: window.location.origin + "/superset",
|
||||
mountPoint: containerRef.current,
|
||||
|
||||
// Busca guest token do gateway Arcádia
|
||||
fetchGuestToken: async () => {
|
||||
const resp = await fetch("/api/superset/guest-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dashboardId }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) throw new Error("Falha ao obter token");
|
||||
const { token } = await resp.json();
|
||||
return token;
|
||||
},
|
||||
|
||||
dashboardUiConfig: {
|
||||
hideTitle: true, // Título próprio no Arcádia
|
||||
hideChartControls: false, // Manter controles de chart
|
||||
filters: {
|
||||
visible: true, // Mostrar filtros
|
||||
expanded: false,
|
||||
},
|
||||
},
|
||||
}).then(() => {
|
||||
if (mounted) setLoading(false);
|
||||
}).catch((err) => {
|
||||
if (mounted) {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, [dashboardId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-red-500 text-sm">
|
||||
Superset indisponível: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ height }}>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-10">
|
||||
<div className="text-center text-[#1f334d]">
|
||||
<div className="w-8 h-8 border-4 border-[#c89b3c] border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm">Carregando Arcádia Insights...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Uso em QUALQUER página:**
|
||||
```tsx
|
||||
// Exemplo: na página /financeiro
|
||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||
|
||||
// Dashboard financeiro embeddado diretamente no módulo Financeiro
|
||||
<SupersetDashboard dashboardId="financial-overview" height={500} />
|
||||
|
||||
// Exemplo: na página /contabil
|
||||
<SupersetDashboard dashboardId="dre-mensal" />
|
||||
|
||||
// Exemplo: no Dashboard principal (/)
|
||||
<SupersetDashboard dashboardId="executive-summary" height={400} />
|
||||
```
|
||||
|
||||
### 4.7 BiWorkspace.tsx — Advanced tab (única mudança na UI)
|
||||
|
||||
```tsx
|
||||
// Substituir a tab Advanced (linhas 2918-2945)
|
||||
|
||||
<TabsContent value="advanced" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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 em Nova Aba
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Dashboard selecionável */}
|
||||
<SupersetDashboardSelector />
|
||||
</div>
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. TABELAS E VARIÁVEIS
|
||||
|
||||
### 5.1 Nenhuma tabela nova no schema Arcádia
|
||||
|
||||
O Superset usa seu próprio banco (`arcadia_superset`) para metadados internos. As tabelas do Arcádia não mudam.
|
||||
|
||||
### 5.2 Novas variáveis de ambiente
|
||||
|
||||
```bash
|
||||
# .env — adicionar/substituir:
|
||||
|
||||
# Superset (substitui METABASE_*)
|
||||
SUPERSET_HOST=superset # nome do container Docker
|
||||
SUPERSET_PORT=8088
|
||||
SUPERSET_SECRET_KEY= # gerar: openssl rand -hex 32
|
||||
SUPERSET_ADMIN_USER=admin
|
||||
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
||||
SUPERSET_ADMIN_PASSWORD= # senha forte em prod
|
||||
|
||||
# URL do banco Arcádia para o Superset acessar (read-only ideal)
|
||||
ARCADIA_DATABASE_URL=postgresql://arcadia_ro:pass@db:5432/arcadia
|
||||
|
||||
# Remover (não são mais necessários):
|
||||
# METABASE_HOST
|
||||
# METABASE_PORT
|
||||
# METASET_ADMIN_EMAIL
|
||||
# METASET_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ROADMAP — Fases de Implementação
|
||||
|
||||
### Fase 1 — Infraestrutura (2-3 dias)
|
||||
```
|
||||
[ ] Criar docker/superset/superset_config.py
|
||||
[ ] Criar docker/superset/init.sh
|
||||
[ ] Atualizar docker-compose.yml (expandir seção superset existente)
|
||||
[ ] Adicionar volumes: superset_home no docker-compose
|
||||
[ ] Testar: docker compose --profile bi up superset
|
||||
[ ] Verificar: banco arcadia_superset criado, admin funciona, :8088 acessível
|
||||
```
|
||||
|
||||
### Fase 2 — Gateway (1-2 dias)
|
||||
```
|
||||
[ ] Criar server/superset/proxy.ts (substitui server/metabase/proxy.ts)
|
||||
[ ] Criar server/superset/routes.ts (guest token, lista dashboards, health)
|
||||
[ ] Registrar no server/routes.ts: setupSupersetProxy() + registerSupersetRoutes()
|
||||
[ ] Remover/comentar setupMetabaseProxy() e registerMetaSetRoutes()
|
||||
[ ] Testar: GET /api/superset/health → { online: true }
|
||||
[ ] Testar: POST /api/superset/guest-token → token JWT
|
||||
```
|
||||
|
||||
### Fase 3 — Frontend (2-3 dias)
|
||||
```
|
||||
[ ] npm install @superset-ui/embedded-sdk
|
||||
[ ] Criar client/src/components/SupersetDashboard.tsx
|
||||
[ ] Atualizar BiWorkspace.tsx Advanced tab (substituir iframe MetaSet)
|
||||
[ ] Testar: dashboard embeddado na Advanced tab
|
||||
[ ] Criar 3 dashboards base no Superset:
|
||||
- executive-summary (KPIs gerais)
|
||||
- financial-overview (Financeiro)
|
||||
- dre-mensal (DRE)
|
||||
```
|
||||
|
||||
### Fase 4 — Embedding em outras telas (1 semana)
|
||||
```
|
||||
[ ] Embedar SupersetDashboard no /financeiro (tab Análise)
|
||||
[ ] Embedar SupersetDashboard no /contabil (tab DRE/Balanço)
|
||||
[ ] Embedar SupersetDashboard no SOE (tab Dashboard)
|
||||
[ ] Embedar SupersetDashboard no Home / Dashboard principal
|
||||
[ ] RLS por tenantId (Row Level Security nos dashboards)
|
||||
```
|
||||
|
||||
### Fase 5 — Migração de dashboards (conforme necessário)
|
||||
```
|
||||
[ ] Recriar no Superset os dashboards que existiam no MetaSet (se houver)
|
||||
[ ] Configurar alertas automáticos (Superset Alerts & Reports)
|
||||
[ ] Criar dashboards para cada domínio do SOE:
|
||||
- Vendas, Compras, Fiscal, Pessoas, Estoque
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. DIAGRAMA BI ATUALIZADO (completo)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ ARCÁDIA BI STACK v2 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ FRONTEND REACT — Pode usar em QUALQUER rota │ │
|
||||
│ │ │ │
|
||||
│ │ BiWorkspace.tsx → 8 tabs (7 iguais + Advanced=Superset) │ │
|
||||
│ │ <SupersetDashboard> → componente reutilizável │ │
|
||||
│ │ Guest Token API → /api/superset/guest-token │ │
|
||||
│ └────────────────────────────┬─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┼──────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼──────┐ ┌────────▼───────┐ ┌─────────▼──────────┐ │
|
||||
│ │ API BI │ │ BI Engine │ │ Apache Superset │ │
|
||||
│ │ (Node.js) │ │ Python:8004 │ │ Python:8088 │ │
|
||||
│ │ sem mudança│ │ sem mudança │ │ │ │
|
||||
│ │ │ │ │ │ SQL Lab │ │
|
||||
│ │ CRUD │ │ SQL+Charts │ │ 50+ chart types │ │
|
||||
│ │ Upload/ETL │ │ Micro-BI │ │ Guest Token (embed) │ │
|
||||
│ │ Staging │ │ Análise Pandas │ │ Alerts & Reports │ │
|
||||
│ │ Backups │ │ Cache 5min │ │ REST API v1 │ │
|
||||
│ └─────┬───────┘ └────────┬───────┘ └─────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────────┼─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ PostgreSQL │ │
|
||||
│ │ │ │
|
||||
│ │ arcadia │ ← Dados SOE (read-only) │
|
||||
│ │ arcadia_superset │ ← Metadados Superset │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CIENTISTA (Python) — IA/ML: análise, padrões, insights │ │
|
||||
│ │ ASSISTENTE BI (GPT) — chat sobre dados │ │
|
||||
│ │ (ambos sem mudança) │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. RESUMO — O que muda, o que fica
|
||||
|
||||
| Componente | Ação | Esforço |
|
||||
|---|---|---|
|
||||
| `server/metabase/proxy.ts` | Substituir por `server/superset/proxy.ts` | 30 min |
|
||||
| `server/metaset/routes.ts` | Substituir por `server/superset/routes.ts` | 2h |
|
||||
| `server/metaset/client.ts` | Remover (lógica vai para routes.ts) | 5 min |
|
||||
| `docker-compose.yml` Superset | Expandir configuração existente | 1h |
|
||||
| `docker/superset/superset_config.py` | Criar (novo arquivo) | 30 min |
|
||||
| `docker/superset/init.sh` | Criar (novo arquivo) | 1h |
|
||||
| `BiWorkspace.tsx` Advanced tab | Substituir iframe MetaSet | 1h |
|
||||
| `SupersetDashboard.tsx` | Criar componente (novo) | 2h |
|
||||
| `npm install @superset-ui/embedded-sdk` | Instalar dependência | 5 min |
|
||||
| BI Engine Python (:8004) | **Não muda nada** | — |
|
||||
| API BI Node /api/bi/* | **Não muda nada** | — |
|
||||
| Upload/ETL/Staging | **Não muda nada** | — |
|
||||
| 7 tabs do BiWorkspace | **Não muda nada** | — |
|
||||
| Tabelas do banco | **Não muda nada** | — |
|
||||
|
||||
**Total estimado: 1-2 dias de implementação real** (a infra já está 80% pronta).
|
||||
|
||||
---
|
||||
|
||||
## 9. VANTAGEM ESTRATÉGICA
|
||||
|
||||
> O Apache Superset não é apenas "trocar Metabase".
|
||||
>
|
||||
> É a virada de chave que permite que o BI deixe de ser uma página separada
|
||||
> e vire um **componente vivo** que aparece **dentro de cada módulo do Arcádia**:
|
||||
>
|
||||
> - O usuário está no módulo Financeiro → vê o dashboard financeiro ali mesmo
|
||||
> - O usuário está no módulo Contábil → vê o DRE ali mesmo
|
||||
> - O usuário está no SOE → vê os KPIs do negócio ali mesmo
|
||||
>
|
||||
> Sem abrir nova aba. Sem sair do contexto. Sem re-autenticar.
|
||||
> O Superset renderiza no lugar exato onde o usuário está.
|
||||
|
||||
---
|
||||
|
||||
*PLANO_BI_SUPERSET.md — Arcádia Suite v3.0*
|
||||
*Gerado em: 2026-03-16 — Baseado na análise do MAPA_BI_ARCADIA.md (Replit) + código real*
|
||||
|
|
@ -0,0 +1,666 @@
|
|||
# PLANO SOE CENTRAL v2 — Análise Real + Evolução
|
||||
## Baseado no MAPA_SOE_ARCADIA.md (Replit) + Auditoria do Código
|
||||
### Versão 2.0 — Março 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. DIAGNÓSTICO — O QUE JÁ EXISTE (e o plano v1 não sabia)
|
||||
|
||||
O MAPA_SOE_ARCADIA.md do Replit revelou que **o SOE já é uma realidade arquitetural robusta**, não apenas um conceito. Antes de planejar qualquer coisa, é crítico entender o que já funciona.
|
||||
|
||||
### 1.1 O que já está construído e funcionando
|
||||
|
||||
| Componente | Status | Localização |
|
||||
|---|---|---|
|
||||
| `SOE.tsx` | ✅ 2.414 linhas — UI completa com 9 tabs | `client/src/pages/SOE.tsx` |
|
||||
| `SoeMotorContext.tsx` | ✅ Seletor de motor (plus/erpnext) | `client/src/contexts/` |
|
||||
| `ErpApiClient` (interface) | ✅ Interface unificada definida | `server/erp/index.ts` |
|
||||
| `ArcadiaPlusClient` | ✅ Adaptador HTTP → Plus:8080 | `server/erp/index.ts` |
|
||||
| `ArcadiaNextClient` | ✅ Adaptador HTTP → ERPNext API | `server/erp/index.ts` |
|
||||
| `registerSoeRoutes()` | ✅ 60+ endpoints em /api/soe/* | `server/erp/routes.ts` |
|
||||
| `server/plus/proxy.ts` | ✅ Proxy reverso para Plus | `server/plus/` |
|
||||
| `server/plus/sso.ts` | ✅ SSO automático Plus ↔ Suite | `server/plus/` |
|
||||
| `server/plus/launcher.ts` | ✅ Lança PHP artisan serve | `server/plus/` |
|
||||
| Módulo de Pessoas unificado | ✅ `persons` + `person_roles` | Schema PostgreSQL |
|
||||
| Módulo de Produtos | ✅ Com NCM, CST, CFOP, IMEI | Schema PostgreSQL |
|
||||
| Módulo Financeiro | ✅ Contas a P/R + transações | Schema PostgreSQL |
|
||||
| Motor Fisco (:8002) | ✅ NF-e/NFC-e via nfelib | `server/python/` |
|
||||
| Motor Contábil (:8003) | ✅ DRE, Balanço, SPED | `server/python/` |
|
||||
| Sistema de módulos por tenant | ✅ `tenants.features` (JSONB) | Schema PostgreSQL |
|
||||
| Middleware de gating | ✅ `requireModule()` | `server/` |
|
||||
|
||||
### 1.2 Bancos de Dados por Motor (detalhe crítico)
|
||||
|
||||
```
|
||||
Arcádia Suite → PostgreSQL 16 (dados do SOE, pessoas, produtos, vendas...)
|
||||
Arcádia Plus → MySQL 8.0 (dados do Laravel — vendas, estoque, NF-e...)
|
||||
ERPNext → MariaDB (dados do Frappe — CRM, HR, projetos...)
|
||||
Motor Fisco → sem banco (só processa, retorna resultado)
|
||||
Motor Contábil → sem banco (só processa, retorna resultado)
|
||||
```
|
||||
|
||||
**Implicação:** Não há sincronização automática entre esses bancos. Um pedido criado no SOE (PostgreSQL) não aparece no Plus (MySQL) automaticamente — e vice-versa.
|
||||
|
||||
### 1.3 O padrão motor/adaptador atual
|
||||
|
||||
```
|
||||
Request do usuário
|
||||
│
|
||||
▼
|
||||
/api/soe/sales-orders (POST)
|
||||
│
|
||||
▼
|
||||
Verifica motor ativo (localStorage: arcadia_soe_motor)
|
||||
│
|
||||
├── motor = "local" → INSERT direto no PostgreSQL
|
||||
├── motor = "plus" → POST → Plus:8080 → MySQL
|
||||
└── motor = "erpnext" → POST → ERPNext API → MariaDB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. O PROBLEMA REAL — Onde o SOE atual para
|
||||
|
||||
O SOE atual é um **roteador inteligente**: recebe o request, decide para qual motor enviar, e despacha. Ele **não tem inteligência própria**.
|
||||
|
||||
### 2.1 O que falta (lacuna real)
|
||||
|
||||
```
|
||||
Situação atual:
|
||||
|
||||
SOE.tsx → /api/soe/* ────────────────────────────► Plus/ERPNext
|
||||
(sem regras, sem processamento)
|
||||
Regras ficam DENTRO do Plus
|
||||
Regras ficam DENTRO do ERPNext
|
||||
|
||||
Situação desejada:
|
||||
|
||||
SOE.tsx → /api/soe/* → [RULE ENGINE SOE] ──────► Plus/ERPNext
|
||||
│ (só executa)
|
||||
├── Regras Fiscais
|
||||
├── Regras Contábeis
|
||||
├── Regras Financeiras
|
||||
└── Regras de Negócio
|
||||
```
|
||||
|
||||
### 2.2 Problemas concretos hoje
|
||||
|
||||
| Problema | Impacto |
|
||||
|---|---|
|
||||
| Fiscal rules dentro do Plus | Se trocar de motor (Plus→ERPNext), perde todas as regras |
|
||||
| Nenhum lançamento contábil automático | DRE fica vazio, contabilidade manual |
|
||||
| Motor Contábil (:8003) existe mas nada alimenta ele | 0 lançamentos automáticos |
|
||||
| Plus tem UI própria (/plus) | Usuário pode bypassar o SOE e ir direto ao Plus |
|
||||
| Sem event bus entre motores | Uma NF-e emitida no Plus não dispara nada no SOE |
|
||||
| 25 conectores ERP definidos, zero chamadas reais | Integração existe no papel, não funciona |
|
||||
|
||||
### 2.3 O que o usuário vê hoje vs o que deveria ver
|
||||
|
||||
```
|
||||
Hoje:
|
||||
/soe → UI do SOE (tabs: dashboard, pessoas, produtos, vendas...)
|
||||
/plus → UI do Plus (Laravel) — direto, sem passar pelo SOE
|
||||
/erp → UI do ERP legado
|
||||
(usuário VÊ o Plus se quiser)
|
||||
|
||||
Desejado:
|
||||
/soe → UI do SOE (mesma, mas com inteligência)
|
||||
/plus → BLOQUEADO ou redirecionado para /soe
|
||||
/erp → BLOQUEADO ou redirecionado para /soe
|
||||
(Plus e ERPNext = invisíveis, executando em background)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. ARQUITETURA EVOLUÍDA — SOE com Rule Engine
|
||||
|
||||
### 3.1 O que muda (e o que fica igual)
|
||||
|
||||
**NÃO MUDA:**
|
||||
- `SOE.tsx` — a UI continua a mesma (só ganha funcionalidades)
|
||||
- `SoeMotorContext.tsx` — seleção de motor continua existindo
|
||||
- `ArcadiaPlusClient` / `ArcadiaNextClient` — adaptadores continuam
|
||||
- Todas as rotas `/api/soe/*` — continuam funcionando
|
||||
- Todos os bancos — PostgreSQL, MySQL, MariaDB — continuam separados
|
||||
|
||||
**MUDA / ADICIONA:**
|
||||
- Uma **camada de Rule Engine** entre as rotas e os adaptadores
|
||||
- **Event Bus SOE** que dispara lançamentos contábeis automaticamente
|
||||
- **Ocultação do Plus** — `/plus` passa a ser controlado pelo SOE
|
||||
- **Regras fiscais** saem do Plus e entram no SOE
|
||||
|
||||
### 3.2 Diagrama completo com Rule Engine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ARCÁDIA SUITE — CAMADA DE APRESENTAÇÃO │
|
||||
│ │
|
||||
│ SOE.tsx (2.414+ linhas) │
|
||||
│ Tabs: Dashboard | Pessoas | Produtos | Vendas | Compras │
|
||||
│ Financeiro | CRM | Sincronização | Config │
|
||||
│ │
|
||||
│ SoeMotorContext.tsx → motor = "plus" | "erpnext" | "local" │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ /api/soe/* — CAMADA DE API (já existe) │
|
||||
│ registerSoeRoutes() em server/erp/routes.ts │
|
||||
│ requireAuth + requireModule middleware │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼ ◄── INSERIR AQUI (novo)
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SOE RULE ENGINE (NOVO) │
|
||||
│ server/soe/rule-engine/ │
|
||||
│ │
|
||||
│ Antes de despachar para o motor, o Rule Engine: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. FISCAL RULES (server/soe/rule-engine/fiscal.ts) │ │
|
||||
│ │ • Resolve CFOP correto (intra/interestadual) │ │
|
||||
│ │ • Aplica tributação por regime (Simples/Presumido/Real) │ │
|
||||
│ │ • Valida NCM obrigatório │ │
|
||||
│ │ • Define CST/CSOSN automático │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 2. BUSINESS RULES (server/soe/rule-engine/business.ts) │ │
|
||||
│ │ • Aprovação automática de pedidos < R$ X │ │
|
||||
│ │ • Reserva de estoque ao confirmar venda │ │
|
||||
│ │ • Trigger de NF-e ao faturar pedido │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 3. ACCOUNTING RULES (server/soe/rule-engine/accounting.ts) │ │
|
||||
│ │ • Emitiu NF-e → lançamento automático D: Clientes │ │
|
||||
│ │ C: Receita │ │
|
||||
│ │ • Pagou conta → lançamento automático D: Fornecedor │ │
|
||||
│ │ C: Banco │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ DESPACHADOR DE MOTOR (já existe, aprimorado) │
|
||||
│ createErpClient(connection) em server/erp/index.ts │
|
||||
│ │
|
||||
│ motor = "local" → INSERT PostgreSQL │
|
||||
│ motor = "plus" → ArcadiaPlusClient → Plus:8080 → MySQL │
|
||||
│ motor = "erpnext" → ArcadiaNextClient → ERPNext API → MariaDB │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SOE EVENT BUS (NOVO) │
|
||||
│ server/soe/event-bus.ts │
|
||||
│ │
|
||||
│ Depois que o motor executa, o Event Bus dispara: │
|
||||
│ │
|
||||
│ evento: "nfe_emitida" → Motor Contábil (lançamento auto) │
|
||||
│ evento: "venda_confirmada" → Reserva estoque + alerta WhatsApp │
|
||||
│ evento: "pagamento_feito" → Baixa C/P + lançamento contábil │
|
||||
│ evento: "recebimento" → Baixa C/R + lançamento contábil │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. O QUE CONSTRUIR — Detalhamento por Módulo
|
||||
|
||||
### 4.1 SOE Rule Engine (PRIORIDADE MÁXIMA)
|
||||
|
||||
**Localização:** `server/soe/rule-engine/`
|
||||
|
||||
Este é o coração da evolução. É um middleware que intercepta as chamadas às rotas SOE **antes** de despachar para o motor.
|
||||
|
||||
#### Como funciona na prática
|
||||
|
||||
```typescript
|
||||
// server/erp/routes.ts — modificação cirúrgica
|
||||
|
||||
// ANTES (atual):
|
||||
app.post("/api/soe/sales-orders", requireAuth, async (req, res) => {
|
||||
const client = getActiveClient(req) // pega Plus ou ERPNext
|
||||
const result = await client.createSalesOrder(req.body) // despacha direto
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
// DEPOIS (com Rule Engine):
|
||||
app.post("/api/soe/sales-orders", requireAuth, async (req, res) => {
|
||||
// 1. Aplica regras ANTES de despachar
|
||||
const enriched = await ruleEngine.apply('pre_sales_order', req.body, req.user)
|
||||
|
||||
// 2. Despacha para o motor com dados enriquecidos
|
||||
const client = getActiveClient(req)
|
||||
const result = await client.createSalesOrder(enriched.payload)
|
||||
|
||||
// 3. Dispara eventos DEPOIS da execução
|
||||
await eventBus.emit('sales_order_created', { result, rules: enriched.appliedRules })
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
```
|
||||
|
||||
#### Estrutura do Rule Engine
|
||||
|
||||
```typescript
|
||||
// server/soe/rule-engine/index.ts
|
||||
|
||||
interface SoeRule {
|
||||
id: string
|
||||
dominio: 'fiscal' | 'business' | 'accounting' | 'financial'
|
||||
trigger: string // ex: 'pre_sales_order', 'post_nfe_emitida'
|
||||
condicao: RuleCondition // quando esta regra se aplica
|
||||
acao: RuleAction // o que ela faz ao payload
|
||||
prioridade: number
|
||||
ativo: boolean
|
||||
empresaId?: number // null = regra global de todos os tenants
|
||||
}
|
||||
|
||||
class SoeRuleEngine {
|
||||
async apply(trigger: string, payload: any, context: RuleContext): Promise<EnrichedPayload> {
|
||||
// 1. Busca regras ativas para este trigger (do banco + defaults hardcoded)
|
||||
const rules = await this.getActiveRules(trigger, context.tenantId)
|
||||
|
||||
// 2. Filtra regras cuja condição se aplica ao payload
|
||||
const applicable = rules.filter(r => this.avaliarCondicao(r.condicao, payload, context))
|
||||
|
||||
// 3. Aplica cada regra em ordem de prioridade
|
||||
let enriched = { ...payload }
|
||||
const applied: string[] = []
|
||||
for (const rule of applicable.sort((a, b) => a.prioridade - b.prioridade)) {
|
||||
enriched = await this.aplicarAcao(rule.acao, enriched, context)
|
||||
applied.push(rule.id)
|
||||
}
|
||||
|
||||
return { payload: enriched, appliedRules: applied }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Regras Fiscais no SOE
|
||||
|
||||
Estas regras **hoje vivem dentro do Plus** (Laravel controllers). A ideia é trazê-las para o SOE.
|
||||
|
||||
**Nota importante:** Não é mover o código PHP para TypeScript. É criar regras **declarativas** no SOE que enriquecem o payload **antes** de enviar ao Plus. O Plus ainda processa e emite — mas recebe o payload já correto.
|
||||
|
||||
```typescript
|
||||
// server/soe/rule-engine/fiscal-rules.ts
|
||||
|
||||
export const FISCAL_RULES_PADRAO: SoeRule[] = [
|
||||
|
||||
// CFOP — resolve automaticamente por operação
|
||||
{
|
||||
id: 'CFOP_VENDA_DENTRO_ESTADO',
|
||||
dominio: 'fiscal',
|
||||
trigger: 'pre_sales_order',
|
||||
condicao: { uf_emitente: '==', uf_destinatario: true, tipo_op: 'venda_produto' },
|
||||
acao: { set: { 'items[*].cfop': '5.102' } },
|
||||
prioridade: 10, ativo: true
|
||||
},
|
||||
{
|
||||
id: 'CFOP_VENDA_OUTRO_ESTADO',
|
||||
dominio: 'fiscal',
|
||||
trigger: 'pre_sales_order',
|
||||
condicao: { uf_emitente: '!=', uf_destinatario: true, tipo_op: 'venda_produto' },
|
||||
acao: { set: { 'items[*].cfop': '6.102' } },
|
||||
prioridade: 10, ativo: true
|
||||
},
|
||||
|
||||
// Tributação por Regime
|
||||
{
|
||||
id: 'CST_SIMPLES_NACIONAL',
|
||||
dominio: 'fiscal',
|
||||
trigger: 'pre_nfe_emissao',
|
||||
condicao: { regime_tributario: 'simples_nacional' },
|
||||
acao: {
|
||||
set: { 'items[*].cst_icms': '400', 'items[*].pis': null, 'items[*].cofins': null },
|
||||
// No Simples Nacional: CSOSN 400 = tributado, sem destaque PIS/COFINS
|
||||
},
|
||||
prioridade: 5, ativo: true
|
||||
},
|
||||
{
|
||||
id: 'CST_LUCRO_PRESUMIDO',
|
||||
dominio: 'fiscal',
|
||||
trigger: 'pre_nfe_emissao',
|
||||
condicao: { regime_tributario: 'lucro_presumido' },
|
||||
acao: { set: { 'items[*].cst_pis': '01', 'items[*].cst_cofins': '01' } },
|
||||
prioridade: 5, ativo: true
|
||||
},
|
||||
|
||||
// Validações obrigatórias
|
||||
{
|
||||
id: 'VALIDAR_NCM_NFE',
|
||||
dominio: 'fiscal',
|
||||
trigger: 'pre_nfe_emissao',
|
||||
condicao: { tipo_documento: ['nfe', 'nfce'] },
|
||||
acao: { validar: { campo: 'items[*].ncm', obrigatorio: true, formato: /^\d{8}$/ } },
|
||||
prioridade: 1, ativo: true
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### 4.3 SOE Event Bus — Lançamentos Contábeis Automáticos
|
||||
|
||||
Este é o **maior salto de valor**. Hoje o Motor Contábil (:8003) existe mas nada o alimenta automaticamente.
|
||||
|
||||
```typescript
|
||||
// server/soe/event-bus.ts
|
||||
|
||||
type SoeEvent =
|
||||
| 'nfe_emitida'
|
||||
| 'nfe_cancelada'
|
||||
| 'venda_confirmada'
|
||||
| 'compra_recebida'
|
||||
| 'pagamento_realizado'
|
||||
| 'recebimento_realizado'
|
||||
| 'boleto_gerado'
|
||||
|
||||
interface SoeEventPayload {
|
||||
empresaId: number
|
||||
tenantId: number
|
||||
evento: SoeEvent
|
||||
dados: Record<string, any>
|
||||
motorOrigem: 'plus' | 'erpnext' | 'local'
|
||||
}
|
||||
|
||||
class SoeEventBus {
|
||||
async emit(evento: SoeEvent, payload: SoeEventPayload) {
|
||||
// Registra no banco (audit trail)
|
||||
await db.insert(soeEventos).values({ ...payload, createdAt: new Date() })
|
||||
|
||||
// Dispara handlers
|
||||
await Promise.allSettled(
|
||||
this.getHandlers(evento).map(h => h(payload))
|
||||
)
|
||||
}
|
||||
|
||||
// Handlers por evento
|
||||
private handlers: Record<SoeEvent, EventHandler[]> = {
|
||||
'nfe_emitida': [
|
||||
contabilHandler.lancamentoVenda, // D: Clientes / C: Receita Bruta
|
||||
contabilHandler.lancamentoImpostos, // D: Impostos / C: ICMS/PIS/COFINS a Recolher
|
||||
financialHandler.gerarRecebivel, // Cria C/R automaticamente
|
||||
],
|
||||
'pagamento_realizado': [
|
||||
contabilHandler.lancamentoPagamento, // D: Fornecedores / C: Banco
|
||||
financialHandler.baixarContaPagar, // Baixa C/P
|
||||
],
|
||||
'recebimento_realizado': [
|
||||
contabilHandler.lancamentoRecebimento, // D: Banco / C: Clientes
|
||||
financialHandler.baixarContaReceber, // Baixa C/R
|
||||
],
|
||||
'compra_recebida': [
|
||||
contabilHandler.lancamentoCompra, // D: Estoque / C: Fornecedores
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Lançamentos automáticos para cada evento
|
||||
|
||||
```
|
||||
NF-e emitida (venda):
|
||||
D: 1.1.3.01 — Clientes (valor total da nota)
|
||||
C: 3.1.1.01 — Receita Bruta de Vendas
|
||||
|
||||
D: 3.1.2.01 — Deduções (ICMS)
|
||||
C: 2.1.2.01 — ICMS a Recolher
|
||||
|
||||
D: 3.1.2.02 — Deduções (PIS)
|
||||
C: 2.1.2.02 — PIS a Recolher
|
||||
|
||||
D: 3.1.2.03 — Deduções (COFINS)
|
||||
C: 2.1.2.03 — COFINS a Recolher
|
||||
|
||||
Pagamento de fornecedor:
|
||||
D: 2.1.1.{categoria} — Fornecedores
|
||||
C: 1.1.1.01 — Banco (conta configurada)
|
||||
|
||||
Recebimento de cliente:
|
||||
D: 1.1.1.01 — Banco
|
||||
C: 1.1.3.01 — Clientes
|
||||
|
||||
Compra recebida (NF-e entrada):
|
||||
D: 1.1.4.01 — Estoque / Mercadorias
|
||||
C: 2.1.1.01 — Fornecedores
|
||||
```
|
||||
|
||||
### 4.4 Ocultação do Plus e ERPNext
|
||||
|
||||
O Plus tem página própria em `/plus` (client/src/pages/Plus.tsx). O usuário pode acessá-la diretamente. Precisamos de 3 mudanças:
|
||||
|
||||
#### a) Redirecionar `/plus` para `/soe`
|
||||
|
||||
```typescript
|
||||
// client/src/App.tsx — modificação simples
|
||||
// Trocar: <Route path="/plus" component={Plus} />
|
||||
// Por: <Route path="/plus" render={() => <Redirect to="/soe" />} />
|
||||
```
|
||||
|
||||
**Mas manter o proxy reverso Plus ativo** — o Plus continua funcionando em background, só não fica visível como página separada.
|
||||
|
||||
#### b) Ocultar o seletor de motor do usuário final
|
||||
|
||||
```typescript
|
||||
// client/src/contexts/SoeMotorContext.tsx
|
||||
// O seletor fica SOMENTE na tab Config do SOE, e somente para admins
|
||||
|
||||
// A tab Config na SOE.tsx — adicionar role check:
|
||||
{user?.role === 'admin' && <TabsTrigger value="config">Config</TabsTrigger>}
|
||||
```
|
||||
|
||||
#### c) Na tab Config (admin), o label muda
|
||||
|
||||
```
|
||||
Antes: "Selecione o motor: Plus | ERPNext"
|
||||
Depois: "Motor de execução: Plus | ERPNext | Local"
|
||||
(invisível para usuário final, só admin de tenant vê)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. TABELAS A CRIAR (complemento ao schema existente)
|
||||
|
||||
As tabelas canônicas do SOE já existem. Precisamos adicionar apenas as tabelas do Rule Engine.
|
||||
|
||||
```typescript
|
||||
// shared/schema.ts — adicionar ao final
|
||||
|
||||
// Regras do SOE — configuráveis por tenant
|
||||
export const soeRegras = pgTable('soe_regras', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: integer('tenant_id'), // null = regra global (todas as empresas)
|
||||
empresaId: integer('empresa_id'), // null = todas as empresas do tenant
|
||||
dominio: varchar('dominio', { length: 50 }).notNull(),
|
||||
trigger: varchar('trigger', { length: 100 }).notNull(),
|
||||
nome: varchar('nome', { length: 200 }).notNull(),
|
||||
condicao: jsonb('condicao').notNull(),
|
||||
acao: jsonb('acao').notNull(),
|
||||
prioridade: integer('prioridade').default(10),
|
||||
ativo: boolean('ativo').default(true),
|
||||
origemPadrao: boolean('origem_padrao').default(false), // true = regra default do sistema
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
})
|
||||
|
||||
// Log de todos os eventos SOE (audit trail completo)
|
||||
export const soeEventos = pgTable('soe_eventos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: integer('tenant_id').notNull(),
|
||||
empresaId: integer('empresa_id'),
|
||||
evento: varchar('evento', { length: 100 }).notNull(),
|
||||
motorOrigem: varchar('motor_origem', { length: 50 }), // 'plus', 'erpnext', 'local'
|
||||
regraIds: jsonb('regra_ids'), // regras aplicadas
|
||||
payloadEntrada: jsonb('payload_entrada'),
|
||||
payloadSaida: jsonb('payload_saida'),
|
||||
status: varchar('status', { length: 50 }).notNull(),
|
||||
duracaoMs: integer('duracao_ms'),
|
||||
erro: text('erro'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
})
|
||||
|
||||
// Lançamentos contábeis automáticos do SOE
|
||||
export const soeLancamentos = pgTable('soe_lancamentos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: integer('tenant_id').notNull(),
|
||||
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', etc.
|
||||
origemEventoId: uuid('origem_evento_id'), // FK para soe_eventos
|
||||
periodo: varchar('periodo', { length: 7 }), // '2026-03'
|
||||
enviado8003: boolean('enviado_8003').default(false), // sincronizado com Motor Contábil
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ESTRUTURA DE PASTAS (Novo código a criar)
|
||||
|
||||
```
|
||||
server/
|
||||
└── soe/ ← NOVA PASTA
|
||||
├── rule-engine/
|
||||
│ ├── index.ts ← SoeRuleEngine class
|
||||
│ ├── fiscal-rules.ts ← Regras fiscais padrão
|
||||
│ ├── business-rules.ts ← Regras de negócio
|
||||
│ └── accounting-rules.ts ← Regras de lançamento contábil
|
||||
│
|
||||
├── event-bus.ts ← SoeEventBus class
|
||||
│
|
||||
├── handlers/
|
||||
│ ├── contabil-handler.ts ← Lançamentos automáticos
|
||||
│ └── financial-handler.ts ← Automação financeira
|
||||
│
|
||||
└── fiscal/
|
||||
└── cfop-resolver.ts ← Resolução de CFOP (lógica complexa)
|
||||
```
|
||||
|
||||
**Modificações em arquivos existentes:**
|
||||
```
|
||||
server/erp/routes.ts ← Inserir ruleEngine.apply() antes do dispatch
|
||||
client/src/App.tsx ← Redirecionar /plus para /soe
|
||||
client/src/pages/SOE.tsx ← Ocultar Config tab para não-admins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. ROADMAP — Fases de Implementação
|
||||
|
||||
### Fase 1 — Rule Engine Básico (1 semana)
|
||||
```
|
||||
[ ] Criar server/soe/rule-engine/index.ts (SoeRuleEngine)
|
||||
[ ] Criar tabelas soe_regras e soe_eventos (drizzle push)
|
||||
[ ] Inserir ruleEngine.apply() em 3 rotas críticas:
|
||||
- POST /api/soe/sales-orders
|
||||
- POST /api/soe/sales-orders/:id/generate-nfe
|
||||
- POST /api/soe/purchase-orders
|
||||
[ ] Seed das regras fiscais padrão (CFOP, CST, validações NCM)
|
||||
[ ] Testar: criar pedido → verificar que payload está sendo enriquecido
|
||||
```
|
||||
|
||||
### Fase 2 — Event Bus + Contabilidade Automática (1 semana)
|
||||
```
|
||||
[ ] Criar server/soe/event-bus.ts
|
||||
[ ] Criar tabela soe_lancamentos (drizzle push)
|
||||
[ ] Criar handlers: contabil-handler.ts + financial-handler.ts
|
||||
[ ] Conectar: POST generate-nfe → emit('nfe_emitida') → lançamentos
|
||||
[ ] Conectar: pagamento/recebimento → emit() → lançamentos
|
||||
[ ] Criar endpoint: GET /api/soe/contabil/lancamentos?periodo=YYYY-MM
|
||||
[ ] DRE: agora tem dados reais (via soe_lancamentos → Motor :8003)
|
||||
```
|
||||
|
||||
### Fase 3 — Ocultar Plus/ERPNext (3 dias)
|
||||
```
|
||||
[ ] client/src/App.tsx: /plus → redirect para /soe
|
||||
[ ] client/src/pages/SOE.tsx: Config tab só para admins
|
||||
[ ] Manter proxy Plus funcionando (invisível)
|
||||
[ ] Testar: fluxo completo sem nenhum acesso direto ao Plus UI
|
||||
```
|
||||
|
||||
### Fase 4 — Regras Configuráveis pelo Usuário (1 semana)
|
||||
```
|
||||
[ ] UI na tab Config do SOE: "Regras de Negócio"
|
||||
[ ] CRUD de regras via /api/soe/rules
|
||||
[ ] Admin pode criar regras custom sem código
|
||||
[ ] Exemplo: "Pedidos acima de R$10.000 requerem aprovação manual"
|
||||
```
|
||||
|
||||
### Fase 5 — Financeiro Avançado (2 semanas)
|
||||
```
|
||||
[ ] Auto-boleto ao confirmar venda (Asaas API)
|
||||
[ ] Alertas de vencimento (Manus → WhatsApp)
|
||||
[ ] Conciliação bancária OFX semi-automática
|
||||
[ ] Previsão financeira (Motor BI :8004 + histórico soe_lancamentos)
|
||||
```
|
||||
|
||||
### Fase 6 — Contábil Avançado (2 semanas)
|
||||
```
|
||||
[ ] DRE automático mensal (100% alimentado pelo Event Bus)
|
||||
[ ] Balanço Patrimonial automático
|
||||
[ ] Fechamento contábil mensal
|
||||
[ ] SPED ECD/ECF alimentado por soe_lancamentos
|
||||
[ ] Exportação para escritório contábil (OFX, Excel, PDF)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. COMPARAÇÃO: PLANO v1 vs PLANO v2
|
||||
|
||||
| Aspecto | Plano v1 (antes do Replit .md) | Plano v2 (com conhecimento real) |
|
||||
|---|---|---|
|
||||
| Adaptadores Plus/ERPNext | "Precisam ser criados" | Já existem — não tocar |
|
||||
| SOE Routes | "Precisam ser criadas" | 60+ endpoints já existem |
|
||||
| SOE.tsx | "Precisa ser construída" | 2.414 linhas — já funciona |
|
||||
| Onde adicionar código | server/soe/ do zero | Middleware nas rotas existentes |
|
||||
| Esforço estimado | Alto (refatoração total) | Médio (adição cirúrgica) |
|
||||
| Risco de quebrar | Alto | Baixo (adição, não substituição) |
|
||||
| Banco Plus | Ignorado | MySQL — separado, nunca tocar direto |
|
||||
|
||||
---
|
||||
|
||||
## 9. PRINCÍPIO ORIENTADOR (REVISADO)
|
||||
|
||||
> O SOE já é o roteador certo. Agora vamos torná-lo o **árbitro de regras**.
|
||||
>
|
||||
> O padrão motor/adaptador está perfeito. O que falta é a **inteligência entre a rota e o adaptador**.
|
||||
>
|
||||
> Plus e ERPNext continuam existindo e funcionando — só que o usuário final não sabe.
|
||||
> Ele só vê a Central SOE. O restante é infraestrutura.
|
||||
|
||||
---
|
||||
|
||||
## 10. RESUMO EXECUTIVO — O que fazer agora
|
||||
|
||||
**1 ação que entrega valor imediato:**
|
||||
```
|
||||
Criar o SoeRuleEngine e plugar nas rotas de venda/NF-e.
|
||||
Resultado: Ao emitir uma NF-e, o CFOP correto é calculado
|
||||
automaticamente pelo SOE — sem o usuário saber que o Plus está
|
||||
sendo chamado por baixo.
|
||||
```
|
||||
|
||||
**1 ação que entrega valor a médio prazo:**
|
||||
```
|
||||
Criar o Event Bus com o handler de contabilidade.
|
||||
Resultado: Cada NF-e emitida gera lançamentos contábeis
|
||||
automaticamente. O DRE do mês é gerado sem entrada manual de dados.
|
||||
```
|
||||
|
||||
**1 ação que fecha o conceito:**
|
||||
```
|
||||
Redirecionar /plus → /soe.
|
||||
Resultado: O usuário não tem mais acesso ao Plus diretamente.
|
||||
Ele só vê o Arcádia. O Plus é infraestrutura invisível.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*PLANO_SOE_CENTRAL_v2.md — Arcádia Suite v3.0*
|
||||
*Gerado em: 2026-03-16 — Baseado na análise real do MAPA_SOE_ARCADIA.md (Replit)*
|
||||
Loading…
Reference in New Issue