Compare commits

...

6 Commits

Author SHA1 Message Date
Claude eb0926704a
feat: ERPNext em container Docker + SOE Rule Importer
- docker-compose.yml: adiciona serviços `erpnext` (frappe/erpnext:version-15)
  e `erpnext-db` (MariaDB 10.6) no perfil `erpnext`
  Volumes: erpnext_db, erpnext_sites, erpnext_logs
  Porta exposta: 8090 (→ :8080 interno)

- docker/erpnext/init.sh: script de inicialização que aguarda MariaDB,
  cria o site `erpnext.local` com bench new-site, instala app erpnext
  e sobe o servidor na porta 8080

- server/erpnext/proxy.ts: proxy transparente /erpnext-app/* → container
  (ativo em DOCKER_MODE=true ou ERPNEXT_PROXY_ENABLED=true)
  Reescreve Location headers, forward X-Forwarded-* e X-Site-Name
  Endpoint: GET /api/erpnext/container-status

- server/soe/erpnext-rule-importer.ts: importa regras de negócio do ERPNext
  (campos obrigatórios por DocType + Workflows ativos) e salva em soe_regras
  13 DocTypes mapeados para triggers SOE correspondentes
  Endpoint: POST /api/soe/erpnext/import-rules

- server/routes.ts: registra setupErpNextProxy e endpoint de import-rules
- .env.example: documenta variáveis para modo container e modo externo

Uso: docker compose --profile erpnext up

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

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

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
2026-03-16 18:45:22 +00:00
Claude 5a39fb66c4
docs: adiciona PLANO_BI_SUPERSET.md — migração Metabase → Apache Superset
Análise do código real revelou que:
- Superset já está no docker-compose.yml (profile: bi)
- MetaSet = Metabase com nome customizado (server/metaset/*)
- Falta apenas a integração gateway + guest token + componente React

Plano cobre:
- docker/superset/superset_config.py + init.sh
- server/superset/proxy.ts + routes.ts (guest token para embedding)
- SupersetDashboard.tsx (componente React reutilizável em qualquer tela)
- @superset-ui/embedded-sdk para embedding nativo
- Roadmap em 5 fases (1-2 dias de implementação real)

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
2026-03-16 18:24:21 +00:00
Claude 353af841dd
docs: adiciona PLANO_SOE_CENTRAL_v2.md — plano revisado baseado no Replit
Análise profunda do MAPA_SOE_ARCADIA.md revelou que o SOE já tem:
- SOE.tsx com 2.414 linhas funcionando
- 60+ endpoints /api/soe/* já existindo
- ArcadiaPlusClient e ArcadiaNextClient já implementados
- Padrão motor/adaptador já operacional

O plano v2 foca no que realmente falta:
- SOE Rule Engine (middleware entre rota e adaptador)
- Event Bus (lançamentos contábeis automáticos)
- Ocultação de Plus/ERPNext do usuário final
- Regras fiscais centralizadas no SOE

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
2026-03-16 18:09:00 +00:00
Claude 7a1fb61d15
docs: adiciona PLANO_SOE_CENTRAL.md — planejamento da Central SOE
Documenta a arquitetura e roadmap para transformar o Arcádia em um
SOE Central que orquestra Plus (Laravel) e ERPNext de forma invisível,
com regras fiscais, contábeis e financeiras centralizadas.

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
2026-03-16 17:11:27 +00:00
Claude 4900886d90
docs: guia de integração Ollama + LLMFit no servidor de produção
Cobre os dois cenários (Ollama no host vs. container), configuração
do LLMFit como TIER 1, variáveis de ambiente, ordem de inicialização
e troubleshooting.

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
2026-03-16 14:28:44 +00:00
27 changed files with 4626 additions and 46 deletions

View File

@ -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
@ -78,8 +84,20 @@ REDIS_URL=redis://localhost:6379
# ── Domínio (produção) ────────────────────────────────────────────────────────
DOMAIN=seudominio.com.br
# ── Integrações externas (opcional) ──────────────────────────────────────────
# ERPNext
# ── ERPNext ────────────────────────────────────────────────────────────────────
# Modo 1 — ERPNext em container Docker (perfil erpnext)
# Sobe com: docker compose --profile erpnext up
# Proxy ativado automaticamente em DOCKER_MODE=true
ERPNEXT_PROXY_ENABLED=false # true para forçar proxy mesmo fora do Docker
ERPNEXT_CONTAINER_HOST=erpnext # nome do serviço no docker-compose
ERPNEXT_CONTAINER_PORT=8080
ERPNEXT_DB_ROOT_PASSWORD=erpnext-root-2026 # senha root do MariaDB
ERPNEXT_DB_PASSWORD=frappe2026 # senha do usuário frappe
ERPNEXT_ADMIN_PASSWORD=admin2026 # senha do Administrator no site
# Modo 2 — ERPNext externo (instância existente)
# Em Docker: ERPNEXT_URL=http://erpnext:8080 (aponta pro container)
# Externo: ERPNEXT_URL=https://meuerpnext.com
ERPNEXT_URL=
ERPNEXT_API_KEY=
ERPNEXT_API_SECRET=

262
INTEGRACAO_IA.md Normal file
View File

@ -0,0 +1,262 @@
# Integração de IA — Ollama + LLMFit no Servidor
Guia para conectar o Arcádia Suite às IAs locais em produção.
---
## Visão geral do fluxo
```
Arcádia Suite (Manus, Agents, Embeddings)
│ http://litellm:4000/v1
LiteLLM (porta 4000) — gateway único
├──► LLMFit (seus modelos fine-tuned) [TIER 1 — prioridade]
└──► Ollama (modelos open source locais) [TIER 2 — padrão/fallback]
```
Nenhum serviço do Arcádia chama Ollama ou LLMFit diretamente.
Tudo passa pelo LiteLLM — que roteia, loga e faz fallback automaticamente.
---
## Cenário A: Ollama já instalado no servidor (fora do Docker)
### 1. Verificar se o Ollama responde
```bash
curl http://localhost:11434/api/tags
# Deve retornar a lista de modelos instalados
```
### 2. Configurar a variável no Coolify
```
OLLAMA_BASE_URL=http://host-gateway:11434
```
> `host-gateway` é o endereço do host dentro da rede Docker.
> Se não funcionar, use o IP da interface de rede do servidor (ex: `http://192.168.1.X:11434`).
### 3. Desativar o container Ollama (não precisa dos dois)
No Coolify, não suba o profile `ai` se não for usar o container Docker do Ollama:
```bash
# Sobe tudo EXCETO ollama e open-webui
docker compose -f docker-compose.prod.yml up -d
# Sobe só o LiteLLM (necessário sempre)
docker compose -f docker-compose.prod.yml --profile ai up litellm -d
```
### 4. Puxar os modelos necessários
```bash
# Modelos usados pelo Arcádia (mínimo recomendado)
ollama pull llama3.3 # agente principal (Manus)
ollama pull nomic-embed-text # embeddings semânticos
ollama pull qwen2.5-coder:7b # geração de código (DevCenter)
ollama pull deepseek-r1:7b # raciocínio complexo
```
---
## Cenário B: Ollama como container Docker
Use este cenário se o Ollama NÃO está instalado no servidor.
### 1. Configurar variável
```
OLLAMA_BASE_URL=http://ollama:11434
```
### 2. Subir com o profile ai
```bash
docker compose -f docker-compose.prod.yml --profile ai up -d
```
### 3. Puxar modelos após container subir
```bash
docker exec -it $(docker ps -qf "name=ollama") ollama pull llama3.3
docker exec -it $(docker ps -qf "name=ollama") ollama pull nomic-embed-text
docker exec -it $(docker ps -qf "name=ollama") ollama pull qwen2.5-coder:7b
docker exec -it $(docker ps -qf "name=ollama") ollama pull deepseek-r1:7b
```
---
## Configuração do LLMFit
### 1. Pré-requisito
O LLMFit deve expor uma API compatível com OpenAI (formato `/v1/chat/completions`).
Verifique se está respondendo:
```bash
curl http://IP_DO_LLMFIT:PORTA/v1/models
```
### 2. Configurar variável no Coolify
```
LLMFIT_BASE_URL=http://IP_DO_LLMFIT:PORTA
```
### 3. Ativar no LiteLLM config
Edite `docker/litellm-config.yaml` e **descomente** o bloco TIER 1:
```yaml
model_list:
# TIER 1 — LLMFit (fine-tuned, prioridade máxima)
- model_name: arcadia-finetuned
litellm_params:
model: openai/NOME_DO_SEU_MODELO # substitua pelo nome real
api_base: os.environ/LLMFIT_BASE_URL
api_key: llmfit-internal
# Modelo de embeddings fine-tuned (se disponível)
- model_name: arcadia-embed
litellm_params:
model: openai/NOME_DO_MODELO_EMBED
api_base: os.environ/LLMFIT_BASE_URL
api_key: llmfit-internal
```
### 4. Definir LLMFit como modelo padrão do Arcádia
No mesmo arquivo, atualize o `arcadia-default`:
```yaml
- model_name: arcadia-default
litellm_params:
model: openai/NOME_DO_SEU_MODELO
api_base: os.environ/LLMFIT_BASE_URL
api_key: llmfit-internal
model_info:
fallbacks: ["llama3.3"] # cai para Ollama se LLMFit falhar
```
### 5. Reiniciar o LiteLLM para aplicar
```bash
docker compose -f docker-compose.prod.yml restart litellm
```
---
## Variáveis de ambiente — resumo completo
Configure todas no Coolify antes do deploy:
```bash
# ── Banco ─────────────────────────────────────────────────────────────────────
PGPASSWORD= # senha forte, não use a padrão
DATABASE_URL=postgresql://arcadia:SENHA@db:5432/arcadia
# ── Segurança (gerar com: openssl rand -hex 32) ────────────────────────────────
SESSION_SECRET=
SSO_SECRET=
LITELLM_API_KEY=
WEBUI_SECRET_KEY=
SUPERSET_SECRET_KEY=
# ── Manus → LiteLLM (não alterar — já configurado) ───────────────────────────
AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1
AI_INTEGRATIONS_OPENAI_API_KEY=${LITELLM_API_KEY}
# ── Ollama ────────────────────────────────────────────────────────────────────
# Ollama no host: http://host-gateway:11434
# Ollama em container: http://ollama:11434
OLLAMA_BASE_URL=http://host-gateway:11434
# ── LLMFit (deixar vazio até estar disponível) ────────────────────────────────
LLMFIT_BASE_URL=
# ── Providers externos (deixar vazio para soberania total) ───────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GROQ_API_KEY=
# ── Domínio ───────────────────────────────────────────────────────────────────
DOMAIN=seudominio.com.br
```
---
## Verificar se a integração está funcionando
### 1. Testar LiteLLM direto
```bash
curl http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer SEU_LITELLM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"arcadia-default","messages":[{"role":"user","content":"Oi"}]}'
```
### 2. Testar Manus via interface
Acesse `https://seudominio.com.br` → abra o Manus → envie uma mensagem simples.
O Manus deve responder via Ollama (ou LLMFit se configurado).
### 3. Ver logs em tempo real
```bash
# Logs do LiteLLM (todas as chamadas de IA)
docker compose logs -f litellm
# Logs do app (Manus, erros, requests)
docker compose logs -f app
```
---
## Ordem de inicialização recomendada
```bash
# 1. Banco e Redis
docker compose -f docker-compose.prod.yml up -d db redis
# 2. Aguardar banco ficar saudável
docker compose -f docker-compose.prod.yml ps # esperar db = healthy
# 3. Migrations (primeira vez ou após atualização de schema)
docker compose -f docker-compose.prod.yml run --rm app npm run db:push
# 4. LiteLLM
docker compose -f docker-compose.prod.yml up -d litellm
# 5. App + microserviços Python
docker compose -f docker-compose.prod.yml up -d app contabil bi automation fisco embeddings
# 6. Ollama (se usando container)
docker compose -f docker-compose.prod.yml --profile ai up -d ollama
# Aguardar e puxar modelos:
docker exec $(docker ps -qf "name=ollama") ollama pull llama3.3
docker exec $(docker ps -qf "name=ollama") ollama pull nomic-embed-text
```
---
## Dúvidas frequentes
**O Manus não responde / trava**
→ Verifique se o LiteLLM está de pé: `docker compose logs litellm`
→ Verifique se o Ollama tem o modelo: `ollama list`
**Erro "model not found" no LiteLLM**
→ O modelo referenciado em `litellm-config.yaml` não foi baixado no Ollama.
→ Execute `ollama pull NOME_DO_MODELO`
**LLMFit não está sendo chamado**
→ Confirme que `LLMFIT_BASE_URL` está definido e o serviço está respondendo.
→ Reinicie o LiteLLM após alterar o config: `docker compose restart litellm`
**Ollama no host não é alcançado de dentro do Docker**
→ Tente `OLLAMA_BASE_URL=http://172.17.0.1:11434` (IP padrão do docker0)
→ Ou use o IP real da interface de rede: `ip addr show` para descobrir

787
PLANO_BI_SUPERSET.md Normal file
View File

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

733
PLANO_SOE_CENTRAL.md Normal file
View File

@ -0,0 +1,733 @@
# PLANO SOE CENTRAL — Arcádia Suite
## Orquestração Inteligente: Plus + ERPNext invisíveis, Central visível
### Versão 1.0 — Março 2026
---
## 1. ONDE ESTAMOS (Orientação Geral)
### O Projeto nasceu no Replit como…
- Uma plataforma de **escritório empresarial com IA central** (Manus Agent)
- Com proxy para o **Arcádia Plus** (Laravel/PHP, porta 8080) — ERP fiscal completo
- Com integração ao **ERPNext/Frappe** via API
- Stack principal: **React 18 + Express.js + PostgreSQL + FastAPI (Python)**
### O que JÁ existe e funciona:
| Componente | Status | Onde vive |
|---|---|---|
| Manus Agent (IA, 30+ tools) | ✅ Funcionando | `server/manus/` |
| Arcádia Plus (Laravel ERP) | ✅ Funcionando | `plus/` → porta 8080 |
| SSO Plus ↔ Suite | ✅ Funcionando | `server/plus/` |
| Motor Fiscal (nfelib) | ✅ Funcionando | `server/python/` → porta 8002 |
| Motor Contábil | ✅ Funcionando | `server/python/` → porta 8003 |
| Motor BI | ✅ Funcionando | `server/python/` → porta 8004 |
| Motor Automação | ✅ Funcionando | `server/python/` → porta 8005 |
| Motor Comunicação (Comm) | ✅ Funcionando | porta 8006 |
| Dev Center XOS (6 agentes) | ✅ Funcionando | `server/blackboard/` |
| LiteLLM Gateway | ✅ Configurado | porta 4000 |
| CRM, WhatsApp, Chat | ✅ Funcionando | `server/crm/`, `server/whatsapp/` |
| ERPNext integração | ⚠️ Parcial | `server/erp/routes.ts` |
### O que FALTA para o plano atual:
| Componente | Status |
|---|---|
| **SOE Central (Regras Unificadas)** | ❌ Não existe — este é o plano |
| Fiscal rules centralizadas (acima do Plus) | ❌ Dispersas no Plus |
| ERPNext rules centralizadas | ❌ Não existe camada de abstração |
| Contábil Avançado (lançamentos automáticos) | ❌ Motor existe, regras não |
| Financeiro Avançado (previsão, DRE automático) | ❌ Motor existe, regras não |
| Testes automatizados / CI-CD | ❌ |
| Monitoramento (APM, Sentry) | ❌ |
---
## 2. O PROBLEMA QUE VAMOS RESOLVER
### Situação Atual (problemática)
```
Usuário → Arcádia Suite
├── /plus → acessa Laravel diretamente (usuário VÊ o Plus)
├── /erp → acessa ERPNext diretamente (usuário VÊ o ERPNext)
└── Regras fiscais ficam dentro do Plus
Regras contábeis ficam no Motor 8003
Regras ERP ficam no ERPNext
→ Fragmentado, sem governança central
```
### Situação Desejada (SOE Central)
```
Usuário → Arcádia Suite (SOE)
▼ [Central de Regras SOE]
├── Fiscal: regras unificadas (CFOP, NCM, tributação, NF-e, CT-e...)
├── ERP: regras de negócio (pedidos, estoque, compras...)
├── Contábil: regras de lançamentos, plano de contas, DRE...
└── Financeiro: regras de fluxo, provisões, conciliação...
▼ [Execução invisível]
├── Plus → emite documentos, registra no banco
└── ERPNext → executa operações, registra no banco
O usuário NUNCA VÊ o Plus nem o ERPNext.
Só vê a Central SOE do Arcádia.
```
---
## 3. ARQUITETURA SOE CENTRAL
### 3.1 Diagrama Completo
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ARCÁDIA SUITE (Frontend React) │
│ /financeiro /contabil /fisco /erp /people /production /bi │
│ Usuário nunca acessa /plus ou ERPNext diretamente │
└───────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ SOE — SISTEMA OPERACIONAL EMPRESARIAL (Nova Camada) │
│ Express.js / porta 5000 / server/soe/ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ CENTRAL FISCAL │ │ CENTRAL ERP │ │
│ │ server/soe/fiscal/ │ │ server/soe/erp/ │ │
│ │ │ │ │ │
│ │ • Regras NCM/CFOP │ │ • Regras de pedidos │ │
│ │ • Políticas tribut. │ │ • Regras de estoque │ │
│ │ • Emissão NF-e/NFC-e │ │ • Regras de compras │ │
│ │ • Cancelamento │ │ • Regras de pessoas │ │
│ │ • SPED/SINTEGRA │ │ • Integrações ext. │ │
│ └──────────┬───────────┘ └──────────┬─────────────┘ │
│ │ │ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ CENTRAL CONTÁBIL │ │ CENTRAL FINANCEIRO │ │
│ │ server/soe/contabil/│ │ server/soe/financ/ │ │
│ │ │ │ │ │
│ │ • Plano de contas │ │ • Fluxo de caixa │ │
│ │ • Regras de lanç. │ │ • Contas a P/R │ │
│ │ • DRE automático │ │ • Conciliação │ │
│ │ • Balancete │ │ • Previsão (IA) │ │
│ │ • Fechamento │ │ • Impostos │ │
│ └──────────┬───────────┘ └──────────┬─────────────┘ │
│ │ │ │
│ └────────────┬─────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ SOE RULE ENGINE (Núcleo) │ │
│ │ server/soe/rule-engine.ts │ │
│ │ │ │
│ │ • Resolução de conflitos │ │
│ │ • Aplicação de políticas XOS │ │
│ │ • Audit trail de todas regras │ │
│ │ • Manus consulta este engine │ │
│ └─────────────────┬───────────────┘ │
└────────────────────────────┼────────────────────────────────────────────┘
┌──────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ARCÁDIA PLUS │ │ ERPNEXT │ │ MOTORES PYTHON │
│ Laravel :8080 │ │ Frappe : │ │ 8002 Fiscal │
│ (invisível) │ │ (invisível) │ │ 8003 Contábil │
│ │ │ │ │ 8004 BI │
│ Emite docs │ │ Executa ERP │ │ 8005 Automação │
│ fiscais │ │ operacional │ │ │
└─────────────────┘ └──────────────┘ └──────────────────┘
```
---
## 4. CENTRAL FISCAL — Detalhamento
### 4.1 O que ela resolve
O Plus tem **376 controllers** e **1.546 rotas** — tudo fiscal/operacional. A Central Fiscal **não substitui** o Plus, ela **orquestra** o Plus com regras definidas no SOE.
### 4.2 Regras que vão para a Central (tiradas do Plus)
```typescript
// server/soe/fiscal/rules.ts — Exemplo de estrutura
interface FiscalRule {
id: string
nome: string
trigger: 'pre_emissao' | 'pos_emissao' | 'cancelamento' | 'validacao'
condicao: FiscalCondition
acao: FiscalAction
prioridade: number
ativo: boolean
}
// Exemplos de regras:
const REGRAS_FISCAIS: FiscalRule[] = [
{
id: 'NFE_CFOP_INTERESTADUAL',
nome: 'Ajusta CFOP para operação interestadual',
trigger: 'pre_emissao',
condicao: { uf_emitente: '!=', uf_destinatario: true },
acao: { ajustar_cfop: 'interestadual' },
prioridade: 1,
ativo: true
},
{
id: 'NFE_SIMPLES_NACIONAL',
nome: 'Aplica tributação Simples Nacional',
trigger: 'pre_emissao',
condicao: { regime_tributario: 'simples_nacional' },
acao: { aplicar_cst: 'tabela_simples', remover_pis_cofins: true },
prioridade: 2,
ativo: true
},
{
id: 'NFE_VALIDACAO_NCM',
nome: 'Valida NCM obrigatório para NF-e',
trigger: 'validacao',
condicao: { tipo_documento: 'nfe' },
acao: { validar_campo: 'ncm', obrigatorio: true },
prioridade: 1,
ativo: true
}
]
```
### 4.3 Fluxo de Emissão via SOE Central
```
Usuário clica "Emitir NF-e" no Arcádia
POST /api/soe/fiscal/emitir-nfe
SOE Rule Engine: aplica REGRAS_FISCAIS (pre_emissao)
• Verifica regime tributário
• Aplica CFOP correto
• Calcula impostos (ICMS, PIS, COFINS, IPI)
• Valida NCM obrigatório
• Adiciona dados da empresa emitente
SOE envia para Plus via API interna (invisível)
POST http://plus:8080/api/nfe/emitir
+ token SSO automático
Plus emite via Cloud-DFE → SEFAZ
SOE recebe retorno
• Registra no banco SOE (tabela soe_fiscal_eventos)
• Dispara lançamento contábil automático
• Notifica via WebSocket o frontend
Usuário vê resultado no Arcádia (nunca soube do Plus)
```
### 4.4 Tabelas SOE para Fiscal
```sql
-- Tabelas a criar no schema Arcádia (PostgreSQL)
CREATE TABLE soe_fiscal_regras (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome VARCHAR(200) NOT NULL,
trigger VARCHAR(50) NOT NULL,
condicao JSONB NOT NULL,
acao JSONB NOT NULL,
prioridade INTEGER DEFAULT 10,
ativo BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE soe_fiscal_eventos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tipo VARCHAR(50) NOT NULL, -- 'emissao_nfe', 'cancelamento', etc.
empresa_id INTEGER NOT NULL,
documento_chave VARCHAR(100),
regras_aplicadas JSONB, -- quais regras foram aplicadas
payload_enviado JSONB, -- o que foi enviado ao Plus
payload_retorno JSONB, -- o que o Plus/SEFAZ respondeu
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE soe_fiscal_configuracoes (
empresa_id INTEGER PRIMARY KEY,
regime_tributario VARCHAR(50), -- simples, lucro_presumido, lucro_real
uf VARCHAR(2),
cnpj VARCHAR(18),
certificado_tipo VARCHAR(20),
aliquotas JSONB, -- alíquotas padrão por operação
cfops_padrao JSONB, -- CFOPs padrão por tipo de operação
created_at TIMESTAMP DEFAULT NOW()
);
```
---
## 5. CENTRAL ERP (ERPNext/Frappe) — Detalhamento
### 5.1 O que ela resolve
O ERPNext tem **APIs complexas**. A Central ERP abstrai tudo isso em operações simples que o Arcádia entende.
### 5.2 Adapter Layer ERPNext
```typescript
// server/soe/erp/frappe-adapter.ts
interface ERPAdapter {
// Produtos
criarProduto(data: ProdutoSOE): Promise<ERPProduto>
buscarProduto(codigo: string): Promise<ProdutoSOE>
atualizarEstoque(movimentacao: MovimentacaoEstoque): Promise<void>
// Pedidos
criarPedidoVenda(pedido: PedidoSOE): Promise<ERPPedido>
aprovarPedido(id: string): Promise<void>
faturarPedido(id: string): Promise<ERPFatura>
// Financeiro
criarLancamento(lancamento: LancamentoSOE): Promise<ERPLancamento>
buscarPlanoContas(): Promise<ContaContabil[]>
// Compras
criarPedidoCompra(data: PedidoCompraSOE): Promise<ERPPedidoCompra>
receberMercadoria(data: RecebimentoSOE): Promise<void>
}
// As regras ficam no SOE, o adapter só traduz
class FrappeAdapter implements ERPAdapter {
private baseUrl: string
private apiKey: string
async criarPedidoVenda(pedido: PedidoSOE): Promise<ERPPedido> {
// Traduz do modelo SOE para o modelo Frappe
const frappePedido = this.traduzirParaFrappe(pedido)
// Envia para ERPNext (invisível para o usuário)
const response = await fetch(`${this.baseUrl}/api/resource/Sales Order`, {
method: 'POST',
headers: { 'Authorization': `token ${this.apiKey}` },
body: JSON.stringify(frappePedido)
})
// Traduz retorno de volta para modelo SOE
return this.traduzirDeVoltaSOE(await response.json())
}
}
```
### 5.3 Regras de Negócio ERP no SOE
```typescript
// server/soe/erp/rules.ts
const REGRAS_ERP = [
{
id: 'PEDIDO_APROVACAO_AUTOMATICA',
nome: 'Aprovação automática até R$ 5.000',
trigger: 'pre_aprovacao_pedido',
condicao: { valor_total: { menor_que: 5000 } },
acao: { aprovar_automaticamente: true }
},
{
id: 'ESTOQUE_RESERVA_VENDA',
nome: 'Reservar estoque ao confirmar venda',
trigger: 'pos_confirmacao_venda',
condicao: { status: 'confirmado' },
acao: { reservar_estoque: true, gerar_separacao: true }
},
{
id: 'NF_AUTOMATICA_FATURAMENTO',
nome: 'Emite NF-e automaticamente ao faturar',
trigger: 'pos_faturamento',
condicao: { tipo_cliente: ['pj', 'pf_contribuinte'] },
acao: { emitir_nfe: true, via_soe_fiscal: true }
}
]
```
---
## 6. CENTRAL CONTÁBIL AVANÇADA
### 6.1 Motor Atual (porta 8003) + Regras SOE
O Motor Contábil Python já faz DRE e Balancete. O que falta é **alimentação automática**.
### 6.2 Lançamentos Automáticos (grande salto)
```typescript
// server/soe/contabil/auto-lancamentos.ts
interface RegrasLancamentoAuto {
gatilho: string
debito: string // Conta contábil
credito: string // Conta contábil
historico: string
formula: string // Como calcular o valor
}
const LANCAMENTOS_AUTOMATICOS: RegrasLancamentoAuto[] = [
// Quando emite NF-e de venda
{
gatilho: 'emissao_nfe_venda',
debito: '1.1.3.01', // Clientes
credito: '3.1.1.01', // Receita Bruta de Vendas
historico: 'Venda conforme NF-e {numero}',
formula: 'valor_total_nota'
},
{
gatilho: 'emissao_nfe_venda',
debito: '3.1.2.01', // Dedução de Receita (ICMS)
credito: '2.1.2.01', // ICMS a Recolher
historico: 'ICMS s/ Venda NF-e {numero}',
formula: 'valor_icms'
},
// Quando paga conta
{
gatilho: 'pagamento_conta',
debito: '2.1.1.{categoria}', // Conta a Pagar específica
credito: '1.1.1.01', // Banco/Caixa
historico: 'Pagamento: {descricao}',
formula: 'valor_pago'
},
// Quando recebe pagamento
{
gatilho: 'recebimento',
debito: '1.1.1.01', // Banco/Caixa
credito: '1.1.3.01', // Clientes
historico: 'Recebimento: {descricao}',
formula: 'valor_recebido'
},
// Compra de mercadoria
{
gatilho: 'entrada_nfe_compra',
debito: '1.1.4.01', // Estoque
credito: '2.1.1.01', // Fornecedores
historico: 'Compra conforme NF-e {numero} / {fornecedor}',
formula: 'valor_mercadorias'
}
]
```
### 6.3 DRE Automático via SOE
```
Usuário pede DRE do mês
SOE Contábil consulta:
1. Todos lançamentos do período (tabela soe_lancamentos)
2. Agrupa por conta contábil
3. Aplica estrutura DRE configurada
4. Envia para Motor Python 8003 para cálculos
5. Retorna DRE formatado
Usuário vê DRE no /contabil do Arcádia
(Nunca soube que teve lançamentos automáticos de NF-e, pagamentos, etc.)
```
---
## 7. CENTRAL FINANCEIRO AVANÇADO
### 7.1 Regras Financeiras no SOE
```typescript
// server/soe/financeiro/rules.ts
const REGRAS_FINANCEIRO = [
{
id: 'BOLETO_AUTO_VENCIMENTO',
nome: 'Gerar boleto automático para vendas a prazo',
trigger: 'pos_confirmacao_venda_prazo',
acao: { gerar_boleto: true, via: 'asaas', vencimento: '+{prazo}_dias' }
},
{
id: 'ALERTA_VENCIMENTO_3DIAS',
nome: 'Alerta de vencimento 3 dias antes',
trigger: 'cron_diario',
condicao: { vencimento_em: 3 },
acao: { notificar: ['whatsapp', 'email'], via_manus: true }
},
{
id: 'PROVISAO_IMPOSTOS',
nome: 'Provisão mensal de impostos',
trigger: 'cron_mensal',
acao: { calcular_provisao: ['icms', 'pis', 'cofins', 'csll', 'irpj'], registrar_lancamento: true }
},
{
id: 'CONCILIACAO_AUTO',
nome: 'Conciliação bancária automática via OFX',
trigger: 'importacao_ofx',
acao: { tentar_conciliar: true, confianca_minima: 0.85, pendentes_para_revisao: true }
}
]
```
---
## 8. MANUS AGENT + SOE CENTRAL
### 8.1 O Manus consulta a Central
```
Pergunta: "Qual foi o resultado do mês passado?"
Manus → SOE Central
• GET /api/soe/contabil/dre?periodo=2026-02
• GET /api/soe/financeiro/fluxo?periodo=2026-02
• GET /api/soe/fiscal/resumo?periodo=2026-02
SOE agrega dados dos motores (Python) + Plus + ERPNext
Manus recebe contexto completo e responde:
"Faturamento: R$ 120.000
Deduções (impostos): R$ 18.000
Receita Líquida: R$ 102.000
Custo das mercadorias: R$ 65.000
Lucro Bruto: R$ 37.000
..."
```
### 8.2 Tools do Manus para o SOE
```typescript
// Novas tools do Manus que consultam o SOE Central
{
name: 'consultar_situacao_fiscal',
description: 'Consulta situação fiscal da empresa (NFs pendentes, impostos, SPED)',
endpoint: 'GET /api/soe/fiscal/situacao'
},
{
name: 'emitir_documento_fiscal',
description: 'Emite NF-e, NFC-e, CT-e ou MDF-e',
endpoint: 'POST /api/soe/fiscal/emitir'
},
{
name: 'consultar_dre',
description: 'Gera DRE de qualquer período',
endpoint: 'GET /api/soe/contabil/dre'
},
{
name: 'consultar_fluxo_caixa',
description: 'Consulta fluxo de caixa e previsões',
endpoint: 'GET /api/soe/financeiro/fluxo'
},
{
name: 'registrar_lancamento_contabil',
description: 'Registra lançamento contábil manualmente',
endpoint: 'POST /api/soe/contabil/lancamento'
}
```
---
## 9. ESTRUTURA DE PASTAS A CRIAR
```
server/
└── soe/ ← Nova pasta (Central SOE)
├── index.ts ← Router principal do SOE
├── rule-engine.ts ← Motor de regras central
├── fiscal/
│ ├── routes.ts ← GET/POST /api/soe/fiscal/*
│ ├── rules.ts ← Regras fiscais (CFOP, NCM, tributação)
│ ├── emissao.ts ← Orquestração de emissão (→ Plus)
│ ├── plus-adapter.ts ← Adaptador para o Plus (invisível)
│ └── sefaz-adapter.ts ← Comunicação direta SEFAZ (via Python 8002)
├── erp/
│ ├── routes.ts ← GET/POST /api/soe/erp/*
│ ├── rules.ts ← Regras de negócio ERP
│ ├── frappe-adapter.ts ← Adaptador ERPNext (invisível)
│ └── plus-erp-adapter.ts ← Adaptador Plus-ERP (para dados de venda)
├── contabil/
│ ├── routes.ts ← GET/POST /api/soe/contabil/*
│ ├── rules.ts ← Regras contábeis
│ ├── auto-lancamentos.ts ← Lançamentos automáticos
│ ├── plano-contas.ts ← Plano de contas SOE
│ └── python-adapter.ts ← Adaptador Motor Python 8003
├── financeiro/
│ ├── routes.ts ← GET/POST /api/soe/financeiro/*
│ ├── rules.ts ← Regras financeiras
│ ├── previsao.ts ← Previsão com IA
│ └── conciliacao.ts ← Conciliação bancária
└── shared/
├── types.ts ← Tipos SOE compartilhados
├── audit.ts ← Registro de todas as operações
└── event-bus.ts ← Eventos entre Centrais
```
---
## 10. TABELAS DO BANCO A CRIAR (Drizzle ORM)
```typescript
// shared/schema.ts — Adicionar:
// Configuração da empresa no SOE
export const soeEmpresaConfig = pgTable('soe_empresa_config', {
id: serial('id').primaryKey(),
empresaId: integer('empresa_id').notNull(),
regimeTributario: varchar('regime_tributario', { length: 50 }), // simples, presumido, real
uf: varchar('uf', { length: 2 }),
cnpj: varchar('cnpj', { length: 18 }),
planoContasId: integer('plano_contas_id'),
configFiscal: jsonb('config_fiscal'), // alíquotas, CFOPs padrão
configErp: jsonb('config_erp'), // qual ERP: plus, erpnext, ambos
createdAt: timestamp('created_at').defaultNow(),
})
// Regras configuráveis (editáveis pelo usuário)
export const soeRegras = pgTable('soe_regras', {
id: uuid('id').primaryKey().defaultRandom(),
empresaId: integer('empresa_id'), // null = regra global
dominio: varchar('dominio', { length: 50 }).notNull(), // 'fiscal', 'erp', 'contabil', 'financeiro'
nome: varchar('nome', { length: 200 }).notNull(),
trigger: varchar('trigger', { length: 100 }).notNull(),
condicao: jsonb('condicao').notNull(),
acao: jsonb('acao').notNull(),
prioridade: integer('prioridade').default(10),
ativo: boolean('ativo').default(true),
createdAt: timestamp('created_at').defaultNow(),
})
// Log de todas as operações SOE (audit trail)
export const soeEventos = pgTable('soe_eventos', {
id: uuid('id').primaryKey().defaultRandom(),
empresaId: integer('empresa_id').notNull(),
dominio: varchar('dominio', { length: 50 }).notNull(),
tipo: varchar('tipo', { length: 100 }).notNull(),
regraIds: jsonb('regra_ids'), // regras aplicadas
payloadEntrada: jsonb('payload_entrada'),
payloadSaida: jsonb('payload_saida'),
erp: varchar('erp', { length: 50 }), // 'plus', 'erpnext', 'python_8003', etc.
status: varchar('status', { length: 50 }).notNull(),
duracao: integer('duracao'), // ms
createdAt: timestamp('created_at').defaultNow(),
})
// Lançamentos contábeis centralizados
export const soeLancamentos = pgTable('soe_lancamentos', {
id: uuid('id').primaryKey().defaultRandom(),
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(),
origem: varchar('origem', { length: 100 }), // 'emissao_nfe', 'pagamento', etc.
origemId: varchar('origem_id', { length: 100 }),
period: varchar('period', { length: 7 }), // '2026-03'
createdAt: timestamp('created_at').defaultNow(),
})
```
---
## 11. ROADMAP DE IMPLEMENTAÇÃO
### Fase 1 — Fundação (23 semanas)
```
[ ] 1. Criar server/soe/ com estrutura básica
[ ] 2. Criar tabelas: soe_empresa_config, soe_regras, soe_eventos, soe_lancamentos
[ ] 3. Implementar Plus Adapter (encapsula chamadas ao Laravel)
[ ] 4. Implementar Frappe Adapter (encapsula chamadas ao ERPNext)
[ ] 5. Endpoint GET /api/soe/status (health check de todos os motores)
[ ] 6. Migrar /api/fisco/* para passar pelo SOE
```
### Fase 2 — Central Fiscal (23 semanas)
```
[ ] 7. Implementar Rule Engine para fiscal
[ ] 8. Criar regras fiscais base (NCM, CFOP, tributação por regime)
[ ] 9. POST /api/soe/fiscal/emitir-nfe → Plus (invisível)
[ ] 10. POST /api/soe/fiscal/cancelar-nfe → Plus (invisível)
[ ] 11. GET /api/soe/fiscal/situacao → agrega Plus + Python 8002
[ ] 12. Atualizar frontend /fisco para usar /api/soe/fiscal/*
```
### Fase 3 — Central Contábil (2 semanas)
```
[ ] 13. Implementar auto-lancamentos para eventos fiscais
[ ] 14. Integrar Motor Python 8003 via SOE
[ ] 15. GET /api/soe/contabil/dre → resultado automático
[ ] 16. GET /api/soe/contabil/balancete → atualizado em tempo real
[ ] 17. Atualizar frontend /contabil para usar /api/soe/contabil/*
```
### Fase 4 — Central Financeiro (2 semanas)
```
[ ] 18. Regras de boleto automático (via Asaas)
[ ] 19. Alertas de vencimento (via Manus/WhatsApp)
[ ] 20. Conciliação bancária semi-automática
[ ] 21. Previsão financeira com IA (Motor Python 8003 + Manus)
[ ] 22. Atualizar frontend /financeiro para usar /api/soe/financeiro/*
```
### Fase 5 — Manus + SOE (1 semana)
```
[ ] 23. Registrar novas tools Manus apontando para SOE
[ ] 24. Manus usa SOE para responder perguntas de negócio
[ ] 25. Dashboard SOE: visão geral de todos os eventos
```
### Fase 6 — Central ERP (3 semanas)
```
[ ] 26. Frappe Adapter completo (produtos, pedidos, estoque, compras)
[ ] 27. Regras de negócio ERP no SOE
[ ] 28. Sincronização bidirecional SOE ↔ ERPNext (via eventos)
[ ] 29. Atualizar /erp para usar /api/soe/erp/*
```
---
## 12. RESULTADO FINAL — O QUE O USUÁRIO EXPERIMENTA
```
Antes (fragmentado):
Usuário → /plus → vê Laravel diretamente
Usuário → /erp → vê ERPNext diretamente
Regras fiscais só no Plus
Contabilidade manual
Financeiro sem automação
Depois (SOE Central):
Usuário → /fisco → emite NF-e, Plus executa invisível
Usuário → /contabil → vê DRE gerado automaticamente
Usuário → /financeiro → vê fluxo, previsões, alertas automáticos
Usuário → Manus → pergunta "qual meu resultado?" → resposta completa
Plus e ERPNext: INVISÍVEIS, executando em background
Regras: CENTRALIZADAS no SOE, editáveis
Lançamentos: AUTOMÁTICOS baseados em eventos (NF-e, pagamentos, etc.)
Auditoria: TOTAL de todas as operações
```
---
## 13. PRINCÍPIO ORIENTADOR
> **"O usuário não sabe o que está por trás. Ele só sabe que funciona."**
>
> O Arcádia Suite é o **Sistema Operacional** da empresa.
> O Plus e o ERPNext são **drivers de hardware** — poderosos, mas invisíveis.
> O SOE Central é o **kernel** que os orquestra com regras e inteligência.
---
*PLANO_SOE_CENTRAL.md — Arcádia Suite v3.0*
*Gerado em: 2026-03-16*

666
PLANO_SOE_CENTRAL_v2.md Normal file
View File

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

View File

@ -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() {
<ProtectedRoute path="/engineering" component={EngineeringHub} />
<ProtectedRoute path="/development" component={DevelopmentModule} />
<ProtectedRoute path="/retail" component={ArcadiaRetail} />
<ProtectedRoute path="/plus" component={Plus} />
<ProtectedRoute path="/plus" component={() => { const [, nav] = useLocation(); useEffect(() => nav("/soe"), []); return null; }} />
<ProtectedRoute path="/super-admin" component={SuperAdmin} />
<ProtectedRoute path="/marketplace" component={Marketplace} />
<ProtectedRoute path="/lms" component={LMS} />

View File

@ -0,0 +1,160 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Loader2, AlertTriangle, ExternalLink, RefreshCw } from "lucide-react";
interface SupersetDashboardProps {
dashboardId: string;
height?: string | number;
className?: string;
showOpenLink?: boolean;
}
/**
* Componente reutilizável para embedar dashboards do Apache Superset (Arcádia Insights)
* em QUALQUER tela do Arcádia Suite via Guest Token JWT.
*
* Uso:
* <SupersetDashboard dashboardId="financial-overview" />
* <SupersetDashboard dashboardId="dre-mensal" height={400} />
*/
export function SupersetDashboard({
dashboardId,
height = "calc(100vh - 320px)",
className = "",
showOpenLink = true,
}: SupersetDashboardProps) {
const containerRef = useRef<HTMLDivElement>(null);
const sdkRef = useRef<any>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading");
const [errorMsg, setErrorMsg] = useState<string>("");
const fetchGuestToken = useCallback(async (): Promise<string> => {
const resp = await fetch("/api/superset/guest-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dashboardId }),
credentials: "include",
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: "Erro desconhecido" }));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const { token } = await resp.json();
return token;
}, [dashboardId]);
const loadDashboard = useCallback(async () => {
if (!containerRef.current) return;
setStatus("loading");
setErrorMsg("");
try {
// Verifica se Superset está disponível
const health = await fetch("/api/superset/health", { credentials: "include" });
const healthData = await health.json();
if (!healthData.online) {
setStatus("unavailable");
return;
}
// Importa SDK dinamicamente (só quando necessário)
const { embedDashboard } = await import("@superset-ui/embedded-sdk");
// Limpa embedding anterior se houver
if (sdkRef.current) {
containerRef.current.innerHTML = "";
}
sdkRef.current = await embedDashboard({
id: dashboardId,
supersetDomain: window.location.origin + "/superset",
mountPoint: containerRef.current,
fetchGuestToken,
dashboardUiConfig: {
hideTitle: true,
hideChartControls: false,
filters: { visible: true, expanded: false },
},
});
setStatus("ready");
} catch (err: any) {
console.error("[SupersetDashboard] Erro:", err.message);
// Se SDK não estiver instalado, usa iframe como fallback
if (err.message?.includes("Cannot find module") || err.message?.includes("embedDashboard")) {
setStatus("unavailable");
} else {
setErrorMsg(err.message);
setStatus("error");
}
}
}, [dashboardId, fetchGuestToken]);
useEffect(() => {
loadDashboard();
return () => {
if (containerRef.current) containerRef.current.innerHTML = "";
};
}, [loadDashboard]);
const heightStyle = typeof height === "number" ? `${height}px` : height;
if (status === "unavailable") {
return (
<div className={`rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm ${className}`} style={{ height: heightStyle }}>
<iframe
src="/superset/login"
className="w-full h-full border-0"
title="Arcádia Insights — Apache Superset"
allow="fullscreen"
/>
</div>
);
}
if (status === "error") {
return (
<div className={`flex flex-col items-center justify-center gap-4 rounded-xl border border-red-100 bg-red-50 ${className}`} style={{ height: heightStyle }}>
<AlertTriangle className="w-8 h-8 text-red-400" />
<div className="text-center">
<p className="font-medium text-red-700 text-sm">Erro ao carregar dashboard</p>
<p className="text-red-500 text-xs mt-1">{errorMsg}</p>
</div>
<button
onClick={loadDashboard}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition-colors"
>
<RefreshCw className="w-4 h-4" /> Tentar novamente
</button>
</div>
);
}
return (
<div className={`relative ${className}`} style={{ height: heightStyle }}>
{status === "loading" && (
<div className="absolute inset-0 flex items-center justify-center bg-white/90 z-10 rounded-xl">
<div className="text-center">
<Loader2 className="w-8 h-8 text-[#c89b3c] animate-spin mx-auto mb-3" />
<p className="text-sm text-[#1f334d] font-medium">Carregando Arcádia Insights...</p>
</div>
</div>
)}
{showOpenLink && status === "ready" && (
<a
href="/superset"
target="_blank"
rel="noopener noreferrer"
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
>
<ExternalLink className="w-3 h-3" /> Abrir completo
</a>
)}
<div
ref={containerRef}
className="w-full h-full rounded-xl overflow-hidden border border-[#c89b3c]/20"
/>
</div>
);
}

View File

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

View File

@ -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) ────────────────────────────────
@ -227,7 +247,68 @@ services:
- "4000:4000"
profiles: [ai]
# ─────────────── PERFIL: erpnext (ERPNext local) ──────────────────────────────
# ── MariaDB para ERPNext ─────────────────────────────────────────────────────
erpnext-db:
image: mariadb:10.6
restart: unless-stopped
profiles: [erpnext]
environment:
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}
MYSQL_DATABASE: _frappe
MYSQL_USER: frappe
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD:-frappe2026}
volumes:
- erpnext_db:/var/lib/mysql
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--skip-character-set-client-handshake
--skip-innodb-read-only-compressed
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# ── ERPNext (Frappe Framework + ERPNext app) ─────────────────────────────────
erpnext:
image: frappe/erpnext:version-15
restart: unless-stopped
profiles: [erpnext]
environment:
FRAPPE_SITE_NAME_HEADER: erpnext.local
BACKEND_PORT: 8000
volumes:
- erpnext_sites:/home/frappe/frappe-bench/sites
- erpnext_logs:/home/frappe/frappe-bench/logs
- ./docker/erpnext/init.sh:/usr/local/bin/init-erpnext.sh:ro
depends_on:
erpnext-db:
condition: service_healthy
ports:
- "8090:8080"
command: ["/bin/bash", "/usr/local/bin/init-erpnext.sh"]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/method/frappe.ping || exit 1"]
interval: 30s
timeout: 10s
retries: 10
start_period: 120s
networks:
- arcadia
volumes:
pgdata:
ollama_models:
open_webui_data:
superset_home:
erpnext_db:
erpnext_sites:
erpnext_logs:
networks:
arcadia:
driver: bridge

56
docker/erpnext/init.sh Normal file
View File

@ -0,0 +1,56 @@
#!/bin/bash
# ─── ERPNext init — cria site e instala app se necessário ─────────────────────
# Executado como entrypoint do container frappe/erpnext:version-15
set -e
SITE_NAME="${FRAPPE_SITE_NAME:-erpnext.local}"
DB_HOST="${DB_HOST:-erpnext-db}"
DB_ROOT_PASSWORD="${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}"
ADMIN_PASSWORD="${ERPNEXT_ADMIN_PASSWORD:-admin2026}"
BENCH_DIR="/home/frappe/frappe-bench"
echo "[ERPNext Init] Aguardando MariaDB em ${DB_HOST}..."
until mysqladmin ping -h "${DB_HOST}" -u root --password="${DB_ROOT_PASSWORD}" --silent 2>/dev/null; do
echo "[ERPNext Init] MariaDB ainda não disponível — aguardando 3s..."
sleep 3
done
echo "[ERPNext Init] MariaDB disponível."
cd "${BENCH_DIR}"
SITES_DIR="${BENCH_DIR}/sites"
SITE_DIR="${SITES_DIR}/${SITE_NAME}"
if [ ! -f "${SITE_DIR}/site_config.json" ]; then
echo "[ERPNext Init] Criando site ${SITE_NAME}..."
bench new-site "${SITE_NAME}" \
--mariadb-root-password "${DB_ROOT_PASSWORD}" \
--admin-password "${ADMIN_PASSWORD}" \
--db-host "${DB_HOST}" \
--no-mariadb-socket \
--verbose || true
echo "[ERPNext Init] Instalando app erpnext..."
bench --site "${SITE_NAME}" install-app erpnext || true
echo "[ERPNext Init] Ativando modo developer..."
bench --site "${SITE_NAME}" set-config developer_mode 1 || true
echo "[ERPNext Init] Desativando SSL para env local..."
bench --site "${SITE_NAME}" set-config ssl_certificate "" || true
echo "[ERPNext Init] Site criado com sucesso."
else
echo "[ERPNext Init] Site ${SITE_NAME} já existe — pulando criação."
fi
# Configura common_site_config.json para servir na porta 8080
if [ ! -f "${SITES_DIR}/common_site_config.json" ]; then
echo '{"serve_default_site": true, "socketio_port": 9000, "background_workers": 1}' \
> "${SITES_DIR}/common_site_config.json"
fi
echo "[ERPNext Init] Iniciando servidor Frappe na porta 8080..."
exec bench serve --port 8080 --site "${SITE_NAME}"

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

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

View File

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

View File

@ -43,6 +43,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@superset-ui/embedded-sdk": "^0.3.0",
"@tanstack/react-query": "^5.60.5",
"@tiptap/extension-image": "^3.15.3",
"@tiptap/extension-link": "^3.15.3",

View File

@ -4,6 +4,8 @@ import { db } from "../../db/index";
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable } from "@shared/schema";
import { eq, and, sql, desc } from "drizzle-orm";
import { z } from "zod";
import { soeRuleEngine } from "../soe/rule-engine/index";
import { soeEventBus } from "../soe/event-bus";
export function registerErpRoutes(app: Express): void {
app.get("/api/erp/connections", async (req: Request, res: Response) => {
@ -850,12 +852,24 @@ export function registerErpRoutes(app: Express): void {
return res.status(401).json({ error: "Not authenticated" });
}
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" });

115
server/erpnext/proxy.ts Normal file
View File

@ -0,0 +1,115 @@
// ERPNext Proxy — Transparente, igual ao Plus proxy
// /erpnext-app/* → http://erpnext:8080/*
import { Express, Response } from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const ERPNEXT_HOST = process.env.ERPNEXT_CONTAINER_HOST || "erpnext";
const ERPNEXT_CONTAINER_PORT = parseInt(process.env.ERPNEXT_CONTAINER_PORT || "8080", 10);
const ERPNEXT_TIMEOUT = parseInt(process.env.ERPNEXT_TIMEOUT || "60000", 10);
export function setupErpNextProxy(app: Express): void {
// Só ativa se o container estiver configurado (DOCKER_MODE ou flag explícita)
const dockerMode = process.env.DOCKER_MODE === "true";
const forceProxy = process.env.ERPNEXT_PROXY_ENABLED === "true";
if (!dockerMode && !forceProxy) {
console.log("[ERPNext Proxy] Modo container desativado — proxy não configurado.");
console.log("[ERPNext Proxy] Use ERPNEXT_URL para apontar para instância externa.");
return;
}
const erpnextTarget = `http://${ERPNEXT_HOST}:${ERPNEXT_CONTAINER_PORT}`;
const erpnextProxy = createProxyMiddleware({
target: erpnextTarget,
changeOrigin: true,
timeout: ERPNEXT_TIMEOUT,
proxyTimeout: ERPNEXT_TIMEOUT,
pathRewrite: {
"^/erpnext-app": "",
},
on: {
error: (err, req, res) => {
console.error("[ERPNext Proxy] Error:", err.message);
if (res && typeof (res as Response).status === "function") {
(res as Response).status(502).json({
error: "ERPNext não disponível",
message: `Frappe não está rodando em ${erpnextTarget}`,
});
}
},
proxyReq: (proxyReq, req) => {
console.log(`[ERPNext Proxy] ${req.method} ${req.url} -> ${erpnextTarget}`);
const forwardedHost = req.headers["x-forwarded-host"];
const host = (typeof forwardedHost === "string" ? forwardedHost : req.headers.host) || "localhost";
const forwardedProto = req.headers["x-forwarded-proto"];
const proto = typeof forwardedProto === "string" ? forwardedProto : "https";
proxyReq.setHeader("X-Forwarded-Host", host);
proxyReq.setHeader("X-Forwarded-Proto", proto);
proxyReq.setHeader("X-Forwarded-Prefix", "/erpnext-app");
// Frappe usa este header para resolver URLs absolutas
proxyReq.setHeader("X-Site-Name", "erpnext.local");
},
proxyRes: (proxyRes) => {
const location = proxyRes.headers["location"];
if (location && typeof location === "string") {
let newLocation = location;
// Rewrite URLs absolutas do container → /erpnext-app
newLocation = newLocation.replace(
new RegExp(`http://${ERPNEXT_HOST}:${ERPNEXT_CONTAINER_PORT}`, "g"),
"/erpnext-app"
);
newLocation = newLocation.replace(/http:\/\/erpnext:8080/g, "/erpnext-app");
// Se é path relativo sem prefixo, adiciona /erpnext-app
if (newLocation.startsWith("/") && !newLocation.startsWith("/erpnext-app")) {
newLocation = "/erpnext-app" + newLocation;
}
proxyRes.headers["location"] = newLocation;
}
},
},
});
app.use("/erpnext-app", erpnextProxy);
// Status check do container ERPNext
app.get("/api/erpnext/container-status", async (_req, res) => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${erpnextTarget}/api/method/frappe.ping`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (response.ok) {
const data = await response.json() as any;
res.json({
status: "online",
host: ERPNEXT_HOST,
port: ERPNEXT_CONTAINER_PORT,
message: data.message,
mode: "container",
});
} else {
res.json({
status: "error",
host: ERPNEXT_HOST,
port: ERPNEXT_CONTAINER_PORT,
httpStatus: response.status,
});
}
} catch (err: any) {
res.json({
status: "offline",
host: ERPNEXT_HOST,
port: ERPNEXT_CONTAINER_PORT,
error: err.name === "AbortError" ? "timeout" : err.message,
});
}
});
console.log(`[ERPNext Proxy] Configurado: /erpnext-app -> ${erpnextTarget}`);
}

View File

@ -47,9 +47,11 @@ 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 { setupErpNextProxy } from "./erpnext/proxy";
import { importErpNextRulesForTenant } from "./soe/erpnext-rule-importer";
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";
@ -70,8 +72,12 @@ 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);
// ERPNext container proxy (ativo apenas em DOCKER_MODE ou ERPNEXT_PROXY_ENABLED=true)
setupErpNextProxy(app);
registerChatRoutes(app);
registerSoeRoutes(app);
@ -86,7 +92,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);
@ -108,6 +113,17 @@ export async function registerRoutes(
registerCommunityRoutes(app);
app.use("/api/para", paraRoutes);
app.use("/api/erpnext", erpnextRoutes);
// SOE — Importar regras do ERPNext para soe_regras
app.post("/api/soe/erpnext/import-rules", async (req: any, res) => {
try {
const tenantId = req.user?.tenantId || req.body?.tenantId;
const result = await importErpNextRulesForTenant(tenantId);
res.json({ ok: true, ...result });
} catch (e: any) {
res.status(500).json({ ok: false, error: e.message });
}
});
app.use("/api/quality", qualityRoutes);
app.use("/api/lowcode", lowcodeRoutes);
app.use("/api/dev-agent", devAgentRoutes);

View File

@ -0,0 +1,299 @@
// SOE — ERPNext Rule Importer
// Lê DocTypes e Workflows do ERPNext via API REST e converte
// em soe_regras para o SOE Rule Engine.
//
// Uso:
// import { importErpNextRules } from "./erpnext-rule-importer";
// await importErpNextRules(tenantId);
//
// As regras geradas ficam na tabela soe_regras com origemPadrao=false
// (origem: "erpnext") e podem ser sobrescritas por regras custom do tenant.
import { db } from "../db/index";
import { soeRegras } from "@shared/schema";
import { and, eq } from "drizzle-orm";
// DocTypes de negócio que queremos importar regras
const DOCTYPES_ALVO = [
"Sales Order",
"Purchase Order",
"Sales Invoice",
"Purchase Invoice",
"Delivery Note",
"Purchase Receipt",
"Payment Entry",
"Journal Entry",
"Item",
"Customer",
"Supplier",
"Stock Entry",
"Quotation",
];
// Mapa DocType → trigger SOE correspondente
const DOCTYPE_TRIGGER_MAP: Record<string, string> = {
"Sales Order": "pre_sales_order",
"Purchase Order": "pre_purchase_order",
"Sales Invoice": "pre_nfe_emissao",
"Purchase Invoice": "pre_compra_recebida",
"Delivery Note": "pre_entrega",
"Purchase Receipt": "pre_recebimento_mercadoria",
"Payment Entry": "pre_pagamento",
"Journal Entry": "pre_lancamento_contabil",
"Item": "pre_cadastro_produto",
"Customer": "pre_cadastro_cliente",
"Supplier": "pre_cadastro_fornecedor",
"Stock Entry": "pre_movimentacao_estoque",
"Quotation": "pre_orcamento",
};
interface FrappeDocTypeField {
fieldname: string;
label?: string;
fieldtype: string;
reqd?: number;
options?: string;
depends_on?: string;
}
interface FrappeWorkflowState {
state: string;
doc_status?: string;
allow_edit?: string;
}
interface FrappeWorkflowTransition {
state: string;
action: string;
next_state: string;
allowed?: string;
}
interface FrappeWorkflow {
name: string;
document_type: string;
is_active: number;
states?: FrappeWorkflowState[];
transitions?: FrappeWorkflowTransition[];
}
function getErpNextUrl(): string {
// Em modo container, usa o serviço Docker interno
const dockerMode = process.env.DOCKER_MODE === "true";
if (dockerMode) {
const host = process.env.ERPNEXT_CONTAINER_HOST || "erpnext";
const port = process.env.ERPNEXT_CONTAINER_PORT || "8080";
return `http://${host}:${port}`;
}
return process.env.ERPNEXT_URL || "";
}
function getAuthHeader(): Record<string, string> {
const key = process.env.ERPNEXT_API_KEY || "";
const secret = process.env.ERPNEXT_API_SECRET || "";
return {
Authorization: `token ${key}:${secret}`,
"Content-Type": "application/json",
};
}
async function fetchDocTypeFields(baseUrl: string, doctype: string): Promise<FrappeDocTypeField[]> {
try {
const resp = await fetch(
`${baseUrl}/api/resource/DocType/${encodeURIComponent(doctype)}`,
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
);
if (!resp.ok) return [];
const data = await resp.json() as any;
return (data?.data?.fields as FrappeDocTypeField[]) || [];
} catch {
return [];
}
}
async function fetchWorkflows(baseUrl: string, doctype: string): Promise<FrappeWorkflow[]> {
try {
const resp = await fetch(
`${baseUrl}/api/resource/Workflow?filters=[["document_type","=","${doctype}"],["is_active","=",1]]&fields=["name","document_type","is_active"]`,
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
);
if (!resp.ok) return [];
const data = await resp.json() as any;
const workflows: FrappeWorkflow[] = data?.data || [];
// Busca detalhes de cada workflow (states/transitions)
const detailed: FrappeWorkflow[] = [];
for (const wf of workflows) {
try {
const detResp = await fetch(
`${baseUrl}/api/resource/Workflow/${encodeURIComponent(wf.name)}`,
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
);
if (detResp.ok) {
const det = await detResp.json() as any;
detailed.push(det.data);
}
} catch {
detailed.push(wf);
}
}
return detailed;
} catch {
return [];
}
}
function campoParaSoe(field: FrappeDocTypeField, doctype: string, trigger: string) {
// Mapeamento de nomes de campo Frappe → nomes SOE
const fieldMap: Record<string, string> = {
customer: "clienteId",
supplier: "fornecedorId",
item_code: "itemCodigo",
qty: "quantidade",
rate: "valorUnitario",
amount: "valorTotal",
posting_date: "dataEmissao",
due_date: "vencimento",
payment_terms_template: "condicaoPagamento",
cost_center: "centroCusto",
company: "empresaNome",
};
return fieldMap[field.fieldname] || field.fieldname;
}
function gerarRegraObrigatorio(
field: FrappeDocTypeField,
doctype: string,
trigger: string,
tenantId: number | undefined
) {
const campoSoe = campoParaSoe(field, doctype, trigger);
return {
tenantId,
dominio: "business" as const,
trigger,
nome: `ERPNext: ${doctype}${field.label || field.fieldname} obrigatório`,
condicao: {},
acao: {
validar: {
campo: campoSoe,
obrigatorio: true,
},
},
prioridade: 50,
ativo: true,
origemPadrao: false,
};
}
export async function importErpNextRules(tenantId?: number): Promise<{
imported: number;
skipped: number;
errors: string[];
}> {
const baseUrl = getErpNextUrl();
if (!baseUrl) {
return { imported: 0, skipped: 0, errors: ["ERPNEXT_URL não configurado"] };
}
console.log(`[SOE RuleImporter] Iniciando importação de regras ERPNext de ${baseUrl}...`);
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const doctype of DOCTYPES_ALVO) {
const trigger = DOCTYPE_TRIGGER_MAP[doctype];
if (!trigger) continue;
try {
// 1. Campos obrigatórios do DocType
const fields = await fetchDocTypeFields(baseUrl, doctype);
const requiredFields = fields.filter((f) => f.reqd === 1 && f.fieldname !== "name");
for (const field of requiredFields) {
const ruleId = `erpnext:${doctype.toLowerCase().replace(/ /g, "_")}:${field.fieldname}:reqd`;
// Verifica se já existe
try {
const existing = await db.query.soeRegras?.findFirst?.({
where: (t: any, { eq }: any) => eq(t.id, ruleId),
});
if (existing) { skipped++; continue; }
} catch { /* tabela pode não existir ainda */ }
try {
const ruleData = gerarRegraObrigatorio(field, doctype, trigger, tenantId);
await db.insert(soeRegras).values({
id: ruleId,
...ruleData,
condicao: ruleData.condicao,
acao: ruleData.acao,
} as any);
imported++;
} catch (e: any) {
errors.push(`${doctype}/${field.fieldname}: ${e.message}`);
}
}
// 2. Workflows ativos → regras de estado permitido
const workflows = await fetchWorkflows(baseUrl, doctype);
for (const wf of workflows) {
if (!wf.transitions?.length) continue;
// Extrai ações permitidas por estado → regra de validação de status
const allowedActions = wf.transitions.map((t) => t.action).filter(Boolean);
if (allowedActions.length === 0) continue;
const ruleId = `erpnext:wf:${wf.name.toLowerCase().replace(/ /g, "_")}`;
try {
const existing = await db.query.soeRegras?.findFirst?.({
where: (t: any, { eq }: any) => eq(t.id, ruleId),
});
if (existing) { skipped++; continue; }
} catch { /* ok */ }
try {
await db.insert(soeRegras).values({
id: ruleId,
tenantId,
dominio: "business",
trigger,
nome: `ERPNext Workflow: ${wf.name}`,
condicao: {},
acao: {
set: {
_erpnextWorkflow: wf.name,
_erpnextAllowedActions: allowedActions,
},
},
prioridade: 80,
ativo: true,
origemPadrao: false,
} as any);
imported++;
} catch (e: any) {
errors.push(`Workflow ${wf.name}: ${e.message}`);
}
}
console.log(`[SOE RuleImporter] ${doctype}: ${requiredFields.length} campos obrigatórios, ${workflows.length} workflow(s)`);
} catch (e: any) {
errors.push(`DocType ${doctype}: ${e.message}`);
}
}
console.log(`[SOE RuleImporter] Concluído: ${imported} importadas, ${skipped} já existiam, ${errors.length} erros`);
return { imported, skipped, errors };
}
// Endpoint para disparar importação via API
export async function importErpNextRulesForTenant(tenantId: number): Promise<{
imported: number;
skipped: number;
errors: string[];
}> {
return importErpNextRules(tenantId);
}

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

@ -0,0 +1,112 @@
// SOE Event Bus — Dispara eventos após execução do motor
// Permite que lançamentos contábeis, alertas e automações
// sejam disparados de forma desacoplada após cada operação SOE.
import { db } from "../db/index";
import { soeEventos } from "@shared/schema";
import { contabilHandler } from "./handlers/contabil-handler";
import { financialHandler } from "./handlers/financial-handler";
export type SoeEventType =
| "nfe_emitida"
| "nfe_cancelada"
| "venda_confirmada"
| "venda_faturada"
| "compra_recebida"
| "pagamento_realizado"
| "recebimento_realizado"
| "estoque_baixo";
export interface SoeEventPayload {
tenantId?: number;
empresaId?: number;
motorOrigem?: "plus" | "erpnext" | "local";
dados: Record<string, any>;
}
type EventHandler = (payload: SoeEventPayload) => Promise<void>;
// Mapa de handlers por evento
const HANDLERS: Record<SoeEventType, EventHandler[]> = {
nfe_emitida: [
contabilHandler.lancamentoVenda,
financialHandler.criarContaReceber,
],
nfe_cancelada: [
contabilHandler.estornoLancamentoVenda,
],
venda_confirmada: [
financialHandler.reservarEstoque,
],
venda_faturada: [
contabilHandler.lancamentoVenda,
],
compra_recebida: [
contabilHandler.lancamentoCompra,
financialHandler.criarContaPagar,
],
pagamento_realizado: [
contabilHandler.lancamentoPagamento,
financialHandler.baixarContaPagar,
],
recebimento_realizado: [
contabilHandler.lancamentoRecebimento,
financialHandler.baixarContaReceber,
],
estoque_baixo: [
financialHandler.alertaEstoqueBaixo,
],
};
export class SoeEventBus {
private static instance: SoeEventBus;
static getInstance(): SoeEventBus {
if (!SoeEventBus.instance) SoeEventBus.instance = new SoeEventBus();
return SoeEventBus.instance;
}
async emit(evento: SoeEventType, payload: SoeEventPayload): Promise<void> {
const start = Date.now();
// Registra evento no banco
let eventoId: string | undefined;
try {
const [row] = await db
.insert(soeEventos)
.values({
tenantId: payload.tenantId,
empresaId: payload.empresaId,
evento,
motorOrigem: payload.motorOrigem,
payloadEntrada: payload.dados,
status: "ok",
})
.returning({ id: soeEventos.id });
eventoId = row?.id;
} catch (e: any) {
console.error("[SoeEventBus] Erro ao registrar evento:", e.message);
}
// Dispara handlers em paralelo (falha isolada — não bloqueia o fluxo)
const handlers = HANDLERS[evento] ?? [];
const results = await Promise.allSettled(
handlers.map((h) => h({ ...payload, dados: { ...payload.dados, _eventoId: eventoId } }))
);
const erros = results
.filter((r) => r.status === "rejected")
.map((r) => (r as PromiseRejectedResult).reason?.message);
if (erros.length > 0) {
console.warn(`[SoeEventBus] ${evento}: ${erros.length} handler(s) falharam:`, erros);
}
console.log(
`[SoeEventBus] ${evento} processado em ${Date.now() - start}ms | ` +
`${handlers.length} handlers | ${erros.length} erros`
);
}
}
export const soeEventBus = SoeEventBus.getInstance();

View File

@ -0,0 +1,182 @@
// SOE — Handler Contábil
// Gera lançamentos contábeis automáticos a partir de eventos SOE.
// Os lançamentos são salvos em soe_lancamentos e depois sincronizados
// com o Motor Contábil Python (:8003).
import { db } from "../../db/index";
import { soeLancamentos } from "@shared/schema";
import type { SoeEventPayload } from "../event-bus";
const CONTABIL_ENGINE_URL =
`http://${process.env.CONTABIL_ENGINE_HOST || "localhost"}:${process.env.CONTABIL_PORT || "8003"}`;
async function inserirLancamento(
tenantId: number | undefined,
empresaId: number,
data: string,
contaDebito: string,
contaCredito: string,
valor: number,
historico: string,
origemEvento: string,
origemEventoId?: string
) {
if (!valor || valor <= 0) return;
try {
await db.insert(soeLancamentos).values({
tenantId,
empresaId,
data,
contaDebito,
contaCredito,
valor: String(valor),
historico,
origemEvento,
origemEventoId,
periodo: data.substring(0, 7), // 'YYYY-MM'
enviado8003: false,
});
} catch (e: any) {
console.error("[ContabilHandler] Erro ao inserir lançamento:", e.message);
}
}
export const contabilHandler = {
// NF-e de venda emitida → lançamentos de receita + impostos
lancamentoVenda: async (payload: SoeEventPayload) => {
const { dados, tenantId, empresaId } = payload;
if (!empresaId || !dados.valorTotal) return;
const data = dados.dataEmissao?.substring(0, 10) || new Date().toISOString().substring(0, 10);
const numeroNfe = dados.numero || dados.nfeId || "?";
const eventoId = dados._eventoId;
// D: Clientes / C: Receita Bruta de Vendas
await inserirLancamento(
tenantId, empresaId, data,
"1.1.3.01", "3.1.1.01",
parseFloat(dados.valorTotal),
`Venda NF-e ${numeroNfe}`,
"nfe_emitida", eventoId
);
// D: Deduções ICMS / C: ICMS a Recolher
if (dados.valorIcms && parseFloat(dados.valorIcms) > 0) {
await inserirLancamento(
tenantId, empresaId, data,
"3.1.2.01", "2.1.2.01",
parseFloat(dados.valorIcms),
`ICMS s/ Venda NF-e ${numeroNfe}`,
"nfe_emitida", eventoId
);
}
// D: Deduções PIS / C: PIS a Recolher
if (dados.valorPis && parseFloat(dados.valorPis) > 0) {
await inserirLancamento(
tenantId, empresaId, data,
"3.1.2.02", "2.1.2.02",
parseFloat(dados.valorPis),
`PIS s/ Venda NF-e ${numeroNfe}`,
"nfe_emitida", eventoId
);
}
// D: Deduções COFINS / C: COFINS a Recolher
if (dados.valorCofins && parseFloat(dados.valorCofins) > 0) {
await inserirLancamento(
tenantId, empresaId, data,
"3.1.2.03", "2.1.2.03",
parseFloat(dados.valorCofins),
`COFINS s/ Venda NF-e ${numeroNfe}`,
"nfe_emitida", eventoId
);
}
},
// Cancelamento de NF-e → estorno dos lançamentos
estornoLancamentoVenda: async (payload: SoeEventPayload) => {
const { dados, tenantId, empresaId } = payload;
if (!empresaId || !dados.valorTotal) return;
const data = new Date().toISOString().substring(0, 10);
const numeroNfe = dados.numero || dados.nfeId || "?";
// Estorno: inverte débito/crédito
await inserirLancamento(
tenantId, empresaId, data,
"3.1.1.01", "1.1.3.01",
parseFloat(dados.valorTotal),
`Estorno NF-e ${numeroNfe} (cancelamento)`,
"nfe_cancelada"
);
},
// Compra recebida (NF-e entrada) → entrada em estoque + fornecedor
lancamentoCompra: async (payload: SoeEventPayload) => {
const { dados, tenantId, empresaId } = payload;
if (!empresaId || !dados.valorMercadorias) return;
const data = dados.dataEntrada?.substring(0, 10) || new Date().toISOString().substring(0, 10);
const numeroNfe = dados.numero || "?";
const fornecedor = dados.fornecedor || "Fornecedor";
await inserirLancamento(
tenantId, empresaId, data,
"1.1.4.01", "2.1.1.01",
parseFloat(dados.valorMercadorias),
`Compra NF-e ${numeroNfe} / ${fornecedor}`,
"compra_recebida"
);
},
// Pagamento de conta a pagar
lancamentoPagamento: async (payload: SoeEventPayload) => {
const { dados, tenantId, empresaId } = payload;
if (!empresaId || !dados.valorPago) return;
const data = dados.dataPagamento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
await inserirLancamento(
tenantId, empresaId, data,
"2.1.1.01", "1.1.1.01",
parseFloat(dados.valorPago),
`Pagamento: ${dados.descricao || "Conta a pagar"}`,
"pagamento_realizado"
);
},
// Recebimento de cliente
lancamentoRecebimento: async (payload: SoeEventPayload) => {
const { dados, tenantId, empresaId } = payload;
if (!empresaId || !dados.valorRecebido) return;
const data = dados.dataRecebimento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
await inserirLancamento(
tenantId, empresaId, data,
"1.1.1.01", "1.1.3.01",
parseFloat(dados.valorRecebido),
`Recebimento: ${dados.descricao || "Conta a receber"}`,
"recebimento_realizado"
);
},
};
// Sincroniza lançamentos pendentes com Motor Contábil Python (:8003)
export async function sincronizarComMotorContabil(empresaId: number, periodo: string) {
try {
const resp = await fetch(`${CONTABIL_ENGINE_URL}/lancamentos/sincronizar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ empresaId, periodo }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
console.warn("[ContabilHandler] Motor :8003 retornou:", resp.status);
}
} catch (e: any) {
console.warn("[ContabilHandler] Motor :8003 indisponível:", e.message);
}
}

View File

@ -0,0 +1,128 @@
// SOE — Handler Financeiro
// Automações financeiras disparadas pelo Event Bus SOE.
import { db } from "../../db/index";
import { finAccountsReceivable, finAccountsPayable } from "@shared/schema";
import type { SoeEventPayload } from "../event-bus";
export const financialHandler = {
// Cria conta a receber automaticamente ao emitir NF-e de venda
criarContaReceber: async (payload: SoeEventPayload) => {
const { dados, tenantId } = payload;
if (!dados.valorTotal || !dados.clienteId) return;
try {
// Verifica se já existe conta a receber para esta NF-e
if (dados.nfeId) {
const existente = await db.query.finAccountsReceivable?.findFirst?.({
where: (t: any, { eq }: any) => eq(t.reference, `nfe:${dados.nfeId}`),
});
if (existente) return;
}
const vencimento = dados.vencimento
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
await db.insert(finAccountsReceivable).values({
tenantId: tenantId ?? 1,
customerId: dados.clienteId,
description: `NF-e ${dados.numero || dados.nfeId || "?"}${dados.clienteNome || "Cliente"}`,
amount: String(dados.valorTotal),
dueDate: vencimento,
status: "pending",
category: "vendas",
paymentMethod: dados.formaPagamento || "outros",
notes: `Gerado automaticamente pelo SOE ao emitir NF-e`,
} as any);
console.log("[FinancialHandler] Conta a receber criada para NF-e", dados.nfeId || dados.numero);
} catch (e: any) {
console.error("[FinancialHandler] criarContaReceber error:", e.message);
}
},
// Baixa conta a receber após recebimento confirmado
baixarContaReceber: async (payload: SoeEventPayload) => {
const { dados } = payload;
if (!dados.contaReceiverId) return;
try {
await db
.update(finAccountsReceivable)
.set({
status: "received",
receivedDate: dados.dataRecebimento || new Date().toISOString().substring(0, 10),
paymentMethod: dados.formaPagamento || "outros",
} as any)
.where((finAccountsReceivable as any).id.eq(dados.contaReceiverId));
} catch (e: any) {
console.error("[FinancialHandler] baixarContaReceber error:", e.message);
}
},
// Cria conta a pagar ao receber mercadoria
criarContaPagar: async (payload: SoeEventPayload) => {
const { dados, tenantId } = payload;
if (!dados.valorTotal || !dados.fornecedorId) return;
try {
const vencimento = dados.vencimento
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
await db.insert(finAccountsPayable).values({
tenantId: tenantId ?? 1,
supplierId: dados.fornecedorId,
description: `NF-e Entrada ${dados.numero || "?"}${dados.fornecedor || "Fornecedor"}`,
amount: String(dados.valorTotal),
dueDate: vencimento,
status: "pending",
category: "compras",
notes: `Gerado automaticamente pelo SOE ao receber compra`,
} as any);
} catch (e: any) {
console.error("[FinancialHandler] criarContaPagar error:", e.message);
}
},
// Baixa conta a pagar após pagamento
baixarContaPagar: async (payload: SoeEventPayload) => {
const { dados } = payload;
if (!dados.contaPayableId) return;
try {
await db
.update(finAccountsPayable)
.set({
status: "paid",
paidDate: dados.dataPagamento || new Date().toISOString().substring(0, 10),
paymentMethod: dados.formaPagamento || "outros",
} as any)
.where((finAccountsPayable as any).id.eq(dados.contaPayableId));
} catch (e: any) {
console.error("[FinancialHandler] baixarContaPagar error:", e.message);
}
},
// Reserva estoque ao confirmar venda (notificação — execução no motor)
reservarEstoque: async (payload: SoeEventPayload) => {
const { dados } = payload;
if (!dados.items || dados.motor === "local") return;
// Log apenas — a reserva real é feita pelo motor Plus/ERPNext
console.log(
`[FinancialHandler] Venda ${dados.id} confirmada — ` +
`${dados.items?.length || 0} item(s) para reserva de estoque`
);
},
// Alerta de estoque baixo (via console/log — webhook futuro)
alertaEstoqueBaixo: async (payload: SoeEventPayload) => {
const { dados } = payload;
console.warn(
`[FinancialHandler] ALERTA: Estoque baixo — ` +
`Produto ${dados.produtoId} / ${dados.produtoNome} ` +
`(atual: ${dados.estoqueAtual}, mínimo: ${dados.estoqueMinimo})`
);
// TODO Fase 5: enviar via WhatsApp/Email usando Motor Comunicação (:8006)
},
};

View File

@ -0,0 +1,72 @@
// SOE — Regras de Negócio Padrão
import type { SoeRule } from "./types";
export const BUSINESS_RULES_PADRAO: SoeRule[] = [
// ── Aprovação de pedidos ───────────────────────────────────────────────────
{
id: "PEDIDO_APROVACAO_AUTO_BAIXO_VALOR",
dominio: "business",
trigger: "pre_sales_order_confirm",
nome: "Aprovação automática para pedidos de baixo valor",
condicao: { "totalAmount": { "_lt": 5000 } },
acao: { set: { "_aprovacaoAutomatica": true, "status": "confirmed" } },
prioridade: 10,
ativo: false, // tenant habilita com seu limite
origemPadrao: true,
},
// ── Estoque ────────────────────────────────────────────────────────────────
{
id: "RESERVA_ESTOQUE_AO_CONFIRMAR",
dominio: "business",
trigger: "post_sales_order_confirm",
nome: "Reserva estoque ao confirmar pedido",
condicao: { "status": "confirmed" },
acao: { set: { "_reservarEstoque": true } },
prioridade: 10,
ativo: true,
origemPadrao: true,
},
// ── Financeiro ─────────────────────────────────────────────────────────────
{
id: "CRIAR_CONTA_RECEBER_AO_FATURAR",
dominio: "business",
trigger: "post_nfe_emitida",
nome: "Cria conta a receber automaticamente ao faturar",
condicao: { "_tipoOperacao": "venda" },
acao: { set: { "_criarContaReceber": true } },
prioridade: 5,
ativo: true,
origemPadrao: true,
},
{
id: "CRIAR_CONTA_PAGAR_AO_RECEBER_COMPRA",
dominio: "business",
trigger: "post_purchase_received",
nome: "Cria conta a pagar ao receber mercadoria",
condicao: {},
acao: { set: { "_criarContaPagar": true } },
prioridade: 5,
ativo: true,
origemPadrao: true,
},
// ── Alertas ────────────────────────────────────────────────────────────────
{
id: "ALERTA_ESTOQUE_MINIMO",
dominio: "business",
trigger: "post_sales_order_confirm",
nome: "Alerta quando estoque cai abaixo do mínimo",
condicao: { "_estoqueAbaixoMinimo": true },
acao: { set: { "_enviarAlertaEstoque": true } },
prioridade: 15,
ativo: true,
origemPadrao: true,
},
];

View File

@ -0,0 +1,150 @@
// SOE — Regras Fiscais Padrão
// Estas regras enriquecem o payload ANTES de despachar ao motor (Plus/ERPNext)
// O motor recebe o payload já correto e só executa.
import type { SoeRule } from "./types";
export const FISCAL_RULES_PADRAO: SoeRule[] = [
// ── CFOP — Resolução automática por direção da operação ──────────────────
{
id: "CFOP_VENDA_DENTRO_ESTADO",
dominio: "fiscal",
trigger: "pre_sales_order",
nome: "CFOP padrão — venda dentro do estado",
condicao: { _cfopNaoDefinido: true, _operacao: "venda" },
acao: { set: { "cfopPadrao": "5.102" } },
prioridade: 20,
ativo: true,
origemPadrao: true,
},
{
id: "CFOP_VENDA_OUTRO_ESTADO",
dominio: "fiscal",
trigger: "pre_sales_order",
nome: "CFOP padrão — venda para outro estado",
condicao: { _cfopNaoDefinido: true, _operacao: "venda", _interestadual: true },
acao: { set: { "cfopPadrao": "6.102" } },
prioridade: 19,
ativo: true,
origemPadrao: true,
},
{
id: "CFOP_COMPRA_DENTRO_ESTADO",
dominio: "fiscal",
trigger: "pre_purchase_order",
nome: "CFOP padrão — compra dentro do estado",
condicao: { _cfopNaoDefinido: true },
acao: { set: { "cfopPadrao": "1.102" } },
prioridade: 20,
ativo: true,
origemPadrao: true,
},
// ── Tributação por Regime ──────────────────────────────────────────────────
{
id: "TRIBUTACAO_SIMPLES_NACIONAL",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Simples Nacional — aplica CSOSN e remove destaque PIS/COFINS",
condicao: { regimeTributario: "simples_nacional" },
acao: {
set: {
"tributacao.csosnPadrao": "400",
"tributacao.destacarPisCofins": false,
"tributacao.aliquotaIcms": 0,
},
},
prioridade: 5,
ativo: true,
origemPadrao: true,
},
{
id: "TRIBUTACAO_LUCRO_PRESUMIDO",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Lucro Presumido — CST 00 com alíquotas padrão",
condicao: { regimeTributario: "lucro_presumido" },
acao: {
set: {
"tributacao.cstIcmsPadrao": "00",
"tributacao.cstPisPadrao": "01",
"tributacao.cstCofinsPadrao": "01",
"tributacao.destacarPisCofins": true,
},
},
prioridade: 5,
ativo: true,
origemPadrao: true,
},
{
id: "TRIBUTACAO_LUCRO_REAL",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Lucro Real — CST 00, PIS/COFINS não cumulativo",
condicao: { regimeTributario: "lucro_real" },
acao: {
set: {
"tributacao.cstIcmsPadrao": "00",
"tributacao.cstPisPadrao": "50",
"tributacao.cstCofinsPadrao": "50",
"tributacao.destacarPisCofins": true,
"tributacao.regimePisCofins": "nao_cumulativo",
},
},
prioridade: 5,
ativo: true,
origemPadrao: true,
},
// ── Validações obrigatórias ────────────────────────────────────────────────
{
id: "VALIDAR_NCM_NFE",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Valida NCM obrigatório em NF-e",
condicao: { tipoDocumento: ["nfe", "nfce"] },
acao: { validar: { campo: "ncm", obrigatorio: true, formato: "^\\d{8}$" } },
prioridade: 1,
ativo: true,
origemPadrao: true,
},
{
id: "VALIDAR_CFOP_OBRIGATORIO",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Valida CFOP obrigatório",
condicao: {},
acao: { validar: { campo: "cfop", obrigatorio: true, formato: "^\\d\\.\\d{3}$" } },
prioridade: 1,
ativo: true,
origemPadrao: true,
},
{
id: "VALIDAR_CNPJ_CPF_DESTINATARIO",
dominio: "fiscal",
trigger: "pre_nfe_emissao",
nome: "Valida CNPJ/CPF do destinatário",
condicao: { tipoDocumento: "nfe" },
acao: { validar: { campo: "destinatario.cpfCnpj", obrigatorio: true } },
prioridade: 1,
ativo: true,
origemPadrao: true,
},
// ── Geração automática de NF-e ao faturar ─────────────────────────────────
{
id: "NFE_AUTO_AO_FATURAR",
dominio: "fiscal",
trigger: "pre_sales_order_confirm",
nome: "Gera NF-e automaticamente ao confirmar pedido (se configurado)",
condicao: { "config.nfeAutomatica": true },
acao: { set: { "_gerarNfe": true } },
prioridade: 10,
ativo: false, // desativado por padrão — tenant habilita
origemPadrao: true,
},
];

View File

@ -0,0 +1,240 @@
// SOE Rule Engine — Motor Central de Regras
// Intercepta chamadas às rotas SOE antes de despachar ao motor (Plus/ERPNext/local)
// e aplica regras configuráveis de negócio, fiscal, contábil e financeiro.
import { db } from "../../db/index";
import { soeRegras, soeEventos } from "@shared/schema";
import { eq, and, isNull, or } from "drizzle-orm";
import type { SoeRule, RuleContext, EnrichedPayload, SoeRuleCondition } from "./types";
import { FISCAL_RULES_PADRAO } from "./fiscal-rules";
import { BUSINESS_RULES_PADRAO } from "./business-rules";
const DEFAULT_RULES: SoeRule[] = [...FISCAL_RULES_PADRAO, ...BUSINESS_RULES_PADRAO];
export class SoeRuleEngine {
private static instance: SoeRuleEngine;
static getInstance(): SoeRuleEngine {
if (!SoeRuleEngine.instance) SoeRuleEngine.instance = new SoeRuleEngine();
return SoeRuleEngine.instance;
}
// Aplica todas as regras ativas para um trigger + contexto
async apply(
trigger: string,
payload: Record<string, any>,
context: RuleContext
): Promise<EnrichedPayload> {
const start = Date.now();
const appliedRules: string[] = [];
const validationErrors: string[] = [];
try {
// Carrega regras do banco (custom do tenant) + defaults hardcoded
const rules = await this.getActiveRules(trigger, context);
// Enriquece payload com contexto (empresa, regime tributário)
let enriched = this.enrichWithContext(payload, context);
// Aplica cada regra em ordem de prioridade
for (const rule of rules.sort((a, b) => a.prioridade - b.prioridade)) {
if (!this.avaliarCondicao(rule.condicao, enriched, context)) continue;
const result = this.aplicarAcao(rule.acao, enriched);
if (result.validationError) {
validationErrors.push(`[${rule.id}] ${result.validationError}`);
} else {
enriched = result.payload;
appliedRules.push(rule.id);
}
}
// Registra evento no banco (async, não bloqueia a response)
this.logEvent(trigger, payload, enriched, appliedRules, context, Date.now() - start).catch(
(e) => console.error("[SoeRuleEngine] log error:", e.message)
);
return { payload: enriched, appliedRules, validationErrors };
} catch (err: any) {
console.error("[SoeRuleEngine] apply error:", err.message);
return { payload, appliedRules, validationErrors };
}
}
private async getActiveRules(trigger: string, context: RuleContext): Promise<SoeRule[]> {
// Regras do banco (custom do tenant)
let dbRules: SoeRule[] = [];
try {
const rows = await db
.select()
.from(soeRegras)
.where(
and(
eq(soeRegras.trigger, trigger),
eq(soeRegras.ativo, true),
or(
isNull(soeRegras.tenantId),
eq(soeRegras.tenantId, context.tenantId ?? 0)
)
)
);
dbRules = rows.map((r) => ({
id: r.id,
dominio: r.dominio as any,
trigger: r.trigger,
nome: r.nome,
condicao: r.condicao as any,
acao: r.acao as any,
prioridade: r.prioridade ?? 10,
ativo: r.ativo ?? true,
}));
} catch {
// Banco pode não ter a tabela ainda (antes de migration)
}
// Regras padrão hardcoded (filtradas pelo trigger)
const defaults = DEFAULT_RULES.filter((r) => r.trigger === trigger && r.ativo);
// Merge: regras do banco sobrescrevem defaults com mesmo ID
const merged = new Map<string, SoeRule>();
for (const r of defaults) merged.set(r.id, r);
for (const r of dbRules) merged.set(r.id, r);
return Array.from(merged.values());
}
private enrichWithContext(
payload: Record<string, any>,
context: RuleContext
): Record<string, any> {
return {
...payload,
_contexto: {
tenantId: context.tenantId,
empresaId: context.empresaId,
motor: context.motor,
regimeTributario: context.empresa?.regimeTributario,
uf: context.empresa?.uf,
},
// Atalhos para condições
regimeTributario: context.empresa?.regimeTributario ?? payload.regimeTributario,
};
}
private avaliarCondicao(
condicao: SoeRuleCondition,
payload: Record<string, any>,
_context: RuleContext
): boolean {
if (!condicao || Object.keys(condicao).length === 0) return true;
for (const [key, expected] of Object.entries(condicao)) {
// Condições internas (_xxx)
if (key === "_cfopNaoDefinido") {
if (expected && payload.cfop) return false;
continue;
}
if (key === "_operacao") {
// Infere operação pelo tipo (simplificado)
if (expected === "venda" && payload._tipo !== "venda") return false;
continue;
}
if (key === "_interestadual") {
const interestadual = payload.ufDestinatario && payload.ufEmitente &&
payload.ufDestinatario !== payload.ufEmitente;
if (expected !== interestadual) return false;
continue;
}
const actual = this.getNestedValue(payload, key);
if (Array.isArray(expected)) {
if (!expected.includes(actual)) return false;
} else if (typeof expected === "object" && expected !== null) {
if ("_lt" in expected && !(actual < expected._lt)) return false;
if ("_gt" in expected && !(actual > expected._gt)) return false;
if ("_eq" in expected && actual !== expected._eq) return false;
} else {
if (actual !== expected) return false;
}
}
return true;
}
private aplicarAcao(
acao: any,
payload: Record<string, any>
): { payload: Record<string, any>; validationError?: string } {
let result = { ...payload };
if (acao.set) {
for (const [path, value] of Object.entries(acao.set)) {
result = this.setNestedValue(result, path, value);
}
}
if (acao.validar) {
const { campo, obrigatorio, formato } = acao.validar;
const valor = this.getNestedValue(payload, campo);
if (obrigatorio && !valor) {
return { payload, validationError: `Campo obrigatório ausente: ${campo}` };
}
if (formato && valor) {
const re = typeof formato === "string" ? new RegExp(formato) : formato;
if (!re.test(String(valor))) {
return { payload, validationError: `Campo ${campo} com formato inválido: ${valor}` };
}
}
}
return { payload: result };
}
private getNestedValue(obj: Record<string, any>, path: string): any {
return path.split(".").reduce((curr, key) => curr?.[key], obj);
}
private setNestedValue(
obj: Record<string, any>,
path: string,
value: any
): Record<string, any> {
const result = { ...obj };
const keys = path.split(".");
let curr: any = result;
for (let i = 0; i < keys.length - 1; i++) {
if (!curr[keys[i]]) curr[keys[i]] = {};
curr = curr[keys[i]];
}
curr[keys[keys.length - 1]] = value;
return result;
}
private async logEvent(
trigger: string,
payloadEntrada: any,
payloadSaida: any,
regraIds: string[],
context: RuleContext,
duracaoMs: number
) {
try {
await db.insert(soeEventos).values({
tenantId: context.tenantId,
empresaId: context.empresaId,
evento: trigger,
motorOrigem: context.motor,
regraIds,
payloadEntrada,
payloadSaida,
status: "ok",
duracaoMs,
});
} catch {
// Silencia erro de log — não deve quebrar o fluxo principal
}
}
}
// Singleton exportado
export const soeRuleEngine = SoeRuleEngine.getInstance();

View File

@ -0,0 +1,47 @@
// SOE Rule Engine — Types
export interface SoeRuleCondition {
// Comparações simples: { campo: valor } ou { campo: { op: valor } }
[key: string]: any;
}
export interface SoeRuleAction {
// set: sobrescreve campos no payload
set?: Record<string, any>;
// validar: valida campos obrigatórios/formato
validar?: { campo: string; obrigatorio?: boolean; formato?: RegExp | string };
// addItem: adiciona item à lista
addItem?: { campo: string; valor: any };
// calcular: define um campo como resultado de expressão
calcular?: { campo: string; expressao: string };
}
export interface SoeRule {
id: string;
dominio: "fiscal" | "business" | "accounting" | "financial";
trigger: string;
nome: string;
condicao: SoeRuleCondition;
acao: SoeRuleAction;
prioridade: number;
ativo: boolean;
origemPadrao?: boolean;
}
export interface RuleContext {
tenantId?: number;
empresaId?: number;
userId?: string;
motor?: "plus" | "erpnext" | "local";
empresa?: {
regimeTributario?: "simples_nacional" | "lucro_presumido" | "lucro_real";
uf?: string;
cnpj?: string;
};
}
export interface EnrichedPayload {
payload: Record<string, any>;
appliedRules: string[];
validationErrors: string[];
}

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

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

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

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

View File

@ -7315,3 +7315,70 @@ export const commEvents = pgTable("comm_events", {
processedByAgents: boolean("processed_by_agents").default(false), // consumed by agents?
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
// ═══════════════════════════════════════════════════════════════════════════════
// SOE — Sistema Operacional Empresarial (Rule Engine + Event Bus)
// ═══════════════════════════════════════════════════════════════════════════════
// Regras do SOE — configuráveis por tenant/empresa
export const soeRegras = pgTable("soe_regras", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
empresaId: integer("empresa_id"),
dominio: varchar("dominio", { length: 50 }).notNull(), // 'fiscal' | 'business' | 'accounting' | 'financial'
trigger: varchar("trigger", { length: 100 }).notNull(), // 'pre_sales_order' | 'pre_nfe_emissao' | etc.
nome: varchar("nome", { length: 200 }).notNull(),
condicao: jsonb("condicao").notNull().$type<Record<string, any>>(),
acao: jsonb("acao").notNull().$type<Record<string, any>>(),
prioridade: integer("prioridade").default(10),
ativo: boolean("ativo").default(true),
origemPadrao: boolean("origem_padrao").default(false), // true = regra padrão do sistema
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
// Log de todos os eventos SOE (audit trail completo)
export const soeEventos = pgTable("soe_eventos", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: integer("tenant_id").references(() => tenants.id),
empresaId: integer("empresa_id"),
evento: varchar("evento", { length: 100 }).notNull(), // 'nfe_emitida' | 'venda_confirmada' | etc.
motorOrigem: varchar("motor_origem", { length: 50 }), // 'plus' | 'erpnext' | 'local'
regraIds: jsonb("regra_ids").$type<string[]>(), // IDs das regras aplicadas
payloadEntrada: jsonb("payload_entrada").$type<Record<string, any>>(),
payloadSaida: jsonb("payload_saida").$type<Record<string, any>>(),
status: varchar("status", { length: 50 }).notNull().default("ok"), // 'ok' | 'error' | 'skipped'
duracaoMs: integer("duracao_ms"),
erro: text("erro"),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
// Lançamentos contábeis automáticos gerados pelo SOE Event Bus
export const soeLancamentos = pgTable("soe_lancamentos", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: integer("tenant_id").references(() => tenants.id),
empresaId: integer("empresa_id").notNull(),
data: date("data").notNull(),
contaDebito: varchar("conta_debito", { length: 30 }).notNull(),
contaCredito: varchar("conta_credito", { length: 30 }).notNull(),
valor: numeric("valor", { precision: 15, scale: 2 }).notNull(),
historico: text("historico").notNull(),
origemEvento: varchar("origem_evento", { length: 100 }), // 'nfe_emitida' | 'pagamento_realizado' | etc.
origemEventoId: uuid("origem_evento_id").references(() => soeEventos.id),
periodo: varchar("periodo", { length: 7 }), // '2026-03'
enviado8003: boolean("enviado_8003").default(false), // sincronizado com Motor Contábil Python
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
// Insert schemas
export const insertSoeRegraSchema = createInsertSchema(soeRegras);
export const insertSoeEventoSchema = createInsertSchema(soeEventos);
export const insertSoeLancamentoSchema = createInsertSchema(soeLancamentos);
// Types
export type SoeRegra = typeof soeRegras.$inferSelect;
export type InsertSoeRegra = z.infer<typeof insertSoeRegraSchema>;
export type SoeEvento = typeof soeEventos.$inferSelect;
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;