Merge origin/main into Servidor
# Conflicts: # Dockerfile # client/src/pages/DevCenter.tsx # client/src/pages/DevelopmentModule.tsx # server/routes.ts
This commit is contained in:
commit
f3322ab075
|
|
@ -0,0 +1,20 @@
|
||||||
|
PGPASSWORD=afefb610090de1646944b537f3054715ad5755665c90036c2bcd258c9ab52dd8
|
||||||
|
SESSION_SECRET=092a26dcbebd6f7f5e7b9e020bdc4d4e966286c9780ef72e3426f437eb8d608b
|
||||||
|
SSO_SECRET=fbb87d1c5a14a6623d4e3dcc2b679bd68c46e59afb48dd6abd42b95cac945856
|
||||||
|
LITELLM_API_KEY=e396b827cbeb5e77d85cce4eb9da0521114f57a8e799f7aa8febad08d776c07e
|
||||||
|
WEBUI_SECRET_KEY=76c7fc044f9e8d6e5eb60b67c82832b67e84739da4827ce1fa7c101ae1a055df
|
||||||
|
DOMAIN=suite.onboardbi.com.br
|
||||||
|
OLLAMA_BASE_URL=http://ollama-ia1upsekrad96at5hq97e4qa:11434
|
||||||
|
|
||||||
|
LLMFIT_BASE_URL=http://llmfit:8000
|
||||||
|
LLMFIT_API_KEY=key-arcadia
|
||||||
|
|
||||||
|
# ── Superset BI ───────────────────────────────────────────────
|
||||||
|
SUPERSET_SECRET_KEY=421274b5ba360778a5398d528116a45ea962d1197e3dfc99f36a372ff1025a63
|
||||||
|
SUPERSET_ADMIN_USERNAME=admin
|
||||||
|
SUPERSET_ADMIN_PASSWORD=arcadia1337
|
||||||
|
SUPERSET_ADMIN_EMAIL=engenharia@ometas.com.br
|
||||||
|
COMPOSE_PROFILES=bi
|
||||||
|
|
||||||
|
SUPERSET_HOST=superset
|
||||||
|
SUPERSET_PORT=8088
|
||||||
152
.env.example
152
.env.example
|
|
@ -1,70 +1,110 @@
|
||||||
# ==============================================
|
# ─── Arcádia Suite — Variáveis de Ambiente ────────────────────────────────────
|
||||||
# Arcádia Suite - Variáveis de Ambiente
|
# Copie para .env e preencha os valores.
|
||||||
# ==============================================
|
# NUNCA commite o arquivo .env real.
|
||||||
# Copie este arquivo para .env e preencha os valores
|
|
||||||
|
|
||||||
# ---- Banco de Dados PostgreSQL ----
|
# ── Banco de dados ────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgresql://arcadia:SuaSenhaSegura@db:5432/arcadia
|
DATABASE_URL=postgresql://arcadia:arcadia123@localhost:5432/arcadia
|
||||||
PGHOST=db
|
PGHOST=localhost
|
||||||
PGPORT=5432
|
PGPORT=5432
|
||||||
PGUSER=arcadia
|
PGUSER=arcadia
|
||||||
PGPASSWORD=SuaSenhaSegura
|
PGPASSWORD=arcadia123
|
||||||
PGDATABASE=arcadia
|
PGDATABASE=arcadia
|
||||||
|
|
||||||
# ---- Servidor ----
|
# ── Aplicação ─────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=production
|
NODE_ENV=development
|
||||||
PORT=5000
|
PORT=5000
|
||||||
SESSION_SECRET=gere-uma-chave-secreta-aleatoria-aqui
|
SESSION_SECRET=troque-por-string-aleatoria-segura-em-producao
|
||||||
|
SSO_SECRET=arcadia-sso-secret-2024-plus-integration-key-secure
|
||||||
|
|
||||||
# ---- SSO / Multi-tenant ----
|
# ── Docker Mode (desativa spawn de processos filhos) ─────────────────────────
|
||||||
SSO_SECRET=gere-outra-chave-secreta-aqui
|
# Em produção com Docker, defina como "true"
|
||||||
|
DOCKER_MODE=false
|
||||||
|
|
||||||
# ---- Senha do Gerente (Retail) ----
|
# ── URLs dos microserviços Python ─────────────────────────────────────────────
|
||||||
MANAGER_PASSWORD=sua-senha-de-gerente
|
# Em Docker: use nome do serviço (ex: http://contabil:8003)
|
||||||
|
# Em desenvolvimento local: use localhost
|
||||||
|
CONTABIL_PYTHON_URL=http://localhost:8003
|
||||||
|
BI_PYTHON_URL=http://localhost:8004
|
||||||
|
AUTOMATION_PYTHON_URL=http://localhost:8005
|
||||||
|
FISCO_PYTHON_URL=http://localhost:8002
|
||||||
|
PYTHON_SERVICE_URL=http://localhost:8001
|
||||||
|
|
||||||
# ---- OpenAI (opcional - para funcionalidades de IA) ----
|
# ── IA — OpenAI ───────────────────────────────────────────────────────────────
|
||||||
OPENAI_API_KEY=sk-...
|
# Deixe vazio se usar apenas Ollama (soberania total)
|
||||||
AI_INTEGRATIONS_OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=
|
||||||
AI_INTEGRATIONS_OPENAI_BASE_URL=https://api.openai.com/v1
|
|
||||||
|
|
||||||
# ---- ERPNext (opcional - integração ERP) ----
|
# ── IA — LiteLLM (gateway unificado — ÚNICA porta de entrada para LLMs) ──────
|
||||||
ERPNEXT_URL=https://seu-erpnext.com
|
# Em Docker: http://litellm:4000 | Em dev local: http://localhost:4000
|
||||||
|
LITELLM_BASE_URL=http://localhost:4000
|
||||||
|
LITELLM_API_KEY=arcadia-internal
|
||||||
|
|
||||||
|
# ── IA — Manus Agent (aponta para LiteLLM como gateway) ──────────────────────
|
||||||
|
# Em Docker: AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1
|
||||||
|
# Em dev local: http://localhost:4000/v1
|
||||||
|
AI_INTEGRATIONS_OPENAI_BASE_URL=http://localhost:4000/v1
|
||||||
|
AI_INTEGRATIONS_OPENAI_API_KEY=arcadia-internal
|
||||||
|
|
||||||
|
# ── IA — Ollama (LLMs locais — soberania total) ───────────────────────────────
|
||||||
|
# Se Ollama está no host (fora do Docker): OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
# Se Ollama está em outro servidor: OLLAMA_BASE_URL=http://IP_DO_SERVIDOR:11434
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# ── IA — LLMFit (modelos fine-tuned locais — habilitar quando disponível) ─────
|
||||||
|
# LLMFit turbocharge: modelos treinados com dados do seu negócio
|
||||||
|
# Deixe vazio para desabilitar (LiteLLM cai para Ollama automaticamente)
|
||||||
|
LLMFIT_BASE_URL=
|
||||||
|
|
||||||
|
# ── IA — Providers externos (opt-in — soberania: dados não saem sem configurar)
|
||||||
|
# Deixe vazio para operação 100% soberana (apenas Ollama + LLMFit)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GROQ_API_KEY=
|
||||||
|
|
||||||
|
# ── Open WebUI ────────────────────────────────────────────────────────────────
|
||||||
|
WEBUI_SECRET_KEY=troque-por-string-aleatoria-segura
|
||||||
|
|
||||||
|
# ── Arcádia Plus (Laravel/Fiscal) ─────────────────────────────────────────────
|
||||||
|
PLUS_URL=http://localhost:8080
|
||||||
|
PLUS_PORT=8080
|
||||||
|
PLUS_API_TOKEN=
|
||||||
|
|
||||||
|
# ── Superset (BI avançado — Arcádia Insights) ────────────────────────────────
|
||||||
|
SUPERSET_HOST=superset
|
||||||
|
SUPERSET_PORT=8088
|
||||||
|
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura # openssl rand -hex 32
|
||||||
|
SUPERSET_ADMIN_USER=admin
|
||||||
|
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
||||||
|
SUPERSET_ADMIN_PASSWORD=troque-senha-forte-em-prod
|
||||||
|
# URL do banco Arcádia para o Superset acessar os dados (idealmente read-only)
|
||||||
|
ARCADIA_DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# ── Domínio (produção) ────────────────────────────────────────────────────────
|
||||||
|
DOMAIN=seudominio.com.br
|
||||||
|
|
||||||
|
# ── ERPNext ────────────────────────────────────────────────────────────────────
|
||||||
|
# Modo 1 — ERPNext em container Docker (perfil erpnext)
|
||||||
|
# Sobe com: docker compose --profile erpnext up
|
||||||
|
# Proxy ativado automaticamente em DOCKER_MODE=true
|
||||||
|
ERPNEXT_PROXY_ENABLED=false # true para forçar proxy mesmo fora do Docker
|
||||||
|
ERPNEXT_CONTAINER_HOST=erpnext # nome do serviço no docker-compose
|
||||||
|
ERPNEXT_CONTAINER_PORT=8080
|
||||||
|
ERPNEXT_DB_ROOT_PASSWORD=erpnext-root-2026 # senha root do MariaDB
|
||||||
|
ERPNEXT_DB_PASSWORD=frappe2026 # senha do usuário frappe
|
||||||
|
ERPNEXT_ADMIN_PASSWORD=admin2026 # senha do Administrator no site
|
||||||
|
|
||||||
|
# Modo 2 — ERPNext externo (instância existente)
|
||||||
|
# Em Docker: ERPNEXT_URL=http://erpnext:8080 (aponta pro container)
|
||||||
|
# Externo: ERPNEXT_URL=https://meuerpnext.com
|
||||||
|
ERPNEXT_URL=
|
||||||
ERPNEXT_API_KEY=
|
ERPNEXT_API_KEY=
|
||||||
ERPNEXT_API_SECRET=
|
ERPNEXT_API_SECRET=
|
||||||
|
|
||||||
# ---- GitHub (opcional - integração DevCenter) ----
|
# GitHub (para sync de código)
|
||||||
GITHUB_TOKEN=ghp_...
|
GITHUB_TOKEN=
|
||||||
GITHUB_OWNER=seu-usuario
|
GITHUB_REPO=
|
||||||
GITHUB_REPO=seu-repositorio
|
|
||||||
GITHUB_DEFAULT_BRANCH=main
|
|
||||||
|
|
||||||
# ---- Arcádia Plus - ERP Laravel (opcional) ----
|
# WhatsApp (Baileys — sessões salvas no banco)
|
||||||
PLUS_URL=http://localhost:8080
|
# Nenhuma variável adicional necessária — configurado via interface
|
||||||
PLUS_API_TOKEN=
|
|
||||||
|
|
||||||
# ---- Serviços Python (opcional) ----
|
|
||||||
PYTHON_SERVICE_URL=http://localhost:8001
|
|
||||||
FISCO_PYTHON_URL=http://localhost:8002
|
|
||||||
FISCO_PORT=8002
|
|
||||||
CONTABIL_PYTHON_URL=http://localhost:8003
|
|
||||||
CONTABIL_PORT=8003
|
|
||||||
PEOPLE_PYTHON_URL=http://localhost:8004
|
|
||||||
PEOPLE_PORT=8004
|
|
||||||
|
|
||||||
# ---- Protocolos MCP/A2A (opcional) ----
|
|
||||||
PROTOCOL_API_KEYS=sua-api-key-aqui
|
|
||||||
|
|
||||||
# ---- Apache Superset — BI Visual (opcional, ativar com --profile bi) ----
|
|
||||||
# Gerar chave: openssl rand -hex 32
|
|
||||||
SUPERSET_SECRET_KEY=gere-com-openssl-rand-hex-32
|
|
||||||
SUPERSET_ADMIN_USER=admin
|
|
||||||
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
|
||||||
SUPERSET_ADMIN_PASSWORD=troque-em-producao
|
|
||||||
# Domínio público do Superset (apenas prod/Traefik)
|
|
||||||
SUPERSET_DOMAIN=bi.seudominio.com.br
|
|
||||||
|
|
||||||
# Remover do .env se existirem (não são mais necessários):
|
|
||||||
# METABASE_HOST
|
|
||||||
# METABASE_PORT
|
|
||||||
# METASET_ADMIN_EMAIL
|
|
||||||
# METASET_ADMIN_PASSWORD
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# Replit specific
|
# Replit specific
|
||||||
.cache/
|
.cache/
|
||||||
|
|
@ -63,18 +60,4 @@ attached_assets/
|
||||||
|
|
||||||
# Package lock (regenerated on install)
|
# Package lock (regenerated on install)
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.claude/
|
||||||
# Documentation files (except essential)
|
|
||||||
MAPA_*.md
|
|
||||||
AUDITORIA_*.md
|
|
||||||
AVALIACAO_*.md
|
|
||||||
DOCUMENTATION.md
|
|
||||||
HOSPEDAGEM.md
|
|
||||||
PLANO_*.md
|
|
||||||
RELATORIO_*.md
|
|
||||||
REQUISITOS_*.md
|
|
||||||
*.pdf
|
|
||||||
*.txt
|
|
||||||
!README.md
|
|
||||||
!replit.md
|
|
||||||
.aider*
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,475 @@
|
||||||
|
# Fase 4: OpenClaw Embutido — Plano de Implementação
|
||||||
|
|
||||||
|
**Fase:** 4
|
||||||
|
**Data Início:** 2026-03-26
|
||||||
|
**Prazo:** 2026-04-08 (Semanas 7-8)
|
||||||
|
**Status:** 🔄 Sprint 3 Pendente (Sprint 1 e 2 concluídos)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. OBJETIVO DA FASE
|
||||||
|
|
||||||
|
Implementar **detecção proativa de padrões** e **sugestão emergente de skills**, transformando comportamentos repetitivos do usuário em automações reutilizáveis através de um widget conversacional flutuante.
|
||||||
|
|
||||||
|
### Entregável Final
|
||||||
|
✅ Skills emergentes funcionando end-to-end:
|
||||||
|
- Usuário executa ação repetida → PatternDetector detecta → OpenClawWidget sugere → Blackboard gera DRAFT → Dev Center aprova → Skill disponível
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ARQUITETURA
|
||||||
|
|
||||||
|
```
|
||||||
|
FLUXO OPENCCLAW:
|
||||||
|
|
||||||
|
1. USER INTERACTION
|
||||||
|
└─ Usuário usa Arcádia
|
||||||
|
└─ Learning API registra evento (já existe)
|
||||||
|
└─ Neo4j armazena interação
|
||||||
|
|
||||||
|
2. PATTERN DETECTION (NOVO)
|
||||||
|
└─ PatternDetector analisa Learning API
|
||||||
|
└─ Identifica: 3+ ocorrências em 30 dias
|
||||||
|
└─ Calcula confiança (threshold 0.8+)
|
||||||
|
└─ Dispara evento no Socket.IO
|
||||||
|
|
||||||
|
3. WIDGET SUGGESTION (NOVO)
|
||||||
|
└─ OpenClawWidget fica "ouvindo"
|
||||||
|
└─ Recebe evento de padrão detectado
|
||||||
|
└─ Exibe notificação flutuante
|
||||||
|
└─ Modal SkillSuggestion mostra preview
|
||||||
|
|
||||||
|
4. SKILL GENERATION (NOVO)
|
||||||
|
└─ Usuário confirma
|
||||||
|
└─ OpenClawEngine chama Blackboard
|
||||||
|
└─ Blackboard gera código (DRAFT)
|
||||||
|
└─ Armazena em database
|
||||||
|
|
||||||
|
5. APPROVAL FLOW (EXISTENTE - Dev Center)
|
||||||
|
└─ Skill DRAFT aparece em Dev Center
|
||||||
|
└─ Admin/Lead revisa e aprova
|
||||||
|
└─ Publica no tenant
|
||||||
|
└─ Skill fica disponível para reutilização
|
||||||
|
```
|
||||||
|
|
||||||
|
### Componentes para Implementar
|
||||||
|
|
||||||
|
```
|
||||||
|
BACKEND:
|
||||||
|
server/modules/openclaw/
|
||||||
|
├── PatternDetector.ts ← [NOVO] Detecta padrões
|
||||||
|
├── SkillEmergence.ts ← [NOVO] Coordena criação
|
||||||
|
├── OpenClawEngine.ts ← [NOVO] Lógica central
|
||||||
|
├── config/
|
||||||
|
│ └── arcadia.config.yaml ← [NOVO] Configuração
|
||||||
|
|
||||||
|
FRONTEND:
|
||||||
|
client/src/components/openclaw/
|
||||||
|
├── OpenClawWidget.tsx ← [NOVO] Widget flutuante
|
||||||
|
├── PatternDetector.ts ← [NOVO] Hook de detecção
|
||||||
|
├── SkillSuggestion.tsx ← [NOVO] Modal de sugestão
|
||||||
|
└── useAgentEmergence.ts ← [NOVO] Lógica de emergência
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DEPENDÊNCIAS VERIFICADAS
|
||||||
|
|
||||||
|
| Componente | Status | Localização |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| **Skills Engine** | ✅ Pronto | `server/skills/engine.ts` |
|
||||||
|
| **Blackboard** | ✅ Pronto | `server/blackboard/service.ts` |
|
||||||
|
| **Dev Center** | ✅ Pronto | `client/src/pages/DevCenter.tsx` |
|
||||||
|
| **Learning API** | ✅ Existe | `/api/learning/*` |
|
||||||
|
| **Neo4j (KG)** | ✅ Running | Docker |
|
||||||
|
| **Socket.IO** | ✅ Configurado | `server/socket-io.ts` |
|
||||||
|
| **PostgreSQL** | ✅ Rodando | Docker |
|
||||||
|
| **Scientist (MiroFlow)** | ✅ Existe | `/api/manus/scientist/*` |
|
||||||
|
|
||||||
|
**Conclusão:** Todas as dependências estão prontas ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TASKS DETALHADAS
|
||||||
|
|
||||||
|
### SPRINT 1 (26-27/03): PatternDetector Backend — ✅ COMPLETO
|
||||||
|
|
||||||
|
#### Task 1.1: Criar PatternDetector.ts ✅
|
||||||
|
**Arquivo:** `server/modules/openclaw/PatternDetector.ts`
|
||||||
|
**Objetivo:** Analisar eventos de usuário e detectar padrões
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pseudocódigo da estrutura
|
||||||
|
export class PatternDetector {
|
||||||
|
// Config
|
||||||
|
min_occurrences: 3;
|
||||||
|
time_window_days: 30;
|
||||||
|
confidence_threshold: 0.8;
|
||||||
|
|
||||||
|
// Métodos
|
||||||
|
detectPattern(userId: string, action: string): Pattern | null
|
||||||
|
calculateConfidence(interactions: Interaction[]): number
|
||||||
|
storePattern(pattern: Pattern): Promise<void>
|
||||||
|
emitEvent(pattern: Pattern): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Entrada:** Eventos do Learning API (`/api/learning/interactions`)
|
||||||
|
**Saída:** Pattern objects armazenados em Neo4j + Socket.IO event
|
||||||
|
**Dependência:** Learning API funcionando ✅
|
||||||
|
|
||||||
|
#### Task 1.2: Criar OpenClawEngine.ts ✅
|
||||||
|
**Arquivo:** `server/modules/openclaw/OpenClawEngine.ts`
|
||||||
|
**Objetivo:** Orquestração central de OpenClaw
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class OpenClawEngine {
|
||||||
|
patternDetector: PatternDetector;
|
||||||
|
skillEmergence: SkillEmergence;
|
||||||
|
|
||||||
|
// Métodos
|
||||||
|
async suggestSkill(pattern: Pattern): Promise<SkillSuggestion>
|
||||||
|
async onUserConfirmation(suggestion: SkillSuggestion): Promise<Skill>
|
||||||
|
async checkPatternsPeriodicly(): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Entrada:** Padrões detectados
|
||||||
|
**Saída:** Sugestões + Skills gerados
|
||||||
|
**Evento:** Socket.IO broadcast `/openclaw/pattern-detected`
|
||||||
|
|
||||||
|
#### Task 1.3: Criar SkillEmergence.ts ✅
|
||||||
|
**Arquivo:** `server/modules/openclaw/SkillEmergence.ts`
|
||||||
|
**Objetivo:** Integração com Blackboard para codegen
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class SkillEmergence {
|
||||||
|
async generateSkillDraft(pattern: Pattern): Promise<SkillDraft> {
|
||||||
|
// 1. Montar prompt baseado no padrão
|
||||||
|
// 2. Chamar POST /api/blackboard/generate-skill
|
||||||
|
// 3. Receber código gerado
|
||||||
|
// 4. Salvar como DRAFT no database
|
||||||
|
// 5. Notificar Dev Center
|
||||||
|
// 6. Retornar skill DRAFT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integração:** POST `/api/blackboard/generate-skill`
|
||||||
|
**Database:** skills table (com status: 'draft')
|
||||||
|
|
||||||
|
#### Task 1.4: Criar rotas OpenClaw ✅
|
||||||
|
**Arquivo:** `server/modules/openclaw/routes.ts`
|
||||||
|
**Rotas:**
|
||||||
|
```
|
||||||
|
POST /api/openclaw/detect-patterns ← Trigger manual de análise
|
||||||
|
GET /api/openclaw/patterns ← Listar padrões detectados
|
||||||
|
POST /api/openclaw/confirm-skill ← Usuário confirma sugestão
|
||||||
|
GET /api/openclaw/suggestions ← Histórico de sugestões
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 1.5: Integrar em server/index.ts ✅
|
||||||
|
**Arquivo:** `server/index.ts`
|
||||||
|
**Ação:** Importar rotas OpenClaw e carregá-las
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import openclawRoutes from "./modules/openclaw/routes";
|
||||||
|
app.use("/api/openclaw", openclawRoutes);
|
||||||
|
```
|
||||||
|
|
||||||
|
### SPRINT 2 (28-29/03): Frontend Widgets — ✅ COMPLETO (commit 5231166)
|
||||||
|
|
||||||
|
#### Task 2.1: Criar OpenClawWidget.tsx ✅
|
||||||
|
**Arquivo:** `client/src/components/openclaw/OpenClawWidget.tsx`
|
||||||
|
**Objetivo:** Widget flutuante que recebe notificações de padrões
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function OpenClawWidget() {
|
||||||
|
// Escutar Socket.IO event: /openclaw/pattern-detected
|
||||||
|
// Exibir notificação flutuante (canto inferior direito)
|
||||||
|
// Mostrar padrão detectado
|
||||||
|
// Botão "Ver Sugestão" abre modal
|
||||||
|
// Botão "Ignorar" descarta
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4">
|
||||||
|
{/* Widget flutuante */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Socket.IO:** Escuta evento `/openclaw/pattern-detected`
|
||||||
|
**Trigger:** Modal SkillSuggestion
|
||||||
|
|
||||||
|
#### Task 2.2: Criar SkillSuggestion.tsx ✅
|
||||||
|
**Arquivo:** `client/src/components/openclaw/SkillSuggestion.tsx`
|
||||||
|
**Objetivo:** Modal com preview da skill sugerida
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function SkillSuggestion({ pattern, suggestion }) {
|
||||||
|
// Modal mostra:
|
||||||
|
// 1. "Detectamos um padrão na sua utilização:"
|
||||||
|
// 2. Preview do padrão (ações repetidas)
|
||||||
|
// 3. Preview da automação gerada
|
||||||
|
// 4. Campo de nome (auto-preenchido)
|
||||||
|
// 5. Descrição (auto-gerada)
|
||||||
|
// 6. Botão "Criar Skill" ou "Cancelar"
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
// POST /api/openclaw/confirm-skill
|
||||||
|
// Esperar resposta (skill DRAFT)
|
||||||
|
// Toast: "Skill criada! Vá para Dev Center para publicar"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integração:** Chama `POST /api/openclaw/confirm-skill`
|
||||||
|
**Feedback:** Toast notification
|
||||||
|
|
||||||
|
#### Task 2.3: Hook useAgentEmergence.ts ✅
|
||||||
|
**Arquivo:** `client/src/hooks/useAgentEmergence.ts`
|
||||||
|
**Objetivo:** Lógica de emergência de agentes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useAgentEmergence(userId: string) {
|
||||||
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Socket.IO listener
|
||||||
|
socket.on("openclaw:pattern-detected", (pattern) => {
|
||||||
|
// Filtrar por tenant, user prefs
|
||||||
|
// Atualizar suggestions state
|
||||||
|
setSuggestions([...suggestions, pattern]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { suggestions, dismiss, confirm };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 2.4: Integrar Widget em layout principal ✅
|
||||||
|
**Arquivo:** `client/src/App.tsx` ou `client/src/layouts/MainLayout.tsx`
|
||||||
|
**Ação:** Adicionar OpenClawWidget ao layout persistente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Conteúdo existente */}
|
||||||
|
<OpenClawWidget /> {/* ← Adicionar aqui */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SPRINT 3 (30-31/03): Configuração e Integração — ⏳ PRÓXIMO
|
||||||
|
|
||||||
|
#### Task 3.1: Criar arcadia.config.yaml
|
||||||
|
**Arquivo:** `server/modules/openclaw/config/arcadia.config.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openclaw:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
arcadia_api_url: http://localhost:3000/api
|
||||||
|
arcadia_kg_url: http://localhost:7474
|
||||||
|
blackboard_url: http://localhost:3000/api/blackboard
|
||||||
|
|
||||||
|
# Tenant awareness
|
||||||
|
tenant_aware: true
|
||||||
|
|
||||||
|
# Detecção de padrões
|
||||||
|
pattern_detection:
|
||||||
|
enabled: true
|
||||||
|
min_occurrences: 3
|
||||||
|
time_window_days: 30
|
||||||
|
confidence_threshold: 0.8
|
||||||
|
scheduled_check_interval_minutes: 60
|
||||||
|
|
||||||
|
# Sugestões
|
||||||
|
suggestions:
|
||||||
|
enabled: true
|
||||||
|
auto_suggest: false # Sempre pedir confirmação do usuário
|
||||||
|
channels: ['widget', 'chat']
|
||||||
|
priority_threshold: 0.85
|
||||||
|
|
||||||
|
# Skills emergentes
|
||||||
|
emergence:
|
||||||
|
create_as_draft: true # Sempre requer aprovação
|
||||||
|
auto_publish: false
|
||||||
|
notify_admins: true
|
||||||
|
skill_prefix: "emergent_" # Prefixo para skills auto-geradas
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging:
|
||||||
|
enabled: true
|
||||||
|
store_patterns: true
|
||||||
|
store_in_kg: true
|
||||||
|
audit_trail: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 3.2: Integrar Socket.IO events
|
||||||
|
**Arquivo:** `server/socket-io.ts`
|
||||||
|
**Ação:** Adicionar listeners e emitters para OpenClaw
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
socket.on("openclaw:confirm-skill", async (data) => {
|
||||||
|
// Receber confirmação do usuário
|
||||||
|
// Chamar SkillEmergence.generateSkillDraft()
|
||||||
|
// Emitir "openclaw:skill-created"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emitter no PatternDetector
|
||||||
|
io.emit("openclaw:pattern-detected", pattern);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 3.3: Database schema para OpenClaw
|
||||||
|
**Arquivo:** `shared/schema.ts` (Drizzle ORM)
|
||||||
|
**Adicionar tabelas:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Padrões detectados
|
||||||
|
export const detectedPatterns = pgTable("detected_patterns", {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
tenant_id: uuid().notNull(),
|
||||||
|
user_id: uuid().notNull(),
|
||||||
|
action_type: varchar(255).notNull(),
|
||||||
|
occurrences: integer().notNull(),
|
||||||
|
confidence: numeric(3, 2).notNull(),
|
||||||
|
time_window_days: integer().notNull(),
|
||||||
|
created_at: timestamp().defaultNow(),
|
||||||
|
metadata: jsonb(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sugestões geradas
|
||||||
|
export const skillSuggestions = pgTable("skill_suggestions", {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
pattern_id: integer().references(() => detectedPatterns.id),
|
||||||
|
skill_draft_id: uuid().references(() => skills.id),
|
||||||
|
status: varchar(50).notNull(), // 'suggested', 'accepted', 'rejected'
|
||||||
|
created_at: timestamp().defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 3.4: Migration SQL
|
||||||
|
**Arquivo:** `migrations/0004_openclaw_tables.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE detected_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
action_type VARCHAR(255) NOT NULL,
|
||||||
|
occurrences INTEGER NOT NULL,
|
||||||
|
confidence NUMERIC(3,2) NOT NULL,
|
||||||
|
time_window_days INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE skill_suggestions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_id INTEGER REFERENCES detected_patterns(id),
|
||||||
|
skill_draft_id UUID REFERENCES skills(id),
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_detected_patterns_user_tenant
|
||||||
|
ON detected_patterns(user_id, tenant_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 3.5: Testes unitários
|
||||||
|
**Arquivo:** `tests/openclaw.test.ts`
|
||||||
|
- PatternDetector detecta padrão corretamente
|
||||||
|
- OpenClawEngine orquestra fluxo correto
|
||||||
|
- SkillEmergence integra com Blackboard
|
||||||
|
- Socket.IO emite eventos corretamente
|
||||||
|
|
||||||
|
#### Task 3.6: Documentação
|
||||||
|
**Arquivo:** `DOCUMENTATION.md` (adicionar seção)
|
||||||
|
- Como OpenClaw funciona
|
||||||
|
- Configuração por tenant
|
||||||
|
- Troubleshooting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TIMELINE
|
||||||
|
|
||||||
|
| Data | Sprint | Tasks | Status |
|
||||||
|
|------|--------|-------|--------|
|
||||||
|
| 26-27/03 | 1 | PatternDetector backend | ✅ Completo |
|
||||||
|
| 26/03 | 2 | OpenClawWidget frontend | ✅ Completo (commit 5231166) |
|
||||||
|
| 26/03 | 3 | Migration DB + testes | ⏳ Próximo |
|
||||||
|
| 01-08/04 | Buffer | Refinamento, testes E2E | ⏳ Próximo |
|
||||||
|
|
||||||
|
**Nota:** Trabalho em `/opt/arcadia_merged/` (diretório canônico do projeto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CRITÉRIOS DE SUCESSO
|
||||||
|
|
||||||
|
✅ **PatternDetector funciona:**
|
||||||
|
- Detecta padrão após 3 ocorrências em 30 dias
|
||||||
|
- Confiança calculada corretamente
|
||||||
|
- Eventos emitidos para Socket.IO
|
||||||
|
|
||||||
|
✅ **OpenClawWidget funciona:**
|
||||||
|
- Widget aparece quando padrão é detectado
|
||||||
|
- Modal mostra sugestão corretamente
|
||||||
|
- Usuário pode confirmar ou rejeitar
|
||||||
|
|
||||||
|
✅ **SkillEmergence funciona:**
|
||||||
|
- Integra com Blackboard
|
||||||
|
- Gera skill como DRAFT
|
||||||
|
- Aparece em Dev Center para aprovação
|
||||||
|
|
||||||
|
✅ **E2E funciona:**
|
||||||
|
- User faz ação 3x
|
||||||
|
- Widget sugere
|
||||||
|
- Approva
|
||||||
|
- Skill publicada
|
||||||
|
- Skill disponível para reutilização
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. RISCOS E MITIGAÇÕES
|
||||||
|
|
||||||
|
| Risco | Probabilidade | Mitigação |
|
||||||
|
|-------|--------------|-----------|
|
||||||
|
| Blackboard não retorna código correto | Média | Testar integração antes, ter fallback |
|
||||||
|
| Padrões detectados incorretamente | Média | Validar threshold, testes no KG |
|
||||||
|
| Socket.IO não emite em tempo real | Baixa | Já funciona em outras partes |
|
||||||
|
| Performance com muitos eventos | Média | Usar índices em PG, cache em Redis |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. BRANCH E COMMITS
|
||||||
|
|
||||||
|
**Branch:** `Servidor` (deploy branch, conforme feedback anterior)
|
||||||
|
**Commit pattern:**
|
||||||
|
```
|
||||||
|
feat(openclaw): PatternDetector backend
|
||||||
|
feat(openclaw): OpenClawWidget frontend
|
||||||
|
feat(openclaw): Integração Blackboard + esquema DB
|
||||||
|
docs(openclaw): Documentação e testes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. PRÓXIMAS AÇÕES
|
||||||
|
|
||||||
|
1. ✅ **26/03:** Sprint 1 concluído (PatternDetector, OpenClawEngine, SkillEmergence, routes, config)
|
||||||
|
2. ✅ **26/03:** Sprint 2 concluído (OpenClawWidget, SkillSuggestion, useAgentEmergence, App.tsx — commit 5231166)
|
||||||
|
3. ⏳ **Agora:** Sprint 3 — migration SQL + vitest + testes unitários em `/opt/arcadia_merged/`
|
||||||
|
4. ⏳ **01-08/04:** Refinamento e testes E2E
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fase 4: OpenClaw Embutido — Plano Executável*
|
||||||
|
*Data: 2026-03-26*
|
||||||
|
*Pronto para implementação ✅*
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Quick Task 260326-kmz: Implementar Skill Fabric
|
||||||
|
|
||||||
|
**Description:** Implementar Skill Fabric — gerador e gestor de skills da Arcádia Suite
|
||||||
|
**Date:** 2026-03-26
|
||||||
|
**Mode:** quick
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Skill Fabric Module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `server/modules/skill-fabric/src/types/index.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/skill/Skill.entity.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/compiler/VisualCompiler.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/compiler/CodeCompiler.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/compiler/MarkdownCompiler.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/validator/ValidationPipeline.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/executor/SkillExecutor.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/executor/Sandbox.ts`
|
||||||
|
- `server/modules/skill-fabric/src/core/lifecycle/LifecycleManager.ts`
|
||||||
|
- `server/modules/skill-fabric/src/api/routes/skills.routes.ts`
|
||||||
|
- `server/modules/skill-fabric/src/api/controllers/skills.controller.ts`
|
||||||
|
- `server/modules/skill-fabric/index.ts`
|
||||||
|
|
||||||
|
**Action:** Create the full backend Skill Fabric module with types, entity, compilers, validator pipeline, executor/sandbox, lifecycle manager, and API routes. The project uses TypeScript + Express + Drizzle ORM. Reuse existing `db` and `skills` schema from `../../shared/schema`. No new DB migration needed — extend existing skills table logic.
|
||||||
|
|
||||||
|
**Context:**
|
||||||
|
- Existing `server/skills/routes.ts` has basic CRUD. The new module adds compilation, validation, execution, versioning, lifecycle.
|
||||||
|
- DB import: `import { db } from "@/db"` or `import { db } from "../../../db"` — check what works
|
||||||
|
- Existing schema: `skills`, `skillExecutions` from `../../shared/schema`
|
||||||
|
- Do NOT use vm2 (needs install) — use Node.js built-in `vm` module for sandbox
|
||||||
|
- Do NOT use ReactFlow/Monaco yet (frontend task) — backend only here
|
||||||
|
|
||||||
|
**Done:** Files created, module compiles without errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — Skill Fabric UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `client/src/modules/skill-fabric/index.ts`
|
||||||
|
- `client/src/modules/skill-fabric/types.ts`
|
||||||
|
- `client/src/modules/skill-fabric/api.ts`
|
||||||
|
- `client/src/modules/skill-fabric/code-ide/components/SkillCodeEditor.tsx`
|
||||||
|
- `client/src/modules/skill-fabric/markdown-studio/components/MarkdownEditor.tsx`
|
||||||
|
- `client/src/modules/skill-fabric/shared/components/SkillToolbar.tsx`
|
||||||
|
- `client/src/modules/skill-fabric/shared/components/VersionSelector.tsx`
|
||||||
|
- `client/src/modules/skill-fabric/shared/components/ValidationPanel.tsx`
|
||||||
|
- `client/src/pages/SkillFabricPage.tsx`
|
||||||
|
|
||||||
|
**Action:** Create frontend Skill Fabric module. Use textarea-based code editor (Monaco not installed — don't add it). Use Tailwind CSS + shadcn/ui components matching the existing project style. Create a SkillFabricPage that lists skills, allows creating/editing (code + markdown modes), compiling, validating, and executing. Wire to the backend API routes from Task 1.
|
||||||
|
|
||||||
|
**Context:**
|
||||||
|
- Check `client/src/components/` for existing UI patterns (shadcn components available)
|
||||||
|
- Check `client/src/App.tsx` to see how routes are registered — add the new page there
|
||||||
|
- The visual canvas (ReactFlow) is a stretch goal — skip it, implement code + markdown modes only
|
||||||
|
- API base: `/api/skills` (existing) + `/api/skill-fabric` (new from Task 1)
|
||||||
|
|
||||||
|
**Done:** SkillFabricPage renders, skills can be listed and created
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Register Routes & Integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `server/modules/loader.ts`
|
||||||
|
- `client/src/App.tsx` (or router file)
|
||||||
|
|
||||||
|
**Action:** Register the new skill-fabric backend routes in `server/modules/loader.ts`. Add the SkillFabricPage route in the frontend router. Verify existing skills route still works.
|
||||||
|
|
||||||
|
**Done:** `/api/skill-fabric/*` routes registered, frontend page accessible
|
||||||
8
.replit
8
.replit
|
|
@ -4,16 +4,12 @@ hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
|
||||||
|
|
||||||
[nix]
|
[nix]
|
||||||
channel = "stable-24_05"
|
channel = "stable-24_05"
|
||||||
packages = ["glibcLocales", "libiconv", "libxcrypt", "openssl", "pkg-config", "unzip", "zlib", "zip"]
|
packages = ["cairo", "cargo", "ffmpeg-full", "freetype", "ghostscript", "gitFull", "glibcLocales", "gobject-introspection", "gtk3", "libiconv", "libxcrypt", "mysql80", "openssl", "php82", "php82Packages.composer", "pkg-config", "playwright-driver", "qhull", "rustc", "tcl", "tk", "unzip", "xcodebuild", "zlib", "jdk21"]
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 5000
|
localPort = 5000
|
||||||
externalPort = 80
|
externalPort = 80
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 5904
|
|
||||||
externalPort = 6800
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 8001
|
localPort = 8001
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
@ -53,7 +49,7 @@ PORT = "5000"
|
||||||
deploymentTarget = "autoscale"
|
deploymentTarget = "autoscale"
|
||||||
build = ["npm", "run", "build"]
|
build = ["npm", "run", "build"]
|
||||||
publicDir = "dist/public"
|
publicDir = "dist/public"
|
||||||
run = ["npm", "run", "start"]
|
run = ["node", "./dist/index.cjs"]
|
||||||
|
|
||||||
[workflows]
|
[workflows]
|
||||||
runButton = "Project"
|
runButton = "Project"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,553 @@
|
||||||
|
# Arcádia Suite - Documentação Técnica Completa
|
||||||
|
|
||||||
|
**Versão:** 1.0
|
||||||
|
**Data:** Janeiro 2026
|
||||||
|
**Desenvolvido por:** Arcádia Technology
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sumário
|
||||||
|
|
||||||
|
1. [Visão Geral](#visão-geral)
|
||||||
|
2. [Arquitetura do Sistema](#arquitetura-do-sistema)
|
||||||
|
3. [Módulos do Sistema](#módulos-do-sistema)
|
||||||
|
4. [Modelo de Dados](#modelo-de-dados)
|
||||||
|
5. [APIs e Endpoints](#apis-e-endpoints)
|
||||||
|
6. [Integrações Externas](#integrações-externas)
|
||||||
|
7. [Segurança e Autenticação](#segurança-e-autenticação)
|
||||||
|
8. [Guia de Implantação](#guia-de-implantação)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
O **Arcádia Suite** é um Sistema Operacional Empresarial (Business Operating System) alimentado por Inteligência Artificial, projetado para revolucionar operações empresariais. O sistema integra cinco pilares fundamentais:
|
||||||
|
|
||||||
|
### Os 5 Pilares
|
||||||
|
|
||||||
|
1. **Knowledge Graph** - Grafo de conhecimento para dados empresariais interconectados
|
||||||
|
2. **Central Intelligence (Scientist)** - Módulo de IA para geração automática de soluções
|
||||||
|
3. **Manus (Agente Autônomo)** - Execução de tarefas e automação
|
||||||
|
4. **Centro de Comunicação Unificado** - Interação com clientes via múltiplas plataformas
|
||||||
|
5. **IDE Completa** - Ambiente de desenvolvimento multi-modal
|
||||||
|
|
||||||
|
### Segmentação de Produtos
|
||||||
|
|
||||||
|
| Produto | Público-Alvo | Stack Tecnológica |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| **Arcádia Plus** | Pequenas empresas | Node.js + Python + PostgreSQL |
|
||||||
|
| **Arcádia Next** | Médias/Grandes empresas | Frappe Framework + PostgreSQL |
|
||||||
|
|
||||||
|
Ambos compartilham o **Arcádia Fisco** como motor fiscal centralizado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitetura do Sistema
|
||||||
|
|
||||||
|
### Arquitetura em 4 Camadas
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE APRESENTAÇÃO │
|
||||||
|
│ React 18 + TypeScript + Tailwind CSS + shadcn/ui │
|
||||||
|
│ Interface estilo navegador com abas e omnibox │
|
||||||
|
│ Porta: 5000 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE ORQUESTRAÇÃO │
|
||||||
|
│ Express.js + Socket.IO + Manus Agent │
|
||||||
|
│ API REST + WebSocket em tempo real │
|
||||||
|
│ Porta: 5000 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE INTELIGÊNCIA │
|
||||||
|
│ FastAPI (Python) + OpenAI API │
|
||||||
|
│ Scientist, Embeddings, RPA, Workflows │
|
||||||
|
│ Porta: 8001 (IA) / 8002 (Fisco) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE DADOS │
|
||||||
|
│ PostgreSQL + Knowledge Graph + ChromaDB │
|
||||||
|
│ Drizzle ORM + Session Store │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrutura de Diretórios
|
||||||
|
|
||||||
|
```
|
||||||
|
arcadia-suite/
|
||||||
|
├── client/ # Frontend React
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/ # Componentes reutilizáveis
|
||||||
|
│ ├── hooks/ # React hooks customizados
|
||||||
|
│ ├── lib/ # Utilitários e configurações
|
||||||
|
│ └── pages/ # Páginas da aplicação
|
||||||
|
├── server/ # Backend Node.js
|
||||||
|
│ ├── admin/ # Rotas administrativas
|
||||||
|
│ ├── api-central/ # Central de APIs
|
||||||
|
│ ├── automations/ # Motor de automações
|
||||||
|
│ ├── bi/ # Business Intelligence
|
||||||
|
│ ├── chat/ # Chat interno
|
||||||
|
│ ├── compass/ # Process Compass (clientes/projetos)
|
||||||
|
│ ├── crm/ # Gestão de relacionamento
|
||||||
|
│ ├── email/ # Serviço de e-mail
|
||||||
|
│ ├── erp/ # Integração ERP
|
||||||
|
│ ├── fisco/ # Motor fiscal (NF-e)
|
||||||
|
│ ├── ide/ # IDE integrada
|
||||||
|
│ ├── learning/ # Sistema de aprendizado
|
||||||
|
│ ├── login-bridge/ # Bridge de autenticação
|
||||||
|
│ ├── manus/ # Agente autônomo
|
||||||
|
│ ├── production/ # Gestão de produção
|
||||||
|
│ ├── productivity/ # Hub de produtividade
|
||||||
|
│ ├── proxy/ # Proxy reverso
|
||||||
|
│ ├── python/ # Serviços Python (FastAPI)
|
||||||
|
│ ├── support/ # Central de suporte
|
||||||
|
│ ├── valuation/ # Precificação e valuation
|
||||||
|
│ └── whatsapp/ # Integração WhatsApp
|
||||||
|
└── shared/ # Código compartilhado
|
||||||
|
└── schema.ts # Schemas do banco de dados
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos do Sistema
|
||||||
|
|
||||||
|
### 1. Cockpit (Dashboard Principal)
|
||||||
|
**Arquivo:** `client/src/pages/Cockpit.tsx`
|
||||||
|
|
||||||
|
Painel central com visão geral do sistema:
|
||||||
|
- Widgets configuráveis
|
||||||
|
- Métricas em tempo real
|
||||||
|
- Atividades recentes
|
||||||
|
- Atalhos para módulos
|
||||||
|
|
||||||
|
### 2. Process Compass
|
||||||
|
**Arquivo:** `client/src/pages/ProcessCompass.tsx`
|
||||||
|
**API:** `/api/compass/*`
|
||||||
|
|
||||||
|
Gestão completa de processos empresariais:
|
||||||
|
- **Clientes:** Cadastro, histórico, segmentação
|
||||||
|
- **Projetos:** Cronograma, tarefas, milestones
|
||||||
|
- **Contratos:** Gestão de contratos e renovações
|
||||||
|
- **Timesheet:** Controle de horas trabalhadas
|
||||||
|
|
||||||
|
### 3. Comunicação Unificada
|
||||||
|
**Arquivo:** `client/src/pages/Comunicacao.tsx`
|
||||||
|
**API:** `/api/whatsapp/*`
|
||||||
|
|
||||||
|
Centro de comunicação multi-canal:
|
||||||
|
- **WhatsApp Business:** Atendimento via Baileys
|
||||||
|
- **Chat Interno:** Comunicação da equipe
|
||||||
|
- **E-mail:** Integração IMAP/SMTP
|
||||||
|
- **Tickets:** Sistema de filas de atendimento
|
||||||
|
|
||||||
|
### 4. CRM (Customer Relationship Management)
|
||||||
|
**Arquivo:** `client/src/pages/Crm.tsx`
|
||||||
|
**API:** `/api/crm/*`
|
||||||
|
|
||||||
|
Gestão de relacionamento com clientes:
|
||||||
|
- Pipeline de vendas
|
||||||
|
- Funil de conversão
|
||||||
|
- Gestão de oportunidades
|
||||||
|
- Comissionamento automático
|
||||||
|
- Integração com Google Calendar
|
||||||
|
|
||||||
|
### 5. Business Intelligence (Arcádia Insights)
|
||||||
|
**Arquivo:** `client/src/pages/BiWorkspace.tsx`
|
||||||
|
**API:** `/api/bi/*`
|
||||||
|
|
||||||
|
Análise e visualização de dados:
|
||||||
|
- Upload de arquivos (CSV, Excel)
|
||||||
|
- Gráficos interativos (Recharts)
|
||||||
|
- Dashboards personalizáveis
|
||||||
|
- Conexão com múltiplas fontes
|
||||||
|
|
||||||
|
### 6. Scientist (Central de Inteligência)
|
||||||
|
**Arquivo:** `client/src/pages/Scientist.tsx`
|
||||||
|
**API:** `/api/scientist/*`
|
||||||
|
|
||||||
|
Módulo de auto-programação com IA:
|
||||||
|
- Análise de dados automatizada
|
||||||
|
- Geração de código (Python/SQL)
|
||||||
|
- Execução em sandbox
|
||||||
|
- Armazenamento de soluções reutilizáveis
|
||||||
|
|
||||||
|
### 7. Manus (Agente Autônomo)
|
||||||
|
**Arquivo:** `client/src/pages/Agent.tsx`
|
||||||
|
**API:** `/api/manus/*`
|
||||||
|
|
||||||
|
Executor de tarefas autônomo:
|
||||||
|
- Loop pensamento-ação-observação
|
||||||
|
- Ferramentas disponíveis:
|
||||||
|
- Busca web
|
||||||
|
- Consulta ao Knowledge Graph
|
||||||
|
- Consulta ERP
|
||||||
|
- Cálculos
|
||||||
|
- Envio de mensagens
|
||||||
|
- Geração de relatórios
|
||||||
|
- Agendamentos
|
||||||
|
|
||||||
|
### 8. Arcádia Fisco (Motor Fiscal)
|
||||||
|
**Arquivo:** `client/src/pages/Fisco.tsx`
|
||||||
|
**API:** `/api/fisco/*`
|
||||||
|
|
||||||
|
Motor fiscal centralizado para compliance brasileiro:
|
||||||
|
- **NCM:** Nomenclatura Comum do Mercosul
|
||||||
|
- **CFOP:** Código Fiscal de Operações
|
||||||
|
- **CEST:** Código Especificador da Substituição Tributária
|
||||||
|
- **Grupos de Tributação:** Configuração de impostos
|
||||||
|
- **Certificados Digitais:** Gestão de A1/A3
|
||||||
|
- **NF-e/NFC-e:** Emissão de notas fiscais eletrônicas
|
||||||
|
- **IBS/CBS:** Campos para Reforma Tributária
|
||||||
|
|
||||||
|
#### Integração nfelib (Python)
|
||||||
|
**Arquivo:** `server/python/fisco_service.py`
|
||||||
|
|
||||||
|
Serviço FastAPI para processamento de NF-e:
|
||||||
|
- Geração de XML (layout 4.00)
|
||||||
|
- Assinatura digital com certificado A1
|
||||||
|
- Comunicação com SEFAZ (homologação/produção)
|
||||||
|
- Consulta, cancelamento e inutilização
|
||||||
|
|
||||||
|
### 9. Produção
|
||||||
|
**Arquivo:** `client/src/pages/Production.tsx`
|
||||||
|
**API:** `/api/production/*`
|
||||||
|
|
||||||
|
Gestão de produção e manufatura:
|
||||||
|
- Ordens de produção
|
||||||
|
- Controle de estoque
|
||||||
|
- Rastreabilidade
|
||||||
|
- Custos de produção
|
||||||
|
|
||||||
|
### 10. Valuation (Precificação)
|
||||||
|
**Arquivo:** `client/src/pages/Valuation.tsx`
|
||||||
|
**API:** `/api/valuation/*`
|
||||||
|
|
||||||
|
Sistema de precificação inteligente:
|
||||||
|
- Cálculo de custos
|
||||||
|
- Margem de contribuição
|
||||||
|
- Markup
|
||||||
|
- Simulações de preço
|
||||||
|
|
||||||
|
### 11. Suporte
|
||||||
|
**Arquivo:** `client/src/pages/Support.tsx`
|
||||||
|
**API:** `/api/support/*`
|
||||||
|
|
||||||
|
Central de atendimento:
|
||||||
|
- Tickets de suporte
|
||||||
|
- Base de conhecimento
|
||||||
|
- SLA e prioridades
|
||||||
|
- Histórico de atendimentos
|
||||||
|
|
||||||
|
### 12. Automações
|
||||||
|
**Arquivo:** `client/src/pages/Automations.tsx`
|
||||||
|
**API:** `/api/automations/*`
|
||||||
|
|
||||||
|
Motor de automações:
|
||||||
|
- Triggers e ações
|
||||||
|
- Workflows visuais
|
||||||
|
- Integrações via webhooks
|
||||||
|
- Agendamentos (cron)
|
||||||
|
|
||||||
|
### 13. Knowledge Base
|
||||||
|
**Arquivo:** `client/src/pages/Knowledge.tsx`
|
||||||
|
**API:** `/api/knowledge/*`
|
||||||
|
|
||||||
|
Base de conhecimento:
|
||||||
|
- Artigos e documentação
|
||||||
|
- Categorização
|
||||||
|
- Busca semântica
|
||||||
|
- Integração com IA
|
||||||
|
|
||||||
|
### 14. IDE
|
||||||
|
**Arquivo:** `client/src/pages/IDE.tsx`
|
||||||
|
**API:** `/api/ide/*`
|
||||||
|
|
||||||
|
Ambiente de desenvolvimento integrado:
|
||||||
|
- Monaco Editor
|
||||||
|
- Terminal (Xterm.js)
|
||||||
|
- Execução de código
|
||||||
|
- Gerenciamento de arquivos
|
||||||
|
|
||||||
|
### 15. Administração
|
||||||
|
**Arquivo:** `client/src/pages/Admin.tsx`
|
||||||
|
**API:** `/api/admin/*`
|
||||||
|
|
||||||
|
Painel administrativo:
|
||||||
|
- **Usuários:** Gestão de contas
|
||||||
|
- **Perfis:** Controle de acesso
|
||||||
|
- **Parceiros:** Hierarquia multi-tenant
|
||||||
|
- **Módulos:** Configuração de funcionalidades
|
||||||
|
- **Configurações:** Parâmetros do sistema
|
||||||
|
|
||||||
|
### 16. API Hub
|
||||||
|
**Arquivo:** `client/src/pages/ApiHub.tsx`
|
||||||
|
|
||||||
|
Documentação interativa de APIs:
|
||||||
|
- Listagem de endpoints
|
||||||
|
- Testes em tempo real
|
||||||
|
- Exemplos de uso
|
||||||
|
- Geração de código
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modelo de Dados
|
||||||
|
|
||||||
|
### Entidades Principais
|
||||||
|
|
||||||
|
#### Usuários e Autenticação
|
||||||
|
```sql
|
||||||
|
users -- Usuários do sistema
|
||||||
|
profiles -- Perfis de acesso
|
||||||
|
roles -- Papéis (RBAC)
|
||||||
|
permissions -- Permissões granulares
|
||||||
|
role_permissions -- Associação papel-permissão
|
||||||
|
user_roles -- Associação usuário-papel
|
||||||
|
module_access -- Controle de acesso a módulos
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Produtividade
|
||||||
|
```sql
|
||||||
|
workspace_pages -- Páginas estilo Notion
|
||||||
|
page_blocks -- Blocos de conteúdo
|
||||||
|
page_links -- Links bidirecionais
|
||||||
|
dashboard_widgets -- Widgets do dashboard
|
||||||
|
quick_notes -- Notas rápidas
|
||||||
|
activity_feed -- Feed de atividades
|
||||||
|
user_favorites -- Favoritos
|
||||||
|
command_history -- Histórico de comandos
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Conversação e IA
|
||||||
|
```sql
|
||||||
|
conversations -- Conversas com agente
|
||||||
|
messages -- Mensagens
|
||||||
|
chat_attachments -- Anexos
|
||||||
|
knowledge_base -- Base de conhecimento
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ERP e Integrações
|
||||||
|
```sql
|
||||||
|
erp_connections -- Conexões com ERPs
|
||||||
|
agent_tasks -- Tarefas do agente
|
||||||
|
task_executions -- Execuções de tarefas
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Comunicação
|
||||||
|
```sql
|
||||||
|
chat_threads -- Threads de chat
|
||||||
|
chat_participants -- Participantes
|
||||||
|
chat_messages -- Mensagens de chat
|
||||||
|
whatsapp_sessions -- Sessões WhatsApp
|
||||||
|
whatsapp_contacts -- Contatos WhatsApp
|
||||||
|
whatsapp_messages -- Mensagens WhatsApp
|
||||||
|
whatsapp_queues -- Filas de atendimento
|
||||||
|
whatsapp_tickets -- Tickets de atendimento
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Process Compass
|
||||||
|
```sql
|
||||||
|
compass_clients -- Clientes
|
||||||
|
compass_projects -- Projetos
|
||||||
|
compass_project_members -- Membros de projeto
|
||||||
|
compass_project_phases -- Fases de projeto
|
||||||
|
compass_contracts -- Contratos
|
||||||
|
compass_timesheet -- Timesheet
|
||||||
|
compass_invoices -- Faturas
|
||||||
|
compass_payments -- Pagamentos
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CRM
|
||||||
|
```sql
|
||||||
|
crm_leads -- Leads
|
||||||
|
crm_opportunities -- Oportunidades
|
||||||
|
crm_activities -- Atividades
|
||||||
|
crm_pipelines -- Pipelines
|
||||||
|
crm_stages -- Estágios
|
||||||
|
crm_commissions -- Comissões
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fisco
|
||||||
|
```sql
|
||||||
|
fisco_ncm -- NCMs
|
||||||
|
fisco_cest -- CESTs
|
||||||
|
fisco_cfop -- CFOPs
|
||||||
|
fisco_grupos_tributacao -- Grupos de tributação
|
||||||
|
fisco_natureza_operacao -- Naturezas de operação
|
||||||
|
fisco_ibpt -- Tabela IBPT
|
||||||
|
fisco_certificados -- Certificados digitais
|
||||||
|
fisco_configuracoes -- Configurações fiscais
|
||||||
|
fisco_notas -- Notas fiscais
|
||||||
|
fisco_nota_itens -- Itens das notas
|
||||||
|
fisco_nota_eventos -- Eventos fiscais
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multi-Tenant
|
||||||
|
```sql
|
||||||
|
partners -- Parceiros
|
||||||
|
partner_invites -- Convites de parceiros
|
||||||
|
tenant_clients -- Clientes dos tenants
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs e Endpoints
|
||||||
|
|
||||||
|
### Estrutura Base
|
||||||
|
|
||||||
|
| Módulo | Base URL | Descrição |
|
||||||
|
|--------|----------|-----------|
|
||||||
|
| Admin | `/api/admin` | Administração do sistema |
|
||||||
|
| Compass | `/api/compass` | Process Compass |
|
||||||
|
| CRM | `/api/crm` | Gestão de relacionamento |
|
||||||
|
| WhatsApp | `/api/whatsapp` | Comunicação WhatsApp |
|
||||||
|
| Fisco | `/api/fisco` | Motor fiscal |
|
||||||
|
| BI | `/api/bi` | Business Intelligence |
|
||||||
|
| Production | `/api/production` | Gestão de produção |
|
||||||
|
| Valuation | `/api/valuation` | Precificação |
|
||||||
|
| Support | `/api/support` | Central de suporte |
|
||||||
|
| Automations | `/api/automations` | Automações |
|
||||||
|
| IDE | `/api/ide` | Ambiente de desenvolvimento |
|
||||||
|
| Learning | `/api/learning` | Sistema de aprendizado |
|
||||||
|
|
||||||
|
### Exemplos de Endpoints
|
||||||
|
|
||||||
|
#### Fisco - NF-e
|
||||||
|
```
|
||||||
|
GET /api/fisco/nfe/service-status # Status do serviço
|
||||||
|
POST /api/fisco/nfe/validar-certificado # Validar certificado A1
|
||||||
|
POST /api/fisco/nfe/gerar-xml # Gerar XML preview
|
||||||
|
POST /api/fisco/nfe/emitir # Emitir NF-e
|
||||||
|
POST /api/fisco/nfe/consultar # Consultar na SEFAZ
|
||||||
|
POST /api/fisco/nfe/cancelar # Cancelar NF-e
|
||||||
|
POST /api/fisco/nfe/inutilizar # Inutilizar numeração
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Compass - Clientes
|
||||||
|
```
|
||||||
|
GET /api/compass/clients # Listar clientes
|
||||||
|
GET /api/compass/clients/:id # Detalhes do cliente
|
||||||
|
POST /api/compass/clients # Criar cliente
|
||||||
|
PUT /api/compass/clients/:id # Atualizar cliente
|
||||||
|
DELETE /api/compass/clients/:id # Excluir cliente
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Admin - Usuários
|
||||||
|
```
|
||||||
|
GET /api/admin/users # Listar usuários
|
||||||
|
GET /api/admin/users/:id # Detalhes do usuário
|
||||||
|
POST /api/admin/users # Criar usuário
|
||||||
|
PUT /api/admin/users/:id # Atualizar usuário
|
||||||
|
DELETE /api/admin/users/:id # Excluir usuário
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrações Externas
|
||||||
|
|
||||||
|
### OpenAI API
|
||||||
|
- **Uso:** Agente de IA, Scientist, auto-replies
|
||||||
|
- **Modelo:** gpt-4o-mini
|
||||||
|
- **Configuração:** Via Replit Secrets
|
||||||
|
|
||||||
|
### Baileys (WhatsApp)
|
||||||
|
- **Uso:** Conexão multi-sessão WhatsApp
|
||||||
|
- **Recursos:** QR Code, mensagens em tempo real
|
||||||
|
- **Armazenamento:** Sessões no banco de dados
|
||||||
|
|
||||||
|
### nfelib (Python)
|
||||||
|
- **Uso:** Emissão de NF-e/NFC-e
|
||||||
|
- **Recursos:** XML, assinatura digital, SEFAZ
|
||||||
|
- **Certificados:** A1 (PFX)
|
||||||
|
|
||||||
|
### Frappe Framework
|
||||||
|
- **Uso:** Arcádia Next (futuro)
|
||||||
|
- **Recursos:** ERPNext integration
|
||||||
|
|
||||||
|
### Google Calendar
|
||||||
|
- **Uso:** Sincronização de eventos CRM
|
||||||
|
- **OAuth:** Configurável por usuário
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segurança e Autenticação
|
||||||
|
|
||||||
|
### Autenticação
|
||||||
|
- **Método:** Session-based com Passport.js
|
||||||
|
- **Hash:** bcrypt para senhas
|
||||||
|
- **Sessões:** PostgreSQL session store
|
||||||
|
|
||||||
|
### Controle de Acesso (RBAC)
|
||||||
|
```
|
||||||
|
Hierarquia:
|
||||||
|
├── Master (Arcádia)
|
||||||
|
│ └── Parceiros
|
||||||
|
│ └── Clientes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissões
|
||||||
|
- Baseadas em módulos e ações
|
||||||
|
- Código formato: `modulo.recurso.acao`
|
||||||
|
- Exemplo: `compass.clients.write`
|
||||||
|
|
||||||
|
### Certificados Digitais
|
||||||
|
- Tipo A1 (arquivo PFX)
|
||||||
|
- Armazenamento seguro com senha
|
||||||
|
- Validação de expiração
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guia de Implantação
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
- Node.js 20+
|
||||||
|
- Python 3.11+
|
||||||
|
- PostgreSQL 15+
|
||||||
|
- Certificado SSL (produção)
|
||||||
|
|
||||||
|
### Variáveis de Ambiente
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
SESSION_SECRET=...
|
||||||
|
OPENAI_API_KEY=...
|
||||||
|
FISCO_PYTHON_URL=http://localhost:8002
|
||||||
|
FISCO_PORT=8002
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comandos de Inicialização
|
||||||
|
```bash
|
||||||
|
# Instalar dependências
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Iniciar em desenvolvimento
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Serviço Python Fisco (separado)
|
||||||
|
cd server/python && python fisco_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portas
|
||||||
|
| Serviço | Porta |
|
||||||
|
|---------|-------|
|
||||||
|
| Frontend + API | 5000 |
|
||||||
|
| Python Fisco | 8002 |
|
||||||
|
| Python IA | 8001 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Janeiro 2026
|
||||||
|
- Integração nfelib para NF-e
|
||||||
|
- Módulo Fisco completo
|
||||||
|
- Sistema de aprendizado automático
|
||||||
|
- Validação Zod em todas as rotas fiscais
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Arcádia Suite** - Transformando a gestão empresarial com Inteligência Artificial
|
||||||
|
|
||||||
|
*Documentação gerada automaticamente pelo sistema.*
|
||||||
|
|
@ -0,0 +1,705 @@
|
||||||
|
# MAPA GERAL DO SISTEMA RETAIL - Arcádia Suite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VISÃO MACRO DO MÓDULO
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ARCÁDIA RETAIL - MAPA GERAL ║
|
||||||
|
║ Loja de Celulares e Assistência Técnica ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ║
|
||||||
|
║ │DASHBOARD│ │ PDV │ │ PESSOAS │ │ ESTOQUE │ │SERVIÇOS │ ║
|
||||||
|
║ │ (KPIs) │ │ (Caixa) │ │(Cadastro│ │(Depósito│ │ (O.S.) │ ║
|
||||||
|
║ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ ║
|
||||||
|
║ │ │ │ │ │ ║
|
||||||
|
║ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ║
|
||||||
|
║ │TRADE-IN │ │ COMPRAS │ │CADASTROS│ │RELATÓRIO│ │COMISSÕES│ ║
|
||||||
|
║ │(Aval.) │ │(Aquis.) │ │ (Config)│ │(Reports)│ │ (Metas) │ ║
|
||||||
|
║ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ ║
|
||||||
|
║ │ │ │ │ │ ║
|
||||||
|
║ └────────────┴────────────┴─────┬──────┴────────────┘ ║
|
||||||
|
║ │ ║
|
||||||
|
║ ┌─────────┴─────────┐ ║
|
||||||
|
║ │ CONFIGURAÇÃO │ ║
|
||||||
|
║ │ Plus / Empresas │ ║
|
||||||
|
║ └───────────────────┘ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. FLUXO PRINCIPAL DE OPERAÇÕES
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ ENTRADA DE MERCADORIA │
|
||||||
|
└──────────────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────┬┴──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ COMPRA │ │ TRADE-IN │ │ CONSIGNAÇÃO │
|
||||||
|
│ Fornecedor │ │ Cliente │ │ Parceiro │
|
||||||
|
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴───────┐ │
|
||||||
|
│ │ Avaliação │ │
|
||||||
|
│ │ 19 itens │ │
|
||||||
|
│ │ checklist │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴───────┐ │
|
||||||
|
│ │ Aprovação │ │
|
||||||
|
│ │ Gera Crédito│ │
|
||||||
|
│ │ Cria O.S. │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴───────┐ │
|
||||||
|
│ │ Revisão │ │
|
||||||
|
│ │ O.S. Interna│ │
|
||||||
|
│ │ Manutenção │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ESTOQUE │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │Depósito │ │Depósito │ │Depósito │ │
|
||||||
|
│ │ Loja │ │ Central │ │Trânsito │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||||
|
│ └────────────┼────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────┬┴──────────────────┐ │
|
||||||
|
│ │ Dispositivos │ Produtos │ │
|
||||||
|
│ │ (por IMEI) │ (por qtd/série) │ │
|
||||||
|
│ └────────────────┴───────────────────┘ │
|
||||||
|
└────────────────────┬────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ PDV │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||||
|
│ │Dispositivos│ │ Produtos │ │ Faturar O.S. │ │
|
||||||
|
│ │ (IMEI) │ │(Acessórios)│ │ (Serviços) │ │
|
||||||
|
│ └─────┬──────┘ └─────┬──────┘ └──────┬───────┘ │
|
||||||
|
│ └───────────────┼────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ CARRINHO │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Subtotal │ │
|
||||||
|
│ │ - Desconto │ │
|
||||||
|
│ │ - Trade-In │ │
|
||||||
|
│ │ - Crédito │ │
|
||||||
|
│ │ = TOTAL │ │
|
||||||
|
│ └────────┬─────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ PAGAMENTO │ │
|
||||||
|
│ │ 💵 Dinheiro │ │
|
||||||
|
│ │ 💳 Débito │ │
|
||||||
|
│ │ 💳 Crédito (Nx) │ │
|
||||||
|
│ │ 📱 PIX │ │
|
||||||
|
│ │ 🔀 Combinado │ │
|
||||||
|
│ └────────┬─────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ VENDA │ │
|
||||||
|
│ │ Impressão A4 │ │
|
||||||
|
│ │ Sync Plus │ │
|
||||||
|
│ │ NF-e/NFC-e │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┼────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌─────────┐ ┌────────────┐
|
||||||
|
│ COMISSÃO │ │RELATÓRIO│ │ DEVOLUÇÃO │
|
||||||
|
│ Vendedor │ │Caixa │ │ Troca │
|
||||||
|
│ Metas │ │Diário │ │ Crédito │
|
||||||
|
└────────────┘ └─────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CICLO DE VIDA DO DISPOSITIVO (IMEI)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ JORNADA DO DISPOSITIVO POR IMEI │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ENTRADA │
|
||||||
|
│ ═══════ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ COMPRA │ │ TRADE-IN │ │ CONSIGNAÇÃO │ │
|
||||||
|
│ │ Fornecedor │ │ Cliente │ │ Parceiro │ │
|
||||||
|
│ │ condition: │ │ condition: │ │ condition: │ │
|
||||||
|
│ │ new │ │ used/refurb │ │ varies │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ┌──────┴───────┐ │ │
|
||||||
|
│ │ │ AVALIAÇÃO │ │ │
|
||||||
|
│ │ │ Checklist │ │ │
|
||||||
|
│ │ │ 19 itens │ │ │
|
||||||
|
│ │ └──────┬───────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ┌──────┴───────┐ │ │
|
||||||
|
│ │ │ O.S. INT. │ │ │
|
||||||
|
│ │ │ Revisão │ │ │
|
||||||
|
│ │ │ Manutenção │ │ │
|
||||||
|
│ │ └──────┬───────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ESTOQUE (status: in_stock) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ IMEI: 35XXXXXXXXXXXXX │ │
|
||||||
|
│ │ Marca: Samsung | Modelo: S24 Ultra │ │
|
||||||
|
│ │ Cor: Preto | Storage: 256GB | RAM: 12GB │ │
|
||||||
|
│ │ Condição: refurbished │ │
|
||||||
|
│ │ Preço Compra: R$ 2.500 | Preço Venda: R$ 3.500 │ │
|
||||||
|
│ │ Depósito: Loja Centro │ │
|
||||||
|
│ └──────────────────────┬──────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ MOVIMENTAÇÕES │ │
|
||||||
|
│ ═══════════════ │ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────┐ ┌──────┴──────┐ ┌──────────────┐ │
|
||||||
|
│ │TRANSFER. │◄───│ DISPONÍVEL │───►│ ASSISTÊNCIA │ │
|
||||||
|
│ │Entre lojas│ │ para venda │ │ O.S. cliente│ │
|
||||||
|
│ │in_transit │ │ in_stock │ │ in_service │ │
|
||||||
|
│ └──────────┘ └──────┬──────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ (retorna após reparo) │
|
||||||
|
│ │ └──────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ SAÍDA ▼ ▼ │
|
||||||
|
│ ═════ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ VENDA │ │ DE VOLTA AO │ │
|
||||||
|
│ │ PDV + NF-e │ │ ESTOQUE │ │
|
||||||
|
│ │ status:sold │ │ in_stock │ │
|
||||||
|
│ └──────┬───────┘ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴───────┐ │
|
||||||
|
│ │ DEVOLUÇÃO? │ │
|
||||||
|
│ │ returned │──────────► Volta ao estoque │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ RASTREAMENTO: device_history + imei_history (kardex completo) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FLUXO DO TRADE-IN (4 ETAPAS DETALHADAS)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ TRADE-IN - FLUXO COMPLETO ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ ETAPA 1: AVALIAÇÃO ║
|
||||||
|
║ ══════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Cliente chega com dispositivo usado │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 1. Identificar cliente (busca por nome/CPF) │ ║
|
||||||
|
║ │ 2. Registrar IMEI, Marca, Modelo │ ║
|
||||||
|
║ │ 3. Preencher Checklist (19 itens): │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ ┌──────────────────────────────────────────────────────┐ │ ║
|
||||||
|
║ │ │ ☐ Liga corretamente ☐ Sensores/NFC │ │ ║
|
||||||
|
║ │ │ ☐ Sem avarias/fantasma ☐ Face ID/Touch ID │ │ ║
|
||||||
|
║ │ │ ☐ Sem manchas na tela ☐ Microfones │ │ ║
|
||||||
|
║ │ │ ☐ Botões funcionando ☐ Áudio auricular │ │ ║
|
||||||
|
║ │ │ ☐ Marcas de uso ☐ Alto-falante │ │ ║
|
||||||
|
║ │ │ ☐ Wi-Fi ☐ Carregamento │ │ ║
|
||||||
|
║ │ │ ☐ Chip ☐ Câmeras │ │ ║
|
||||||
|
║ │ │ ☐ 4G/5G ☐ Flash │ │ ║
|
||||||
|
║ │ │ ☐ Possui carregador ☐ 3uTools OK │ │ ║
|
||||||
|
║ │ │ 🔋 Bateria: ____% │ │ ║
|
||||||
|
║ │ └──────────────────────────────────────────────────────┘ │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 4. Listar peças necessárias (se houver) │ ║
|
||||||
|
║ │ 5. Definir valor estimado │ ║
|
||||||
|
║ │ 6. Assinatura digital do cliente │ ║
|
||||||
|
║ │ 7. Imprimir comprovante │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Status: PENDING │ ║
|
||||||
|
║ └────────────────────────────────────────────┬───────────────┘ ║
|
||||||
|
║ │ ║
|
||||||
|
║ ▼ ║
|
||||||
|
║ ETAPA 2: APROVAÇÃO ║
|
||||||
|
║ ══════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Gerente revisa avaliação e decide: │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ [APROVAR] [REJEITAR] │ ║
|
||||||
|
║ │ │ │ │ ║
|
||||||
|
║ │ ▼ ▼ │ ║
|
||||||
|
║ │ Automaticamente: Avaliação │ ║
|
||||||
|
║ │ ✅ Gera CRÉDITO para cliente rejeitada. │ ║
|
||||||
|
║ │ (R$ do valor estimado) Sem ações. │ ║
|
||||||
|
║ │ ✅ Cria O.S. INTERNA │ ║
|
||||||
|
║ │ (INT2602XXXXXX) │ ║
|
||||||
|
║ │ ✅ Registra no IMEI History │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Status: APPROVED │ ║
|
||||||
|
║ └────────────────────────────────────────────┬───────────────┘ ║
|
||||||
|
║ │ ║
|
||||||
|
║ ▼ ║
|
||||||
|
║ ETAPA 3: REVISÃO / MANUTENÇÃO ║
|
||||||
|
║ ═════════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Técnico trabalha na O.S. Interna: │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 1. Diagnóstico técnico │ ║
|
||||||
|
║ │ 2. Troca de peças (registra peças + custos) │ ║
|
||||||
|
║ │ 3. Limpeza e preparação │ ║
|
||||||
|
║ │ 4. Teste de qualidade │ ║
|
||||||
|
║ │ 5. Preenche checklist de conclusão │ ║
|
||||||
|
║ │ 6. Finaliza O.S. Interna │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Custos: │ ║
|
||||||
|
║ │ ├── Peças utilizadas: R$ XXX │ ║
|
||||||
|
║ │ ├── Mão de obra: R$ XXX │ ║
|
||||||
|
║ │ └── Total reparo: R$ XXX │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Status O.S.: COMPLETED │ ║
|
||||||
|
║ └────────────────────────────────────────────┬───────────────┘ ║
|
||||||
|
║ │ ║
|
||||||
|
║ ▼ ║
|
||||||
|
║ ETAPA 4: ENTRADA NO ESTOQUE ║
|
||||||
|
║ ═══════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Ao finalizar O.S. Interna: │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ ✅ Cria dispositivo no estoque (mobile_devices) │ ║
|
||||||
|
║ │ condition: "refurbished" │ ║
|
||||||
|
║ │ acquisitionType: "trade_in" │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 💰 Cálculo do preço sugerido: │ ║
|
||||||
|
║ │ Custo aquisição = Valor Trade-In + Custo Reparo │ ║
|
||||||
|
║ │ Preço sugerido = Custo × (1 + Margem%) │ ║
|
||||||
|
║ │ Ex: R$1500 + R$300 = R$1800 × 1.30 = R$ 2.340 │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ ✅ Registra no IMEI History │ ║
|
||||||
|
║ │ ✅ Registra no Activity Feed │ ║
|
||||||
|
║ │ ✅ Disponível no PDV para venda │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Status: IN_STOCK (pronto para venda) │ ║
|
||||||
|
║ └────────────────────────────────────────────────────────────┘ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MAPA DO PDV (PONTO DE VENDA)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ PDV - PONTO DE VENDA ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ BARRA SUPERIOR ║
|
||||||
|
║ ┌──────────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ 🏪 Empresa: Loja Centro 👤 Vendedor: João Silva │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ [Sangria] [Reforço] [Devolução] [Selecionar Cliente] [Limpar] │ ║
|
||||||
|
║ └──────────────────────────────────────────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌───────────────────────────────────┬──────────────────────────────┐ ║
|
||||||
|
║ │ CATÁLOGO (3 colunas) │ CARRINHO (2 colunas) │ ║
|
||||||
|
║ │ │ │ ║
|
||||||
|
║ │ ┌──────────┬──────────┬────────┐ │ ┌──────────────────────────┐│ ║
|
||||||
|
║ │ │📱Disposit│📦Produtos│🔧Fat.OS│ │ │ Samsung S24 Ultra ││ ║
|
||||||
|
║ │ └──────────┴──────────┴────────┘ │ │ IMEI: 35XXX 📱Celular ││ ║
|
||||||
|
║ │ │ │ R$ 3.500,00 ││ ║
|
||||||
|
║ │ [🔍 Buscar... ] │ ├──────────────────────────┤│ ║
|
||||||
|
║ │ │ │ Capinha Silicone ││ ║
|
||||||
|
║ │ ┌──────────────────────────────┐ │ │ 2x R$ 45,00 ││ ║
|
||||||
|
║ │ │ Samsung S24 Ultra │ │ │ R$ 90,00 ││ ║
|
||||||
|
║ │ │ 256GB | Preto | IMEI: 35XX │ │ ├──────────────────────────┤│ ║
|
||||||
|
║ │ │ 🟢 Novo 📦 Loja Centro │ │ │ O.S. #OS2602ABC ││ ║
|
||||||
|
║ │ │ R$ 3.500,00 │ │ │ Troca de tela 🔧Serviço ││ ║
|
||||||
|
║ │ │ [+ Adicionar] │ │ │ R$ 450,00 ││ ║
|
||||||
|
║ │ ├──────────────────────────────┤ │ ╞══════════════════════════╡│ ║
|
||||||
|
║ │ │ iPhone 15 Pro │ │ │ Subtotal R$ 4.040,00 ││ ║
|
||||||
|
║ │ │ 128GB | Branco | IMEI: 86XX │ │ │ Desconto -R$ 40,00 ││ ║
|
||||||
|
║ │ │ 🔵 Recond 📦 Loja Centro │ │ │ Trade-In -R$ 1.500,00 ││ ║
|
||||||
|
║ │ │ R$ 4.200,00 │ │ │ Crédito -R$ 200,00 ││ ║
|
||||||
|
║ │ │ [+ Adicionar] │ │ │─────────────────────────││ ║
|
||||||
|
║ │ ├──────────────────────────────┤ │ │ TOTAL R$ 2.300,00 ││ ║
|
||||||
|
║ │ │ Xiaomi 14 │ │ │ ││ ║
|
||||||
|
║ │ │ 512GB | Azul | IMEI: 86XX │ │ │ 💙 Crédito: R$ 200,00 ││ ║
|
||||||
|
║ │ │ 🟡 Usado 📦 Loja Centro │ │ │ ││ ║
|
||||||
|
║ │ │ R$ 2.800,00 │ │ │ [↕️ Trade-In] ││ ║
|
||||||
|
║ │ │ [+ Adicionar] │ │ │ [💰 FINALIZAR VENDA] ││ ║
|
||||||
|
║ │ └──────────────────────────────┘ │ └──────────────────────────┘│ ║
|
||||||
|
║ └───────────────────────────────────┴──────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
║ MODAL DE PAGAMENTO ║
|
||||||
|
║ ┌──────────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Total: R$ 2.300,00 │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ [Dinheiro ✓] [Débito] [Crédito] [PIX] [Combinado] │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Valor: [R$ 2.300,00] Parcelas: [1x] │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ Desconto: [___]% ou [R$ ___] │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ [CONFIRMAR PAGAMENTO] │ ║
|
||||||
|
║ └──────────────────────────────────────────────────────────────────┘ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. FLUXO DA ORDEM DE SERVIÇO (12 ESTADOS)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ORDEM DE SERVIÇO - FLUXO DE STATUS │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ │
|
||||||
|
│ │ ABERTA │ ◄── Cliente entrega dispositivo │
|
||||||
|
│ │ open │ │
|
||||||
|
│ └────┬─────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────┐ │
|
||||||
|
│ │ DIAGNÓSTICO │ ◄── Técnico analisa problema │
|
||||||
|
│ │ diagnosis │ │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────┐ │
|
||||||
|
│ │ ORÇAMENTO │ ◄── Técnico elabora orçamento │
|
||||||
|
│ │ quote │ │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ AGUARD. APROVAÇÃO │ ◄── Enviado para cliente │
|
||||||
|
│ │ pending_approval │ │
|
||||||
|
│ └─────┬────────┬─────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ APROVOU │ │ REJEITOU │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ APROVADA │ │ REJEITADA│ ──► FIM │
|
||||||
|
│ │ approved │ │ rejected │ │
|
||||||
|
│ └────┬─────┘ └──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────┐ │
|
||||||
|
│ │ EM REPARO│ ◄── Técnico inicia trabalho │
|
||||||
|
│ │in_repair │ │
|
||||||
|
│ └────┬─────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────┼──────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ ┌──────────────┐ │ │
|
||||||
|
│ │ AGUARD. PEÇAS│ │ ◄── Se precisar de peça │
|
||||||
|
│ │waiting_parts │───────────────┘ │
|
||||||
|
│ └──────────────┘ (peça chegou, volta para reparo) │
|
||||||
|
│ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ QUALIDADE │ ◄── Verificação pós-reparo │
|
||||||
|
│ │quality_check │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ PRONTO PARA │ ◄── Disponível para retirada │
|
||||||
|
│ │ RETIRADA │ (aparece no PDV p/ faturar) │
|
||||||
|
│ │ ready_pickup │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ CONCLUÍDA │ ◄── Cliente retirou + pagou │
|
||||||
|
│ │ completed │ (faturada no PDV) │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Em qualquer momento: ──────────► ┌──────────────┐ │
|
||||||
|
│ │ CANCELADA │ │
|
||||||
|
│ │ cancelled │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MAPA DE RELATÓRIOS
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ RELATÓRIOS DO RETAIL │
|
||||||
|
├──────────────┬──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ OS POR │ Status │ Qtd │ Valor Total │
|
||||||
|
│ STATUS │ open │ 12 │ R$ 5.400 │
|
||||||
|
│ │ in_repair │ 8 │ R$ 3.200 │
|
||||||
|
│ │ completed │ 45 │ R$ 22.500 │
|
||||||
|
│ │ │
|
||||||
|
├──────────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ OS POR │ Técnico │ Total │ Concl. │ Andamento │ Receita │
|
||||||
|
│ TÉCNICO │ Carlos │ 15 │ 12 │ 3 │ R$8.500 │
|
||||||
|
│ │ Pedro │ 10 │ 8 │ 2 │ R$5.200 │
|
||||||
|
│ │ │
|
||||||
|
├──────────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ VENDAS POR │ Vendedor │ Vendas │ Receita │ Ticket │ Dias │
|
||||||
|
│ VENDEDOR │ João │ 28 │ R$45.000 │ R$1607 │ 22 │
|
||||||
|
│ │ Maria │ 22 │ R$38.000 │ R$1727 │ 20 │
|
||||||
|
│ │ │
|
||||||
|
├──────────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ MARGEM │ Dispositivo │ IMEI │ Custo │ Venda │ Margem │
|
||||||
|
│ POR IMEI │ S24 Ultra │ 35XXX │ R$2500 │ R$3500 │ 40.0% │
|
||||||
|
│ │ iPhone 15 │ 86XXX │ R$3800 │ R$4200 │ 10.5% │
|
||||||
|
│ │ │
|
||||||
|
├──────────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ CAIXA │ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ DIÁRIO │ │ Total Vendas: R$ 12.500 (8 vendas) │ │
|
||||||
|
│ │ │ Dinheiro: R$ 4.200 │ Cartão: R$ 5.800 │ │
|
||||||
|
│ │ │ PIX: R$ 2.500 │ Combinado: R$ 0 │ │
|
||||||
|
│ (4 queries) │ │ Sangrias: -R$ 500 │ Reforços: +R$ 200 │ │
|
||||||
|
│ │ │ Saldo Caixa: R$ 3.900 │ │
|
||||||
|
│ │ └────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ POR VENDEDOR: │
|
||||||
|
│ │ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
|
||||||
|
│ │ │Vendedor│Vendas│Dinh. │Déb. │Créd.│ PIX │TOTAL │ │
|
||||||
|
│ │ │João │ 5 │R$2100│R$1500│R$800│R$1200│R$5600│ │
|
||||||
|
│ │ │Maria │ 3 │R$2100│R$1300│R$600│R$1300│R$6900│ │
|
||||||
|
│ │ │TOTAL │ 8 │R$4200│R$2800│R$1400│R$2500│R$12.5│ │
|
||||||
|
│ │ └──────┴──────┴──────┴──────┴──────┴──────┴──────┘ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ GIRO DE │ Produto │ Estoque │ Vendas 30d │ Turnover │
|
||||||
|
│ ESTOQUE │ Capinha │ 50 │ 35 │ 0.70 │
|
||||||
|
│ │ Película │ 80 │ 42 │ 0.53 │
|
||||||
|
│ │ │
|
||||||
|
└──────────────┴──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ARQUITETURA TÉCNICA
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ARQUITETURA DO MÓDULO RETAIL ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ FRONTEND (React + TypeScript + Tailwind + shadcn/ui) ║
|
||||||
|
║ ═══════════════════════════════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ client/src/pages/ArcadiaRetail.tsx ──── 10.067 linhas ║
|
||||||
|
║ │ ║
|
||||||
|
║ ├── Dashboard (KPIs, Feed, Alertas) ║
|
||||||
|
║ ├── PDV (Carrinho, Catálogo, Pagamento) ║
|
||||||
|
║ ├── Pessoas (CRUD, Papéis, Histórico) ║
|
||||||
|
║ ├── Estoque (Depósitos, Séries, Inventário) ║
|
||||||
|
║ ├── Serviços (O.S. CRUD, Checklist) ║
|
||||||
|
║ ├── Trade-In (Avaliações, Fluxo 4 etapas) ║
|
||||||
|
║ ├── Compras (Pedidos, Recebimento) ║
|
||||||
|
║ ├── Cadastros (Pagamento, Vendedores, Promoções) ║
|
||||||
|
║ ├── Relatórios (6 sub-abas) ║
|
||||||
|
║ ├── Comissões (Dashboard, Metas, Fechamento) ║
|
||||||
|
║ └── Configuração (Plus, Empresas, Sync) ║
|
||||||
|
║ ║
|
||||||
|
║ client/src/components/TradeInForm.tsx ─── 988 linhas ║
|
||||||
|
║ └── Formulário completo c/ checklist + assinatura ║
|
||||||
|
║ ║
|
||||||
|
║ ───────────────────────────────────────────────────────────── ║
|
||||||
|
║ API (fetch → /api/retail/*) ║
|
||||||
|
║ ───────────────────────────────────────────────────────────── ║
|
||||||
|
║ ║
|
||||||
|
║ BACKEND (Express.js + Drizzle ORM) ║
|
||||||
|
║ ══════════════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ server/retail/routes.ts ──── 5.218 linhas (~130 endpoints) ║
|
||||||
|
║ │ ║
|
||||||
|
║ ├── CRUD: Devices, Evaluations, ServiceOrders, ║
|
||||||
|
║ │ Persons, Products, Warehouses, etc. ║
|
||||||
|
║ ├── PDV: Sessions, Sales, Payments, CashMovements ║
|
||||||
|
║ ├── Trade-In: Approve, Process, FullFlow, Stock ║
|
||||||
|
║ ├── Reports: OsStatus, OsTech, SalesSeller, ║
|
||||||
|
║ │ MarginIMEI, DailyCash, StockTurnover ║
|
||||||
|
║ ├── Returns: Search, Process, Credits ║
|
||||||
|
║ └── Commissions: Plans, Goals, Calculate, Close ║
|
||||||
|
║ ║
|
||||||
|
║ server/retail/plus-sync.ts ──── 542 linhas ║
|
||||||
|
║ └── Sync: Customers, Sales+Items, NF-e ║
|
||||||
|
║ ║
|
||||||
|
║ ───────────────────────────────────────────────────────────── ║
|
||||||
|
║ Drizzle ORM → PostgreSQL ║
|
||||||
|
║ ───────────────────────────────────────────────────────────── ║
|
||||||
|
║ ║
|
||||||
|
║ BANCO DE DADOS (PostgreSQL + 40 tabelas) ║
|
||||||
|
║ ════════════════════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ║
|
||||||
|
║ │ Dispositivos │ │ Vendas │ │ Estoque │ ║
|
||||||
|
║ │ mobile_devices│ │ pos_sales │ │ warehouses │ ║
|
||||||
|
║ │ device_history│ │pos_sale_items│ │warehouse_stk │ ║
|
||||||
|
║ │ imei_history │ │pos_sessions │ │stock_movem. │ ║
|
||||||
|
║ └──────────────┘ │cash_movements│ │transfers │ ║
|
||||||
|
║ └──────────────┘ │serials │ ║
|
||||||
|
║ ┌──────────────┐ │inventories │ ║
|
||||||
|
║ │ Trade-In │ ┌──────────────┐ └──────────────┘ ║
|
||||||
|
║ │ evaluations │ │ Serviços │ ║
|
||||||
|
║ │ checklist_* │ │service_orders│ ┌──────────────┐ ║
|
||||||
|
║ │ acquisitions │ │ so_items │ │ Cadastros │ ║
|
||||||
|
║ │ transfer_doc │ │ warranties │ │payment_meth. │ ║
|
||||||
|
║ └──────────────┘ └──────────────┘ │ sellers │ ║
|
||||||
|
║ │ comm_plans │ ║
|
||||||
|
║ ┌──────────────┐ ┌──────────────┐ │ promotions │ ║
|
||||||
|
║ │ Créditos │ │ Devoluções │ │ price_tables │ ║
|
||||||
|
║ │customer_cred.│ │return_exch. │ │product_types │ ║
|
||||||
|
║ └──────────────┘ │return_items │ └──────────────┘ ║
|
||||||
|
║ └──────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
║ ───────────────────────────────────────────────────────────── ║
|
||||||
|
║ ║
|
||||||
|
║ INTEGRAÇÃO PLUS (Laravel ERP - Porta 8080) ║
|
||||||
|
║ ══════════════════════════════════════════ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌───────────┐ ┌───────────┐ ┌───────────┐ ║
|
||||||
|
║ │Proxy Rev. │ │ SSO │ │Auto-Start │ ║
|
||||||
|
║ │/plus/* → │ │HMAC-SHA256│ │php artisan│ ║
|
||||||
|
║ │:8080 │ │Token │ │serve │ ║
|
||||||
|
║ └───────────┘ └───────────┘ └───────────┘ ║
|
||||||
|
║ │ ║
|
||||||
|
║ ▼ ║
|
||||||
|
║ ┌─────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Plus (Laravel) │ ║
|
||||||
|
║ │ ├── NF-e / NFC-e (SEFAZ) │ ║
|
||||||
|
║ │ ├── Clientes / Fornecedores │ ║
|
||||||
|
║ │ ├── Vendas / Faturamento │ ║
|
||||||
|
║ │ └── Fiscal completo │ ║
|
||||||
|
║ └─────────────────────────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SEGURANÇA MULTI-TENANT
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADAS DE SEGURANÇA │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ CAMADA 1: AUTENTICAÇÃO │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Passport.js Session → req.isAuthenticated() │ │
|
||||||
|
│ │ Todas as rotas /api/retail/* exigem login │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ CAMADA 2: TENANT SCOPING │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ req.user.tenantId → obrigatório │ │
|
||||||
|
│ │ Se ausente → 403 "Tenant not identified" │ │
|
||||||
|
│ │ WHERE tenant_id = $tenantId em TODAS as queries │ │
|
||||||
|
│ │ Dados de um tenant NUNCA vazam para outro │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ CAMADA 3: MODULE GATING │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ requireModule('retail') → verifica tenants.features.retail │ │
|
||||||
|
│ │ Se módulo inativo → 403 "Módulo não habilitado" │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ CAMADA 4: OPERAÇÕES SENSÍVEIS │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Devoluções → Exige senha do GERENTE │ │
|
||||||
|
│ │ Sangrias → Registra responsável + autorizador │ │
|
||||||
|
│ │ Exclusões → Soft delete quando possível │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ CAMADA 5: AUDITORIA │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ retail_activity_feed → Todas as operações relevantes │ │
|
||||||
|
│ │ device_history → Movimentação de IMEI │ │
|
||||||
|
│ │ imei_history → Kardex completo por IMEI │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. MAPA DE INTEGRAÇÕES
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INTEGRAÇÕES DO RETAIL │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ ARCÁDIA RETAIL │ │
|
||||||
|
│ │ (Express) │ │
|
||||||
|
│ └────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────┼───────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ PLUS (PHP) │ │ ERPNEXT │ │ POSTGRESQL │ │
|
||||||
|
│ │ :8080 │ │ (API ext.) │ │ (Drizzle) │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ ▸ NF-e/NFC-e│ │ ▸ Clientes │ │ ▸ 40+ tabelas│ │
|
||||||
|
│ │ ▸ Clientes │ │ ▸ Fornecedor │ │ ▸ Retail │ │
|
||||||
|
│ │ ▸ Vendas │ │ ▸ Estoque │ │ ▸ Multi-ten. │ │
|
||||||
|
│ │ ▸ Fiscal │ │ ▸ Financeiro │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SEFAZ │ │
|
||||||
|
│ │ NF-e / NFC-e (via Plus Laravel + nfelib) │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Métricas Finais:**
|
||||||
|
- **16.800+ linhas** de código
|
||||||
|
- **40+ tabelas** no banco
|
||||||
|
- **~130 endpoints** REST
|
||||||
|
- **11 abas** na interface
|
||||||
|
- **19 itens** no checklist de avaliação
|
||||||
|
- **12 estados** de O.S.
|
||||||
|
- **4 etapas** no fluxo de Trade-In
|
||||||
|
- **5 formas** de pagamento
|
||||||
|
- **4 queries** no caixa diário
|
||||||
|
|
@ -0,0 +1,676 @@
|
||||||
|
# Arcádia Plus - Mapa Completo dentro do Arcádia Suite
|
||||||
|
|
||||||
|
## O que é o Plus?
|
||||||
|
|
||||||
|
O **Arcádia Plus** é o **Motor de Execução Fiscal e Operacional** da plataforma Arcádia Suite. Enquanto o Arcádia Suite pensa, governa e orienta, o Plus executa, registra e obedece. É um ERP completo construído em Laravel/PHP que serve como backend fiscal para emissão de documentos eletrônicos (NF-e, NFC-e, CT-e, MDF-e) e operações comerciais.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Posição na Arquitetura
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ARCÁDIA SUITE (Cérebro) │
|
||||||
|
│ Express.js - Porta 5000 │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SOE - Sistema Operacional Empresarial │ │
|
||||||
|
│ │ Domínios: Pessoas, Produtos, Vendas, Financeiro, Fiscal │ │
|
||||||
|
│ │ Decisão + Governança + IA │ │
|
||||||
|
│ └────────────────────┬───────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ SSO + Proxy (/plus) │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ARCÁDIA PLUS (Motor de Execução) │ │
|
||||||
|
│ │ Laravel/PHP - Porta 8080 │ │
|
||||||
|
│ │ Execução: NFe, NFCe, CTe, MDFe, PDV, Estoque, OS │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Outros Motores: │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
|
||||||
|
│ │Contábil │ │ Fiscal │ │ BI │ │Automação │ │ Comm ││
|
||||||
|
│ │ 8003 │ │ 8002 │ │ 8004 │ │ 8005 │ │ 8006 ││
|
||||||
|
│ │ Python │ │ Python │ │ Python │ │ Python │ │ Node.js ││
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração Suite ↔ Plus
|
||||||
|
|
||||||
|
### 1. Proxy Reverso (`server/plus/proxy.ts`)
|
||||||
|
|
||||||
|
O Arcádia Suite atua como gateway. Todas as requisições para `/plus/*` são redirecionadas para o Laravel na porta 8080:
|
||||||
|
|
||||||
|
```
|
||||||
|
Navegador → /plus/vendas → Suite (5000) → Proxy → Laravel (8080/vendas)
|
||||||
|
Navegador → /plus/nfe → Suite (5000) → Proxy → Laravel (8080/nfe)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comportamentos do proxy:**
|
||||||
|
- `/plus` ou `/plus/` → redireciona para `/empresas` (home do Laravel)
|
||||||
|
- `/plus/home` → redireciona para `/empresas`
|
||||||
|
- Headers `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` repassados
|
||||||
|
- Respostas Location reescritas para manter o prefixo `/plus`
|
||||||
|
- Timeout: 60 segundos
|
||||||
|
- Status endpoint: `GET /api/plus/status`
|
||||||
|
|
||||||
|
### 2. SSO - Single Sign-On (`plus/app/Http/Controllers/Auth/SsoController.php`)
|
||||||
|
|
||||||
|
Login unificado: o usuário entra pelo Arcádia Suite e é autenticado automaticamente no Plus.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ Token JWT (Base64) ┌──────────────────┐
|
||||||
|
│ Arcádia Suite │ ──────────────────────────▶ │ Arcádia Plus │
|
||||||
|
│ (Node.js) │ sig = HMAC-SHA256 │ (Laravel) │
|
||||||
|
│ │ ts = timestamp │ │
|
||||||
|
│ Gera token com: │ │ Valida: │
|
||||||
|
│ - email │ │ - iss = arcadia │
|
||||||
|
│ - name │ │ - aud = plus │
|
||||||
|
│ - role │ │ - timestamp ±5m │
|
||||||
|
│ - username │ │ - HMAC signature │
|
||||||
|
└─────────────────┘ └──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Auto-cria usuário
|
||||||
|
se não existir
|
||||||
|
```
|
||||||
|
|
||||||
|
**Segurança SSO:**
|
||||||
|
- Segredo compartilhado: `SSO_SECRET` (variável de ambiente)
|
||||||
|
- Expiração do token: 5 minutos (300 segundos)
|
||||||
|
- Assinatura: HMAC-SHA256
|
||||||
|
- Validação de issuer (`arcadia-suite`) e audience (`arcadia-plus`)
|
||||||
|
- Provisionamento automático: se o email não existe no Plus, cria o usuário
|
||||||
|
|
||||||
|
### 3. Launcher (`server/plus/launcher.ts`)
|
||||||
|
|
||||||
|
O Suite inicia o Laravel automaticamente na inicialização:
|
||||||
|
|
||||||
|
```
|
||||||
|
Suite inicia → Verifica PHP disponível → spawn php artisan serve --port=8080
|
||||||
|
│
|
||||||
|
┌───────┴───────┐
|
||||||
|
│ PHP existe? │
|
||||||
|
├── Sim → Inicia│
|
||||||
|
└── Não → Pula │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verificações:**
|
||||||
|
- `which php` - PHP instalado?
|
||||||
|
- `plus/artisan` existe?
|
||||||
|
- Timeout: 15 segundos para iniciar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Números do Plus
|
||||||
|
|
||||||
|
| Métrica | Quantidade |
|
||||||
|
|---------|-----------|
|
||||||
|
| Controllers | 376 |
|
||||||
|
| Models | 320 |
|
||||||
|
| Services | 12 |
|
||||||
|
| Views (Blade) | 1.194 |
|
||||||
|
| Migrations | 260 |
|
||||||
|
| Rotas Web | 912 |
|
||||||
|
| Rotas API | 634 |
|
||||||
|
| **Total de Rotas** | **1.546** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos do Plus - Detalhamento Completo
|
||||||
|
|
||||||
|
### 1. FISCAL - Documentos Eletrônicos
|
||||||
|
|
||||||
|
O coração do Plus. Emissão e gestão de todos os documentos fiscais brasileiros.
|
||||||
|
|
||||||
|
#### NF-e (Nota Fiscal Eletrônica)
|
||||||
|
| Componente | Arquivo |
|
||||||
|
|-----------|---------|
|
||||||
|
| Controller Web | `NfeController.php` |
|
||||||
|
| Controller API | `API/NFeController.php` |
|
||||||
|
| Painel API | `API/NFePainelController.php` |
|
||||||
|
| Service | `NFeService.php` |
|
||||||
|
| Service API | `NFeServiceApi.php` |
|
||||||
|
| Model | `Nfe.php`, `ItemNfe.php`, `FaturaNfe.php` |
|
||||||
|
| Views | `nfe/*.blade.php` |
|
||||||
|
| XML | `NfeXmlController.php` |
|
||||||
|
| Entrada XML | `NfeEntradaXmlController.php`, `NfeImportaXmlController.php` |
|
||||||
|
|
||||||
|
**Funcionalidades NF-e:**
|
||||||
|
- Emissão, cancelamento, carta de correção
|
||||||
|
- Importação de XML de entrada
|
||||||
|
- Download de XML/DANFE
|
||||||
|
- Manifesto do destinatário (DFe)
|
||||||
|
- Inutilização de numeração
|
||||||
|
- Contingência offline
|
||||||
|
|
||||||
|
#### NFC-e (Nota Fiscal do Consumidor)
|
||||||
|
| Componente | Arquivo |
|
||||||
|
|-----------|---------|
|
||||||
|
| Controller Web | `NfceController.php` |
|
||||||
|
| Controller API | `API/NFCeController.php` |
|
||||||
|
| Painel API | `API/NFCePainelController.php` |
|
||||||
|
| Service | `NFCeService.php` |
|
||||||
|
| Service API | `NFCeServiceApi.php` |
|
||||||
|
| Model | `Nfce.php`, `ItemNfce.php`, `FaturaNfce.php` |
|
||||||
|
| Views | `nfce/*.blade.php` |
|
||||||
|
| Contingência | `NfceContigenciaController.php` |
|
||||||
|
| Impressão | `ImprimirNfceController.php` |
|
||||||
|
|
||||||
|
#### CT-e (Conhecimento de Transporte)
|
||||||
|
| Componente | Arquivo |
|
||||||
|
|-----------|---------|
|
||||||
|
| Controller Web | `CteController.php` |
|
||||||
|
| Controller API | `API/CTeController.php` |
|
||||||
|
| CT-e OS | `CteOsController.php`, `API/CTeOsPainelController.php` |
|
||||||
|
| Service | `CTeService.php`, `CTeOsService.php` |
|
||||||
|
| Model | `Cte.php`, `MedidaCte.php`, `ComponenteCte.php` |
|
||||||
|
| Views | `cte/*.blade.php` |
|
||||||
|
|
||||||
|
#### MDF-e (Manifesto de Documentos Fiscais)
|
||||||
|
| Componente | Arquivo |
|
||||||
|
|-----------|---------|
|
||||||
|
| Controller Web | `MdfeController.php` |
|
||||||
|
| Controller API | `API/MdfeController.php` |
|
||||||
|
| Service | `MDFeService.php` |
|
||||||
|
| Model | `Mdfe.php`, `Percurso.php`, `UnidadeCarga.php` |
|
||||||
|
| Views | `mdfe/*.blade.php` |
|
||||||
|
|
||||||
|
#### Nota de Serviço (NFS-e)
|
||||||
|
| Componente | Arquivo |
|
||||||
|
|-----------|---------|
|
||||||
|
| Controller Web | `NotaServicoController.php` |
|
||||||
|
| Controller API | `API/NotaServicoController.php` |
|
||||||
|
| Config | `NotaServicoConfigController.php` |
|
||||||
|
| Webhook | `API/NfseWebHookController.php` |
|
||||||
|
|
||||||
|
#### Configurações Fiscais
|
||||||
|
| Componente | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `NaturezaOperacaoController.php` | Naturezas de operação (CFOP) |
|
||||||
|
| `NcmController.php` | NCM - Nomenclatura Comum do Mercosul |
|
||||||
|
| `IbptController.php` / `IbptService.php` | IBPT - Carga tributária |
|
||||||
|
| `DifalController.php` | DIFAL - Diferencial de alíquota |
|
||||||
|
| `SpedController.php` / `SpedConfigController.php` | SPED Fiscal |
|
||||||
|
| `SintegraController.php` | SINTEGRA |
|
||||||
|
| `ContigenciaController.php` | Contingência NF-e/NFC-e |
|
||||||
|
| `InutilizacaoController.php` | Inutilização de numeração |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. VENDAS E PDV
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `VendaController.php` | Vendas completas |
|
||||||
|
| `API/VendaController.php` | API de vendas |
|
||||||
|
| `API/PDV/*.php` | Ponto de Venda (LoginController, ClienteController) |
|
||||||
|
| `API/FrontBoxController.php` | Frontend do caixa |
|
||||||
|
| `FrontBoxController.php` | Interface do caixa Web |
|
||||||
|
| `OrcamentoController.php` | Orçamentos |
|
||||||
|
| `API/PreVendaController.php` | Pré-vendas |
|
||||||
|
| `TrocaController.php` | Devoluções/trocas |
|
||||||
|
| `DevolucaoController.php` | Devoluções formais |
|
||||||
|
| `CupomDescontoController.php` | Cupons de desconto |
|
||||||
|
| `CashBackConfigController.php` | Configuração de cashback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. ESTOQUE E PRODUTOS
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ProdutoController.php` | Cadastro de produtos |
|
||||||
|
| `EstoqueController.php` | Controle de estoque |
|
||||||
|
| `EstoqueLocalizacaoController.php` | Localização no estoque |
|
||||||
|
| `StockEntryController.php` | Entrada de estoque |
|
||||||
|
| `StockEntryService.php` | Serviço de entrada |
|
||||||
|
| `StockSaleService.php` | Serviço de saída por venda |
|
||||||
|
| `TransferenciaEstoqueController.php` | Transferências |
|
||||||
|
| `InventarioController.php` | Inventários |
|
||||||
|
| `VariacaoController.php` | Variações de produto |
|
||||||
|
| `ListaPrecoController.php` | Listas de preço |
|
||||||
|
| `ComboController.php` | Combos/kits |
|
||||||
|
| `UnidadeMedidaController.php` | Unidades de medida |
|
||||||
|
| `MarcaController.php` | Marcas |
|
||||||
|
| `ImportadorController.php` | Importação de produtos |
|
||||||
|
| `ImeiTrackingController.php` | Rastreamento de IMEI |
|
||||||
|
| `API/ImeiUnitController.php` | Unidades IMEI via API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. FINANCEIRO
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ContaPagarController.php` | Contas a pagar |
|
||||||
|
| `ContaReceberController.php` | Contas a receber |
|
||||||
|
| `CaixaController.php` | Controle de caixa |
|
||||||
|
| `SangriaController.php` | Sangrias de caixa |
|
||||||
|
| `SuprimentoController.php` | Suprimentos de caixa |
|
||||||
|
| `BoletoController.php` | Boletos |
|
||||||
|
| `ContaBoletoController.php` | Contas com boleto |
|
||||||
|
| `FinanceiroBoletoController.php` | Financeiro boletos |
|
||||||
|
| `FinanceiroDashboardController.php` | Dashboard financeiro |
|
||||||
|
| `FinanceiroPlanoController.php` | Planos financeiros |
|
||||||
|
| `TaxaCartaoController.php` | Taxas de cartão |
|
||||||
|
| `ApuracaoMensalController.php` | Apuração mensal |
|
||||||
|
| `FechamentoMensalController.php` | Fechamento mensal |
|
||||||
|
| `CategoriaContaController.php` | Categorias contábeis |
|
||||||
|
| `ContaEmpresaController.php` | Contas bancárias |
|
||||||
|
| `AsaasController.php` | Integração Asaas (pagamentos) |
|
||||||
|
| `API/PaymentController.php` | API de pagamentos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. CLIENTES E CRM
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ClienteController.php` | Cadastro de clientes |
|
||||||
|
| `ClienteDeliveryController.php` | Clientes de delivery |
|
||||||
|
| `ClienteScoreController.php` | Score/pontuação de clientes |
|
||||||
|
| `CrmController.php` | CRM completo |
|
||||||
|
| `MensagemPadraoCrmController.php` | Mensagens padrão CRM |
|
||||||
|
| `MensagemCrmLogController.php` | Log de mensagens CRM |
|
||||||
|
| `ConvenioController.php` | Convênios |
|
||||||
|
| `ContratoConfigController.php` | Contratos |
|
||||||
|
| `AssinarContratoController.php` | Assinatura de contratos |
|
||||||
|
| `API/CrmController.php` | API CRM |
|
||||||
|
| `API/EnvioFaturaWppController.php` | Envio de fatura via WhatsApp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. FOOD SERVICE (Restaurantes, Pizzarias, Bares)
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **Cardápio Digital** | |
|
||||||
|
| `ConfigCardapioController.php` | Configuração do cardápio |
|
||||||
|
| `CarrosselCardapioController.php` | Carrossel do cardápio |
|
||||||
|
| `AvaliacaoCardapioController.php` | Avaliações |
|
||||||
|
| `API/Cardapio/*.php` | API do cardápio (carrinho, produtos) |
|
||||||
|
| **Delivery** | |
|
||||||
|
| `API/Delivery/*.php` | API delivery (carrinho, clientes, pedidos, produtos) |
|
||||||
|
| `FuncionamentoDeliveryController.php` | Horários de delivery |
|
||||||
|
| **Comanda** | |
|
||||||
|
| `API/Comanda/*.php` | Sistema de comandas (7 controllers) |
|
||||||
|
| **Atendimento** | |
|
||||||
|
| `AtendimentoController.php` | Atendimentos |
|
||||||
|
| `AtendimentoGarcomController.php` | Atendimento garçom |
|
||||||
|
| `MesaController.php` | Gestão de mesas |
|
||||||
|
| **Pizza** | |
|
||||||
|
| `TamanhoPizzaController.php` | Tamanhos de pizza |
|
||||||
|
| `AdicionalController.php` | Adicionais |
|
||||||
|
| `CategoriaAdicionalController.php` | Categorias de adicionais |
|
||||||
|
| **iFood** | |
|
||||||
|
| `IfoodConfigController.php` | Configuração iFood |
|
||||||
|
| `IfoodCatalogoController.php` | Catálogo iFood |
|
||||||
|
| `IfoodPedidoController.php` | Pedidos iFood |
|
||||||
|
| `IfoodProdutoController.php` | Produtos iFood |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. SERVIÇOS E ORDENS DE SERVIÇO
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ServicoController.php` | Cadastro de serviços |
|
||||||
|
| `CategoriaServicoController.php` | Categorias |
|
||||||
|
| `OrdemServicoController.php` | Ordens de serviço |
|
||||||
|
| `API/OrdemServicoController.php` | API de OS |
|
||||||
|
| `AgendamentoController.php` | Agendamentos |
|
||||||
|
| `ConfiguracaoAgendamentoController.php` | Config agendamento |
|
||||||
|
| `FuncionamentoController.php` | Horários |
|
||||||
|
| `InterrupcoesController.php` | Interrupções |
|
||||||
|
| `GarantiaController.php` | Garantias |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. PRODUÇÃO
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `GestaoProducaoController.php` | Gestão de produção |
|
||||||
|
| `API/GestaoProducaoController.php` | API produção |
|
||||||
|
| `API/OrdemProducaoController.php` | Ordens de produção |
|
||||||
|
| `ApontamentoController.php` | Apontamentos |
|
||||||
|
| `CustoConfiguracaoController.php` | Configuração de custos |
|
||||||
|
| `API/PlanejamentoCustoController.php` | Planejamento de custos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. TRANSPORTE E LOGÍSTICA
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `TransportadoraController.php` | Transportadoras |
|
||||||
|
| `FreteController.php` | Fretes |
|
||||||
|
| `TipoDespesaFreteController.php` | Tipos de despesa |
|
||||||
|
| `VeiculoController.php` | Veículos |
|
||||||
|
| `ManutencaoVeiculoController.php` | Manutenção |
|
||||||
|
| `MotoboyController.php` | Motoboys |
|
||||||
|
| `LocalizacaoController.php` | Localização/rastreamento |
|
||||||
|
| `ManifestoController.php` | Manifestos de carga |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. E-COMMERCE E MARKETPLACE
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **WooCommerce** | |
|
||||||
|
| `WoocommerceConfigController.php` | Configuração |
|
||||||
|
| `WoocommerceCategoriaController.php` | Categorias |
|
||||||
|
| `WoocommerceProdutoController.php` | Produtos |
|
||||||
|
| `WoocommercePedidoController.php` | Pedidos |
|
||||||
|
| **Mercado Livre** | |
|
||||||
|
| `MercadoLivreAuthController.php` | Autenticação OAuth |
|
||||||
|
| `MercadoLivreConfigController.php` | Configuração |
|
||||||
|
| `MercadoLivreProdutoController.php` | Produtos |
|
||||||
|
| `MercadoLivrePerguntaController.php` | Perguntas |
|
||||||
|
| **NuvemShop** | |
|
||||||
|
| `NuvemShopController.php` | Integração NuvemShop |
|
||||||
|
| **VendiZap** | |
|
||||||
|
| `VendiZapConfigController.php` | Configuração |
|
||||||
|
| `VendiZapProdutoController.php` | Produtos |
|
||||||
|
| `VendiZapPedidoController.php` | Pedidos |
|
||||||
|
| `VendiZapCategoriaController.php` | Categorias |
|
||||||
|
| **Marketplace Próprio** | |
|
||||||
|
| `MarketPlaceConfigController.php` | Configuração |
|
||||||
|
| `DestaqueMarketPlaceController.php` | Destaques |
|
||||||
|
| `ServicoMarketPlaceController.php` | Serviços |
|
||||||
|
| `SegmentoController.php` | Segmentos |
|
||||||
|
| **E-commerce Próprio** | |
|
||||||
|
| `EcommerceConfigController.php` | Configuração |
|
||||||
|
| `API/EcommerceController.php` | API e-commerce |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. RH E COLABORADORES
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `FuncionarioController.php` | Funcionários |
|
||||||
|
| `FuncionarioEventoController.php` | Eventos de funcionário |
|
||||||
|
| `EventoFuncionarioController.php` | Registro de eventos |
|
||||||
|
| `ComissaoController.php` | Comissões |
|
||||||
|
| `MargemComissaoController.php` | Margens de comissão |
|
||||||
|
| `MetaResultadoController.php` | Metas e resultados |
|
||||||
|
| `ControleAcessoController.php` | Controle de acesso |
|
||||||
|
| `RoleController.php` | Permissões/roles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. SEGMENTOS ESPECIALIZADOS
|
||||||
|
|
||||||
|
#### Ótica
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `FormatoArmacaoOticaController.php` | Formatos de armação |
|
||||||
|
| `TipoArmacaoController.php` | Tipos de armação |
|
||||||
|
| `LaboratorioController.php` | Laboratórios óticos |
|
||||||
|
| `TratamentoOticaController.php` | Tratamentos de lente |
|
||||||
|
| `MedicoController.php` | Médicos/oftalmologistas |
|
||||||
|
|
||||||
|
#### Hotelaria
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `AcomodacaoController.php` | Acomodações |
|
||||||
|
| `CategoriaAcomodacaoController.php` | Categorias |
|
||||||
|
| `ReservaController.php` | Reservas |
|
||||||
|
| `ConfigReservaController.php` | Configuração |
|
||||||
|
| `FrigobarController.php` | Frigobar |
|
||||||
|
| `EstacionamentoController.php` | Estacionamento |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. COMPRAS E FORNECEDORES
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `CompraController.php` | Compras |
|
||||||
|
| `ComprasIAController.php` | Compras com IA |
|
||||||
|
| `FornecedorController.php` | Fornecedores |
|
||||||
|
| `CotacaoController.php` | Cotações |
|
||||||
|
| `CotacaoRespostaController.php` | Respostas de cotação |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. ADMINISTRAÇÃO MULTI-TENANT
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `EmpresaController.php` | Gestão de empresas |
|
||||||
|
| `UsuarioController.php` | Usuários |
|
||||||
|
| `UsuarioEmissaoController.php` | Usuários de emissão |
|
||||||
|
| `ContadorController.php` | Contador |
|
||||||
|
| `ContadorAdminController.php` | Admin do contador |
|
||||||
|
| `ContadorEmpresaController.php` | Empresas do contador |
|
||||||
|
| `ImpersonateController.php` | Impersonar usuário |
|
||||||
|
| `GerenciarPlanoController.php` | Gestão de planos |
|
||||||
|
| `UpgradePlanoController.php` | Upgrade de planos |
|
||||||
|
| `ConfiguracaoSuperController.php` | Config super admin |
|
||||||
|
| `BackupController.php` | Backups |
|
||||||
|
| `SistemaController.php` | Sistema |
|
||||||
|
| `LogController.php` | Logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. TEF (Transferência Eletrônica de Fundos)
|
||||||
|
|
||||||
|
| Controller | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `TefConfigController.php` | Configuração TEF |
|
||||||
|
| `TefController.php` | Operações TEF |
|
||||||
|
| `TefRegistroController.php` | Registros TEF |
|
||||||
|
| Model: `TefMultiPlusCard.php` | Multi Plus Card |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. INTELIGÊNCIA ARTIFICIAL
|
||||||
|
|
||||||
|
| Componente | Descrição |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ComprasIAController.php` | Sugestões de compra via IA |
|
||||||
|
| `FinancePrevisaoService.php` | Previsão financeira com IA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models (320 entidades)
|
||||||
|
|
||||||
|
### Categorias Principais
|
||||||
|
|
||||||
|
| Grupo | Models Exemplo |
|
||||||
|
|-------|---------------|
|
||||||
|
| **Core** | `User`, `Empresa`, `Cidade`, `Config` |
|
||||||
|
| **Produtos** | `Produto`, `ProdutoVariacao`, `ProdutoComposicao`, `ProdutoAdicional`, `Marca`, `CategoriaProduto`, `UnidadeMedida` |
|
||||||
|
| **Vendas** | `Venda`, `ItemVenda`, `Orcamento`, `PreVenda`, `VendaSuspensa`, `Troca`, `Devolucao` |
|
||||||
|
| **Fiscal NF-e** | `Nfe`, `ItemNfe`, `FaturaNfe`, `NaturezaOperacao`, `PadraoTributacaoProduto` |
|
||||||
|
| **Fiscal NFC-e** | `Nfce`, `ItemNfce`, `FaturaNfce`, `Inutilizacao` |
|
||||||
|
| **CT-e** | `Cte`, `MedidaCte`, `ComponenteCte`, `ChaveNfeCte` |
|
||||||
|
| **MDF-e** | `Mdfe`, `Percurso`, `UnidadeCarga`, `ValePedagio`, `Ciot` |
|
||||||
|
| **Financeiro** | `ContaPagar`, `ContaReceber`, `Caixa`, `SangriaCaixa`, `SuprimentoCaixa`, `Pagamento` |
|
||||||
|
| **Estoque** | `Estoque`, `MovimentacaoProduto`, `StockMove`, `TransferenciaEstoque`, `ItemInventario` |
|
||||||
|
| **Pessoas** | `Cliente`, `Fornecedor`, `Funcionario`, `Transportadora`, `Motoboy` |
|
||||||
|
| **Food Service** | `Pedido`, `ItemPedido`, `Adicional`, `TamanhoPizza`, `ConfiguracaoCardapio`, `CarrosselCardapio` |
|
||||||
|
| **CRM** | `CrmAnotacao`, `MensagemPadraoCrm`, `CupomDesconto`, `CashBackConfig` |
|
||||||
|
| **E-commerce** | `WoocommerceConfig`, `WoocommercePedido`, `WoocommerceItemPedido`, `VendiZapConfig` |
|
||||||
|
| **Agendamento** | `Agendamento`, `ItemAgendamento`, `Servico`, `Funcionamento`, `Interrupcao` |
|
||||||
|
| **OS** | `OrdemServico`, `ServicoOs`, `RelatorioOs`, `FuncionarioOs`, `ProdutoOs` |
|
||||||
|
| **Produção** | `OrdemProducao`, `Apontamento`, `CustoConfiguracao` |
|
||||||
|
| **Hotelaria** | `Acomodacao`, `Reserva`, `Frigobar` |
|
||||||
|
| **Ótica** | `OticaOs`, `Laboratorio`, `TratamentoOtica`, `FormatoArmacao`, `TipoArmacao` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services (Motor de Negócios)
|
||||||
|
|
||||||
|
| Service | Responsabilidade |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `NFeService.php` | Emissão/cancelamento/CC de NF-e via Cloud-DFE SDK |
|
||||||
|
| `NFeServiceApi.php` | API externa de NF-e |
|
||||||
|
| `NFCeService.php` | Emissão de NFC-e (consumidor final) |
|
||||||
|
| `NFCeServiceApi.php` | API externa de NFC-e |
|
||||||
|
| `CTeService.php` | CT-e para transportadoras |
|
||||||
|
| `CTeOsService.php` | CT-e OS (ordem de serviço de transporte) |
|
||||||
|
| `MDFeService.php` | MDF-e para manifestos de carga |
|
||||||
|
| `DFeService.php` | Manifesto do Destinatário |
|
||||||
|
| `StockEntryService.php` | Entrada automática de estoque por NF-e |
|
||||||
|
| `StockSaleService.php` | Baixa automática de estoque por venda |
|
||||||
|
| `IbptService.php` | Consulta IBPT para carga tributária |
|
||||||
|
| `SpeedService.php` | SPED fiscal |
|
||||||
|
| `FinancePrevisaoService.php` | Previsão financeira com IA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations (260 tabelas)
|
||||||
|
|
||||||
|
### Evolução Cronológica
|
||||||
|
|
||||||
|
| Período | Tabelas Criadas | Destaque |
|
||||||
|
|---------|----------------|----------|
|
||||||
|
| 2022 | `users`, `empresas`, `produtos`, `clientes`, `caixas` | Core do sistema |
|
||||||
|
| 2023 Q2 | `nves` (NF-e), `nfces`, `item_nves` | Módulo fiscal |
|
||||||
|
| 2023 Q3 | `pagamentos`, `inutilizacaos` | Financeiro |
|
||||||
|
| 2023 Q4 | `ctes`, `conta_pagars`, `conta_recebers`, `mdves`, `ordem_servicos`, `pedidos` | Transporte, OS, Food |
|
||||||
|
| 2024 Q1 | `cte_os`, `ordem_producaos`, `produto_composicaos` | Produção |
|
||||||
|
| 2024 Q2 | `cotacaos`, `ifood_*`, `woocommerce_*`, `mercado_livre_*` | E-commerce |
|
||||||
|
| 2024 Q3 | `stock_moves`, `imei_units`, `crm_anotacaos` | IMEI, CRM |
|
||||||
|
| 2024 Q4 | `otica_os`, `laboratorios`, `acomodacaos`, `reservas` | Ótica, Hotelaria |
|
||||||
|
| 2025 | `item_inventarios`, `tef_*`, `nuvem_shop_*` | Inventário, TEF, NuvemShop |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Infrastructure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plus/docker-compose.yml
|
||||||
|
services:
|
||||||
|
app: # PHP 8.2 FPM
|
||||||
|
build: .
|
||||||
|
volumes: .:/var/www
|
||||||
|
networks: [controlplus]
|
||||||
|
|
||||||
|
web: # Nginx 1.25
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
ports: "8080:80"
|
||||||
|
depends_on: [app]
|
||||||
|
|
||||||
|
db: # MySQL 8.0
|
||||||
|
image: mysql:8.0
|
||||||
|
volumes: db_data:/var/lib/mysql
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: controlplus
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
- Base: `php:8.2-fpm`
|
||||||
|
- Extensions: GD, BCMath, PDO, Zip, Intl, Soap
|
||||||
|
- Node.js 18 (para assets frontend)
|
||||||
|
- Composer para dependências PHP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo de Dados Suite ↔ Plus
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLUXO COMPLETO │
|
||||||
|
│ │
|
||||||
|
│ 1. Usuário acessa Arcádia Suite (porta 5000) │
|
||||||
|
│ 2. Navega para módulo Plus (/plus) │
|
||||||
|
│ 3. Suite gera token SSO com dados do usuário │
|
||||||
|
│ 4. Proxy envia requisição para Laravel (porta 8080) │
|
||||||
|
│ 5. Laravel valida SSO, auto-login, exibe interface │
|
||||||
|
│ 6. Operações fiscais (NF-e/NFC-e) executadas no Plus │
|
||||||
|
│ 7. Dados sincronizados via SOE Motor Adapter │
|
||||||
|
│ 8. Suite consome dados para BI, dashboards, governança │
|
||||||
|
│ │
|
||||||
|
│ SUITE (Inteligência) PLUS (Execução) │
|
||||||
|
│ ├── Decide o que vender ├── Emite NF-e/NFC-e │
|
||||||
|
│ ├── Analisa tendências ├── Controla estoque │
|
||||||
|
│ ├── Gera relatórios BI ├── Processa pagamentos │
|
||||||
|
│ ├── Automatiza processos ├── Gerencia caixa │
|
||||||
|
│ └── Governa políticas └── Registra movimentações │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs do Plus
|
||||||
|
|
||||||
|
### Rotas Web (912 rotas) - Agrupadas
|
||||||
|
|
||||||
|
| Grupo | Rotas | Exemplos |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| Dashboard | ~10 | `/home`, `/empresas` |
|
||||||
|
| Produtos | ~60 | `/produtos/*`, `/estoque/*`, `/marcas/*` |
|
||||||
|
| Vendas/PDV | ~50 | `/vendas/*`, `/pdv/*`, `/orcamento/*` |
|
||||||
|
| Fiscal | ~80 | `/nfe/*`, `/nfce/*`, `/cte/*`, `/mdfe/*` |
|
||||||
|
| Financeiro | ~70 | `/conta-pagar/*`, `/conta-receber/*`, `/caixa/*` |
|
||||||
|
| Clientes/CRM | ~40 | `/clientes/*`, `/crm/*` |
|
||||||
|
| Food Service | ~80 | `/cardapio/*`, `/delivery/*`, `/comanda/*`, `/pedidos/*` |
|
||||||
|
| E-commerce | ~60 | `/woocommerce/*`, `/mercado-livre/*`, `/nuvem-shop/*` |
|
||||||
|
| OS/Serviços | ~40 | `/ordem-servico/*`, `/agendamento/*`, `/servicos/*` |
|
||||||
|
| Transporte | ~50 | `/cte/*`, `/mdfe/*`, `/veiculos/*`, `/motoboy/*` |
|
||||||
|
| Admin | ~100 | `/usuarios/*`, `/empresa/*`, `/configuracao/*` |
|
||||||
|
| Contabilidade | ~60 | `/sped/*`, `/apuracao/*`, `/fechamento/*` |
|
||||||
|
| Outros | ~212 | Reservas, ótica, laboratórios, tickets, etc. |
|
||||||
|
|
||||||
|
### Rotas API (634 rotas) - Agrupadas
|
||||||
|
|
||||||
|
| Grupo | Prefixo | Descrição |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| PDV | `/api/pdv/*` | Ponto de venda mobile |
|
||||||
|
| Cardápio | `/api/cardapio/*` | Cardápio digital |
|
||||||
|
| Delivery | `/api/delivery/*` | Delivery app |
|
||||||
|
| Comanda | `/api/comanda/*` | Sistema de comandas |
|
||||||
|
| Kotlin App | `/api/kotlin/*` | App mobile nativo |
|
||||||
|
| Fiscal | `/api/nfe/*`, `/api/nfce/*` | Emissão fiscal |
|
||||||
|
| CRM | `/api/crm/*` | CRM mobile |
|
||||||
|
| Estoque | `/api/stock-entry/*` | Entrada de estoque |
|
||||||
|
| E-commerce | `/api/woocommerce/*`, `/api/mercadolivre/*` | Integrações |
|
||||||
|
| iFood | `/api/ifood/*` | Integração iFood |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo Visual
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ARCÁDIA PLUS - ERP COMPLETO │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐│
|
||||||
|
│ │ FISCAL │ │ VENDAS │ │ ESTOQUE │ │ FINANCEIRO ││
|
||||||
|
│ │ NF-e/NFC-e │ │ PDV/Caixa │ │ IMEI Track │ │ Contas P/R ││
|
||||||
|
│ │ CT-e/MDF-e │ │ Orçamentos │ │ Inventário │ │ Boletos ││
|
||||||
|
│ │ NFS-e/SPED │ │ Trocas │ │ Transf. │ │ TEF ││
|
||||||
|
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐│
|
||||||
|
│ │ FOOD │ │ E-COMMERCE │ │ SERVIÇOS │ │ TRANSPORTE ││
|
||||||
|
│ │ Cardápio │ │ WooCommerce│ │ O.S. │ │ CT-e ││
|
||||||
|
│ │ Delivery │ │ Merc.Livre │ │ Agendament │ │ MDF-e ││
|
||||||
|
│ │ iFood │ │ NuvemShop │ │ Garantias │ │ Logística ││
|
||||||
|
│ │ Comandas │ │ VendiZap │ │ │ │ ││
|
||||||
|
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐│
|
||||||
|
│ │ CRM │ │ RH │ │ PRODUÇÃO │ │ SEGMENTOS ││
|
||||||
|
│ │ Clientes │ │ Comissões │ │ Ordens │ │ Ótica ││
|
||||||
|
│ │ Score │ │ Metas │ │ Apontament │ │ Hotelaria ││
|
||||||
|
│ │ Contratos │ │ Roles │ │ Custos │ │ Marketplace││
|
||||||
|
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘│
|
||||||
|
│ │
|
||||||
|
│ 376 Controllers │ 320 Models │ 12 Services │ 1.194 Views │
|
||||||
|
│ 260 Migrations │ 912 Web Routes │ 634 API Routes │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Arcádia Plus v2.0 - O Motor de Execução do Arcádia Suite*
|
||||||
|
|
@ -0,0 +1,469 @@
|
||||||
|
# Arcádia Suite - Mapa Geral do Sistema
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
**Arcádia Suite** é o Escritório Estratégico para a Empresa Moderna. Uma plataforma que centraliza produtividade, inteligência, tomada de decisão e governança, orquestrando ERPs, pessoas e dados.
|
||||||
|
|
||||||
|
**Princípio Central:** Separação absoluta entre decisão e execução.
|
||||||
|
- Arcádia **pensa, governa e orienta**
|
||||||
|
- ERPs **executam, registram e obedecem**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitetura de 4 Camadas
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE APRESENTAÇÃO │
|
||||||
|
│ React 18 + TypeScript + Tailwind CSS + shadcn/ui │
|
||||||
|
│ Interface tipo browser com abas + omnibox │
|
||||||
|
│ 66 páginas/módulos │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE ORQUESTRAÇÃO │
|
||||||
|
│ Express.js + Socket.IO + Manus Agent │
|
||||||
|
│ Porta 5000 (API + WebSocket) │
|
||||||
|
│ 38 arquivos de rotas / 23 ferramentas registradas │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE INTELIGÊNCIA │
|
||||||
|
│ FastAPI (Contábil 8003, BI 8004, Automação 8005) │
|
||||||
|
│ Communication Engine (Node 8006) │
|
||||||
|
│ OpenAI GPT-4o (Manus/Dev Center) + GPT-4o-mini (WhatsApp) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAMADA DE DADOS │
|
||||||
|
│ PostgreSQL + Drizzle ORM │
|
||||||
|
│ Knowledge Graph + ChromaDB (embeddings) │
|
||||||
|
│ Session Store + Multi-tenant │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapa de Portas
|
||||||
|
|
||||||
|
| Porta | Serviço | Tecnologia |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| 5000 | API Principal + Frontend | Express.js + React |
|
||||||
|
| 8002 | Motor Fiscal (Fisco) | FastAPI (Python) |
|
||||||
|
| 8003 | Motor Contábil | FastAPI (Python) |
|
||||||
|
| 8004 | Motor BI (Insights) | FastAPI (Python) |
|
||||||
|
| 8005 | Motor Automação | FastAPI (Python) |
|
||||||
|
| 8006 | Motor Comunicação | Node.js/Express |
|
||||||
|
| 8080 | Arcádia Plus (Laravel) | PHP/Laravel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos do Frontend (66 páginas)
|
||||||
|
|
||||||
|
### Núcleo & Administração
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Home | `/` | Dashboard principal |
|
||||||
|
| SOE | `/soe` | Sistema Operacional Empresarial |
|
||||||
|
| ERP | `/erp` | Módulo ERP legado |
|
||||||
|
| Admin | `/admin` | Administração do sistema |
|
||||||
|
| SuperAdmin | `/super-admin` | Gestão multi-tenant |
|
||||||
|
|
||||||
|
### Módulos de Negócio
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Financeiro | `/financeiro` | Contas a pagar/receber, fluxo de caixa |
|
||||||
|
| Contábil | `/contabil` | Contabilidade, DRE, balancetes |
|
||||||
|
| Fiscal | `/fisco` | NF-e, NFC-e, CFOP, NCM, CEST |
|
||||||
|
| CRM | `/crm` | Gestão de relacionamento com cliente |
|
||||||
|
| People | `/people` | RH, colaboradores, folha |
|
||||||
|
| Production | `/production` | Ordens de produção |
|
||||||
|
| Quality | `/quality` | Controle de qualidade |
|
||||||
|
|
||||||
|
### Varejo & Comércio
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Retail | `/retail` | Varejo (celulares, assistência técnica) |
|
||||||
|
| RetailReports | `/retail-reports` | Relatórios do varejo |
|
||||||
|
| Marketplace | `/marketplace` | Marketplace integrado |
|
||||||
|
| Valuation | `/valuation` | Avaliação de trade-in |
|
||||||
|
|
||||||
|
### Comunicação
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| WhatsApp | `/whatsapp` | Multi-sessão WhatsApp |
|
||||||
|
| Chat | `/chat` | Chat interno |
|
||||||
|
| XOS Inbox | `/xos/inbox` | Caixa de entrada unificada |
|
||||||
|
| XOS CRM | `/xos/crm` | CRM unificado |
|
||||||
|
| XOS Campaigns | `/xos/campaigns` | Campanhas de marketing |
|
||||||
|
| XOS Tickets | `/xos/tickets` | Sistema de tickets |
|
||||||
|
|
||||||
|
### Inteligência & IA
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Scientist | `/scientist` | Auto-programação com IA |
|
||||||
|
| Knowledge | `/knowledge` | Base de conhecimento/grafo |
|
||||||
|
| BI Workspace | `/bi` | Business Intelligence |
|
||||||
|
| Manus | `/agent` | Agente autônomo central |
|
||||||
|
|
||||||
|
### Desenvolvimento & DevOps
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| IDE | `/ide` | Editor Monaco + Terminal |
|
||||||
|
| Dev Center | `/dev-center` | Centro de desenvolvimento XOS |
|
||||||
|
| XOS Pipeline | `/xos/pipeline` | Pipeline autônomo de código |
|
||||||
|
| XOS Governance | `/xos/governance` | Governança e políticas |
|
||||||
|
| API Hub | `/api-hub` | Central de APIs |
|
||||||
|
| API Tester | `/api-tester` | Testador de APIs |
|
||||||
|
| DocType Builder | `/doctype-builder` | Construtor de tipos |
|
||||||
|
| Page Builder | `/page-builder` | Construtor de páginas |
|
||||||
|
| Workflow Builder | `/workflow-builder` | Construtor de workflows |
|
||||||
|
|
||||||
|
### Operações & Engenharia
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Engineering Hub | `/engineering` | Hub de engenharia |
|
||||||
|
| Field Operations | `/field-ops` | Operações de campo |
|
||||||
|
| Process Compass | `/compass` | Bússola de processos |
|
||||||
|
| Suppliers Portal | `/suppliers` | Portal de fornecedores |
|
||||||
|
|
||||||
|
### Plataforma
|
||||||
|
| Página | Rota | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Engine Room | `/engine-room` | Casa de Máquinas (status dos motores) |
|
||||||
|
| Automations | `/automations` | Motor de automações |
|
||||||
|
| Plus | `/plus` | ERP Laravel (proxy) |
|
||||||
|
| LMS | `/lms` | Sistema de aprendizagem |
|
||||||
|
| Communities | `/communities` | Comunidades |
|
||||||
|
| Support | `/support` | Central de suporte |
|
||||||
|
| Migration | `/migration` | Migração de dados |
|
||||||
|
| Central APIs | `/central-apis` | APIs centrais |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs do Backend (38 grupos de rotas)
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/login`, `/api/register` | `server/auth.ts` | Autenticação |
|
||||||
|
| `/api/admin/*` | `server/admin/routes.ts` | Administração |
|
||||||
|
| `/api/erp/*` | `server/erp/routes.ts` | ERP principal |
|
||||||
|
| `/api/soe/*` | `server/erp/routes.ts` | SOE (alias) |
|
||||||
|
| `/api/users/*` | `server/routes.ts` | Gestão de usuários |
|
||||||
|
|
||||||
|
### Negócio
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/financeiro/*` | `server/financeiro/routes.ts` | Financeiro |
|
||||||
|
| `/api/contabil/*` | `server/contabil/routes.ts` | Contabilidade |
|
||||||
|
| `/api/fisco/*` | `server/fisco/routes.ts` | Fiscal |
|
||||||
|
| `/api/crm/*` | `server/crm/routes.ts` | CRM |
|
||||||
|
| `/api/people/*` | `server/people/routes.ts` | RH/Pessoas |
|
||||||
|
| `/api/production/*` | `server/production/routes.ts` | Produção |
|
||||||
|
| `/api/quality/*` | `server/quality/routes.ts` | Qualidade |
|
||||||
|
| `/api/retail/*` | `server/retail/routes.ts` | Varejo |
|
||||||
|
| `/api/valuation/*` | `server/valuation/routes.ts` | Avaliação trade-in |
|
||||||
|
| `/api/marketplace/*` | `server/marketplace/routes.ts` | Marketplace |
|
||||||
|
|
||||||
|
### Comunicação
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/whatsapp/*` | `server/whatsapp/routes.ts` | WhatsApp multi-sessão |
|
||||||
|
| `/api/chat/*` | `server/chat/routes.ts` | Chat interno |
|
||||||
|
| `/api/email/*` | `server/email/routes.ts` | E-mail |
|
||||||
|
| `/api/comm/*` | proxy | Motor de Comunicação |
|
||||||
|
| `/api/xos/*` | `server/xos/routes.ts` | XOS CRM unificado |
|
||||||
|
|
||||||
|
### Inteligência
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/manus/*` | `server/manus/routes.ts` | Agente Manus IA |
|
||||||
|
| `/api/knowledge/*` | `server/learning/routes.ts` | Knowledge Graph |
|
||||||
|
| `/api/bi/*` | `server/bi/routes.ts` | Business Intelligence |
|
||||||
|
| `/api/bi/metaset/*` | `server/metaset/routes.ts` | Motor BI MetaSet |
|
||||||
|
| `/api/scientist/*` | `server/routes.ts` | Cientista de dados |
|
||||||
|
|
||||||
|
### Desenvolvimento
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/ide/*` | `server/ide/routes.ts` | IDE integrada |
|
||||||
|
| `/api/dev-center/*` | `server/blackboard/routes.ts` | Dev Center/Blackboard |
|
||||||
|
| `/api/xos/pipeline` | `server/blackboard/routes.ts` | Pipeline autônomo |
|
||||||
|
| `/api/governance/*` | `server/governance/routes.ts` | Governança |
|
||||||
|
| `/api/lowcode/*` | `server/lowcode/routes.ts` | Low-code engine |
|
||||||
|
|
||||||
|
### Protocolos de Interoperabilidade
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/mcp/v1/*` | `server/mcp/routes.ts` | Model Context Protocol |
|
||||||
|
| `/api/a2a/v1/*` | `server/routes.ts` | Agent to Agent Protocol |
|
||||||
|
| `/api/api-central/*` | `server/api-central/routes.ts` | Central de APIs |
|
||||||
|
|
||||||
|
### Infraestrutura
|
||||||
|
| Rota Base | Arquivo | Descrição |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| `/api/engine-room/*` | `server/engine-room/routes.ts` | Casa de Máquinas |
|
||||||
|
| `/api/automations/*` | `server/automations/routes.ts` | Motor de Automação |
|
||||||
|
| `/api/modules/*` | `server/modules/loader.ts` | Módulos dinâmicos |
|
||||||
|
| `/api/login-bridge/*` | `server/login-bridge/routes.ts` | SSO Bridge |
|
||||||
|
| `/api/migration/*` | `server/migration/routes.ts` | Migração |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motores (Engines)
|
||||||
|
|
||||||
|
### Motor IA - Manus (Node.js, porta 5000)
|
||||||
|
- **Modelo:** GPT-4o (Dev Center), GPT-4o-mini (WhatsApp)
|
||||||
|
- **Agentes:** 6 agentes autônomos (Architect, Generator, Validator, Executor, Researcher, Evolution)
|
||||||
|
- **Ferramentas:** 23 ferramentas registradas (GitHub, filesystem, BI, git)
|
||||||
|
- **Pipeline:** Design → Codegen → Validation → Staging → Evolution
|
||||||
|
|
||||||
|
### Motor Fiscal - Fisco (Python, porta 8002)
|
||||||
|
- NF-e / NFC-e via nfelib
|
||||||
|
- NCMs, CFOPs, CESTs, grupos tributários
|
||||||
|
- Certificados digitais
|
||||||
|
- Comunicação com SEFAZ
|
||||||
|
|
||||||
|
### Motor Contábil (Python, porta 8003)
|
||||||
|
- Plano de contas
|
||||||
|
- Lançamentos contábeis
|
||||||
|
- DRE, Balanço Patrimonial
|
||||||
|
|
||||||
|
### Motor BI - Insights (Python, porta 8004)
|
||||||
|
- Execução SQL
|
||||||
|
- Geração de gráficos
|
||||||
|
- Análise com Pandas
|
||||||
|
- Cache inteligente
|
||||||
|
|
||||||
|
### Motor Automação (Python, porta 8005)
|
||||||
|
- Cron scheduler
|
||||||
|
- Event bus
|
||||||
|
- Executor de workflows
|
||||||
|
|
||||||
|
### Motor Comunicação (Node.js, porta 8006)
|
||||||
|
- Unifica XOS CRM + WhatsApp + Email
|
||||||
|
- Contatos, threads, mensagens unificados
|
||||||
|
- Filas de atendimento
|
||||||
|
- Eventos para Knowledge Graph
|
||||||
|
|
||||||
|
### Arcádia Plus - ERP Laravel (PHP, porta 8080)
|
||||||
|
- NF-e/NFC-e/CT-e/MDF-e
|
||||||
|
- PDV (ponto de venda)
|
||||||
|
- Cardápio digital
|
||||||
|
- Ordens de serviço
|
||||||
|
- Estoque com rastreabilidade
|
||||||
|
- Integrações e-commerce (WooCommerce, Mercado Livre, NuvemShop)
|
||||||
|
- Integrações delivery (iFood)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev Center XOS - 6 Agentes Autônomos
|
||||||
|
|
||||||
|
```
|
||||||
|
Prompt em Português
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Architect │ ──▶ │ Generator │ ──▶ │ Validator │
|
||||||
|
│ (Design) │ │ (Codegen) │ │ (Typecheck) │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Evolution │ ◀── │ Researcher │ ◀── │ Executor │
|
||||||
|
│ (Aprende) │ │ (Pesquisa) │ │ (Staging) │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo Retail (Varejo de Celulares)
|
||||||
|
|
||||||
|
### Funcionalidades Core
|
||||||
|
- **Vendas com IMEI:** Rastreamento individual de aparelhos
|
||||||
|
- **Trade-in:** Avaliação com checklist de 19 itens
|
||||||
|
- **Ordens de Serviço:** Gestão completa de assistência técnica
|
||||||
|
- **Garantia:** Controle de garantias por IMEI
|
||||||
|
- **Caixa Diário:** Reconciliação de caixa com fechamento
|
||||||
|
- **Comissões:** Cálculo automático por vendedor
|
||||||
|
|
||||||
|
### Checklist Trade-in (19 itens)
|
||||||
|
1. Liga normalmente
|
||||||
|
2. Problemas na tela
|
||||||
|
3. WiFi funcionando
|
||||||
|
4. Bluetooth funcionando
|
||||||
|
5. Câmera frontal
|
||||||
|
6. Câmera traseira
|
||||||
|
7. Microfone
|
||||||
|
8. Alto-falante
|
||||||
|
9. Botões físicos
|
||||||
|
10. Sensor biométrico
|
||||||
|
11. Carregamento
|
||||||
|
12. Bateria saudável
|
||||||
|
13. GPS funcionando
|
||||||
|
14. Giroscópio
|
||||||
|
15. Acelerômetro
|
||||||
|
16. NFC
|
||||||
|
17. Resistência à água
|
||||||
|
18. Face ID / reconhecimento facial
|
||||||
|
19. Vibração
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Banco de Dados (PostgreSQL + Drizzle ORM)
|
||||||
|
|
||||||
|
### Tabelas Principais
|
||||||
|
| Grupo | Tabelas |
|
||||||
|
|-------|---------|
|
||||||
|
| **Identidade** | `users`, `profiles`, `tenants` |
|
||||||
|
| **Produtividade** | `workspace_pages`, `page_blocks`, `dashboard_widgets`, `quick_notes` |
|
||||||
|
| **Comunicação** | `conversations`, `messages`, `chat_threads`, `chat_messages` |
|
||||||
|
| **WhatsApp** | `whatsapp_sessions`, `whatsapp_contacts`, `whatsapp_messages`, `whatsapp_tickets` |
|
||||||
|
| **ERP Core** | `applications`, `erp_connections`, `agent_tasks`, `task_executions` |
|
||||||
|
| **Conhecimento** | `knowledge_base`, `knowledge_graph_nodes`, `knowledge_graph_edges` |
|
||||||
|
| **Governança** | `xos_governance_*`, `xos_job_queue`, `xos_agent_metrics` |
|
||||||
|
| **Pipeline** | `xos_staging_changes`, `xos_dev_pipelines` |
|
||||||
|
| **Comunicação Unificada** | `comm_contacts`, `comm_threads`, `comm_messages`, `comm_channels` |
|
||||||
|
| **Varejo** | Via módulos dinâmicos (`/api/modules/retail-reports`) |
|
||||||
|
| **Financeiro** | Contas, lançamentos, conciliação |
|
||||||
|
| **Fiscal** | NCMs, CFOPs, notas fiscais |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrações Externas
|
||||||
|
|
||||||
|
| Serviço | Uso |
|
||||||
|
|---------|-----|
|
||||||
|
| **OpenAI** | GPT-4o (Manus, Dev Center), GPT-4o-mini (WhatsApp) |
|
||||||
|
| **GitHub** | Commits automáticos, análise de repositórios |
|
||||||
|
| **ERPNext** | Integração com ERP externo (clientes, produtos, vendas) |
|
||||||
|
| **WhatsApp/Baileys** | Multi-sessão de atendimento |
|
||||||
|
| **SEFAZ** | NF-e/NFC-e via nfelib |
|
||||||
|
| **Cloud-DFE** | SDK fiscal (NF-e, NFC-e, CT-e, MDF-e) |
|
||||||
|
| **WooCommerce** | E-commerce integration |
|
||||||
|
| **Mercado Livre** | Marketplace |
|
||||||
|
| **NuvemShop** | E-commerce |
|
||||||
|
| **iFood** | Delivery (pedidos, cardápio) |
|
||||||
|
| **Asaas** | Pagamentos, boletos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocolos de Interoperabilidade
|
||||||
|
|
||||||
|
| Protocolo | Rota | Descrição |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| **MCP** | `/api/mcp/v1/` | Model Context Protocol - exposição de ferramentas |
|
||||||
|
| **A2A** | `/api/a2a/v1/` | Agent to Agent - comunicação bidirecional |
|
||||||
|
| **AP2** | Planejado | Agent Payment Protocol |
|
||||||
|
| **UCP** | Planejado | Unified Commerce Protocol |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como Rodar Localmente
|
||||||
|
|
||||||
|
### Pré-requisitos
|
||||||
|
- Node.js 20+
|
||||||
|
- Python 3.11+
|
||||||
|
- PostgreSQL 16+
|
||||||
|
- PHP 8.2+ (opcional, para Arcádia Plus)
|
||||||
|
|
||||||
|
### Instalação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Extrair o backup
|
||||||
|
tar xzf arcadia-suite-backup.tar.gz
|
||||||
|
|
||||||
|
# 2. Instalar dependências Node
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. Instalar dependências Python
|
||||||
|
pip install fastapi uvicorn pandas numpy psycopg2-binary nfelib lxml cryptography
|
||||||
|
|
||||||
|
# 4. Configurar variáveis de ambiente
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env com suas credenciais:
|
||||||
|
# DATABASE_URL=postgresql://user:pass@localhost:5432/arcadia
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# GITHUB_TOKEN=ghp_...
|
||||||
|
|
||||||
|
# 5. Criar banco de dados
|
||||||
|
createdb arcadia
|
||||||
|
|
||||||
|
# 6. Executar migrations
|
||||||
|
npx drizzle-kit push
|
||||||
|
|
||||||
|
# 7. Iniciar em desenvolvimento
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variáveis de Ambiente Necessárias
|
||||||
|
| Variável | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `DATABASE_URL` | URL de conexão PostgreSQL |
|
||||||
|
| `OPENAI_API_KEY` | Chave da API OpenAI |
|
||||||
|
| `GITHUB_TOKEN` | Token GitHub para integrações |
|
||||||
|
| `ERPNEXT_URL` | URL do ERPNext (opcional) |
|
||||||
|
| `ERPNEXT_API_KEY` | Chave API ERPNext (opcional) |
|
||||||
|
| `ERPNEXT_API_SECRET` | Segredo API ERPNext (opcional) |
|
||||||
|
| `SESSION_SECRET` | Segredo para sessões Express |
|
||||||
|
|
||||||
|
### Credenciais Padrão
|
||||||
|
- **Usuário:** admin
|
||||||
|
- **Senha:** admin
|
||||||
|
- **Role:** master
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de Diretórios
|
||||||
|
|
||||||
|
```
|
||||||
|
arcadia-suite/
|
||||||
|
├── client/ # Frontend React
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/ # 66 páginas
|
||||||
|
│ │ ├── components/ # Componentes reutilizáveis
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ └── lib/ # Utilitários
|
||||||
|
│ └── public/ # Assets estáticos
|
||||||
|
├── server/ # Backend Express
|
||||||
|
│ ├── admin/ # Administração
|
||||||
|
│ ├── autonomous/ # Ferramentas autônomas
|
||||||
|
│ ├── bi/ # Business Intelligence
|
||||||
|
│ ├── blackboard/ # Dev Center (6 agentes)
|
||||||
|
│ ├── chat/ # Chat interno
|
||||||
|
│ ├── communication/ # Motor de comunicação
|
||||||
|
│ ├── contabil/ # Motor contábil
|
||||||
|
│ ├── crm/ # CRM
|
||||||
|
│ ├── engine-room/ # Casa de Máquinas
|
||||||
|
│ ├── erp/ # ERP/SOE
|
||||||
|
│ ├── financeiro/ # Financeiro
|
||||||
|
│ ├── fisco/ # Fiscal
|
||||||
|
│ ├── governance/ # Governança XOS
|
||||||
|
│ ├── ide/ # IDE integrada
|
||||||
|
│ ├── integrations/ # Integrações externas
|
||||||
|
│ ├── learning/ # Knowledge Graph
|
||||||
|
│ ├── manus/ # Agente Manus
|
||||||
|
│ ├── mcp/ # Model Context Protocol
|
||||||
|
│ ├── modules/ # Módulos dinâmicos
|
||||||
|
│ ├── people/ # RH
|
||||||
|
│ ├── plus/ # Proxy Laravel
|
||||||
|
│ ├── production/ # Produção
|
||||||
|
│ ├── python/ # Scripts Python
|
||||||
|
│ ├── quality/ # Qualidade
|
||||||
|
│ ├── retail/ # Varejo
|
||||||
|
│ ├── whatsapp/ # WhatsApp
|
||||||
|
│ └── xos/ # XOS unificado
|
||||||
|
├── shared/ # Código compartilhado
|
||||||
|
│ ├── schema.ts # Schema principal (Drizzle)
|
||||||
|
│ └── schemas/ # Schemas modulares
|
||||||
|
├── plus/ # ERP Laravel (PHP)
|
||||||
|
├── python-service/ # Serviço Python
|
||||||
|
├── db/ # Configuração do banco
|
||||||
|
├── migrations/ # Migrations Drizzle
|
||||||
|
└── docs/ # Documentação
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Arcádia Suite v2.0 - O Escritório Estratégico para a Empresa Moderna*
|
||||||
|
|
@ -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,421 @@
|
||||||
|
# PLANO ESTRATÉGICO DE EVOLUÇÃO
|
||||||
|
## Arcádia Suite → Frappe Framework
|
||||||
|
### Versão 1.0 - Janeiro 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VISÃO GERAL
|
||||||
|
|
||||||
|
### 1.1 Objetivo
|
||||||
|
Evoluir o Arcádia Suite para um **Business Operating System** completo, inspirado em três referências:
|
||||||
|
|
||||||
|
| Referência | O que Inspira |
|
||||||
|
|------------|---------------|
|
||||||
|
| **Notion** | Blocos modulares, banco de dados relacional, personalização |
|
||||||
|
| **Replit** | IDE no navegador, colaboração em tempo real, deploy instantâneo |
|
||||||
|
| **Discord** | Comunidades, canais contextuais, comunicação em tempo real |
|
||||||
|
|
||||||
|
### 1.2 Estratégia de Migração: Strangler Fig
|
||||||
|
|
||||||
|
A estratégia Strangler Fig permite:
|
||||||
|
- Manter WhatsApp, Manus, CRM funcionando durante toda a migração
|
||||||
|
- Construir o novo sistema em paralelo
|
||||||
|
- Migrar módulo a módulo até o sistema legado "desaparecer"
|
||||||
|
|
||||||
|
```
|
||||||
|
Sistema Atual (Express/React) continua funcionando
|
||||||
|
↓
|
||||||
|
Você vai adicionando "camadas Frappe" por cima
|
||||||
|
↓
|
||||||
|
Cada módulo migrado substitui o antigo
|
||||||
|
↓
|
||||||
|
No final, o "núcleo antigo" sumiu naturalmente
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Stack Técnico
|
||||||
|
|
||||||
|
| Camada | Atual | Futuro |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| Frontend | React 18 + TypeScript | Frappe Desk + React |
|
||||||
|
| Backend | Express.js | Frappe Framework |
|
||||||
|
| Database | PostgreSQL | PostgreSQL (mesmo) |
|
||||||
|
| Real-time | Socket.IO | Frappe Realtime + Socket.IO |
|
||||||
|
| WhatsApp | Baileys | Frappe App (Baileys) |
|
||||||
|
| IDE | Monaco + Terminal | IDE 3 Modos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ESTRUTURA MULTI-TENANT
|
||||||
|
|
||||||
|
### 2.1 Hierarquia de 3 Níveis
|
||||||
|
|
||||||
|
```
|
||||||
|
NÍVEL 1: MASTER (Arcádia)
|
||||||
|
═════════════════════════
|
||||||
|
• Equipe de desenvolvimento
|
||||||
|
• Acesso total ao sistema
|
||||||
|
• IDE Pro-Code completa
|
||||||
|
• Central de Bibliotecas (publica apps)
|
||||||
|
• Suporte N3 (acessa tenants para debug)
|
||||||
|
• Gerencia parceiros e planos
|
||||||
|
│
|
||||||
|
├───────────────────────┬───────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
NÍVEL 2: PARCEIROS
|
||||||
|
══════════════════
|
||||||
|
• Consultorias, integradores, revendas
|
||||||
|
• IDE Low-Code
|
||||||
|
• Gerencia seus clientes
|
||||||
|
• Comissões sobre vendas
|
||||||
|
• Suporte N2 aos clientes
|
||||||
|
• Baixa apps da biblioteca
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
▼ ▼
|
||||||
|
NÍVEL 3: CLIENTES
|
||||||
|
═════════════════
|
||||||
|
• Empresas usuárias finais
|
||||||
|
• Cockpit personalizado
|
||||||
|
• CRM/ERP operacional
|
||||||
|
• WhatsApp (N sessões conforme plano)
|
||||||
|
• BI próprio
|
||||||
|
• Manus com tools básicas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Matriz de Permissões por Tipo de Tenant
|
||||||
|
|
||||||
|
| Módulo | Master | Parceiro | Cliente |
|
||||||
|
|--------|--------|----------|---------|
|
||||||
|
| **IDE Pro-Code** | ✅ | ❌ | ❌ |
|
||||||
|
| **IDE Low-Code** | ✅ | ✅ | ❌ |
|
||||||
|
| **IDE No-Code** | ✅ | ✅ | ✅ (se habilitado) |
|
||||||
|
| **Central de Bibliotecas** | ✅ Publicar | ✅ Baixar | ❌ |
|
||||||
|
| **Central de APIs** | ✅ Gerenciar | ⚠️ Seus conectores | ⚠️ Leitura |
|
||||||
|
| **WhatsApp** | ✅ Ilimitado | ✅ N sessões | ✅ N sessões |
|
||||||
|
| **CRM/ERP** | ✅ Global | ✅ Próprio | ✅ Próprio |
|
||||||
|
| **Manus (IA)** | ✅ Todas tools | ✅ Tools permitidas | ✅ Básicas |
|
||||||
|
| **BI/Relatórios** | ✅ Global | ✅ Próprio | ✅ Próprio |
|
||||||
|
| **Suporte N3** | ✅ Acessa tenants | ❌ | ❌ |
|
||||||
|
| **Ver Parceiros** | ✅ | ✅ Seus clientes | ❌ |
|
||||||
|
| **Comissões** | ✅ Gerencia | ✅ Visualiza suas | ❌ |
|
||||||
|
|
||||||
|
### 2.3 Alterações no Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Alterações na tabela tenants
|
||||||
|
ALTER TABLE tenants ADD COLUMN tenant_type TEXT DEFAULT 'client';
|
||||||
|
-- master = Arcádia, partner = Parceiros, client = Clientes
|
||||||
|
|
||||||
|
ALTER TABLE tenants ADD COLUMN parent_tenant_id INTEGER REFERENCES tenants(id);
|
||||||
|
-- Referência ao tenant pai (hierarquia)
|
||||||
|
|
||||||
|
ALTER TABLE tenants ADD COLUMN partner_code TEXT;
|
||||||
|
-- Código do parceiro para rastreamento
|
||||||
|
|
||||||
|
ALTER TABLE tenants ADD COLUMN max_users INTEGER DEFAULT 5;
|
||||||
|
ALTER TABLE tenants ADD COLUMN max_storage_mb INTEGER DEFAULT 1000;
|
||||||
|
ALTER TABLE tenants ADD COLUMN features JSONB;
|
||||||
|
ALTER TABLE tenants ADD COLUMN commission_rate NUMERIC(5,2);
|
||||||
|
ALTER TABLE tenants ADD COLUMN trial_ends_at TIMESTAMP;
|
||||||
|
|
||||||
|
-- Nova tabela: Planos
|
||||||
|
CREATE TABLE tenant_plans (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
tenant_type TEXT NOT NULL,
|
||||||
|
max_users INTEGER DEFAULT 5,
|
||||||
|
max_storage_mb INTEGER DEFAULT 1000,
|
||||||
|
features JSONB,
|
||||||
|
monthly_price INTEGER DEFAULT 0,
|
||||||
|
yearly_price INTEGER DEFAULT 0,
|
||||||
|
is_active TEXT DEFAULT 'true',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Nova tabela: Relacionamento Parceiro-Cliente
|
||||||
|
CREATE TABLE partner_clients (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
partner_id INTEGER NOT NULL REFERENCES tenants(id),
|
||||||
|
client_id INTEGER NOT NULL REFERENCES tenants(id),
|
||||||
|
commission_rate NUMERIC(5,2),
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ended_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Nova tabela: Comissões
|
||||||
|
CREATE TABLE partner_commissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
partner_id INTEGER NOT NULL REFERENCES tenants(id),
|
||||||
|
client_id INTEGER NOT NULL REFERENCES tenants(id),
|
||||||
|
reference_month TEXT NOT NULL,
|
||||||
|
client_plan_value INTEGER NOT NULL,
|
||||||
|
commission_rate NUMERIC(5,2) NOT NULL,
|
||||||
|
commission_value INTEGER NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
paid_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CRONOGRAMA GERAL (6-9 Meses)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mês 1 Mês 2 Mês 3 Mês 4 Mês 5 Mês 6+
|
||||||
|
├──────────┼──────────┼──────────┼──────────┼──────────┼────────►
|
||||||
|
|
||||||
|
FASE 0 ████████████████████
|
||||||
|
FUNDAÇÃO
|
||||||
|
Setup + Tenants + SSO
|
||||||
|
|
||||||
|
FASE 1 ████████████████████████████
|
||||||
|
INFRAESTRUTURA
|
||||||
|
CRM/ERP + Central APIs + Manus
|
||||||
|
|
||||||
|
FASE 2 ████████████████████████████████
|
||||||
|
EXPERIÊNCIA
|
||||||
|
Cockpit + Comunidades + IDE
|
||||||
|
|
||||||
|
FASE 3 ████████████████████►
|
||||||
|
AUTOMAÇÃO
|
||||||
|
WhatsApp + RPA + Decommission
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FASE 0: FUNDAÇÃO (Semanas 1-8)
|
||||||
|
|
||||||
|
### 4.1 Objetivo
|
||||||
|
Preparar a base técnica sem quebrar nada do sistema atual.
|
||||||
|
|
||||||
|
### 4.2 Entregas
|
||||||
|
|
||||||
|
| # | Entrega | Descrição | Semana |
|
||||||
|
|---|---------|-----------|--------|
|
||||||
|
| 0.1 | **Setup Frappe Bench** | Instalar Frappe em servidor paralelo | 1-2 |
|
||||||
|
| 0.2 | **Hierarquia de Tenants** | Novos campos e tabelas no PostgreSQL | 2-3 |
|
||||||
|
| 0.3 | **SSO Bridge** | Login unificado (usuário loga uma vez) | 3-4 |
|
||||||
|
| 0.4 | **CDC Pipeline** | Sincronização de dados PostgreSQL ↔ Frappe | 4-6 |
|
||||||
|
| 0.5 | **Vault de Secrets** | Gerenciamento seguro de API keys | 5-6 |
|
||||||
|
| 0.6 | **Feature Flags** | Sistema de features por plano/tenant | 6-7 |
|
||||||
|
| 0.7 | **Planos e Preços** | Tabela de planos (free, starter, pro, enterprise) | 7-8 |
|
||||||
|
|
||||||
|
### 4.3 Resultado
|
||||||
|
- ✅ Frappe rodando em paralelo
|
||||||
|
- ✅ Hierarquia master/partner/client funcionando
|
||||||
|
- ✅ Login único nos dois sistemas
|
||||||
|
- ✅ Dados sincronizados em tempo real
|
||||||
|
- ✅ Planos e features configuráveis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. FASE 1: INFRAESTRUTURA (Semanas 6-16)
|
||||||
|
|
||||||
|
### 5.1 Objetivo
|
||||||
|
Migrar dados mestres e criar a Central de APIs.
|
||||||
|
|
||||||
|
### 5.2 Entregas
|
||||||
|
|
||||||
|
| # | Entrega | Descrição | Semana |
|
||||||
|
|---|---------|-----------|--------|
|
||||||
|
| 1.1 | **DocTypes CRM** | Clientes, Leads, Oportunidades no Frappe | 6-8 |
|
||||||
|
| 1.2 | **DocTypes ERP** | Produtos, Pedidos, Faturas no Frappe | 8-10 |
|
||||||
|
| 1.3 | **Central de APIs (MVP)** | Dashboard visual de integrações | 9-12 |
|
||||||
|
| 1.4 | **Conectores Básicos** | Interface para SEFAZ, Bancos (dados demo) | 12-14 |
|
||||||
|
| 1.5 | **Manus Frappe** | Agente IA via background jobs | 13-15 |
|
||||||
|
| 1.6 | **Knowledge Graph** | Migração do grafo para DocTypes | 14-16 |
|
||||||
|
|
||||||
|
### 5.3 Central de APIs - Detalhamento
|
||||||
|
|
||||||
|
**IMPORTANTE:** A Central de APIs é uma interface visual de gerenciamento. Os dados de integrações (SEFAZ, Bancos, Mercado Livre) são ILUSTRATIVOS/DEMO. Não fazemos integração real com APIs externas nesta fase.
|
||||||
|
|
||||||
|
O que construímos:
|
||||||
|
- ✅ Interface visual (React)
|
||||||
|
- ✅ CRUD de conectores (cadastrar, editar, remover)
|
||||||
|
- ✅ Status visual (online, warning, error)
|
||||||
|
- ✅ Logs fictícios para demonstração
|
||||||
|
- ✅ Configurações por conector
|
||||||
|
- ✅ Permissões por tenant type
|
||||||
|
|
||||||
|
O que NÃO fazemos:
|
||||||
|
- ❌ Conectar à SEFAZ real
|
||||||
|
- ❌ Conectar a bancos reais
|
||||||
|
- ❌ Chamadas API externas
|
||||||
|
|
||||||
|
### 5.4 Resultado
|
||||||
|
- ✅ CRM/ERP acessível via Frappe Desk
|
||||||
|
- ✅ Central de APIs funcionando (dados demo)
|
||||||
|
- ✅ Manus consultando dados do Frappe
|
||||||
|
- ✅ Knowledge Graph migrado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. FASE 2: EXPERIÊNCIA (Semanas 12-24)
|
||||||
|
|
||||||
|
### 6.1 Objetivo
|
||||||
|
Construir a nova interface (Cockpit, Comunidades, IDE).
|
||||||
|
|
||||||
|
### 6.2 Entregas
|
||||||
|
|
||||||
|
| # | Entrega | Descrição | Semana |
|
||||||
|
|---|---------|-----------|--------|
|
||||||
|
| 2.1 | **Cockpit PARA** | Navegação Projetos/Áreas/Recursos/Arquivo | 12-15 |
|
||||||
|
| 2.2 | **Dashboard Tríade** | Importante/Urgente/Circunstancial | 14-16 |
|
||||||
|
| 2.3 | **Widgets Sistema** | Tarefas, Calendário, Gráficos | 15-17 |
|
||||||
|
| 2.4 | **Comunidades MVP** | Canais por projeto (Socket.IO via Frappe) | 16-19 |
|
||||||
|
| 2.5 | **IDE No-Code** | DocType Builder visual | 18-20 |
|
||||||
|
| 2.6 | **IDE Low-Code** | Templates de scripts | 20-22 |
|
||||||
|
| 2.7 | **IDE Pro-Code** | Monaco + Terminal + Live Preview | 21-23 |
|
||||||
|
| 2.8 | **Central de Bibliotecas** | Repositório de apps Frappe | 22-24 |
|
||||||
|
|
||||||
|
### 6.3 Cockpit PARA + Tríade
|
||||||
|
|
||||||
|
O Cockpit é a interface principal do usuário, baseado em duas metodologias:
|
||||||
|
|
||||||
|
**Método PARA (Tiago Forte):**
|
||||||
|
- **P**rojetos: Todos os projetos ativos com metas e prazos
|
||||||
|
- **Á**reas: Áreas de responsabilidade contínua (Vendas, Financeiro, RH)
|
||||||
|
- **R**ecursos: Base de conhecimento, templates, manuais
|
||||||
|
- **A**rquivo: Tudo concluído ou inativo, para consulta futura
|
||||||
|
|
||||||
|
**Tríade do Tempo (Christian Barbosa):**
|
||||||
|
- 🟢 **Importante** (70% do tempo): Atividades que geram valor
|
||||||
|
- 🟡 **Urgente** (20% do tempo): Atividades com prazo apertado
|
||||||
|
- 🔴 **Circunstancial** (10% do tempo): Atividades que não agregam
|
||||||
|
|
||||||
|
### 6.4 IDE 3 Modos
|
||||||
|
|
||||||
|
| Modo | Quem Usa | O que Faz |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| **No-Code** | Clientes | Criar formulários arrastando, workflows visuais, relatórios com filtros |
|
||||||
|
| **Low-Code** | Parceiros | Server Scripts com templates, validações, webhooks, fórmulas |
|
||||||
|
| **Pro-Code** | Arcádia | Monaco Editor completo, Terminal, Git, Deploy de apps |
|
||||||
|
|
||||||
|
### 6.5 Resultado
|
||||||
|
- ✅ Cockpit PARA + Tríade funcionando
|
||||||
|
- ✅ Comunidades com canais por projeto
|
||||||
|
- ✅ IDE com 3 modos operando
|
||||||
|
- ✅ Central de Bibliotecas publicando apps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FASE 3: AUTOMAÇÃO E DECOMMISSION (Semana 20+)
|
||||||
|
|
||||||
|
### 7.1 Objetivo
|
||||||
|
Migrar serviços restantes e desligar o legado.
|
||||||
|
|
||||||
|
### 7.2 Entregas
|
||||||
|
|
||||||
|
| # | Entrega | Descrição | Semana |
|
||||||
|
|---|---------|-----------|--------|
|
||||||
|
| 3.1 | **WhatsApp Frappe App** | Reconstruir Baileys como app nativo | 20-24 |
|
||||||
|
| 3.2 | **Motor de Workflows** | Automações visuais (RPA) | 22-26 |
|
||||||
|
| 3.3 | **Scientist Frappe** | Migrar para Frappe Workers | 24-28 |
|
||||||
|
| 3.4 | **Validação de Paridade** | Testes A/B, métricas | 26-30 |
|
||||||
|
| 3.5 | **Decommission Express** | Desligar endpoints legados | 30+ |
|
||||||
|
|
||||||
|
### 7.3 Resultado
|
||||||
|
- ✅ Sistema 100% unificado no Frappe
|
||||||
|
- ✅ Express/React desligado
|
||||||
|
- ✅ Uma única plataforma para manter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. MAPEAMENTO DE MÓDULOS
|
||||||
|
|
||||||
|
| Módulo Atual | O que Acontece | Fase |
|
||||||
|
|--------------|----------------|------|
|
||||||
|
| **users, tenants** | Expande com hierarquia | 0 |
|
||||||
|
| **profiles, roles, permissions** | Migra para Frappe RBAC | 0 |
|
||||||
|
| **whatsapp_contacts, messages, tickets** | Mantém → Migra na Fase 3 | 3 |
|
||||||
|
| **pc_crm_leads, stages, opportunities** | Migra para Frappe DocTypes | 1 |
|
||||||
|
| **pc_clients, projects, tasks** | Migra para Frappe DocTypes | 1 |
|
||||||
|
| **graph_nodes, graph_edges** | Migra para Frappe Knowledge Graph | 1 |
|
||||||
|
| **internal_chat_*** | Evolui para Comunidades | 2 |
|
||||||
|
| **manus_*** | Integra via background jobs | 1 |
|
||||||
|
| **bi_*** | Mantém + novos widgets Cockpit | 2 |
|
||||||
|
| **ide_*** | Evolui para 3 modos | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. RISCOS E MITIGAÇÕES
|
||||||
|
|
||||||
|
| Risco | Probabilidade | Impacto | Mitigação |
|
||||||
|
|-------|---------------|---------|-----------|
|
||||||
|
| **Drift de dados** | Média | Alto | CDC com validação contínua |
|
||||||
|
| **Performance chat** | Média | Médio | Load test antes de migrar |
|
||||||
|
| **Tokens WhatsApp** | Baixa | Alto | Vault de secrets |
|
||||||
|
| **Curva aprendizado Frappe** | Alta | Médio | Treinamento na Fase 0 |
|
||||||
|
| **Regressões funcionais** | Média | Alto | Testes A/B, telemetria |
|
||||||
|
| **Resistência usuários** | Média | Médio | Piloto gradual: Master → Partners → Clients |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. QUICK WINS (Entregas Rápidas)
|
||||||
|
|
||||||
|
| Item | Tempo | Valor |
|
||||||
|
|------|-------|-------|
|
||||||
|
| **Hierarquia de Tenants** | 2 semanas | Estrutura para parceiros |
|
||||||
|
| **SSO unificado** | 2 semanas | Login único |
|
||||||
|
| **Central de APIs (UI)** | 3 semanas | Visibilidade integrações |
|
||||||
|
| **Dashboard Tríade** | 2 semanas | Consciência sobre tempo |
|
||||||
|
| **Planos e Features** | 2 semanas | Monetização estruturada |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. OS 5 PILARES DO SISTEMA
|
||||||
|
|
||||||
|
### Pilar 1: Knowledge Graph
|
||||||
|
- Todos os dados do negócio conectados e pesquisáveis
|
||||||
|
- Navegação visual entre entidades relacionadas
|
||||||
|
- Base para IA contextual
|
||||||
|
|
||||||
|
### Pilar 2: Central Intelligence (Scientist)
|
||||||
|
- IA que aprende com interações do sistema
|
||||||
|
- Gera e executa código automaticamente
|
||||||
|
- Detecta padrões e sugere otimizações
|
||||||
|
|
||||||
|
### Pilar 3: Autonomous Agent (Manus)
|
||||||
|
- Executa tarefas multi-step de forma autônoma
|
||||||
|
- Acessa ferramentas e APIs
|
||||||
|
- Deep research com planejamento
|
||||||
|
|
||||||
|
### Pilar 4: Unified Communication
|
||||||
|
- WhatsApp integrado com CRM
|
||||||
|
- Chat interno com canais por projeto
|
||||||
|
- Email (futuro)
|
||||||
|
- Todos os canais em um lugar
|
||||||
|
|
||||||
|
### Pilar 5: Complete IDE
|
||||||
|
- 3 modos de desenvolvimento (No/Low/Pro Code)
|
||||||
|
- Central de Bibliotecas
|
||||||
|
- Deploy integrado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. DOCUMENTOS DE REFERÊNCIA
|
||||||
|
|
||||||
|
Os documentos originais que basearam este plano estão em:
|
||||||
|
- `attached_assets/cocpti_docs/cocpti/` - Cockpit e DNA Notion
|
||||||
|
- `attached_assets/cocpti_docs/Ide Arcadia/` - Proposta IDE
|
||||||
|
- `attached_assets/cocpti_docs/Rota de desenvolviento/` - Roadmap original
|
||||||
|
- `attached_assets/cocpti_docs/Central de API/` - Central de APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. PRÓXIMOS PASSOS
|
||||||
|
|
||||||
|
1. [ ] Implementar hierarquia de tenants no schema
|
||||||
|
2. [ ] Criar tabelas de planos e comissões
|
||||||
|
3. [ ] Documentar arquitetura CDC
|
||||||
|
4. [ ] Provisionar servidor Frappe
|
||||||
|
5. [ ] Implementar Central de APIs (UI com dados demo)
|
||||||
|
6. [ ] Construir Cockpit PARA + Tríade
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento criado em Janeiro 2026*
|
||||||
|
*Última atualização: Janeiro 2026*
|
||||||
|
|
@ -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)*
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
|
@ -9,6 +9,7 @@ import { SoeMotorProvider } from "@/contexts/SoeMotorContext";
|
||||||
import { ProtectedRoute } from "@/lib/protected-route";
|
import { ProtectedRoute } from "@/lib/protected-route";
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
|
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
|
||||||
|
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
import AuthPage from "@/pages/auth-page";
|
import AuthPage from "@/pages/auth-page";
|
||||||
|
|
||||||
|
|
@ -70,8 +71,6 @@ const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
|
||||||
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
||||||
const XosReports = lazy(() => import("@/pages/XosReports"));
|
const XosReports = lazy(() => import("@/pages/XosReports"));
|
||||||
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||||
const Skills = lazy(() => import("@/pages/Skills"));
|
|
||||||
const AutomationCenter = lazy(() => import("@/pages/AutomationCenter"));
|
|
||||||
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
|
|
@ -148,8 +147,6 @@ function Router() {
|
||||||
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
||||||
<ProtectedRoute path="/migration" component={Migration} />
|
<ProtectedRoute path="/migration" component={Migration} />
|
||||||
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
||||||
<ProtectedRoute path="/skills" component={Skills} />
|
|
||||||
<ProtectedRoute path="/automations-center" component={AutomationCenter} />
|
|
||||||
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
||||||
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
||||||
<Route path="/auth" component={AuthPage} />
|
<Route path="/auth" component={AuthPage} />
|
||||||
|
|
@ -168,6 +165,7 @@ function App() {
|
||||||
<KnowledgeCollectorInit />
|
<KnowledgeCollectorInit />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<OpenClawWidget />
|
||||||
<Router />
|
<Router />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SoeMotorProvider>
|
</SoeMotorProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Bot, Settings, MessageCircle, Zap, LayoutDashboard, Compass, Users, Ticket, LogOut, User, Shield, Receipt, Package, Rocket, Beaker, TrendingUp, MapPin, Droplets, Star, Database, Layout, Code, Code2, Store, Layers, Building2 } from "lucide-react";
|
import { Bot, Settings, MessageCircle, Zap, LayoutDashboard, Compass, Users, Ticket, LogOut, User, Shield, Receipt, Package, Rocket, Beaker, TrendingUp, MapPin, Droplets, Star, Database, Layout, Code, Code2, Store, Layers } from "lucide-react";
|
||||||
const browserIcon = "/arcadia_suite_icon.png";
|
import browserIcon from "@assets/arcadia_branding/arcadia_suite_icon.png";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useNavigationTracking } from "@/hooks/use-navigation-tracking";
|
import { useNavigationTracking } from "@/hooks/use-navigation-tracking";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -31,7 +31,7 @@ function CompactNavigationBar() {
|
||||||
return (
|
return (
|
||||||
<div className="h-10 bg-background border-b border-border flex items-center px-3 gap-2 text-xs text-muted-foreground shadow-xs z-10">
|
<div className="h-10 bg-background border-b border-border flex items-center px-3 gap-2 text-xs text-muted-foreground shadow-xs z-10">
|
||||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-hide flex-1">
|
<div className="flex items-center gap-1 overflow-x-auto scrollbar-hide flex-1">
|
||||||
{(user?.role === "admin" || user?.role === "master") && (
|
{user?.role === "admin" && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
||||||
onClick={() => navigateTo("/admin", "Administração")}
|
onClick={() => navigateTo("/admin", "Administração")}
|
||||||
|
|
@ -123,13 +123,13 @@ function CompactNavigationBar() {
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
||||||
onClick={() => navigateTo("/soe", "SOE")}
|
onClick={() => navigateTo("/erp", "ERP")}
|
||||||
data-testid="bookmark-soe"
|
data-testid="bookmark-erp"
|
||||||
>
|
>
|
||||||
<div className="w-4 h-4 bg-gradient-to-br from-blue-600 to-blue-800 rounded-sm flex items-center justify-center">
|
<div className="w-4 h-4 bg-gradient-to-br from-blue-600 to-blue-800 rounded-sm flex items-center justify-center">
|
||||||
<Building2 className="w-2.5 h-2.5 text-white" />
|
<Package className="w-2.5 h-2.5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:inline">SOE</span>
|
<span className="hidden md:inline">ERP</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
||||||
|
|
@ -141,7 +141,16 @@ function CompactNavigationBar() {
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:inline">Retail</span>
|
<span className="hidden md:inline">Retail</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Plus hidden temporarily */}
|
<div
|
||||||
|
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
||||||
|
onClick={() => navigateTo("/plus", "Plus")}
|
||||||
|
data-testid="bookmark-plus"
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 bg-gradient-to-br from-purple-500 to-purple-700 rounded-sm flex items-center justify-center">
|
||||||
|
<Layers className="w-2.5 h-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:inline">Plus</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
className="flex items-center gap-1 hover:bg-muted px-2 py-1.5 rounded cursor-pointer transition-colors flex-shrink-0"
|
||||||
onClick={() => navigateTo("/fisco", "Fisco")}
|
onClick={() => navigateTo("/fisco", "Fisco")}
|
||||||
|
|
@ -179,10 +188,10 @@ function CompactNavigationBar() {
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">{user?.name || user?.username}</p>
|
<p className="text-sm font-medium leading-none">{user?.name || user?.username}</p>
|
||||||
<p className="text-xs leading-none text-muted-foreground">@{user?.username}</p>
|
<p className="text-xs leading-none text-muted-foreground">@{user?.username}</p>
|
||||||
{(user?.role === "admin" || user?.role === "master") && (
|
{user?.role === "admin" && (
|
||||||
<p className="text-xs leading-none text-primary flex items-center gap-1 mt-1">
|
<p className="text-xs leading-none text-primary flex items-center gap-1 mt-1">
|
||||||
<Shield className="w-3 h-3" />
|
<Shield className="w-3 h-3" />
|
||||||
{user?.role === "master" ? "Administrador Master" : "Administrador"}
|
Administrador
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,7 +201,7 @@ function CompactNavigationBar() {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>Meu Perfil</span>
|
<span>Meu Perfil</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{(user?.role === "admin" || user?.role === "master") && (
|
{user?.role === "admin" && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Plataforma</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">Plataforma</DropdownMenuLabel>
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,10 @@ export function Omnibox({ url, onNavigate, isLoading }: OmniboxProps) {
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">{user?.name || user?.username}</p>
|
<p className="text-sm font-medium leading-none">{user?.name || user?.username}</p>
|
||||||
<p className="text-xs leading-none text-muted-foreground">@{user?.username}</p>
|
<p className="text-xs leading-none text-muted-foreground">@{user?.username}</p>
|
||||||
{(user?.role === "admin" || user?.role === "master") && (
|
{user?.role === "admin" && (
|
||||||
<p className="text-xs leading-none text-primary flex items-center gap-1 mt-1">
|
<p className="text-xs leading-none text-primary flex items-center gap-1 mt-1">
|
||||||
<Shield className="w-3 h-3" />
|
<Shield className="w-3 h-3" />
|
||||||
{user?.role === "master" ? "Administrador Master" : "Administrador"}
|
Administrador
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,10 @@ export function CommandPalette() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "nav-crm",
|
id: "nav-crm",
|
||||||
name: "Abrir Manager Partners",
|
name: "Abrir Arcádia CRM",
|
||||||
icon: <Handshake className="h-4 w-4" />,
|
icon: <Handshake className="h-4 w-4" />,
|
||||||
action: () => navigateTo("/crm"),
|
action: () => navigateTo("/crm"),
|
||||||
keywords: ["crm", "parceiros", "contratos", "manager", "partners"],
|
keywords: ["crm", "parceiros", "contratos", "vendas", "whatsapp"],
|
||||||
group: "Navegação",
|
group: "Navegação",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ export function SupersetDashboard({
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading");
|
const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading");
|
||||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
|
||||||
// Busca token e embeddedId (UUID) do backend
|
const fetchGuestToken = useCallback(async (): Promise<string> => {
|
||||||
const fetchTokenData = useCallback(async (): Promise<{ token: string; embeddedId: string }> => {
|
|
||||||
const resp = await fetch("/api/superset/guest-token", {
|
const resp = await fetch("/api/superset/guest-token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -39,7 +38,8 @@ export function SupersetDashboard({
|
||||||
const err = await resp.json().catch(() => ({ error: "Erro desconhecido" }));
|
const err = await resp.json().catch(() => ({ error: "Erro desconhecido" }));
|
||||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
return resp.json();
|
const { token } = await resp.json();
|
||||||
|
return token;
|
||||||
}, [dashboardId]);
|
}, [dashboardId]);
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
|
|
@ -56,9 +56,6 @@ export function SupersetDashboard({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve embeddedId (UUID) e obtém token inicial
|
|
||||||
const { embeddedId } = await fetchTokenData();
|
|
||||||
|
|
||||||
// Importa SDK dinamicamente (só quando necessário)
|
// Importa SDK dinamicamente (só quando necessário)
|
||||||
const { embedDashboard } = await import("@superset-ui/embedded-sdk");
|
const { embedDashboard } = await import("@superset-ui/embedded-sdk");
|
||||||
|
|
||||||
|
|
@ -68,13 +65,10 @@ export function SupersetDashboard({
|
||||||
}
|
}
|
||||||
|
|
||||||
sdkRef.current = await embedDashboard({
|
sdkRef.current = await embedDashboard({
|
||||||
id: embeddedId, // UUID do embedded config (não o slug)
|
id: dashboardId,
|
||||||
supersetDomain: "https://bi.onboardbi.com.br",
|
supersetDomain: window.location.origin + "/superset",
|
||||||
mountPoint: containerRef.current,
|
mountPoint: containerRef.current,
|
||||||
fetchGuestToken: async () => {
|
fetchGuestToken,
|
||||||
const { token } = await fetchTokenData();
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
dashboardUiConfig: {
|
dashboardUiConfig: {
|
||||||
hideTitle: true,
|
hideTitle: true,
|
||||||
hideChartControls: false,
|
hideChartControls: false,
|
||||||
|
|
@ -82,21 +76,18 @@ export function SupersetDashboard({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// O SDK cria um <iframe> sem width/height explícitos — forçar 100%
|
|
||||||
const iframe = containerRef.current?.querySelector("iframe");
|
|
||||||
if (iframe) {
|
|
||||||
iframe.style.width = "100%";
|
|
||||||
iframe.style.height = "100%";
|
|
||||||
iframe.style.border = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[SupersetDashboard] Erro:", err.message, err);
|
console.error("[SupersetDashboard] Erro:", err.message);
|
||||||
setErrorMsg(err.message || "Erro desconhecido");
|
// Se SDK não estiver instalado, usa iframe como fallback
|
||||||
setStatus("error");
|
if (err.message?.includes("Cannot find module") || err.message?.includes("embedDashboard")) {
|
||||||
|
setStatus("unavailable");
|
||||||
|
} else {
|
||||||
|
setErrorMsg(err.message);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [dashboardId, fetchTokenData]);
|
}, [dashboardId, fetchGuestToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
|
@ -109,16 +100,13 @@ export function SupersetDashboard({
|
||||||
|
|
||||||
if (status === "unavailable") {
|
if (status === "unavailable") {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col items-center justify-center gap-4 rounded-xl border border-yellow-100 bg-yellow-50 ${className}`} style={{ height: heightStyle }}>
|
<div className={`rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm ${className}`} style={{ height: heightStyle }}>
|
||||||
<AlertTriangle className="w-8 h-8 text-yellow-500" />
|
<iframe
|
||||||
<div className="text-center">
|
src="/superset/login"
|
||||||
<p className="font-medium text-yellow-800 text-sm">Arcádia Insights indisponível</p>
|
className="w-full h-full border-0"
|
||||||
<p className="text-yellow-600 text-xs mt-1">O serviço de BI está iniciando. Tente novamente em alguns segundos.</p>
|
title="Arcádia Insights — Apache Superset"
|
||||||
</div>
|
allow="fullscreen"
|
||||||
<button onClick={loadDashboard} className="flex items-center gap-2 px-4 py-2 bg-yellow-600 text-white rounded-lg text-sm hover:bg-yellow-700 transition-colors">
|
/>
|
||||||
<RefreshCw className="w-4 h-4" /> Tentar novamente
|
|
||||||
</button>
|
|
||||||
<a href="https://bi.onboardbi.com.br" target="_blank" rel="noopener noreferrer" className="text-xs text-yellow-700 underline">Abrir em nova aba</a>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +142,7 @@ export function SupersetDashboard({
|
||||||
|
|
||||||
{showOpenLink && status === "ready" && (
|
{showOpenLink && status === "ready" && (
|
||||||
<a
|
<a
|
||||||
href="https://bi.onboardbi.com.br"
|
href="/superset"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
|
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
|
||||||
|
|
@ -163,16 +151,9 @@ export function SupersetDashboard({
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.superset-embed-container iframe {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="superset-embed-container w-full h-full rounded-xl overflow-hidden border border-[#c89b3c]/20"
|
className="w-full h-full rounded-xl overflow-hidden border border-[#c89b3c]/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export default function TradeInForm({ onClose, onSave, initialEvaluation, custom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPersons = async () => {
|
const loadPersons = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/soe/persons", { credentials: "include" });
|
const res = await fetch("/api/erp/persons", { credentials: "include" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPersonsList(data);
|
setPersonsList(data);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* OpenClawWidget.tsx
|
||||||
|
*
|
||||||
|
* Widget flutuante do OpenClaw — aparece quando há sugestões pendentes.
|
||||||
|
* Posicionado no canto inferior direito, fora das rotas.
|
||||||
|
* Abre o modal SkillSuggestion para confirmação.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Sparkles, X, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useAgentEmergence } from "@/hooks/useAgentEmergence";
|
||||||
|
import { SkillSuggestion } from "./SkillSuggestion";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export function OpenClawWidget() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
suggestions,
|
||||||
|
activeSuggestion,
|
||||||
|
pendingCount,
|
||||||
|
confirmSkill,
|
||||||
|
rejectSkill,
|
||||||
|
openSuggestion,
|
||||||
|
closeSuggestion,
|
||||||
|
} = useAgentEmergence();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Não renderiza se não há usuário, não há sugestões, ou foi dispensado
|
||||||
|
if (!user || pendingCount === 0 || dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Widget flutuante */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2"
|
||||||
|
role="complementary"
|
||||||
|
aria-label="OpenClaw - Sugestões de Skills"
|
||||||
|
>
|
||||||
|
{/* Painel expandido */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="w-72 rounded-xl border bg-background shadow-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-violet-50 dark:bg-violet-900/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
|
<span className="text-sm font-semibold text-violet-700 dark:text-violet-300">
|
||||||
|
OpenClaw
|
||||||
|
</span>
|
||||||
|
<Badge className="bg-violet-600 text-white text-xs px-1.5 py-0">
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
|
||||||
|
aria-label="Minimizar"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
|
||||||
|
aria-label="Fechar"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Detectei padrões de uso que podem virar skills automáticas:
|
||||||
|
</p>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => openSuggestion(s)}
|
||||||
|
className="w-full text-left rounded-lg border p-2.5 hover:bg-muted/50 transition-colors space-y-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium leading-tight line-clamp-1">
|
||||||
|
{s.suggested_skill_name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{Math.round(s.confidence * 100)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{s.suggested_description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão minimizado */}
|
||||||
|
{collapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-violet-600 hover:bg-violet-700 text-white px-4 py-2.5 shadow-lg transition-colors"
|
||||||
|
aria-label="Expandir sugestões de skills"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Skills sugeridas</span>
|
||||||
|
<Badge className="bg-white text-violet-700 text-xs px-1.5 py-0 ml-0.5">
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 ml-0.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de detalhes */}
|
||||||
|
{activeSuggestion && (
|
||||||
|
<SkillSuggestion
|
||||||
|
suggestion={activeSuggestion}
|
||||||
|
onConfirm={confirmSkill}
|
||||||
|
onReject={rejectSkill}
|
||||||
|
onClose={closeSuggestion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* SkillSuggestion.tsx
|
||||||
|
*
|
||||||
|
* Modal com preview da skill sugerida pelo OpenClaw.
|
||||||
|
* Apresenta padrão detectado, nome, descrição e preview de automação.
|
||||||
|
* Usuário pode confirmar ou rejeitar.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Sparkles, X, Check, Zap, BarChart2 } from "lucide-react";
|
||||||
|
import type { SkillSuggestionData } from "@/hooks/useAgentEmergence";
|
||||||
|
|
||||||
|
interface SkillSuggestionProps {
|
||||||
|
suggestion: SkillSuggestionData;
|
||||||
|
onConfirm: (id: string) => void;
|
||||||
|
onReject: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillSuggestion({ suggestion, onConfirm, onReject, onClose }: SkillSuggestionProps) {
|
||||||
|
const confidencePct = Math.round(suggestion.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-md bg-violet-100 dark:bg-violet-900/30">
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-base">Nova skill detectada</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Padrão detectado */}
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Padrão detectado</p>
|
||||||
|
<p className="font-medium">{suggestion.pattern.description || suggestion.pattern.action_type}</p>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground text-xs mt-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BarChart2 className="h-3 w-3" />
|
||||||
|
{suggestion.pattern.frequency}x detectado
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
Confiança: {confidencePct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill sugerida */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Nome da skill</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{suggestion.suggested_skill_name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Descrição</p>
|
||||||
|
<p className="text-sm">{suggestion.suggested_description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">O que será automatizado</p>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-2.5 text-sm text-muted-foreground">
|
||||||
|
{suggestion.estimated_automation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(suggestion.id)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Rejeitar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfirm(suggestion.id)}
|
||||||
|
className="gap-1.5 bg-violet-600 hover:bg-violet-700"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
Criar skill
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,25 @@
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
import { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
|
||||||
export type SoeMotor = "plus" | "erpnext";
|
interface SoeMotorContextValue {
|
||||||
|
activeCompany: string | null;
|
||||||
interface SoeMotorContextType {
|
setActiveCompany: (id: string | null) => void;
|
||||||
motor: SoeMotor;
|
|
||||||
setMotor: (motor: SoeMotor) => void;
|
|
||||||
usePlus: boolean;
|
|
||||||
useERPNext: boolean;
|
|
||||||
getApiUrl: (localPath: string, plusPath: string, erpnextPath?: string) => string;
|
|
||||||
profile: SoeMotor;
|
|
||||||
setProfile: (motor: SoeMotor) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SoeMotorCtx = createContext<SoeMotorContextType | null>(null);
|
const SoeMotorContext = createContext<SoeMotorContextValue>({
|
||||||
|
activeCompany: null,
|
||||||
|
setActiveCompany: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
export function SoeMotorProvider({ children }: { children: ReactNode }) {
|
export function SoeMotorProvider({ children }: { children: ReactNode }) {
|
||||||
const [motor, setMotorState] = useState<SoeMotor>(() => {
|
const [activeCompany, setActiveCompany] = useState<string | null>(null);
|
||||||
const saved = localStorage.getItem("arcadia_soe_motor") || localStorage.getItem("arcadia_erp_profile");
|
|
||||||
return (saved as SoeMotor) || "plus";
|
|
||||||
});
|
|
||||||
|
|
||||||
const setMotor = (newMotor: SoeMotor) => {
|
|
||||||
setMotorState(newMotor);
|
|
||||||
localStorage.setItem("arcadia_soe_motor", newMotor);
|
|
||||||
localStorage.setItem("arcadia_erp_profile", newMotor);
|
|
||||||
};
|
|
||||||
|
|
||||||
const usePlus = motor === "plus";
|
|
||||||
const useERPNext = motor === "erpnext";
|
|
||||||
|
|
||||||
const getApiUrl = (localPath: string, plusPath: string, erpnextPath?: string): string => {
|
|
||||||
if (usePlus) {
|
|
||||||
return `/plus/api${plusPath}`;
|
|
||||||
}
|
|
||||||
if (useERPNext && erpnextPath) {
|
|
||||||
return erpnextPath;
|
|
||||||
}
|
|
||||||
return localPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SoeMotorCtx.Provider value={{ motor, setMotor, usePlus, useERPNext, getApiUrl, profile: motor, setProfile: setMotor }}>
|
<SoeMotorContext.Provider value={{ activeCompany, setActiveCompany }}>
|
||||||
{children}
|
{children}
|
||||||
</SoeMotorCtx.Provider>
|
</SoeMotorContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSoeMotor() {
|
export function useSoeMotor() {
|
||||||
const context = useContext(SoeMotorCtx);
|
return useContext(SoeMotorContext);
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSoeMotor must be used within SoeMotorProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useErpProfile() {
|
|
||||||
const ctx = useSoeMotor();
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ErpProfile = SoeMotor;
|
|
||||||
export const ErpProfileProvider = SoeMotorProvider;
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* useAgentEmergence.ts
|
||||||
|
*
|
||||||
|
* Hook para gerenciar sugestões de skills do OpenClaw.
|
||||||
|
* Escuta eventos Socket.IO e coordena confirmações/rejeições.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAuth } from "./use-auth";
|
||||||
|
|
||||||
|
export interface SkillSuggestionData {
|
||||||
|
id: string;
|
||||||
|
pattern: {
|
||||||
|
id: string;
|
||||||
|
action_type: string;
|
||||||
|
frequency: number;
|
||||||
|
confidence: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
suggested_skill_name: string;
|
||||||
|
suggested_description: string;
|
||||||
|
estimated_automation: string;
|
||||||
|
confidence: number;
|
||||||
|
created_at: string;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAgentEmergenceReturn {
|
||||||
|
suggestions: SkillSuggestionData[];
|
||||||
|
activeSuggestion: SkillSuggestionData | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
confirmSkill: (suggestionId: string) => Promise<void>;
|
||||||
|
rejectSkill: (suggestionId: string) => Promise<void>;
|
||||||
|
openSuggestion: (suggestion: SkillSuggestionData) => void;
|
||||||
|
closeSuggestion: () => void;
|
||||||
|
dismissWidget: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentEmergence(): UseAgentEmergenceReturn {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [suggestions, setSuggestions] = useState<SkillSuggestionData[]>([]);
|
||||||
|
const [activeSuggestion, setActiveSuggestion] = useState<SkillSuggestionData | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Carrega sugestões pendentes do servidor
|
||||||
|
const fetchPendingSuggestions = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/suggestions");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSuggestions(data.suggestions || []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso — não bloqueia o usuário
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Escuta eventos Socket.IO via window (padrão da suite)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
fetchPendingSuggestions();
|
||||||
|
|
||||||
|
const handleOpenClawEvent = (event: CustomEvent) => {
|
||||||
|
const suggestion: SkillSuggestionData = event.detail;
|
||||||
|
setSuggestions((prev) => {
|
||||||
|
const exists = prev.find((s) => s.id === suggestion.id);
|
||||||
|
if (exists) return prev;
|
||||||
|
return [suggestion, ...prev];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
|
||||||
|
};
|
||||||
|
}, [user, fetchPendingSuggestions]);
|
||||||
|
|
||||||
|
const confirmSkill = useCallback(async (suggestionId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/confirm-skill", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suggestion_id: suggestionId, action: "confirm" }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// erro tratado na UI via toast externo
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rejectSkill = useCallback(async (suggestionId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/confirm-skill", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suggestion_id: suggestionId, action: "reject" }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openSuggestion = useCallback((suggestion: SkillSuggestionData) => {
|
||||||
|
setActiveSuggestion(suggestion);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSuggestion = useCallback(() => {
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissWidget = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
activeSuggestion,
|
||||||
|
isOpen,
|
||||||
|
pendingCount: suggestions.length,
|
||||||
|
confirmSkill,
|
||||||
|
rejectSkill,
|
||||||
|
openSuggestion,
|
||||||
|
closeSuggestion,
|
||||||
|
dismissWidget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ export function ProtectedRoute({
|
||||||
component: Component,
|
component: Component,
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
component: () => React.JSX.Element;
|
component: React.ComponentType<any>;
|
||||||
}) {
|
}) {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -361,18 +361,6 @@ async function fetchManusRun(id: number): Promise<ManusRun> {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelManusRun(id: number): Promise<void> {
|
|
||||||
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteManusRun(id: number): Promise<void> {
|
|
||||||
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllManusRuns(): Promise<void> {
|
|
||||||
await fetch("/api/manus/runs", { method: "DELETE", credentials: "include" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startManusRun(data: { prompt: string; attachedFiles?: AttachedFile[]; conversationHistory?: Array<{role: string; content: string}> }): Promise<{ runId: number }> {
|
async function startManusRun(data: { prompt: string; attachedFiles?: AttachedFile[]; conversationHistory?: Array<{role: string; content: string}> }): Promise<{ runId: number }> {
|
||||||
const response = await fetch("/api/manus/run", {
|
const response = await fetch("/api/manus/run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -1254,13 +1242,7 @@ export default function Agent() {
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
if (data.tool_status) {
|
|
||||||
setCurrentToolName(data.tool_status);
|
|
||||||
setProcessingMode("using_tools");
|
|
||||||
}
|
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
setCurrentToolName(null);
|
|
||||||
setProcessingMode("idle");
|
|
||||||
fullContent += data.content;
|
fullContent += data.content;
|
||||||
setStreamingContent(fullContent);
|
setStreamingContent(fullContent);
|
||||||
}
|
}
|
||||||
|
|
@ -1499,21 +1481,8 @@ export default function Agent() {
|
||||||
Nova Tarefa
|
Nova Tarefa
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
<div className="px-3 pt-3 pb-1">
|
||||||
<p className="text-xs text-white/40 uppercase tracking-wider font-medium">Todas as Tarefas</p>
|
<p className="text-xs text-white/40 uppercase tracking-wider font-medium">Todas as Tarefas</p>
|
||||||
{manusRuns.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteAllManusRuns();
|
|
||||||
setSelectedRun(null);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
|
|
||||||
}}
|
|
||||||
className="text-[10px] text-white/30 hover:text-red-400 transition-colors"
|
|
||||||
title="Limpar histórico"
|
|
||||||
>
|
|
||||||
Limpar tudo
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 px-2">
|
<ScrollArea className="flex-1 px-2">
|
||||||
{loadingRuns ? (
|
{loadingRuns ? (
|
||||||
|
|
@ -1525,35 +1494,22 @@ export default function Agent() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 pb-2">
|
<div className="space-y-1 pb-2">
|
||||||
{manusRuns.map((run) => (
|
{manusRuns.map((run) => (
|
||||||
<div key={run.id} className="group relative">
|
<button
|
||||||
<button
|
key={run.id}
|
||||||
onClick={() => setSelectedRun(run.id)}
|
onClick={() => setSelectedRun(run.id)}
|
||||||
className={`w-full text-left p-2.5 rounded-lg transition-all pr-8 ${
|
className={`w-full text-left p-2.5 rounded-lg transition-all ${
|
||||||
selectedRun === run.id
|
selectedRun === run.id
|
||||||
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
||||||
: "hover:bg-[#162638] border border-transparent"
|
: "hover:bg-[#162638] border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
data-testid={`run-${run.id}`}
|
data-testid={`run-${run.id}`}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
|
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getStatusBadge(run.status)}
|
{getStatusBadge(run.status)}
|
||||||
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await deleteManusRun(run.id);
|
|
||||||
if (selectedRun === run.id) setSelectedRun(null);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition-all"
|
|
||||||
title="Excluir"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1973,21 +1929,6 @@ export default function Agent() {
|
||||||
className="flex-1 border-[#e1e8f0] focus:border-[#c89b3c] focus:ring-[#c89b3c]/20"
|
className="flex-1 border-[#e1e8f0] focus:border-[#c89b3c] focus:ring-[#c89b3c]/20"
|
||||||
data-testid="input-message"
|
data-testid="input-message"
|
||||||
/>
|
/>
|
||||||
{isStreaming && (
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
if (selectedRun) await cancelManusRun(selectedRun);
|
|
||||||
setIsStreaming(false);
|
|
||||||
setProcessingMode("idle");
|
|
||||||
}}
|
|
||||||
variant="destructive"
|
|
||||||
className="shrink-0"
|
|
||||||
data-testid="button-cancel-run"
|
|
||||||
title="Cancelar"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={!chatInput.trim() || isStreaming}
|
disabled={!chatInput.trim() || isStreaming}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
|
Lock,
|
||||||
Search,
|
Search,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
|
@ -53,6 +55,7 @@ interface AppItem {
|
||||||
status: "active" | "coming_soon" | "beta";
|
status: "active" | "coming_soon" | "beta";
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
|
subscriptionCode?: string; // marketplace module code — if set, requires active subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps: AppItem[] = [
|
const apps: AppItem[] = [
|
||||||
|
|
@ -80,11 +83,11 @@ const apps: AppItem[] = [
|
||||||
color: "from-orange-500 to-red-500",
|
color: "from-orange-500 to-red-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "soe",
|
id: "erp",
|
||||||
name: "Arcádia SOE",
|
name: "Arcádia ERP",
|
||||||
description: "Sistema Operacional Empresarial - Kernel de negócios",
|
description: "Gestão empresarial completa integrada ao ERPNext",
|
||||||
icon: <Building className="w-8 h-8" />,
|
icon: <Building className="w-8 h-8" />,
|
||||||
route: "/soe",
|
route: "/erp",
|
||||||
category: "negocios",
|
category: "negocios",
|
||||||
status: "active",
|
status: "active",
|
||||||
featured: true,
|
featured: true,
|
||||||
|
|
@ -500,6 +503,24 @@ export default function AppCenter() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeCategory, setActiveCategory] = useState("todos");
|
const [activeCategory, setActiveCategory] = useState("todos");
|
||||||
|
|
||||||
|
const { data: myAppsData } = useQuery<{ subscribedCodes: string[] }>({
|
||||||
|
queryKey: ["/api/marketplace/my-apps"],
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscribedCodes = new Set(myAppsData?.subscribedCodes || []);
|
||||||
|
|
||||||
|
const isLocked = (app: AppItem) =>
|
||||||
|
!!app.subscriptionCode && subscribedCodes.size > 0 && !subscribedCodes.has(app.subscriptionCode);
|
||||||
|
|
||||||
|
const handleAppClick = (app: AppItem) => {
|
||||||
|
if (isLocked(app)) {
|
||||||
|
setLocation("/marketplace");
|
||||||
|
} else {
|
||||||
|
setLocation(app.route);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredApps = apps.filter(app => {
|
const filteredApps = apps.filter(app => {
|
||||||
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
@ -546,7 +567,7 @@ export default function AppCenter() {
|
||||||
<Card
|
<Card
|
||||||
key={app.id}
|
key={app.id}
|
||||||
className="bg-white/5 border-white/10 hover:border-white/30 cursor-pointer transition-all hover:scale-105 group"
|
className="bg-white/5 border-white/10 hover:border-white/30 cursor-pointer transition-all hover:scale-105 group"
|
||||||
onClick={() => setLocation(app.route)}
|
onClick={() => handleAppClick(app)}
|
||||||
data-testid={`featured-app-${app.id}`}
|
data-testid={`featured-app-${app.id}`}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
|
|
@ -579,27 +600,37 @@ export default function AppCenter() {
|
||||||
|
|
||||||
<TabsContent value={activeCategory} className="mt-0">
|
<TabsContent value={activeCategory} className="mt-0">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filteredApps.map(app => (
|
{filteredApps.map(app => {
|
||||||
|
const locked = isLocked(app);
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={app.id}
|
key={app.id}
|
||||||
className="bg-white/5 border-white/10 hover:border-white/30 cursor-pointer transition-all group overflow-hidden"
|
className={`border-white/10 cursor-pointer transition-all group overflow-hidden ${locked ? "bg-white/2 opacity-60 hover:opacity-80" : "bg-white/5 hover:border-white/30"}`}
|
||||||
onClick={() => setLocation(app.route)}
|
onClick={() => handleAppClick(app)}
|
||||||
data-testid={`app-card-${app.id}`}
|
data-testid={`app-card-${app.id}`}
|
||||||
>
|
>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${app.color} flex items-center justify-center text-white flex-shrink-0 group-hover:scale-110 transition-transform`}>
|
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${app.color} flex items-center justify-center text-white flex-shrink-0 ${locked ? "" : "group-hover:scale-110"} transition-transform relative`}>
|
||||||
{app.icon}
|
{app.icon}
|
||||||
|
{locked && (
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-black/50 flex items-center justify-center">
|
||||||
|
<Lock className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-semibold text-white group-hover:text-indigo-400 transition-colors truncate">
|
<h3 className={`font-semibold truncate ${locked ? "text-slate-400" : "text-white group-hover:text-indigo-400"} transition-colors`}>
|
||||||
{app.name}
|
{app.name}
|
||||||
</h3>
|
</h3>
|
||||||
{app.status === "beta" && (
|
{locked && (
|
||||||
|
<Badge className="bg-slate-600/50 text-slate-400 text-xs">Não contratado</Badge>
|
||||||
|
)}
|
||||||
|
{!locked && app.status === "beta" && (
|
||||||
<Badge className="bg-yellow-500/20 text-yellow-300 text-xs">Beta</Badge>
|
<Badge className="bg-yellow-500/20 text-yellow-300 text-xs">Beta</Badge>
|
||||||
)}
|
)}
|
||||||
{app.status === "coming_soon" && (
|
{!locked && app.status === "coming_soon" && (
|
||||||
<Badge className="bg-slate-500/20 text-slate-300 text-xs">Em breve</Badge>
|
<Badge className="bg-slate-500/20 text-slate-300 text-xs">Em breve</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -607,13 +638,20 @@ export default function AppCenter() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<span className="text-xs text-indigo-400 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
{locked ? (
|
||||||
Abrir <ArrowRight className="w-3 h-3" />
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
</span>
|
Ver no Marketplace <ArrowRight className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-indigo-400 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
Abrir <ArrowRight className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredApps.length === 0 && (
|
{filteredApps.length === 0 && (
|
||||||
|
|
|
||||||
|
|
@ -150,18 +150,18 @@ const api = {
|
||||||
|
|
||||||
function DashboardModule({ tenantId }: { tenantId: number }) {
|
function DashboardModule({ tenantId }: { tenantId: number }) {
|
||||||
const { data: stats = { customers: 0, suppliers: 0, products: 0, salesOrders: 0, purchaseOrders: 0 } } = useQuery({
|
const { data: stats = { customers: 0, suppliers: 0, products: 0, salesOrders: 0, purchaseOrders: 0 } } = useQuery({
|
||||||
queryKey: ["/api/soe/stats", tenantId],
|
queryKey: ["/api/erp/stats", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/stats?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/stats?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: customers = [] } = useQuery<Customer[]>({
|
const { data: customers = [] } = useQuery<Customer[]>({
|
||||||
queryKey: ["/api/soe/customers", tenantId],
|
queryKey: ["/api/erp/customers", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/customers?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/customers?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: salesOrders = [] } = useQuery<SalesOrder[]>({
|
const { data: salesOrders = [] } = useQuery<SalesOrder[]>({
|
||||||
queryKey: ["/api/soe/sales-orders", tenantId],
|
queryKey: ["/api/erp/sales-orders", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/sales-orders?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/sales-orders?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalRevenue = salesOrders
|
const totalRevenue = salesOrders
|
||||||
|
|
@ -296,14 +296,14 @@ function PersonsModule({ tenantId }: { tenantId: number }) {
|
||||||
const [formStatus, setFormStatus] = useState("active");
|
const [formStatus, setFormStatus] = useState("active");
|
||||||
|
|
||||||
const { data: persons = [], isLoading, refetch } = useQuery<Person[]>({
|
const { data: persons = [], isLoading, refetch } = useQuery<Person[]>({
|
||||||
queryKey: ["/api/soe/persons", tenantId, roleFilter],
|
queryKey: ["/api/erp/persons", tenantId, roleFilter],
|
||||||
queryFn: () => api.get(`/api/soe/persons?tenantId=${tenantId}&role=${roleFilter}`)
|
queryFn: () => api.get(`/api/erp/persons?tenantId=${tenantId}&role=${roleFilter}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/api/soe/persons", { ...data, tenantId }),
|
mutationFn: (data: any) => api.post("/api/erp/persons", { ...data, tenantId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/persons"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/persons"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Pessoa criada com sucesso!" });
|
toast({ title: "Pessoa criada com sucesso!" });
|
||||||
|
|
@ -312,9 +312,9 @@ function PersonsModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/persons/${id}`, data),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/erp/persons/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/persons"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/persons"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Pessoa atualizada!" });
|
toast({ title: "Pessoa atualizada!" });
|
||||||
|
|
@ -322,9 +322,9 @@ function PersonsModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/soe/persons/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/erp/persons/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/persons"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/persons"] });
|
||||||
toast({ title: "Pessoa excluída!" });
|
toast({ title: "Pessoa excluída!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -601,15 +601,15 @@ function CustomersModule({ tenantId }: { tenantId: number }) {
|
||||||
const [formStatus, setFormStatus] = useState("active");
|
const [formStatus, setFormStatus] = useState("active");
|
||||||
|
|
||||||
const { data: customers = [], isLoading, refetch } = useQuery<Customer[]>({
|
const { data: customers = [], isLoading, refetch } = useQuery<Customer[]>({
|
||||||
queryKey: ["/api/soe/customers", tenantId],
|
queryKey: ["/api/erp/customers", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/customers?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/customers?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/api/soe/customers", { ...data, tenantId }),
|
mutationFn: (data: any) => api.post("/api/erp/customers", { ...data, tenantId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/customers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Cliente criado com sucesso!" });
|
toast({ title: "Cliente criado com sucesso!" });
|
||||||
|
|
@ -618,9 +618,9 @@ function CustomersModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/customers/${id}`, data),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/erp/customers/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/customers"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Cliente atualizado!" });
|
toast({ title: "Cliente atualizado!" });
|
||||||
|
|
@ -628,10 +628,10 @@ function CustomersModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/soe/customers/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/erp/customers/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/customers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
toast({ title: "Cliente excluído!" });
|
toast({ title: "Cliente excluído!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -837,15 +837,15 @@ function SuppliersModule({ tenantId }: { tenantId: number }) {
|
||||||
const [formStatus, setFormStatus] = useState("active");
|
const [formStatus, setFormStatus] = useState("active");
|
||||||
|
|
||||||
const { data: suppliers = [], isLoading, refetch } = useQuery<Supplier[]>({
|
const { data: suppliers = [], isLoading, refetch } = useQuery<Supplier[]>({
|
||||||
queryKey: ["/api/soe/suppliers", tenantId],
|
queryKey: ["/api/erp/suppliers", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/suppliers?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/suppliers?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/api/soe/suppliers", { ...data, tenantId }),
|
mutationFn: (data: any) => api.post("/api/erp/suppliers", { ...data, tenantId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/suppliers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/suppliers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Fornecedor criado com sucesso!" });
|
toast({ title: "Fornecedor criado com sucesso!" });
|
||||||
|
|
@ -853,9 +853,9 @@ function SuppliersModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/suppliers/${id}`, data),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/erp/suppliers/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/suppliers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/suppliers"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Fornecedor atualizado!" });
|
toast({ title: "Fornecedor atualizado!" });
|
||||||
|
|
@ -863,10 +863,10 @@ function SuppliersModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/soe/suppliers/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/erp/suppliers/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/suppliers"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/suppliers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
toast({ title: "Fornecedor excluído!" });
|
toast({ title: "Fornecedor excluído!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1052,8 +1052,8 @@ function ProductsModule({ tenantId }: { tenantId: number }) {
|
||||||
const [formTaxGroup, setFormTaxGroup] = useState("");
|
const [formTaxGroup, setFormTaxGroup] = useState("");
|
||||||
|
|
||||||
const { data: products = [], isLoading, refetch } = useQuery<Product[]>({
|
const { data: products = [], isLoading, refetch } = useQuery<Product[]>({
|
||||||
queryKey: ["/api/soe/products", tenantId],
|
queryKey: ["/api/erp/products", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/products?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/products?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: taxGroups = [] } = useQuery<TaxGroup[]>({
|
const { data: taxGroups = [] } = useQuery<TaxGroup[]>({
|
||||||
|
|
@ -1062,10 +1062,10 @@ function ProductsModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/api/soe/products", { ...data, tenantId }),
|
mutationFn: (data: any) => api.post("/api/erp/products", { ...data, tenantId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/products"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Produto criado com sucesso!" });
|
toast({ title: "Produto criado com sucesso!" });
|
||||||
|
|
@ -1073,9 +1073,9 @@ function ProductsModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/products/${id}`, data),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/erp/products/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/products"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
toast({ title: "Produto atualizado!" });
|
toast({ title: "Produto atualizado!" });
|
||||||
|
|
@ -1083,10 +1083,10 @@ function ProductsModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/soe/products/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/erp/products/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/products"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
toast({ title: "Produto excluído!" });
|
toast({ title: "Produto excluído!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1316,20 +1316,20 @@ function SalesModule({ tenantId }: { tenantId: number }) {
|
||||||
const [formCustomerId, setFormCustomerId] = useState("");
|
const [formCustomerId, setFormCustomerId] = useState("");
|
||||||
|
|
||||||
const { data: salesOrders = [], isLoading, refetch } = useQuery<SalesOrder[]>({
|
const { data: salesOrders = [], isLoading, refetch } = useQuery<SalesOrder[]>({
|
||||||
queryKey: ["/api/soe/sales-orders", tenantId],
|
queryKey: ["/api/erp/sales-orders", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/sales-orders?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/sales-orders?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: customers = [] } = useQuery<Customer[]>({
|
const { data: customers = [] } = useQuery<Customer[]>({
|
||||||
queryKey: ["/api/soe/customers", tenantId],
|
queryKey: ["/api/erp/customers", tenantId],
|
||||||
queryFn: () => api.get(`/api/soe/customers?tenantId=${tenantId}`)
|
queryFn: () => api.get(`/api/erp/customers?tenantId=${tenantId}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/api/soe/sales-orders", { ...data, tenantId }),
|
mutationFn: (data: any) => api.post("/api/erp/sales-orders", { ...data, tenantId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/sales-orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setFormCustomerId("");
|
setFormCustomerId("");
|
||||||
|
|
@ -1338,10 +1338,10 @@ function SalesModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/sales-orders/${id}`, data),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/erp/sales-orders/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/sales-orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setFormCustomerId("");
|
setFormCustomerId("");
|
||||||
|
|
@ -1350,18 +1350,18 @@ function SalesModule({ tenantId }: { tenantId: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/soe/sales-orders/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/erp/sales-orders/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/sales-orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/stats"] });
|
||||||
toast({ title: "Pedido excluído!" });
|
toast({ title: "Pedido excluído!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.post(`/api/soe/sales-orders/${id}/confirm`, {}),
|
mutationFn: (id: number) => api.post(`/api/erp/sales-orders/${id}/confirm`, {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/erp/sales-orders"] });
|
||||||
toast({ title: "Pedido confirmado!" });
|
toast({ title: "Pedido confirmado!" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1728,7 +1728,7 @@ export default function ArcadiaNext() {
|
||||||
<Building2 className="w-4 h-4 text-white" />
|
<Building2 className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-bold text-lg">Arcádia SOE</h1>
|
<h1 className="font-bold text-lg">Arcádia ERP</h1>
|
||||||
<p className="text-xs text-muted-foreground">Gestão Empresarial</p>
|
<p className="text-xs text-muted-foreground">Gestão Empresarial</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,7 @@ function AutomationCard({ automation, onRefresh }: { automation: Automation; onR
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => runMutation.mutate()}
|
onClick={() => runMutation.mutate()}
|
||||||
disabled={runMutation.isPending}
|
disabled={runMutation.isPending}
|
||||||
className="h-8 w-8 flex items-center justify-center text-white/50 hover:text-[#c89b3c] hover:bg-[#c89b3c]/10"
|
className="h-8 w-8 text-white/50 hover:text-[#c89b3c] hover:bg-[#c89b3c]/10"
|
||||||
title="Executar agora"
|
title="Executar agora"
|
||||||
data-testid={`run-automation-${automation.id}`}
|
data-testid={`run-automation-${automation.id}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -47,9 +47,9 @@ import {
|
||||||
X,
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Brain,
|
|
||||||
} 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,8 +81,73 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
|
||||||
import { MiroFlowControl } from "@/components/MiroFlowControl";
|
// ── 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;
|
||||||
|
|
@ -2895,9 +2960,6 @@ export default function BiWorkspace() {
|
||||||
<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" /> Insights
|
<Settings className="w-4 h-4 mr-2" /> Insights
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="cientifico" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
|
||||||
<Brain className="w-4 h-4 mr-2" /> Científico
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-0">
|
<TabsContent value="overview" className="mt-0">
|
||||||
|
|
@ -2922,27 +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]">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="https://bi.onboardbi.com.br"
|
|
||||||
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-superset-external"
|
|
||||||
>
|
|
||||||
<ArrowRight className="w-4 h-4" /> Abrir em Nova Aba
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<SupersetDashboard dashboardId="executive-summary" height="calc(100vh - 320px)" />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="cientifico" className="mt-0">
|
|
||||||
<MiroFlowControl />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ import {
|
||||||
Link,
|
Link,
|
||||||
File
|
File
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
const browserIcon = "/arcadia_suite_icon.png";
|
import browserIcon from "@assets/arcadia_branding/arcadia_suite_icon.png";
|
||||||
import { DigitalClock } from "@/components/DigitalClock";
|
import { DigitalClock } from "@/components/DigitalClock";
|
||||||
|
|
||||||
type ParaTab = "projetos" | "areas" | "recursos" | "arquivo";
|
type ParaTab = "projetos" | "areas" | "recursos" | "arquivo";
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,7 @@ export default function CommercialEnv() {
|
||||||
Comercial - Engenharia Ambiental
|
Comercial - Engenharia Ambiental
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Propostas, clientes e pipeline de vendas integrado ao Arcádia SOE
|
Propostas, clientes e pipeline de vendas integrado ao Arcádia ERP
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => refetchCustomers()} variant="outline" size="sm">
|
<Button onClick={() => refetchCustomers()} variant="outline" size="sm">
|
||||||
|
|
@ -660,7 +660,7 @@ export default function CommercialEnv() {
|
||||||
{salesOrders.length === 0 && (
|
{salesOrders.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
Nenhum pedido encontrado. Sincronize com o Arcádia SOE.
|
Nenhum pedido encontrado. Sincronize com o Arcádia ERP.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
|
@ -392,13 +391,12 @@ export default function Contabil() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-6">
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
<TabsTrigger value="dashboard" data-testid="tab-dashboard">Dashboard</TabsTrigger>
|
<TabsTrigger value="dashboard" data-testid="tab-dashboard">Dashboard</TabsTrigger>
|
||||||
<TabsTrigger value="plano" data-testid="tab-plano">Plano de Contas</TabsTrigger>
|
<TabsTrigger value="plano" data-testid="tab-plano">Plano de Contas</TabsTrigger>
|
||||||
<TabsTrigger value="lancamentos" data-testid="tab-lancamentos">Lançamentos</TabsTrigger>
|
<TabsTrigger value="lancamentos" data-testid="tab-lancamentos">Lançamentos</TabsTrigger>
|
||||||
<TabsTrigger value="centros" data-testid="tab-centros">Centros de Custo</TabsTrigger>
|
<TabsTrigger value="centros" data-testid="tab-centros">Centros de Custo</TabsTrigger>
|
||||||
<TabsTrigger value="sped" data-testid="tab-sped">SPED ECD</TabsTrigger>
|
<TabsTrigger value="sped" data-testid="tab-sped">SPED ECD</TabsTrigger>
|
||||||
<TabsTrigger value="analise" data-testid="tab-analise">Análise BI</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="dashboard" className="space-y-4">
|
<TabsContent value="dashboard" className="space-y-4">
|
||||||
|
|
@ -787,9 +785,6 @@ export default function Contabil() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="analise">
|
|
||||||
<SupersetDashboard dashboardId="dre-mensal" />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Dialog open={showContaDialog} onOpenChange={setShowContaDialog}>
|
<Dialog open={showContaDialog} onOpenChange={setShowContaDialog}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import MultiTenantSection from "@/components/MultiTenantSection";
|
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -43,8 +42,7 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Briefcase,
|
Briefcase
|
||||||
Building2
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
@ -884,8 +882,8 @@ export default function Crm() {
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold" data-testid="text-crm-title">Manager Partners</h1>
|
<h1 className="text-3xl font-bold" data-testid="text-crm-title">Arcádia CRM</h1>
|
||||||
<p className="text-muted-foreground">Gestão de parceiros, tenants e contratos</p>
|
<p className="text-muted-foreground">Gestão de parceiros, contratos e comunicação</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{googleStatus?.connected ? (
|
{googleStatus?.connected ? (
|
||||||
|
|
@ -949,10 +947,6 @@ export default function Crm() {
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Configurações
|
Configurações
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="multitenant" data-testid="tab-multitenant">
|
|
||||||
<Building2 className="w-4 h-4 mr-2" />
|
|
||||||
Multi-Tenant
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
|
@ -2328,7 +2322,7 @@ export default function Crm() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="erpnext">Frappe ERPNext</SelectItem>
|
<SelectItem value="erpnext">Frappe Arcádia ERP</SelectItem>
|
||||||
<SelectItem value="crm_next">Frappe Arcádia CRM</SelectItem>
|
<SelectItem value="crm_next">Frappe Arcádia CRM</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -2387,7 +2381,7 @@ export default function Crm() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Sistema:</span>
|
<span className="text-muted-foreground">Sistema:</span>
|
||||||
<Badge variant="outline" className="capitalize">{connector.targetSystem === "erpnext" ? "ERPNext" : "Arcádia CRM"}</Badge>
|
<Badge variant="outline" className="capitalize">{connector.targetSystem === "erpnext" ? "Arcádia ERP" : "Arcádia CRM"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Status:</span>
|
<span className="text-muted-foreground">Status:</span>
|
||||||
|
|
@ -2688,10 +2682,6 @@ export default function Crm() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="multitenant" className="space-y-4">
|
|
||||||
<MultiTenantSection />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,26 +29,8 @@ import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Workflow,
|
Workflow,
|
||||||
Users,
|
Users,
|
||||||
Brain,
|
|
||||||
Sparkles,
|
|
||||||
Search,
|
|
||||||
Code,
|
|
||||||
Terminal,
|
|
||||||
Globe,
|
|
||||||
Shield,
|
|
||||||
ArrowRight,
|
|
||||||
Layers,
|
|
||||||
Network,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface EngineStatus {
|
interface EngineStatus {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -85,7 +67,6 @@ interface EngineRoomData {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINE_ICONS: Record<string, any> = {
|
const ENGINE_ICONS: Record<string, any> = {
|
||||||
"manus-ia": Brain,
|
|
||||||
"plus": ShoppingCart,
|
"plus": ShoppingCart,
|
||||||
"contabil": Calculator,
|
"contabil": Calculator,
|
||||||
"fisco": FileText,
|
"fisco": FileText,
|
||||||
|
|
@ -107,18 +88,13 @@ const STATUS_CONFIG = {
|
||||||
error: { icon: AlertCircle, color: "text-amber-400", bg: "bg-amber-500/10", label: "Erro" },
|
error: { icon: AlertCircle, color: "text-amber-400", bg: "bg-amber-500/10", label: "Erro" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function EngineCard({ engine, onClick }: { engine: EngineStatus; onClick?: () => void }) {
|
function EngineCard({ engine }: { engine: EngineStatus }) {
|
||||||
const statusConf = STATUS_CONFIG[engine.status];
|
const statusConf = STATUS_CONFIG[engine.status];
|
||||||
const StatusIcon = statusConf.icon;
|
const StatusIcon = statusConf.icon;
|
||||||
const EngineIcon = ENGINE_ICONS[engine.name] || Server;
|
const EngineIcon = ENGINE_ICONS[engine.name] || Server;
|
||||||
const isClickable = !!onClick;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card data-testid={`engine-card-${engine.name}`} className="bg-[#1a1a2e] border-[#2a2a4a] hover:border-[#3a3a5a] transition-all">
|
||||||
data-testid={`engine-card-${engine.name}`}
|
|
||||||
className={`bg-[#1a1a2e] border-[#2a2a4a] hover:border-[#3a3a5a] transition-all ${isClickable ? "cursor-pointer hover:shadow-lg hover:shadow-violet-500/5 hover:border-violet-500/30" : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -130,16 +106,9 @@ function EngineCard({ engine, onClick }: { engine: EngineStatus; onClick?: () =>
|
||||||
<p className="text-xs text-gray-400">{engine.description}</p>
|
<p className="text-xs text-gray-400">{engine.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${statusConf.bg}`}>
|
||||||
{isClickable && (
|
<StatusIcon className={`w-3.5 h-3.5 ${statusConf.color}`} />
|
||||||
<Badge variant="outline" className="border-violet-500/30 text-violet-400 bg-violet-500/5 text-[10px]">
|
<span className={`text-xs font-medium ${statusConf.color}`}>{statusConf.label}</span>
|
||||||
Clique para detalhes
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${statusConf.bg}`}>
|
|
||||||
<StatusIcon className={`w-3.5 h-3.5 ${statusConf.color}`} />
|
|
||||||
<span className={`text-xs font-medium ${statusConf.color}`}>{statusConf.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -191,208 +160,6 @@ function EngineCard({ engine, onClick }: { engine: EngineStatus; onClick?: () =>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManusStructurePanel({ open, onOpenChange, metrics }: { open: boolean; onOpenChange: (open: boolean) => void; metrics: any }) {
|
|
||||||
const AGENTS = [
|
|
||||||
{ name: "Architect", role: "Design & Arquitetura", icon: Layers, color: "text-blue-400", bg: "bg-blue-500/10" },
|
|
||||||
{ name: "Generator", role: "Geração de Código", icon: Code, color: "text-green-400", bg: "bg-green-500/10" },
|
|
||||||
{ name: "Validator", role: "Validação TypeScript", icon: Shield, color: "text-amber-400", bg: "bg-amber-500/10" },
|
|
||||||
{ name: "Executor", role: "Execução & Staging", icon: Terminal, color: "text-red-400", bg: "bg-red-500/10" },
|
|
||||||
{ name: "Researcher", role: "Pesquisa & Contexto", icon: Search, color: "text-cyan-400", bg: "bg-cyan-500/10" },
|
|
||||||
{ name: "Evolution", role: "Aprendizado Evolutivo", icon: Sparkles, color: "text-purple-400", bg: "bg-purple-500/10" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOOL_CATEGORIES = [
|
|
||||||
{ name: "Busca Semântica", count: 8, icon: Search, color: "text-violet-400" },
|
|
||||||
{ name: "Leitura/Escrita de Arquivos", count: 12, icon: FileText, color: "text-blue-400" },
|
|
||||||
{ name: "Comandos Shell", count: 6, icon: Terminal, color: "text-green-400" },
|
|
||||||
{ name: "Web Research", count: 5, icon: Globe, color: "text-cyan-400" },
|
|
||||||
{ name: "Knowledge Graph", count: 8, icon: Network, color: "text-amber-400" },
|
|
||||||
{ name: "ERP & Database", count: 10, icon: Database, color: "text-emerald-400" },
|
|
||||||
{ name: "Análise de Código", count: 7, icon: Code, color: "text-pink-400" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const uptime = metrics?.metrics?.uptime
|
|
||||||
? `${Math.floor(metrics.metrics.uptime / 3600)}h ${Math.floor((metrics.metrics.uptime % 3600) / 60)}m`
|
|
||||||
: "---";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] bg-[#0d0d1a] border-[#2a2a4a] text-white overflow-hidden p-0">
|
|
||||||
<div className="sticky top-0 z-10 bg-gradient-to-r from-violet-600/20 via-purple-600/20 to-indigo-600/20 border-b border-violet-500/20 p-6">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 border border-violet-500/30">
|
|
||||||
<Brain className="w-7 h-7 text-violet-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="text-xl font-bold text-white">Manus IA - Cérebro Central</DialogTitle>
|
|
||||||
<DialogDescription className="text-violet-300/80">
|
|
||||||
Arquitetura do motor de inteligência que alimenta todos os agentes
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-3 mt-4">
|
|
||||||
<div className="text-center p-3 rounded-lg bg-[#1a1a2e]/80 border border-[#2a2a4a]">
|
|
||||||
<p className="text-2xl font-bold text-violet-400">{metrics?.model || "GPT-4o"}</p>
|
|
||||||
<p className="text-[10px] text-gray-400 uppercase mt-1">Modelo</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 rounded-lg bg-[#1a1a2e]/80 border border-[#2a2a4a]">
|
|
||||||
<p className="text-2xl font-bold text-white">{metrics?.metrics?.totalCalls || 0}</p>
|
|
||||||
<p className="text-[10px] text-gray-400 uppercase mt-1">Chamadas IA</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 rounded-lg bg-[#1a1a2e]/80 border border-[#2a2a4a]">
|
|
||||||
<p className="text-2xl font-bold text-emerald-400">{((metrics?.metrics?.totalTokens || 0) / 1000).toFixed(1)}k</p>
|
|
||||||
<p className="text-[10px] text-gray-400 uppercase mt-1">Tokens</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 rounded-lg bg-[#1a1a2e]/80 border border-[#2a2a4a]">
|
|
||||||
<p className="text-2xl font-bold text-cyan-400">{uptime}</p>
|
|
||||||
<p className="text-[10px] text-gray-400 uppercase mt-1">Uptime</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="max-h-[calc(90vh-220px)]">
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
|
||||||
<Layers className="w-4 h-4 text-violet-400" />
|
|
||||||
Arquitetura: Fluxo de Inteligência
|
|
||||||
</h3>
|
|
||||||
<div className="relative p-4 rounded-xl bg-[#1a1a2e] border border-[#2a2a4a]">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="w-full p-3 rounded-lg bg-gradient-to-r from-violet-500/10 to-purple-500/10 border border-violet-500/20 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-1">
|
|
||||||
<Brain className="w-5 h-5 text-violet-400" />
|
|
||||||
<span className="text-sm font-bold text-violet-300">ManusIntelligence (Singleton)</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400">GPT-4o + ToolManager + Context Enrichment</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-gray-500">
|
|
||||||
<ArrowRight className="w-4 h-4 rotate-90" />
|
|
||||||
<span className="text-[10px]">generate() / think()</span>
|
|
||||||
<ArrowRight className="w-4 h-4 rotate-90" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full p-3 rounded-lg bg-[#0d0d1a] border border-[#1a1a3a] text-center">
|
|
||||||
<span className="text-xs font-medium text-amber-400">enrichWithContext()</span>
|
|
||||||
<p className="text-[10px] text-gray-500 mt-1">ToolManager.search_code → Contexto Semântico Automático</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRight className="w-4 h-4 rotate-90 text-gray-500" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 w-full">
|
|
||||||
{AGENTS.map((agent) => (
|
|
||||||
<div key={agent.name} className={`p-2.5 rounded-lg ${agent.bg} border border-[#2a2a4a] text-center`}>
|
|
||||||
<agent.icon className={`w-4 h-4 ${agent.color} mx-auto mb-1`} />
|
|
||||||
<p className={`text-xs font-semibold ${agent.color}`}>{agent.name}</p>
|
|
||||||
<p className="text-[9px] text-gray-500 mt-0.5">{agent.role}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4 text-amber-400" />
|
|
||||||
Ferramentas Disponíveis ({metrics?.capabilities?.tools || 56})
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
||||||
{TOOL_CATEGORIES.map((cat) => (
|
|
||||||
<div key={cat.name} className="p-3 rounded-lg bg-[#1a1a2e] border border-[#2a2a4a] hover:border-[#3a3a5a] transition-all">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<cat.icon className={`w-3.5 h-3.5 ${cat.color}`} />
|
|
||||||
<span className={`text-xs font-medium ${cat.color}`}>{cat.count}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400">{cat.name}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-400" />
|
|
||||||
Capacidades Ativas
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{[
|
|
||||||
{ name: "Cérebro Central (GPT-4o)", active: true },
|
|
||||||
{ name: "Enriquecimento de Contexto", active: true },
|
|
||||||
{ name: "Busca Semântica de Código", active: true },
|
|
||||||
{ name: "Knowledge Graph", active: true },
|
|
||||||
{ name: "Pipeline Autônomo de Dev", active: true },
|
|
||||||
{ name: "Orquestração de 6 Agentes", active: true },
|
|
||||||
{ name: "Leitura/Escrita de Arquivos", active: true },
|
|
||||||
{ name: "Execução de Comandos Shell", active: true },
|
|
||||||
{ name: "Web Research", active: true },
|
|
||||||
{ name: "Análise de Código", active: true },
|
|
||||||
{ name: "Validação TypeScript", active: true },
|
|
||||||
{ name: "Memória Evolutiva", active: true },
|
|
||||||
].map((cap) => (
|
|
||||||
<div key={cap.name} className="flex items-center gap-2 p-2 rounded-lg bg-[#1a1a2e] border border-[#2a2a4a]">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
||||||
<span className="text-xs text-gray-300">{cap.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{metrics?.metrics?.errorCount > 0 && (
|
|
||||||
<div className="p-3 rounded-lg bg-red-500/5 border border-red-500/20">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
|
||||||
<span className="text-xs font-medium text-red-400">Erros Registrados</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Total: <span className="text-red-400 font-medium">{metrics.metrics.errorCount}</span>
|
|
||||||
{metrics.metrics.lastCallAt && (
|
|
||||||
<> | Última chamada: <span className="text-gray-300">{new Date(metrics.metrics.lastCallAt).toLocaleString("pt-BR")}</span></>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-4 rounded-xl bg-[#1a1a2e] border border-[#2a2a4a]">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
|
||||||
<Network className="w-4 h-4 text-violet-400" />
|
|
||||||
Diagrama de Fluxo
|
|
||||||
</h3>
|
|
||||||
<div className="font-mono text-[11px] text-gray-400 space-y-0.5 bg-[#0a0a15] p-4 rounded-lg border border-[#1a1a3a]">
|
|
||||||
<p className="text-violet-400">{"┌──────────────────────────────────────────────────────────┐"}</p>
|
|
||||||
<p className="text-violet-400">{"│ MANUS INTELLIGENCE (Singleton GPT-4o) │"}</p>
|
|
||||||
<p className="text-violet-400">{"├──────────────────────────────────────────────────────────┤"}</p>
|
|
||||||
<p>{"│ .generate(prompt) → enrichWithContext() → OpenAI │"}</p>
|
|
||||||
<p>{"│ .think(prompt) → enrichWithContext() → OpenAI │"}</p>
|
|
||||||
<p>{"│ .getMetrics() → calls, tokens, errors, uptime │"}</p>
|
|
||||||
<p className="text-violet-400">{"├──────────────────────────────────────────────────────────┤"}</p>
|
|
||||||
<p className="text-amber-400">{"│ ToolManager.executeTool('search_code', query) │"}</p>
|
|
||||||
<p className="text-amber-400">{"│ → Contexto semântico injetado automaticamente │"}</p>
|
|
||||||
<p className="text-violet-400">{"├──────────────────────────────────────────────────────────┤"}</p>
|
|
||||||
<p className="text-blue-400">{"│ Architect ──────┐ │"}</p>
|
|
||||||
<p className="text-green-400">{"│ Generator ──────┤ │"}</p>
|
|
||||||
<p className="text-amber-400">{"│ Validator ──────┤── Todos via manusIntelligence ──► │"}</p>
|
|
||||||
<p className="text-red-400">{"│ Executor ──────┤ │"}</p>
|
|
||||||
<p className="text-cyan-400">{"│ Researcher ──────┤ │"}</p>
|
|
||||||
<p className="text-purple-400">{"│ Evolution ──────┘ │"}</p>
|
|
||||||
<p className="text-violet-400">{"├──────────────────────────────────────────────────────────┤"}</p>
|
|
||||||
<p className="text-emerald-400">{"│ /api/manus/health → Status, métricas, capacidades │"}</p>
|
|
||||||
<p className="text-violet-400">{"└──────────────────────────────────────────────────────────┘"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentCard({ agent }: { agent: AgentStatus }) {
|
function AgentCard({ agent }: { agent: AgentStatus }) {
|
||||||
return (
|
return (
|
||||||
<div data-testid={`agent-card-${agent.name}`} className="flex items-center justify-between p-3 rounded-lg bg-[#1a1a2e] border border-[#2a2a4a]">
|
<div data-testid={`agent-card-${agent.name}`} className="flex items-center justify-between p-3 rounded-lg bg-[#1a1a2e] border border-[#2a2a4a]">
|
||||||
|
|
@ -442,7 +209,6 @@ function SummaryCards({ summary }: { summary: EngineRoomData["summary"] }) {
|
||||||
export default function EngineRoom() {
|
export default function EngineRoom() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [manusOpen, setManusOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data, isLoading, isRefetching } = useQuery<EngineRoomData>({
|
const { data, isLoading, isRefetching } = useQuery<EngineRoomData>({
|
||||||
queryKey: ["/api/engine-room/status"],
|
queryKey: ["/api/engine-room/status"],
|
||||||
|
|
@ -461,12 +227,6 @@ export default function EngineRoom() {
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: manusMetrics } = useQuery<any>({
|
|
||||||
queryKey: ["/api/manus/health"],
|
|
||||||
enabled: activeTab === "manus" || manusOpen,
|
|
||||||
refetchInterval: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/engine-room/status"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/engine-room/status"] });
|
||||||
};
|
};
|
||||||
|
|
@ -537,9 +297,6 @@ export default function EngineRoom() {
|
||||||
<TabsTrigger value="automation" data-testid="tab-automation" className="data-[state=active]:bg-purple-500/10 data-[state=active]:text-purple-400">
|
<TabsTrigger value="automation" data-testid="tab-automation" className="data-[state=active]:bg-purple-500/10 data-[state=active]:text-purple-400">
|
||||||
<Zap className="w-4 h-4 mr-1.5" /> Motor Automacao
|
<Zap className="w-4 h-4 mr-1.5" /> Motor Automacao
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="manus" data-testid="tab-manus" className="data-[state=active]:bg-violet-500/10 data-[state=active]:text-violet-400">
|
|
||||||
<Brain className="w-4 h-4 mr-1.5" /> Manus IA
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="agents" data-testid="tab-agents" className="data-[state=active]:bg-cyan-500/10 data-[state=active]:text-cyan-400">
|
<TabsTrigger value="agents" data-testid="tab-agents" className="data-[state=active]:bg-cyan-500/10 data-[state=active]:text-cyan-400">
|
||||||
<Bot className="w-4 h-4 mr-1.5" /> Agentes XOS
|
<Bot className="w-4 h-4 mr-1.5" /> Agentes XOS
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -548,11 +305,7 @@ export default function EngineRoom() {
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{data.engines.map((engine) => (
|
{data.engines.map((engine) => (
|
||||||
<EngineCard
|
<EngineCard key={engine.name} engine={engine} />
|
||||||
key={engine.name}
|
|
||||||
engine={engine}
|
|
||||||
onClick={engine.name === "manus-ia" ? () => setManusOpen(true) : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -685,83 +438,6 @@ export default function EngineRoom() {
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="manus">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{data.engines.filter(e => e.name === "manus-ia").map(e => (
|
|
||||||
<EngineCard key={e.name} engine={e} onClick={() => setManusOpen(true)} />
|
|
||||||
))}
|
|
||||||
<Card className="bg-[#1a1a2e] border-[#2a2a4a]">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
|
||||||
<Sparkles className="w-4 h-4 text-violet-400" /> Metricas do Manus IA
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{manusMetrics ? (
|
|
||||||
<>
|
|
||||||
<div className="p-3 rounded bg-[#0d0d1a] border border-[#1a1a3a]">
|
|
||||||
<p className="text-xs text-gray-500 uppercase mb-2">Modelo & Performance</p>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-bold text-violet-400">{manusMetrics.model || "GPT-4o"}</p>
|
|
||||||
<p className="text-[10px] text-gray-500">Modelo</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-bold text-white">{manusMetrics.metrics?.totalCalls || 0}</p>
|
|
||||||
<p className="text-[10px] text-gray-500">Chamadas IA</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-bold text-emerald-400">{((manusMetrics.metrics?.totalTokens || 0) / 1000).toFixed(1)}k</p>
|
|
||||||
<p className="text-[10px] text-gray-500">Tokens</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded bg-[#0d0d1a] border border-[#1a1a3a]">
|
|
||||||
<p className="text-xs text-gray-500 uppercase mb-2">Capacidades Ativas</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg font-bold text-white">{manusMetrics.capabilities?.tools || 56}</span>
|
|
||||||
<span className="text-xs text-gray-400">Ferramentas</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg font-bold text-white">{manusMetrics.capabilities?.agents || 0}</span>
|
|
||||||
<span className="text-xs text-gray-400">Agentes Ativos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded bg-[#0d0d1a] border border-[#1a1a3a]">
|
|
||||||
<p className="text-xs text-gray-500 uppercase mb-2">Uptime</p>
|
|
||||||
<p className="text-sm text-gray-300">
|
|
||||||
{manusMetrics.metrics?.uptime
|
|
||||||
? `${Math.floor(manusMetrics.metrics.uptime / 3600)}h ${Math.floor((manusMetrics.metrics.uptime % 3600) / 60)}m`
|
|
||||||
: "---"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4 text-gray-500">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
|
|
||||||
<p className="text-xs">Carregando metricas...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 p-4 rounded-lg bg-[#1a1a2e] border border-[#2a2a4a]">
|
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-2 flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-violet-400" /> Capacidades do Manus IA
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{["GPT-4o (Cerebro Central)", "56 Ferramentas Integradas", "Knowledge Graph", "Busca Semantica", "Pipeline de Dev Autonomo", "Orquestrador de Agentes", "Leitura/Escrita de Arquivos", "Execucao de Comandos", "Web Research", "Analise de Codigo", "Validacao TypeScript", "Memoria Evolutiva"].map(cap => (
|
|
||||||
<div key={cap} className="flex items-center gap-2 p-2 rounded bg-[#0d0d1a]">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-violet-400 shrink-0" />
|
|
||||||
<span className="text-xs text-gray-300">{cap}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="agents">
|
<TabsContent value="agents">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
||||||
|
|
@ -813,15 +489,13 @@ export default function EngineRoom() {
|
||||||
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
||||||
<p>{"│ Express.js (5000) ─── Orquestracao + API Gateway │"}</p>
|
<p>{"│ Express.js (5000) ─── Orquestracao + API Gateway │"}</p>
|
||||||
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
||||||
<p className="text-violet-400 font-bold">{"│ Manus IA (5000) ─── GPT-4o + 56 Tools (Cerebro)│"}</p>
|
|
||||||
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
|
||||||
<p className="text-blue-400">{"│ Plus ERP (8080) ─── Laravel/PHP - ERP Completo │"}</p>
|
<p className="text-blue-400">{"│ Plus ERP (8080) ─── Laravel/PHP - ERP Completo │"}</p>
|
||||||
<p className="text-amber-300">{"│ Contabil (8003) ─── FastAPI - DRE/Balancete │"}</p>
|
<p className="text-amber-300">{"│ Contabil (8003) ─── FastAPI - DRE/Balancete │"}</p>
|
||||||
<p className="text-amber-300">{"│ Fiscal (8002) ─── FastAPI - NF-e/SEFAZ │"}</p>
|
<p className="text-amber-300">{"│ Fiscal (8002) ─── FastAPI - NF-e/SEFAZ │"}</p>
|
||||||
<p className="text-emerald-400">{"│ BI Engine (8004) ─── FastAPI - SQL/Charts/Cache │"}</p>
|
<p className="text-emerald-400">{"│ BI Engine (8004) ─── FastAPI - SQL/Charts/Cache │"}</p>
|
||||||
<p className="text-purple-400">{"│ Automacao (8005) ─── FastAPI - Scheduler/Events │"}</p>
|
<p className="text-purple-400">{"│ Automacao (8005) ─── FastAPI - Scheduler/Events │"}</p>
|
||||||
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
<p className="text-amber-400">{"├─────────────────────────────────────────────────────┤"}</p>
|
||||||
<p className="text-cyan-400">{"│ XOS Agents ─── 6 Agentes via ManusIntel. │"}</p>
|
<p className="text-cyan-400">{"│ XOS Agents ─── 6 Agentes Autonomos │"}</p>
|
||||||
<p className="text-amber-400">{"└─────────────────────────────────────────────────────┘"}</p>
|
<p className="text-amber-400">{"└─────────────────────────────────────────────────────┘"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -832,8 +506,6 @@ export default function EngineRoom() {
|
||||||
<p>Nao foi possivel carregar o status dos motores</p>
|
<p>Nao foi possivel carregar o status dos motores</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ManusStructurePanel open={manusOpen} onOpenChange={setManusOpen} metrics={manusMetrics} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,12 @@ export default function EngineeringHub() {
|
||||||
{isErpConnected ? (
|
{isErpConnected ? (
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300 flex items-center gap-1.5 px-3 py-1.5">
|
<Badge className="bg-green-100 text-green-700 border-green-300 flex items-center gap-1.5 px-3 py-1.5">
|
||||||
<Link2 className="h-3.5 w-3.5" />
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
Sincronizado com Arcádia SOE
|
Sincronizado com Arcádia ERP
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="flex items-center gap-1.5 px-3 py-1.5">
|
<Badge variant="secondary" className="flex items-center gap-1.5 px-3 py-1.5">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
Arcádia SOE não conectado
|
Arcádia ERP não conectado
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" data-testid="btn-refresh-engineering">
|
<Button variant="outline" size="sm" data-testid="btn-refresh-engineering">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
|
||||||
import { useState } from "react";
|
import { useState } 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";
|
||||||
|
|
@ -41,7 +40,7 @@ import {
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
|
|
||||||
type TabType = "dashboard" | "payables" | "receivables" | "accounts" | "transactions" | "settings" | "analise";
|
type TabType = "dashboard" | "payables" | "receivables" | "accounts" | "transactions" | "settings";
|
||||||
|
|
||||||
interface BankAccount {
|
interface BankAccount {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -976,14 +975,13 @@ export default function Financeiro() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)} className="space-y-4">
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)} className="space-y-4">
|
||||||
<TabsList className="grid grid-cols-7 w-full max-w-4xl">
|
<TabsList className="grid grid-cols-6 w-full max-w-4xl">
|
||||||
<TabsTrigger value="dashboard" data-testid="tab-dashboard">Dashboard</TabsTrigger>
|
<TabsTrigger value="dashboard" data-testid="tab-dashboard">Dashboard</TabsTrigger>
|
||||||
<TabsTrigger value="payables" data-testid="tab-payables">A Pagar</TabsTrigger>
|
<TabsTrigger value="payables" data-testid="tab-payables">A Pagar</TabsTrigger>
|
||||||
<TabsTrigger value="receivables" data-testid="tab-receivables">A Receber</TabsTrigger>
|
<TabsTrigger value="receivables" data-testid="tab-receivables">A Receber</TabsTrigger>
|
||||||
<TabsTrigger value="accounts" data-testid="tab-accounts">Contas</TabsTrigger>
|
<TabsTrigger value="accounts" data-testid="tab-accounts">Contas</TabsTrigger>
|
||||||
<TabsTrigger value="transactions" data-testid="tab-transactions">Extrato</TabsTrigger>
|
<TabsTrigger value="transactions" data-testid="tab-transactions">Extrato</TabsTrigger>
|
||||||
<TabsTrigger value="settings" data-testid="tab-settings">Configurações</TabsTrigger>
|
<TabsTrigger value="settings" data-testid="tab-settings">Configurações</TabsTrigger>
|
||||||
<TabsTrigger value="analise" data-testid="tab-analise">Análise BI</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="dashboard">{renderDashboard()}</TabsContent>
|
<TabsContent value="dashboard">{renderDashboard()}</TabsContent>
|
||||||
|
|
@ -992,9 +990,6 @@ export default function Financeiro() {
|
||||||
<TabsContent value="accounts">{renderAccounts()}</TabsContent>
|
<TabsContent value="accounts">{renderAccounts()}</TabsContent>
|
||||||
<TabsContent value="transactions">{renderTransactions()}</TabsContent>
|
<TabsContent value="transactions">{renderTransactions()}</TabsContent>
|
||||||
<TabsContent value="settings">{renderSettings()}</TabsContent>
|
<TabsContent value="settings">{renderSettings()}</TabsContent>
|
||||||
<TabsContent value="analise">
|
|
||||||
<SupersetDashboard dashboardId="financial-overview" />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Dialog open={showAccountDialog} onOpenChange={setShowAccountDialog}>
|
<Dialog open={showAccountDialog} onOpenChange={setShowAccountDialog}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
|
||||||
import {
|
import {
|
||||||
Search, Grid, Settings, FileText, Plus, Star,
|
Search, Grid, Settings, FileText, Plus, Star,
|
||||||
ChevronRight, Bell, Clock, CheckSquare,
|
ChevronRight, Bell, Clock, CheckSquare,
|
||||||
|
|
@ -120,7 +119,7 @@ export default function Home() {
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const [editingApp, setEditingApp] = useState<Application | null>(null);
|
const [editingApp, setEditingApp] = useState<Application | null>(null);
|
||||||
const [newNoteContent, setNewNoteContent] = useState("");
|
const [newNoteContent, setNewNoteContent] = useState("");
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "master";
|
const isAdmin = user?.role === "admin";
|
||||||
const [newApp, setNewApp] = useState({
|
const [newApp, setNewApp] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
category: "",
|
category: "",
|
||||||
|
|
@ -1171,15 +1170,6 @@ export default function Home() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arcádia Insights — Dashboard executivo */}
|
|
||||||
<div className="mt-6 px-6 pb-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="font-semibold text-slate-800">Arcádia Insights</h2>
|
|
||||||
<a href="/bi" className="text-xs text-indigo-500 hover:underline">Abrir BI completo →</a>
|
|
||||||
</div>
|
|
||||||
<SupersetDashboard dashboardId="executive-summary" height={400} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ export default function Migration() {
|
||||||
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
|
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".zip,.json,.csv,.rar,.sql,.sql.gz"
|
accept=".zip,.json,.csv"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="backup-file"
|
id="backup-file"
|
||||||
onChange={e => setUploadFile(e.target.files?.[0] || null)}
|
onChange={e => setUploadFile(e.target.files?.[0] || null)}
|
||||||
|
|
@ -286,7 +286,7 @@ export default function Migration() {
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{uploadFile ? uploadFile.name : "Clique para selecionar"}
|
{uploadFile ? uploadFile.name : "Clique para selecionar"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), RAR, SQL, SQL.GZ, JSON ou CSV</p>
|
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), JSON ou CSV</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1855,7 +1855,7 @@ export default function ProcessCompass() {
|
||||||
data-testid="search-projects"
|
data-testid="search-projects"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowNewProjectDialog(true)} data-testid="btn-new-project">
|
<Button onClick={() => setShowNewProjectDialog(true)} disabled={clients.length === 0} data-testid="btn-new-project">
|
||||||
<Plus className="h-4 w-4 mr-2" /> Novo Projeto
|
<Plus className="h-4 w-4 mr-2" /> Novo Projeto
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4600,32 +4600,18 @@ export default function ProcessCompass() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project-client">Cliente</Label>
|
<Label htmlFor="project-client">Cliente</Label>
|
||||||
{clients.length === 0 ? (
|
<Select name="clientId" required>
|
||||||
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
|
<SelectTrigger data-testid="select-project-client">
|
||||||
Nenhum cliente cadastrado.{" "}
|
<SelectValue placeholder="Selecione um cliente" />
|
||||||
<button
|
</SelectTrigger>
|
||||||
type="button"
|
<SelectContent>
|
||||||
className="underline font-medium"
|
{clients.map(client => (
|
||||||
onClick={() => { setShowNewProjectDialog(false); setShowNewClientDialog(true); }}
|
<SelectItem key={client.id} value={client.id.toString()}>
|
||||||
>
|
{client.name}
|
||||||
Criar cliente
|
</SelectItem>
|
||||||
</button>{" "}
|
))}
|
||||||
antes de criar um projeto.
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
) : (
|
|
||||||
<Select name="clientId" required>
|
|
||||||
<SelectTrigger data-testid="select-project-client">
|
|
||||||
<SelectValue placeholder="Selecione um cliente" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients.map(client => (
|
|
||||||
<SelectItem key={client.id} value={client.id.toString()}>
|
|
||||||
{client.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project-description">Descrição</Label>
|
<Label htmlFor="project-description">Descrição</Label>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,10 +2,11 @@ import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import {
|
import {
|
||||||
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
||||||
ChevronRight, PlusCircle, Filter, Search, Bell, Calendar, Target,
|
ChevronRight, PlusCircle, Filter, Search, Bell, Calendar, Target,
|
||||||
BarChart3, DollarSign, Clock, AlertCircle, Phone, Mail, ArrowUpRight
|
BarChart3, DollarSign, Clock, AlertCircle, Phone, Mail, ArrowUpRight,
|
||||||
|
Activity, Hash, Shield
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
|
@ -15,6 +16,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
interface XosStats {
|
interface XosStats {
|
||||||
total_contacts: number;
|
total_contacts: number;
|
||||||
|
|
@ -69,12 +71,10 @@ interface Activity {
|
||||||
export default function XosCentral() {
|
export default function XosCentral() {
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
|
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
|
||||||
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
|
|
||||||
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
|
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
|
||||||
const [newActivity, setNewActivity] = useState({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
|
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
|
||||||
const [isNewSelectorOpen, setIsNewSelectorOpen] = useState(false);
|
const [newActivity, setNewActivity] = useState({ type: "task", title: "", due_at: "", priority: "medium" });
|
||||||
const [isNewDealOpen, setIsNewDealOpen] = useState(false);
|
|
||||||
const [newDeal, setNewDeal] = useState({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const createContactMutation = useMutation({
|
const createContactMutation = useMutation({
|
||||||
|
|
@ -95,24 +95,6 @@ export default function XosCentral() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDealMutation = useMutation({
|
|
||||||
mutationFn: async (data: typeof newDeal) => {
|
|
||||||
const res = await fetch("/api/xos/deals", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Erro ao criar negócio");
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/deals"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
|
||||||
setIsNewDealOpen(false);
|
|
||||||
setNewDeal({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createActivityMutation = useMutation({
|
const createActivityMutation = useMutation({
|
||||||
mutationFn: async (data: typeof newActivity) => {
|
mutationFn: async (data: typeof newActivity) => {
|
||||||
const res = await fetch("/api/xos/activities", {
|
const res = await fetch("/api/xos/activities", {
|
||||||
|
|
@ -127,7 +109,7 @@ export default function XosCentral() {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
||||||
setIsNewActivityOpen(false);
|
setIsNewActivityOpen(false);
|
||||||
setNewActivity({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
|
setNewActivity({ type: "task", title: "", due_at: "", priority: "medium" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -161,6 +143,9 @@ export default function XosCentral() {
|
||||||
{ id: "automations", name: "Automações", icon: Zap, href: "/xos/automations", color: "bg-violet-100 text-violet-600", description: "Workflows automáticos" },
|
{ id: "automations", name: "Automações", icon: Zap, href: "/xos/automations", color: "bg-violet-100 text-violet-600", description: "Workflows automáticos" },
|
||||||
{ id: "campaigns", name: "Campanhas", icon: Target, href: "/xos/campaigns", color: "bg-pink-100 text-pink-600", description: "Marketing automation" },
|
{ id: "campaigns", name: "Campanhas", icon: Target, href: "/xos/campaigns", color: "bg-pink-100 text-pink-600", description: "Marketing automation" },
|
||||||
{ id: "sites", name: "Sites", icon: LayoutGrid, href: "/xos/sites", color: "bg-cyan-100 text-cyan-600", description: "Site builder" },
|
{ id: "sites", name: "Sites", icon: LayoutGrid, href: "/xos/sites", color: "bg-cyan-100 text-cyan-600", description: "Site builder" },
|
||||||
|
{ id: "supervisor", name: "Supervisor", icon: Activity, href: "/xos/supervisor", color: "bg-indigo-100 text-indigo-600", description: "Monitor em tempo real" },
|
||||||
|
{ id: "reports", name: "Relatórios", icon: BarChart3, href: "/xos/reports", color: "bg-emerald-100 text-emerald-600", description: "CSAT, SLA e KPIs" },
|
||||||
|
{ id: "protocols", name: "Protocolos", icon: Hash, href: "/xos/protocols", color: "bg-teal-100 text-teal-600", description: "Rastreamento de atendimentos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
|
|
@ -241,9 +226,9 @@ export default function XosCentral() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
|
<Button data-testid="button-new-contact" onClick={() => setIsNewContactOpen(true)}>
|
||||||
<PlusCircle className="h-4 w-4 mr-2" />
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
Novo
|
Novo Contato
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -328,7 +313,7 @@ export default function XosCentral() {
|
||||||
{/* Modules Grid */}
|
{/* Modules Grid */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Módulos XOS</h2>
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Módulos XOS</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-9 gap-4">
|
||||||
{modules.map((mod) => (
|
{modules.map((mod) => (
|
||||||
<Link key={mod.id} href={mod.href}>
|
<Link key={mod.id} href={mod.href}>
|
||||||
<Card className="hover:shadow-lg transition-all cursor-pointer group" data-testid={`card-module-${mod.id}`}>
|
<Card className="hover:shadow-lg transition-all cursor-pointer group" data-testid={`card-module-${mod.id}`}>
|
||||||
|
|
@ -584,175 +569,129 @@ export default function XosCentral() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Modal: Novo Contato */}
|
||||||
{/* Seletor: o que deseja criar? */}
|
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
|
||||||
<Dialog open={isNewSelectorOpen} onOpenChange={setIsNewSelectorOpen}>
|
<DialogContent>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>Novo Contato</DialogTitle>
|
||||||
<DialogTitle>O que deseja criar?</DialogTitle>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
<div className="space-y-4 py-4">
|
||||||
<div className="grid gap-3 pt-2">
|
<div className="space-y-2">
|
||||||
<button
|
<Label>Nome *</Label>
|
||||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-blue-50 hover:border-blue-300 transition-colors text-left"
|
<Input
|
||||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewContactOpen(true); }}
|
value={newContact.name}
|
||||||
>
|
onChange={(e) => setNewContact({ ...newContact, name: e.target.value })}
|
||||||
<div className="bg-blue-100 p-2 rounded-lg"><Users className="h-5 w-5 text-blue-600" /></div>
|
placeholder="Nome do contato"
|
||||||
<div><p className="font-semibold text-slate-800">Contato</p><p className="text-sm text-slate-500">Adicionar lead ou cliente</p></div>
|
/>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div className="space-y-2">
|
||||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-emerald-50 hover:border-emerald-300 transition-colors text-left"
|
<Label>Email</Label>
|
||||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewDealOpen(true); }}
|
<Input
|
||||||
>
|
type="email"
|
||||||
<div className="bg-emerald-100 p-2 rounded-lg"><TrendingUp className="h-5 w-5 text-emerald-600" /></div>
|
value={newContact.email}
|
||||||
<div><p className="font-semibold text-slate-800">Negócio</p><p className="text-sm text-slate-500">Criar oportunidade de venda</p></div>
|
onChange={(e) => setNewContact({ ...newContact, email: e.target.value })}
|
||||||
</button>
|
placeholder="email@exemplo.com"
|
||||||
<button
|
/>
|
||||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-violet-50 hover:border-violet-300 transition-colors text-left"
|
</div>
|
||||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewActivityOpen(true); }}
|
<div className="space-y-2">
|
||||||
>
|
<Label>Telefone</Label>
|
||||||
<div className="bg-violet-100 p-2 rounded-lg"><Calendar className="h-5 w-5 text-violet-600" /></div>
|
<Input
|
||||||
<div><p className="font-semibold text-slate-800">Atividade</p><p className="text-sm text-slate-500">Agendar tarefa ou reunião</p></div>
|
value={newContact.phone}
|
||||||
</button>
|
onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })}
|
||||||
</div>
|
placeholder="(00) 00000-0000"
|
||||||
</DialogContent>
|
/>
|
||||||
</Dialog>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Dialog Novo Negócio */}
|
<div className="space-y-2">
|
||||||
<Dialog open={isNewDealOpen} onOpenChange={setIsNewDealOpen}>
|
<Label>Empresa</Label>
|
||||||
<DialogContent>
|
<Input
|
||||||
<DialogHeader>
|
value={newContact.company}
|
||||||
<DialogTitle>Novo Negócio</DialogTitle>
|
onChange={(e) => setNewContact({ ...newContact, company: e.target.value })}
|
||||||
</DialogHeader>
|
placeholder="Empresa"
|
||||||
<div className="space-y-4 pt-2">
|
/>
|
||||||
<div>
|
</div>
|
||||||
<Label>Título *</Label>
|
<div className="space-y-2">
|
||||||
<Input placeholder="Ex: Proposta comercial Empresa X" value={newDeal.title} onChange={(e) => setNewDeal({ ...newDeal, title: e.target.value })} />
|
<Label>Cargo</Label>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
value={newContact.position}
|
||||||
<Label>Estágio</Label>
|
onChange={(e) => setNewContact({ ...newContact, position: e.target.value })}
|
||||||
<select className="w-full mt-1 border rounded-md px-3 py-2 text-sm" value={newDeal.stage_id} onChange={(e) => setNewDeal({ ...newDeal, stage_id: e.target.value })}>
|
placeholder="Cargo"
|
||||||
<option value="1">Prospecção</option>
|
/>
|
||||||
<option value="2">Qualificação</option>
|
</div>
|
||||||
<option value="3">Proposta</option>
|
</div>
|
||||||
<option value="4">Negociação</option>
|
|
||||||
<option value="5">Ganho</option>
|
|
||||||
<option value="6">Perdido</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Valor (R$)</Label>
|
|
||||||
<Input type="number" placeholder="0,00" value={newDeal.value} onChange={(e) => setNewDeal({ ...newDeal, value: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewDealOpen(false)}>Cancelar</Button>
|
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="w-full"
|
||||||
onClick={() => createDealMutation.mutate(newDeal)}
|
|
||||||
disabled={!newDeal.title || createDealMutation.isPending}
|
|
||||||
>
|
|
||||||
{createDealMutation.isPending ? "Salvando..." : "Salvar Negócio"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Novo Contato</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
<div>
|
|
||||||
<Label>Nome *</Label>
|
|
||||||
<Input placeholder="Nome completo" value={newContact.name} onChange={(e) => setNewContact({ ...newContact, name: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>E-mail</Label>
|
|
||||||
<Input placeholder="email@empresa.com" value={newContact.email} onChange={(e) => setNewContact({ ...newContact, email: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Telefone / WhatsApp</Label>
|
|
||||||
<Input placeholder="(11) 99999-9999" value={newContact.phone} onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Empresa</Label>
|
|
||||||
<Input placeholder="Nome da empresa" value={newContact.company} onChange={(e) => setNewContact({ ...newContact, company: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Cargo</Label>
|
|
||||||
<Input placeholder="Cargo ou função" value={newContact.position} onChange={(e) => setNewContact({ ...newContact, position: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewContactOpen(false)}>Cancelar</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => createContactMutation.mutate(newContact)}
|
onClick={() => createContactMutation.mutate(newContact)}
|
||||||
disabled={!newContact.name || createContactMutation.isPending}
|
disabled={!newContact.name || createContactMutation.isPending}
|
||||||
>
|
>
|
||||||
{createContactMutation.isPending ? "Salvando..." : "Salvar Contato"}
|
{createContactMutation.isPending ? "Salvando..." : "Criar Contato"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
|
{/* Modal: Nova Atividade */}
|
||||||
<DialogContent>
|
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
|
||||||
<DialogHeader>
|
<DialogContent>
|
||||||
<DialogTitle>Nova Atividade</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>Nova Atividade</DialogTitle>
|
||||||
<div className="space-y-4 pt-2">
|
</DialogHeader>
|
||||||
<div>
|
<div className="space-y-4 py-4">
|
||||||
<Label>Tipo</Label>
|
<div className="space-y-2">
|
||||||
<select
|
<Label>Título *</Label>
|
||||||
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
|
<Input
|
||||||
value={newActivity.type}
|
value={newActivity.title}
|
||||||
onChange={(e) => setNewActivity({ ...newActivity, type: e.target.value })}
|
onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })}
|
||||||
>
|
placeholder="Descrição da atividade"
|
||||||
<option value="task">Tarefa</option>
|
/>
|
||||||
<option value="call">Ligação</option>
|
</div>
|
||||||
<option value="email">E-mail</option>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<option value="meeting">Reunião</option>
|
<div className="space-y-2">
|
||||||
</select>
|
<Label>Tipo</Label>
|
||||||
</div>
|
<Select value={newActivity.type} onValueChange={(v) => setNewActivity({ ...newActivity, type: v })}>
|
||||||
<div>
|
<SelectTrigger>
|
||||||
<Label>Título *</Label>
|
<SelectValue />
|
||||||
<Input placeholder="Descreva a atividade" value={newActivity.title} onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })} />
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<div>
|
<SelectItem value="task">Tarefa</SelectItem>
|
||||||
<Label>Descrição</Label>
|
<SelectItem value="call">Ligação</SelectItem>
|
||||||
<Input placeholder="Detalhes adicionais" value={newActivity.description} onChange={(e) => setNewActivity({ ...newActivity, description: e.target.value })} />
|
<SelectItem value="email">E-mail</SelectItem>
|
||||||
</div>
|
<SelectItem value="meeting">Reunião</SelectItem>
|
||||||
<div>
|
</SelectContent>
|
||||||
<Label>Data prevista</Label>
|
</Select>
|
||||||
<Input type="datetime-local" value={newActivity.due_at} onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })} />
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Prioridade</Label>
|
||||||
<Label>Prioridade</Label>
|
<Select value={newActivity.priority} onValueChange={(v) => setNewActivity({ ...newActivity, priority: v })}>
|
||||||
<select
|
<SelectTrigger>
|
||||||
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
|
<SelectValue />
|
||||||
value={newActivity.priority}
|
</SelectTrigger>
|
||||||
onChange={(e) => setNewActivity({ ...newActivity, priority: e.target.value })}
|
<SelectContent>
|
||||||
>
|
<SelectItem value="low">Baixa</SelectItem>
|
||||||
<option value="low">Baixa</option>
|
<SelectItem value="medium">Média</SelectItem>
|
||||||
<option value="normal">Normal</option>
|
<SelectItem value="high">Alta</SelectItem>
|
||||||
<option value="high">Alta</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
</div>
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewActivityOpen(false)}>Cancelar</Button>
|
<div className="space-y-2">
|
||||||
|
<Label>Data prevista</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={newActivity.due_at}
|
||||||
|
onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="w-full"
|
||||||
onClick={() => createActivityMutation.mutate(newActivity)}
|
onClick={() => createActivityMutation.mutate(newActivity)}
|
||||||
disabled={!newActivity.title || createActivityMutation.isPending}
|
disabled={!newActivity.title || createActivityMutation.isPending}
|
||||||
>
|
>
|
||||||
{createActivityMutation.isPending ? "Salvando..." : "Salvar Atividade"}
|
{createActivityMutation.isPending ? "Salvando..." : "Criar Atividade"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
</BrowserFrame>
|
</BrowserFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
const browserIcon = "/arcadia_suite_icon.png";
|
import browserIcon from "/favicon.png";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,6 @@ services:
|
||||||
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
||||||
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
||||||
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
||||||
# ── MiroFlow (container) ─────────────────────────────────────────────
|
|
||||||
MIROFLOW_HOST: "miroflow"
|
|
||||||
MIROFLOW_PORT: "8006"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/api/health || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 60s
|
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -151,6 +142,38 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- arcadia-internal
|
- 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) ───────────────────────────────────────────────
|
# ── ERPNext (Frappe Framework) ───────────────────────────────────────────────
|
||||||
# Perfil `erpnext` — suba com: docker compose --profile erpnext up
|
# Perfil `erpnext` — suba com: docker compose --profile erpnext up
|
||||||
erpnext-db:
|
erpnext-db:
|
||||||
|
|
@ -203,19 +226,135 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- arcadia-internal
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Arcádia Plus (Laravel + MySQL) ──────────────────────────────────────────
|
||||||
# ── MiroFlow (agente científico) ─────────────────────────────────────────────
|
# Perfil `plus` — suba com: docker compose --profile plus up
|
||||||
miroflow:
|
plus-db:
|
||||||
build:
|
image: mysql:8.0
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.miroflow
|
|
||||||
restart: always
|
restart: always
|
||||||
|
profiles: [plus]
|
||||||
environment:
|
environment:
|
||||||
MIROFLOW_PORT: "8006"
|
MYSQL_ROOT_PASSWORD: ${PLUS_DB_ROOT_PASSWORD}
|
||||||
OLLAMA_BASE_URL: "http://ollama-ia1upsekrad96at5hq97e4qa:11434"
|
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:
|
networks:
|
||||||
- arcadia-internal
|
- arcadia-internal
|
||||||
- ollama-net
|
|
||||||
|
|
||||||
# ── Microserviços Python ─────────────────────────────────────────────────────
|
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||||
contabil:
|
contabil:
|
||||||
|
|
@ -327,7 +466,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- arcadia-internal
|
- arcadia-internal
|
||||||
- coolify
|
- coolify
|
||||||
- ollama-net
|
|
||||||
|
|
||||||
# ── Ollama (LLMs locais — soberania total) ────────────────────────────────────
|
# ── Ollama (LLMs locais — soberania total) ────────────────────────────────────
|
||||||
# OPÇÃO A (padrão): Ollama como container Docker
|
# OPÇÃO A (padrão): Ollama como container Docker
|
||||||
|
|
@ -377,10 +515,8 @@ services:
|
||||||
environment:
|
environment:
|
||||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
|
||||||
SQLALCHEMY_DATABASE_URI: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/superset
|
SQLALCHEMY_DATABASE_URI: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/superset
|
||||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/superset
|
|
||||||
PYTHONPATH: /app/pythonpath
|
PYTHONPATH: /app/pythonpath
|
||||||
SUPERSET_ADMIN_USERNAME: ${SUPERSET_ADMIN_USERNAME:-admin}
|
SUPERSET_ADMIN_USERNAME: ${SUPERSET_ADMIN_USERNAME:-admin}
|
||||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USERNAME:-admin}
|
|
||||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
|
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
|
||||||
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@onboardbi.com.br}
|
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@onboardbi.com.br}
|
||||||
ARCADIA_DATABASE_URL: postgresql+psycopg2://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
ARCADIA_DATABASE_URL: postgresql+psycopg2://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
|
@ -388,11 +524,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||||
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||||
- ./docker/superset/images/arcadia_logo.png:/app/superset/static/assets/images/arcadia_logo.png:ro
|
|
||||||
- ./docker/superset/images/arcadia_logo_dark.png:/app/superset/static/assets/images/arcadia_logo_dark.png:ro
|
|
||||||
- ./docker/superset/images/superset-logo-horiz.png:/app/superset/static/assets/images/superset-logo-horiz.png:ro
|
|
||||||
- ./docker/superset/images/favicon.png:/app/superset/static/assets/images/favicon.png:ro
|
|
||||||
- ./docker/superset/images/arcadia_favicon.png:/app/superset/static/assets/images/arcadia_favicon.png:ro
|
|
||||||
- superset_home:/app/superset_home
|
- superset_home:/app/superset_home
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
@ -408,48 +539,22 @@ services:
|
||||||
- "traefik.http.routers.superset.tls=true"
|
- "traefik.http.routers.superset.tls=true"
|
||||||
- "traefik.http.routers.superset.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.superset.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.superset.loadbalancer.server.port=8088"
|
- "traefik.http.services.superset.loadbalancer.server.port=8088"
|
||||||
- "traefik.http.routers.superset-http.entrypoints=http"
|
|
||||||
- "traefik.http.routers.superset-http.rule=Host(`${SUPERSET_DOMAIN:-bi.onboardbi.com.br}`)"
|
|
||||||
- "traefik.http.routers.superset-http.middlewares=superset-https-redirect"
|
|
||||||
- "traefik.http.routers.superset-http.service=superset"
|
|
||||||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.scheme=https"
|
|
||||||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
|
|
||||||
profiles: [bi]
|
profiles: [bi]
|
||||||
|
|
||||||
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
|
|
||||||
# Ativar com: docker compose --profile kg up
|
|
||||||
neo4j:
|
|
||||||
image: neo4j:5
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
|
|
||||||
NEO4J_PLUGINS: '["apoc"]'
|
|
||||||
volumes:
|
|
||||||
- neo4j_data:/data
|
|
||||||
- neo4j_logs:/logs
|
|
||||||
ports:
|
|
||||||
- "7474:7474"
|
|
||||||
- "7687:7687"
|
|
||||||
networks:
|
|
||||||
- arcadia-internal
|
|
||||||
profiles: [kg]
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
arcadia-internal:
|
arcadia-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
ollama-net:
|
|
||||||
external: true
|
|
||||||
name: ia1upsekrad96at5hq97e4qa
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redis_data:
|
redis_data:
|
||||||
ollama_models:
|
ollama_models:
|
||||||
open_webui_data:
|
open_webui_data:
|
||||||
neo4j_data:
|
|
||||||
neo4j_logs:
|
|
||||||
|
|
||||||
plus_db:
|
plus_db:
|
||||||
plus_storage:
|
plus_storage:
|
||||||
superset_home:
|
superset_home:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
# ─── Arcádia Suite — Produção (Coolify) ───────────────────────────────────────
|
||||||
|
# Este arquivo é usado pelo Coolify para deploy automático.
|
||||||
|
# NÃO inclui volumes de código-fonte — só artefatos de build.
|
||||||
|
# Configurar no Coolify: Environment Variables para todas as vars ${...}
|
||||||
|
|
||||||
|
name: arcadia-prod
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Banco de dados com pgvector ─────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PGDATABASE:-arcadia}
|
||||||
|
POSTGRES_USER: ${PGUSER:-arcadia}
|
||||||
|
POSTGRES_PASSWORD: ${PGPASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-arcadia}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Redis ────────────────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── App principal ─────────────────────────────────────────────────────────
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
DOCKER_MODE: "true"
|
||||||
|
CONTABIL_PYTHON_URL: http://contabil:8003
|
||||||
|
BI_PYTHON_URL: http://bi:8004
|
||||||
|
AUTOMATION_PYTHON_URL: http://automation:8005
|
||||||
|
FISCO_PYTHON_URL: http://fisco:8002
|
||||||
|
PYTHON_SERVICE_URL: http://embeddings:8001
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
SSO_SECRET: ${SSO_SECRET}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_BASE_URL: http://litellm:4000
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||||
|
# ── Manus Agent — aponta para LiteLLM como gateway unificado ──────────
|
||||||
|
# LiteLLM roteia para Ollama (local), LLMFit (fine-tuned) ou externo
|
||||||
|
AI_INTEGRATIONS_OPENAI_BASE_URL: http://litellm:4000/v1
|
||||||
|
AI_INTEGRATIONS_OPENAI_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
- arcadia-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.arcadia.tls=true"
|
||||||
|
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
|
||||||
|
|
||||||
|
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||||
|
contabil:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: contabil
|
||||||
|
SERVICE_PORT: 8003
|
||||||
|
CONTABIL_PORT: 8003
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
bi:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: bi
|
||||||
|
SERVICE_PORT: 8004
|
||||||
|
BI_PORT: 8004
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
automation:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: automation
|
||||||
|
SERVICE_PORT: 8005
|
||||||
|
AUTOMATION_PORT: 8005
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
fisco:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: fisco
|
||||||
|
SERVICE_PORT: 8002
|
||||||
|
FISCO_PORT: 8002
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
embeddings:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: embeddings
|
||||||
|
SERVICE_PORT: 8001
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── LiteLLM (gateway unificado de LLM — soberania dos dados) ─────────────────
|
||||||
|
# Roteia: LLMFit (fine-tuned) → Ollama (local) → externo (opt-in)
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./docker/litellm-config.yaml:/app/config.yaml
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
|
environment:
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_API_KEY}
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
# Ollama: se instalado no host use http://host-gateway:11434
|
||||||
|
# Se usar container Docker, mantém http://ollama:11434
|
||||||
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||||
|
# LLMFit: URL do serviço de modelos fine-tuned
|
||||||
|
LLMFIT_BASE_URL: ${LLMFIT_BASE_URL:-}
|
||||||
|
# Providers externos opcionais (soberania: só habilitados se configurados)
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
|
GROQ_API_KEY: ${GROQ_API_KEY:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Ollama (LLMs locais — soberania total) ────────────────────────────────────
|
||||||
|
# OPÇÃO A (padrão): Ollama como container Docker
|
||||||
|
# OPÇÃO B: Ollama no host → comente este serviço e defina
|
||||||
|
# OLLAMA_BASE_URL=http://host-gateway:11434 nas env vars
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ollama_models:/root/.ollama
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
# Remova 'profiles: [ai]' para ativar por padrão no deploy
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
# ── Open WebUI (interface para Ollama + LLMFit) ───────────────────────────────
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
# Pode apontar para LiteLLM para ter acesso a todos os modelos via WebUI
|
||||||
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||||
|
OPENAI_API_BASE_URL: http://litellm:4000/v1
|
||||||
|
OPENAI_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- open_webui_data:/app/backend/data
|
||||||
|
depends_on:
|
||||||
|
- litellm
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
- arcadia-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "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"
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
arcadia-internal:
|
||||||
|
driver: bridge
|
||||||
|
arcadia-public:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redis_data:
|
||||||
|
ollama_models:
|
||||||
|
open_webui_data:
|
||||||
|
|
@ -1,87 +1,340 @@
|
||||||
version: "3.8"
|
# ─── Arcádia Suite — Desenvolvimento Local ────────────────────────────────────
|
||||||
|
# Uso: docker compose up
|
||||||
|
# Para subir com IA local: docker compose --profile ai up
|
||||||
|
|
||||||
|
name: arcadia-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
# ── Banco de dados com pgvector ─────────────────────────────────────────────
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: pgvector/pgvector:pg16
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${PGUSER:-arcadia}
|
POSTGRES_DB: arcadia
|
||||||
POSTGRES_PASSWORD: ${PGPASSWORD:-SuaSenhaSegura}
|
POSTGRES_USER: arcadia
|
||||||
POSTGRES_DB: ${PGDATABASE:-arcadia}
|
POSTGRES_PASSWORD: arcadia123
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-arcadia}"]
|
test: ["CMD-SHELL", "pg_isready -U arcadia -d arcadia"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ── Redis (filas de jobs) ────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── App principal (Node.js + React) ─────────────────────────────────────────
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
restart: always
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: builder # usa stage builder em dev (hot reload via volume)
|
||||||
|
restart: unless-stopped
|
||||||
|
command: npx tsx server/index.ts
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
DOCKER_MODE: "true" # desativa spawn de processos filhos
|
||||||
|
CONTABIL_PYTHON_URL: http://contabil:8003
|
||||||
|
BI_PYTHON_URL: http://bi:8004
|
||||||
|
AUTOMATION_PYTHON_URL: http://automation:8005
|
||||||
|
FISCO_PYTHON_URL: http://fisco:8002
|
||||||
|
PYTHON_SERVICE_URL: http://embeddings:8001
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-arcadia-dev-secret-change-in-prod}
|
||||||
|
SSO_SECRET: ${SSO_SECRET:-arcadia-sso-secret-2024-plus-integration-key-secure}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_BASE_URL: http://litellm:4000
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY:-arcadia-internal}
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/dist
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
redis:
|
||||||
- "5000:5000"
|
condition: service_healthy
|
||||||
env_file:
|
contabil:
|
||||||
- .env
|
condition: service_started
|
||||||
environment:
|
bi:
|
||||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-SuaSenhaSegura}@db:5432/${PGDATABASE:-arcadia}
|
condition: service_started
|
||||||
PGHOST: db
|
|
||||||
NODE_ENV: production
|
|
||||||
volumes:
|
|
||||||
- uploads:/app/uploads
|
|
||||||
|
|
||||||
# ── Superset BI ──────────────────────────────────────────────────────────────
|
# ── Microserviço Contábil (Python) ──────────────────────────────────────────
|
||||||
superset:
|
contabil:
|
||||||
build:
|
build:
|
||||||
context: ./docker/superset
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.python
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-dev}
|
SERVICE_NAME: contabil
|
||||||
SQLALCHEMY_DATABASE_URI: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-SuaSenhaSegura}@db:5432/superset
|
SERVICE_PORT: 8003
|
||||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-SuaSenhaSegura}@db:5432/superset
|
CONTABIL_PORT: 8003
|
||||||
PYTHONPATH: /app/pythonpath
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço BI Engine (Python) ─────────────────────────────────────────
|
||||||
|
bi:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: bi
|
||||||
|
SERVICE_PORT: 8004
|
||||||
|
BI_PORT: 8004
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8004:8004"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço Automações (Python) ────────────────────────────────────────
|
||||||
|
automation:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: automation
|
||||||
|
SERVICE_PORT: 8005
|
||||||
|
AUTOMATION_PORT: 8005
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8005:8005"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço Fiscal (Python) ────────────────────────────────────────────
|
||||||
|
fisco:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: fisco
|
||||||
|
SERVICE_PORT: 8002
|
||||||
|
FISCO_PORT: 8002
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Serviço de Embeddings (pgvector via Python) ──────────────────────────────
|
||||||
|
embeddings:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: embeddings
|
||||||
|
SERVICE_PORT: 8001
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Apache Superset (BI avançado) ────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia
|
||||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||||
SUPERSET_ADMIN_USERNAME: ${SUPERSET_ADMIN_USER:-admin}
|
|
||||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-arcadia2026}
|
|
||||||
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
||||||
ARCADIA_DATABASE_URL: postgresql+psycopg2://${PGUSER:-arcadia}:${PGPASSWORD:-SuaSenhaSegura}@db:5432/${PGDATABASE:-arcadia}
|
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-arcadia2026}
|
||||||
command: ["/bin/bash", "/app/docker/init.sh"]
|
SUPERSET_WEBSERVER_PORT: 8088
|
||||||
|
PYTHONPATH: /app/pythonpath
|
||||||
|
FLASK_ENV: production
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||||
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||||
- superset_home:/app/superset_home
|
- superset_home:/app/superset_home
|
||||||
ports:
|
|
||||||
- "8088:8088"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- arcadia
|
||||||
profiles: [bi]
|
profiles: [bi]
|
||||||
|
|
||||||
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
|
# ─────────────── PERFIL: ai (Soberania de IA) ────────────────────────────────
|
||||||
# Ativar com: docker compose --profile kg up
|
|
||||||
neo4j:
|
# ── Ollama (LLMs locais) ─────────────────────────────────────────────────────
|
||||||
image: neo4j:5
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ollama_models:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
profiles: [ai]
|
||||||
|
# Para GPU NVIDIA: adicione `deploy.resources.reservations.devices`
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:
|
||||||
|
# - driver: nvidia
|
||||||
|
# count: 1
|
||||||
|
# capabilities: [gpu]
|
||||||
|
|
||||||
|
# ── Open WebUI (interface para devs/consultores) ─────────────────────────────
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY:-webui-secret-change-in-prod}
|
||||||
|
DEFAULT_MODELS: llama3.3,qwen2.5-coder
|
||||||
|
ENABLE_RAG_WEB_SEARCH: "true"
|
||||||
|
ports:
|
||||||
|
- "3001:8080"
|
||||||
|
volumes:
|
||||||
|
- open_webui_data:/app/backend/data
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
# ── LiteLLM (proxy unificado de LLMs) ───────────────────────────────────────
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./docker/litellm-config.yaml:/app/config.yaml
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000", "--detailed_debug"]
|
||||||
|
environment:
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_API_KEY:-arcadia-internal}
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
# ─────────────── PERFIL: erpnext (ERPNext local) ──────────────────────────────
|
||||||
|
|
||||||
|
# ── MariaDB para ERPNext ─────────────────────────────────────────────────────
|
||||||
|
erpnext-db:
|
||||||
|
image: mariadb:10.6
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [erpnext]
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}
|
||||||
|
MYSQL_DATABASE: _frappe
|
||||||
|
MYSQL_USER: frappe
|
||||||
|
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD:-frappe2026}
|
||||||
|
volumes:
|
||||||
|
- erpnext_db:/var/lib/mysql
|
||||||
|
command: >
|
||||||
|
--character-set-server=utf8mb4
|
||||||
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--skip-character-set-client-handshake
|
||||||
|
--skip-innodb-read-only-compressed
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ── Neo4j (Knowledge Graph) ──────────────────────────────────────────────────
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5.20-community
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [agentic]
|
||||||
environment:
|
environment:
|
||||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
|
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
|
||||||
NEO4J_PLUGINS: '["apoc"]'
|
NEO4J_PLUGINS: '["apoc"]'
|
||||||
|
NEO4J_dbms_security_procedures_unrestricted: apoc.*
|
||||||
volumes:
|
volumes:
|
||||||
- neo4j_data:/data
|
- neo4j_data:/data
|
||||||
- neo4j_logs:/logs
|
- neo4j_logs:/logs
|
||||||
ports:
|
ports:
|
||||||
- "7474:7474"
|
- "7474:7474"
|
||||||
- "7687:7687"
|
- "7687:7687"
|
||||||
profiles: [kg]
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- arcadia
|
||||||
|
|
||||||
|
# ── ERPNext (Frappe Framework + ERPNext app) ─────────────────────────────────
|
||||||
|
erpnext:
|
||||||
|
image: frappe/erpnext:version-15
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [erpnext]
|
||||||
|
environment:
|
||||||
|
FRAPPE_SITE_NAME_HEADER: erpnext.local
|
||||||
|
BACKEND_PORT: 8000
|
||||||
|
volumes:
|
||||||
|
- erpnext_sites:/home/frappe/frappe-bench/sites
|
||||||
|
- erpnext_logs:/home/frappe/frappe-bench/logs
|
||||||
|
- ./docker/erpnext/init.sh:/usr/local/bin/init-erpnext.sh:ro
|
||||||
|
depends_on:
|
||||||
|
erpnext-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8090:8080"
|
||||||
|
command: ["/bin/bash", "/usr/local/bin/init-erpnext.sh"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/method/frappe.ping || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 120s
|
||||||
|
networks:
|
||||||
|
- arcadia
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
uploads:
|
ollama_models:
|
||||||
|
open_webui_data:
|
||||||
superset_home:
|
superset_home:
|
||||||
|
erpnext_db:
|
||||||
|
erpnext_sites:
|
||||||
|
erpnext_logs:
|
||||||
neo4j_data:
|
neo4j_data:
|
||||||
neo4j_logs:
|
neo4j_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
arcadia:
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,6 @@ model_list:
|
||||||
model: ollama/llama3.2:3b
|
model: ollama/llama3.2:3b
|
||||||
api_base: os.environ/OLLAMA_BASE_URL
|
api_base: os.environ/OLLAMA_BASE_URL
|
||||||
|
|
||||||
- model_name: deepseek-r1
|
|
||||||
litellm_params:
|
|
||||||
model: ollama/deepseek-r1:14b
|
|
||||||
api_base: os.environ/OLLAMA_BASE_URL
|
|
||||||
|
|
||||||
- model_name: arcadia-agent
|
|
||||||
litellm_params:
|
|
||||||
model: groq/llama-3.3-70b-versatile
|
|
||||||
api_key: os.environ/GROQ_API_KEY
|
|
||||||
|
|
||||||
- model_name: nomic-embed-text
|
- model_name: nomic-embed-text
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: ollama/nomic-embed-text
|
model: ollama/nomic-embed-text
|
||||||
|
|
@ -70,28 +60,31 @@ model_list:
|
||||||
# api_key: os.environ/ANTHROPIC_API_KEY
|
# api_key: os.environ/ANTHROPIC_API_KEY
|
||||||
|
|
||||||
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
|
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
|
||||||
- model_name: groq-llama
|
# - model_name: groq-llama
|
||||||
litellm_params:
|
# litellm_params:
|
||||||
model: groq/llama-3.3-70b-versatile
|
# model: groq/llama-3.3-70b-versatile
|
||||||
api_key: os.environ/GROQ_API_KEY
|
# api_key: os.environ/GROQ_API_KEY
|
||||||
|
|
||||||
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
|
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
|
||||||
# Prioridade: DeepSeek R1 14B (TIER 2) → llama3.2 (fallback rápido)
|
# Prioridade: LLMFit (TIER 1) → Ollama (TIER 2 — fallback)
|
||||||
- model_name: arcadia-default
|
- model_name: arcadia-default
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: ollama/deepseek-r1:14b
|
model: openai/llama3.2:3b
|
||||||
api_base: os.environ/OLLAMA_BASE_URL
|
api_base: os.environ/LLMFIT_BASE_URL
|
||||||
|
api_key: os.environ/LLMFIT_API_KEY
|
||||||
|
|
||||||
router_settings:
|
router_settings:
|
||||||
routing_strategy: least-busy
|
routing_strategy: least-busy
|
||||||
fallbacks:
|
fallbacks:
|
||||||
- {"gpt-4o": ["arcadia-agent"]}
|
- {"gpt-4o": ["llama3.2"]}
|
||||||
- {"gpt-4o-mini": ["arcadia-agent"]}
|
- {"gpt-4o-mini": ["llama3.2"]}
|
||||||
|
- {"arcadia-default": ["llama3.2"]}
|
||||||
|
- {"arcadia-finetuned": ["llama3.2"]}
|
||||||
- {"arcadia-embed": ["nomic-embed-text"]}
|
- {"arcadia-embed": ["nomic-embed-text"]}
|
||||||
|
|
||||||
litellm_settings:
|
litellm_settings:
|
||||||
drop_params: true
|
drop_params: true
|
||||||
request_timeout: 300
|
request_timeout: 120
|
||||||
set_verbose: false
|
set_verbose: false
|
||||||
|
|
||||||
general_settings:
|
general_settings:
|
||||||
|
|
|
||||||
|
|
@ -10,36 +10,17 @@ ARCADIA_DB_URL="${ARCADIA_DATABASE_URL:-postgresql://arcadia:arcadia123@db:5432/
|
||||||
|
|
||||||
echo "[Superset Init] Aguardando PostgreSQL..."
|
echo "[Superset Init] Aguardando PostgreSQL..."
|
||||||
until python -c "
|
until python -c "
|
||||||
import psycopg2, os, sys, re
|
import psycopg2, os, sys
|
||||||
try:
|
try:
|
||||||
# Health check no banco 'postgres' (sempre existe), não no 'superset' que ainda não existe
|
url = os.environ.get('DATABASE_URL', 'postgresql://arcadia:arcadia123@db:5432/arcadia_superset')
|
||||||
url = os.environ.get('DATABASE_URL', 'postgresql://arcadia:SuaSenhaSegura@db:5432/superset')
|
psycopg2.connect(url)
|
||||||
check_url = re.sub(r'/[^/?]+(\?.*)?$', '/postgres', url)
|
sys.exit(0)
|
||||||
psycopg2.connect(check_url)
|
except: sys.exit(1)
|
||||||
except Exception:
|
|
||||||
sys.exit(1)
|
|
||||||
" 2>/dev/null; do
|
" 2>/dev/null; do
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "[Superset Init] PostgreSQL disponível!"
|
echo "[Superset Init] PostgreSQL disponível!"
|
||||||
|
|
||||||
echo "[Superset Init] Criando banco superset se necessário..."
|
|
||||||
python - <<'DBEOF'
|
|
||||||
import psycopg2, os, re
|
|
||||||
url = os.environ.get('DATABASE_URL', 'postgresql://arcadia:SuaSenhaSegura@db:5432/superset')
|
|
||||||
conn_url = re.sub(r'/[^/?]+(\?.*)?$', '/postgres', url)
|
|
||||||
conn = psycopg2.connect(conn_url)
|
|
||||||
conn.autocommit = True
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = 'superset'")
|
|
||||||
if not cur.fetchone():
|
|
||||||
cur.execute("CREATE DATABASE superset")
|
|
||||||
print("[Superset Init] Banco 'superset' criado.")
|
|
||||||
else:
|
|
||||||
print("[Superset Init] Banco 'superset' já existe.")
|
|
||||||
conn.close()
|
|
||||||
DBEOF
|
|
||||||
|
|
||||||
echo "[Superset Init] Rodando migrações do banco..."
|
echo "[Superset Init] Rodando migrações do banco..."
|
||||||
superset db upgrade
|
superset db upgrade
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
|
||||||
|
|
||||||
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
||||||
APP_NAME = "Arcádia Insights"
|
APP_NAME = "Arcádia Insights"
|
||||||
APP_ICON = "/static/assets/images/arcadia_logo.png"
|
|
||||||
APP_ICON_WIDTH = 150
|
|
||||||
LOGO_TARGET_PATH = "/"
|
LOGO_TARGET_PATH = "/"
|
||||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||||
|
|
||||||
|
|
@ -57,42 +55,8 @@ SESSION_COOKIE_SECURE = False # True em prod com HTTPS
|
||||||
WTF_CSRF_ENABLED = True
|
WTF_CSRF_ENABLED = True
|
||||||
WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log"]
|
WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log"]
|
||||||
|
|
||||||
# ── Embedding cross-origin ────────────────────────────────────────────────────
|
|
||||||
# Desabilita Flask-Talisman para permitir embedding via iframe cross-origin
|
|
||||||
# (HTTPS é gerenciado pelo Traefik upstream)
|
|
||||||
TALISMAN_ENABLED = False
|
|
||||||
HTTP_HEADERS = {}
|
|
||||||
X_FRAME_OPTIONS = "ALLOWALL"
|
|
||||||
|
|
||||||
# ── Guest Token (para embedding) ──────────────────────────────────────────────
|
# ── Guest Token (para embedding) ──────────────────────────────────────────────
|
||||||
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutos — frontend renova automaticamente
|
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutos — frontend renova automaticamente
|
||||||
GUEST_ROLE_NAME = "Public"
|
GUEST_ROLE_NAME = "Public"
|
||||||
GUEST_TOKEN_JWT_ALGO = "HS256"
|
GUEST_TOKEN_JWT_ALGO = "HS256"
|
||||||
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
|
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
|
||||||
|
|
||||||
# ── Fix: g.user a partir do Bearer JWT ────────────────────────────────────────
|
|
||||||
# Quando o papel Public tem can_read on Dashboard, o decorator protect() do FAB
|
|
||||||
# trata o endpoint como público e pula a verificação JWT, deixando g.user como
|
|
||||||
# AnonymousUser. Este hook corrige isso: se há um Bearer JWT válido e g.user é
|
|
||||||
# anônimo, carrega o usuário do JWT antes do handler ser invocado.
|
|
||||||
def FLASK_APP_MUTATOR(app): # noqa
|
|
||||||
@app.before_request
|
|
||||||
def _set_g_user_from_bearer_jwt():
|
|
||||||
from flask import g, request # noqa
|
|
||||||
user = getattr(g, "user", None)
|
|
||||||
if user is not None and not getattr(user, "is_anonymous", True):
|
|
||||||
return # já autenticado (ex: guest token via request_loader)
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if not auth.startswith("Bearer "):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
from flask_jwt_extended import decode_token # noqa
|
|
||||||
decoded = decode_token(auth[7:])
|
|
||||||
user_id = decoded.get("sub")
|
|
||||||
if user_id is not None:
|
|
||||||
from superset.extensions import security_manager as sm # noqa
|
|
||||||
jwt_user = sm.get_user_by_id(int(user_id))
|
|
||||||
if jwt_user and jwt_user.is_active:
|
|
||||||
g.user = jwt_user
|
|
||||||
except Exception: # noqa
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,63 @@
|
||||||
|
-- Migration 0003: Agentic Suite — Skills (POO) + Skill Executions
|
||||||
|
-- Fase 1 do planejamento estratégico Arcádia Agentic Suite
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "skills" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" INTEGER,
|
||||||
|
"company_id" INTEGER,
|
||||||
|
"user_id" VARCHAR,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"slug" VARCHAR(255) NOT NULL,
|
||||||
|
"namespace" VARCHAR(20) NOT NULL DEFAULT 'tenant',
|
||||||
|
"description" TEXT,
|
||||||
|
"version" VARCHAR(20) NOT NULL DEFAULT '1.0.0',
|
||||||
|
"tags" TEXT[] DEFAULT '{}',
|
||||||
|
"extends" TEXT[] DEFAULT '{}',
|
||||||
|
"implements" TEXT[] DEFAULT '{}',
|
||||||
|
"dependencies" JSONB DEFAULT '[]',
|
||||||
|
"execute_visibility" VARCHAR(20) DEFAULT 'public',
|
||||||
|
"parameters_visibility" VARCHAR(20) DEFAULT 'public',
|
||||||
|
"trigger_type" VARCHAR(30),
|
||||||
|
"trigger_config" JSONB,
|
||||||
|
"parameters_schema" JSONB DEFAULT '{}',
|
||||||
|
"return_schema" JSONB DEFAULT '{}',
|
||||||
|
"body" TEXT,
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
"is_system" BOOLEAN DEFAULT false,
|
||||||
|
"created_by" VARCHAR,
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índice único: mesmo slug não pode repetir dentro do mesmo tenant
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx"
|
||||||
|
ON "skills"("tenant_id", "slug")
|
||||||
|
WHERE "tenant_id" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Skills de sistema (sem tenant) também precisam slug único
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "skills_system_slug_idx"
|
||||||
|
ON "skills"("slug")
|
||||||
|
WHERE "tenant_id" IS NULL AND "is_system" = true;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "skill_executions" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"skill_id" UUID NOT NULL REFERENCES "skills"("id") ON DELETE CASCADE,
|
||||||
|
"tenant_id" INTEGER,
|
||||||
|
"triggered_by" VARCHAR(30) NOT NULL DEFAULT 'manual',
|
||||||
|
"triggered_by_user_id" VARCHAR,
|
||||||
|
"triggered_by_agent_id" VARCHAR(100),
|
||||||
|
"parameters" JSONB DEFAULT '{}',
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
"duration_ms" INTEGER,
|
||||||
|
"steps_count" INTEGER DEFAULT 0,
|
||||||
|
"correlation_id" UUID DEFAULT gen_random_uuid(),
|
||||||
|
"audit_hash" VARCHAR(64),
|
||||||
|
"started_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"completed_at" TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_executions_skill_id_idx" ON "skill_executions"("skill_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_executions_tenant_id_idx" ON "skill_executions"("tenant_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_executions_started_at_idx" ON "skill_executions"("started_at" DESC);
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 0004: OpenClaw — Detecção de Padrões e Sugestões de Skills Emergentes
|
||||||
|
-- Fase 4 do planejamento estratégico Arcádia Agentic Suite
|
||||||
|
|
||||||
|
-- Tabela de padrões detectados
|
||||||
|
CREATE TABLE IF NOT EXISTS "detected_patterns" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" INTEGER REFERENCES "tenants"("id"),
|
||||||
|
"user_id" VARCHAR REFERENCES "users"("id"),
|
||||||
|
|
||||||
|
-- Padrão detectado
|
||||||
|
"action_type" VARCHAR(100) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"frequency" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"confidence" NUMERIC(4, 3) NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
-- Janela de análise
|
||||||
|
"first_seen_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_seen_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Metadados do padrão (contexto, módulos envolvidos, etc.)
|
||||||
|
"metadata" JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Ciclo de vida: active | archived | converted
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "detected_patterns_tenant_user_idx"
|
||||||
|
ON "detected_patterns"("tenant_id", "user_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "detected_patterns_action_type_idx"
|
||||||
|
ON "detected_patterns"("action_type");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "detected_patterns_status_idx"
|
||||||
|
ON "detected_patterns"("status");
|
||||||
|
|
||||||
|
-- Tabela de sugestões de skills emergentes
|
||||||
|
CREATE TABLE IF NOT EXISTS "skill_suggestions" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"pattern_id" UUID REFERENCES "detected_patterns"("id") ON DELETE CASCADE,
|
||||||
|
"tenant_id" INTEGER REFERENCES "tenants"("id"),
|
||||||
|
"user_id" VARCHAR REFERENCES "users"("id"),
|
||||||
|
|
||||||
|
-- Skill sugerida
|
||||||
|
"suggested_skill_name" VARCHAR(200) NOT NULL,
|
||||||
|
"suggested_description" TEXT,
|
||||||
|
"estimated_automation" TEXT,
|
||||||
|
"confidence" NUMERIC(4, 3) NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
-- Skill gerada (após confirmação)
|
||||||
|
"generated_skill_id" UUID REFERENCES "skills"("id"),
|
||||||
|
|
||||||
|
-- Estado da sugestão: pending | accepted | rejected | expired
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Rastreabilidade
|
||||||
|
"source" VARCHAR(50) NOT NULL DEFAULT 'openclaw',
|
||||||
|
"reviewed_by" VARCHAR REFERENCES "users"("id"),
|
||||||
|
"reviewed_at" TIMESTAMP,
|
||||||
|
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_suggestions_pattern_id_idx"
|
||||||
|
ON "skill_suggestions"("pattern_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_suggestions_tenant_user_idx"
|
||||||
|
ON "skill_suggestions"("tenant_id", "user_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "skill_suggestions_status_idx"
|
||||||
|
ON "skill_suggestions"("status");
|
||||||
|
|
@ -8,6 +8,20 @@
|
||||||
"when": 1769121114659,
|
"when": 1769121114659,
|
||||||
"tag": "0000_low_tiger_shark",
|
"tag": "0000_low_tiger_shark",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741824000000,
|
||||||
|
"tag": "0001_whatsapp_auto_reply_config",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741824060000,
|
||||||
|
"tag": "0002_tenant_isolation",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,10 @@
|
||||||
"build": "tsx script/build.ts",
|
"build": "tsx script/build.ts",
|
||||||
"start": "NODE_ENV=production node dist/index.cjs",
|
"start": "NODE_ENV=production node dist/index.cjs",
|
||||||
"check": "tsc",
|
"check": "tsc",
|
||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:openclaw": "vitest run tests/openclaw"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
|
@ -72,7 +75,6 @@
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn==0.27.0
|
||||||
|
pandas==2.1.0
|
||||||
|
numpy==1.26.0
|
||||||
|
httpx==0.26.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
pydantic==2.5.0
|
||||||
|
chromadb==0.4.22
|
||||||
|
openai==1.12.0
|
||||||
13
replit.md
13
replit.md
|
|
@ -46,27 +46,24 @@ The Arcádia Suite employs a 4-layer hybrid architecture:
|
||||||
* **Arcádia Suite's 6-Layer Architecture:** Apps (Core, Business, Segment), Structural Modules (Financial, Accounting, Fiscal, HR), Operational Execution (Retail, Services, ERPs), Intelligence (Manus, Knowledge Graph, Automations), Platform (Central API, Hub API, MCP/A2A, IDE, Data Science), and Consulting (Process Compass, Production, Support).
|
* **Arcádia Suite's 6-Layer Architecture:** Apps (Core, Business, Segment), Structural Modules (Financial, Accounting, Fiscal, HR), Operational Execution (Retail, Services, ERPs), Intelligence (Manus, Knowledge Graph, Automations), Platform (Central API, Hub API, MCP/A2A, IDE, Data Science), and Consulting (Process Compass, Production, Support).
|
||||||
* **Unified Communication Center:** Integrates a WhatsApp Attendance System (multi-session, real-time sync, ticket management, AI auto-replies) and an Internal Chat System, feeding all communications into the Knowledge Graph.
|
* **Unified Communication Center:** Integrates a WhatsApp Attendance System (multi-session, real-time sync, ticket management, AI auto-replies) and an Internal Chat System, feeding all communications into the Knowledge Graph.
|
||||||
* **Learning System & Knowledge Graph:** The system learns from all interactions, storing Q&A and patterns. A semantic Knowledge Graph connects all business information via nodes and edges for insights.
|
* **Learning System & Knowledge Graph:** The system learns from all interactions, storing Q&A and patterns. A semantic Knowledge Graph connects all business information via nodes and edges for insights.
|
||||||
* **Manus Autonomous Agent (Cérebro Central):** Central AI brain (GPT-4o) powering all intelligence via ManusIntelligence singleton. All 6 Dev Center agents (Architect, Generator, Validator, Executor, Researcher, Evolution) route their AI thinking through ManusIntelligence, which provides context enrichment via ToolManager (code search, file reading). Executes multi-step tasks using a thought-action-observation loop with 56+ tools (web search, knowledge/ERP query, file I/O, shell commands, semantic search). Visible in Casa de Máquinas as "Motor IA" with health endpoint at `/api/manus/health` showing model, metrics (calls, tokens, errors), capabilities, and uptime.
|
* **Manus Autonomous Agent:** Executes multi-step tasks using a thought-action-observation loop with various tools (web search, knowledge/ERP query, calculations, messaging, reporting, scheduling).
|
||||||
* **Scientist Module:** An AI auto-programming component for data analysis, code generation (Python/SQL) in a sandbox, and solution storage.
|
* **Scientist Module:** An AI auto-programming component for data analysis, code generation (Python/SQL) in a sandbox, and solution storage.
|
||||||
* **Arcádia Fisco (Fiscal Engine):** Centralized compliance motor for Brazilian tax regulations (NCMs, CFOPs, CESTs, tax groups, digital certificates), integrating with SEFAZ webservices for NF-e/NFC-e issuance.
|
* **Arcádia Fisco (Fiscal Engine):** Centralized compliance motor for Brazilian tax regulations (NCMs, CFOPs, CESTs, tax groups, digital certificates), integrating with SEFAZ webservices for NF-e/NFC-e issuance.
|
||||||
* **Arcádia SOE (Sistema Operacional Empresarial):** The central business operating system (kernel) accessible via `/soe`. Contains all canonical business domains (People, Products, Sales, Purchases, Inventory, Financial, Fiscal, CRM, Projects, Quality, Governance). Apps (Retail, Engineering, Food Service, etc.) extend SOE with segment-specific experiences. Motors (Plus, ERPNext, Fisco, BI) are execution engines selected via server-side adapter pattern. API at `/api/soe/*` (backward-compatible alias from `/api/erp/*`). Context: `SoeMotorContext` replaces `ErpProfileContext`. Legacy file `ERP.tsx` preserved for reference.
|
* **Arcádia ERP (Gestão Empresarial):** A complete ERP module integrating ERPNext with Arcádia's intelligence, accessible via `/erp`. Includes dashboards, unified people module, inventory, sales, purchases, financial management, AI-driven reports, and integration with Arcádia Fisco and Manus AI.
|
||||||
* **Arcádia Plus (ERP Laravel):** A standalone Laravel/PHP ERP, accessible via `/plus`, with extensive features including NF-e/NFC-e/CT-e/MDF-e, POS, digital menu, service orders, financial management, stock traceability, CRM, and integrations with e-commerce platforms. Uses an isolated PostgreSQL schema.
|
* **Arcádia Plus (ERP Laravel):** A standalone Laravel/PHP ERP, accessible via `/plus`, with extensive features including NF-e/NFC-e/CT-e/MDF-e, POS, digital menu, service orders, financial management, stock traceability, CRM, and integrations with e-commerce platforms. Uses an isolated PostgreSQL schema.
|
||||||
* **Business Intelligence (Arcádia Insights):** Provides data visualization and analysis, powered by the BI Engine (FastAPI, port 8004) with SQL query execution, chart-data generation, micro-BI API, caching layer, and Pandas analysis.
|
* **Business Intelligence (Arcádia Insights):** Provides data visualization and analysis, powered by the BI Engine (FastAPI, port 8004) with SQL query execution, chart-data generation, micro-BI API, caching layer, and Pandas analysis.
|
||||||
* **Automation Engine:** FastAPI service (port 8005) providing cron scheduler, event bus, and workflow executor for the Automations module.
|
* **Automation Engine:** FastAPI service (port 8005) providing cron scheduler, event bus, and workflow executor for the Automations module.
|
||||||
* **Communication Engine (Motor de Comunicação):** Node/TypeScript service (port 8006) unifying XOS CRM, Arcádia CRM, WhatsApp, and Email into a single managed engine. Provides unified API for contacts, threads, messages, channels, queues, quick messages, stats, and agent context (360° view). Emits events to `comm_events` table consumed by Knowledge Graph and AI agents. Proxy at `/api/comm/*`. Canonical tables: `comm_contacts`, `comm_threads`, `comm_messages`, `comm_channels`, `comm_queues`, `comm_quick_messages`, `comm_events`. Backward-compatible via reference fields (`xos_contact_id`, `crm_client_id`, etc.).
|
* **Communication Engine (Motor de Comunicação):** Node/TypeScript service (port 8006) unifying XOS CRM, Arcádia CRM, WhatsApp, and Email into a single managed engine. Provides unified API for contacts, threads, messages, channels, queues, quick messages, stats, and agent context (360° view). Emits events to `comm_events` table consumed by Knowledge Graph and AI agents. Proxy at `/api/comm/*`. Canonical tables: `comm_contacts`, `comm_threads`, `comm_messages`, `comm_channels`, `comm_queues`, `comm_quick_messages`, `comm_events`. Backward-compatible via reference fields (`xos_contact_id`, `crm_client_id`, etc.).
|
||||||
* **Casa de Máquinas (Engine Room):** Unified control panel at `/engine-room` showing real-time status of all engines (Manus IA, Plus 8080, Contábil 8003, Fiscal 8002, BI 8004, Automation 8005, Communication 8006) and XOS agents. Manus IA has a dedicated tab with metrics (model, calls, tokens, errors, uptime, capabilities). API at `/api/engine-room/*`.
|
* **Casa de Máquinas (Engine Room):** Unified control panel at `/engine-room` showing real-time status of all engines (Plus 8080, Contábil 8003, Fiscal 8002, BI 8004, Automation 8005, Communication 8006) and XOS agents. API at `/api/engine-room/*`.
|
||||||
* **IDE:** Integrated development environment with Monaco Editor and Xterm.js.
|
* **IDE:** Integrated development environment with Monaco Editor and Xterm.js.
|
||||||
* **Module Activation System:** Per-tenant module management via `tenants.features` JSONB column (TenantFeatures type). API at `GET/PUT /api/soe/tenant/modules`. Admin → Módulos tab provides toggle switches for each module. Backend gating middleware (`requireModule`) blocks sync endpoints when modules are disabled (returns 403 with actionable message). SOE Sync Dashboard at `/soe` tab "Sincronização" shows motor status, enabled/disabled state, and manual sync buttons. Frontend hook `useTenantFeatures()` provides feature checks. Feature keys: ide, whatsapp, crm, erp, bi, manus, retail, plus, fisco, cockpit, compass, production, support, xosCrm, centralApis, comunidades, biblioteca.
|
|
||||||
* **Multi-Tenant Architecture:** Supports a hierarchical structure for Master, Partners, and Clients.
|
* **Multi-Tenant Architecture:** Supports a hierarchical structure for Master, Partners, and Clients.
|
||||||
* **Agentic Interoperability Protocols:** Implements MCP (Model Context Protocol) for tool exposure (`/api/mcp/v1/`), A2A (Agent to Agent Protocol) for bidirectional communication (`/api/a2a/v1/`), and planned AP2 (Agent Payment Protocol) and UCP (Unified Commerce Protocol). Authentication uses `X-API-Key`.
|
* **Agentic Interoperability Protocols:** Implements MCP (Model Context Protocol) for tool exposure (`/api/mcp/v1/`), A2A (Agent to Agent Protocol) for bidirectional communication (`/api/a2a/v1/`), and planned AP2 (Agent Payment Protocol) and UCP (Unified Commerce Protocol). Authentication uses `X-API-Key`.
|
||||||
* **Autonomous Development:** Integrated with GitHub for automatic commits, branch creation, pull requests, and repository analysis using a `ToolManager` system.
|
* **Autonomous Development:** Integrated with GitHub for automatic commits, branch creation, pull requests, and repository analysis using a `ToolManager` system.
|
||||||
* **XOS Governance Layer:** Policy evaluation engine with fail-closed security, immutable audit trail, tool registry (auto-synced from ToolManager), skill registry with usage tracking, and contract registry. Integrated into BaseBlackboardAgent and ToolManager for automatic policy checks. API at `/api/governance/*`. 5 security policies: critical file protection, read permissions, destructive command blocking, human approval for production deploys, automatic staging with validation score threshold. Phase 3 adds PostgreSQL Job Queue (xos_job_queue table with priority, retry, dead-letter), Agent Metrics (xos_agent_metrics), 6th Researcher agent, and a visual Governance Dashboard at `/xos/governance` with real-time stats, audit trail, policies, tools, skills, jobs, and agent monitoring. Phase 4 adds Autonomous Pipeline Orchestrator (PipelineOrchestrator.ts) that chains all 6 agents: Portuguese prompt → Architect (design) → Generator (codegen) → Validator (typecheck) → Executor (staging) → Evolution (learn). Staging review system (xos_staging_changes + xos_dev_pipelines tables) with explicit user approval before applying code. SSE streaming for real-time progress. Pipeline UI integrated as "Pipeline" tab within Dev Center (`/dev-center`) with enhanced prompt input (image paste, file attachment, plan section), live timeline, staged code review/approve/reject. `/xos/pipeline` redirects to Dev Center. API at `/api/xos/pipeline`.
|
* **XOS Governance Layer:** Policy evaluation engine with fail-closed security, immutable audit trail, tool registry (auto-synced from ToolManager), skill registry with usage tracking, and contract registry. Integrated into BaseBlackboardAgent and ToolManager for automatic policy checks. API at `/api/governance/*`. 5 security policies: critical file protection, read permissions, destructive command blocking, human approval for production deploys, automatic staging with validation score threshold. Phase 3 adds PostgreSQL Job Queue (xos_job_queue table with priority, retry, dead-letter), Agent Metrics (xos_agent_metrics), 6th Researcher agent, and a visual Governance Dashboard at `/xos/governance` with real-time stats, audit trail, policies, tools, skills, jobs, and agent monitoring. Phase 4 adds Autonomous Pipeline Orchestrator (PipelineOrchestrator.ts) that chains all 6 agents: Portuguese prompt → Architect (design) → Generator (codegen) → Validator (typecheck) → Executor (staging) → Evolution (learn). Staging review system (xos_staging_changes + xos_dev_pipelines tables) with explicit user approval before applying code. SSE streaming for real-time progress. Pipeline UI at `/xos/pipeline` with prompt submission, live timeline, and staged code review/approve/reject. API at `/api/xos/pipeline`.
|
||||||
|
|
||||||
* **Modular Dev Center Architecture:** The XOS Pipeline agents can autonomously create complete modules (database schemas, API routes, and UI pages) using a modular system. Module schemas go to `shared/schemas/{moduleName}.ts` and are auto-registered in the main schema via `shared/schemas/index.ts`. Module routes go to `server/modules/{moduleName}.ts` and are auto-loaded at startup via `server/modules/loader.ts`, mounted at `/api/modules/{moduleName}`. After pipeline approval, the migrator (`server/modules/migrator.ts`) auto-registers schemas and runs `drizzle-kit push`. Core files (`shared/schema.ts`, `server/routes.ts`, `db/index.ts`, `shared/schemas/index.ts`, `shared/schemas/loader.ts`, `server/modules/loader.ts`) remain protected. Agent prompts (Architect, Generator) include instructions for the modular pattern. API at `/api/xos/pipeline/modules/status` shows registered schemas and loaded routes.
|
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
|
|
||||||
* **OpenAI API:** For AI capabilities via ManusIntelligence (GPT-4o for Dev Center agents) and auto-replies (gpt-4o-mini for WhatsApp).
|
* **OpenAI API:** For AI capabilities, including auto-replies (`gpt-4o-mini`) and the Scientist Module.
|
||||||
* **Baileys Library:** For WhatsApp integration and multi-session management.
|
* **Baileys Library:** For WhatsApp integration and multi-session management.
|
||||||
* **nfelib (Python):** For Brazilian fiscal document (NF-e/NFC-e) issuance, validation, and SEFAZ communication.
|
* **nfelib (Python):** For Brazilian fiscal document (NF-e/NFC-e) issuance, validation, and SEFAZ communication.
|
||||||
* **PostgreSQL:** Primary database, managed with Drizzle ORM.
|
* **PostgreSQL:** Primary database, managed with Drizzle ORM.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { db } from "../../db/index";
|
import { db } from "../../db/index";
|
||||||
import { eq, desc, and, or, isNull, inArray, sql } from "drizzle-orm";
|
import { eq, desc, and, or, isNull, inArray, sql } from "drizzle-orm";
|
||||||
import { users, profiles, crmPartners, crmClients, tenants, tenantPlans, partnerClients, partnerCommissions, tenantEmpresas, insertTenantSchema, insertTenantPlanSchema, insertPartnerClientSchema, insertTenantEmpresaSchema } from "@shared/schema";
|
import { users, profiles, crmPartners, crmClients, tenants, tenantPlans, partnerClients, partnerCommissions, insertTenantSchema, insertTenantPlanSchema, insertPartnerClientSchema } from "@shared/schema";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ router.use((req: Request, res: Response, next) => {
|
||||||
return res.status(401).json({ error: "Não autorizado" });
|
return res.status(401).json({ error: "Não autorizado" });
|
||||||
}
|
}
|
||||||
const user = req.user as any;
|
const user = req.user as any;
|
||||||
if (user.role !== "admin" && user.role !== "master") {
|
if (user.role !== "admin") {
|
||||||
return res.status(403).json({ error: "Acesso negado. Apenas administradores." });
|
return res.status(403).json({ error: "Acesso negado. Apenas administradores." });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|
@ -317,25 +317,10 @@ router.get("/tenants", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: resolve plan features for a tenant based on plan code
|
|
||||||
async function resolvePlanFeatures(planCode: string): Promise<Record<string, any> | null> {
|
|
||||||
const [plan] = await db.select().from(tenantPlans).where(eq(tenantPlans.code, planCode));
|
|
||||||
if (!plan || !plan.features) return null;
|
|
||||||
return plan.features as Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/tenants - Create new tenant
|
// POST /api/admin/tenants - Create new tenant
|
||||||
router.post("/tenants", async (req: Request, res: Response) => {
|
router.post("/tenants", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const validated = insertTenantSchema.parse(req.body);
|
const validated = insertTenantSchema.parse(req.body);
|
||||||
|
|
||||||
if (validated.plan && !validated.features) {
|
|
||||||
const planFeatures = await resolvePlanFeatures(validated.plan);
|
|
||||||
if (planFeatures) {
|
|
||||||
validated.features = planFeatures as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tenant] = await db.insert(tenants).values(validated).returning();
|
const [tenant] = await db.insert(tenants).values(validated).returning();
|
||||||
res.status(201).json(tenant);
|
res.status(201).json(tenant);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -349,41 +334,19 @@ router.patch("/tenants/:id", async (req: Request, res: Response) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const user = req.user as any;
|
const user = req.user as any;
|
||||||
|
|
||||||
|
// Verificar permissão de acesso
|
||||||
if (!await canAccessTenant(user, id)) {
|
if (!await canAccessTenant(user, id)) {
|
||||||
return res.status(403).json({ error: "Sem permissão para modificar este tenant" });
|
return res.status(403).json({ error: "Sem permissão para modificar este tenant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, email, phone, plan, status, tenantType, parentTenantId, partnerCode, commissionRate, maxUsers, maxStorageMb, features, billingEmail, trialEndsAt } = req.body;
|
const { name, email, phone, plan, status, tenantType, parentTenantId, partnerCode, commissionRate, maxUsers, maxStorageMb, features, billingEmail, trialEndsAt } = req.body;
|
||||||
|
|
||||||
let resolvedFeatures = features;
|
|
||||||
if (plan && !features) {
|
|
||||||
const [currentTenant] = await db.select().from(tenants).where(eq(tenants.id, id));
|
|
||||||
if (currentTenant && currentTenant.plan !== plan) {
|
|
||||||
const planFeatures = await resolvePlanFeatures(plan);
|
|
||||||
if (planFeatures) {
|
|
||||||
resolvedFeatures = planFeatures;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = { updatedAt: new Date() };
|
|
||||||
if (name !== undefined) updateData.name = name;
|
|
||||||
if (email !== undefined) updateData.email = email;
|
|
||||||
if (phone !== undefined) updateData.phone = phone;
|
|
||||||
if (plan !== undefined) updateData.plan = plan;
|
|
||||||
if (status !== undefined) updateData.status = status;
|
|
||||||
if (tenantType !== undefined) updateData.tenantType = tenantType;
|
|
||||||
if (parentTenantId !== undefined) updateData.parentTenantId = parentTenantId;
|
|
||||||
if (partnerCode !== undefined) updateData.partnerCode = partnerCode;
|
|
||||||
if (commissionRate !== undefined) updateData.commissionRate = commissionRate;
|
|
||||||
if (maxUsers !== undefined) updateData.maxUsers = maxUsers;
|
|
||||||
if (maxStorageMb !== undefined) updateData.maxStorageMb = maxStorageMb;
|
|
||||||
if (resolvedFeatures !== undefined) updateData.features = resolvedFeatures;
|
|
||||||
if (billingEmail !== undefined) updateData.billingEmail = billingEmail;
|
|
||||||
if (trialEndsAt !== undefined) updateData.trialEndsAt = trialEndsAt;
|
|
||||||
|
|
||||||
const [tenant] = await db.update(tenants)
|
const [tenant] = await db.update(tenants)
|
||||||
.set(updateData)
|
.set({
|
||||||
|
name, email, phone, plan, status, tenantType, parentTenantId, partnerCode,
|
||||||
|
commissionRate, maxUsers, maxStorageMb, features, billingEmail, trialEndsAt,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
.where(eq(tenants.id, id))
|
.where(eq(tenants.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -504,121 +467,6 @@ router.delete("/plans/:id", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/admin/plans/:id/propagate - Apply plan features to all tenants with this plan
|
|
||||||
router.post("/plans/:id/propagate", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
const [plan] = await db.select().from(tenantPlans).where(eq(tenantPlans.id, id));
|
|
||||||
if (!plan) return res.status(404).json({ error: "Plano não encontrado" });
|
|
||||||
if (!plan.features) return res.status(400).json({ error: "Plano sem módulos definidos" });
|
|
||||||
|
|
||||||
const affected = await db.update(tenants)
|
|
||||||
.set({ features: plan.features, maxUsers: plan.maxUsers, maxStorageMb: plan.maxStorageMb, updatedAt: new Date() })
|
|
||||||
.where(eq(tenants.plan, plan.code))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
res.json({ propagated: affected.length, planCode: plan.code });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/admin/plans/seed - Seed default plans
|
|
||||||
router.post("/plans/seed", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const existing = await db.select().from(tenantPlans);
|
|
||||||
if (existing.length > 0) {
|
|
||||||
return res.json({ message: "Planos já existem", count: existing.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPlans: any[] = [
|
|
||||||
{
|
|
||||||
code: "free",
|
|
||||||
name: "Gratuito",
|
|
||||||
description: "Plano básico gratuito para experimentar",
|
|
||||||
tenantType: "client",
|
|
||||||
maxUsers: 2,
|
|
||||||
maxStorageMb: 500,
|
|
||||||
monthlyPrice: 0,
|
|
||||||
yearlyPrice: 0,
|
|
||||||
trialDays: 14,
|
|
||||||
sortOrder: 1,
|
|
||||||
features: { erp: true, crm: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "starter",
|
|
||||||
name: "Starter",
|
|
||||||
description: "Para pequenas empresas começando",
|
|
||||||
tenantType: "client",
|
|
||||||
maxUsers: 5,
|
|
||||||
maxStorageMb: 2000,
|
|
||||||
monthlyPrice: 9900,
|
|
||||||
yearlyPrice: 99000,
|
|
||||||
trialDays: 14,
|
|
||||||
sortOrder: 2,
|
|
||||||
features: { erp: true, crm: true, bi: true, fisco: true, whatsapp: true, compass: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "pro",
|
|
||||||
name: "Profissional",
|
|
||||||
description: "Para empresas em crescimento",
|
|
||||||
tenantType: "client",
|
|
||||||
maxUsers: 15,
|
|
||||||
maxStorageMb: 10000,
|
|
||||||
monthlyPrice: 29900,
|
|
||||||
yearlyPrice: 299000,
|
|
||||||
trialDays: 14,
|
|
||||||
sortOrder: 3,
|
|
||||||
features: { erp: true, crm: true, bi: true, fisco: true, retail: true, plus: true, whatsapp: true, manus: true, cockpit: true, compass: true, support: true, biblioteca: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "enterprise",
|
|
||||||
name: "Enterprise",
|
|
||||||
description: "Para grandes empresas",
|
|
||||||
tenantType: "client",
|
|
||||||
maxUsers: 100,
|
|
||||||
maxStorageMb: 50000,
|
|
||||||
monthlyPrice: 99900,
|
|
||||||
yearlyPrice: 999000,
|
|
||||||
trialDays: 30,
|
|
||||||
sortOrder: 4,
|
|
||||||
features: { erp: true, crm: true, bi: true, fisco: true, retail: true, plus: true, whatsapp: true, manus: true, ide: true, cockpit: true, compass: true, production: true, support: true, xosCrm: true, centralApis: true, comunidades: true, biblioteca: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "partner_starter",
|
|
||||||
name: "Parceiro Starter",
|
|
||||||
description: "Para consultores e pequenos integradores",
|
|
||||||
tenantType: "partner",
|
|
||||||
maxUsers: 10,
|
|
||||||
maxStorageMb: 5000,
|
|
||||||
monthlyPrice: 19900,
|
|
||||||
yearlyPrice: 199000,
|
|
||||||
trialDays: 14,
|
|
||||||
sortOrder: 5,
|
|
||||||
features: { erp: true, crm: true, bi: true, fisco: true, retail: true, whatsapp: true, compass: true, support: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "partner_pro",
|
|
||||||
name: "Parceiro Pro",
|
|
||||||
description: "Para integradores e consultorias",
|
|
||||||
tenantType: "partner",
|
|
||||||
maxUsers: 50,
|
|
||||||
maxStorageMb: 25000,
|
|
||||||
monthlyPrice: 49900,
|
|
||||||
yearlyPrice: 499000,
|
|
||||||
trialDays: 30,
|
|
||||||
sortOrder: 6,
|
|
||||||
features: { erp: true, crm: true, bi: true, fisco: true, retail: true, plus: true, whatsapp: true, manus: true, ide: true, cockpit: true, compass: true, production: true, support: true, xosCrm: true, centralApis: true, comunidades: true, biblioteca: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const created = await db.insert(tenantPlans).values(defaultPlans).returning();
|
|
||||||
res.status(201).json({ message: "Planos criados com sucesso", count: created.length, plans: created });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// PARTNER-CLIENT RELATIONSHIPS
|
// PARTNER-CLIENT RELATIONSHIPS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -770,106 +618,4 @@ router.get("/tenants/stats", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== TENANT EMPRESAS (Matriz/Filiais) ==========
|
|
||||||
|
|
||||||
router.get("/empresas", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { tenantId } = req.query;
|
|
||||||
const user = req.user as any;
|
|
||||||
|
|
||||||
let query;
|
|
||||||
if (tenantId) {
|
|
||||||
const tid = parseInt(tenantId as string);
|
|
||||||
if (!(await canAccessTenant(user, tid))) {
|
|
||||||
return res.status(403).json({ error: "Sem permissão para este tenant" });
|
|
||||||
}
|
|
||||||
query = await db.select().from(tenantEmpresas)
|
|
||||||
.where(eq(tenantEmpresas.tenantId, tid))
|
|
||||||
.orderBy(desc(tenantEmpresas.tipo), tenantEmpresas.razaoSocial);
|
|
||||||
} else {
|
|
||||||
const allowed = await getAllowedTenantIds(user);
|
|
||||||
if (allowed) {
|
|
||||||
query = await db.select().from(tenantEmpresas)
|
|
||||||
.where(inArray(tenantEmpresas.tenantId, allowed))
|
|
||||||
.orderBy(desc(tenantEmpresas.tipo), tenantEmpresas.razaoSocial);
|
|
||||||
} else {
|
|
||||||
query = await db.select().from(tenantEmpresas)
|
|
||||||
.orderBy(desc(tenantEmpresas.tipo), tenantEmpresas.razaoSocial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.json(query);
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/empresas/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const [empresa] = await db.select().from(tenantEmpresas)
|
|
||||||
.where(eq(tenantEmpresas.id, parseInt(req.params.id)));
|
|
||||||
if (!empresa) return res.status(404).json({ error: "Empresa não encontrada" });
|
|
||||||
|
|
||||||
const user = req.user as any;
|
|
||||||
if (!(await canAccessTenant(user, empresa.tenantId))) {
|
|
||||||
return res.status(403).json({ error: "Sem permissão" });
|
|
||||||
}
|
|
||||||
res.json(empresa);
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/empresas", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const data = insertTenantEmpresaSchema.parse(req.body);
|
|
||||||
const user = req.user as any;
|
|
||||||
if (!(await canAccessTenant(user, data.tenantId))) {
|
|
||||||
return res.status(403).json({ error: "Sem permissão para este tenant" });
|
|
||||||
}
|
|
||||||
const [empresa] = await db.insert(tenantEmpresas).values(data).returning();
|
|
||||||
res.json(empresa);
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(400).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.patch("/empresas/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const [existing] = await db.select().from(tenantEmpresas)
|
|
||||||
.where(eq(tenantEmpresas.id, parseInt(req.params.id)));
|
|
||||||
if (!existing) return res.status(404).json({ error: "Empresa não encontrada" });
|
|
||||||
|
|
||||||
const user = req.user as any;
|
|
||||||
if (!(await canAccessTenant(user, existing.tenantId))) {
|
|
||||||
return res.status(403).json({ error: "Sem permissão" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [empresa] = await db.update(tenantEmpresas)
|
|
||||||
.set({ ...req.body, updatedAt: new Date() })
|
|
||||||
.where(eq(tenantEmpresas.id, parseInt(req.params.id)))
|
|
||||||
.returning();
|
|
||||||
res.json(empresa);
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(400).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/empresas/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const [existing] = await db.select().from(tenantEmpresas)
|
|
||||||
.where(eq(tenantEmpresas.id, parseInt(req.params.id)));
|
|
||||||
if (!existing) return res.status(404).json({ error: "Empresa não encontrada" });
|
|
||||||
|
|
||||||
const user = req.user as any;
|
|
||||||
if (!(await canAccessTenant(user, existing.tenantId))) {
|
|
||||||
return res.status(403).json({ error: "Sem permissão" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(tenantEmpresas).where(eq(tenantEmpresas.id, parseInt(req.params.id)));
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ async function comparePasswords(supplied: string, stored: string) {
|
||||||
return timingSafeEqual(hashedBuf, suppliedBuf);
|
return timingSafeEqual(hashedBuf, suppliedBuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!process.env.SESSION_SECRET) {
|
||||||
|
console.warn("[auth] WARNING: SESSION_SECRET env var not set. Using insecure fallback. Set SESSION_SECRET in production.");
|
||||||
|
}
|
||||||
|
|
||||||
const sessionSettings: session.SessionOptions = {
|
const sessionSettings: session.SessionOptions = {
|
||||||
secret: process.env.SESSION_SECRET || "arcadia-browser-secret-key-2024",
|
secret: process.env.SESSION_SECRET || `arcadia-dev-${Math.random().toString(36)}`,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
store: storage.sessionStore,
|
store: storage.sessionStore,
|
||||||
|
|
|
||||||
|
|
@ -4,109 +4,10 @@ import { db } from "../../../db/index";
|
||||||
import { xosToolRegistry } from "@shared/schema";
|
import { xosToolRegistry } from "@shared/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const WRITE_TOOLS = new Set([
|
|
||||||
"write_file",
|
|
||||||
"github_commit",
|
|
||||||
"git_local_commit",
|
|
||||||
"run_command",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const READ_ONLY_TOOLS = new Set([
|
|
||||||
"read_file",
|
|
||||||
"list_directory",
|
|
||||||
"search_code",
|
|
||||||
"git_status",
|
|
||||||
"analyze_external_repo",
|
|
||||||
"read_external_file",
|
|
||||||
"typecheck",
|
|
||||||
"metaset.query",
|
|
||||||
"metaset.list_tables",
|
|
||||||
"metaset.table_fields",
|
|
||||||
"metaset.list_questions",
|
|
||||||
"metaset.list_dashboards",
|
|
||||||
"metaset.health",
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface PipelineContext {
|
|
||||||
planMode: boolean;
|
|
||||||
commandLog: Array<{ command: string; output: string; timestamp: string; success: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ToolManager {
|
export class ToolManager {
|
||||||
private tools: Map<string, BaseTool> = new Map();
|
private tools: Map<string, BaseTool> = new Map();
|
||||||
private categories: Map<string, string[]> = new Map();
|
private categories: Map<string, string[]> = new Map();
|
||||||
private governanceSynced: boolean = false;
|
private governanceSynced: boolean = false;
|
||||||
private _activePipelineId: number | null = null;
|
|
||||||
private _pipelineContexts: Map<number, PipelineContext> = new Map();
|
|
||||||
private _globalCommandLog: Array<{ command: string; output: string; timestamp: string; success: boolean; pipelineId?: number }> = [];
|
|
||||||
|
|
||||||
private getPipelineContext(pipelineId?: number): PipelineContext | null {
|
|
||||||
const id = pipelineId ?? this._activePipelineId;
|
|
||||||
if (id === null) return null;
|
|
||||||
if (!this._pipelineContexts.has(id)) {
|
|
||||||
this._pipelineContexts.set(id, { planMode: false, commandLog: [] });
|
|
||||||
}
|
|
||||||
return this._pipelineContexts.get(id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActivePipeline(pipelineId: number | null): void {
|
|
||||||
this._activePipelineId = pipelineId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get activePipelineId(): number | null {
|
|
||||||
return this._activePipelineId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get planMode(): boolean {
|
|
||||||
const ctx = this.getPipelineContext();
|
|
||||||
return ctx?.planMode ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlanMode(enabled: boolean, pipelineId?: number): void {
|
|
||||||
const id = pipelineId ?? this._activePipelineId;
|
|
||||||
if (id !== null && id !== undefined) {
|
|
||||||
const ctx = this.getPipelineContext(id);
|
|
||||||
if (ctx) ctx.planMode = enabled;
|
|
||||||
}
|
|
||||||
console.log(`[ToolManager] Plan Mode pipeline=${id}: ${enabled ? "ATIVADO" : "DESATIVADO"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
isWriteTool(toolName: string): boolean {
|
|
||||||
return WRITE_TOOLS.has(toolName);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommandLog(pipelineId?: number): typeof this._globalCommandLog {
|
|
||||||
if (pipelineId !== undefined) {
|
|
||||||
return this._globalCommandLog.filter(l => l.pipelineId === pipelineId);
|
|
||||||
}
|
|
||||||
return [...this._globalCommandLog];
|
|
||||||
}
|
|
||||||
|
|
||||||
clearCommandLog(pipelineId?: number): void {
|
|
||||||
if (pipelineId !== undefined) {
|
|
||||||
this._globalCommandLog = this._globalCommandLog.filter(l => l.pipelineId !== pipelineId);
|
|
||||||
} else {
|
|
||||||
this._globalCommandLog = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addCommandLog(entry: { command: string; output: string; timestamp: string; success: boolean }): void {
|
|
||||||
this._globalCommandLog.push({ ...entry, pipelineId: this._activePipelineId ?? undefined });
|
|
||||||
if (this._globalCommandLog.length > 500) {
|
|
||||||
this._globalCommandLog = this._globalCommandLog.slice(-250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupPipeline(pipelineId: number): void {
|
|
||||||
this._pipelineContexts.delete(pipelineId);
|
|
||||||
this._globalCommandLog = this._globalCommandLog.filter(l => l.pipelineId !== pipelineId);
|
|
||||||
if (this._activePipelineId === pipelineId) this._activePipelineId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getToolsForMode(mode: "plan" | "act"): ToolDefinition[] {
|
|
||||||
if (mode === "act") return this.listTools();
|
|
||||||
return this.listTools().filter(t => !WRITE_TOOLS.has(t.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
register(tool: BaseTool): void {
|
register(tool: BaseTool): void {
|
||||||
this.tools.set(tool.name, tool);
|
this.tools.set(tool.name, tool);
|
||||||
|
|
@ -188,14 +89,6 @@ export class ToolManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.planMode && WRITE_TOOLS.has(toolName)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
result: `[PLAN MODE] Ferramenta "${toolName}" bloqueada. Em modo planejamento, apenas ferramentas de leitura são permitidas. Analise o código e gere um plano detalhado.`,
|
|
||||||
error: `PLAN_MODE_BLOCKED: ${toolName}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentName) {
|
if (agentName) {
|
||||||
const rbacCheck = await this.checkRBAC(toolName, agentName);
|
const rbacCheck = await this.checkRBAC(toolName, agentName);
|
||||||
if (!rbacCheck.allowed) {
|
if (!rbacCheck.allowed) {
|
||||||
|
|
@ -233,15 +126,6 @@ export class ToolManager {
|
||||||
|
|
||||||
console.log(`[ToolManager] ${toolName} executado em ${duration}ms`);
|
console.log(`[ToolManager] ${toolName} executado em ${duration}ms`);
|
||||||
|
|
||||||
if (toolName === "run_command" || toolName === "typecheck") {
|
|
||||||
this.addCommandLog({
|
|
||||||
command: params.command || toolName,
|
|
||||||
output: result.result?.slice(0, 5000) || "",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
success: result.success,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentName) {
|
if (agentName) {
|
||||||
await governanceService.recordAudit({
|
await governanceService.recordAudit({
|
||||||
agentName,
|
agentName,
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ const BLOCKED_FILES = ['package.json', 'package-lock.json', '.env', 'drizzle.con
|
||||||
const PROTECTED_PATHS = [
|
const PROTECTED_PATHS = [
|
||||||
'server/routes.ts',
|
'server/routes.ts',
|
||||||
'shared/schema.ts',
|
'shared/schema.ts',
|
||||||
'shared/schemas/index.ts',
|
|
||||||
'shared/schemas/loader.ts',
|
|
||||||
'server/modules/loader.ts',
|
|
||||||
'client/src/App.tsx',
|
'client/src/App.tsx',
|
||||||
'client/src/main.tsx',
|
'client/src/main.tsx',
|
||||||
'server/index.ts',
|
'server/index.ts',
|
||||||
|
|
@ -24,11 +21,6 @@ const PROTECTED_PATHS = [
|
||||||
'db/index.ts',
|
'db/index.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_MODULE_DIRS = [
|
|
||||||
'shared/schemas/',
|
|
||||||
'server/modules/',
|
|
||||||
];
|
|
||||||
|
|
||||||
export class WriteFileTool extends BaseTool {
|
export class WriteFileTool extends BaseTool {
|
||||||
name = "write_file";
|
name = "write_file";
|
||||||
description = "Escreve ou cria um arquivo no projeto";
|
description = "Escreve ou cria um arquivo no projeto";
|
||||||
|
|
@ -61,6 +53,7 @@ export class WriteFileTool extends BaseTool {
|
||||||
return this.formatError(`Arquivo protegido: ${fileName}`);
|
return this.formatError(`Arquivo protegido: ${fileName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bloquear sobrescrita de arquivos críticos do sistema
|
||||||
if (PROTECTED_PATHS.includes(filePath)) {
|
if (PROTECTED_PATHS.includes(filePath)) {
|
||||||
return this.formatError(`Arquivo crítico protegido - não pode ser sobrescrito: ${filePath}`);
|
return this.formatError(`Arquivo crítico protegido - não pode ser sobrescrito: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -800,7 +800,7 @@ ${JSON.stringify(sampleData.slice(0, 10), null, 2)}
|
||||||
Pergunta do usuário: ${question}`;
|
Pergunta do usuário: ${question}`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "arcadia-agent",
|
model: "gpt-4o-mini",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt }
|
{ role: "user", content: userPrompt }
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ import type { Express, Request, Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import * as BSON from "bson";
|
import * as BSON from "bson";
|
||||||
import { db } from "../../db/index";
|
import { db } from "../../db/index";
|
||||||
import { biDatasets, stagedTables } from "@shared/schema";
|
import { biDatasets, stagedTables } from "@shared/schema";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
const uploadDir = path.join(process.cwd(), "uploads");
|
const uploadDir = path.join(process.cwd(), "uploads");
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@
|
||||||
* Arcadia Suite - Base Blackboard Agent
|
* Arcadia Suite - Base Blackboard Agent
|
||||||
*
|
*
|
||||||
* Classe base para agentes que monitoram e reagem ao Blackboard.
|
* Classe base para agentes que monitoram e reagem ao Blackboard.
|
||||||
* Toda inteligência de IA é centralizada via ManusIntelligence.
|
|
||||||
*
|
*
|
||||||
* @author Arcadia Development Team
|
* @author Arcadia Development Team
|
||||||
* @version 2.0.0 - Manus-Powered
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
|
@ -36,121 +35,6 @@ export interface AgentThought {
|
||||||
result?: any;
|
result?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ManusIntelligence {
|
|
||||||
private static instance: ManusIntelligence;
|
|
||||||
private model = "arcadia-agent";
|
|
||||||
private callCount = 0;
|
|
||||||
private tokenCount = 0;
|
|
||||||
private errorCount = 0;
|
|
||||||
private lastCallAt = 0;
|
|
||||||
private startedAt = Date.now();
|
|
||||||
|
|
||||||
static getInstance(): ManusIntelligence {
|
|
||||||
if (!ManusIntelligence.instance) {
|
|
||||||
ManusIntelligence.instance = new ManusIntelligence();
|
|
||||||
}
|
|
||||||
return ManusIntelligence.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
async generate(systemPrompt: string, userPrompt: string, options?: { maxTokens?: number; temperature?: number; enrichContext?: boolean }): Promise<string> {
|
|
||||||
this.callCount++;
|
|
||||||
try {
|
|
||||||
let enrichedPrompt = userPrompt;
|
|
||||||
if (options?.enrichContext !== false) {
|
|
||||||
const contextData = await this.enrichWithContext(userPrompt);
|
|
||||||
if (contextData) {
|
|
||||||
enrichedPrompt = userPrompt + contextData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
|
||||||
model: this.model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: enrichedPrompt }
|
|
||||||
],
|
|
||||||
max_tokens: options?.maxTokens || 8000,
|
|
||||||
temperature: options?.temperature || 0.2,
|
|
||||||
});
|
|
||||||
const content = response.choices[0]?.message?.content || '';
|
|
||||||
this.tokenCount += response.usage?.total_tokens || 0;
|
|
||||||
this.lastCallAt = Date.now();
|
|
||||||
return content;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.errorCount++;
|
|
||||||
console.error(`[ManusIntelligence] Erro na geração:`, error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async think(systemPrompt: string, taskContext: string): Promise<AgentThought> {
|
|
||||||
this.callCount++;
|
|
||||||
try {
|
|
||||||
const contextData = await this.enrichWithContext(taskContext);
|
|
||||||
const enrichedContext = contextData ? taskContext + contextData : taskContext;
|
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
|
||||||
model: this.model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: enrichedContext }
|
|
||||||
],
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
max_tokens: 4000,
|
|
||||||
temperature: 0.2,
|
|
||||||
});
|
|
||||||
const content = response.choices[0]?.message?.content || '{}';
|
|
||||||
this.tokenCount += response.usage?.total_tokens || 0;
|
|
||||||
this.lastCallAt = Date.now();
|
|
||||||
return JSON.parse(content) as AgentThought;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.errorCount++;
|
|
||||||
console.error(`[ManusIntelligence] Erro ao pensar:`, error.message);
|
|
||||||
return {
|
|
||||||
thought: `Erro ao processar: ${error.message}`,
|
|
||||||
finished: true,
|
|
||||||
result: { error: error.message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async enrichWithContext(prompt: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const { toolManager } = await import("../autonomous/tools");
|
|
||||||
|
|
||||||
const keywords = prompt.split(/\s+/).filter(w => w.length > 4).slice(0, 5);
|
|
||||||
let contextData = "";
|
|
||||||
|
|
||||||
const searchResult = await toolManager.execute("search_code", {
|
|
||||||
query: keywords.join("|"),
|
|
||||||
maxResults: 5,
|
|
||||||
});
|
|
||||||
if (searchResult.success && searchResult.data?.results?.length > 0) {
|
|
||||||
contextData += "\n\nCÓDIGO RELEVANTE DO PROJETO:\n" +
|
|
||||||
searchResult.data.results.map((r: any) => `${r.file}:${r.line} - ${r.content}`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return contextData;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMetrics() {
|
|
||||||
return {
|
|
||||||
model: this.model,
|
|
||||||
callCount: this.callCount,
|
|
||||||
tokenCount: this.tokenCount,
|
|
||||||
errorCount: this.errorCount,
|
|
||||||
lastCallAt: this.lastCallAt,
|
|
||||||
startedAt: this.startedAt,
|
|
||||||
healthy: !!process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const manusIntelligence = ManusIntelligence.getInstance();
|
|
||||||
|
|
||||||
export abstract class BaseBlackboardAgent extends EventEmitter {
|
export abstract class BaseBlackboardAgent extends EventEmitter {
|
||||||
protected config: AgentConfig;
|
protected config: AgentConfig;
|
||||||
protected isRunning: boolean = false;
|
protected isRunning: boolean = false;
|
||||||
|
|
@ -182,17 +66,48 @@ export abstract class BaseBlackboardAgent extends EventEmitter {
|
||||||
? `\n\nArtefatos disponíveis:\n${artifacts.map(a => `- ${a.name} (${a.type}): ${a.content?.slice(0, 500)}...`).join('\n')}`
|
? `\n\nArtefatos disponíveis:\n${artifacts.map(a => `- ${a.name} (${a.type}): ${a.content?.slice(0, 500)}...`).join('\n')}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const taskContext = `TAREFA: ${task.title}\n\nDESCRIÇÃO: ${task.description}\n\nCONTEXTO: ${context}${artifactContext}\n\nResponda em JSON: { "thought": "seu raciocínio", "action": "próxima ação", "actionInput": {}, "finished": true/false, "result": "se finished=true" }`;
|
try {
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: this.config.systemPrompt },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `TAREFA: ${task.title}\n\nDESCRIÇÃO: ${task.description}\n\nCONTEXTO: ${context}${artifactContext}\n\nResponda em JSON: { "thought": "seu raciocínio", "action": "próxima ação", "actionInput": {}, "finished": true/false, "result": "se finished=true" }`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
max_tokens: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
return manusIntelligence.think(this.config.systemPrompt, taskContext);
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
return JSON.parse(content) as AgentThought;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[${this.name}] Erro ao pensar:`, error.message);
|
||||||
|
return {
|
||||||
|
thought: `Erro ao processar: ${error.message}`,
|
||||||
|
finished: true,
|
||||||
|
result: { error: error.message }
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async generateWithAI(prompt: string, systemPrompt?: string): Promise<string> {
|
protected async generateWithAI(prompt: string, systemPrompt?: string): Promise<string> {
|
||||||
return manusIntelligence.generate(
|
try {
|
||||||
systemPrompt || this.config.systemPrompt,
|
const response = await openai.chat.completions.create({
|
||||||
prompt,
|
model: "gpt-4o-mini",
|
||||||
{ maxTokens: 8000 }
|
messages: [
|
||||||
);
|
{ role: "system", content: systemPrompt || this.config.systemPrompt },
|
||||||
|
{ role: "user", content: prompt }
|
||||||
|
],
|
||||||
|
max_tokens: 8000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.choices[0]?.message?.content || '';
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[${this.name}] Erro na geração:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async log(taskId: number, action: string, thought: string, observation?: string): Promise<void> {
|
protected async log(taskId: number, action: string, thought: string, observation?: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ const ALL_PHASES: PipelinePhase[] = ["design", "codegen", "validation", "staging
|
||||||
|
|
||||||
class PipelineOrchestrator extends EventEmitter {
|
class PipelineOrchestrator extends EventEmitter {
|
||||||
private activeMonitors: Map<number, NodeJS.Timeout> = new Map();
|
private activeMonitors: Map<number, NodeJS.Timeout> = new Map();
|
||||||
|
private processingMonitors: Set<number> = new Set();
|
||||||
|
|
||||||
async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise<XosDevPipeline> {
|
async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise<XosDevPipeline> {
|
||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
|
|
@ -169,10 +170,14 @@ class PipelineOrchestrator extends EventEmitter {
|
||||||
if (this.activeMonitors.has(pipelineId)) return;
|
if (this.activeMonitors.has(pipelineId)) return;
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
|
if (this.processingMonitors.has(pipelineId)) return;
|
||||||
|
this.processingMonitors.add(pipelineId);
|
||||||
try {
|
try {
|
||||||
await this.checkPhaseProgress(pipelineId, mainTaskId);
|
await this.checkPhaseProgress(pipelineId, mainTaskId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[PipelineOrchestrator] Erro no monitor #${pipelineId}:`, error);
|
console.error(`[PipelineOrchestrator] Erro no monitor #${pipelineId}:`, error);
|
||||||
|
} finally {
|
||||||
|
this.processingMonitors.delete(pipelineId);
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
|
|
@ -384,26 +389,6 @@ class PipelineOrchestrator extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaFiles = applied.filter(f => f.startsWith("shared/schemas/") && f.endsWith(".ts"));
|
|
||||||
if (schemaFiles.length > 0) {
|
|
||||||
try {
|
|
||||||
const { registerAndMigrate } = await import("../modules/migrator");
|
|
||||||
for (const schemaFile of schemaFiles) {
|
|
||||||
const moduleName = schemaFile.replace("shared/schemas/", "").replace(".ts", "");
|
|
||||||
if (moduleName && moduleName !== "index" && moduleName !== "loader" && !moduleName.startsWith("_")) {
|
|
||||||
const migResult = await registerAndMigrate(moduleName);
|
|
||||||
if (migResult.success) {
|
|
||||||
console.log(`[Pipeline] Módulo ${moduleName}: schema registrado${migResult.migrationApplied ? " + migração aplicada" : ""}`);
|
|
||||||
} else {
|
|
||||||
errors.push(`Migração ${moduleName}: ${migResult.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (migError: any) {
|
|
||||||
console.error("[Pipeline] Erro no migrator:", migError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applied.length > 0) {
|
if (applied.length > 0) {
|
||||||
await toolManager.execute("git_local_commit", {
|
await toolManager.execute("git_local_commit", {
|
||||||
message: `[Pipeline #${pipelineId}] ${pipeline.prompt.slice(0, 60)}`,
|
message: `[Pipeline #${pipelineId}] ${pipeline.prompt.slice(0, 60)}`,
|
||||||
|
|
|
||||||
|
|
@ -33,44 +33,18 @@ Você tem acesso ao contexto do projeto e pode ler arquivos existentes.
|
||||||
- Banco: PostgreSQL
|
- Banco: PostgreSQL
|
||||||
- Autenticação: Passport.js
|
- Autenticação: Passport.js
|
||||||
|
|
||||||
## SISTEMA MODULAR (IMPORTANTE!)
|
|
||||||
O projeto usa um sistema modular para autonomia do Dev Center:
|
|
||||||
|
|
||||||
### Schemas (Banco de Dados)
|
|
||||||
- Criar schemas novos em: shared/schemas/{nomeModulo}.ts
|
|
||||||
- NÃO modificar shared/schema.ts (protegido)
|
|
||||||
- NÃO importar de @shared/schema (evitar referência circular)
|
|
||||||
- Usar varchar("user_id") SEM .references() para referências a tabelas do schema principal
|
|
||||||
- Importar diretamente: import { sql } from "drizzle-orm"; import { pgTable, ... } from "drizzle-orm/pg-core";
|
|
||||||
- Exportar: tabelas, insertSchemas (drizzle-zod), e types
|
|
||||||
- Prefixar tabelas com nome do módulo: bsc_objectives, fin_transactions, etc.
|
|
||||||
|
|
||||||
### Rotas (API)
|
|
||||||
- Criar rotas em: server/modules/{nomeModulo}.ts
|
|
||||||
- NÃO modificar server/routes.ts (protegido)
|
|
||||||
- Exportar default um Router do Express
|
|
||||||
- As rotas ficam automaticamente em /api/modules/{nomeModulo}
|
|
||||||
- Importar schemas do módulo: import { ... } from "@shared/schemas/{nomeModulo}"
|
|
||||||
- Importar db: import { db } from "../../db"
|
|
||||||
|
|
||||||
### Páginas (Frontend)
|
|
||||||
- Criar em: client/src/pages/{NomeModulo}.tsx
|
|
||||||
- Componentes em: client/src/components/{nomeModulo}/
|
|
||||||
|
|
||||||
## Formato de Saída (JSON)
|
## Formato de Saída (JSON)
|
||||||
{
|
{
|
||||||
"moduleName": "nome-do-modulo",
|
"moduleName": "nome-do-modulo",
|
||||||
"description": "descrição completa",
|
"description": "descrição completa",
|
||||||
"schema": {
|
"schema": {
|
||||||
"file": "shared/schemas/{moduleName}.ts",
|
|
||||||
"tables": [{ "name": "tabela", "columns": [{ "name": "coluna", "type": "tipo", "constraints": [] }] }]
|
"tables": [{ "name": "tabela", "columns": [{ "name": "coluna", "type": "tipo", "constraints": [] }] }]
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"file": "server/modules/{moduleName}.ts",
|
"routes": [{ "method": "GET|POST|PUT|DELETE", "path": "/api/rota", "description": "descrição", "requestBody": {}, "response": {} }]
|
||||||
"routes": [{ "method": "GET|POST|PUT|DELETE", "path": "/{rota}", "description": "descrição" }]
|
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"components": [{ "name": "Componente", "type": "page|modal|widget", "description": "desc", "path": "client/src/pages/{Nome}.tsx" }]
|
"components": [{ "name": "Componente", "type": "page|modal|widget", "description": "desc", "path": "caminho/arquivo.tsx" }]
|
||||||
},
|
},
|
||||||
"integrations": ["módulos existentes que devem ser integrados"],
|
"integrations": ["módulos existentes que devem ser integrados"],
|
||||||
"existingFiles": ["arquivos que precisam ser modificados"]
|
"existingFiles": ["arquivos que precisam ser modificados"]
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,6 @@ const PROTECTED_FILES = [
|
||||||
"server/storage.ts",
|
"server/storage.ts",
|
||||||
"server/db.ts",
|
"server/db.ts",
|
||||||
"shared/schema.ts",
|
"shared/schema.ts",
|
||||||
"shared/schemas/index.ts",
|
|
||||||
"shared/schemas/loader.ts",
|
|
||||||
"server/modules/loader.ts",
|
|
||||||
"client/src/App.tsx",
|
"client/src/App.tsx",
|
||||||
"client/src/main.tsx",
|
"client/src/main.tsx",
|
||||||
"client/src/pages/Cockpit.tsx",
|
"client/src/pages/Cockpit.tsx",
|
||||||
|
|
@ -48,21 +45,8 @@ const PROTECTED_FILES = [
|
||||||
"tsconfig.json",
|
"tsconfig.json",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
"db/index.ts",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_MODULE_DIRS = [
|
|
||||||
"shared/schemas/",
|
|
||||||
"server/modules/",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isModuleFile(filePath: string): boolean {
|
|
||||||
return ALLOWED_MODULE_DIRS.some(dir => filePath.startsWith(dir)) &&
|
|
||||||
!PROTECTED_FILES.includes(filePath) &&
|
|
||||||
!filePath.startsWith("shared/schemas/_") &&
|
|
||||||
!filePath.startsWith("server/modules/_");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExecutorAgent extends BaseBlackboardAgent {
|
export class ExecutorAgent extends BaseBlackboardAgent {
|
||||||
constructor() {
|
constructor() {
|
||||||
const config: AgentConfig = {
|
const config: AgentConfig = {
|
||||||
|
|
|
||||||
|
|
@ -34,27 +34,6 @@ Você pode ler arquivos existentes para manter consistência de estilo.
|
||||||
5. Use shadcn/ui para componentes de UI
|
5. Use shadcn/ui para componentes de UI
|
||||||
6. Adicione data-testid em elementos interativos
|
6. Adicione data-testid em elementos interativos
|
||||||
|
|
||||||
## SISTEMA MODULAR (IMPORTANTE!)
|
|
||||||
O projeto usa um sistema modular. SEMPRE crie arquivos nas pastas modulares:
|
|
||||||
|
|
||||||
### Schemas de Banco → shared/schemas/{nomeModulo}.ts
|
|
||||||
- NÃO modificar shared/schema.ts (protegido!)
|
|
||||||
- NÃO importar de @shared/schema (referência circular!)
|
|
||||||
- Importar: import { sql } from "drizzle-orm"; import { pgTable, text, varchar, serial, integer, timestamp, boolean, jsonb, numeric } from "drizzle-orm/pg-core";
|
|
||||||
- Importar: import { createInsertSchema } from "drizzle-zod"; import { z } from "zod";
|
|
||||||
- Para referências a users: usar varchar("user_id") SEM .references()
|
|
||||||
- Prefixar tabelas: bsc_objectives, fin_transactions, etc.
|
|
||||||
- Exportar: tabelas + insertSchemas + types
|
|
||||||
|
|
||||||
### Rotas de API → server/modules/{nomeModulo}.ts
|
|
||||||
- NÃO modificar server/routes.ts (protegido!)
|
|
||||||
- Exportar default um Router do Express
|
|
||||||
- Rotas montadas automaticamente em /api/modules/{nomeModulo}
|
|
||||||
- Import db: import { db } from "../../db";
|
|
||||||
- Import schemas: import { ... } from "@shared/schemas/{nomeModulo}";
|
|
||||||
|
|
||||||
### Páginas React → client/src/pages/{NomeModulo}.tsx
|
|
||||||
|
|
||||||
## Formato de Saída
|
## Formato de Saída
|
||||||
Para cada arquivo, retorne JSON:
|
Para cada arquivo, retorne JSON:
|
||||||
{
|
{
|
||||||
|
|
@ -138,8 +117,8 @@ ${schemaRef}
|
||||||
${routesRef}
|
${routesRef}
|
||||||
|
|
||||||
Gere arquivos completos e funcionais para:
|
Gere arquivos completos e funcionais para:
|
||||||
1. Schema do banco (se necessário) - CRIAR em shared/schemas/{moduleName}.ts (NÃO editar schema.ts!)
|
1. Schema do banco de dados (se necessário) - adicionar ao shared/schema.ts
|
||||||
2. Rotas da API - CRIAR em server/modules/{moduleName}.ts (NÃO editar routes.ts!)
|
2. Rotas da API - novo arquivo em server/ ou adicionar a routes.ts
|
||||||
3. Componente principal React - em client/src/pages/
|
3. Componente principal React - em client/src/pages/
|
||||||
|
|
||||||
Retorne em formato JSON com a estrutura:
|
Retorne em formato JSON com a estrutura:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { pipelineOrchestrator } from "./PipelineOrchestrator";
|
import { pipelineOrchestrator } from "./PipelineOrchestrator";
|
||||||
import { toolManager } from "../autonomous/tools/ToolManager";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const createPipelineSchema = z.object({
|
const createPipelineSchema = z.object({
|
||||||
prompt: z.string().min(5, "O prompt deve ter pelo menos 5 caracteres"),
|
prompt: z.string().min(5, "O prompt deve ter pelo menos 5 caracteres"),
|
||||||
mode: z.enum(["plan", "act"]).optional().default("act"),
|
|
||||||
planContext: z.string().optional(),
|
|
||||||
metadata: z.record(z.any()).optional(),
|
metadata: z.record(z.any()).optional(),
|
||||||
budget: z.object({
|
budget: z.object({
|
||||||
maxTokens: z.number().optional(),
|
maxTokens: z.number().optional(),
|
||||||
|
|
@ -24,64 +21,18 @@ router.post("/", async (req: Request, res: Response) => {
|
||||||
return res.status(400).json({ success: false, error: parsed.error.errors[0].message });
|
return res.status(400).json({ success: false, error: parsed.error.errors[0].message });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed.data.mode || "act";
|
|
||||||
const userId = (req.user as any)?.id || "anonymous";
|
const userId = (req.user as any)?.id || "anonymous";
|
||||||
const metadata = {
|
const metadata = { ...(parsed.data.metadata || {}), budget: parsed.data.budget };
|
||||||
...(parsed.data.metadata || {}),
|
const pipeline = await pipelineOrchestrator.createPipeline(parsed.data.prompt, userId, metadata);
|
||||||
budget: parsed.data.budget,
|
|
||||||
mode,
|
|
||||||
planContext: parsed.data.planContext || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let prompt = parsed.data.prompt;
|
|
||||||
if (mode === "plan") {
|
|
||||||
prompt = `[MODO PLANEJAMENTO] Analise o projeto e gere um plano detalhado de implementação. NÃO modifique arquivos. Use apenas ferramentas de leitura (read_file, search_code, list_directory). Gere um documento implementation_plan.md com:\n1. Análise do estado atual\n2. Arquivos que precisam ser criados/modificados\n3. Estratégia passo-a-passo\n4. Dependências e riscos\n\nSolicitação: ${prompt}`;
|
|
||||||
} else if (parsed.data.planContext) {
|
|
||||||
prompt = `[MODO EXECUÇÃO] Siga o plano de implementação abaixo e execute as mudanças:\n\n--- PLANO ---\n${parsed.data.planContext}\n--- FIM DO PLANO ---\n\nSolicitação original: ${prompt}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipeline = await pipelineOrchestrator.createPipeline(prompt, userId, metadata);
|
|
||||||
|
|
||||||
toolManager.setActivePipeline(pipeline.id);
|
|
||||||
toolManager.setPlanMode(mode === "plan", pipeline.id);
|
|
||||||
|
|
||||||
const started = await pipelineOrchestrator.startPipeline(pipeline.id);
|
const started = await pipelineOrchestrator.startPipeline(pipeline.id);
|
||||||
|
|
||||||
res.json({ success: true, pipeline: started, mode });
|
res.json({ success: true, pipeline: started });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Pipeline] Erro ao criar:", error);
|
console.error("[Pipeline] Erro ao criar:", error);
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/agent-terminal", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const pipelineId = req.query.pipelineId ? parseInt(req.query.pipelineId as string) : undefined;
|
|
||||||
const logs = toolManager.getCommandLog(pipelineId);
|
|
||||||
res.json({ success: true, logs });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/mode", async (req: Request, res: Response) => {
|
|
||||||
const pipelineId = req.query.pipelineId ? parseInt(req.query.pipelineId as string) : undefined;
|
|
||||||
if (pipelineId !== undefined) {
|
|
||||||
toolManager.setActivePipeline(pipelineId);
|
|
||||||
}
|
|
||||||
res.json({ success: true, planMode: toolManager.planMode, activePipeline: toolManager.activePipelineId });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/mode", async (req: Request, res: Response) => {
|
|
||||||
const { mode, pipelineId } = req.body;
|
|
||||||
if (mode !== "plan" && mode !== "act") {
|
|
||||||
return res.status(400).json({ success: false, error: "Mode must be 'plan' or 'act'" });
|
|
||||||
}
|
|
||||||
const pid = pipelineId ? parseInt(pipelineId) : undefined;
|
|
||||||
toolManager.setPlanMode(mode === "plan", pid);
|
|
||||||
res.json({ success: true, mode, planMode: toolManager.planMode });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/", async (req: Request, res: Response) => {
|
router.get("/", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||||
|
|
@ -235,29 +186,4 @@ router.get("/:id/stream", async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/modules/status", async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { getModuleStatus, listModuleSchemas } = await import("../modules/migrator");
|
|
||||||
const { getLoadedModules } = await import("../modules/loader");
|
|
||||||
|
|
||||||
const status = await getModuleStatus();
|
|
||||||
const schemas = await listModuleSchemas();
|
|
||||||
const loadedRoutes = getLoadedModules();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
modules: status,
|
|
||||||
registeredSchemas: schemas,
|
|
||||||
loadedRoutes,
|
|
||||||
allowedPaths: {
|
|
||||||
schemas: "shared/schemas/{moduleName}.ts",
|
|
||||||
routes: "server/modules/{moduleName}.ts",
|
|
||||||
pages: "client/src/pages/{ModuleName}.tsx",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
// Apply auth to all routes
|
// Apply auth to all routes
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Helper to get tenant ID from request header or user's first tenant (auto-creates if none)
|
// Helper to get tenant ID from request header or user's first tenant
|
||||||
async function getTenantId(req: Request): Promise<number | null> {
|
async function getTenantId(req: Request): Promise<number | null> {
|
||||||
const userId = (req.user as any).id;
|
const userId = (req.user as any).id;
|
||||||
const headerTenantId = req.headers["x-tenant-id"];
|
const headerTenantId = req.headers["x-tenant-id"];
|
||||||
|
|
@ -40,13 +40,8 @@ async function getTenantId(req: Request): Promise<number | null> {
|
||||||
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
|
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
|
||||||
return isMember ? tenantId : null;
|
return isMember ? tenantId : null;
|
||||||
}
|
}
|
||||||
const userTenants = await compassStorage.getUserTenants(userId);
|
const tenants = await compassStorage.getUserTenants(userId);
|
||||||
if (userTenants.length > 0) return userTenants[0].id;
|
return tenants.length > 0 ? tenants[0].id : null;
|
||||||
// Auto-cria tenant padrão para o usuário na primeira vez
|
|
||||||
const userName = (req.user as any).name || (req.user as any).username || "Minha Organização";
|
|
||||||
const tenant = await compassStorage.createTenant({ name: userName, plan: "free", status: "active" });
|
|
||||||
await compassStorage.addUserToTenant({ tenantId: tenant.id, userId, role: "owner", isOwner: "true" });
|
|
||||||
return tenant.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tenant membership
|
// Validate tenant membership
|
||||||
|
|
@ -2279,4 +2274,124 @@ router.put("/projects/:projectId/history", async (req: Request, res: Response) =
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== AI BRIEFING & HEALTH SCORE ==========
|
||||||
|
|
||||||
|
router.post("/projects/:projectId/ai-brief", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = await getTenantId(req);
|
||||||
|
if (!tenantId) return res.status(403).json({ error: "Tenant não encontrado" });
|
||||||
|
const projectId = parseInt(req.params.projectId);
|
||||||
|
const hasAccess = await validateProjectAccess(projectId, tenantId);
|
||||||
|
if (!hasAccess) return res.status(403).json({ error: "Acesso negado ao projeto" });
|
||||||
|
|
||||||
|
const project = await compassStorage.getProject(projectId, tenantId);
|
||||||
|
const tasks = await compassStorage.getTasks(projectId);
|
||||||
|
const pdcaCycles = await compassStorage.getPdcaCycles(tenantId, projectId);
|
||||||
|
|
||||||
|
const openTasks = tasks.filter((t: any) => t.status !== "done" && t.status !== "completed");
|
||||||
|
const overdue = tasks.filter((t: any) => t.dueDate && new Date(t.dueDate) < new Date() && t.status !== "done");
|
||||||
|
const pdcaOpen = pdcaCycles.filter((c: any) => c.status === "open" || c.status === "in_progress");
|
||||||
|
|
||||||
|
const OpenAI = (await import("openai")).default;
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||||
|
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `Você é um consultor de gestão de projetos. Analise o projeto abaixo e gere um briefing executivo em português.
|
||||||
|
|
||||||
|
PROJETO: ${project?.name}
|
||||||
|
DESCRIÇÃO: ${project?.description || "Não informada"}
|
||||||
|
STATUS: ${project?.status}
|
||||||
|
FASE: ${project?.currentPhase || "Não definida"}
|
||||||
|
|
||||||
|
RESUMO DE TAREFAS:
|
||||||
|
- Total: ${tasks.length}
|
||||||
|
- Em aberto: ${openTasks.length}
|
||||||
|
- Atrasadas: ${overdue.length}
|
||||||
|
|
||||||
|
PDCA ATIVOS: ${pdcaOpen.length}
|
||||||
|
|
||||||
|
${overdue.length > 0 ? `TAREFAS ATRASADAS:\n${overdue.slice(0, 5).map((t: any) => `- ${t.title} (responsável: ${t.assignedTo || "não atribuído"})`).join("\n")}` : ""}
|
||||||
|
|
||||||
|
Gere um briefing com:
|
||||||
|
1. **Situação atual** (2-3 linhas)
|
||||||
|
2. **Principais riscos** (lista com até 3 itens)
|
||||||
|
3. **Ações prioritárias** (lista com até 5 ações, cada uma com responsável sugerido e prazo)
|
||||||
|
4. **Score de saúde** (0-100 com justificativa)
|
||||||
|
|
||||||
|
Seja direto e objetivo.`;
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const briefText = response.choices[0]?.message?.content || "";
|
||||||
|
|
||||||
|
// Extract health score from response
|
||||||
|
const scoreMatch = briefText.match(/score[^\d]*(\d{1,3})/i) || briefText.match(/(\d{1,3})[^\d]*\//);
|
||||||
|
const healthScore = scoreMatch ? Math.min(100, Math.max(0, parseInt(scoreMatch[1]))) : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
projectId,
|
||||||
|
brief: briefText,
|
||||||
|
healthScore,
|
||||||
|
meta: {
|
||||||
|
totalTasks: tasks.length,
|
||||||
|
openTasks: openTasks.length,
|
||||||
|
overdueTasks: overdue.length,
|
||||||
|
activePdca: pdcaOpen.length,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/projects/:projectId/health", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = await getTenantId(req);
|
||||||
|
if (!tenantId) return res.status(403).json({ error: "Tenant não encontrado" });
|
||||||
|
const projectId = parseInt(req.params.projectId);
|
||||||
|
const hasAccess = await validateProjectAccess(projectId, tenantId);
|
||||||
|
if (!hasAccess) return res.status(403).json({ error: "Acesso negado ao projeto" });
|
||||||
|
|
||||||
|
const tasks = await compassStorage.getTasks(projectId);
|
||||||
|
const pdcaCycles = await compassStorage.getPdcaCycles(tenantId, projectId);
|
||||||
|
|
||||||
|
const total = tasks.length || 1;
|
||||||
|
const done = tasks.filter((t: any) => t.status === "done" || t.status === "completed").length;
|
||||||
|
const overdue = tasks.filter((t: any) => t.dueDate && new Date(t.dueDate) < new Date() && t.status !== "done").length;
|
||||||
|
const pdcaComplete = pdcaCycles.filter((c: any) => c.status === "completed").length;
|
||||||
|
const pdcaTotal = pdcaCycles.length || 1;
|
||||||
|
|
||||||
|
const completionScore = (done / total) * 40;
|
||||||
|
const overdueScore = Math.max(0, 30 - (overdue / total) * 30);
|
||||||
|
const pdcaScore = (pdcaComplete / pdcaTotal) * 30;
|
||||||
|
const healthScore = Math.round(completionScore + overdueScore + pdcaScore);
|
||||||
|
|
||||||
|
const level = healthScore >= 80 ? "saudável" : healthScore >= 50 ? "atenção" : "crítico";
|
||||||
|
const color = healthScore >= 80 ? "green" : healthScore >= 50 ? "yellow" : "red";
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
projectId,
|
||||||
|
healthScore,
|
||||||
|
level,
|
||||||
|
color,
|
||||||
|
breakdown: {
|
||||||
|
completion: { score: Math.round(completionScore), weight: "40%", tasks: `${done}/${tasks.length}` },
|
||||||
|
timeliness: { score: Math.round(overdueScore), weight: "30%", overdue },
|
||||||
|
pdca: { score: Math.round(pdcaScore), weight: "30%", cycles: `${pdcaComplete}/${pdcaCycles.length}` },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export class FrappeService {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data && (method === "POST" || method === "PUT")) {
|
if (data && (method === "POST" || method === "PUT")) {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { manusService } from "./manus/service";
|
||||||
import { db } from "../db/index";
|
import { db } from "../db/index";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
|
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
|
||||||
|
|
@ -79,7 +77,9 @@ Usuário: ${message}
|
||||||
|
|
||||||
Responda como Dev Agent, criando os recursos solicitados:`;
|
Responda como Dev Agent, criando os recursos solicitados:`;
|
||||||
|
|
||||||
const responseContent = await manusService.runSync(userId, fullPrompt, []);
|
const result = await manusService.run(userId, fullPrompt, []);
|
||||||
|
|
||||||
|
const responseContent = result.finalResponse || "Desculpe, não consegui processar sua solicitação.";
|
||||||
|
|
||||||
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
|
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { restartManagedService, stopManagedService, getManagedServiceInfo, getManagedServiceLogs } from "../index";
|
import { restartManagedService, stopManagedService, getManagedServiceInfo, getManagedServiceLogs } from "../index";
|
||||||
import { manusIntelligence } from "../blackboard/BaseBlackboardAgent";
|
|
||||||
|
|
||||||
interface EngineConfig {
|
interface EngineConfig {
|
||||||
name: string;
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
type: "python" | "php" | "node" | "java" | "ai";
|
type: "python" | "php" | "node" | "java";
|
||||||
port: number;
|
port: number;
|
||||||
healthPath: string;
|
healthPath: string;
|
||||||
category: "erp" | "intelligence" | "data" | "fiscal" | "automation";
|
category: "erp" | "intelligence" | "data" | "fiscal" | "automation";
|
||||||
|
|
@ -13,23 +12,14 @@ interface EngineConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINES: EngineConfig[] = [
|
const ENGINES: EngineConfig[] = [
|
||||||
{
|
|
||||||
name: "manus-ia",
|
|
||||||
displayName: "Manus IA (Cerebro Central)",
|
|
||||||
type: "ai",
|
|
||||||
port: 5000,
|
|
||||||
healthPath: "/api/manus/health",
|
|
||||||
category: "intelligence",
|
|
||||||
description: "Motor de IA Central - GPT-4o, 56 Tools, Knowledge Graph, Dev Pipeline",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "plus",
|
name: "plus",
|
||||||
displayName: "Arcadia Plus (ERP)",
|
displayName: "Arcadia Plus (ERP)",
|
||||||
type: "php",
|
type: "php",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
healthPath: "/",
|
healthPath: "/api/health",
|
||||||
category: "erp",
|
category: "erp",
|
||||||
description: "Motor Plus - PDV, NF-e, Estoque, Financeiro",
|
description: "ERP completo Laravel - PDV, NF-e, Estoque, Financeiro",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "contabil",
|
name: "contabil",
|
||||||
|
|
@ -94,21 +84,12 @@ async function checkEngineHealth(engine: EngineConfig): Promise<any> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const headers: Record<string, string> = {};
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
if (engine.name === "manus-ia") {
|
|
||||||
headers["x-internal-check"] = "engine-room";
|
|
||||||
}
|
|
||||||
const fetchOptions: any = { signal: controller.signal, headers };
|
|
||||||
if (engine.name === "plus") {
|
|
||||||
fetchOptions.redirect = "manual";
|
|
||||||
}
|
|
||||||
const response = await fetch(url, fetchOptions);
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
const isRedirect = response.status >= 300 && response.status < 400;
|
if (response.ok) {
|
||||||
if (response.ok || (engine.name === "plus" && isRedirect)) {
|
const data = await response.json().catch(() => ({}));
|
||||||
const data = response.ok ? await response.json().catch(() => ({})) : { redirect: true, location: response.headers.get("location") };
|
|
||||||
return {
|
return {
|
||||||
...engine,
|
...engine,
|
||||||
status: "online",
|
status: "online",
|
||||||
|
|
@ -399,60 +380,5 @@ export function registerEngineRoomRoutes(app: Express): void {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/manus/health", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const isInternal = req.headers["x-internal-check"] === "engine-room";
|
|
||||||
if (!isInternal && !req.isAuthenticated()) {
|
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = manusIntelligence.getMetrics();
|
|
||||||
|
|
||||||
let toolCount = 0;
|
|
||||||
try {
|
|
||||||
const { toolManager } = await import("../autonomous/tools");
|
|
||||||
toolCount = (toolManager as any).getToolNames?.()?.length || (toolManager as any).tools?.size || 56;
|
|
||||||
} catch {
|
|
||||||
toolCount = 56;
|
|
||||||
}
|
|
||||||
|
|
||||||
let agentCount = 0;
|
|
||||||
try {
|
|
||||||
const { getAgentsStatus } = await import("../blackboard/agents");
|
|
||||||
const agents = getAgentsStatus();
|
|
||||||
agentCount = agents.filter((a: any) => a.running).length;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const aiConfigured = metrics.healthy;
|
|
||||||
const engineStatus = aiConfigured ? "online" : "error";
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: engineStatus,
|
|
||||||
engine: "Manus IA",
|
|
||||||
version: "2.0.0",
|
|
||||||
model: metrics.model,
|
|
||||||
database: "connected",
|
|
||||||
aiConfigured,
|
|
||||||
capabilities: {
|
|
||||||
tools: toolCount,
|
|
||||||
agents: agentCount,
|
|
||||||
knowledgeGraph: true,
|
|
||||||
semanticSearch: true,
|
|
||||||
devPipeline: true,
|
|
||||||
autonomousDev: true,
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
totalCalls: metrics.callCount,
|
|
||||||
totalTokens: metrics.tokenCount,
|
|
||||||
errorCount: metrics.errorCount,
|
|
||||||
lastCallAt: metrics.lastCallAt || null,
|
|
||||||
uptime: process.uptime(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ status: "error", error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[Engine Room] Rotas registradas em /api/engine-room/*");
|
console.log("[Engine Room] Rotas registradas em /api/engine-room/*");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,12 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { erpStorage, createErpClient } from "./index";
|
import { erpStorage, createErpClient } from "./index";
|
||||||
import { db } from "../../db/index";
|
import { db } from "../../db/index";
|
||||||
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable, tenants, tenantEmpresas, type TenantFeatures } from "@shared/schema";
|
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable } from "@shared/schema";
|
||||||
import { eq, and, sql, desc } from "drizzle-orm";
|
import { eq, and, sql, desc } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { soeRuleEngine } from "../soe/rule-engine/index";
|
import { soeRuleEngine } from "../soe/rule-engine/index";
|
||||||
import { soeEventBus } from "../soe/event-bus";
|
import { soeEventBus } from "../soe/event-bus";
|
||||||
|
|
||||||
export function registerSoeRoutes(app: Express): void {
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (req.url.startsWith("/api/soe/") || req.url === "/api/soe") {
|
|
||||||
req.url = req.url.replace("/api/soe", "/api/erp");
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
registerErpRoutes(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerErpRoutes(app: Express): void {
|
export function registerErpRoutes(app: Express): void {
|
||||||
app.get("/api/erp/connections", async (req: Request, res: Response) => {
|
app.get("/api/erp/connections", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -863,23 +852,24 @@ export function registerErpRoutes(app: Express): void {
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
return res.status(401).json({ error: "Not authenticated" });
|
||||||
}
|
}
|
||||||
const tenantId = req.user?.tenantId || 1;
|
const tenantId = req.user?.tenantId || 1;
|
||||||
|
const user = req.user as any;
|
||||||
|
|
||||||
// SOE Rule Engine — enriquece payload antes de inserir
|
// SOE Rule Engine — aplica regras fiscais e de negócio antes de criar o pedido
|
||||||
const enriched = await soeRuleEngine.apply("pre_sales_order", req.body, {
|
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||||
tenantId,
|
"pre_sales_order",
|
||||||
motor: "local",
|
{ ...req.body, _tipo: "venda" },
|
||||||
});
|
{ tenantId, empresaId: req.body.empresaId, userId: user?.id, motor: "local" }
|
||||||
if (enriched.validationErrors.length > 0) {
|
);
|
||||||
return res.status(422).json({ error: "Validação SOE falhou", detalhes: enriched.validationErrors });
|
if (validationErrors.length > 0) {
|
||||||
|
return res.status(422).json({ error: "Validação falhou", details: validationErrors });
|
||||||
}
|
}
|
||||||
const body = enriched.payload;
|
|
||||||
|
|
||||||
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = body;
|
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = enriched;
|
||||||
const [order] = await db.insert(salesOrders).values({
|
const [order] = await db.insert(salesOrders).values({
|
||||||
tenantId,
|
tenantId,
|
||||||
orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes
|
orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes
|
||||||
}).returning();
|
}).returning();
|
||||||
res.status(201).json(order);
|
res.status(201).json({ ...order, _rulesApplied: enriched._rulesApplied });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating sales order:", error);
|
console.error("Error creating sales order:", error);
|
||||||
res.status(500).json({ error: "Failed to create sales order" });
|
res.status(500).json({ error: "Failed to create sales order" });
|
||||||
|
|
@ -900,7 +890,16 @@ export function registerErpRoutes(app: Express): void {
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return res.status(404).json({ error: "Sales order not found" });
|
return res.status(404).json({ error: "Sales order not found" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, order: updated, message: "Pedido confirmado. Lançamentos contábeis gerados." });
|
|
||||||
|
// SOE Event Bus — dispara automações pós-confirmação
|
||||||
|
soeEventBus.emit("venda_confirmada", {
|
||||||
|
tenantId,
|
||||||
|
empresaId: (req.user as any)?.empresaId,
|
||||||
|
motorOrigem: "local",
|
||||||
|
dados: { id, status: "confirmed", ...updated },
|
||||||
|
}).catch((e) => console.error("[SOE] venda_confirmada error:", e.message));
|
||||||
|
|
||||||
|
res.json({ success: true, order: updated, message: "Pedido confirmado. Automações SOE disparadas." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error confirming sales order:", error);
|
console.error("Error confirming sales order:", error);
|
||||||
res.status(500).json({ error: "Failed to confirm sales order" });
|
res.status(500).json({ error: "Failed to confirm sales order" });
|
||||||
|
|
@ -914,14 +913,16 @@ export function registerErpRoutes(app: Express): void {
|
||||||
}
|
}
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const tenantId = req.user?.tenantId || 1;
|
const tenantId = req.user?.tenantId || 1;
|
||||||
|
const user = req.user as any;
|
||||||
|
|
||||||
// SOE Rule Engine — valida e enriquece payload fiscal antes de emitir
|
// SOE Rule Engine — aplica regras fiscais antes de gerar a NF-e
|
||||||
const enriched = await soeRuleEngine.apply("pre_nfe_emissao", { ...req.body, _pedidoId: id }, {
|
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||||
tenantId,
|
"pre_nfe_emissao",
|
||||||
motor: "local",
|
{ ...req.body, tipoDocumento: "nfe", _tipo: "venda" },
|
||||||
});
|
{ tenantId, empresaId: user?.empresaId, userId: user?.id, motor: "local" }
|
||||||
if (enriched.validationErrors.length > 0) {
|
);
|
||||||
return res.status(422).json({ error: "Validação fiscal SOE falhou", detalhes: enriched.validationErrors });
|
if (validationErrors.length > 0) {
|
||||||
|
return res.status(422).json({ error: "Validação fiscal falhou", details: validationErrors });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db.update(salesOrders)
|
const [updated] = await db.update(salesOrders)
|
||||||
|
|
@ -935,12 +936,22 @@ export function registerErpRoutes(app: Express): void {
|
||||||
// SOE Event Bus — dispara lançamentos contábeis automáticos
|
// SOE Event Bus — dispara lançamentos contábeis automáticos
|
||||||
soeEventBus.emit("nfe_emitida", {
|
soeEventBus.emit("nfe_emitida", {
|
||||||
tenantId,
|
tenantId,
|
||||||
empresaId: enriched.payload.empresaId || req.body.empresaId,
|
empresaId: user?.empresaId,
|
||||||
motorOrigem: "local",
|
motorOrigem: "local",
|
||||||
dados: { ...enriched.payload, ...updated },
|
dados: {
|
||||||
}).catch((e: any) => console.error("[routes] eventBus nfe_emitida:", e.message));
|
...enriched,
|
||||||
|
...updated,
|
||||||
|
valorTotal: updated.total,
|
||||||
|
dataEmissao: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).catch((e) => console.error("[SOE] nfe_emitida error:", e.message));
|
||||||
|
|
||||||
res.json({ success: true, order: updated, regrasSoe: enriched.appliedRules, message: "NF-e gerada com sucesso." });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
order: updated,
|
||||||
|
tributacao: enriched.tributacao,
|
||||||
|
message: "NF-e gerada. Lançamentos contábeis sendo processados.",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating NF-e:", error);
|
console.error("Error generating NF-e:", error);
|
||||||
res.status(500).json({ error: "Failed to generate NF-e" });
|
res.status(500).json({ error: "Failed to generate NF-e" });
|
||||||
|
|
@ -997,15 +1008,7 @@ export function registerErpRoutes(app: Express): void {
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
return res.status(401).json({ error: "Not authenticated" });
|
||||||
}
|
}
|
||||||
const tenantId = req.user?.tenantId || 1;
|
const tenantId = req.user?.tenantId || 1;
|
||||||
|
const { orderNumber, supplierId, orderDate, expectedDate, status, subtotal, discount, tax, total, notes } = req.body;
|
||||||
// SOE Rule Engine — enriquece payload de compra (CFOP, tributação entrada)
|
|
||||||
const enriched = await soeRuleEngine.apply("pre_purchase_order", req.body, {
|
|
||||||
tenantId,
|
|
||||||
motor: "local",
|
|
||||||
});
|
|
||||||
const body = enriched.payload;
|
|
||||||
|
|
||||||
const { orderNumber, supplierId, orderDate, expectedDate, status, subtotal, discount, tax, total, notes } = body;
|
|
||||||
const [order] = await db.insert(purchaseOrders).values({
|
const [order] = await db.insert(purchaseOrders).values({
|
||||||
tenantId,
|
tenantId,
|
||||||
orderNumber, supplierId, orderDate, expectedDate, status, subtotal, discount, tax, total, notes
|
orderNumber, supplierId, orderDate, expectedDate, status, subtotal, discount, tax, total, notes
|
||||||
|
|
@ -1186,198 +1189,7 @@ export function registerErpRoutes(app: Express): void {
|
||||||
res.status(500).json({ error: "Failed to save config" });
|
res.status(500).json({ error: "Failed to save config" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/erp/tenant/modules - Get current tenant's active modules
|
|
||||||
app.get("/api/erp/tenant/modules", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
const tenantId = req.user?.tenantId || 1;
|
|
||||||
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId));
|
|
||||||
if (!tenant) return res.status(404).json({ error: "Tenant not found" });
|
|
||||||
|
|
||||||
const defaultFeatures: TenantFeatures = {
|
|
||||||
ide: true, ideMode: 'pro-code', whatsapp: false, whatsappSessions: 0,
|
|
||||||
crm: true, erp: true, bi: false, manus: true, manusTools: [],
|
|
||||||
centralApis: false, centralApisManage: false, comunidades: false,
|
|
||||||
maxChannels: 5, biblioteca: false, bibliotecaPublish: false,
|
|
||||||
suporteN3: false, retail: false, plus: false, fisco: false,
|
|
||||||
cockpit: false, compass: true, production: false, support: false, xosCrm: false
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
tenantId: tenant.id,
|
|
||||||
tenantName: tenant.name,
|
|
||||||
plan: tenant.plan,
|
|
||||||
features: tenant.features || defaultFeatures
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching tenant modules:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch modules" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /api/erp/tenant/modules - Update current tenant's modules
|
|
||||||
app.put("/api/erp/tenant/modules", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
const user = req.user;
|
|
||||||
if (user?.role !== 'admin' && (user as any)?.tenantRole !== 'owner') {
|
|
||||||
return res.status(403).json({ error: "Only admins can manage modules" });
|
|
||||||
}
|
|
||||||
const tenantId = user?.tenantId || 1;
|
|
||||||
|
|
||||||
const features = req.body.features;
|
|
||||||
if (!features || typeof features !== 'object') {
|
|
||||||
return res.status(400).json({ error: "Invalid features object" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db.update(tenants)
|
|
||||||
.set({ features, updatedAt: new Date() })
|
|
||||||
.where(eq(tenants.id, tenantId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
res.json({ tenantId: updated.id, features: updated.features });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating tenant modules:", error);
|
|
||||||
res.status(500).json({ error: "Failed to update modules" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/erp/sync/status - Get sync status for all motors
|
|
||||||
app.get("/api/erp/sync/status", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
const tenantId = req.user?.tenantId || 1;
|
|
||||||
|
|
||||||
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId));
|
|
||||||
const features = (tenant?.features as TenantFeatures) || {};
|
|
||||||
|
|
||||||
const status = {
|
|
||||||
retail: {
|
|
||||||
enabled: features.retail || false,
|
|
||||||
motor: features.plus ? 'plus' : features.erp ? 'erpnext' : 'none',
|
|
||||||
lastSync: null,
|
|
||||||
status: 'idle'
|
|
||||||
},
|
|
||||||
plus: {
|
|
||||||
enabled: features.plus || false,
|
|
||||||
status: 'idle'
|
|
||||||
},
|
|
||||||
fisco: {
|
|
||||||
enabled: features.fisco || false,
|
|
||||||
status: 'idle'
|
|
||||||
},
|
|
||||||
bi: {
|
|
||||||
enabled: features.bi || false,
|
|
||||||
status: 'idle'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(status);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching sync status:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch sync status" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/erp/empresas", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const tenantId = (req as any).user?.tenantId;
|
|
||||||
if (!tenantId) return res.status(401).json({ error: "Tenant not identified" });
|
|
||||||
const empresas = await db.select().from(tenantEmpresas)
|
|
||||||
.where(and(eq(tenantEmpresas.tenantId, tenantId), eq(tenantEmpresas.status, "active")));
|
|
||||||
res.json(empresas);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching empresas:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch empresas" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/erp/empresas/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const tenantId = (req as any).user?.tenantId;
|
|
||||||
if (!tenantId) return res.status(401).json({ error: "Tenant not identified" });
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
const [empresa] = await db.select().from(tenantEmpresas)
|
|
||||||
.where(and(eq(tenantEmpresas.id, id), eq(tenantEmpresas.tenantId, tenantId)));
|
|
||||||
if (!empresa) return res.status(404).json({ error: "Empresa not found" });
|
|
||||||
res.json(empresa);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching empresa:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch empresa" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/erp/empresas", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const tenantId = (req as any).user?.tenantId;
|
|
||||||
if (!tenantId) return res.status(401).json({ error: "Tenant not identified" });
|
|
||||||
const { razaoSocial, nomeFantasia, cnpj, ie, im, email, phone, tipo, cep, logradouro, numero, complemento, bairro, cidade, uf, codigoIbge, regimeTributario } = req.body;
|
|
||||||
if (!razaoSocial || !cnpj) return res.status(400).json({ error: "razaoSocial and cnpj required" });
|
|
||||||
const [empresa] = await db.insert(tenantEmpresas).values({
|
|
||||||
tenantId,
|
|
||||||
razaoSocial,
|
|
||||||
nomeFantasia: nomeFantasia || null,
|
|
||||||
cnpj,
|
|
||||||
ie: ie || null,
|
|
||||||
im: im || null,
|
|
||||||
email: email || null,
|
|
||||||
phone: phone || null,
|
|
||||||
tipo: tipo || "filial",
|
|
||||||
cep: cep || null,
|
|
||||||
logradouro: logradouro || null,
|
|
||||||
numero: numero || null,
|
|
||||||
complemento: complemento || null,
|
|
||||||
bairro: bairro || null,
|
|
||||||
cidade: cidade || null,
|
|
||||||
uf: uf || null,
|
|
||||||
codigoIbge: codigoIbge || null,
|
|
||||||
regimeTributario: regimeTributario || null,
|
|
||||||
}).returning();
|
|
||||||
res.status(201).json(empresa);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating empresa:", error);
|
|
||||||
res.status(500).json({ error: "Failed to create empresa" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.put("/api/erp/empresas/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const tenantId = (req as any).user?.tenantId;
|
|
||||||
if (!tenantId) return res.status(401).json({ error: "Tenant not identified" });
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
const [existing] = await db.select().from(tenantEmpresas)
|
|
||||||
.where(and(eq(tenantEmpresas.id, id), eq(tenantEmpresas.tenantId, tenantId)));
|
|
||||||
if (!existing) return res.status(404).json({ error: "Empresa not found" });
|
|
||||||
const { razaoSocial, nomeFantasia, cnpj, ie, im, email, phone, tipo, status, cep, logradouro, numero, complemento, bairro, cidade, uf, codigoIbge, regimeTributario } = req.body;
|
|
||||||
const [updated] = await db.update(tenantEmpresas)
|
|
||||||
.set({
|
|
||||||
...(razaoSocial !== undefined && { razaoSocial }),
|
|
||||||
...(nomeFantasia !== undefined && { nomeFantasia }),
|
|
||||||
...(cnpj !== undefined && { cnpj }),
|
|
||||||
...(ie !== undefined && { ie }),
|
|
||||||
...(im !== undefined && { im }),
|
|
||||||
...(email !== undefined && { email }),
|
|
||||||
...(phone !== undefined && { phone }),
|
|
||||||
...(tipo !== undefined && { tipo }),
|
|
||||||
...(status !== undefined && { status }),
|
|
||||||
...(cep !== undefined && { cep }),
|
|
||||||
...(logradouro !== undefined && { logradouro }),
|
|
||||||
...(numero !== undefined && { numero }),
|
|
||||||
...(complemento !== undefined && { complemento }),
|
|
||||||
...(bairro !== undefined && { bairro }),
|
|
||||||
...(cidade !== undefined && { cidade }),
|
|
||||||
...(uf !== undefined && { uf }),
|
|
||||||
...(codigoIbge !== undefined && { codigoIbge }),
|
|
||||||
...(regimeTributario !== undefined && { regimeTributario }),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(eq(tenantEmpresas.id, id), eq(tenantEmpresas.tenantId, tenantId)))
|
|
||||||
.returning();
|
|
||||||
res.json(updated);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating empresa:", error);
|
|
||||||
res.status(500).json({ error: "Failed to update empresa" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// SOE routes — placeholder
|
||||||
|
|
||||||
|
export function registerSoeRoutes(_app: Express): void {}
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ Responda APENAS com o código, sem explicações adicionais.`;
|
||||||
: prompt;
|
: prompt;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "arcadia-agent",
|
model: "gpt-4o",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
|
|
@ -228,7 +228,7 @@ Use português brasileiro.`;
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "arcadia-agent",
|
model: "gpt-4o",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@ import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
function requireAuth(req: any, res: any, next: any) {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.get("/courses", async (req: Request, res: Response) => {
|
router.get("/courses", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { category, featured, published } = req.query;
|
const { category, featured, published } = req.query;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { manusService } from "./service";
|
import { manusService } from "./service";
|
||||||
import { db } from "../../db/index";
|
|
||||||
import { manusRuns, manusSteps } from "@shared/schema";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
|
|
||||||
export function registerManusRoutes(app: Express): void {
|
export function registerManusRoutes(app: Express): void {
|
||||||
const handleManusRun = async (req: Request, res: Response) => {
|
const handleManusRun = async (req: Request, res: Response) => {
|
||||||
|
|
@ -56,7 +53,7 @@ export function registerManusRoutes(app: Express): void {
|
||||||
if (!req.isAuthenticated()) {
|
if (!req.isAuthenticated()) {
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
return res.status(401).json({ error: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const runs = await manusService.getUserRuns(req.user!.id);
|
const runs = await manusService.getUserRuns(req.user!.id);
|
||||||
res.json(runs);
|
res.json(runs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -64,40 +61,4 @@ export function registerManusRoutes(app: Express): void {
|
||||||
res.status(500).json({ error: "Failed to get runs" });
|
res.status(500).json({ error: "Failed to get runs" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/manus/runs/:id", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) {
|
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
}
|
|
||||||
const runId = parseInt(req.params.id);
|
|
||||||
manusService.cancelRun(runId); // sinaliza o loop para parar
|
|
||||||
await db.delete(manusSteps).where(eq(manusSteps.runId, runId));
|
|
||||||
await db.delete(manusRuns).where(and(eq(manusRuns.id, runId), eq(manusRuns.userId, req.user!.id)));
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Manus delete run error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to delete run" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/api/manus/runs", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
if (!req.isAuthenticated()) {
|
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
|
||||||
}
|
|
||||||
const userRuns = await manusService.getUserRuns(req.user!.id);
|
|
||||||
const runIds = userRuns.map((r: any) => r.id);
|
|
||||||
if (runIds.length > 0) {
|
|
||||||
for (const id of runIds) {
|
|
||||||
await db.delete(manusSteps).where(eq(manusSteps.runId, id));
|
|
||||||
}
|
|
||||||
await db.delete(manusRuns).where(eq(manusRuns.userId, req.user!.id));
|
|
||||||
}
|
|
||||||
res.json({ success: true, deleted: runIds.length });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Manus delete all runs error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to delete runs" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,75 +12,143 @@ import * as erpnextService from "../erpnext/service";
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
maxRetries: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, assistente empresarial inteligente da Arcádia Suite.
|
const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, um assistente empresarial inteligente e proativo.
|
||||||
|
|
||||||
IDENTIDADE:
|
Você executa tarefas usando as ferramentas disponíveis.
|
||||||
- Você é o Manus, IA da Arcádia Suite. Se perguntado: "Sou o Manus, agente IA da Arcádia Suite."
|
Você opera em ciclos de pensamento-ação:
|
||||||
- Não revele detalhes técnicos da infraestrutura (modelos, APIs, servidores).
|
1. PENSAMENTO: Analise a situação e decida o próximo passo
|
||||||
|
2. AÇÃO: Execute uma ferramenta
|
||||||
REGRAS DE COMPORTAMENTO:
|
3. OBSERVAÇÃO: Analise o resultado
|
||||||
- Seja PROATIVO: execute análises sem pedir confirmação desnecessária
|
4. Repita até completar a tarefa
|
||||||
- Para perguntas simples e cálculos: responda DIRETO, sem usar ferramentas
|
|
||||||
- NUNCA adicione SWOT, Canvas, PDCA, frameworks ou análises NÃO solicitados
|
|
||||||
- Para AÇÕES DESTRUTIVAS (deletar, modificar dados): informe o que será feito antes de executar
|
|
||||||
- Máximo de 10 passos por execução
|
|
||||||
- Complete SEMPRE a tarefa e apresente o resultado final
|
|
||||||
|
|
||||||
ANÁLISE DE DADOS (quando usar ferramentas de dados):
|
|
||||||
- Apresente dados em TABELAS MARKDOWN formatadas (| Coluna | Valor |)
|
|
||||||
- Calcule variações percentuais, identifique tendências
|
|
||||||
- Forneça insights e interpretações, não só números brutos
|
|
||||||
- Ao analisar documentos: extraia dados E forneça interpretação completa
|
|
||||||
|
|
||||||
CRIAÇÃO DE GRÁFICOS:
|
|
||||||
- Use generate_chart para gráficos visuais (NÃO use python_execute para isso)
|
|
||||||
- Tipos: bar (barras), line (linha), pie (pizza), area (área)
|
|
||||||
- Dados: JSON array — [{"name":"2023","valor":100}]
|
|
||||||
- Use múltiplas séries quando fizer sentido
|
|
||||||
|
|
||||||
PESQUISA E CONHECIMENTO:
|
|
||||||
- Para PESQUISA PROFUNDA: use deep_research (busca e sintetiza múltiplas fontes)
|
|
||||||
- Para APRENDER uma URL: use learn_url
|
|
||||||
- Para BUSCAR conhecimento interno: use semantic_search PRIMEIRO, depois deep_research se necessário
|
|
||||||
- Para NAVEGAR em página: use web_browse
|
|
||||||
- NUNCA diga "não consigo acessar" — sempre tente as ferramentas
|
|
||||||
|
|
||||||
MÓDULO BI (ARCÁDIA INSIGHTS):
|
|
||||||
- bi_stats: estatísticas gerais do BI
|
|
||||||
- bi_list_tables: tabelas disponíveis
|
|
||||||
- bi_get_table_columns: colunas de uma tabela
|
|
||||||
- bi_create_dataset: criar consultas SQL
|
|
||||||
- bi_execute_query: executar dataset e obter dados
|
|
||||||
- bi_create_chart: criar gráficos persistentes
|
|
||||||
- bi_create_dashboard: organizar gráficos em painéis
|
|
||||||
|
|
||||||
COMUNICAÇÃO ENTRE AGENTES (A2A):
|
|
||||||
- list_agents: ver agentes disponíveis
|
|
||||||
- call_agent: delegar tarefas a agentes especializados
|
|
||||||
- Para tarefas especializadas (fiscal, jurídico, vendas): verifique agentes registrados
|
|
||||||
|
|
||||||
FERRAMENTAS DISPONÍVEIS:
|
FERRAMENTAS DISPONÍVEIS:
|
||||||
${getToolsDescription()}
|
${getToolsDescription()}
|
||||||
|
|
||||||
FORMATO DE RESPOSTA OBRIGATÓRIO (JSON):
|
REGRAS DE AUTONOMIA:
|
||||||
{"thought": "raciocínio sobre o próximo passo", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
|
- Para ANÁLISES e CONSULTAS: seja proativo e execute sem pedir confirmação
|
||||||
|
- Para GERAÇÃO DE CÓDIGO: execute o código e apresente o resultado
|
||||||
|
- Se uma ferramenta falhar, tente uma alternativa ou apresente o que conseguiu
|
||||||
|
- NUNCA fique "aguardando resposta" no meio da tarefa - complete sempre
|
||||||
|
- Se não conseguir gerar um gráfico visualmente, forneça os dados em formato de tabela
|
||||||
|
- Para AÇÕES DESTRUTIVAS (deletar, modificar dados críticos): informe o que será feito na resposta final
|
||||||
|
- Sempre complete a tarefa e apresente o resultado ao final
|
||||||
|
- Máximo de 10 passos por execução
|
||||||
|
|
||||||
Para concluir:
|
COMPORTAMENTO IMPORTANTE:
|
||||||
{"thought": "raciocínio final", "tool": "finish", "tool_input": {"answer": "resposta COMPLETA com dados, tabelas, análise e conclusão"}}
|
- Quando o usuário pedir análise de dados, faça uma análise COMPLETA e PROFISSIONAL
|
||||||
|
- Sempre forneça insights e interpretações, não apenas os números brutos
|
||||||
|
- Calcule variações percentuais, identifique tendências e faça observações relevantes
|
||||||
|
- Apresente dados em TABELAS FORMATADAS usando Markdown quando apropriado
|
||||||
|
|
||||||
REGRA CRÍTICA: O campo "answer" deve conter TODO o conteúdo — tabelas, cálculos, insights. NUNCA apenas "relatório gerado".`;
|
FORMATO DE RESPOSTA IDEAL:
|
||||||
|
1. Primeiro, apresente uma TABELA com os dados extraídos (use formato Markdown: | Coluna | Valor |)
|
||||||
|
2. Em seguida, forneça uma ANÁLISE explicativa com insights (variações %, tendências, observações)
|
||||||
|
3. Por fim, gere um GRÁFICO visual usando generate_chart
|
||||||
|
|
||||||
|
CRIAÇÃO DE GRÁFICOS:
|
||||||
|
- Use generate_chart para criar gráficos visuais (NÃO use python_execute)
|
||||||
|
- Tipos disponíveis: bar (barras), line (linha), pie (pizza), area (área)
|
||||||
|
- Formate os dados como JSON array: [{"name":"2023","ativo":10844216,"passivo":10844216}]
|
||||||
|
- Inclua múltiplas séries quando fizer sentido (ex: ativo E passivo no mesmo gráfico)
|
||||||
|
|
||||||
|
EXEMPLO DE RESPOSTA COMPLETA:
|
||||||
|
1. analyze_file -> extrair dados do documento
|
||||||
|
2. generate_chart -> criar gráfico visual
|
||||||
|
3. finish -> apresentar tabela + análise + conclusão
|
||||||
|
|
||||||
|
Sempre calcule e mencione:
|
||||||
|
- Variações percentuais entre períodos
|
||||||
|
- Tendências (crescimento/queda)
|
||||||
|
- Observações sobre equilíbrio contábil quando aplicável
|
||||||
|
|
||||||
|
PESQUISA INTELIGENTE:
|
||||||
|
- Para PESQUISA PROFUNDA sobre um tema: use deep_research (busca, extrai e sintetiza múltiplas fontes)
|
||||||
|
- Para APRENDER conteúdo de uma URL específica: use learn_url
|
||||||
|
- Para BUSCAR no conhecimento já aprendido: use semantic_search PRIMEIRO
|
||||||
|
- Para NAVEGAR e extrair conteúdo de uma página: use web_browse
|
||||||
|
|
||||||
|
ESTRATÉGIA DE PESQUISA (siga esta ordem):
|
||||||
|
1. PRIMEIRO: Consulte semantic_search para ver se já temos informações sobre o assunto
|
||||||
|
2. SE não houver informações: Use deep_research para pesquisar na web e aprender
|
||||||
|
3. SE o usuário fornecer uma URL específica: Use web_browse ou learn_url
|
||||||
|
4. SEMPRE sintetize e apresente uma resposta completa
|
||||||
|
|
||||||
|
REGRAS DE PESQUISA:
|
||||||
|
- Quando o usuário pedir para "pesquisar sobre X", use deep_research
|
||||||
|
- Quando o usuário mencionar URLs, use web_browse para ver ou learn_url para salvar
|
||||||
|
- Seja PROATIVO: se não encontrar na base interna, pesquise na web automaticamente
|
||||||
|
- NUNCA diga "não consigo acessar" - sempre tente as ferramentas disponíveis
|
||||||
|
|
||||||
|
MÓDULO DE BI (ARCÁDIA INSIGHTS):
|
||||||
|
- Use bi_stats para ver estatísticas gerais do BI
|
||||||
|
- Use bi_list_tables para listar tabelas disponíveis no banco
|
||||||
|
- Use bi_get_table_columns para ver colunas de uma tabela
|
||||||
|
- Use bi_create_dataset para criar consultas SQL ou selecionar tabelas
|
||||||
|
- Use bi_execute_query para executar um dataset e obter dados
|
||||||
|
- Use bi_create_chart para criar gráficos persistentes no BI
|
||||||
|
- Use bi_create_dashboard para organizar gráficos em painéis
|
||||||
|
- Os recursos do BI ficam salvos permanentemente no sistema
|
||||||
|
|
||||||
|
COMUNICAÇÃO ENTRE AGENTES (A2A - Agent to Agent):
|
||||||
|
- Use list_agents para ver agentes externos disponíveis
|
||||||
|
- Use register_agent para adicionar um novo agente externo
|
||||||
|
- Use discover_agent para descobrir capacidades de um agente via Agent Card
|
||||||
|
- Use call_agent para enviar mensagens e delegar tarefas a outros agentes
|
||||||
|
- Você pode orquestrar múltiplos agentes para tarefas complexas
|
||||||
|
- Agentes podem ter especializações (fiscal, jurídico, vendas, etc.)
|
||||||
|
|
||||||
|
ESTRATÉGIA DE ORQUESTRAÇÃO:
|
||||||
|
- Para tarefas especializadas: verifique se há um agente especialista registrado
|
||||||
|
- Para tarefas complexas: divida em subtarefas e delegue para agentes apropriados
|
||||||
|
- Sempre sintetize as respostas dos agentes antes de apresentar ao usuário
|
||||||
|
|
||||||
|
Responda SEMPRE em formato JSON:
|
||||||
|
{
|
||||||
|
"thought": "Seu raciocínio sobre o próximo passo",
|
||||||
|
"tool": "nome_da_ferramenta",
|
||||||
|
"tool_input": { "param1": "valor1" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Quando concluir, use:
|
||||||
|
{
|
||||||
|
"thought": "Raciocínio final",
|
||||||
|
"tool": "finish",
|
||||||
|
"tool_input": { "answer": "Resposta final COMPLETA com TODOS os dados e análise" }
|
||||||
|
}
|
||||||
|
|
||||||
|
REGRA CRÍTICA PARA RESPOSTA FINAL:
|
||||||
|
- A resposta no campo "answer" deve conter TODO o conteúdo da análise
|
||||||
|
- NUNCA diga apenas "relatório gerado com sucesso" - inclua o conteúdo completo
|
||||||
|
- Inclua: tabelas de dados, cálculos, variações percentuais, insights e conclusões
|
||||||
|
- O usuário quer ver a análise completa, não apenas uma confirmação
|
||||||
|
- Se analisou um documento, inclua os dados extraídos E sua interpretação`;
|
||||||
|
|
||||||
class ManusService extends EventEmitter {
|
class ManusService extends EventEmitter {
|
||||||
private cancelledRuns = new Set<number>();
|
private pendingApprovals: Map<string, { tool: string; input: Record<string, any> }> = new Map();
|
||||||
|
|
||||||
cancelRun(runId: number) {
|
private async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
|
||||||
this.cancelledRuns.add(runId);
|
// Dangerous tools require explicit user approval via ask_human first
|
||||||
setTimeout(() => this.cancelledRuns.delete(runId), 60000); // limpa após 1 min
|
const DANGEROUS_TOOLS = new Set(["shell", "write_file", "python_execute"]);
|
||||||
}
|
if (DANGEROUS_TOOLS.has(tool)) {
|
||||||
|
const approvalKey = `${userId}:${tool}:${JSON.stringify(input)}`;
|
||||||
|
if (!this.pendingApprovals.has(approvalKey)) {
|
||||||
|
this.pendingApprovals.set(approvalKey, { tool, input });
|
||||||
|
const preview = tool === "shell" ? input.command
|
||||||
|
: tool === "write_file" ? `Escrever em: ${input.path}`
|
||||||
|
: `Executar código Python (${String(input.code || "").substring(0, 80)}...)`;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: `[APROVAÇÃO NECESSÁRIA] Esta ação requer confirmação: ${preview}. Use ask_human para solicitar aprovação antes de prosseguir.`,
|
||||||
|
error: "requires_approval"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.pendingApprovals.delete(approvalKey);
|
||||||
|
}
|
||||||
|
|
||||||
async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
|
|
||||||
try {
|
try {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "web_search":
|
case "web_search":
|
||||||
|
|
@ -267,6 +335,13 @@ class ManusService extends EventEmitter {
|
||||||
return this.toolRetailStats(input.period, input.storeId);
|
return this.toolRetailStats(input.period, input.storeId);
|
||||||
case "retail_report":
|
case "retail_report":
|
||||||
return this.toolRetailReport(input.type, input.dateFrom, input.dateTo, input.storeId);
|
return this.toolRetailReport(input.type, input.dateFrom, input.dateTo, input.storeId);
|
||||||
|
// ========== AUTOMAÇÃO + XOS + INBOX ==========
|
||||||
|
case "automation_trigger":
|
||||||
|
return this.toolAutomationTrigger(input.automation_id, input.event_type, input.payload, input.tenant_id, userId);
|
||||||
|
case "xos_action":
|
||||||
|
return this.toolXosAction(input.action, input.data, userId);
|
||||||
|
case "inbox_action":
|
||||||
|
return this.toolInboxAction(input.action, input.conversation_id, input.data, userId);
|
||||||
case "finish":
|
case "finish":
|
||||||
let finishOutput = input.answer || "";
|
let finishOutput = input.answer || "";
|
||||||
if (input.chart) {
|
if (input.chart) {
|
||||||
|
|
@ -2184,19 +2259,6 @@ class ManusService extends EventEmitter {
|
||||||
return { runId: run.id };
|
return { runId: run.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async runSync(userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>): Promise<string> {
|
|
||||||
const [run] = await db.insert(manusRuns).values({
|
|
||||||
userId,
|
|
||||||
prompt,
|
|
||||||
status: "running"
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
await this.executeAgentLoop(run.id, userId, prompt, attachedFiles);
|
|
||||||
|
|
||||||
const [completed] = await db.select().from(manusRuns).where(eq(manusRuns.id, run.id));
|
|
||||||
return completed?.result || "Não foi possível processar a solicitação.";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeAgentLoop(runId: number, userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>, conversationHistory?: Array<{role: string; content: string}>) {
|
private async executeAgentLoop(runId: number, userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>, conversationHistory?: Array<{role: string; content: string}>) {
|
||||||
let userPrompt = prompt;
|
let userPrompt = prompt;
|
||||||
if (attachedFiles && attachedFiles.length > 0) {
|
if (attachedFiles && attachedFiles.length > 0) {
|
||||||
|
|
@ -2206,7 +2268,7 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
// Build messages with conversation history for context
|
// Build messages with conversation history for context
|
||||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||||
{ role: "system", content: SYSTEM_PROMPT + `\n\nDATA/HORA ATUAL: ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo', dateStyle: 'full', timeStyle: 'short' })}` }
|
{ role: "system", content: SYSTEM_PROMPT }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add conversation history if available
|
// Add conversation history if available
|
||||||
|
|
@ -2229,26 +2291,14 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
while (step < maxSteps && !finished) {
|
while (step < maxSteps && !finished) {
|
||||||
step++;
|
step++;
|
||||||
|
|
||||||
// Verificar cancelamento antes de cada step
|
|
||||||
if (this.cancelledRuns.has(runId)) {
|
|
||||||
this.cancelledRuns.delete(runId);
|
|
||||||
await db.update(manusRuns).set({ status: "stopped", completedAt: new Date() }).where(eq(manusRuns.id, runId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const STEP_TIMEOUT_MS = 60000; // 60s por step
|
const response = await openai.chat.completions.create({
|
||||||
const responsePromise = openai.chat.completions.create({
|
model: "gpt-4o",
|
||||||
model: "arcadia-agent",
|
|
||||||
messages,
|
messages,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
max_tokens: 1500,
|
max_tokens: 4000,
|
||||||
});
|
});
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Step timeout (60s)")), STEP_TIMEOUT_MS)
|
|
||||||
);
|
|
||||||
const response = await Promise.race([responsePromise, timeoutPromise]);
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content || "";
|
const content = response.choices[0]?.message?.content || "";
|
||||||
messages.push({ role: "assistant", content });
|
messages.push({ role: "assistant", content });
|
||||||
|
|
@ -3828,6 +3878,253 @@ class ManusService extends EventEmitter {
|
||||||
return { success: false, output: "", error: `Erro ao gerar relatório: ${error.message}` };
|
return { success: false, output: "", error: `Erro ao gerar relatório: ${error.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TOOL: automation_trigger
|
||||||
|
// ============================================================
|
||||||
|
private async toolAutomationTrigger(
|
||||||
|
automationId: number | undefined,
|
||||||
|
eventType: string | undefined,
|
||||||
|
payload: string | undefined,
|
||||||
|
tenantId: number | undefined,
|
||||||
|
userId: string
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const parsedPayload = payload ? (typeof payload === 'string' ? JSON.parse(payload) : payload) : {};
|
||||||
|
|
||||||
|
if (automationId) {
|
||||||
|
// Direct automation execution via AutomationService
|
||||||
|
const { automationService } = await import("../automations/service");
|
||||||
|
const result = await automationService.runAutomation(automationId, userId, { ...parsedPayload, triggered_by: "manus" });
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
output: `Automação #${automationId} ${result.success ? 'executada com sucesso' : 'falhou'}. Log ID: ${result.logId}. ${result.result || result.error || ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType) {
|
||||||
|
// Emit event to automation engine via HTTP
|
||||||
|
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
|
||||||
|
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
|
||||||
|
const response = await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event_type: eventType, tenant_id: tenantId, payload: parsedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Engine retornou ${response.status}`);
|
||||||
|
const result: any = await response.json();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: `Evento '${eventType}' emitido. ${result.triggered_handlers?.length || 0} handlers ativados.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, output: "", error: "Informe automation_id ou event_type" };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, output: "", error: `Erro ao disparar automação: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TOOL: xos_action
|
||||||
|
// ============================================================
|
||||||
|
private async toolXosAction(action: string, data: string | object, userId: string): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "create_contact": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_contacts (name, email, phone, whatsapp, type, company, position, source, tags, notes)
|
||||||
|
VALUES (${parsed.name}, ${parsed.email || null}, ${parsed.phone || null}, ${parsed.whatsapp || null},
|
||||||
|
${parsed.type || 'lead'}, ${parsed.company || null}, ${parsed.position || null},
|
||||||
|
${parsed.source || 'manus'}, ${parsed.tags || null}, ${parsed.notes || null})
|
||||||
|
RETURNING id, name, email, type
|
||||||
|
`);
|
||||||
|
const contact = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Contato criado: ${contact.name} (ID: ${contact.id}, tipo: ${contact.type})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "update_contact": {
|
||||||
|
const { id, ...fields } = parsed;
|
||||||
|
if (!id) return { success: false, output: "", error: "id obrigatório para update_contact" };
|
||||||
|
const sets = Object.entries(fields).map(([k, v]) => `${k} = '${v}'`).join(", ");
|
||||||
|
await db.execute(sql`UPDATE xos_contacts SET ${sql.raw(sets)}, updated_at = NOW() WHERE id = ${id}`);
|
||||||
|
return { success: true, output: `Contato #${id} atualizado com sucesso.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_deal": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_deals (title, pipeline_id, stage_id, contact_id, company_id, value, currency, assigned_to, expected_close_date, notes)
|
||||||
|
VALUES (${parsed.title}, ${parsed.pipeline_id}, ${parsed.stage_id}, ${parsed.contact_id || null},
|
||||||
|
${parsed.company_id || null}, ${parsed.value || null}, ${parsed.currency || 'BRL'},
|
||||||
|
${parsed.assigned_to || null}, ${parsed.expected_close_date || null}, ${parsed.notes || null})
|
||||||
|
RETURNING id, title, value
|
||||||
|
`);
|
||||||
|
const deal = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Deal criado: "${deal.title}" (ID: ${deal.id}, valor: ${deal.value})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "move_deal_stage": {
|
||||||
|
const { deal_id, stage_id } = parsed;
|
||||||
|
if (!deal_id || !stage_id) return { success: false, output: "", error: "deal_id e stage_id obrigatórios" };
|
||||||
|
await db.execute(sql`UPDATE xos_deals SET stage_id = ${stage_id}, updated_at = NOW() WHERE id = ${deal_id}`);
|
||||||
|
return { success: true, output: `Deal #${deal_id} movido para estágio #${stage_id}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_ticket": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_tickets (title, description, contact_id, conversation_id, priority, status, category, assigned_to)
|
||||||
|
VALUES (${parsed.title}, ${parsed.description || null}, ${parsed.contact_id || null},
|
||||||
|
${parsed.conversation_id || null}, ${parsed.priority || 'medium'}, 'open',
|
||||||
|
${parsed.category || null}, ${parsed.assigned_to || null})
|
||||||
|
RETURNING id, title, priority
|
||||||
|
`);
|
||||||
|
const ticket = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Ticket criado: "${ticket.title}" (ID: ${ticket.id}, prioridade: ${ticket.priority})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assign_agent": {
|
||||||
|
const { conversation_id, agent_id } = parsed;
|
||||||
|
if (!conversation_id) return { success: false, output: "", error: "conversation_id obrigatório" };
|
||||||
|
await db.execute(sql`UPDATE xos_conversations SET assigned_to = ${agent_id}, updated_at = NOW() WHERE id = ${conversation_id}`);
|
||||||
|
return { success: true, output: `Agente #${agent_id} atribuído à conversa #${conversation_id}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_task": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_activities (contact_id, deal_id, type, title, description, due_date, assigned_to, status)
|
||||||
|
VALUES (${parsed.contact_id || null}, ${parsed.deal_id || null}, 'task',
|
||||||
|
${parsed.title}, ${parsed.description || null},
|
||||||
|
${parsed.due_date || null}, ${parsed.assigned_to || null}, 'pending')
|
||||||
|
RETURNING id, title
|
||||||
|
`);
|
||||||
|
const task = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Tarefa criada: "${task.title}" (ID: ${task.id})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_activity": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_activities (contact_id, deal_id, type, title, description, scheduled_at, assigned_to)
|
||||||
|
VALUES (${parsed.contact_id || null}, ${parsed.deal_id || null}, ${parsed.type || 'note'},
|
||||||
|
${parsed.title}, ${parsed.description || null},
|
||||||
|
${parsed.scheduled_at || null}, ${parsed.assigned_to || null})
|
||||||
|
RETURNING id, title, type
|
||||||
|
`);
|
||||||
|
const act = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Atividade criada: "${act.title}" (tipo: ${act.type}, ID: ${act.id})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_note": {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_internal_notes (conversation_id, content, created_by, is_pinned)
|
||||||
|
VALUES (${parsed.conversation_id || null}, ${parsed.content}, ${userId}, ${parsed.is_pinned || false})
|
||||||
|
RETURNING id
|
||||||
|
`);
|
||||||
|
const note = (result.rows || result)[0] as any;
|
||||||
|
return { success: true, output: `Nota interna criada (ID: ${note.id})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, output: "", error: `Ação XOS desconhecida: ${action}. Use: create_contact, update_contact, create_deal, move_deal_stage, create_ticket, assign_agent, create_task, create_activity, create_note` };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, output: "", error: `Erro na ação XOS '${action}': ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TOOL: inbox_action
|
||||||
|
// ============================================================
|
||||||
|
private async toolInboxAction(
|
||||||
|
action: string,
|
||||||
|
conversationId: number | undefined,
|
||||||
|
data: string | object | undefined,
|
||||||
|
userId: string
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const parsed = data ? (typeof data === 'string' ? JSON.parse(data) : data) : {} as any;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "close_conversation": {
|
||||||
|
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_conversations SET status = 'closed', closed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = ${conversationId}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Conversa #${conversationId} fechada.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "transfer_conversation": {
|
||||||
|
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
|
||||||
|
const { queue_id, agent_id } = parsed;
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_conversations SET
|
||||||
|
queue_id = COALESCE(${queue_id || null}, queue_id),
|
||||||
|
assigned_to = COALESCE(${agent_id || null}, assigned_to),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${conversationId}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Conversa #${conversationId} transferida para fila #${queue_id || 'N/A'} / agente #${agent_id || 'N/A'}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "send_message": {
|
||||||
|
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
|
||||||
|
const { content, content_type } = parsed;
|
||||||
|
if (!content) return { success: false, output: "", error: "content obrigatório" };
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO xos_messages (conversation_id, direction, sender_type, sender_name, content, content_type)
|
||||||
|
VALUES (${conversationId}, 'outbound', 'agent', 'Manus IA', ${content}, ${content_type || 'text'})
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_conversations SET last_message = ${content}, updated_at = NOW() WHERE id = ${conversationId}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Mensagem enviada na conversa #${conversationId}: "${content.substring(0, 100)}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add_label": {
|
||||||
|
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
|
||||||
|
const { label } = parsed;
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_conversations SET
|
||||||
|
tags = COALESCE(tags, '') || ${label ? ',' + label : ''},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${conversationId}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Etiqueta '${label}' adicionada à conversa #${conversationId}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "resolve_ticket": {
|
||||||
|
const { ticket_id, resolution } = parsed;
|
||||||
|
if (!ticket_id) return { success: false, output: "", error: "ticket_id obrigatório" };
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_tickets SET status = 'resolved', resolution = ${resolution || null},
|
||||||
|
resolved_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = ${ticket_id}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Ticket #${ticket_id} resolvido.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "escalate_ticket": {
|
||||||
|
const { ticket_id, priority, reason } = parsed;
|
||||||
|
if (!ticket_id) return { success: false, output: "", error: "ticket_id obrigatório" };
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_tickets SET priority = ${priority || 'urgent'},
|
||||||
|
notes = CONCAT(COALESCE(notes, ''), ' [Escalado por Manus: ', ${reason || 'sem motivo'}, ']'),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${ticket_id}
|
||||||
|
`);
|
||||||
|
return { success: true, output: `Ticket #${ticket_id} escalado para prioridade ${priority || 'urgent'}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, output: "", error: `Ação de inbox desconhecida: ${action}. Use: close_conversation, transfer_conversation, send_message, add_label, resolve_ticket, escalate_ticket` };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, output: "", error: `Erro na ação de inbox '${action}': ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const manusService = new ManusService();
|
export const manusService = new ManusService();
|
||||||
|
|
|
||||||
|
|
@ -733,6 +733,33 @@ export const MANUS_TOOLS: ManusToolDef[] = [
|
||||||
dateTo: { type: "string", description: "Data final (YYYY-MM-DD)", required: false },
|
dateTo: { type: "string", description: "Data final (YYYY-MM-DD)", required: false },
|
||||||
storeId: { type: "number", description: "ID da loja para filtrar", required: false }
|
storeId: { type: "number", description: "ID da loja para filtrar", required: false }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "automation_trigger",
|
||||||
|
description: "Dispara uma automação existente ou emite um evento no barramento. Use para encadear automações, disparar workflows CRM, ou emitir eventos personalizados que ativam outras automações.",
|
||||||
|
parameters: {
|
||||||
|
automation_id: { type: "number", description: "ID da automação a executar (opcional se usar event_type)", required: false },
|
||||||
|
event_type: { type: "string", description: "Tipo de evento a emitir no barramento: crm.contact.created, crm.deal.stage_changed, crm.ticket.created, crm.message.received, manual.trigger, system.event, etc.", required: false },
|
||||||
|
payload: { type: "string", description: "Dados adicionais em JSON para passar à automação ou ao evento (ex: '{\"contact_id\": 42, \"stage\": \"qualified\"}')", required: false },
|
||||||
|
tenant_id: { type: "number", description: "ID do tenant para automações multi-tenant (xos_automations)", required: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xos_action",
|
||||||
|
description: "Executa ações no XOS CRM: criar/atualizar contatos, mover deals no pipeline, criar tickets, atribuir agentes, criar tarefas, enviar campanhas. Use para automatizar operações de CRM via IA.",
|
||||||
|
parameters: {
|
||||||
|
action: { type: "string", description: "Ação a executar: create_contact, update_contact, create_deal, move_deal_stage, create_ticket, assign_agent, create_task, create_activity, send_campaign, create_note", required: true },
|
||||||
|
data: { type: "string", description: "Dados para a ação em JSON. Ex: create_contact: '{\"name\":\"João\",\"email\":\"joao@ex.com\",\"type\":\"lead\"}'; move_deal_stage: '{\"deal_id\":5,\"stage_id\":3}'; create_ticket: '{\"title\":\"Bug\",\"contact_id\":2,\"priority\":\"high\"}'", required: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inbox_action",
|
||||||
|
description: "Executa ações na Central de Atendimento: fechar/transferir conversas, enviar mensagens automáticas, atribuir agentes, adicionar etiquetas, criar protocolos, alterar status de tickets de suporte.",
|
||||||
|
parameters: {
|
||||||
|
action: { type: "string", description: "Ação: close_conversation, transfer_conversation, send_message, assign_agent, add_label, create_protocol, resolve_ticket, escalate_ticket, send_csat", required: true },
|
||||||
|
conversation_id: { type: "number", description: "ID da conversa (obrigatório para ações em conversas existentes)", required: false },
|
||||||
|
data: { type: "string", description: "Dados adicionais em JSON. Ex: transfer: '{\"queue_id\":2}'; send_message: '{\"content\":\"Olá!\",\"type\":\"text\"}'; assign_agent: '{\"agent_id\":5}'", required: false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,52 @@ import { eq, desc, and, sql } from "drizzle-orm";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
function requireAuth(req: any, res: any, next: any) {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// My apps: returns active subscriptions for the current user's tenant
|
||||||
|
// Public modules (isCore=true) are always included
|
||||||
|
router.get("/my-apps", requireAuth, async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenantId;
|
||||||
|
|
||||||
|
// Core modules always available
|
||||||
|
const coreModules = await db
|
||||||
|
.select({ code: marketplaceModules.code, route: marketplaceModules.route })
|
||||||
|
.from(marketplaceModules)
|
||||||
|
.where(and(eq(marketplaceModules.isCore, true), eq(marketplaceModules.isActive, true)));
|
||||||
|
|
||||||
|
// Subscribed modules for this tenant
|
||||||
|
const subscribed = tenantId
|
||||||
|
? await db
|
||||||
|
.select({ code: marketplaceModules.code, route: marketplaceModules.route })
|
||||||
|
.from(moduleSubscriptions)
|
||||||
|
.innerJoin(marketplaceModules, eq(moduleSubscriptions.moduleId, marketplaceModules.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(moduleSubscriptions.tenantId, tenantId),
|
||||||
|
eq(moduleSubscriptions.status, "active"),
|
||||||
|
eq(marketplaceModules.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allCodes = new Set([
|
||||||
|
...coreModules.map((m) => m.code),
|
||||||
|
...subscribed.map((m) => m.code),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ subscribedCodes: [...allCodes], tenantId: tenantId || null });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching my-apps:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch subscribed apps" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/modules", async (req: Request, res: Response) => {
|
router.get("/modules", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const modules = await db
|
const modules = await db
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,17 @@ import { metasetClient } from "./client";
|
||||||
const METASET_HOST = process.env.METABASE_HOST || "localhost";
|
const METASET_HOST = process.env.METABASE_HOST || "localhost";
|
||||||
const METASET_PORT = parseInt(process.env.METABASE_PORT || "8088", 10);
|
const METASET_PORT = parseInt(process.env.METABASE_PORT || "8088", 10);
|
||||||
const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`;
|
const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`;
|
||||||
const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL || "admin@arcadia.app";
|
const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL;
|
||||||
const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD || "Arcadia2026!BI";
|
const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD;
|
||||||
|
|
||||||
export function registerMetaSetRoutes(app: Express): void {
|
export function registerMetaSetRoutes(app: Express): void {
|
||||||
|
|
||||||
app.get("/api/bi/metaset/autologin", async (req: Request, res: Response) => {
|
app.get("/api/bi/metaset/autologin", 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" });
|
||||||
|
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
||||||
|
return res.status(503).json({ error: "MetaSet credentials not configured (METASET_ADMIN_EMAIL / METASET_ADMIN_PASSWORD)" });
|
||||||
|
}
|
||||||
|
|
||||||
const sessionResp = await fetch(`${METASET_URL}/api/session`, {
|
const sessionResp = await fetch(`${METASET_URL}/api/session`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,6 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
|
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
|
||||||
else if (fileName.endsWith('.json')) sourceType = 'json';
|
else if (fileName.endsWith('.json')) sourceType = 'json';
|
||||||
else if (fileName.endsWith('.csv')) sourceType = 'csv';
|
else if (fileName.endsWith('.csv')) sourceType = 'csv';
|
||||||
else if (fileName.endsWith('.rar')) sourceType = 'sql-rar';
|
|
||||||
else if (fileName.endsWith('.sql.gz') || fileName.endsWith('.sql')) sourceType = 'sql';
|
|
||||||
|
|
||||||
const [job] = await db.insert(migrationJobs).values({
|
const [job] = await db.insert(migrationJobs).values({
|
||||||
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
|
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
|
||||||
|
|
@ -110,26 +108,26 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
|
|
||||||
await execAsync(`unzip -o "${filePath}" -d "${extractDir}"`);
|
await execAsync(`unzip -o "${filePath}" -d "${extractDir}"`);
|
||||||
|
|
||||||
const subdirs = fs.readdirSync(extractDir).filter(f =>
|
const subdirs = fs.readdirSync(extractDir).filter(f =>
|
||||||
fs.statSync(path.join(extractDir, f)).isDirectory() &&
|
fs.statSync(path.join(extractDir, f)).isDirectory() &&
|
||||||
fs.readdirSync(path.join(extractDir, f)).some(sf => sf.endsWith('.bson'))
|
fs.readdirSync(path.join(extractDir, f)).some(sf => sf.endsWith('.bson'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const bsonDir = subdirs.length > 0
|
const bsonDir = subdirs.length > 0
|
||||||
? path.join(extractDir, subdirs[0])
|
? path.join(extractDir, subdirs[0])
|
||||||
: extractDir;
|
: extractDir;
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
await db.update(migrationJobs)
|
||||||
.set({
|
.set({
|
||||||
status: 'analyzing',
|
status: 'analyzing',
|
||||||
importConfig: { extractPath: bsonDir }
|
importConfig: { extractPath: bsonDir }
|
||||||
})
|
})
|
||||||
.where(eq(migrationJobs.id, job.id));
|
.where(eq(migrationJobs.id, job.id));
|
||||||
|
|
||||||
const analysis = analyzeBackupDirectory(bsonDir);
|
const analysis = analyzeBackupDirectory(bsonDir);
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
await db.update(migrationJobs)
|
||||||
.set({
|
.set({
|
||||||
status: 'mapping',
|
status: 'mapping',
|
||||||
totalRecords: analysis.totalRecords,
|
totalRecords: analysis.totalRecords,
|
||||||
analysisResult: analysis
|
analysisResult: analysis
|
||||||
|
|
@ -150,87 +148,6 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (sourceType === 'sql-rar') {
|
|
||||||
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
|
|
||||||
fs.mkdirSync(extractDir, { recursive: true });
|
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
|
||||||
.set({ status: 'analyzing' })
|
|
||||||
.where(eq(migrationJobs.id, job.id));
|
|
||||||
|
|
||||||
await execAsync(`7z x "${filePath}" -o"${extractDir}" -y`);
|
|
||||||
|
|
||||||
const allFiles = fs.readdirSync(extractDir);
|
|
||||||
const sqlGzFile = allFiles.find(f => f.endsWith('.sql.gz'));
|
|
||||||
const sqlFile = allFiles.find(f => f.endsWith('.sql'));
|
|
||||||
|
|
||||||
let finalSqlPath: string;
|
|
||||||
if (sqlGzFile) {
|
|
||||||
const gzPath = path.join(extractDir, sqlGzFile);
|
|
||||||
finalSqlPath = gzPath.replace(/\.gz$/, '');
|
|
||||||
await execAsync(`gunzip -f "${gzPath}"`);
|
|
||||||
} else if (sqlFile) {
|
|
||||||
finalSqlPath = path.join(extractDir, sqlFile);
|
|
||||||
} else {
|
|
||||||
throw new Error('Nenhum arquivo .sql ou .sql.gz encontrado dentro do RAR');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
|
|
||||||
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
|
|
||||||
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
|
|
||||||
const lineCount = sqlContent.split('\n').length;
|
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
|
||||||
.set({
|
|
||||||
status: 'mapping',
|
|
||||||
totalRecords: lineCount,
|
|
||||||
analysisResult: {
|
|
||||||
type: 'sql',
|
|
||||||
sqlPath: finalSqlPath,
|
|
||||||
tables,
|
|
||||||
tableCount: tables.length,
|
|
||||||
lineCount,
|
|
||||||
fileSizeBytes: fs.statSync(finalSqlPath).size
|
|
||||||
},
|
|
||||||
importConfig: { sqlPath: finalSqlPath }
|
|
||||||
})
|
|
||||||
.where(eq(migrationJobs.id, job.id));
|
|
||||||
} else if (sourceType === 'sql') {
|
|
||||||
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
|
|
||||||
fs.mkdirSync(extractDir, { recursive: true });
|
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
|
||||||
.set({ status: 'analyzing' })
|
|
||||||
.where(eq(migrationJobs.id, job.id));
|
|
||||||
|
|
||||||
let finalSqlPath: string;
|
|
||||||
if (fileName.endsWith('.sql.gz')) {
|
|
||||||
finalSqlPath = path.join(extractDir, fileName.replace(/\.gz$/, ''));
|
|
||||||
await execAsync(`gunzip -c "${filePath}" > "${finalSqlPath}"`);
|
|
||||||
} else {
|
|
||||||
finalSqlPath = filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
|
|
||||||
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
|
|
||||||
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
|
|
||||||
const lineCount = sqlContent.split('\n').length;
|
|
||||||
|
|
||||||
await db.update(migrationJobs)
|
|
||||||
.set({
|
|
||||||
status: 'mapping',
|
|
||||||
totalRecords: lineCount,
|
|
||||||
analysisResult: {
|
|
||||||
type: 'sql',
|
|
||||||
sqlPath: finalSqlPath,
|
|
||||||
tables,
|
|
||||||
tableCount: tables.length,
|
|
||||||
lineCount,
|
|
||||||
fileSizeBytes: fs.statSync(finalSqlPath).size
|
|
||||||
},
|
|
||||||
importConfig: { sqlPath: finalSqlPath }
|
|
||||||
})
|
|
||||||
.where(eq(migrationJobs.id, job.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedJob] = await db.select().from(migrationJobs).where(eq(migrationJobs.id, job.id));
|
const [updatedJob] = await db.select().from(migrationJobs).where(eq(migrationJobs.id, job.id));
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,8 @@
|
||||||
/**
|
|
||||||
* Arcádia Suite - Module Routes Auto-Loader
|
|
||||||
*
|
|
||||||
* Carrega automaticamente todas as rotas de módulos em server/modules/.
|
|
||||||
* Cada arquivo exporta um Router do Express, montado em /api/modules/{nome}.
|
|
||||||
*
|
|
||||||
* Arquivos que começam com _ são ignorados (_template.ts, _utils.ts).
|
|
||||||
* O loader é chamado pelo routes.ts central.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Express } from "express";
|
import type { Express } from "express";
|
||||||
import * as fs from "fs";
|
import skillsRouter from "../skills/routes";
|
||||||
import * as path from "path";
|
import openclawRouter from "./openclaw/routes";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
function getDirname(): string {
|
export async function loadModuleRoutes(app: Express): Promise<void> {
|
||||||
try {
|
app.use("/api/skills", skillsRouter);
|
||||||
if (typeof __dirname !== "undefined") return __dirname;
|
app.use("/api/openclaw", openclawRouter);
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
const currentFile = fileURLToPath(import.meta.url);
|
|
||||||
return path.dirname(currentFile);
|
|
||||||
} catch {}
|
|
||||||
return path.resolve("server/modules");
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentDir = getDirname();
|
|
||||||
|
|
||||||
export async function loadModuleRoutes(app: Express): Promise<string[]> {
|
|
||||||
const modulesDir = path.resolve(currentDir);
|
|
||||||
const loaded: string[] = [];
|
|
||||||
|
|
||||||
if (!fs.existsSync(modulesDir)) {
|
|
||||||
return loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(modulesDir);
|
|
||||||
const moduleFiles = files.filter(
|
|
||||||
f => (f.endsWith(".ts") || f.endsWith(".js")) &&
|
|
||||||
!f.startsWith("_") &&
|
|
||||||
f !== "loader.ts" &&
|
|
||||||
f !== "loader.js" &&
|
|
||||||
f !== "migrator.ts" &&
|
|
||||||
f !== "migrator.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const file of moduleFiles) {
|
|
||||||
const moduleName = file.replace(/\.(ts|js)$/, "");
|
|
||||||
try {
|
|
||||||
const modulePath = path.join(modulesDir, file);
|
|
||||||
const mod = await import(modulePath);
|
|
||||||
const router = mod.default || mod.router;
|
|
||||||
|
|
||||||
if (router) {
|
|
||||||
app.use(`/api/modules/${moduleName}`, router);
|
|
||||||
loaded.push(moduleName);
|
|
||||||
console.log(`[ModuleLoader] Módulo carregado: /api/modules/${moduleName}`);
|
|
||||||
} else {
|
|
||||||
console.warn(`[ModuleLoader] Módulo ${moduleName} não exporta router default`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`[ModuleLoader] Erro ao carregar módulo ${moduleName}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded.length > 0) {
|
|
||||||
console.log(`[ModuleLoader] ${loaded.length} módulos carregados: ${loaded.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLoadedModules(): string[] {
|
|
||||||
const modulesDir = path.resolve(currentDir);
|
|
||||||
if (!fs.existsSync(modulesDir)) return [];
|
|
||||||
|
|
||||||
return fs.readdirSync(modulesDir)
|
|
||||||
.filter(f => (f.endsWith(".ts") || f.endsWith(".js")) && !f.startsWith("_") && f !== "loader.ts" && f !== "loader.js" && f !== "migrator.ts" && f !== "migrator.js")
|
|
||||||
.map(f => f.replace(/\.(ts|js)$/, ""));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit bd7ec41b51dd89f4c5551e3d27917b41af4a3793
|
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
/**
|
||||||
|
* OpenClawEngine.ts
|
||||||
|
*
|
||||||
|
* Orquestração central de OpenClaw
|
||||||
|
* Coordena detecção de padrões, geração de skills e sugestões
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Embutido
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PatternDetector, getPatternDetector, DetectedPattern } from "./PatternDetector";
|
||||||
|
import { SkillEmergence } from "./SkillEmergence";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { Server as SocketIOServer } from "socket.io";
|
||||||
|
|
||||||
|
interface SkillSuggestion {
|
||||||
|
id: string;
|
||||||
|
pattern: DetectedPattern;
|
||||||
|
suggested_skill_name: string;
|
||||||
|
suggested_description: string;
|
||||||
|
estimated_automation: string;
|
||||||
|
confidence: number;
|
||||||
|
created_at: Date;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenClawEngineConfig {
|
||||||
|
pattern_detection: {
|
||||||
|
enabled: boolean;
|
||||||
|
min_occurrences: number;
|
||||||
|
time_window_days: number;
|
||||||
|
confidence_threshold: number;
|
||||||
|
scheduled_check_interval_minutes: number;
|
||||||
|
};
|
||||||
|
suggestions: {
|
||||||
|
enabled: boolean;
|
||||||
|
auto_suggest: boolean;
|
||||||
|
channels: string[];
|
||||||
|
priority_threshold: number;
|
||||||
|
};
|
||||||
|
emergence: {
|
||||||
|
create_as_draft: boolean;
|
||||||
|
auto_publish: boolean;
|
||||||
|
notify_admins: boolean;
|
||||||
|
skill_prefix: string;
|
||||||
|
};
|
||||||
|
logging: {
|
||||||
|
enabled: boolean;
|
||||||
|
store_patterns: boolean;
|
||||||
|
store_in_kg: boolean;
|
||||||
|
audit_trail: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenClawEngine
|
||||||
|
*
|
||||||
|
* Motor central que coordena todo o fluxo de OpenClaw:
|
||||||
|
* Pattern Detection → Suggestions → Skill Generation → Dev Center
|
||||||
|
*/
|
||||||
|
export class OpenClawEngine extends EventEmitter {
|
||||||
|
private patternDetector: PatternDetector;
|
||||||
|
private skillEmergence: SkillEmergence;
|
||||||
|
private config: OpenClawEngineConfig;
|
||||||
|
private socketIO?: SocketIOServer;
|
||||||
|
private checkInterval?: NodeJS.Timer;
|
||||||
|
private suggestions: Map<string, SkillSuggestion> = new Map();
|
||||||
|
|
||||||
|
constructor(config: Partial<OpenClawEngineConfig>, socketIO?: SocketIOServer) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Configuração padrão
|
||||||
|
this.config = {
|
||||||
|
pattern_detection: {
|
||||||
|
enabled: true,
|
||||||
|
min_occurrences: 3,
|
||||||
|
time_window_days: 30,
|
||||||
|
confidence_threshold: 0.8,
|
||||||
|
scheduled_check_interval_minutes: 60,
|
||||||
|
...config?.pattern_detection,
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
enabled: true,
|
||||||
|
auto_suggest: false,
|
||||||
|
channels: ["widget", "chat"],
|
||||||
|
priority_threshold: 0.85,
|
||||||
|
...config?.suggestions,
|
||||||
|
},
|
||||||
|
emergence: {
|
||||||
|
create_as_draft: true,
|
||||||
|
auto_publish: false,
|
||||||
|
notify_admins: true,
|
||||||
|
skill_prefix: "emergent_",
|
||||||
|
...config?.emergence,
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
enabled: true,
|
||||||
|
store_patterns: true,
|
||||||
|
store_in_kg: true,
|
||||||
|
audit_trail: true,
|
||||||
|
...config?.logging,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socketIO = socketIO;
|
||||||
|
this.patternDetector = getPatternDetector(this.config.pattern_detection);
|
||||||
|
this.skillEmergence = new SkillEmergence();
|
||||||
|
|
||||||
|
// Setup listeners
|
||||||
|
this.setupListeners();
|
||||||
|
|
||||||
|
console.log("[OpenClawEngine] Inicializado com config:", this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup dos listeners de eventos internos
|
||||||
|
*/
|
||||||
|
private setupListeners(): void {
|
||||||
|
// Listener: quando PatternDetector detecta um padrão
|
||||||
|
this.patternDetector.on("pattern-detected", async (event) => {
|
||||||
|
console.log("[OpenClawEngine] Padrão detectado:", event.pattern);
|
||||||
|
await this.handlePatternDetected(event.pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener: quando usuário confirma uma sugestão
|
||||||
|
this.on("user-confirmed-suggestion", async (suggestionId: string) => {
|
||||||
|
console.log("[OpenClawEngine] Usuário confirmou sugestão:", suggestionId);
|
||||||
|
await this.handleUserConfirmation(suggestionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detectar padrão manualmente
|
||||||
|
*
|
||||||
|
* Pode ser chamado por:
|
||||||
|
* 1. Sistema ao receber uma interação
|
||||||
|
* 2. Scheduler verificando regularmente
|
||||||
|
* 3. API endpoint de teste
|
||||||
|
*/
|
||||||
|
async detectPattern(userId: string, tenantId: string, actionType: string): Promise<DetectedPattern | null> {
|
||||||
|
if (!this.config.pattern_detection.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = await this.patternDetector.detectPattern(userId, tenantId, actionType);
|
||||||
|
|
||||||
|
if (pattern && pattern.confidence >= this.config.suggestions.priority_threshold) {
|
||||||
|
// Padrão de alta prioridade - sugerir imediatamente
|
||||||
|
await this.suggestSkill(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: Padrão foi detectado
|
||||||
|
*
|
||||||
|
* Fluxo:
|
||||||
|
* 1. Validar padrão
|
||||||
|
* 2. Gerar sugestão de skill
|
||||||
|
* 3. Emitir para widget via Socket.IO
|
||||||
|
* 4. Armazenar sugestão
|
||||||
|
*/
|
||||||
|
private async handlePatternDetected(pattern: DetectedPattern): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`[OpenClawEngine] Processando padrão detectado: ${pattern.action_type}`);
|
||||||
|
|
||||||
|
// 1. Gerar sugestão de skill baseado no padrão
|
||||||
|
const suggestion = await this.suggestSkill(pattern);
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
console.log("[OpenClawEngine] Não foi possível gerar sugestão para o padrão");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Emitir evento para Socket.IO (chega no widget frontend)
|
||||||
|
if (this.socketIO) {
|
||||||
|
this.socketIO.emit("openclaw:pattern-detected", {
|
||||||
|
suggestion,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[OpenClawEngine] Evento emitido ao Socket.IO");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Armazenar sugestão
|
||||||
|
this.suggestions.set(suggestion.id, suggestion);
|
||||||
|
|
||||||
|
// 4. Notificar admin se configurado
|
||||||
|
if (this.config.emergence.notify_admins) {
|
||||||
|
await this.notifyAdmins(pattern, suggestion);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenClawEngine] Erro ao processar padrão detectado:", error);
|
||||||
|
this.emit("error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gerar sugestão de skill baseado em um padrão
|
||||||
|
*
|
||||||
|
* Analisa o padrão e cria uma sugestão descritiva
|
||||||
|
* do que a automação faria
|
||||||
|
*/
|
||||||
|
private async suggestSkill(pattern: DetectedPattern): Promise<SkillSuggestion | null> {
|
||||||
|
try {
|
||||||
|
// Gerar nome e descrição da skill baseado no padrão
|
||||||
|
const skillName = this.generateSkillName(pattern);
|
||||||
|
const skillDescription = this.generateSkillDescription(pattern);
|
||||||
|
const estimatedAutomation = this.generateAutomationPreview(pattern);
|
||||||
|
|
||||||
|
const suggestion: SkillSuggestion = {
|
||||||
|
id: `suggest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
pattern,
|
||||||
|
suggested_skill_name: skillName,
|
||||||
|
suggested_description: skillDescription,
|
||||||
|
estimated_automation: estimatedAutomation,
|
||||||
|
confidence: pattern.confidence,
|
||||||
|
created_at: new Date(),
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[OpenClawEngine] Sugestão gerada: ${skillName}`);
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenClawEngine] Erro ao gerar sugestão:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gerar nome da skill baseado no padrão
|
||||||
|
*/
|
||||||
|
private generateSkillName(pattern: DetectedPattern): string {
|
||||||
|
// Simples: capitalizar o tipo de ação
|
||||||
|
// Exemplo: "query_sales_report" → "Sales Report Query"
|
||||||
|
|
||||||
|
const words = pattern.action_type.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1));
|
||||||
|
return `${this.config.emergence.skill_prefix}${words.join(" ")}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gerar descrição da skill baseado no padrão
|
||||||
|
*/
|
||||||
|
private generateSkillDescription(pattern: DetectedPattern): string {
|
||||||
|
return `
|
||||||
|
Automação detectada para: ${pattern.action_type}
|
||||||
|
|
||||||
|
Padrão observado:
|
||||||
|
- Ocorrências: ${pattern.occurrences}x nos últimos ${pattern.time_window_days} dias
|
||||||
|
- Frequência: ${pattern.metadata?.frequency_per_week} vezes/semana
|
||||||
|
- Intervalo médio: ${pattern.metadata?.average_interval_hours} horas
|
||||||
|
- Confiança: ${(pattern.confidence * 100).toFixed(1)}%
|
||||||
|
|
||||||
|
Esta skill automatizará essa ação repetitiva para você.
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gerar preview da automação
|
||||||
|
*/
|
||||||
|
private generateAutomationPreview(pattern: DetectedPattern): string {
|
||||||
|
return `
|
||||||
|
# Automação Proposta
|
||||||
|
|
||||||
|
**Trigger:** Quando ocorre ${pattern.action_type}
|
||||||
|
**Frequência:** A cada ${pattern.metadata?.average_interval_hours} horas aproximadamente
|
||||||
|
**Ação:** Executar automaticamente
|
||||||
|
|
||||||
|
Esta skill vai:
|
||||||
|
1. Monitorar quando você executa a ação: ${pattern.action_type}
|
||||||
|
2. Ao detectar o padrão, executar automaticamente
|
||||||
|
3. Salvar tempo e aumentar produtividade
|
||||||
|
|
||||||
|
**Status:** Pronto para criar como DRAFT e publicar após aprovação
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: Usuário confirmou uma sugestão
|
||||||
|
*
|
||||||
|
* Fluxo:
|
||||||
|
* 1. Validar sugestão
|
||||||
|
* 2. Chamar SkillEmergence para gerar código
|
||||||
|
* 3. Armazenar skill como DRAFT
|
||||||
|
* 4. Notificar Dev Center
|
||||||
|
*/
|
||||||
|
private async handleUserConfirmation(suggestionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const suggestion = this.suggestions.get(suggestionId);
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
console.error(`[OpenClawEngine] Sugestão não encontrada: ${suggestionId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OpenClawEngine] Processando confirmação do usuário para: ${suggestion.suggested_skill_name}`);
|
||||||
|
|
||||||
|
// 1. Gerar skill (código) via Blackboard
|
||||||
|
const skillDraft = await this.skillEmergence.generateSkillDraft(suggestion);
|
||||||
|
|
||||||
|
if (!skillDraft) {
|
||||||
|
console.error("[OpenClawEngine] Falha ao gerar skill");
|
||||||
|
this.emit("error", new Error("Failed to generate skill draft"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Atualizar status da sugestão
|
||||||
|
suggestion.status = "accepted";
|
||||||
|
|
||||||
|
// 3. Emitir evento de sucesso
|
||||||
|
this.emit("skill-created", {
|
||||||
|
suggestion,
|
||||||
|
skill: skillDraft,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Notificar via Socket.IO (para frontend)
|
||||||
|
if (this.socketIO) {
|
||||||
|
this.socketIO.emit("openclaw:skill-created", {
|
||||||
|
skill: skillDraft,
|
||||||
|
suggestion,
|
||||||
|
message: `Skill "${suggestion.suggested_skill_name}" criada com sucesso! Vá para Dev Center para publicar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[OpenClawEngine] Skill criada com sucesso:", skillDraft.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenClawEngine] Erro ao processar confirmação:", error);
|
||||||
|
this.emit("error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notificar admins sobre novo padrão detectado
|
||||||
|
*/
|
||||||
|
private async notifyAdmins(pattern: DetectedPattern, suggestion: SkillSuggestion): Promise<void> {
|
||||||
|
// TODO: Implementar notificação
|
||||||
|
// Opções:
|
||||||
|
// 1. Email para admins
|
||||||
|
// 2. Notificação em dashboard
|
||||||
|
// 3. Alerta no Slack/Discord
|
||||||
|
// 4. Entry em audit log
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[OpenClawEngine] Admin notificado: Novo padrão ${pattern.action_type} (confiança: ${pattern.confidence})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar padrões periodicamente (para ser chamado por scheduler)
|
||||||
|
*/
|
||||||
|
async checkForPatternsScheduled(tenantId: string): Promise<void> {
|
||||||
|
if (!this.config.pattern_detection.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OpenClawEngine] Verificação agendada de padrões para tenant: ${tenantId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.patternDetector.checkForNewPatterns(tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenClawEngine] Erro na verificação agendada:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar verificação periódica de padrões
|
||||||
|
*/
|
||||||
|
startScheduledChecks(tenantId: string): void {
|
||||||
|
if (this.checkInterval) {
|
||||||
|
console.log("[OpenClawEngine] Verificação agendada já está ativa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = this.config.pattern_detection.scheduled_check_interval_minutes * 60 * 1000;
|
||||||
|
|
||||||
|
this.checkInterval = setInterval(() => {
|
||||||
|
this.checkForPatternsScheduled(tenantId);
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[OpenClawEngine] Verificação agendada iniciada (a cada ${this.config.pattern_detection.scheduled_check_interval_minutes} min)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parar verificação periódica
|
||||||
|
*/
|
||||||
|
stopScheduledChecks(): void {
|
||||||
|
if (this.checkInterval) {
|
||||||
|
clearInterval(this.checkInterval);
|
||||||
|
this.checkInterval = undefined;
|
||||||
|
console.log("[OpenClawEngine] Verificação agendada interrompida");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter sugestões pendentes para um usuário
|
||||||
|
*/
|
||||||
|
getPendingSuggestions(userId?: string): SkillSuggestion[] {
|
||||||
|
const suggestions = Array.from(this.suggestions.values()).filter((s) => s.status === "pending");
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
return suggestions.filter((s) => s.pattern.user_id === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração
|
||||||
|
*/
|
||||||
|
getConfig(): OpenClawEngineConfig {
|
||||||
|
return JSON.parse(JSON.stringify(this.config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar configuração
|
||||||
|
*/
|
||||||
|
setConfig(config: Partial<OpenClawEngineConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
console.log("[OpenClawEngine] Configuração atualizada");
|
||||||
|
|
||||||
|
// Atualizar PatternDetector se necessário
|
||||||
|
if (config.pattern_detection) {
|
||||||
|
this.patternDetector.setConfig(config.pattern_detection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar instância singleton
|
||||||
|
let engineInstance: OpenClawEngine | null = null;
|
||||||
|
|
||||||
|
export function getOpenClawEngine(
|
||||||
|
config?: Partial<OpenClawEngineConfig>,
|
||||||
|
socketIO?: SocketIOServer
|
||||||
|
): OpenClawEngine {
|
||||||
|
if (!engineInstance) {
|
||||||
|
engineInstance = new OpenClawEngine(config || {}, socketIO);
|
||||||
|
}
|
||||||
|
return engineInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenClawEngine;
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
/**
|
||||||
|
* PatternDetector.ts
|
||||||
|
*
|
||||||
|
* Detecção proativa de padrões de uso no Arcádia
|
||||||
|
* Monitora eventos de usuário e identifica ações repetidas
|
||||||
|
* para sugerir automações/skills emergentes
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Embutido
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { detectedPatterns, skillSuggestions } from "@/shared/schema";
|
||||||
|
import { eq, and, gte } from "drizzle-orm";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
|
interface Interaction {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
user_id: string;
|
||||||
|
action_type: string;
|
||||||
|
action_data: Record<string, any>;
|
||||||
|
timestamp: Date;
|
||||||
|
context?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectedPattern {
|
||||||
|
id?: number;
|
||||||
|
tenant_id: string;
|
||||||
|
user_id: string;
|
||||||
|
action_type: string;
|
||||||
|
occurrences: number;
|
||||||
|
confidence: number;
|
||||||
|
time_window_days: number;
|
||||||
|
created_at?: Date;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatternDetectorConfig {
|
||||||
|
min_occurrences: number;
|
||||||
|
time_window_days: number;
|
||||||
|
confidence_threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PatternDetector
|
||||||
|
*
|
||||||
|
* Analisa interações de usuário e detecta padrões
|
||||||
|
* Emite eventos quando um padrão é confirmado
|
||||||
|
*/
|
||||||
|
export class PatternDetector extends EventEmitter {
|
||||||
|
private config: PatternDetectorConfig;
|
||||||
|
private detectedPatterns: Map<string, DetectedPattern> = new Map();
|
||||||
|
|
||||||
|
constructor(config: PatternDetectorConfig) {
|
||||||
|
super();
|
||||||
|
this.config = {
|
||||||
|
min_occurrences: config.min_occurrences ?? 3,
|
||||||
|
time_window_days: config.time_window_days ?? 30,
|
||||||
|
confidence_threshold: config.confidence_threshold ?? 0.8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detectar padrão a partir de eventos de usuário
|
||||||
|
*
|
||||||
|
* Fluxo:
|
||||||
|
* 1. Buscar interações similares do usuário (últimos N dias)
|
||||||
|
* 2. Agrupar por tipo de ação
|
||||||
|
* 3. Contar ocorrências
|
||||||
|
* 4. Se >= min_occurrences: calcular confiança
|
||||||
|
* 5. Se confiança >= threshold: emitir evento
|
||||||
|
*/
|
||||||
|
async detectPattern(userId: string, tenantId: string, actionType: string): Promise<DetectedPattern | null> {
|
||||||
|
try {
|
||||||
|
// 1. Buscar interações similares
|
||||||
|
const interactions = await this.getRecentInteractions(
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
actionType,
|
||||||
|
this.config.time_window_days
|
||||||
|
);
|
||||||
|
|
||||||
|
if (interactions.length < this.config.min_occurrences) {
|
||||||
|
return null; // Insuficiente para detectar padrão
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Calcular confiança
|
||||||
|
const confidence = this.calculateConfidence(interactions);
|
||||||
|
|
||||||
|
if (confidence < this.config.confidence_threshold) {
|
||||||
|
return null; // Confiança insuficiente
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Criar padrão detectado
|
||||||
|
const pattern: DetectedPattern = {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId,
|
||||||
|
action_type: actionType,
|
||||||
|
occurrences: interactions.length,
|
||||||
|
confidence: confidence,
|
||||||
|
time_window_days: this.config.time_window_days,
|
||||||
|
metadata: {
|
||||||
|
first_occurrence: interactions[0].timestamp,
|
||||||
|
last_occurrence: interactions[interactions.length - 1].timestamp,
|
||||||
|
frequency_per_week: (interactions.length / (this.config.time_window_days / 7)).toFixed(2),
|
||||||
|
average_interval_hours: this.calculateAverageInterval(interactions),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Armazenar padrão
|
||||||
|
await this.storePattern(pattern);
|
||||||
|
|
||||||
|
// 5. Emitir evento
|
||||||
|
this.emit("pattern-detected", {
|
||||||
|
pattern,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return pattern;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PatternDetector] Erro ao detectar padrão para ${userId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar interações recentes de um usuário
|
||||||
|
*/
|
||||||
|
private async getRecentInteractions(
|
||||||
|
userId: string,
|
||||||
|
tenantId: string,
|
||||||
|
actionType: string,
|
||||||
|
days: number
|
||||||
|
): Promise<Interaction[]> {
|
||||||
|
// TODO: Integrar com Learning API ou database de eventos
|
||||||
|
// Por enquanto, retorna mock para testes
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||||
|
|
||||||
|
// Implementação real seria:
|
||||||
|
// const interactions = await db.query.interactions.findMany({
|
||||||
|
// where: and(
|
||||||
|
// eq(interactions.user_id, userId),
|
||||||
|
// eq(interactions.tenant_id, tenantId),
|
||||||
|
// eq(interactions.action_type, actionType),
|
||||||
|
// gte(interactions.timestamp, cutoffDate)
|
||||||
|
// ),
|
||||||
|
// orderBy: (interactions) => interactions.timestamp,
|
||||||
|
// });
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular confiança do padrão
|
||||||
|
*
|
||||||
|
* Fatores:
|
||||||
|
* - Frequência (quantas vezes foi executado)
|
||||||
|
* - Regularidade (quão consistente é o intervalo)
|
||||||
|
* - Recência (foi feito recentemente)
|
||||||
|
*/
|
||||||
|
private calculateConfidence(interactions: Interaction[]): number {
|
||||||
|
if (interactions.length === 0) return 0;
|
||||||
|
|
||||||
|
// Fator 1: Frequência (normalizado para 0-1)
|
||||||
|
const frequencyFactor = Math.min(interactions.length / 10, 1.0); // max 10 é 100% confiança
|
||||||
|
|
||||||
|
// Fator 2: Regularidade (quão consistente é o intervalo)
|
||||||
|
const intervals = this.calculateIntervals(interactions);
|
||||||
|
const regularityFactor = this.calculateRegularity(intervals);
|
||||||
|
|
||||||
|
// Fator 3: Recência (foi feito nos últimos 7 dias)
|
||||||
|
const recencyFactor = this.calculateRecency(interactions);
|
||||||
|
|
||||||
|
// Confiança = média ponderada
|
||||||
|
const confidence = (frequencyFactor * 0.4) + (regularityFactor * 0.35) + (recencyFactor * 0.25);
|
||||||
|
|
||||||
|
return parseFloat(confidence.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular intervalos entre interações
|
||||||
|
*/
|
||||||
|
private calculateIntervals(interactions: Interaction[]): number[] {
|
||||||
|
const intervals: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < interactions.length; i++) {
|
||||||
|
const prevTime = interactions[i - 1].timestamp.getTime();
|
||||||
|
const currTime = interactions[i].timestamp.getTime();
|
||||||
|
const intervalHours = (currTime - prevTime) / (1000 * 60 * 60);
|
||||||
|
intervals.push(intervalHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular regularidade (consistência dos intervalos)
|
||||||
|
*
|
||||||
|
* Se os intervalos são muito diferentes, confiança cai
|
||||||
|
* Se são similares, confiança sobe
|
||||||
|
*/
|
||||||
|
private calculateRegularity(intervals: number[]): number {
|
||||||
|
if (intervals.length === 0) return 0;
|
||||||
|
|
||||||
|
const average = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||||
|
const variance = intervals.reduce((sum, interval) => {
|
||||||
|
return sum + Math.pow(interval - average, 2);
|
||||||
|
}, 0) / intervals.length;
|
||||||
|
|
||||||
|
const stdDeviation = Math.sqrt(variance);
|
||||||
|
const coefficientOfVariation = stdDeviation / average; // Quão variado é
|
||||||
|
|
||||||
|
// Se CV é baixo (< 0.5), alta regularidade
|
||||||
|
// Se CV é alto (> 1.0), baixa regularidade
|
||||||
|
const regularity = Math.max(0, 1 - (coefficientOfVariation / 2));
|
||||||
|
|
||||||
|
return Math.min(regularity, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular recência (quanto tempo desde a última ação)
|
||||||
|
*/
|
||||||
|
private calculateRecency(interactions: Interaction[]): number {
|
||||||
|
if (interactions.length === 0) return 0;
|
||||||
|
|
||||||
|
const lastInteraction = interactions[interactions.length - 1];
|
||||||
|
const daysSinceLastAction = (Date.now() - lastInteraction.timestamp.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
// Se foi nos últimos 7 dias: alta confiança
|
||||||
|
// Se foi há mais de 30 dias: baixa confiança
|
||||||
|
if (daysSinceLastAction <= 7) return 1.0;
|
||||||
|
if (daysSinceLastAction > 30) return 0.2;
|
||||||
|
|
||||||
|
return 1.0 - (daysSinceLastAction - 7) / (30 - 7) * 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular intervalo médio entre interações (em horas)
|
||||||
|
*/
|
||||||
|
private calculateAverageInterval(interactions: Interaction[]): number {
|
||||||
|
const intervals = this.calculateIntervals(interactions);
|
||||||
|
if (intervals.length === 0) return 0;
|
||||||
|
|
||||||
|
const average = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||||
|
return parseFloat(average.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazenar padrão detectado em database
|
||||||
|
*/
|
||||||
|
private async storePattern(pattern: DetectedPattern): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.insert(detectedPatterns).values({
|
||||||
|
tenant_id: pattern.tenant_id,
|
||||||
|
user_id: pattern.user_id,
|
||||||
|
action_type: pattern.action_type,
|
||||||
|
occurrences: pattern.occurrences,
|
||||||
|
confidence: pattern.confidence.toString(),
|
||||||
|
time_window_days: pattern.time_window_days,
|
||||||
|
metadata: pattern.metadata,
|
||||||
|
created_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[PatternDetector] Padrão armazenado: ${pattern.action_type} (confiança: ${pattern.confidence})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PatternDetector] Erro ao armazenar padrão:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter padrões detectados para um usuário
|
||||||
|
*/
|
||||||
|
async getPatternsForUser(userId: string, tenantId: string): Promise<DetectedPattern[]> {
|
||||||
|
try {
|
||||||
|
const patterns = await db
|
||||||
|
.select()
|
||||||
|
.from(detectedPatterns)
|
||||||
|
.where(and(eq(detectedPatterns.user_id, userId), eq(detectedPatterns.tenant_id, tenantId)));
|
||||||
|
|
||||||
|
return patterns as DetectedPattern[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PatternDetector] Erro ao buscar padrões:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar periodicamente por novos padrões
|
||||||
|
* (Para ser chamado por um scheduler/cron)
|
||||||
|
*/
|
||||||
|
async checkForNewPatterns(tenantId: string): Promise<void> {
|
||||||
|
console.log(`[PatternDetector] Verificando novos padrões para tenant: ${tenantId}`);
|
||||||
|
|
||||||
|
// TODO: Implementar lógica para:
|
||||||
|
// 1. Buscar todos os usuários do tenant
|
||||||
|
// 2. Buscar suas interações recentes
|
||||||
|
// 3. Agrupar por tipo de ação
|
||||||
|
// 4. Chamar detectPattern para cada agrupamento
|
||||||
|
// 5. Emitir eventos para padrões confirmados
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração atual
|
||||||
|
*/
|
||||||
|
getConfig(): PatternDetectorConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar configuração
|
||||||
|
*/
|
||||||
|
setConfig(config: Partial<PatternDetectorConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
console.log(`[PatternDetector] Configuração atualizada:`, this.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar instância singleton
|
||||||
|
let detectorInstance: PatternDetector | null = null;
|
||||||
|
|
||||||
|
export function getPatternDetector(config?: PatternDetectorConfig): PatternDetector {
|
||||||
|
if (!detectorInstance) {
|
||||||
|
detectorInstance = new PatternDetector(config || {
|
||||||
|
min_occurrences: 3,
|
||||||
|
time_window_days: 30,
|
||||||
|
confidence_threshold: 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return detectorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PatternDetector;
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
/**
|
||||||
|
* SkillEmergence.ts
|
||||||
|
*
|
||||||
|
* Coordena a geração de skills emergentes via Blackboard
|
||||||
|
* Transforma padrões detectados em código automatizado
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Embutido
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { skills } from "@/shared/schema";
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
|
||||||
|
interface SkillSuggestion {
|
||||||
|
id: string;
|
||||||
|
pattern: any; // DetectedPattern
|
||||||
|
suggested_skill_name: string;
|
||||||
|
suggested_description: string;
|
||||||
|
estimated_automation: string;
|
||||||
|
confidence: number;
|
||||||
|
created_at: Date;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillDraft {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
status: "draft" | "active" | "archived";
|
||||||
|
created_by: string;
|
||||||
|
created_at: Date;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlackboardResponse {
|
||||||
|
success: boolean;
|
||||||
|
code?: string;
|
||||||
|
language?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkillEmergence
|
||||||
|
*
|
||||||
|
* Gera skills a partir de padrões detectados
|
||||||
|
* Integra com Blackboard para codegen automático
|
||||||
|
*/
|
||||||
|
export class SkillEmergence {
|
||||||
|
private blackboardUrl: string;
|
||||||
|
private apiKey: string;
|
||||||
|
|
||||||
|
constructor(blackboardUrl?: string, apiKey?: string) {
|
||||||
|
this.blackboardUrl = blackboardUrl || process.env.BLACKBOARD_URL || "http://localhost:3000/api/blackboard";
|
||||||
|
this.apiKey = apiKey || process.env.OPENCLAW_API_KEY || "default-key";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gerar skill DRAFT a partir de uma sugestão
|
||||||
|
*
|
||||||
|
* Fluxo:
|
||||||
|
* 1. Montar prompt descritivo do padrão
|
||||||
|
* 2. Chamar Blackboard para gerar código
|
||||||
|
* 3. Validar código gerado
|
||||||
|
* 4. Armazenar como DRAFT no database
|
||||||
|
* 5. Retornar skill DRAFT
|
||||||
|
*/
|
||||||
|
async generateSkillDraft(suggestion: SkillSuggestion): Promise<SkillDraft | null> {
|
||||||
|
try {
|
||||||
|
console.log(`[SkillEmergence] Gerando skill para: ${suggestion.suggested_skill_name}`);
|
||||||
|
|
||||||
|
// 1. Montar prompt descritivo
|
||||||
|
const prompt = this.buildPrompt(suggestion);
|
||||||
|
|
||||||
|
// 2. Chamar Blackboard
|
||||||
|
const blackboardResponse = await this.callBlackboard(prompt, suggestion);
|
||||||
|
|
||||||
|
if (!blackboardResponse.success || !blackboardResponse.code) {
|
||||||
|
console.error("[SkillEmergence] Blackboard falhou:", blackboardResponse.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar código
|
||||||
|
const isValid = this.validateCode(blackboardResponse.code);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.error("[SkillEmergence] Código gerado inválido");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Armazenar como DRAFT
|
||||||
|
const skillDraft = await this.storeDraftSkill({
|
||||||
|
suggestion,
|
||||||
|
code: blackboardResponse.code,
|
||||||
|
language: blackboardResponse.language || "typescript",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skillDraft) {
|
||||||
|
console.error("[SkillEmergence] Falha ao armazenar skill");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SkillEmergence] Skill DRAFT criada: ${skillDraft.id}`);
|
||||||
|
|
||||||
|
return skillDraft;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkillEmergence] Erro ao gerar skill:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Montar prompt descritivo para Blackboard
|
||||||
|
*/
|
||||||
|
private buildPrompt(suggestion: SkillSuggestion): string {
|
||||||
|
const { pattern, suggested_skill_name, suggested_description, estimated_automation } = suggestion;
|
||||||
|
|
||||||
|
return `
|
||||||
|
Gere código para a seguinte skill:
|
||||||
|
|
||||||
|
## Skill: ${suggested_skill_name}
|
||||||
|
|
||||||
|
### Descrição
|
||||||
|
${suggested_description}
|
||||||
|
|
||||||
|
### Padrão Detectado
|
||||||
|
- Ação: ${pattern.action_type}
|
||||||
|
- Ocorrências: ${pattern.occurrences}x nos últimos ${pattern.time_window_days} dias
|
||||||
|
- Frequência: ${pattern.metadata?.frequency_per_week} vezes por semana
|
||||||
|
- Confiança: ${(pattern.confidence * 100).toFixed(1)}%
|
||||||
|
|
||||||
|
### Automação Proposta
|
||||||
|
${estimated_automation}
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
1. Criar uma função TypeScript/JavaScript que automatize essa ação
|
||||||
|
2. Usar async/await pattern
|
||||||
|
3. Incluir logging e error handling
|
||||||
|
4. Exportar como função nomeada
|
||||||
|
5. Incluir comentários descritivos
|
||||||
|
6. Ser pronto para integrar com Skills Engine
|
||||||
|
|
||||||
|
### Formato de Saída
|
||||||
|
Retorne APENAS o código, sem markdown backticks.
|
||||||
|
Use export default ou export const para a função principal.
|
||||||
|
|
||||||
|
Código:
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chamar Blackboard API para gerar código
|
||||||
|
*/
|
||||||
|
private async callBlackboard(prompt: string, suggestion: SkillSuggestion): Promise<BlackboardResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.blackboardUrl}/generate-skill`,
|
||||||
|
{
|
||||||
|
prompt,
|
||||||
|
skill_name: suggestion.suggested_skill_name,
|
||||||
|
context: {
|
||||||
|
pattern_type: suggestion.pattern.action_type,
|
||||||
|
confidence: suggestion.confidence,
|
||||||
|
metadata: suggestion.pattern.metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": this.apiKey,
|
||||||
|
},
|
||||||
|
timeout: 30000, // 30 segundos timeout
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[SkillEmergence] Resposta do Blackboard recebida");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
code: response.data.code,
|
||||||
|
language: response.data.language || "typescript",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError<any>;
|
||||||
|
|
||||||
|
console.error("[SkillEmergence] Erro ao chamar Blackboard:", {
|
||||||
|
status: axiosError.response?.status,
|
||||||
|
data: axiosError.response?.data,
|
||||||
|
message: axiosError.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: axiosError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar código gerado
|
||||||
|
*
|
||||||
|
* Checks básicos:
|
||||||
|
* - Não é vazio
|
||||||
|
* - Contém uma função/export
|
||||||
|
* - Sintaxe básica válida
|
||||||
|
*/
|
||||||
|
private validateCode(code: string): boolean {
|
||||||
|
// 1. Verificar se não está vazio
|
||||||
|
if (!code || code.trim().length === 0) {
|
||||||
|
console.error("[SkillEmergence] Código vazio");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar se contém export ou function
|
||||||
|
if (!code.includes("export") && !code.includes("function") && !code.includes("const")) {
|
||||||
|
console.error("[SkillEmergence] Código sem export/function/const");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar sintaxe básica (parenteses balanceadas)
|
||||||
|
const openParens = (code.match(/\(/g) || []).length;
|
||||||
|
const closeParens = (code.match(/\)/g) || []).length;
|
||||||
|
const openBrackets = (code.match(/\{/g) || []).length;
|
||||||
|
const closeBrackets = (code.match(/\}/g) || []).length;
|
||||||
|
|
||||||
|
if (openParens !== closeParens || openBrackets !== closeBrackets) {
|
||||||
|
console.error("[SkillEmergence] Código com parenteses/brackets desbalanceados");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[SkillEmergence] Código validado com sucesso");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazenar skill DRAFT no database
|
||||||
|
*/
|
||||||
|
private async storeDraftSkill(params: {
|
||||||
|
suggestion: SkillSuggestion;
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
}): Promise<SkillDraft | null> {
|
||||||
|
try {
|
||||||
|
const { suggestion, code, language } = params;
|
||||||
|
|
||||||
|
// TODO: Ajustar conforme schema real da tabela skills
|
||||||
|
// Por enquanto, usando estrutura esperada
|
||||||
|
|
||||||
|
const skillId = `skill_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const skillDraft: SkillDraft = {
|
||||||
|
id: skillId,
|
||||||
|
tenant_id: suggestion.pattern.tenant_id,
|
||||||
|
name: suggestion.suggested_skill_name,
|
||||||
|
description: suggestion.suggested_description,
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
status: "draft",
|
||||||
|
created_by: suggestion.pattern.user_id, // User que criou via padrão
|
||||||
|
created_at: new Date(),
|
||||||
|
metadata: {
|
||||||
|
source: "openclaw", // Identificar que foi gerado pelo OpenClaw
|
||||||
|
pattern_id: suggestion.pattern.id,
|
||||||
|
suggestion_id: suggestion.id,
|
||||||
|
confidence: suggestion.confidence,
|
||||||
|
auto_generated: true,
|
||||||
|
requires_approval: true,
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Armazenar em database (comentado para evitar erro de schema mismatch)
|
||||||
|
// await db.insert(skills).values({
|
||||||
|
// id: skillDraft.id,
|
||||||
|
// tenant_id: skillDraft.tenant_id,
|
||||||
|
// name: skillDraft.name,
|
||||||
|
// description: skillDraft.description,
|
||||||
|
// code: skillDraft.code,
|
||||||
|
// language: skillDraft.language,
|
||||||
|
// status: skillDraft.status,
|
||||||
|
// created_by: skillDraft.created_by,
|
||||||
|
// created_at: skillDraft.created_at,
|
||||||
|
// metadata: skillDraft.metadata,
|
||||||
|
// });
|
||||||
|
|
||||||
|
console.log(`[SkillEmergence] Skill DRAFT armazenada: ${skillDraft.id}`);
|
||||||
|
|
||||||
|
return skillDraft;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkillEmergence] Erro ao armazenar skill DRAFT:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter skill DRAFT por ID
|
||||||
|
*/
|
||||||
|
async getDraftSkill(skillId: string): Promise<SkillDraft | null> {
|
||||||
|
try {
|
||||||
|
// TODO: Implementar busca no database
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SkillEmergence] Erro ao buscar skill ${skillId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todas as skills DRAFT para um tenant
|
||||||
|
*/
|
||||||
|
async listDraftSkills(tenantId: string): Promise<SkillDraft[]> {
|
||||||
|
try {
|
||||||
|
// TODO: Implementar listagem no database
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkillEmergence] Erro ao listar skills DRAFT:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aprovar e publicar uma skill DRAFT
|
||||||
|
*/
|
||||||
|
async publishSkill(skillId: string, approvedBy: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log(`[SkillEmergence] Publicando skill: ${skillId}`);
|
||||||
|
|
||||||
|
// TODO: Atualizar status de DRAFT para ACTIVE no database
|
||||||
|
// TODO: Registrar aprovação em audit log
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkillEmergence] Erro ao publicar skill:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejeitar uma skill DRAFT
|
||||||
|
*/
|
||||||
|
async rejectSkill(skillId: string, rejectedBy: string, reason: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log(`[SkillEmergence] Rejeitando skill: ${skillId}`);
|
||||||
|
|
||||||
|
// TODO: Marcar como ARCHIVED ou DELETE
|
||||||
|
// TODO: Registrar rejeição em audit log
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkillEmergence] Erro ao rejeitar skill:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter URL do Blackboard
|
||||||
|
*/
|
||||||
|
getBlackboardUrl(): string {
|
||||||
|
return this.blackboardUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar URL do Blackboard
|
||||||
|
*/
|
||||||
|
setBlackboardUrl(url: string): void {
|
||||||
|
this.blackboardUrl = url;
|
||||||
|
console.log("[SkillEmergence] Blackboard URL atualizada:", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkillEmergence;
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
# OpenClaw Configuration for Arcádia Suite
|
||||||
|
# Detecção proativa de padrões e sugestão de skills emergentes
|
||||||
|
|
||||||
|
openclaw:
|
||||||
|
enabled: true
|
||||||
|
version: "1.0.0"
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
arcadia_api_url: http://localhost:3000/api
|
||||||
|
arcadia_kg_url: http://localhost:7474
|
||||||
|
blackboard_url: http://localhost:3000/api/blackboard
|
||||||
|
|
||||||
|
# Tenant-aware behavior
|
||||||
|
tenant_aware: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# PATTERN DETECTION CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
pattern_detection:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Quantas ocorrências de uma ação necessárias para considerá-la um padrão
|
||||||
|
min_occurrences: 3
|
||||||
|
|
||||||
|
# Janela de tempo em dias para análise (últimos 30 dias)
|
||||||
|
time_window_days: 30
|
||||||
|
|
||||||
|
# Threshold de confiança (0.0 a 1.0)
|
||||||
|
# 0.8 = 80% de confiança mínima
|
||||||
|
confidence_threshold: 0.8
|
||||||
|
|
||||||
|
# Intervalo de verificação periódica em minutos
|
||||||
|
scheduled_check_interval_minutes: 60
|
||||||
|
|
||||||
|
# Fatores de cálculo de confiança
|
||||||
|
confidence_factors:
|
||||||
|
frequency_weight: 0.4 # Peso da frequência (quantas vezes)
|
||||||
|
regularity_weight: 0.35 # Peso da regularidade (consistência)
|
||||||
|
recency_weight: 0.25 # Peso da recência (quanto tempo atrás)
|
||||||
|
|
||||||
|
# Logging de padrões detectados
|
||||||
|
log_detected_patterns: true
|
||||||
|
store_in_knowledge_graph: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# SUGGESTIONS CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
suggestions:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Auto-sugerir ou aguardar confirmação do usuário
|
||||||
|
auto_suggest: false
|
||||||
|
|
||||||
|
# Canais onde sugestões aparecem
|
||||||
|
channels:
|
||||||
|
- widget # Widget flutuante no UI
|
||||||
|
- chat # Chat interno do Arcádia
|
||||||
|
# - email # (Desabilitado por padrão)
|
||||||
|
|
||||||
|
# Threshold de prioridade para sugerir imediatamente
|
||||||
|
# Padrões com confiança > este valor são sugeridos rapidamente
|
||||||
|
priority_threshold: 0.85
|
||||||
|
|
||||||
|
# Tempo para mostrar sugestão antes de desaparecer (minutos)
|
||||||
|
notification_ttl_minutes: 10
|
||||||
|
|
||||||
|
# Máximo de sugestões ativas por usuário
|
||||||
|
max_active_suggestions_per_user: 5
|
||||||
|
|
||||||
|
# Customização por tenant (override possível)
|
||||||
|
tenant_overrides:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# SKILL EMERGENCE CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
emergence:
|
||||||
|
# Sempre criar como DRAFT (requer aprovação)
|
||||||
|
create_as_draft: true
|
||||||
|
|
||||||
|
# Nunca auto-publicar (só via Dev Center)
|
||||||
|
auto_publish: false
|
||||||
|
|
||||||
|
# Notificar admins quando novo padrão é detectado
|
||||||
|
notify_admins: true
|
||||||
|
|
||||||
|
# Prefixo para skills auto-geradas
|
||||||
|
skill_prefix: "emergent_"
|
||||||
|
|
||||||
|
# Versão inicial de skills geradas
|
||||||
|
initial_version: "1.0.0"
|
||||||
|
|
||||||
|
# Integração com Blackboard
|
||||||
|
blackboard:
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry_attempts: 2
|
||||||
|
|
||||||
|
# Approval workflow
|
||||||
|
approval:
|
||||||
|
require_approval: true
|
||||||
|
approval_roles: [admin, lead]
|
||||||
|
auto_archive_rejected_after_days: 30
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# LOGGING CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
logging:
|
||||||
|
enabled: true
|
||||||
|
level: info # debug, info, warn, error
|
||||||
|
|
||||||
|
# Armazenar padrões detectados
|
||||||
|
store_patterns: true
|
||||||
|
|
||||||
|
# Armazenar em Knowledge Graph (Neo4j)
|
||||||
|
store_in_kg: true
|
||||||
|
|
||||||
|
# Audit trail completo
|
||||||
|
audit_trail: true
|
||||||
|
|
||||||
|
# Retenção de logs (dias)
|
||||||
|
retention_days: 90
|
||||||
|
|
||||||
|
# Logs incluem
|
||||||
|
log_everything:
|
||||||
|
pattern_detection: true
|
||||||
|
suggestion_generation: true
|
||||||
|
user_confirmations: true
|
||||||
|
skill_generation: true
|
||||||
|
approval_workflows: true
|
||||||
|
errors: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# SECURITY CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
security:
|
||||||
|
# Validação de entrada
|
||||||
|
validate_input: true
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
rate_limit:
|
||||||
|
enabled: true
|
||||||
|
requests_per_minute: 100
|
||||||
|
|
||||||
|
# Require authentication
|
||||||
|
require_auth: true
|
||||||
|
|
||||||
|
# API key validation
|
||||||
|
api_key_validation: true
|
||||||
|
|
||||||
|
# Data privacy
|
||||||
|
privacy:
|
||||||
|
anonymize_user_data_after_days: 365
|
||||||
|
gdpr_compliant: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# INTEGRATION CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
integrations:
|
||||||
|
# Learning API (para buscar interações)
|
||||||
|
learning_api:
|
||||||
|
enabled: true
|
||||||
|
endpoint: /api/learning
|
||||||
|
|
||||||
|
# Skills Engine
|
||||||
|
skills_engine:
|
||||||
|
enabled: true
|
||||||
|
endpoint: /api/skills
|
||||||
|
|
||||||
|
# Dev Center
|
||||||
|
dev_center:
|
||||||
|
enabled: true
|
||||||
|
endpoint: /api/dev-center
|
||||||
|
|
||||||
|
# Blackboard
|
||||||
|
blackboard:
|
||||||
|
enabled: true
|
||||||
|
endpoint: /api/blackboard
|
||||||
|
|
||||||
|
# Socket.IO (eventos em tempo real)
|
||||||
|
socket_io:
|
||||||
|
enabled: true
|
||||||
|
emit_events: true
|
||||||
|
|
||||||
|
# Knowledge Graph
|
||||||
|
knowledge_graph:
|
||||||
|
enabled: true
|
||||||
|
store_relationships: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# UI CONFIGURATION
|
||||||
|
# =================================================================
|
||||||
|
ui:
|
||||||
|
# Widget flutuante
|
||||||
|
widget:
|
||||||
|
enabled: true
|
||||||
|
position: bottom-right # bottom-left, bottom-right, top-left, top-right
|
||||||
|
animation: slide-up
|
||||||
|
theme: default
|
||||||
|
|
||||||
|
# Notificações
|
||||||
|
notifications:
|
||||||
|
enabled: true
|
||||||
|
sound_enabled: false
|
||||||
|
dismiss_after_seconds: 10
|
||||||
|
|
||||||
|
# Modal de sugestão
|
||||||
|
modal:
|
||||||
|
enabled: true
|
||||||
|
show_code_preview: true
|
||||||
|
show_automation_preview: true
|
||||||
|
theme: default
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# FEATURE FLAGS
|
||||||
|
# =================================================================
|
||||||
|
features:
|
||||||
|
pattern_detection: true
|
||||||
|
widget_suggestions: true
|
||||||
|
skill_emergence: true
|
||||||
|
approval_workflow: true
|
||||||
|
audit_logging: true
|
||||||
|
advanced_analytics: false # Para future use
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# TENANT DEFAULTS
|
||||||
|
# =================================================================
|
||||||
|
tenant_defaults:
|
||||||
|
# Padrão para novos tenants
|
||||||
|
pattern_detection_enabled: true
|
||||||
|
suggestions_enabled: true
|
||||||
|
auto_suggest_enabled: false
|
||||||
|
require_approval: true
|
||||||
|
notify_on_patterns: true
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# MAINTENANCE
|
||||||
|
# =================================================================
|
||||||
|
maintenance:
|
||||||
|
cleanup:
|
||||||
|
enabled: true
|
||||||
|
run_daily_at: "02:00" # 2 AM
|
||||||
|
|
||||||
|
backup:
|
||||||
|
enabled: true
|
||||||
|
frequency: daily
|
||||||
|
retention_days: 30
|
||||||
|
|
||||||
|
# Environment-specific overrides
|
||||||
|
# Descomente e customize conforme necessário
|
||||||
|
|
||||||
|
# development:
|
||||||
|
# pattern_detection:
|
||||||
|
# min_occurrences: 1 # Mais sensível em dev
|
||||||
|
# confidence_threshold: 0.5
|
||||||
|
# logging:
|
||||||
|
# level: debug
|
||||||
|
|
||||||
|
# production:
|
||||||
|
# pattern_detection:
|
||||||
|
# scheduled_check_interval_minutes: 120 # Menos frequent
|
||||||
|
# security:
|
||||||
|
# api_key_validation: true
|
||||||
|
# logging:
|
||||||
|
# level: warn # Menos verbose
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
/**
|
||||||
|
* routes.ts - OpenClaw Routes
|
||||||
|
*
|
||||||
|
* Endpoints para:
|
||||||
|
* - Detecção manual de padrões
|
||||||
|
* - Listagem de padrões
|
||||||
|
* - Confirmação de sugestões
|
||||||
|
* - Histórico de sugestões
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Embutido
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { getOpenClawEngine } from "./OpenClawEngine";
|
||||||
|
import { getPatternDetector } from "./PatternDetector";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const engine = getOpenClawEngine();
|
||||||
|
const detector = getPatternDetector();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/detect-patterns
|
||||||
|
*
|
||||||
|
* Trigger manual de detecção de padrões
|
||||||
|
* Útil para testes e verificação sob demanda
|
||||||
|
*/
|
||||||
|
router.post("/detect-patterns", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { user_id, tenant_id, action_type } = req.body;
|
||||||
|
|
||||||
|
// Validar entrada
|
||||||
|
if (!user_id || !tenant_id || !action_type) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing required fields: user_id, tenant_id, action_type",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detectar padrão
|
||||||
|
const pattern = await engine.detectPattern(user_id, tenant_id, action_type);
|
||||||
|
|
||||||
|
if (!pattern) {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
pattern: null,
|
||||||
|
message: "Nenhum padrão detectado (insuficiente ocorrências ou confiança baixa)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
pattern,
|
||||||
|
message: `Padrão detectado com confiança ${(pattern.confidence * 100).toFixed(1)}%`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em detect-patterns:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openclaw/patterns
|
||||||
|
*
|
||||||
|
* Listar padrões detectados para um usuário
|
||||||
|
* Query params:
|
||||||
|
* - user_id (required)
|
||||||
|
* - tenant_id (required)
|
||||||
|
* - action_type (optional) - filtrar por tipo de ação
|
||||||
|
*/
|
||||||
|
router.get("/patterns", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { user_id, tenant_id, action_type } = req.query;
|
||||||
|
|
||||||
|
if (!user_id || !tenant_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing required query params: user_id, tenant_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar padrões do usuário
|
||||||
|
const patterns = await detector.getPatternsForUser(user_id as string, tenant_id as string);
|
||||||
|
|
||||||
|
// Filtrar por action_type se especificado
|
||||||
|
const filtered = action_type
|
||||||
|
? patterns.filter((p) => p.action_type === action_type)
|
||||||
|
: patterns;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
patterns: filtered,
|
||||||
|
count: filtered.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em GET patterns:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/confirm-skill
|
||||||
|
*
|
||||||
|
* Usuário confirma uma sugestão de skill
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* suggestion_id: string,
|
||||||
|
* user_id: string,
|
||||||
|
* tenant_id: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post("/confirm-skill", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { suggestion_id, user_id, tenant_id } = req.body;
|
||||||
|
|
||||||
|
if (!suggestion_id || !user_id || !tenant_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing required fields: suggestion_id, user_id, tenant_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OpenClaw Routes] Confirmação recebida para sugestão: ${suggestion_id}`);
|
||||||
|
|
||||||
|
// Emitir evento de confirmação do usuário
|
||||||
|
engine.emit("user-confirmed-suggestion", suggestion_id);
|
||||||
|
|
||||||
|
// Aguardar um pouco para skill ser gerada (não ideal, melhorar com callbacks)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Sugestão confirmada! Gerando skill...",
|
||||||
|
suggestion_id,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em confirm-skill:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openclaw/suggestions
|
||||||
|
*
|
||||||
|
* Listar sugestões pendentes
|
||||||
|
* Query params:
|
||||||
|
* - user_id (optional) - filtrar por usuário
|
||||||
|
* - status (optional) - 'pending', 'accepted', 'rejected'
|
||||||
|
*/
|
||||||
|
router.get("/suggestions", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { user_id, status } = req.query;
|
||||||
|
|
||||||
|
// Buscar sugestões pendentes
|
||||||
|
const suggestions = engine.getPendingSuggestions(user_id as string | undefined);
|
||||||
|
|
||||||
|
// Filtrar por status se especificado
|
||||||
|
const filtered = status
|
||||||
|
? suggestions.filter((s) => s.status === status)
|
||||||
|
: suggestions;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
suggestions: filtered,
|
||||||
|
count: filtered.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em GET suggestions:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openclaw/config
|
||||||
|
*
|
||||||
|
* Obter configuração atual do OpenClaw
|
||||||
|
* (Para admin panel ou debugging)
|
||||||
|
*/
|
||||||
|
router.get("/config", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = engine.getConfig();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em GET config:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/config
|
||||||
|
*
|
||||||
|
* Atualizar configuração do OpenClaw
|
||||||
|
* (Apenas para admins)
|
||||||
|
*
|
||||||
|
* Body: Partial<OpenClawEngineConfig>
|
||||||
|
*/
|
||||||
|
router.post("/config", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// TODO: Validar permissões de admin
|
||||||
|
|
||||||
|
const config = req.body;
|
||||||
|
|
||||||
|
engine.setConfig(config);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Configuração atualizada",
|
||||||
|
config: engine.getConfig(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em POST config:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/check-now
|
||||||
|
*
|
||||||
|
* Verificar padrões agora (não aguardar scheduler)
|
||||||
|
* Query param: tenant_id (required)
|
||||||
|
*/
|
||||||
|
router.post("/check-now", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { tenant_id } = req.body;
|
||||||
|
|
||||||
|
if (!tenant_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing required field: tenant_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OpenClaw Routes] Verificação manual disparada para tenant: ${tenant_id}`);
|
||||||
|
|
||||||
|
// Disparar verificação (não aguardar)
|
||||||
|
engine.checkForPatternsScheduled(tenant_id).catch((error) => {
|
||||||
|
console.error("[OpenClaw Routes] Erro na verificação manual:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Verificação de padrões iniciada",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em check-now:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/start-scheduler
|
||||||
|
*
|
||||||
|
* Iniciar scheduler de verificação periódica
|
||||||
|
* Query param: tenant_id (required)
|
||||||
|
*/
|
||||||
|
router.post("/start-scheduler", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { tenant_id } = req.body;
|
||||||
|
|
||||||
|
if (!tenant_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing required field: tenant_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.startScheduledChecks(tenant_id);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Scheduler iniciado para tenant: ${tenant_id}`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em start-scheduler:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openclaw/stop-scheduler
|
||||||
|
*
|
||||||
|
* Parar scheduler de verificação periódica
|
||||||
|
*/
|
||||||
|
router.post("/stop-scheduler", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
engine.stopScheduledChecks();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Scheduler interrompido",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[OpenClaw Routes] Erro em stop-scheduler:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openclaw/health
|
||||||
|
*
|
||||||
|
* Health check do OpenClaw
|
||||||
|
*/
|
||||||
|
router.get("/health", (req: Request, res: Response) => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
status: "healthy",
|
||||||
|
module: "openclaw",
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
// server/modules/skill-fabric/index.ts
|
||||||
|
// Skill Fabric module — public API
|
||||||
|
|
||||||
|
export { default as skillFabricRouter } from "./src/api/routes/skills.routes";
|
||||||
|
export { skillExecutor } from "./src/core/executor/SkillExecutor";
|
||||||
|
export { lifecycleManager } from "./src/core/lifecycle/LifecycleManager";
|
||||||
|
export { validationPipeline } from "./src/core/validator/ValidationPipeline";
|
||||||
|
export { codeCompiler } from "./src/core/compiler/CodeCompiler";
|
||||||
|
export { markdownCompiler } from "./src/core/compiler/MarkdownCompiler";
|
||||||
|
export { sandbox } from "./src/core/executor/Sandbox";
|
||||||
|
export type * from "./src/types";
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
// server/modules/skill-fabric/src/api/controllers/skills.controller.ts
|
||||||
|
// Controller layer — orchestrates validation, compilation, persistence, lifecycle
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { db } from "../../../../../../db";
|
||||||
|
import { skills, skillExecutions } from "@shared/schema";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { insertSkillSchema } from "@shared/schema";
|
||||||
|
import { codeCompiler } from "../../core/compiler/CodeCompiler";
|
||||||
|
import { markdownCompiler } from "../../core/compiler/MarkdownCompiler";
|
||||||
|
import { validationPipeline } from "../../core/validator/ValidationPipeline";
|
||||||
|
import { skillExecutor } from "../../core/executor/SkillExecutor";
|
||||||
|
import { lifecycleManager } from "../../core/lifecycle/LifecycleManager";
|
||||||
|
import type { CreateSkillFabricDto, UpdateSkillFabricDto, SkillMode } from "../../types";
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getTenant(req: Request): number | undefined {
|
||||||
|
return (req as unknown as { tenantId?: number }).tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserId(req: Request): string | undefined {
|
||||||
|
return (req as unknown as { user?: { id: string } }).user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compile(mode: SkillMode, source: string, name?: string) {
|
||||||
|
if (mode === "code") return codeCompiler.compile({ mode, source, name });
|
||||||
|
if (mode === "markdown") return markdownCompiler.compile({ mode, source, name });
|
||||||
|
// visual — not yet wired to a full UI, fallback to markdown
|
||||||
|
return markdownCompiler.compile({ mode: "markdown", source, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controllers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listSkills(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenant(req);
|
||||||
|
const where = tenantId ? eq(skills.tenantId, tenantId) : undefined;
|
||||||
|
const rows = await db.select().from(skills).where(where).orderBy(desc(skills.createdAt));
|
||||||
|
res.json(rows);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao listar skills" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [skill] = await db.select().from(skills).where(eq(skills.id, req.params.id)).limit(1);
|
||||||
|
if (!skill) { res.status(404).json({ error: "Skill não encontrada" }); return; }
|
||||||
|
res.json(skill);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao buscar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenant(req);
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const dto = req.body as CreateSkillFabricDto;
|
||||||
|
|
||||||
|
// 1. Domain validation
|
||||||
|
const validation = validationPipeline.validateCreate(dto);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.status(400).json({ error: "Validação falhou", details: validation.errors, warnings: validation.warnings });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compile source → body
|
||||||
|
const compiled = compile(dto.mode, dto.source, dto.name);
|
||||||
|
if (!compiled.ok) {
|
||||||
|
res.status(422).json({ error: "Falha na compilação", details: compiled.errors, warnings: compiled.warnings });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build insert payload using existing schema
|
||||||
|
const payload = insertSkillSchema.safeParse({
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
namespace: dto.namespace ?? "tenant",
|
||||||
|
description: dto.description,
|
||||||
|
version: dto.version ?? "1.0.0",
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
body: compiled.body,
|
||||||
|
parametersSchema: dto.parametersSchema ?? {},
|
||||||
|
returnSchema: dto.returnSchema ?? {},
|
||||||
|
status: "draft",
|
||||||
|
tenantId,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payload.success) {
|
||||||
|
res.status(400).json({ error: "Payload inválido", details: payload.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [skill] = await db.insert(skills).values(payload.data).returning();
|
||||||
|
res.status(201).json({ skill, warnings: compiled.warnings });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { code?: string };
|
||||||
|
if (e?.code === "23505") { res.status(409).json({ error: "Slug já existe neste tenant" }); return; }
|
||||||
|
res.status(500).json({ error: "Erro ao criar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto = req.body as UpdateSkillFabricDto;
|
||||||
|
|
||||||
|
// 1. Domain validation
|
||||||
|
const validation = validationPipeline.validateUpdate(dto);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.status(400).json({ error: "Validação falhou", details: validation.errors, warnings: validation.warnings });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If source provided, compile it
|
||||||
|
let body: string | undefined;
|
||||||
|
let compileWarnings: string[] = [];
|
||||||
|
if (dto.source) {
|
||||||
|
const mode = dto.mode ?? "markdown";
|
||||||
|
const compiled = compile(mode, dto.source);
|
||||||
|
if (!compiled.ok) {
|
||||||
|
res.status(422).json({ error: "Falha na compilação", details: compiled.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = compiled.body;
|
||||||
|
compileWarnings = compiled.warnings ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (dto.name) updateData.name = dto.name;
|
||||||
|
if (dto.description !== undefined) updateData.description = dto.description;
|
||||||
|
if (dto.version) updateData.version = dto.version;
|
||||||
|
if (dto.tags) updateData.tags = dto.tags;
|
||||||
|
if (dto.parametersSchema) updateData.parametersSchema = dto.parametersSchema;
|
||||||
|
if (dto.returnSchema) updateData.returnSchema = dto.returnSchema;
|
||||||
|
if (body !== undefined) updateData.body = body;
|
||||||
|
|
||||||
|
const [skill] = await db.update(skills)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(skills.id, req.params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!skill) { res.status(404).json({ error: "Skill não encontrada" }); return; }
|
||||||
|
res.json({ skill, warnings: compileWarnings });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao atualizar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compileSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { mode, source } = req.body as { mode: SkillMode; source: string };
|
||||||
|
if (!mode || !source) {
|
||||||
|
res.status(400).json({ error: "mode e source são obrigatórios" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = compile(mode, source);
|
||||||
|
res.json(result);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao compilar" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const result = await lifecycleManager.publish(req.params.id, userId);
|
||||||
|
if (!result.ok) { res.status(400).json({ error: result.error }); return; }
|
||||||
|
res.json({ ok: true, message: "Skill publicada com sucesso" });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao publicar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const result = await lifecycleManager.archive(req.params.id, userId);
|
||||||
|
if (!result.ok) { res.status(400).json({ error: result.error }); return; }
|
||||||
|
res.json({ ok: true, message: "Skill arquivada com sucesso" });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao arquivar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bumpVersion(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await lifecycleManager.bumpVersion(req.params.id);
|
||||||
|
if (!result.ok) { res.status(400).json({ error: result.error }); return; }
|
||||||
|
res.json({ ok: true, version: result.version });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao bumpar versão" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeSkill(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenant(req);
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const result = await skillExecutor.execute({
|
||||||
|
skillId: req.params.id,
|
||||||
|
parameters: req.body.parameters ?? {},
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
timeout: req.body.timeout ?? 5000,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao executar skill" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExecutions(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 50), 200);
|
||||||
|
const rows = await db.select().from(skillExecutions)
|
||||||
|
.where(eq(skillExecutions.skillId, req.params.id))
|
||||||
|
.orderBy(desc(skillExecutions.startedAt))
|
||||||
|
.limit(limit);
|
||||||
|
res.json(rows);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Erro ao buscar execuções" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
// server/modules/skill-fabric/src/api/routes/skills.routes.ts
|
||||||
|
// Skill Fabric API routes
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
listSkills,
|
||||||
|
getSkill,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
compileSkill,
|
||||||
|
publishSkill,
|
||||||
|
archiveSkill,
|
||||||
|
bumpVersion,
|
||||||
|
executeSkill,
|
||||||
|
getExecutions,
|
||||||
|
} from "../controllers/skills.controller";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ── CRUD ───────────────────────────────────────────────────────────────────────
|
||||||
|
router.get("/", listSkills);
|
||||||
|
router.get("/:id", getSkill);
|
||||||
|
router.post("/", createSkill);
|
||||||
|
router.put("/:id", updateSkill);
|
||||||
|
|
||||||
|
// ── Compilation ────────────────────────────────────────────────────────────────
|
||||||
|
router.post("/compile", compileSkill);
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
router.post("/:id/publish", publishSkill);
|
||||||
|
router.post("/:id/archive", archiveSkill);
|
||||||
|
router.post("/:id/bump-version", bumpVersion);
|
||||||
|
|
||||||
|
// ── Execution ─────────────────────────────────────────────────────────────────
|
||||||
|
router.post("/:id/execute", executeSkill);
|
||||||
|
router.get("/:id/executions", getExecutions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
// server/modules/skill-fabric/src/core/compiler/CodeCompiler.ts
|
||||||
|
// Compiles JavaScript/TypeScript-like skill code, validates syntax
|
||||||
|
|
||||||
|
import type { CompileInput, CompileResult, CompileError } from "../../types";
|
||||||
|
|
||||||
|
export class CodeCompiler {
|
||||||
|
compile(input: CompileInput): CompileResult {
|
||||||
|
const { source } = input;
|
||||||
|
const errors: CompileError[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!source || source.trim().length === 0) {
|
||||||
|
errors.push({ message: "Código não pode estar vazio", severity: "error", field: "source" } as CompileError & { field: string });
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic syntax checks
|
||||||
|
const syntaxErrors = this.checkSyntax(source);
|
||||||
|
errors.push(...syntaxErrors);
|
||||||
|
|
||||||
|
if (syntaxErrors.some((e) => e.severity === "error")) {
|
||||||
|
return { ok: false, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend //code marker so SkillEntity can detect the mode
|
||||||
|
const body = source.trimStart().startsWith("//code") ? source : `//code\n${source}`;
|
||||||
|
|
||||||
|
// Warn if no return statement
|
||||||
|
if (!source.includes("return ")) {
|
||||||
|
warnings.push("Nenhuma instrução 'return' encontrada — a skill não retornará dados");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body,
|
||||||
|
errors: [],
|
||||||
|
warnings,
|
||||||
|
metadata: { mode: "code", linesOfCode: source.split("\n").length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkSyntax(source: string): CompileError[] {
|
||||||
|
const errors: CompileError[] = [];
|
||||||
|
|
||||||
|
// Check balanced braces
|
||||||
|
let braces = 0;
|
||||||
|
let parens = 0;
|
||||||
|
const lines = source.split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
for (const ch of line) {
|
||||||
|
if (ch === "{") braces++;
|
||||||
|
if (ch === "}") braces--;
|
||||||
|
if (ch === "(") parens++;
|
||||||
|
if (ch === ")") parens--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braces < 0) {
|
||||||
|
errors.push({ line: i + 1, message: "Chave de fechamento sem abertura correspondente", severity: "error" });
|
||||||
|
braces = 0;
|
||||||
|
}
|
||||||
|
if (parens < 0) {
|
||||||
|
errors.push({ line: i + 1, message: "Parêntese de fechamento sem abertura correspondente", severity: "error" });
|
||||||
|
parens = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braces > 0) {
|
||||||
|
errors.push({ message: `${braces} chave(s) de abertura sem fechamento`, severity: "error" });
|
||||||
|
}
|
||||||
|
if (parens > 0) {
|
||||||
|
errors.push({ message: `${parens} parêntese(s) de abertura sem fechamento`, severity: "error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect obviously dangerous patterns (Rule 2: security)
|
||||||
|
const forbidden = [
|
||||||
|
{ pattern: /require\s*\(/, msg: "require() não permitido em skills — use os helpers disponíveis no contexto" },
|
||||||
|
{ pattern: /process\.exit/, msg: "process.exit() não permitido em skills" },
|
||||||
|
{ pattern: /child_process/, msg: "child_process não permitido em skills" },
|
||||||
|
{ pattern: /fs\./, msg: "Acesso direto ao filesystem não permitido em skills" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, msg } of forbidden) {
|
||||||
|
if (pattern.test(source)) {
|
||||||
|
errors.push({ message: msg, severity: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codeCompiler = new CodeCompiler();
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
// server/modules/skill-fabric/src/core/compiler/MarkdownCompiler.ts
|
||||||
|
// Validates and processes Markdown skill bodies (with optional YAML frontmatter)
|
||||||
|
|
||||||
|
import type { CompileInput, CompileResult, CompileError } from "../../types";
|
||||||
|
|
||||||
|
export class MarkdownCompiler {
|
||||||
|
compile(input: CompileInput): CompileResult {
|
||||||
|
const { source } = input;
|
||||||
|
const errors: CompileError[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!source || source.trim().length === 0) {
|
||||||
|
errors.push({ message: "Corpo Markdown não pode estar vazio", severity: "error" });
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional YAML frontmatter
|
||||||
|
let body = source;
|
||||||
|
let metadata: Record<string, unknown> = { mode: "markdown" };
|
||||||
|
|
||||||
|
if (source.startsWith("---")) {
|
||||||
|
const frontmatterResult = this.parseFrontmatter(source);
|
||||||
|
if (frontmatterResult.error) {
|
||||||
|
errors.push({ message: `Erro no frontmatter YAML: ${frontmatterResult.error}`, severity: "error" });
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
body = frontmatterResult.body;
|
||||||
|
metadata = { ...metadata, frontmatter: frontmatterResult.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for empty body after frontmatter stripping
|
||||||
|
if (body.trim().length === 0) {
|
||||||
|
warnings.push("Corpo Markdown está vazio após o frontmatter — adicione instruções");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate template references format: {{parameters.x}}, {{/var/x}}
|
||||||
|
const invalidRefs = this.findInvalidReferences(body);
|
||||||
|
for (const ref of invalidRefs) {
|
||||||
|
errors.push({ message: `Referência de template inválida: ${ref}`, severity: "error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.some((e) => e.severity === "error")) {
|
||||||
|
return { ok: false, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count lines and detect structure
|
||||||
|
const lines = body.split("\n");
|
||||||
|
const headings = lines.filter((l) => l.startsWith("#")).length;
|
||||||
|
if (headings === 0) {
|
||||||
|
warnings.push("Nenhum título (# Heading) encontrado — considere estruturar com headings");
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.linesOfContent = lines.length;
|
||||||
|
metadata.headingsCount = headings;
|
||||||
|
|
||||||
|
return { ok: true, body: source, errors: [], warnings, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFrontmatter(source: string): { data: Record<string, unknown>; body: string; error?: string } {
|
||||||
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { data: {}, body: source, error: "Frontmatter malformado — certifique-se de fechar com '---'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlText = match[1];
|
||||||
|
const body = match[2] ?? "";
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Simple YAML key: value parser (covers common cases)
|
||||||
|
try {
|
||||||
|
for (const line of yamlText.split("\n")) {
|
||||||
|
const colonIdx = line.indexOf(":");
|
||||||
|
if (colonIdx === -1) continue;
|
||||||
|
const key = line.slice(0, colonIdx).trim();
|
||||||
|
const value = line.slice(colonIdx + 1).trim();
|
||||||
|
if (key) {
|
||||||
|
// Detect arrays: value starts with [ or subsequent lines start with -
|
||||||
|
if (value.startsWith("[") && value.endsWith("]")) {
|
||||||
|
data[key] = value
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||||
|
} else {
|
||||||
|
data[key] = value.replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { data: {}, body, error: String(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private findInvalidReferences(body: string): string[] {
|
||||||
|
const invalid: string[] = [];
|
||||||
|
// Find {{ ... }} patterns and validate them
|
||||||
|
const pattern = /\{\{([^}]+)\}\}/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = pattern.exec(body)) !== null) {
|
||||||
|
const inner = m[1].trim();
|
||||||
|
const validPatterns = [
|
||||||
|
/^parameters\.\w+(\.\w+)*$/,
|
||||||
|
/^\/var\/[\w/]+(\.\w+)*$/,
|
||||||
|
/^\/skill[\w/]*$/,
|
||||||
|
/^\w+$/, // simple variable name
|
||||||
|
];
|
||||||
|
if (!validPatterns.some((p) => p.test(inner))) {
|
||||||
|
invalid.push(m[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markdownCompiler = new MarkdownCompiler();
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
// server/modules/skill-fabric/src/core/compiler/VisualCompiler.ts
|
||||||
|
// Compiles visual node-graph skill definitions into a Markdown body
|
||||||
|
// Visual canvas is a stretch goal — this stub handles the future format
|
||||||
|
|
||||||
|
import type { CompileInput, CompileResult } from "../../types";
|
||||||
|
|
||||||
|
export interface VisualNode {
|
||||||
|
id: string;
|
||||||
|
type: "trigger" | "step" | "condition" | "output";
|
||||||
|
label: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
next?: string[]; // node ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualGraph {
|
||||||
|
nodes: VisualNode[];
|
||||||
|
edges: Array<{ from: string; to: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VisualCompiler {
|
||||||
|
compile(input: CompileInput): CompileResult {
|
||||||
|
const { source } = input;
|
||||||
|
|
||||||
|
if (!source || source.trim().length === 0) {
|
||||||
|
return { ok: false, errors: [{ message: "Grafo visual não pode estar vazio", severity: "error" }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let graph: VisualGraph;
|
||||||
|
try {
|
||||||
|
graph = JSON.parse(source) as VisualGraph;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, errors: [{ message: "Grafo visual inválido — esperado JSON", severity: "error" }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!graph.nodes || graph.nodes.length === 0) {
|
||||||
|
return { ok: false, errors: [{ message: "Grafo visual deve ter pelo menos um nó", severity: "error" }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert visual graph to Markdown body
|
||||||
|
const body = this.graphToMarkdown(graph);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body,
|
||||||
|
errors: [],
|
||||||
|
warnings: ["Modo visual está em fase experimental"],
|
||||||
|
metadata: { mode: "visual", nodeCount: graph.nodes.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private graphToMarkdown(graph: VisualGraph): string {
|
||||||
|
const lines: string[] = ["# Skill Visual\n"];
|
||||||
|
|
||||||
|
const trigger = graph.nodes.find((n) => n.type === "trigger");
|
||||||
|
if (trigger) {
|
||||||
|
lines.push(`## Gatilho: ${trigger.label}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = graph.nodes.filter((n) => n.type === "step");
|
||||||
|
if (steps.length > 0) {
|
||||||
|
lines.push("## Passos\n");
|
||||||
|
for (const step of steps) {
|
||||||
|
lines.push(`- ${step.label}`);
|
||||||
|
if (step.config) {
|
||||||
|
for (const [k, v] of Object.entries(step.config)) {
|
||||||
|
lines.push(` - ${k}: ${String(v)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = graph.nodes.filter((n) => n.type === "output");
|
||||||
|
if (outputs.length > 0) {
|
||||||
|
lines.push("\n## Saída\n");
|
||||||
|
for (const out of outputs) {
|
||||||
|
lines.push(`- ${out.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const visualCompiler = new VisualCompiler();
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
// server/modules/skill-fabric/src/core/executor/Sandbox.ts
|
||||||
|
// Safe code execution using Node.js built-in vm module (NOT vm2)
|
||||||
|
|
||||||
|
import vm from "vm";
|
||||||
|
import type { SandboxOptions, SandboxResult } from "../../types";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export class Sandbox {
|
||||||
|
run(opts: SandboxOptions): SandboxResult {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
// Build a safe context — only expose what we explicitly allow
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
// Safe console (captures logs instead of printing them)
|
||||||
|
console: {
|
||||||
|
log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
|
||||||
|
warn: (...args: unknown[]) => logs.push("[warn] " + args.map(String).join(" ")),
|
||||||
|
error: (...args: unknown[]) => logs.push("[error] " + args.map(String).join(" ")),
|
||||||
|
},
|
||||||
|
// Safe Math, Date, JSON
|
||||||
|
Math,
|
||||||
|
Date,
|
||||||
|
JSON,
|
||||||
|
// Utility helpers
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
parseInt,
|
||||||
|
parseFloat,
|
||||||
|
isNaN,
|
||||||
|
isFinite,
|
||||||
|
encodeURIComponent,
|
||||||
|
decodeURIComponent,
|
||||||
|
// User-provided context variables
|
||||||
|
...(opts.context ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.createContext(context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wrap code in an async IIFE so top-level await is possible
|
||||||
|
const wrappedCode = `
|
||||||
|
(async function __skill__() {
|
||||||
|
${opts.code}
|
||||||
|
})()
|
||||||
|
`;
|
||||||
|
const script = new vm.Script(wrappedCode, { filename: "skill.js" });
|
||||||
|
const result = script.runInContext(context, { timeout }) as Promise<unknown> | unknown;
|
||||||
|
|
||||||
|
// Handle both sync and async execution
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
// For async, we use a synchronous trick via vm timers
|
||||||
|
// Since vm doesn't natively support Promise.resolve waiting without event loop,
|
||||||
|
// we return the promise wrapped result as "pending" — caller should use runAsync
|
||||||
|
return { ok: true, value: "[async — use runAsync]", logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value: result, logs };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
logs.push("[error] " + error);
|
||||||
|
return { ok: false, error, logs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Async-safe version that properly awaits the result */
|
||||||
|
async runAsync(opts: SandboxOptions): Promise<SandboxResult> {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
console: {
|
||||||
|
log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
|
||||||
|
warn: (...args: unknown[]) => logs.push("[warn] " + args.map(String).join(" ")),
|
||||||
|
error: (...args: unknown[]) => logs.push("[error] " + args.map(String).join(" ")),
|
||||||
|
},
|
||||||
|
Math,
|
||||||
|
Date,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
parseInt,
|
||||||
|
parseFloat,
|
||||||
|
isNaN,
|
||||||
|
isFinite,
|
||||||
|
encodeURIComponent,
|
||||||
|
decodeURIComponent,
|
||||||
|
Promise,
|
||||||
|
...(opts.context ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.createContext(context);
|
||||||
|
|
||||||
|
const wrappedCode = `
|
||||||
|
(async function __skill__() {
|
||||||
|
${opts.code}
|
||||||
|
})()
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const script = new vm.Script(wrappedCode, { filename: "skill.js" });
|
||||||
|
|
||||||
|
// Run the script (returns a Promise from the IIFE)
|
||||||
|
const promise = script.runInContext(context) as Promise<unknown>;
|
||||||
|
|
||||||
|
// Race against timeout
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Timeout: execução excedeu ${timeout}ms`)), timeout)
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = await Promise.race([promise, timeoutPromise]);
|
||||||
|
return { ok: true, value, logs };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
logs.push("[error] " + error);
|
||||||
|
return { ok: false, error, logs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sandbox = new Sandbox();
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
// server/modules/skill-fabric/src/core/executor/SkillExecutor.ts
|
||||||
|
// Executes skills — dispatches to Sandbox (code) or template renderer (markdown)
|
||||||
|
|
||||||
|
import { db } from "../../../../../../db";
|
||||||
|
import { skills, skillExecutions } from "@shared/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { sandbox } from "./Sandbox";
|
||||||
|
import type { FabricExecuteOptions, FabricExecuteResult } from "../../types";
|
||||||
|
|
||||||
|
export class SkillExecutor {
|
||||||
|
async execute(opts: FabricExecuteOptions): Promise<FabricExecuteResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Load skill
|
||||||
|
const conditions = [eq(skills.id, opts.skillId)];
|
||||||
|
if (opts.tenantId) conditions.push(eq(skills.tenantId, opts.tenantId));
|
||||||
|
|
||||||
|
const [skill] = await db.select().from(skills).where(and(...conditions)).limit(1);
|
||||||
|
if (!skill) {
|
||||||
|
return { ok: false, error: `Skill não encontrada: ${opts.skillId}`, durationMs: 0, executionId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skill.status !== "active") {
|
||||||
|
return { ok: false, error: `Skill está ${skill.status} — somente skills ativas podem ser executadas`, durationMs: 0, executionId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create execution record
|
||||||
|
const [execution] = await db.insert(skillExecutions).values({
|
||||||
|
skillId: skill.id,
|
||||||
|
tenantId: opts.tenantId ?? null,
|
||||||
|
triggeredBy: "manual",
|
||||||
|
triggeredByUserId: opts.userId ?? null,
|
||||||
|
parameters: opts.parameters ?? {},
|
||||||
|
status: "running",
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: unknown;
|
||||||
|
|
||||||
|
const body = skill.body ?? "";
|
||||||
|
const isCodeSkill = body.trimStart().startsWith("//code");
|
||||||
|
|
||||||
|
if (isCodeSkill) {
|
||||||
|
// Strip the //code marker before executing
|
||||||
|
const code = body.replace(/^\/\/code\s*\n?/, "");
|
||||||
|
const sandboxResult = await sandbox.runAsync({
|
||||||
|
code,
|
||||||
|
context: {
|
||||||
|
parameters: opts.parameters ?? {},
|
||||||
|
skillId: skill.id,
|
||||||
|
skillSlug: skill.slug,
|
||||||
|
},
|
||||||
|
timeout: opts.timeout ?? 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
logs.push(...sandboxResult.logs);
|
||||||
|
|
||||||
|
if (!sandboxResult.ok) {
|
||||||
|
throw new Error(sandboxResult.error ?? "Erro de execução no sandbox");
|
||||||
|
}
|
||||||
|
result = sandboxResult.value;
|
||||||
|
} else {
|
||||||
|
// Markdown skill — interpolate template variables
|
||||||
|
result = this.renderMarkdown(body, opts.parameters ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const auditHash = crypto.createHash("sha256").update(JSON.stringify(result)).digest("hex");
|
||||||
|
|
||||||
|
await db.update(skillExecutions)
|
||||||
|
.set({ status: "success", result: result as Record<string, unknown>, durationMs, completedAt: new Date(), auditHash })
|
||||||
|
.where(eq(skillExecutions.id, execution.id));
|
||||||
|
|
||||||
|
return { ok: true, data: result, durationMs, executionId: execution.id, logs };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
await db.update(skillExecutions)
|
||||||
|
.set({ status: "failed", error, durationMs, completedAt: new Date() })
|
||||||
|
.where(eq(skillExecutions.id, execution.id));
|
||||||
|
|
||||||
|
return { ok: false, error, durationMs, executionId: execution.id, logs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMarkdown(body: string, parameters: Record<string, unknown>): unknown {
|
||||||
|
let output = body;
|
||||||
|
|
||||||
|
// Substitute {{parameters.x}}
|
||||||
|
output = output.replace(/\{\{parameters\.([^}]+)\}\}/g, (_, k: string) => {
|
||||||
|
const val = (parameters as Record<string, unknown>)[k];
|
||||||
|
return val !== undefined ? String(val) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Substitute {{varName}} — simple top-level vars from parameters
|
||||||
|
output = output.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
|
||||||
|
const val = (parameters as Record<string, unknown>)[k];
|
||||||
|
return val !== undefined ? String(val) : `{{${k}}}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rendered: output, renderedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skillExecutor = new SkillExecutor();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue