Compare commits
16 Commits
9d7f8d8985
...
e506023d53
| Author | SHA1 | Date |
|---|---|---|
|
|
e506023d53 | |
|
|
5a0bfdb295 | |
|
|
1071f7de4c | |
|
|
20a0237f91 | |
|
|
8d14fc021c | |
|
|
219af5f6f5 | |
|
|
c77b414029 | |
|
|
d1b1b2f0a5 | |
|
|
e82279d9eb | |
|
|
fc20a0e8b2 | |
|
|
eb0926704a | |
|
|
f0c3ce483e | |
|
|
5a39fb66c4 | |
|
|
353af841dd | |
|
|
7a1fb61d15 | |
|
|
4900886d90 |
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Deploy no Coolify — Arcádia Suite
|
||||||
|
|
||||||
|
> Guia completo para subir o Arcádia Suite no Coolify com Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
1. [Pré-requisitos](#pré-requisitos)
|
||||||
|
2. [Arquitetura de Serviços](#arquitetura-de-serviços)
|
||||||
|
3. [Configuração no Coolify](#configuração-no-coolify)
|
||||||
|
4. [Variáveis de Ambiente](#variáveis-de-ambiente)
|
||||||
|
5. [Serviços Opcionais (Profiles)](#serviços-opcionais-profiles)
|
||||||
|
6. [Sequência de Inicialização](#sequência-de-inicialização)
|
||||||
|
7. [Traefik e HTTPS](#traefik-e-https)
|
||||||
|
8. [Volumes e Persistência](#volumes-e-persistência)
|
||||||
|
9. [Stack de IA (LiteLLM + Ollama)](#stack-de-ia-litellm--ollama)
|
||||||
|
10. [Pós-deploy](#pós-deploy)
|
||||||
|
11. [Checklist Final](#checklist-final)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
- Coolify instalado e acessível (v4+)
|
||||||
|
- Docker Engine 24+ e Docker Compose v2+ no servidor
|
||||||
|
- Domínio apontando para o IP do servidor (`A record`)
|
||||||
|
- Mínimo recomendado: **4 vCPU, 8 GB RAM, 50 GB SSD**
|
||||||
|
- Para IA local (Ollama): **16 GB RAM, GPU opcional**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitetura de Serviços
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Traefik (Coolify) → app:5000 → arcadia-internal
|
||||||
|
→ open-webui:8080 (opcional)
|
||||||
|
|
||||||
|
arcadia-internal:
|
||||||
|
db (PostgreSQL 16 + pgvector) :5432
|
||||||
|
redis :6379
|
||||||
|
app (Node.js/Express) :5000
|
||||||
|
embeddings (Python FastAPI) :8001
|
||||||
|
fisco (Python FastAPI) :8002
|
||||||
|
contabil (Python FastAPI) :8003
|
||||||
|
bi (Python FastAPI) :8004
|
||||||
|
automation (Python FastAPI) :8005
|
||||||
|
litellm (gateway LLM) :4000 ← profile: ai
|
||||||
|
ollama (modelos locais) :11434 ← profile: ai
|
||||||
|
superset (BI) :8088 ← profile: bi
|
||||||
|
plus (Laravel/ERP) :8080 ← profile: plus
|
||||||
|
erpnext :8090 ← profile: erpnext
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuração no Coolify
|
||||||
|
|
||||||
|
### 1. Criar novo projeto
|
||||||
|
|
||||||
|
No painel Coolify: **New Project → Docker Compose**.
|
||||||
|
|
||||||
|
### 2. Apontar para o repositório
|
||||||
|
|
||||||
|
- **Source:** Git (GitHub/GitLab/Gitea)
|
||||||
|
- **Branch:** `main` (ou sua branch de produção)
|
||||||
|
- **Docker Compose file:** `docker-compose.prod.yml`
|
||||||
|
|
||||||
|
### 3. Definir o domínio
|
||||||
|
|
||||||
|
Em **Domains**, adicione `seu-dominio.com`. O Coolify irá automaticamente:
|
||||||
|
- Criar as rotas no Traefik
|
||||||
|
- Emitir certificado Let's Encrypt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variáveis de Ambiente
|
||||||
|
|
||||||
|
Configure todas no painel **Environment Variables** do Coolify (nunca no `.env` do repositório).
|
||||||
|
|
||||||
|
### Obrigatórias
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Aplicação
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=5000
|
||||||
|
DOMAIN=seu-dominio.com
|
||||||
|
DOCKER_MODE=true
|
||||||
|
|
||||||
|
# Segredos — gerar com: openssl rand -hex 32
|
||||||
|
SESSION_SECRET=<gerado>
|
||||||
|
SSO_SECRET=<gerado>
|
||||||
|
|
||||||
|
# Banco de dados
|
||||||
|
PGHOST=db
|
||||||
|
PGPORT=5432
|
||||||
|
PGUSER=arcadia
|
||||||
|
PGPASSWORD=<senha-forte>
|
||||||
|
PGDATABASE=arcadia
|
||||||
|
DATABASE_URL=postgresql://arcadia:<senha>@db:5432/arcadia
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# IA — LiteLLM gateway
|
||||||
|
LITELLM_API_KEY=<gerado>
|
||||||
|
AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1
|
||||||
|
AI_INTEGRATIONS_OPENAI_API_KEY=<mesmo-que-LITELLM_API_KEY>
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|
||||||
|
# Microserviços Python
|
||||||
|
PYTHON_SERVICE_URL=http://embeddings:8001
|
||||||
|
FISCO_PYTHON_URL=http://fisco:8002
|
||||||
|
CONTABIL_PYTHON_URL=http://contabil:8003
|
||||||
|
BI_PYTHON_URL=http://bi:8004
|
||||||
|
AUTOMATION_PYTHON_URL=http://automation:8005
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMs externos (opcionais — soberania: deixar em branco)
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GROQ_API_KEY=
|
||||||
|
LLMFIT_BASE_URL= # habilitar quando LLMFit estiver disponível
|
||||||
|
```
|
||||||
|
|
||||||
|
### Superset — profile `bi`
|
||||||
|
|
||||||
|
```env
|
||||||
|
SUPERSET_SECRET_KEY=<openssl rand -hex 32>
|
||||||
|
SUPERSET_ADMIN_USER=admin
|
||||||
|
SUPERSET_ADMIN_EMAIL=admin@seu-dominio.com
|
||||||
|
SUPERSET_ADMIN_PASSWORD=<senha-forte>
|
||||||
|
SUPERSET_HOST=superset
|
||||||
|
SUPERSET_PORT=8088
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arcádia Plus — profile `plus`
|
||||||
|
|
||||||
|
```env
|
||||||
|
SSO_PLUS_BASE_URL=http://plus:8080
|
||||||
|
PLUS_APP_KEY=<laravel-key>
|
||||||
|
PLUS_DB_ROOT_PASSWORD=<senha>
|
||||||
|
PLUS_DB_PASSWORD=<senha>
|
||||||
|
PLUS_DB_DATABASE=arcadia_plus
|
||||||
|
PLUS_DB_USER=plus
|
||||||
|
```
|
||||||
|
|
||||||
|
### ERPNext — profile `erpnext`
|
||||||
|
|
||||||
|
```env
|
||||||
|
ERPNEXT_PROXY_ENABLED=true
|
||||||
|
ERPNEXT_CONTAINER_HOST=erpnext
|
||||||
|
ERPNEXT_DB_ROOT_PASSWORD=<senha>
|
||||||
|
ERPNEXT_DB_PASSWORD=<senha>
|
||||||
|
ERPNEXT_ADMIN_PASSWORD=<senha>
|
||||||
|
ERPNEXT_URL=http://erpnext:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serviços Opcionais (Profiles)
|
||||||
|
|
||||||
|
O `docker-compose.prod.yml` usa profiles para habilitar serviços sob demanda.
|
||||||
|
No Coolify, adicione a variável de ambiente `COMPOSE_PROFILES`:
|
||||||
|
|
||||||
|
| O que habilitar | `COMPOSE_PROFILES` |
|
||||||
|
|---|---|
|
||||||
|
| Somente core | *(vazio)* |
|
||||||
|
| IA local (Ollama + LiteLLM + WebUI) | `ai` |
|
||||||
|
| Superset BI | `bi` |
|
||||||
|
| Arcádia Plus (Laravel ERP) | `plus` |
|
||||||
|
| ERPNext | `erpnext` |
|
||||||
|
| Tudo | `ai,bi,plus,erpnext` |
|
||||||
|
|
||||||
|
> **Atenção:** Ollama com modelos grandes (llama3.3, qwen2.5) pode consumir 10–20 GB de disco. Certifique-se de ter espaço no volume `ollama_models`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequência de Inicialização
|
||||||
|
|
||||||
|
Os `depends_on` garantem a ordem correta automaticamente:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. db → healthcheck: pg_isready (até 10 tentativas)
|
||||||
|
2. redis → healthcheck: redis-cli ping
|
||||||
|
3. embeddings, fisco, contabil, bi, automation → aguardam db healthy
|
||||||
|
4. litellm, ollama → aguardam redis (se profile ai)
|
||||||
|
5. app → aguarda db healthy + redis started
|
||||||
|
6. open-webui, superset, plus, erpnext → aguardam serviços base
|
||||||
|
```
|
||||||
|
|
||||||
|
Se um serviço falhar ao iniciar, cheque os logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# No servidor
|
||||||
|
docker compose -f docker-compose.prod.yml logs app --tail 50
|
||||||
|
docker compose -f docker-compose.prod.yml logs db --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Traefik e HTTPS
|
||||||
|
|
||||||
|
O Coolify gerencia o Traefik. As labels já estão no `docker-compose.prod.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Serviço principal
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.arcadia.tls=true"
|
||||||
|
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.arcadia.loadbalancer.server.port=5000"
|
||||||
|
|
||||||
|
# Open WebUI (profile ai) — subdomínio ai.seu-dominio.com
|
||||||
|
labels:
|
||||||
|
- "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.webui.tls=true"
|
||||||
|
- "traefik.http.routers.webui.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.webui.loadbalancer.server.port=8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
Certifique-se de que o DNS do subdomínio `ai.seu-dominio.com` também aponta para o servidor antes de habilitar o profile `ai`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volumes e Persistência
|
||||||
|
|
||||||
|
| Volume | Conteúdo | Criticidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `pgdata` | Banco de dados PostgreSQL | **CRÍTICO — fazer backup** |
|
||||||
|
| `redis_data` | Sessões e cache | Alta |
|
||||||
|
| `ollama_models` | Modelos LLM baixados (~10–20 GB) | Média (redownload possível) |
|
||||||
|
| `open_webui_data` | Histórico do WebUI | Baixa |
|
||||||
|
| `superset_home` | Dashboards Superset | Média |
|
||||||
|
| `plus_db` | Banco MySQL do Plus | Alta |
|
||||||
|
| `plus_storage` | Arquivos do Plus | Alta |
|
||||||
|
| `erpnext_sites` | Sites ERPNext | **CRÍTICO** |
|
||||||
|
| `erpnext_logs` | Logs ERPNext | Baixa |
|
||||||
|
|
||||||
|
### Backup do PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup
|
||||||
|
docker exec arcadia-db pg_dump -U arcadia arcadia | gzip > backup-$(date +%Y%m%d).sql.gz
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
gunzip < backup-20240101.sql.gz | docker exec -i arcadia-db psql -U arcadia arcadia
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure backups automáticos no Coolify em **Project → Backups**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack de IA (LiteLLM + Ollama)
|
||||||
|
|
||||||
|
### Três tiers configurados em `docker/litellm-config.yaml`
|
||||||
|
|
||||||
|
```
|
||||||
|
TIER 1 — LLMFit (fine-tuned, soberano) → slot pronto, habilitar via LLMFIT_BASE_URL
|
||||||
|
TIER 2 — Ollama (local, padrão) → llama3.3, qwen2.5-coder, nomic-embed-text
|
||||||
|
TIER 3 — Externos (opt-in) → OpenAI, Anthropic, Groq (apenas se API key definida)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Baixar modelos Ollama após o primeiro deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec arcadia-ollama ollama pull llama3.3
|
||||||
|
docker exec arcadia-ollama ollama pull qwen2.5-coder:7b
|
||||||
|
docker exec arcadia-ollama ollama pull nomic-embed-text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Habilitar LLMFit (quando disponível)
|
||||||
|
|
||||||
|
1. Defina `LLMFIT_BASE_URL=http://seu-llmfit:porta`
|
||||||
|
2. Descomente o bloco TIER 1 em `docker/litellm-config.yaml`
|
||||||
|
3. Redeploy do serviço `litellm`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pós-deploy
|
||||||
|
|
||||||
|
### Rodar migrations do banco
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via Coolify → Run Command, ou no servidor:
|
||||||
|
docker exec arcadia-app npm run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar saúde dos serviços
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Todos devem estar `healthy` ou `running`. Se algum ficar em `restarting`, verifique os logs.
|
||||||
|
|
||||||
|
### Verificar conectividade IA
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||||
|
http://localhost:4000/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Final
|
||||||
|
|
||||||
|
### Antes do deploy
|
||||||
|
|
||||||
|
- [ ] Domínio DNS apontando para o servidor (`A record`)
|
||||||
|
- [ ] Todas as variáveis obrigatórias definidas no Coolify
|
||||||
|
- [ ] `SESSION_SECRET` e `SSO_SECRET` gerados com `openssl rand -hex 32`
|
||||||
|
- [ ] Senhas do banco definidas (nunca reutilizar senhas de dev)
|
||||||
|
- [ ] `DOCKER_MODE=true` definido
|
||||||
|
|
||||||
|
### No Coolify
|
||||||
|
|
||||||
|
- [ ] Projeto criado apontando para `docker-compose.prod.yml`
|
||||||
|
- [ ] Domínio configurado no Coolify
|
||||||
|
- [ ] HTTPS ativo (Let's Encrypt)
|
||||||
|
- [ ] Volumes persistentes configurados (especialmente `pgdata`)
|
||||||
|
- [ ] `COMPOSE_PROFILES` definido conforme os serviços desejados
|
||||||
|
|
||||||
|
### Após o deploy
|
||||||
|
|
||||||
|
- [ ] `npm run db:push` executado (migrations)
|
||||||
|
- [ ] Login funcional no `https://seu-dominio.com`
|
||||||
|
- [ ] Healthchecks verdes no painel Coolify
|
||||||
|
- [ ] Modelos Ollama baixados (se profile `ai` ativo)
|
||||||
|
- [ ] Backup automático configurado para `pgdata`
|
||||||
|
- [ ] Monitoramento/alertas configurados (Coolify suporta webhooks)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos de Referência Rápida
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Subir apenas o core
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Subir com IA
|
||||||
|
docker compose -f docker-compose.prod.yml --profile ai up -d
|
||||||
|
|
||||||
|
# Subir tudo
|
||||||
|
docker compose -f docker-compose.prod.yml --profile ai --profile bi --profile plus up -d
|
||||||
|
|
||||||
|
# Ver logs em tempo real
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f app
|
||||||
|
|
||||||
|
# Reiniciar um serviço específico
|
||||||
|
docker compose -f docker-compose.prod.yml restart app
|
||||||
|
|
||||||
|
# Atualizar para nova versão (após git pull)
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --no-deps app
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,787 @@
|
||||||
|
# PLANO BI — Substituição Metabase → Apache Superset
|
||||||
|
## Análise Real + Plano de Migração
|
||||||
|
### Versão 1.0 — Março 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DIAGNÓSTICO — ESTADO ATUAL
|
||||||
|
|
||||||
|
### 1.1 O que foi descoberto no código
|
||||||
|
|
||||||
|
O documento do Replit referenciava **Metabase** (Java, :8088) como o pilar visual do BI. Ao analisar o código real, a situação é diferente:
|
||||||
|
|
||||||
|
| Componente | Nome no doc | Nome real no código | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Plataforma BI Visual | "Metabase" | **"MetaSet"** (alias) | Metabase por baixo |
|
||||||
|
| Proxy | `/metabase/*` | `server/metabase/proxy.ts` | Funcional |
|
||||||
|
| Auth | Metabase session | `server/metaset/routes.ts` (autologin) | Funcional |
|
||||||
|
| Client | MetabaseClient | `server/metaset/client.ts` | Funcional |
|
||||||
|
| UI | Advanced tab iframe | `/api/bi/metaset/autologin` → `/metabase/` | Funcional |
|
||||||
|
| **Superset** | Não mencionado | **`docker-compose.yml` linha 165** | **JÁ CONFIGURADO** |
|
||||||
|
|
||||||
|
**Descoberta crítica:** O Apache Superset **já está no docker-compose.yml**:
|
||||||
|
```yaml
|
||||||
|
superset:
|
||||||
|
image: apache/superset:4.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia_superset
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles: [bi]
|
||||||
|
```
|
||||||
|
|
||||||
|
O Superset está configurado, mas **não integrado ao gateway Node.js**. O que falta é a ponte.
|
||||||
|
|
||||||
|
### 1.2 Arquivos que precisam mudar vs o que fica igual
|
||||||
|
|
||||||
|
**Mudam:**
|
||||||
|
```
|
||||||
|
server/metabase/proxy.ts → substituir por server/superset/proxy.ts
|
||||||
|
server/metaset/routes.ts → substituir por server/superset/routes.ts
|
||||||
|
server/metaset/client.ts → substituir por server/superset/client.ts
|
||||||
|
client/src/pages/BiWorkspace.tsx (Advanced tab apenas)
|
||||||
|
docker-compose.yml (Superset config + init)
|
||||||
|
docker-compose.prod.yml (adicionar Superset)
|
||||||
|
.env.example (novas variáveis)
|
||||||
|
```
|
||||||
|
|
||||||
|
**NÃO mudam (ficam exatamente iguais):**
|
||||||
|
```
|
||||||
|
server/python/bi_engine.py ← Motor Python :8004 continua intacto
|
||||||
|
server/bi/routes.ts ← CRUD de datasets/charts/dashboards
|
||||||
|
server/bi/upload.ts ← ETL pipeline
|
||||||
|
server/bi/staging.ts ← Staging
|
||||||
|
server/bi/engine-proxy.ts ← Proxy para BI Engine Python
|
||||||
|
client/src/pages/BiWorkspace.tsx (7 de 8 tabs ficam iguais)
|
||||||
|
Todas as tabelas do banco (bi_datasets, bi_charts, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. POR QUE SUPERSET É MELHOR PARA O CASO
|
||||||
|
|
||||||
|
### 2.1 Metabase vs Superset
|
||||||
|
|
||||||
|
| Critério | Metabase | Apache Superset |
|
||||||
|
|---|---|---|
|
||||||
|
| Runtime | Java (JVM, 500MB+ RAM) | Python/Flask (mais leve) |
|
||||||
|
| Embedding | Iframe com cookie de sessão | **Guest Token JWT** (seguro, sem credenciais) |
|
||||||
|
| "Qualquer tela" | Iframe estático, limitado | **`@superset-ui/embedded-sdk`** — componente React nativo |
|
||||||
|
| REST API | Limitada e não documentada | **REST API v1 completa e documentada** |
|
||||||
|
| Consistência de stack | Java (fora do padrão) | Python (mesmo stack dos motores existentes) |
|
||||||
|
| Docker image | `metabase/metabase` ~700MB | `apache/superset` ~400MB |
|
||||||
|
| SQL Lab | Básico | **Poderoso** (autocomplete, histórico, salvar queries) |
|
||||||
|
| Alertas | Básico | **Avançado** (webhooks, Slack, email) |
|
||||||
|
| Tipos de chart | ~20 | **50+** (incluindo mapas, sunburst, funnel) |
|
||||||
|
| Banco de metadados | H2/Postgres separado | **Mesmo PostgreSQL** da Arcádia |
|
||||||
|
|
||||||
|
### 2.2 A feature que o usuário pediu: "funcionar em qualquer tela"
|
||||||
|
|
||||||
|
O Superset resolve isso com **Guest Token + Embedded SDK**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fluxo atual (Metabase):
|
||||||
|
BiWorkspace → iframe → /metabase/ → autologin com cookie
|
||||||
|
❌ Só funciona na tab Advanced do BiWorkspace
|
||||||
|
❌ Cookie expira, precisa re-autenticar
|
||||||
|
❌ Não embeda em outras páginas facilmente
|
||||||
|
|
||||||
|
Fluxo novo (Superset):
|
||||||
|
QUALQUER página React → <EmbeddedDashboard dashboardId="xxx" />
|
||||||
|
✅ Funciona no Dashboard principal (/)
|
||||||
|
✅ Funciona no SOE (/soe tab Financeiro)
|
||||||
|
✅ Funciona no /contabil
|
||||||
|
✅ Funciona no /financeiro
|
||||||
|
✅ Funciona em qualquer tela do Arcádia
|
||||||
|
✅ Guest Token renovado automaticamente (sem re-login)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ARQUITETURA COM SUPERSET
|
||||||
|
|
||||||
|
### 3.1 Diagrama atualizado
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USUÁRIO / FRONTEND │
|
||||||
|
│ │
|
||||||
|
│ BiWorkspace.tsx (8 tabs — 7 iguais, Advanced → Superset) │
|
||||||
|
│ + QUALQUER outra página pode embedar um dashboard Superset │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ <SupersetDashboard dashboardId="finance-overview" /> │ │
|
||||||
|
│ │ Componente React reutilizável em QUALQUER rota │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────┬────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼──────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌────────────────┐ ┌──────────────────────┐
|
||||||
|
│ API BI Node │ │ BI Engine Py │ │ Superset (Python) │
|
||||||
|
│ /api/bi/* │ │ /api/bi-engine │ │ /superset/* │
|
||||||
|
│ (sem mudança)│ │ :8004 (igual) │ │ :8088 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ + novo: │ │ │ │ REST API v1 │
|
||||||
|
│ /api/superset│ │ │ │ Guest Token JWT │
|
||||||
|
│ /guest-token │ │ │ │ SQL Lab │
|
||||||
|
│ │ │ │ │ 50+ chart types │
|
||||||
|
└──────────────┘ └────────────────┘ └──────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ arcadia (dados SOE) │
|
||||||
|
│ arcadia_superset │
|
||||||
|
│ (metadados Superset) │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Dois bancos PostgreSQL
|
||||||
|
|
||||||
|
```
|
||||||
|
Banco: arcadia → Dados do SOE (persons, products, sales_orders, etc.)
|
||||||
|
Superset conecta aqui em READ-ONLY para criar charts
|
||||||
|
Banco: arcadia_superset → Metadados do Superset (dashboards, queries, users)
|
||||||
|
Superset usa internamente
|
||||||
|
```
|
||||||
|
|
||||||
|
Ambos no mesmo servidor PostgreSQL, bancos separados. O docker-compose.yml já referencia `arcadia_superset`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. O QUE CONSTRUIR — Passo a Passo
|
||||||
|
|
||||||
|
### 4.1 Configuração do Superset (docker-compose.yml)
|
||||||
|
|
||||||
|
O container já existe. Faltam: init script, volumes, configuração de embedding.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml — seção superset (expandida)
|
||||||
|
|
||||||
|
superset:
|
||||||
|
image: apache/superset:4.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia_superset
|
||||||
|
SUPERSET_WEBSERVER_PORT: 8088
|
||||||
|
PYTHONPATH: /app/pythonpath
|
||||||
|
# Para embedding funcionar:
|
||||||
|
FEATURE_FLAGS: '{"EMBEDDED_SUPERSET": true, "ENABLE_TEMPLATE_PROCESSING": true}'
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
volumes:
|
||||||
|
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py
|
||||||
|
- ./docker/superset/init.sh:/app/docker/init.sh
|
||||||
|
- superset_home:/app/superset_home
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||||
|
profiles: [bi]
|
||||||
|
networks:
|
||||||
|
- arcadia
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Arquivo de Configuração Superset
|
||||||
|
|
||||||
|
```python
|
||||||
|
# docker/superset/superset_config.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Chave secreta (mesma do .env)
|
||||||
|
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY", "change-in-production")
|
||||||
|
|
||||||
|
# Banco de metadados do Superset
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://arcadia:arcadia123@db:5432/arcadia_superset"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS — permite o gateway Arcádia (:5000) chamar a API
|
||||||
|
ENABLE_CORS = True
|
||||||
|
CORS_OPTIONS = {
|
||||||
|
"supports_credentials": True,
|
||||||
|
"allow_headers": ["*"],
|
||||||
|
"resources": {r"/api/*": {"origins": "*"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Embedding habilitado
|
||||||
|
FEATURE_FLAGS = {
|
||||||
|
"EMBEDDED_SUPERSET": True,
|
||||||
|
"ENABLE_TEMPLATE_PROCESSING": True,
|
||||||
|
"ALERT_REPORTS": True, # Alertas automáticos
|
||||||
|
"DRILL_TO_DETAIL": True, # Drill-down em charts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Timeout de queries (30s por default, aumentar para análises pesadas)
|
||||||
|
SUPERSET_WEBSERVER_TIMEOUT = 300
|
||||||
|
|
||||||
|
# Cache (Redis se disponível, senão in-memory)
|
||||||
|
CACHE_CONFIG = {
|
||||||
|
"CACHE_TYPE": "SimpleCache",
|
||||||
|
"CACHE_DEFAULT_TIMEOUT": 300, # 5 min
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tema/branding Arcádia (logo, cores)
|
||||||
|
APP_NAME = "Arcádia Insights"
|
||||||
|
APP_ICON = "/static/arcadia-logo.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Init Script do Superset
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# docker/superset/init.sh — roda ao iniciar o container
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[Superset Init] Iniciando configuração..."
|
||||||
|
|
||||||
|
# Aguarda PostgreSQL
|
||||||
|
until psql "${DATABASE_URL}" -c "SELECT 1" > /dev/null 2>&1; do
|
||||||
|
echo "[Superset Init] Aguardando PostgreSQL..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Cria banco arcadia_superset se não existir
|
||||||
|
psql "postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/postgres" \
|
||||||
|
-c "CREATE DATABASE arcadia_superset OWNER arcadia;" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Inicializa banco do Superset
|
||||||
|
superset db upgrade
|
||||||
|
|
||||||
|
# Cria usuário admin (só se não existir)
|
||||||
|
superset fab create-admin \
|
||||||
|
--username "${SUPERSET_ADMIN_USER:-admin}" \
|
||||||
|
--firstname "Arcádia" \
|
||||||
|
--lastname "Admin" \
|
||||||
|
--email "${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}" \
|
||||||
|
--password "${SUPERSET_ADMIN_PASSWORD:-arcadia2026}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Carrega exemplos (opcional, comentar em prod)
|
||||||
|
# superset load_examples
|
||||||
|
|
||||||
|
# Inicializa roles e permissões
|
||||||
|
superset init
|
||||||
|
|
||||||
|
# Registra conexão com o banco Arcádia (dados do SOE)
|
||||||
|
python - <<'PYEOF'
|
||||||
|
from superset import create_app
|
||||||
|
from superset.extensions import db
|
||||||
|
from superset.models.core import Database
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
existing = db.session.query(Database).filter_by(
|
||||||
|
database_name="Arcádia Suite"
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
arcadia_db = Database(
|
||||||
|
database_name="Arcádia Suite",
|
||||||
|
sqlalchemy_uri=os.environ.get(
|
||||||
|
"ARCADIA_DATABASE_URL",
|
||||||
|
"postgresql://arcadia:arcadia123@db:5432/arcadia"
|
||||||
|
),
|
||||||
|
expose_in_sqllab=True,
|
||||||
|
allow_run_async=True,
|
||||||
|
allow_csv_upload=False,
|
||||||
|
)
|
||||||
|
db.session.add(arcadia_db)
|
||||||
|
db.session.commit()
|
||||||
|
print("[Superset Init] Banco 'Arcádia Suite' registrado!")
|
||||||
|
else:
|
||||||
|
print("[Superset Init] Banco 'Arcádia Suite' já existe.")
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo "[Superset Init] Iniciando servidor..."
|
||||||
|
gunicorn \
|
||||||
|
--bind "0.0.0.0:${SUPERSET_WEBSERVER_PORT:-8088}" \
|
||||||
|
--access-logfile "-" \
|
||||||
|
--error-logfile "-" \
|
||||||
|
--workers 4 \
|
||||||
|
--timeout 120 \
|
||||||
|
--limit-request-line 0 \
|
||||||
|
--limit-request-field_size 0 \
|
||||||
|
"superset.app:create_app()"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Proxy Superset (substitui server/metabase/proxy.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/superset/proxy.ts
|
||||||
|
|
||||||
|
import { Express, Response } from "express";
|
||||||
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||||
|
|
||||||
|
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||||
|
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||||
|
const SUPERSET_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
export function setupSupersetProxy(app: Express): void {
|
||||||
|
const target = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
||||||
|
|
||||||
|
const supersetProxy = createProxyMiddleware({
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: SUPERSET_TIMEOUT,
|
||||||
|
proxyTimeout: SUPERSET_TIMEOUT,
|
||||||
|
pathRewrite: { "^/superset": "" },
|
||||||
|
on: {
|
||||||
|
error: (err, _req, res) => {
|
||||||
|
console.error("[Superset Proxy] Error:", err.message);
|
||||||
|
if (res && typeof (res as Response).status === "function") {
|
||||||
|
(res as Response).status(502).json({
|
||||||
|
error: "Superset indisponível",
|
||||||
|
message: "O Superset está iniciando. Tente novamente em alguns segundos.",
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
proxyRes: (proxyRes) => {
|
||||||
|
const location = proxyRes.headers["location"];
|
||||||
|
if (location && typeof location === "string" && location.startsWith("/")) {
|
||||||
|
proxyRes.headers["location"] = `/superset${location}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/superset", supersetProxy);
|
||||||
|
console.log(`[Superset Proxy] Configurado -> /superset/* => ${target}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Rotas Superset — Guest Token para Embedding
|
||||||
|
|
||||||
|
Esta é a peça-chave para "funcionar em qualquer tela":
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/superset/routes.ts
|
||||||
|
|
||||||
|
import type { Express, Request, Response } from "express";
|
||||||
|
|
||||||
|
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||||
|
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||||
|
const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
||||||
|
const SUPERSET_ADMIN_USER = process.env.SUPERSET_ADMIN_USER || "admin";
|
||||||
|
const SUPERSET_ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
|
||||||
|
|
||||||
|
// Cache do token de serviço (expira em 50 min)
|
||||||
|
let serviceToken: string | null = null;
|
||||||
|
let serviceTokenExpiry = 0;
|
||||||
|
|
||||||
|
async function getServiceToken(): Promise<string> {
|
||||||
|
if (serviceToken && Date.now() < serviceTokenExpiry) return serviceToken;
|
||||||
|
|
||||||
|
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: SUPERSET_ADMIN_USER,
|
||||||
|
password: SUPERSET_ADMIN_PASS,
|
||||||
|
provider: "db",
|
||||||
|
refresh: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) throw new Error("Falha ao autenticar no Superset");
|
||||||
|
const data = await resp.json();
|
||||||
|
serviceToken = data.access_token;
|
||||||
|
serviceTokenExpiry = Date.now() + 50 * 60 * 1000; // 50 min
|
||||||
|
return serviceToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSupersetRoutes(app: Express): void {
|
||||||
|
|
||||||
|
// Guest Token — para embedding em qualquer tela
|
||||||
|
// O frontend chama este endpoint para obter um token temporário
|
||||||
|
// e renderiza o dashboard via @superset-ui/embedded-sdk
|
||||||
|
app.post("/api/superset/guest-token", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||||
|
|
||||||
|
const { dashboardId } = req.body;
|
||||||
|
if (!dashboardId) return res.status(400).json({ error: "dashboardId obrigatório" });
|
||||||
|
|
||||||
|
const token = await getServiceToken();
|
||||||
|
|
||||||
|
// Solicita guest token ao Superset
|
||||||
|
const guestResp = await fetch(`${SUPERSET_URL}/api/v1/security/guest_token/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: {
|
||||||
|
username: `arcadia_${req.user?.id}`,
|
||||||
|
first_name: req.user?.name?.split(" ")[0] || "User",
|
||||||
|
last_name: req.user?.name?.split(" ")[1] || "",
|
||||||
|
},
|
||||||
|
resources: [{ type: "dashboard", id: dashboardId }],
|
||||||
|
rls: [], // Row Level Security (futuro: por tenantId)
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!guestResp.ok) {
|
||||||
|
const err = await guestResp.text();
|
||||||
|
return res.status(502).json({ error: "Falha ao gerar guest token", detail: err });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token: guestToken } = await guestResp.json();
|
||||||
|
res.json({ token: guestToken, supersetUrl: "/superset" });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lista dashboards disponíveis (para o seletor na UI)
|
||||||
|
app.get("/api/superset/dashboards", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||||
|
|
||||||
|
const token = await getServiceToken();
|
||||||
|
const resp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc)`, {
|
||||||
|
headers: { "Authorization": `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) return res.status(502).json({ error: "Superset indisponível" });
|
||||||
|
const data = await resp.json();
|
||||||
|
res.json(data.result || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/api/superset/health", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SUPERSET_URL}/health`);
|
||||||
|
res.json({ online: resp.ok, url: SUPERSET_URL });
|
||||||
|
} catch {
|
||||||
|
res.json({ online: false, url: SUPERSET_URL });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Componente React — SupersetDashboard (reutilizável em QUALQUER tela)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// client/src/components/SupersetDashboard.tsx
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||||
|
|
||||||
|
interface SupersetDashboardProps {
|
||||||
|
dashboardId: string;
|
||||||
|
height?: string | number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupersetDashboard({
|
||||||
|
dashboardId,
|
||||||
|
height = "calc(100vh - 300px)",
|
||||||
|
className = "",
|
||||||
|
}: SupersetDashboardProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
embedDashboard({
|
||||||
|
id: dashboardId,
|
||||||
|
supersetDomain: window.location.origin + "/superset",
|
||||||
|
mountPoint: containerRef.current,
|
||||||
|
|
||||||
|
// Busca guest token do gateway Arcádia
|
||||||
|
fetchGuestToken: async () => {
|
||||||
|
const resp = await fetch("/api/superset/guest-token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dashboardId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("Falha ao obter token");
|
||||||
|
const { token } = await resp.json();
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
|
dashboardUiConfig: {
|
||||||
|
hideTitle: true, // Título próprio no Arcádia
|
||||||
|
hideChartControls: false, // Manter controles de chart
|
||||||
|
filters: {
|
||||||
|
visible: true, // Mostrar filtros
|
||||||
|
expanded: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}).catch((err) => {
|
||||||
|
if (mounted) {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [dashboardId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-48 text-red-500 text-sm">
|
||||||
|
Superset indisponível: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} style={{ height }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-10">
|
||||||
|
<div className="text-center text-[#1f334d]">
|
||||||
|
<div className="w-8 h-8 border-4 border-[#c89b3c] border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-sm">Carregando Arcádia Insights...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Uso em QUALQUER página:**
|
||||||
|
```tsx
|
||||||
|
// Exemplo: na página /financeiro
|
||||||
|
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||||
|
|
||||||
|
// Dashboard financeiro embeddado diretamente no módulo Financeiro
|
||||||
|
<SupersetDashboard dashboardId="financial-overview" height={500} />
|
||||||
|
|
||||||
|
// Exemplo: na página /contabil
|
||||||
|
<SupersetDashboard dashboardId="dre-mensal" />
|
||||||
|
|
||||||
|
// Exemplo: no Dashboard principal (/)
|
||||||
|
<SupersetDashboard dashboardId="executive-summary" height={400} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 BiWorkspace.tsx — Advanced tab (única mudança na UI)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Substituir a tab Advanced (linhas 2918-2945)
|
||||||
|
|
||||||
|
<TabsContent value="advanced" className="mt-0">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1f334d]">Arcádia Insights — Apache Superset</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/superset"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" /> Abrir em Nova Aba
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard selecionável */}
|
||||||
|
<SupersetDashboardSelector />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TABELAS E VARIÁVEIS
|
||||||
|
|
||||||
|
### 5.1 Nenhuma tabela nova no schema Arcádia
|
||||||
|
|
||||||
|
O Superset usa seu próprio banco (`arcadia_superset`) para metadados internos. As tabelas do Arcádia não mudam.
|
||||||
|
|
||||||
|
### 5.2 Novas variáveis de ambiente
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env — adicionar/substituir:
|
||||||
|
|
||||||
|
# Superset (substitui METABASE_*)
|
||||||
|
SUPERSET_HOST=superset # nome do container Docker
|
||||||
|
SUPERSET_PORT=8088
|
||||||
|
SUPERSET_SECRET_KEY= # gerar: openssl rand -hex 32
|
||||||
|
SUPERSET_ADMIN_USER=admin
|
||||||
|
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
||||||
|
SUPERSET_ADMIN_PASSWORD= # senha forte em prod
|
||||||
|
|
||||||
|
# URL do banco Arcádia para o Superset acessar (read-only ideal)
|
||||||
|
ARCADIA_DATABASE_URL=postgresql://arcadia_ro:pass@db:5432/arcadia
|
||||||
|
|
||||||
|
# Remover (não são mais necessários):
|
||||||
|
# METABASE_HOST
|
||||||
|
# METABASE_PORT
|
||||||
|
# METASET_ADMIN_EMAIL
|
||||||
|
# METASET_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ROADMAP — Fases de Implementação
|
||||||
|
|
||||||
|
### Fase 1 — Infraestrutura (2-3 dias)
|
||||||
|
```
|
||||||
|
[ ] Criar docker/superset/superset_config.py
|
||||||
|
[ ] Criar docker/superset/init.sh
|
||||||
|
[ ] Atualizar docker-compose.yml (expandir seção superset existente)
|
||||||
|
[ ] Adicionar volumes: superset_home no docker-compose
|
||||||
|
[ ] Testar: docker compose --profile bi up superset
|
||||||
|
[ ] Verificar: banco arcadia_superset criado, admin funciona, :8088 acessível
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 2 — Gateway (1-2 dias)
|
||||||
|
```
|
||||||
|
[ ] Criar server/superset/proxy.ts (substitui server/metabase/proxy.ts)
|
||||||
|
[ ] Criar server/superset/routes.ts (guest token, lista dashboards, health)
|
||||||
|
[ ] Registrar no server/routes.ts: setupSupersetProxy() + registerSupersetRoutes()
|
||||||
|
[ ] Remover/comentar setupMetabaseProxy() e registerMetaSetRoutes()
|
||||||
|
[ ] Testar: GET /api/superset/health → { online: true }
|
||||||
|
[ ] Testar: POST /api/superset/guest-token → token JWT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 3 — Frontend (2-3 dias)
|
||||||
|
```
|
||||||
|
[ ] npm install @superset-ui/embedded-sdk
|
||||||
|
[ ] Criar client/src/components/SupersetDashboard.tsx
|
||||||
|
[ ] Atualizar BiWorkspace.tsx Advanced tab (substituir iframe MetaSet)
|
||||||
|
[ ] Testar: dashboard embeddado na Advanced tab
|
||||||
|
[ ] Criar 3 dashboards base no Superset:
|
||||||
|
- executive-summary (KPIs gerais)
|
||||||
|
- financial-overview (Financeiro)
|
||||||
|
- dre-mensal (DRE)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 4 — Embedding em outras telas (1 semana)
|
||||||
|
```
|
||||||
|
[ ] Embedar SupersetDashboard no /financeiro (tab Análise)
|
||||||
|
[ ] Embedar SupersetDashboard no /contabil (tab DRE/Balanço)
|
||||||
|
[ ] Embedar SupersetDashboard no SOE (tab Dashboard)
|
||||||
|
[ ] Embedar SupersetDashboard no Home / Dashboard principal
|
||||||
|
[ ] RLS por tenantId (Row Level Security nos dashboards)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 5 — Migração de dashboards (conforme necessário)
|
||||||
|
```
|
||||||
|
[ ] Recriar no Superset os dashboards que existiam no MetaSet (se houver)
|
||||||
|
[ ] Configurar alertas automáticos (Superset Alerts & Reports)
|
||||||
|
[ ] Criar dashboards para cada domínio do SOE:
|
||||||
|
- Vendas, Compras, Fiscal, Pessoas, Estoque
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. DIAGRAMA BI ATUALIZADO (completo)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ARCÁDIA BI STACK v2 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ FRONTEND REACT — Pode usar em QUALQUER rota │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ BiWorkspace.tsx → 8 tabs (7 iguais + Advanced=Superset) │ │
|
||||||
|
│ │ <SupersetDashboard> → componente reutilizável │ │
|
||||||
|
│ │ Guest Token API → /api/superset/guest-token │ │
|
||||||
|
│ └────────────────────────────┬─────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────────┼──────────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────▼──────┐ ┌────────▼───────┐ ┌─────────▼──────────┐ │
|
||||||
|
│ │ API BI │ │ BI Engine │ │ Apache Superset │ │
|
||||||
|
│ │ (Node.js) │ │ Python:8004 │ │ Python:8088 │ │
|
||||||
|
│ │ sem mudança│ │ sem mudança │ │ │ │
|
||||||
|
│ │ │ │ │ │ SQL Lab │ │
|
||||||
|
│ │ CRUD │ │ SQL+Charts │ │ 50+ chart types │ │
|
||||||
|
│ │ Upload/ETL │ │ Micro-BI │ │ Guest Token (embed) │ │
|
||||||
|
│ │ Staging │ │ Análise Pandas │ │ Alerts & Reports │ │
|
||||||
|
│ │ Backups │ │ Cache 5min │ │ REST API v1 │ │
|
||||||
|
│ └─────┬───────┘ └────────┬───────┘ └─────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────────────┼─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────▼──────────┐ │
|
||||||
|
│ │ PostgreSQL │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ arcadia │ ← Dados SOE (read-only) │
|
||||||
|
│ │ arcadia_superset │ ← Metadados Superset │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ CIENTISTA (Python) — IA/ML: análise, padrões, insights │ │
|
||||||
|
│ │ ASSISTENTE BI (GPT) — chat sobre dados │ │
|
||||||
|
│ │ (ambos sem mudança) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. RESUMO — O que muda, o que fica
|
||||||
|
|
||||||
|
| Componente | Ação | Esforço |
|
||||||
|
|---|---|---|
|
||||||
|
| `server/metabase/proxy.ts` | Substituir por `server/superset/proxy.ts` | 30 min |
|
||||||
|
| `server/metaset/routes.ts` | Substituir por `server/superset/routes.ts` | 2h |
|
||||||
|
| `server/metaset/client.ts` | Remover (lógica vai para routes.ts) | 5 min |
|
||||||
|
| `docker-compose.yml` Superset | Expandir configuração existente | 1h |
|
||||||
|
| `docker/superset/superset_config.py` | Criar (novo arquivo) | 30 min |
|
||||||
|
| `docker/superset/init.sh` | Criar (novo arquivo) | 1h |
|
||||||
|
| `BiWorkspace.tsx` Advanced tab | Substituir iframe MetaSet | 1h |
|
||||||
|
| `SupersetDashboard.tsx` | Criar componente (novo) | 2h |
|
||||||
|
| `npm install @superset-ui/embedded-sdk` | Instalar dependência | 5 min |
|
||||||
|
| BI Engine Python (:8004) | **Não muda nada** | — |
|
||||||
|
| API BI Node /api/bi/* | **Não muda nada** | — |
|
||||||
|
| Upload/ETL/Staging | **Não muda nada** | — |
|
||||||
|
| 7 tabs do BiWorkspace | **Não muda nada** | — |
|
||||||
|
| Tabelas do banco | **Não muda nada** | — |
|
||||||
|
|
||||||
|
**Total estimado: 1-2 dias de implementação real** (a infra já está 80% pronta).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. VANTAGEM ESTRATÉGICA
|
||||||
|
|
||||||
|
> O Apache Superset não é apenas "trocar Metabase".
|
||||||
|
>
|
||||||
|
> É a virada de chave que permite que o BI deixe de ser uma página separada
|
||||||
|
> e vire um **componente vivo** que aparece **dentro de cada módulo do Arcádia**:
|
||||||
|
>
|
||||||
|
> - O usuário está no módulo Financeiro → vê o dashboard financeiro ali mesmo
|
||||||
|
> - O usuário está no módulo Contábil → vê o DRE ali mesmo
|
||||||
|
> - O usuário está no SOE → vê os KPIs do negócio ali mesmo
|
||||||
|
>
|
||||||
|
> Sem abrir nova aba. Sem sair do contexto. Sem re-autenticar.
|
||||||
|
> O Superset renderiza no lugar exato onde o usuário está.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*PLANO_BI_SUPERSET.md — Arcádia Suite v3.0*
|
||||||
|
*Gerado em: 2026-03-16 — Baseado na análise do MAPA_BI_ARCADIA.md (Replit) + código real*
|
||||||
|
|
@ -0,0 +1,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 (2–3 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 (2–3 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*
|
||||||
|
|
@ -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)*
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -81,6 +82,73 @@ import {
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
|
// ── Componente da aba Insights (Apache Superset) ──────────────────────────────
|
||||||
|
function SupersetAdvancedTab() {
|
||||||
|
const [selectedDashboard, setSelectedDashboard] = useState<string>("");
|
||||||
|
const [dashboards, setDashboards] = useState<Array<{ id: string; title: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/superset/dashboards", { credentials: "include" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setDashboards(data.map((d: any) => ({ id: String(d.id), title: d.dashboard_title || d.title })));
|
||||||
|
if (data.length > 0) setSelectedDashboard(String(data[0].id));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setDashboards([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1f334d]">Arcádia Insights — Apache Superset</h2>
|
||||||
|
<p className="text-sm text-gray-500">SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{dashboards.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedDashboard}
|
||||||
|
onChange={e => setSelectedDashboard(e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-[#1f334d] focus:outline-none focus:ring-2 focus:ring-[#c89b3c]"
|
||||||
|
>
|
||||||
|
{dashboards.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/superset"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" /> Abrir completo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDashboard ? (
|
||||||
|
<SupersetDashboard
|
||||||
|
dashboardId={selectedDashboard}
|
||||||
|
height="calc(100vh - 320px)"
|
||||||
|
showOpenLink={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: "calc(100vh - 320px)" }}>
|
||||||
|
<iframe
|
||||||
|
src="/superset"
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title="Arcádia Insights — Apache Superset"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BiStats {
|
interface BiStats {
|
||||||
dataSources: number;
|
dataSources: number;
|
||||||
datasets: number;
|
datasets: number;
|
||||||
|
|
@ -2890,7 +2958,7 @@ export default function BiWorkspace() {
|
||||||
<Layers className="w-4 h-4 mr-2" /> Staging
|
<Layers className="w-4 h-4 mr-2" /> Staging
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||||||
<Settings className="w-4 h-4 mr-2" /> MetaSet
|
<Settings className="w-4 h-4 mr-2" /> Insights
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -2916,32 +2984,8 @@ export default function BiWorkspace() {
|
||||||
<StagingTab />
|
<StagingTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced" className="mt-0">
|
<TabsContent value="advanced" className="mt-0">
|
||||||
<div className="space-y-4">
|
<SupersetAdvancedTab />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-[#1f334d]">Superset - 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/superset/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="https://bi.onboardbi.com.br/superset/welcome/"
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Superset - Arcádia Insights"
|
|
||||||
data-testid="iframe-metaset-advanced"
|
|
||||||
allow="fullscreen"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
// SOE — Sistema de Operações Empresariais (alias para o módulo Plus/ERP)
|
// SOE — Sistema de Operações Empresariais
|
||||||
export { default } from "./Plus";
|
export { default } from "./ERP";
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,136 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- arcadia-internal
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Arcádia Plus (Laravel + MySQL) ──────────────────────────────────────────
|
||||||
|
# Perfil `plus` — suba com: docker compose --profile plus up
|
||||||
|
plus-db:
|
||||||
|
image: mysql:8.0
|
||||||
|
restart: always
|
||||||
|
profiles: [plus]
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${PLUS_DB_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
|
||||||
|
MYSQL_USER: ${PLUS_DB_USER:-plus}
|
||||||
|
MYSQL_PASSWORD: ${PLUS_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- plus_db:/var/lib/mysql
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${PLUS_DB_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
plus:
|
||||||
|
image: ${PLUS_IMAGE:-php:8.3-apache}
|
||||||
|
restart: always
|
||||||
|
profiles: [plus]
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_KEY: ${PLUS_APP_KEY}
|
||||||
|
APP_URL: https://${DOMAIN}/plus
|
||||||
|
DB_HOST: plus-db
|
||||||
|
DB_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
|
||||||
|
DB_USERNAME: ${PLUS_DB_USER:-plus}
|
||||||
|
DB_PASSWORD: ${PLUS_DB_PASSWORD}
|
||||||
|
SESSION_DRIVER: redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
ARCADIA_URL: https://${DOMAIN}
|
||||||
|
ARCADIA_SSO_SECRET: ${SSO_SECRET}
|
||||||
|
volumes:
|
||||||
|
- plus_storage:/var/www/html/storage
|
||||||
|
depends_on:
|
||||||
|
plus-db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Apache Superset (BI) ─────────────────────────────────────────────────────
|
||||||
|
# Perfil `bi` — suba com: docker compose --profile bi up
|
||||||
|
superset:
|
||||||
|
image: apache/superset:4.1.0
|
||||||
|
restart: always
|
||||||
|
profiles: [bi]
|
||||||
|
environment:
|
||||||
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/arcadia_superset
|
||||||
|
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||||
|
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
||||||
|
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
|
||||||
|
SUPERSET_WEBSERVER_PORT: "8088"
|
||||||
|
PYTHONPATH: /app/pythonpath
|
||||||
|
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-internal
|
||||||
|
|
||||||
|
# ── ERPNext (Frappe Framework) ───────────────────────────────────────────────
|
||||||
|
# Perfil `erpnext` — suba com: docker compose --profile erpnext up
|
||||||
|
erpnext-db:
|
||||||
|
image: mariadb:10.6
|
||||||
|
restart: always
|
||||||
|
profiles: [erpnext]
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: _frappe
|
||||||
|
MYSQL_USER: frappe
|
||||||
|
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD}
|
||||||
|
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}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
erpnext:
|
||||||
|
image: frappe/erpnext:version-15
|
||||||
|
restart: always
|
||||||
|
profiles: [erpnext]
|
||||||
|
environment:
|
||||||
|
FRAPPE_SITE_NAME_HEADER: erpnext.local
|
||||||
|
ERPNEXT_DB_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
|
||||||
|
ERPNEXT_ADMIN_PASSWORD: ${ERPNEXT_ADMIN_PASSWORD}
|
||||||
|
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
|
||||||
|
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-internal
|
||||||
|
|
||||||
# ── Microserviços Python ─────────────────────────────────────────────────────
|
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||||
contabil:
|
contabil:
|
||||||
build:
|
build:
|
||||||
|
|
@ -423,7 +553,8 @@ volumes:
|
||||||
ollama_models:
|
ollama_models:
|
||||||
open_webui_data:
|
open_webui_data:
|
||||||
|
|
||||||
superset_home:
|
|
||||||
|
|
||||||
plus_db:
|
plus_db:
|
||||||
plus_storage:
|
plus_storage:
|
||||||
superset_home:
|
superset_home:
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,82 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Arcádia Suite — Apache Superset Init Script
|
||||||
|
# Executado ao iniciar o container
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "[Superset Init] Iniciando configuracao..."
|
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
|
superset db upgrade
|
||||||
|
|
||||||
|
echo "[Superset Init] Criando admin..."
|
||||||
superset fab create-admin \
|
superset fab create-admin \
|
||||||
--username "${SUPERSET_ADMIN_USERNAME:-admin}" \
|
--username "${SUPERSET_ADMIN_USER}" \
|
||||||
--firstname "Arcadia" \
|
--firstname "Arcádia" \
|
||||||
--lastname "Admin" \
|
--lastname "Admin" \
|
||||||
--email "${SUPERSET_ADMIN_EMAIL:-admin@onboardbi.com.br}" \
|
--email "${SUPERSET_ADMIN_EMAIL}" \
|
||||||
--password "${SUPERSET_ADMIN_PASSWORD:-arcadia1337}" 2>/dev/null || true
|
--password "${SUPERSET_ADMIN_PASSWORD}" 2>/dev/null || echo "[Superset Init] Admin já existe."
|
||||||
|
|
||||||
|
echo "[Superset Init] Inicializando roles e permissões..."
|
||||||
superset init
|
superset init
|
||||||
|
|
||||||
echo "[Superset Init] Iniciando servidor..."
|
echo "[Superset Init] Registrando banco Arcádia Suite..."
|
||||||
gunicorn \
|
python - <<PYEOF
|
||||||
--bind "0.0.0.0:8088" \
|
import sys
|
||||||
--access-logfile "-" \
|
try:
|
||||||
--error-logfile "-" \
|
from superset import create_app
|
||||||
--workers 2 \
|
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 \
|
--timeout 120 \
|
||||||
|
--limit-request-line 0 \
|
||||||
|
--limit-request-field_size 0 \
|
||||||
"superset.app:create_app()"
|
"superset.app:create_app()"
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,62 @@
|
||||||
|
"""
|
||||||
|
Arcádia Suite — Apache Superset Configuration
|
||||||
|
Habilita embedding nativo via Guest Token JWT
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY", "change-in-production")
|
# ── 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(
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
"SQLALCHEMY_DATABASE_URI",
|
"DATABASE_URL",
|
||||||
"postgresql://arcadia:arcadia123@db:5432/superset"
|
"postgresql://arcadia:arcadia123@db:5432/arcadia_superset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── CORS — permite o gateway Arcádia (:5000) chamar a API ────────────────────
|
||||||
ENABLE_CORS = True
|
ENABLE_CORS = True
|
||||||
CORS_OPTIONS = {
|
CORS_OPTIONS = {
|
||||||
"supports_credentials": True,
|
"supports_credentials": True,
|
||||||
"allow_headers": ["*"],
|
"allow_headers": ["*"],
|
||||||
"resources": {r"/*": {"origins": ["https://suite.onboardbi.com.br"]}},
|
"resources": {r"/api/*": {"origins": "*"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Permitir embedding em iframe a partir do suite
|
# ── Feature Flags ─────────────────────────────────────────────────────────────
|
||||||
TALISMAN_ENABLED = False
|
|
||||||
HTTP_HEADERS = {}
|
|
||||||
|
|
||||||
# Cookie de sessão acessível por todos os subdomínios de onboardbi.com.br
|
|
||||||
SESSION_COOKIE_DOMAIN = ".onboardbi.com.br"
|
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
|
||||||
SESSION_COOKIE_SECURE = True
|
|
||||||
|
|
||||||
FEATURE_FLAGS = {
|
FEATURE_FLAGS = {
|
||||||
"EMBEDDED_SUPERSET": True,
|
"EMBEDDED_SUPERSET": True, # Embedding via Guest Token
|
||||||
"ENABLE_TEMPLATE_PROCESSING": True,
|
"ENABLE_TEMPLATE_PROCESSING": True, # Jinja templates em queries
|
||||||
"ALERT_REPORTS": True,
|
"ALERT_REPORTS": True, # Alertas automáticos
|
||||||
"DRILL_TO_DETAIL": True,
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPERSET_WEBSERVER_TIMEOUT = 300
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
CACHE_CONFIG = {
|
CACHE_CONFIG = {
|
||||||
"CACHE_TYPE": "SimpleCache",
|
"CACHE_TYPE": "SimpleCache",
|
||||||
"CACHE_DEFAULT_TIMEOUT": 300,
|
"CACHE_DEFAULT_TIMEOUT": 300, # 5 minutos
|
||||||
}
|
}
|
||||||
|
|
||||||
APP_NAME = "Arcadia Insights"
|
# ── 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"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Seed das regras padrão do SOE (soe_regras)
|
||||||
|
* Insere FISCAL_RULES_PADRAO + BUSINESS_RULES_PADRAO no banco.
|
||||||
|
* Idempotente: pula regras que já existem (por nome + trigger).
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* DATABASE_URL=postgres://... npx tsx scripts/seed-soe-rules.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pg from "pg";
|
||||||
|
import { FISCAL_RULES_PADRAO } from "../server/soe/rule-engine/fiscal-rules";
|
||||||
|
import { BUSINESS_RULES_PADRAO } from "../server/soe/rule-engine/business-rules";
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error("DATABASE_URL não definida");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const ALL_RULES = [...FISCAL_RULES_PADRAO, ...BUSINESS_RULES_PADRAO];
|
||||||
|
|
||||||
|
console.log(`Inserindo ${ALL_RULES.length} regras padrão SOE...\n`);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const rule of ALL_RULES) {
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT id FROM soe_regras WHERE nome = $1 AND trigger = $2 AND origem_padrao = true`,
|
||||||
|
[rule.nome, rule.trigger]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((existing.rowCount ?? 0) > 0) {
|
||||||
|
console.log(` [SKIP] ${rule.id}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO soe_regras (dominio, trigger, nome, condicao, acao, prioridade, ativo, origem_padrao, tenant_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
rule.dominio,
|
||||||
|
rule.trigger,
|
||||||
|
rule.nome,
|
||||||
|
JSON.stringify(rule.condicao),
|
||||||
|
JSON.stringify(rule.acao),
|
||||||
|
rule.prioridade,
|
||||||
|
rule.ativo,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` [OK] ${rule.id} — ${rule.nome}`);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
console.log(`\nConcluído: ${inserted} inseridas, ${skipped} ignoradas.`);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Express, Response } from "express";
|
import { Express, Response } from "express";
|
||||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||||
|
|
||||||
const SUPERSET_HOST = process.env.SUPERSET_HOST || "superset";
|
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||||
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||||
const SUPERSET_TIMEOUT = 60000;
|
const SUPERSET_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function setupSupersetProxy(app: Express): void {
|
||||||
if (res && typeof (res as Response).status === "function") {
|
if (res && typeof (res as Response).status === "function") {
|
||||||
(res as Response).status(502).json({
|
(res as Response).status(502).json({
|
||||||
error: "Superset indisponível",
|
error: "Superset indisponível",
|
||||||
message: "O Superset está iniciando. Tente novamente em alguns segundos.",
|
message: "O Arcádia Insights está iniciando. Tente novamente em alguns segundos.",
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,40 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
|
|
||||||
const SUPERSET_HOST = process.env.SUPERSET_HOST || "superset";
|
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||||
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||||
const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
||||||
const ADMIN_USER = process.env.SUPERSET_ADMIN_USERNAME || "admin";
|
const ADMIN_USER = process.env.SUPERSET_ADMIN_USER || "admin";
|
||||||
const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia1337";
|
const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
|
||||||
|
|
||||||
// Cache do service token (expira em 50 min)
|
// Cache do service token (válido por 50 min)
|
||||||
let serviceToken: string | null = null;
|
let cachedToken: string | null = null;
|
||||||
let serviceTokenExpiry = 0;
|
let tokenExpiry = 0;
|
||||||
|
|
||||||
async function getServiceToken(): Promise<string> {
|
async function getServiceToken(): Promise<string> {
|
||||||
if (serviceToken && Date.now() < serviceTokenExpiry) return serviceToken;
|
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
|
||||||
|
|
||||||
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS, provider: "db", refresh: true }),
|
||||||
username: ADMIN_USER,
|
|
||||||
password: ADMIN_PASS,
|
|
||||||
provider: "db",
|
|
||||||
refresh: true,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) throw new Error(`Falha ao autenticar no Superset (${resp.status})`);
|
||||||
const err = await resp.text().catch(() => "");
|
|
||||||
throw new Error(`Falha ao autenticar no Superset: ${resp.status} ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
serviceToken = data.access_token;
|
cachedToken = data.access_token;
|
||||||
serviceTokenExpiry = Date.now() + 50 * 60 * 1000;
|
tokenExpiry = Date.now() + 50 * 60 * 1000;
|
||||||
return serviceToken!;
|
return cachedToken!;
|
||||||
}
|
|
||||||
|
|
||||||
async function supersetFetch(path: string, options: RequestInit = {}): Promise<any> {
|
|
||||||
const token = await getServiceToken();
|
|
||||||
const resp = await fetch(`${SUPERSET_URL}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.status === 401) {
|
|
||||||
// Token expirado — forçar renovação
|
|
||||||
serviceToken = null;
|
|
||||||
serviceTokenExpiry = 0;
|
|
||||||
return supersetFetch(path, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.text().catch(() => "");
|
|
||||||
throw new Error(`Superset API ${resp.status}: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.json().catch(() => ({}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSupersetRoutes(app: Express): void {
|
export function registerSupersetRoutes(app: Express): void {
|
||||||
|
|
||||||
// Guest Token — para embedding em qualquer tela React
|
// Gera Guest Token para embedding em qualquer tela
|
||||||
app.post("/api/superset/guest-token", async (req: Request, res: Response) => {
|
app.post("/api/superset/guest-token", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||||
|
|
||||||
const { dashboardId } = req.body;
|
const { dashboardId } = req.body;
|
||||||
if (!dashboardId) return res.status(400).json({ error: "dashboardId obrigatório" });
|
if (!dashboardId) return res.status(400).json({ error: "dashboardId é obrigatório" });
|
||||||
|
|
||||||
const token = await getServiceToken();
|
const token = await getServiceToken();
|
||||||
const user = req.user as any;
|
const user = req.user as any;
|
||||||
|
|
@ -78,13 +43,13 @@ export function registerSupersetRoutes(app: Express): void {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
"Authorization": `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
user: {
|
user: {
|
||||||
username: `arcadia_${user?.id || "guest"}`,
|
username: `arcadia_${user?.id || "guest"}`,
|
||||||
first_name: user?.name?.split(" ")[0] || "User",
|
first_name: user?.name?.split(" ")[0] || "Arcádia",
|
||||||
last_name: user?.name?.split(" ").slice(1).join(" ") || "",
|
last_name: user?.name?.split(" ").slice(1).join(" ") || "User",
|
||||||
},
|
},
|
||||||
resources: [{ type: "dashboard", id: dashboardId }],
|
resources: [{ type: "dashboard", id: dashboardId }],
|
||||||
rls: [],
|
rls: [],
|
||||||
|
|
@ -92,110 +57,52 @@ export function registerSupersetRoutes(app: Express): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!guestResp.ok) {
|
if (!guestResp.ok) {
|
||||||
const err = await guestResp.text();
|
const errText = await guestResp.text();
|
||||||
return res.status(502).json({ error: "Falha ao gerar guest token", detail: err });
|
return res.status(502).json({ error: "Falha ao gerar guest token", detail: errText });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token: guestToken } = await guestResp.json();
|
const { token: guestToken } = await guestResp.json();
|
||||||
res.json({ token: guestToken, supersetUrl: "/superset" });
|
res.json({ token: guestToken, supersetUrl: "/superset" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.error("[Superset] guest-token error:", err.message);
|
||||||
res.status(502).json({ error: err.message });
|
res.status(502).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lista dashboards
|
// Lista dashboards disponíveis (para seletor na UI)
|
||||||
app.get("/api/superset/dashboards", async (req: Request, res: Response) => {
|
app.get("/api/superset/dashboards", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||||
const data = await supersetFetch(
|
|
||||||
"/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)"
|
|
||||||
);
|
|
||||||
res.json(data.result || []);
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lista charts
|
const token = await getServiceToken();
|
||||||
app.get("/api/superset/charts", async (req: Request, res: Response) => {
|
const resp = await fetch(
|
||||||
try {
|
`${SUPERSET_URL}/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)`,
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
{ headers: { "Authorization": `Bearer ${token}` } }
|
||||||
const data = await supersetFetch("/api/v1/chart/?q=(page_size:50)");
|
);
|
||||||
|
|
||||||
|
if (!resp.ok) return res.status(502).json({ error: "Superset indisponível", dashboards: [] });
|
||||||
|
const data = await resp.json();
|
||||||
res.json(data.result || []);
|
res.json(data.result || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(502).json({ error: err.message });
|
console.error("[Superset] dashboards error:", err.message);
|
||||||
|
res.status(502).json({ error: err.message, dashboards: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/api/superset/health", async (_req: Request, res: Response) => {
|
app.get("/api/superset/health", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${SUPERSET_URL}/health`);
|
const resp = await fetch(`${SUPERSET_URL}/health`, { signal: AbortSignal.timeout(5000) });
|
||||||
res.json({ online: resp.ok, url: SUPERSET_URL });
|
const text = await resp.text();
|
||||||
|
res.json({ online: resp.ok && text === "OK", url: SUPERSET_URL, status: text });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ online: false, url: SUPERSET_URL });
|
res.json({ online: false, url: SUPERSET_URL });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manter compatibilidade com rotas antigas do MetaSet
|
// Autologin (compatibilidade — redireciona para /superset com sessão)
|
||||||
app.get("/api/bi/metaset/health", async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${SUPERSET_URL}/health`);
|
|
||||||
res.json({ service: "Superset", online: resp.ok });
|
|
||||||
} catch {
|
|
||||||
res.json({ service: "Superset", online: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Autologin — faz login transparente no Superset e redireciona para /superset/
|
|
||||||
app.get("/api/bi/superset/autologin", async (req: Request, res: Response) => {
|
app.get("/api/bi/superset/autologin", async (req: Request, res: Response) => {
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||||
|
res.redirect("/superset/login");
|
||||||
// Passo 1: obter CSRF token da página de login
|
|
||||||
const loginPageResp = await fetch(`${SUPERSET_URL}/login/`);
|
|
||||||
const loginPageText = await loginPageResp.text();
|
|
||||||
const csrfMatch = loginPageText.match(/name="csrf_token"[\s\S]*?value="([^"]+)"/);
|
|
||||||
if (!csrfMatch) return res.status(502).json({ error: "CSRF token não encontrado no Superset" });
|
|
||||||
|
|
||||||
const csrfToken = csrfMatch[1];
|
|
||||||
const initialCookies = loginPageResp.headers.get("set-cookie") || "";
|
|
||||||
|
|
||||||
// Passo 2: fazer POST de login com as credenciais de admin
|
|
||||||
const formBody = new URLSearchParams({
|
|
||||||
username: ADMIN_USER,
|
|
||||||
password: ADMIN_PASS,
|
|
||||||
csrf_token: csrfToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginResp = await fetch(`${SUPERSET_URL}/login/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Cookie: initialCookies,
|
|
||||||
},
|
|
||||||
body: formBody.toString(),
|
|
||||||
redirect: "manual",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Passo 3: extrair o cookie de sessão e repassar ao browser
|
|
||||||
const setCookieRaw = loginResp.headers.get("set-cookie") || "";
|
|
||||||
const sessionMatch = setCookieRaw.match(/session=([^;]+)/);
|
|
||||||
if (sessionMatch) {
|
|
||||||
res.cookie("session", sessionMatch[1], {
|
|
||||||
domain: ".onboardbi.com.br",
|
|
||||||
secure: true,
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.redirect("https://bi.onboardbi.com.br/superset/welcome/");
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[Superset] Rotas registradas em /api/superset/*");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue