Compare commits
11 Commits
bcc0e21fe0
...
e38cbb24d3
| Author | SHA1 | Date |
|---|---|---|
|
|
e38cbb24d3 | |
|
|
20a0237f91 | |
|
|
8d14fc021c | |
|
|
219af5f6f5 | |
|
|
c77b414029 | |
|
|
d1b1b2f0a5 | |
|
|
e82279d9eb | |
|
|
171dd91a69 | |
|
|
fc20a0e8b2 | |
|
|
eb0926704a | |
|
|
f0c3ce483e |
26
.env.example
26
.env.example
|
|
@ -68,9 +68,15 @@ PLUS_URL=http://localhost:8080
|
|||
PLUS_PORT=8080
|
||||
PLUS_API_TOKEN=
|
||||
|
||||
# ── Superset (BI avançado) ────────────────────────────────────────────────────
|
||||
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura
|
||||
# ── Superset (BI avançado — Arcádia Insights) ────────────────────────────────
|
||||
SUPERSET_HOST=superset
|
||||
SUPERSET_PORT=8088
|
||||
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura # openssl rand -hex 32
|
||||
SUPERSET_ADMIN_USER=admin
|
||||
SUPERSET_ADMIN_EMAIL=admin@arcadia.app
|
||||
SUPERSET_ADMIN_PASSWORD=troque-senha-forte-em-prod
|
||||
# URL do banco Arcádia para o Superset acessar os dados (idealmente read-only)
|
||||
ARCADIA_DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
|
@ -78,8 +84,20 @@ REDIS_URL=redis://localhost:6379
|
|||
# ── Domínio (produção) ────────────────────────────────────────────────────────
|
||||
DOMAIN=seudominio.com.br
|
||||
|
||||
# ── Integrações externas (opcional) ──────────────────────────────────────────
|
||||
# ERPNext
|
||||
# ── ERPNext ────────────────────────────────────────────────────────────────────
|
||||
# Modo 1 — ERPNext em container Docker (perfil erpnext)
|
||||
# Sobe com: docker compose --profile erpnext up
|
||||
# Proxy ativado automaticamente em DOCKER_MODE=true
|
||||
ERPNEXT_PROXY_ENABLED=false # true para forçar proxy mesmo fora do Docker
|
||||
ERPNEXT_CONTAINER_HOST=erpnext # nome do serviço no docker-compose
|
||||
ERPNEXT_CONTAINER_PORT=8080
|
||||
ERPNEXT_DB_ROOT_PASSWORD=erpnext-root-2026 # senha root do MariaDB
|
||||
ERPNEXT_DB_PASSWORD=frappe2026 # senha do usuário frappe
|
||||
ERPNEXT_ADMIN_PASSWORD=admin2026 # senha do Administrator no site
|
||||
|
||||
# Modo 2 — ERPNext externo (instância existente)
|
||||
# Em Docker: ERPNEXT_URL=http://erpnext:8080 (aponta pro container)
|
||||
# Externo: ERPNEXT_URL=https://meuerpnext.com
|
||||
ERPNEXT_URL=
|
||||
ERPNEXT_API_KEY=
|
||||
ERPNEXT_API_SECRET=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
# Deploy no Coolify — Arcádia Suite
|
||||
|
||||
> Guia completo para subir o Arcádia Suite no Coolify com Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Pré-requisitos](#pré-requisitos)
|
||||
2. [Arquitetura de Serviços](#arquitetura-de-serviços)
|
||||
3. [Configuração no Coolify](#configuração-no-coolify)
|
||||
4. [Variáveis de Ambiente](#variáveis-de-ambiente)
|
||||
5. [Serviços Opcionais (Profiles)](#serviços-opcionais-profiles)
|
||||
6. [Sequência de Inicialização](#sequência-de-inicialização)
|
||||
7. [Traefik e HTTPS](#traefik-e-https)
|
||||
8. [Volumes e Persistência](#volumes-e-persistência)
|
||||
9. [Stack de IA (LiteLLM + Ollama)](#stack-de-ia-litellm--ollama)
|
||||
10. [Pós-deploy](#pós-deploy)
|
||||
11. [Checklist Final](#checklist-final)
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Coolify instalado e acessível (v4+)
|
||||
- Docker Engine 24+ e Docker Compose v2+ no servidor
|
||||
- Domínio apontando para o IP do servidor (`A record`)
|
||||
- Mínimo recomendado: **4 vCPU, 8 GB RAM, 50 GB SSD**
|
||||
- Para IA local (Ollama): **16 GB RAM, GPU opcional**
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura de Serviços
|
||||
|
||||
```
|
||||
Internet → Traefik (Coolify) → app:5000 → arcadia-internal
|
||||
→ open-webui:8080 (opcional)
|
||||
|
||||
arcadia-internal:
|
||||
db (PostgreSQL 16 + pgvector) :5432
|
||||
redis :6379
|
||||
app (Node.js/Express) :5000
|
||||
embeddings (Python FastAPI) :8001
|
||||
fisco (Python FastAPI) :8002
|
||||
contabil (Python FastAPI) :8003
|
||||
bi (Python FastAPI) :8004
|
||||
automation (Python FastAPI) :8005
|
||||
litellm (gateway LLM) :4000 ← profile: ai
|
||||
ollama (modelos locais) :11434 ← profile: ai
|
||||
superset (BI) :8088 ← profile: bi
|
||||
plus (Laravel/ERP) :8080 ← profile: plus
|
||||
erpnext :8090 ← profile: erpnext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuração no Coolify
|
||||
|
||||
### 1. Criar novo projeto
|
||||
|
||||
No painel Coolify: **New Project → Docker Compose**.
|
||||
|
||||
### 2. Apontar para o repositório
|
||||
|
||||
- **Source:** Git (GitHub/GitLab/Gitea)
|
||||
- **Branch:** `main` (ou sua branch de produção)
|
||||
- **Docker Compose file:** `docker-compose.prod.yml`
|
||||
|
||||
### 3. Definir o domínio
|
||||
|
||||
Em **Domains**, adicione `seu-dominio.com`. O Coolify irá automaticamente:
|
||||
- Criar as rotas no Traefik
|
||||
- Emitir certificado Let's Encrypt
|
||||
|
||||
---
|
||||
|
||||
## Variáveis de Ambiente
|
||||
|
||||
Configure todas no painel **Environment Variables** do Coolify (nunca no `.env` do repositório).
|
||||
|
||||
### Obrigatórias
|
||||
|
||||
```env
|
||||
# Aplicação
|
||||
NODE_ENV=production
|
||||
PORT=5000
|
||||
DOMAIN=seu-dominio.com
|
||||
DOCKER_MODE=true
|
||||
|
||||
# Segredos — gerar com: openssl rand -hex 32
|
||||
SESSION_SECRET=<gerado>
|
||||
SSO_SECRET=<gerado>
|
||||
|
||||
# Banco de dados
|
||||
PGHOST=db
|
||||
PGPORT=5432
|
||||
PGUSER=arcadia
|
||||
PGPASSWORD=<senha-forte>
|
||||
PGDATABASE=arcadia
|
||||
DATABASE_URL=postgresql://arcadia:<senha>@db:5432/arcadia
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# IA — LiteLLM gateway
|
||||
LITELLM_API_KEY=<gerado>
|
||||
AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1
|
||||
AI_INTEGRATIONS_OPENAI_API_KEY=<mesmo-que-LITELLM_API_KEY>
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# Microserviços Python
|
||||
PYTHON_SERVICE_URL=http://embeddings:8001
|
||||
FISCO_PYTHON_URL=http://fisco:8002
|
||||
CONTABIL_PYTHON_URL=http://contabil:8003
|
||||
BI_PYTHON_URL=http://bi:8004
|
||||
AUTOMATION_PYTHON_URL=http://automation:8005
|
||||
```
|
||||
|
||||
### LLMs externos (opcionais — soberania: deixar em branco)
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
LLMFIT_BASE_URL= # habilitar quando LLMFit estiver disponível
|
||||
```
|
||||
|
||||
### Superset — profile `bi`
|
||||
|
||||
```env
|
||||
SUPERSET_SECRET_KEY=<openssl rand -hex 32>
|
||||
SUPERSET_ADMIN_USER=admin
|
||||
SUPERSET_ADMIN_EMAIL=admin@seu-dominio.com
|
||||
SUPERSET_ADMIN_PASSWORD=<senha-forte>
|
||||
SUPERSET_HOST=superset
|
||||
SUPERSET_PORT=8088
|
||||
```
|
||||
|
||||
### Arcádia Plus — profile `plus`
|
||||
|
||||
```env
|
||||
SSO_PLUS_BASE_URL=http://plus:8080
|
||||
PLUS_APP_KEY=<laravel-key>
|
||||
PLUS_DB_ROOT_PASSWORD=<senha>
|
||||
PLUS_DB_PASSWORD=<senha>
|
||||
PLUS_DB_DATABASE=arcadia_plus
|
||||
PLUS_DB_USER=plus
|
||||
```
|
||||
|
||||
### ERPNext — profile `erpnext`
|
||||
|
||||
```env
|
||||
ERPNEXT_PROXY_ENABLED=true
|
||||
ERPNEXT_CONTAINER_HOST=erpnext
|
||||
ERPNEXT_DB_ROOT_PASSWORD=<senha>
|
||||
ERPNEXT_DB_PASSWORD=<senha>
|
||||
ERPNEXT_ADMIN_PASSWORD=<senha>
|
||||
ERPNEXT_URL=http://erpnext:8090
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Serviços Opcionais (Profiles)
|
||||
|
||||
O `docker-compose.prod.yml` usa profiles para habilitar serviços sob demanda.
|
||||
No Coolify, adicione a variável de ambiente `COMPOSE_PROFILES`:
|
||||
|
||||
| O que habilitar | `COMPOSE_PROFILES` |
|
||||
|---|---|
|
||||
| Somente core | *(vazio)* |
|
||||
| IA local (Ollama + LiteLLM + WebUI) | `ai` |
|
||||
| Superset BI | `bi` |
|
||||
| Arcádia Plus (Laravel ERP) | `plus` |
|
||||
| ERPNext | `erpnext` |
|
||||
| Tudo | `ai,bi,plus,erpnext` |
|
||||
|
||||
> **Atenção:** Ollama com modelos grandes (llama3.3, qwen2.5) pode consumir 10–20 GB de disco. Certifique-se de ter espaço no volume `ollama_models`.
|
||||
|
||||
---
|
||||
|
||||
## Sequência de Inicialização
|
||||
|
||||
Os `depends_on` garantem a ordem correta automaticamente:
|
||||
|
||||
```
|
||||
1. db → healthcheck: pg_isready (até 10 tentativas)
|
||||
2. redis → healthcheck: redis-cli ping
|
||||
3. embeddings, fisco, contabil, bi, automation → aguardam db healthy
|
||||
4. litellm, ollama → aguardam redis (se profile ai)
|
||||
5. app → aguarda db healthy + redis started
|
||||
6. open-webui, superset, plus, erpnext → aguardam serviços base
|
||||
```
|
||||
|
||||
Se um serviço falhar ao iniciar, cheque os logs:
|
||||
|
||||
```bash
|
||||
# No servidor
|
||||
docker compose -f docker-compose.prod.yml logs app --tail 50
|
||||
docker compose -f docker-compose.prod.yml logs db --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Traefik e HTTPS
|
||||
|
||||
O Coolify gerencia o Traefik. As labels já estão no `docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
# Serviço principal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.arcadia.tls=true"
|
||||
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.arcadia.loadbalancer.server.port=5000"
|
||||
|
||||
# Open WebUI (profile ai) — subdomínio ai.seu-dominio.com
|
||||
labels:
|
||||
- "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)"
|
||||
- "traefik.http.routers.webui.tls=true"
|
||||
- "traefik.http.routers.webui.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.webui.loadbalancer.server.port=8080"
|
||||
```
|
||||
|
||||
Certifique-se de que o DNS do subdomínio `ai.seu-dominio.com` também aponta para o servidor antes de habilitar o profile `ai`.
|
||||
|
||||
---
|
||||
|
||||
## Volumes e Persistência
|
||||
|
||||
| Volume | Conteúdo | Criticidade |
|
||||
|---|---|---|
|
||||
| `pgdata` | Banco de dados PostgreSQL | **CRÍTICO — fazer backup** |
|
||||
| `redis_data` | Sessões e cache | Alta |
|
||||
| `ollama_models` | Modelos LLM baixados (~10–20 GB) | Média (redownload possível) |
|
||||
| `open_webui_data` | Histórico do WebUI | Baixa |
|
||||
| `superset_home` | Dashboards Superset | Média |
|
||||
| `plus_db` | Banco MySQL do Plus | Alta |
|
||||
| `plus_storage` | Arquivos do Plus | Alta |
|
||||
| `erpnext_sites` | Sites ERPNext | **CRÍTICO** |
|
||||
| `erpnext_logs` | Logs ERPNext | Baixa |
|
||||
|
||||
### Backup do PostgreSQL
|
||||
|
||||
```bash
|
||||
# Backup
|
||||
docker exec arcadia-db pg_dump -U arcadia arcadia | gzip > backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Restore
|
||||
gunzip < backup-20240101.sql.gz | docker exec -i arcadia-db psql -U arcadia arcadia
|
||||
```
|
||||
|
||||
Configure backups automáticos no Coolify em **Project → Backups**.
|
||||
|
||||
---
|
||||
|
||||
## Stack de IA (LiteLLM + Ollama)
|
||||
|
||||
### Três tiers configurados em `docker/litellm-config.yaml`
|
||||
|
||||
```
|
||||
TIER 1 — LLMFit (fine-tuned, soberano) → slot pronto, habilitar via LLMFIT_BASE_URL
|
||||
TIER 2 — Ollama (local, padrão) → llama3.3, qwen2.5-coder, nomic-embed-text
|
||||
TIER 3 — Externos (opt-in) → OpenAI, Anthropic, Groq (apenas se API key definida)
|
||||
```
|
||||
|
||||
### Baixar modelos Ollama após o primeiro deploy
|
||||
|
||||
```bash
|
||||
docker exec arcadia-ollama ollama pull llama3.3
|
||||
docker exec arcadia-ollama ollama pull qwen2.5-coder:7b
|
||||
docker exec arcadia-ollama ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
### Habilitar LLMFit (quando disponível)
|
||||
|
||||
1. Defina `LLMFIT_BASE_URL=http://seu-llmfit:porta`
|
||||
2. Descomente o bloco TIER 1 em `docker/litellm-config.yaml`
|
||||
3. Redeploy do serviço `litellm`
|
||||
|
||||
---
|
||||
|
||||
## Pós-deploy
|
||||
|
||||
### Rodar migrations do banco
|
||||
|
||||
```bash
|
||||
# Via Coolify → Run Command, ou no servidor:
|
||||
docker exec arcadia-app npm run db:push
|
||||
```
|
||||
|
||||
### Verificar saúde dos serviços
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
Todos devem estar `healthy` ou `running`. Se algum ficar em `restarting`, verifique os logs.
|
||||
|
||||
### Verificar conectividade IA
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||
http://localhost:4000/v1/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Final
|
||||
|
||||
### Antes do deploy
|
||||
|
||||
- [ ] Domínio DNS apontando para o servidor (`A record`)
|
||||
- [ ] Todas as variáveis obrigatórias definidas no Coolify
|
||||
- [ ] `SESSION_SECRET` e `SSO_SECRET` gerados com `openssl rand -hex 32`
|
||||
- [ ] Senhas do banco definidas (nunca reutilizar senhas de dev)
|
||||
- [ ] `DOCKER_MODE=true` definido
|
||||
|
||||
### No Coolify
|
||||
|
||||
- [ ] Projeto criado apontando para `docker-compose.prod.yml`
|
||||
- [ ] Domínio configurado no Coolify
|
||||
- [ ] HTTPS ativo (Let's Encrypt)
|
||||
- [ ] Volumes persistentes configurados (especialmente `pgdata`)
|
||||
- [ ] `COMPOSE_PROFILES` definido conforme os serviços desejados
|
||||
|
||||
### Após o deploy
|
||||
|
||||
- [ ] `npm run db:push` executado (migrations)
|
||||
- [ ] Login funcional no `https://seu-dominio.com`
|
||||
- [ ] Healthchecks verdes no painel Coolify
|
||||
- [ ] Modelos Ollama baixados (se profile `ai` ativo)
|
||||
- [ ] Backup automático configurado para `pgdata`
|
||||
- [ ] Monitoramento/alertas configurados (Coolify suporta webhooks)
|
||||
|
||||
---
|
||||
|
||||
## Comandos de Referência Rápida
|
||||
|
||||
```bash
|
||||
# Subir apenas o core
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Subir com IA
|
||||
docker compose -f docker-compose.prod.yml --profile ai up -d
|
||||
|
||||
# Subir tudo
|
||||
docker compose -f docker-compose.prod.yml --profile ai --profile bi --profile plus up -d
|
||||
|
||||
# Ver logs em tempo real
|
||||
docker compose -f docker-compose.prod.yml logs -f app
|
||||
|
||||
# Reiniciar um serviço específico
|
||||
docker compose -f docker-compose.prod.yml restart app
|
||||
|
||||
# Atualizar para nova versão (após git pull)
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps app
|
||||
```
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Switch, Route } from "wouter";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
|
@ -67,6 +67,9 @@ const XosAutomations = lazy(() => import("@/pages/XosAutomations"));
|
|||
const XosSites = lazy(() => import("@/pages/XosSites"));
|
||||
const XosGovernance = lazy(() => import("@/pages/XosGovernance"));
|
||||
const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
|
||||
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
||||
const XosReports = lazy(() => import("@/pages/XosReports"));
|
||||
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||
|
||||
|
||||
function LoadingFallback() {
|
||||
|
|
@ -121,7 +124,7 @@ function Router() {
|
|||
<ProtectedRoute path="/engineering" component={EngineeringHub} />
|
||||
<ProtectedRoute path="/development" component={DevelopmentModule} />
|
||||
<ProtectedRoute path="/retail" component={ArcadiaRetail} />
|
||||
<ProtectedRoute path="/plus" component={Plus} />
|
||||
<ProtectedRoute path="/plus" component={() => { const [, nav] = useLocation(); useEffect(() => nav("/soe"), []); return null; }} />
|
||||
<ProtectedRoute path="/super-admin" component={SuperAdmin} />
|
||||
<ProtectedRoute path="/marketplace" component={Marketplace} />
|
||||
<ProtectedRoute path="/lms" component={LMS} />
|
||||
|
|
@ -135,6 +138,9 @@ function Router() {
|
|||
<ProtectedRoute path="/xos/sites" component={XosSites} />
|
||||
<ProtectedRoute path="/xos/governance" component={XosGovernance} />
|
||||
<ProtectedRoute path="/xos/pipeline" component={XosPipeline} />
|
||||
<ProtectedRoute path="/xos/supervisor" component={XosSupervisor} />
|
||||
<ProtectedRoute path="/xos/reports" component={XosReports} />
|
||||
<ProtectedRoute path="/xos/protocols" component={XosProtocols} />
|
||||
|
||||
<ProtectedRoute path="/doctype-builder" component={DocTypeBuilder} />
|
||||
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Loader2, AlertTriangle, ExternalLink, RefreshCw } from "lucide-react";
|
||||
|
||||
interface SupersetDashboardProps {
|
||||
dashboardId: string;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
showOpenLink?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente reutilizável para embedar dashboards do Apache Superset (Arcádia Insights)
|
||||
* em QUALQUER tela do Arcádia Suite via Guest Token JWT.
|
||||
*
|
||||
* Uso:
|
||||
* <SupersetDashboard dashboardId="financial-overview" />
|
||||
* <SupersetDashboard dashboardId="dre-mensal" height={400} />
|
||||
*/
|
||||
export function SupersetDashboard({
|
||||
dashboardId,
|
||||
height = "calc(100vh - 320px)",
|
||||
className = "",
|
||||
showOpenLink = true,
|
||||
}: SupersetDashboardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sdkRef = useRef<any>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error" | "unavailable">("loading");
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
|
||||
const fetchGuestToken = useCallback(async (): Promise<string> => {
|
||||
const resp = await fetch("/api/superset/guest-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dashboardId }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: "Erro desconhecido" }));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
const { token } = await resp.json();
|
||||
return token;
|
||||
}, [dashboardId]);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
setStatus("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
// Verifica se Superset está disponível
|
||||
const health = await fetch("/api/superset/health", { credentials: "include" });
|
||||
const healthData = await health.json();
|
||||
if (!healthData.online) {
|
||||
setStatus("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
// Importa SDK dinamicamente (só quando necessário)
|
||||
const { embedDashboard } = await import("@superset-ui/embedded-sdk");
|
||||
|
||||
// Limpa embedding anterior se houver
|
||||
if (sdkRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
|
||||
sdkRef.current = await embedDashboard({
|
||||
id: dashboardId,
|
||||
supersetDomain: window.location.origin + "/superset",
|
||||
mountPoint: containerRef.current,
|
||||
fetchGuestToken,
|
||||
dashboardUiConfig: {
|
||||
hideTitle: true,
|
||||
hideChartControls: false,
|
||||
filters: { visible: true, expanded: false },
|
||||
},
|
||||
});
|
||||
|
||||
setStatus("ready");
|
||||
} catch (err: any) {
|
||||
console.error("[SupersetDashboard] Erro:", err.message);
|
||||
// Se SDK não estiver instalado, usa iframe como fallback
|
||||
if (err.message?.includes("Cannot find module") || err.message?.includes("embedDashboard")) {
|
||||
setStatus("unavailable");
|
||||
} else {
|
||||
setErrorMsg(err.message);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
}, [dashboardId, fetchGuestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
return () => {
|
||||
if (containerRef.current) containerRef.current.innerHTML = "";
|
||||
};
|
||||
}, [loadDashboard]);
|
||||
|
||||
const heightStyle = typeof height === "number" ? `${height}px` : height;
|
||||
|
||||
if (status === "unavailable") {
|
||||
return (
|
||||
<div className={`rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm ${className}`} style={{ height: heightStyle }}>
|
||||
<iframe
|
||||
src="/superset/login"
|
||||
className="w-full h-full border-0"
|
||||
title="Arcádia Insights — Apache Superset"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-4 rounded-xl border border-red-100 bg-red-50 ${className}`} style={{ height: heightStyle }}>
|
||||
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-red-700 text-sm">Erro ao carregar dashboard</p>
|
||||
<p className="text-red-500 text-xs mt-1">{errorMsg}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" /> Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ height: heightStyle }}>
|
||||
{status === "loading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/90 z-10 rounded-xl">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-[#c89b3c] animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-[#1f334d] font-medium">Carregando Arcádia Insights...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOpenLink && status === "ready" && (
|
||||
<a
|
||||
href="/superset"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-3 right-3 z-10 flex items-center gap-1 px-2 py-1 bg-white/80 backdrop-blur text-[#1f334d] text-xs rounded-lg border border-gray-200 hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" /> Abrir completo
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full rounded-xl overflow-hidden border border-[#c89b3c]/20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
interface SoeMotorContextValue {
|
||||
activeCompany: string | null;
|
||||
setActiveCompany: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const SoeMotorContext = createContext<SoeMotorContextValue>({
|
||||
activeCompany: null,
|
||||
setActiveCompany: () => {},
|
||||
});
|
||||
|
||||
export function SoeMotorProvider({ children }: { children: ReactNode }) {
|
||||
const [activeCompany, setActiveCompany] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<SoeMotorContext.Provider value={{ activeCompany, setActiveCompany }}>
|
||||
{children}
|
||||
</SoeMotorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSoeMotor() {
|
||||
return useContext(SoeMotorContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* useXosSocket — subscribes to XOS real-time events from Socket.IO.
|
||||
* Usage:
|
||||
* const { supervisorStats, lastSlaBreachEvent } = useXosSocket();
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
export interface SupervisorStats {
|
||||
openConversations: number;
|
||||
resolvedToday: number;
|
||||
urgentTickets: number;
|
||||
agentsOnline: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SlaBreachEvent {
|
||||
protocolId: number;
|
||||
protocolNumber: string;
|
||||
tenantId: number;
|
||||
queueId: number | null;
|
||||
assignedTo: number | null;
|
||||
breachedAt: string;
|
||||
}
|
||||
|
||||
interface XosSocketState {
|
||||
connected: boolean;
|
||||
supervisorStats: SupervisorStats | null;
|
||||
lastSlaBreachEvent: SlaBreachEvent | null;
|
||||
}
|
||||
|
||||
let _socket: Socket | null = null;
|
||||
let _refCount = 0;
|
||||
|
||||
function getSocket(): Socket {
|
||||
if (!_socket) {
|
||||
_socket = io(window.location.origin, {
|
||||
path: "/socket.io",
|
||||
transports: ["websocket", "polling"],
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
}
|
||||
return _socket;
|
||||
}
|
||||
|
||||
export function useXosSocket() {
|
||||
const [state, setState] = useState<XosSocketState>({
|
||||
connected: false,
|
||||
supervisorStats: null,
|
||||
lastSlaBreachEvent: null,
|
||||
});
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const handleStats = useCallback((data: SupervisorStats) => {
|
||||
setState((s) => ({ ...s, supervisorStats: data }));
|
||||
}, []);
|
||||
|
||||
const handleSlaBreach = useCallback((data: SlaBreachEvent) => {
|
||||
setState((s) => ({ ...s, lastSlaBreachEvent: data }));
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
setState((s) => ({ ...s, connected: true }));
|
||||
}, []);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
setState((s) => ({ ...s, connected: false }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
socketRef.current = socket;
|
||||
_refCount++;
|
||||
|
||||
socket.on("connect", handleConnect);
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
socket.on("xos:supervisor.stats", handleStats);
|
||||
socket.on("xos:sla.breach", handleSlaBreach);
|
||||
|
||||
// Sync initial connection state
|
||||
if (socket.connected) {
|
||||
setState((s) => ({ ...s, connected: true }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off("connect", handleConnect);
|
||||
socket.off("disconnect", handleDisconnect);
|
||||
socket.off("xos:supervisor.stats", handleStats);
|
||||
socket.off("xos:sla.breach", handleSlaBreach);
|
||||
|
||||
_refCount--;
|
||||
// Keep socket alive as long as any component uses it
|
||||
if (_refCount <= 0 && _socket) {
|
||||
_socket.disconnect();
|
||||
_socket = null;
|
||||
_refCount = 0;
|
||||
}
|
||||
};
|
||||
}, [handleConnect, handleDisconnect, handleStats, handleSlaBreach]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ export function ProtectedRoute({
|
|||
component: Component,
|
||||
}: {
|
||||
path: string;
|
||||
component: () => React.JSX.Element;
|
||||
component: React.ComponentType<any>;
|
||||
}) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -49,6 +49,7 @@ import {
|
|||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -81,6 +82,73 @@ import {
|
|||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
// ── Componente da aba Insights (Apache Superset) ──────────────────────────────
|
||||
function SupersetAdvancedTab() {
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<string>("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; title: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/superset/dashboards", { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setDashboards(data.map((d: any) => ({ id: String(d.id), title: d.dashboard_title || d.title })));
|
||||
if (data.length > 0) setSelectedDashboard(String(data[0].id));
|
||||
}
|
||||
})
|
||||
.catch(() => setDashboards([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#1f334d]">Arcádia Insights — Apache Superset</h2>
|
||||
<p className="text-sm text-gray-500">SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{dashboards.length > 0 && (
|
||||
<select
|
||||
value={selectedDashboard}
|
||||
onChange={e => setSelectedDashboard(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-[#1f334d] focus:outline-none focus:ring-2 focus:ring-[#c89b3c]"
|
||||
>
|
||||
{dashboards.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<a
|
||||
href="/superset"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" /> Abrir completo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDashboard ? (
|
||||
<SupersetDashboard
|
||||
dashboardId={selectedDashboard}
|
||||
height="calc(100vh - 320px)"
|
||||
showOpenLink={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: "calc(100vh - 320px)" }}>
|
||||
<iframe
|
||||
src="/superset"
|
||||
className="w-full h-full border-0"
|
||||
title="Arcádia Insights — Apache Superset"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BiStats {
|
||||
dataSources: number;
|
||||
datasets: number;
|
||||
|
|
@ -2890,7 +2958,7 @@ export default function BiWorkspace() {
|
|||
<Layers className="w-4 h-4 mr-2" /> Staging
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||||
<Settings className="w-4 h-4 mr-2" /> MetaSet
|
||||
<Settings className="w-4 h-4 mr-2" /> Insights
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -2916,32 +2984,7 @@ export default function BiWorkspace() {
|
|||
<StagingTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#1f334d]">MetaSet - Motor de BI</h2>
|
||||
<p className="text-sm text-gray-500">Acesso completo para criação manual de consultas SQL, gráficos e dashboards</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/bi/metaset/autologin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||||
data-testid="link-open-metaset-external"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" /> Abrir em Nova Aba
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: 'calc(100vh - 320px)' }}>
|
||||
<iframe
|
||||
src="/api/bi/metaset/autologin"
|
||||
className="w-full h-full border-0"
|
||||
title="MetaSet - Arcádia Insights"
|
||||
data-testid="iframe-metaset-advanced"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SupersetAdvancedTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
// SOE — Sistema de Operações Empresariais (alias para o módulo Plus/ERP)
|
||||
export { default } from "./Plus";
|
||||
|
|
@ -2,10 +2,11 @@ import { useState } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
||||
import {
|
||||
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
||||
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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
|
@ -97,6 +98,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: "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: "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) => {
|
||||
|
|
@ -264,7 +268,7 @@ export default function XosCentral() {
|
|||
{/* Modules Grid */}
|
||||
<div>
|
||||
<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) => (
|
||||
<Link key={mod.id} href={mod.href}>
|
||||
<Card className="hover:shadow-lg transition-all cursor-pointer group" data-testid={`card-module-${mod.id}`}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
Hash, Search, Filter, ArrowLeft, CheckCircle2, Clock, AlertTriangle,
|
||||
Plus, Star, Shield, Copy, ChevronRight, User, Inbox
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface Protocol {
|
||||
id: number;
|
||||
protocol_number: string;
|
||||
status: "open" | "resolved" | "cancelled";
|
||||
subject: string | null;
|
||||
contact_name: string | null;
|
||||
queue_name: string | null;
|
||||
assigned_to: string | null;
|
||||
sla_deadline: string | null;
|
||||
sla_breach: boolean;
|
||||
satisfaction_score: number | null;
|
||||
opened_at: string;
|
||||
resolved_at: string | null;
|
||||
conversation_id: number | null;
|
||||
ticket_id: number | null;
|
||||
}
|
||||
|
||||
export default function XosProtocols() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<Protocol | null>(null);
|
||||
const [showNewDialog, setShowNewDialog] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ subject: "", contactId: "", queueId: "", slaMinutes: "" });
|
||||
const [csatDialog, setCsatDialog] = useState<{ open: boolean; protocol: Protocol | null }>({ open: false, protocol: null });
|
||||
const [csatScore, setCsatScore] = useState(5);
|
||||
const [csatComment, setCsatComment] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: protocols = [], isLoading } = useQuery<Protocol[]>({
|
||||
queryKey: ["/api/xos/protocols", search, statusFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
const res = await fetch(`/api/xos/protocols?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createProtocol = useMutation({
|
||||
mutationFn: async (data: typeof newForm) => {
|
||||
const res = await fetch("/api/xos/protocols", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subject: data.subject || null,
|
||||
contactId: data.contactId ? parseInt(data.contactId) : null,
|
||||
queueId: data.queueId ? parseInt(data.queueId) : null,
|
||||
slaMinutes: data.slaMinutes ? parseInt(data.slaMinutes) : null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha ao criar protocolo");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (prot) => {
|
||||
toast({ title: "Protocolo criado", description: `Número: ${prot.protocol_number}` });
|
||||
setShowNewDialog(false);
|
||||
setNewForm({ subject: "", contactId: "", queueId: "", slaMinutes: "" });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||
},
|
||||
onError: () => toast({ title: "Erro ao criar protocolo", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateProtocol = useMutation({
|
||||
mutationFn: async ({ id, status }: { id: number; status: string }) => {
|
||||
const res = await fetch(`/api/xos/protocols/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Protocolo atualizado" });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||
setSelectedProtocol(null);
|
||||
},
|
||||
});
|
||||
|
||||
const submitCsat = useMutation({
|
||||
mutationFn: async ({ protocol, score, comment }: { protocol: Protocol; score: number; comment: string }) => {
|
||||
const endpoint = protocol.conversation_id
|
||||
? `/api/xos/conversations/${protocol.conversation_id}/csat`
|
||||
: protocol.ticket_id
|
||||
? `/api/xos/tickets/${protocol.ticket_id}/csat`
|
||||
: null;
|
||||
if (!endpoint) throw new Error("Sem conversa ou ticket vinculado");
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ score, comment }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha ao registrar CSAT");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Avaliação registrada!", description: "CSAT salvo com sucesso." });
|
||||
setCsatDialog({ open: false, protocol: null });
|
||||
setCsatScore(5);
|
||||
setCsatComment("");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||
},
|
||||
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const getStatusConfig = (status: string) => ({
|
||||
open: { label: "Aberto", color: "bg-blue-100 text-blue-700", icon: Clock },
|
||||
resolved: { label: "Resolvido", color: "bg-emerald-100 text-emerald-700", icon: CheckCircle2 },
|
||||
cancelled: { label: "Cancelado", color: "bg-slate-100 text-slate-600", icon: AlertTriangle },
|
||||
}[status] || { label: status, color: "bg-slate-100 text-slate-600", icon: Clock });
|
||||
|
||||
const isSlaBreached = (p: Protocol) => {
|
||||
if (p.sla_breach) return true;
|
||||
if (p.status !== "open" || !p.sla_deadline) return false;
|
||||
return new Date(p.sla_deadline) < new Date();
|
||||
};
|
||||
|
||||
const getSlaTimeLeft = (deadline: string) => {
|
||||
const diff = new Date(deadline).getTime() - Date.now();
|
||||
if (diff < 0) return null;
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
};
|
||||
|
||||
const copyProtocol = (number: string) => {
|
||||
navigator.clipboard.writeText(number);
|
||||
toast({ title: "Copiado!", description: number });
|
||||
};
|
||||
|
||||
const open = protocols.filter(p => p.status === "open").length;
|
||||
const resolved = protocols.filter(p => p.status === "resolved").length;
|
||||
const breached = protocols.filter(p => isSlaBreached(p)).length;
|
||||
|
||||
return (
|
||||
<BrowserFrame>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/xos">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="bg-gradient-to-br from-teal-600 to-cyan-700 p-2 rounded-xl">
|
||||
<Hash className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">Protocolos</h1>
|
||||
<p className="text-xs text-slate-500">Rastreamento de atendimentos</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Protocolo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<Clock className="h-8 w-8 text-blue-200" />
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Abertos</p>
|
||||
<p className="text-2xl font-bold">{open}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<CheckCircle2 className="h-8 w-8 text-emerald-200" />
|
||||
<div>
|
||||
<p className="text-emerald-100 text-sm">Resolvidos</p>
|
||||
<p className="text-2xl font-bold">{resolved}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`text-white ${breached > 0 ? "bg-gradient-to-br from-red-500 to-red-600" : "bg-gradient-to-br from-slate-500 to-slate-600"}`}>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 opacity-70" />
|
||||
<div>
|
||||
<p className="opacity-80 text-sm">SLA Violado</p>
|
||||
<p className="text-2xl font-bold">{breached}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Buscar por número ou assunto..."
|
||||
className="pl-10"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="open">Abertos</SelectItem>
|
||||
<SelectItem value="resolved">Resolvidos</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelados</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : protocols.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Inbox className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="text-slate-500">Nenhum protocolo encontrado</p>
|
||||
<Button className="mt-4" onClick={() => setShowNewDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Criar Protocolo
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{protocols.map(protocol => {
|
||||
const statusCfg = getStatusConfig(protocol.status);
|
||||
const StatusIcon = statusCfg.icon;
|
||||
const breached = isSlaBreached(protocol);
|
||||
const slaLeft = protocol.sla_deadline && protocol.status === "open" ? getSlaTimeLeft(protocol.sla_deadline) : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={protocol.id}
|
||||
className={`hover:shadow-md transition-all cursor-pointer ${breached ? "border-red-300 bg-red-50" : ""}`}
|
||||
onClick={() => setSelectedProtocol(protocol)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<StatusIcon className={`h-5 w-5 ${protocol.status === "resolved" ? "text-emerald-500" : protocol.status === "cancelled" ? "text-slate-400" : "text-blue-500"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
className="font-mono font-semibold text-slate-800 hover:text-blue-600 flex items-center gap-1"
|
||||
onClick={e => { e.stopPropagation(); copyProtocol(protocol.protocol_number); }}
|
||||
>
|
||||
#{protocol.protocol_number}
|
||||
<Copy className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
<Badge className={statusCfg.color}>{statusCfg.label}</Badge>
|
||||
{breached && <Badge className="bg-red-100 text-red-700">SLA Violado</Badge>}
|
||||
{protocol.satisfaction_score && (
|
||||
<Badge className="bg-yellow-100 text-yellow-700">
|
||||
<Star className="h-3 w-3 mr-1 fill-current" />
|
||||
{protocol.satisfaction_score}/5
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1 truncate">{protocol.subject || "Sem assunto"}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
{protocol.contact_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" /> {protocol.contact_name}
|
||||
</span>
|
||||
)}
|
||||
{protocol.queue_name && (
|
||||
<span>{protocol.queue_name}</span>
|
||||
)}
|
||||
<span>{new Date(protocol.opened_at).toLocaleDateString("pt-BR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{slaLeft && (
|
||||
<p className="text-xs text-emerald-600 font-medium flex items-center gap-1 justify-end">
|
||||
<Shield className="h-3 w-3" />
|
||||
{slaLeft} restante
|
||||
</p>
|
||||
)}
|
||||
{breached && protocol.status === "open" && (
|
||||
<p className="text-xs text-red-600 font-medium">SLA vencido</p>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-slate-400 mt-1 ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Protocol detail dialog */}
|
||||
{selectedProtocol && (
|
||||
<Dialog open onOpenChange={() => setSelectedProtocol(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Hash className="h-5 w-5 text-teal-600" />
|
||||
Protocolo #{selectedProtocol.protocol_number}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Status</p>
|
||||
<Badge className={getStatusConfig(selectedProtocol.status).color}>
|
||||
{getStatusConfig(selectedProtocol.status).label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Abertura</p>
|
||||
<p className="text-sm font-medium">{new Date(selectedProtocol.opened_at).toLocaleString("pt-BR")}</p>
|
||||
</div>
|
||||
{selectedProtocol.contact_name && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Contato</p>
|
||||
<p className="text-sm font-medium">{selectedProtocol.contact_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedProtocol.queue_name && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Fila</p>
|
||||
<p className="text-sm font-medium">{selectedProtocol.queue_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedProtocol.sla_deadline && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">SLA Deadline</p>
|
||||
<p className={`text-sm font-medium ${isSlaBreached(selectedProtocol) ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{new Date(selectedProtocol.sla_deadline).toLocaleString("pt-BR")}
|
||||
{isSlaBreached(selectedProtocol) && " (violado)"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedProtocol.satisfaction_score && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">CSAT</p>
|
||||
<div className="flex items-center gap-1 text-yellow-600">
|
||||
{"★".repeat(selectedProtocol.satisfaction_score)}
|
||||
<span className="text-sm ml-1">{selectedProtocol.satisfaction_score}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedProtocol.subject && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Assunto</p>
|
||||
<p className="text-sm bg-slate-50 p-2 rounded">{selectedProtocol.subject}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
{selectedProtocol.status === "open" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCsatDialog({ open: true, protocol: selectedProtocol })}
|
||||
>
|
||||
<Star className="h-4 w-4 mr-2" /> Registrar CSAT
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateProtocol.mutate({ id: selectedProtocol.id, status: "resolved" })}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" /> Resolver
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedProtocol(null)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* New Protocol Dialog */}
|
||||
<Dialog open={showNewDialog} onOpenChange={setShowNewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo Protocolo</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">Assunto</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder="Descreva o motivo do atendimento"
|
||||
value={newForm.subject}
|
||||
onChange={e => setNewForm(f => ({ ...f, subject: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="contactId">ID do Contato</Label>
|
||||
<Input
|
||||
id="contactId"
|
||||
type="number"
|
||||
placeholder="Ex: 42"
|
||||
value={newForm.contactId}
|
||||
onChange={e => setNewForm(f => ({ ...f, contactId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slaMinutes">SLA (minutos)</Label>
|
||||
<Input
|
||||
id="slaMinutes"
|
||||
type="number"
|
||||
placeholder="Ex: 480"
|
||||
value={newForm.slaMinutes}
|
||||
onChange={e => setNewForm(f => ({ ...f, slaMinutes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowNewDialog(false)}>Cancelar</Button>
|
||||
<Button onClick={() => createProtocol.mutate(newForm)} disabled={createProtocol.isPending}>
|
||||
{createProtocol.isPending ? "Criando..." : "Criar Protocolo"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* CSAT Dialog */}
|
||||
<Dialog open={csatDialog.open} onOpenChange={o => !o && setCsatDialog({ open: false, protocol: null })}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registrar Avaliação (CSAT)</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Nota (1 a 5)</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setCsatScore(n)}
|
||||
className={`text-2xl transition-transform hover:scale-110 ${n <= csatScore ? "text-yellow-400" : "text-slate-300"}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{["", "Muito insatisfeito", "Insatisfeito", "Neutro", "Satisfeito", "Muito satisfeito"][csatScore]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="csatComment">Comentário (opcional)</Label>
|
||||
<Textarea
|
||||
id="csatComment"
|
||||
placeholder="O que poderia melhorar?"
|
||||
value={csatComment}
|
||||
onChange={e => setCsatComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setCsatDialog({ open: false, protocol: null })}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={() => csatDialog.protocol && submitCsat.mutate({ protocol: csatDialog.protocol, score: csatScore, comment: csatComment })}
|
||||
disabled={submitCsat.isPending}
|
||||
>
|
||||
{submitCsat.isPending ? "Salvando..." : "Salvar Avaliação"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
BarChart3, Star, Shield, Users, MessageSquare, Ticket, TrendingUp,
|
||||
ArrowLeft, DollarSign, CheckCircle2, AlertTriangle, Clock, Download
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface OverviewReport {
|
||||
period: string;
|
||||
conversations: { total: number; resolved: number; open: number; avg_csat: number; avg_handle_minutes: number; channels_used: number };
|
||||
tickets: { total: number; resolved: number; open: number; sla_breaches: number; avg_csat: number };
|
||||
contacts: { total: number; leads: number; customers: number; new_in_period: number };
|
||||
deals: { total: number; won: number; lost: number; won_value: number; win_rate_pct: number };
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
interface AgentReport {
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
conversations_handled: number;
|
||||
resolved: number;
|
||||
avg_csat: number;
|
||||
avg_handle_minutes: number;
|
||||
tickets_handled: number;
|
||||
tickets_resolved: number;
|
||||
}
|
||||
|
||||
interface SlaReport {
|
||||
period: string;
|
||||
protocols: { total: number; breached: number; compliant: number; compliance_rate_pct: number };
|
||||
tickets_by_priority: Array<{ priority: string; total: number; breached: number; avg_resolution_minutes: number }>;
|
||||
}
|
||||
|
||||
interface CsatSummary {
|
||||
period: string;
|
||||
total_responses: number;
|
||||
avg_score: number;
|
||||
satisfaction_rate_pct: number;
|
||||
distribution: { 5: number; 4: number; 3: number; "1-2": number };
|
||||
}
|
||||
|
||||
export default function XosReports() {
|
||||
const [period, setPeriod] = useState("30d");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
const { data: overview, isLoading } = useQuery<OverviewReport>({
|
||||
queryKey: ["/api/xos/reports/overview", period],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/xos/reports/overview?period=${period}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: agentReport = [] } = useQuery<AgentReport[]>({
|
||||
queryKey: ["/api/xos/reports/agents", period],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/xos/reports/agents?period=${period}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: slaReport } = useQuery<SlaReport>({
|
||||
queryKey: ["/api/xos/reports/sla", period],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/xos/reports/sla?period=${period}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: csatSummary } = useQuery<CsatSummary>({
|
||||
queryKey: ["/api/xos/csat/summary", period],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/xos/csat/summary?period=${period}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const formatCurrency = (v: number) =>
|
||||
new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(v || 0);
|
||||
|
||||
const periodLabel: Record<string, string> = { "7d": "7 dias", "30d": "30 dias", "90d": "90 dias", "1y": "1 ano" };
|
||||
|
||||
const getPriorityColor = (p: string) => ({
|
||||
low: "text-slate-500", normal: "text-blue-600", high: "text-orange-600", urgent: "text-red-600"
|
||||
}[p] || "text-slate-600");
|
||||
|
||||
const getPriorityBadge = (p: string) => ({
|
||||
low: "bg-slate-100 text-slate-700", normal: "bg-blue-100 text-blue-700",
|
||||
high: "bg-orange-100 text-orange-700", urgent: "bg-red-100 text-red-700"
|
||||
}[p] || "bg-gray-100 text-gray-700");
|
||||
|
||||
const csatBar = (label: string, count: number, total: number, color: string) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-600 w-8">{label}</span>
|
||||
<Progress value={total > 0 ? (count / total) * 100 : 0} className={`flex-1 h-3 ${color}`} />
|
||||
<span className="text-sm font-medium text-slate-700 w-8 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<BrowserFrame>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const conv = overview?.conversations;
|
||||
const tick = overview?.tickets;
|
||||
const contacts = overview?.contacts;
|
||||
const deals = overview?.deals;
|
||||
|
||||
return (
|
||||
<BrowserFrame>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/xos">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="bg-gradient-to-br from-emerald-600 to-teal-700 p-2 rounded-xl">
|
||||
<BarChart3 className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">Relatórios XOS</h1>
|
||||
<p className="text-xs text-slate-500">Análise de atendimento e CRM</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Últimos 7 dias</SelectItem>
|
||||
<SelectItem value="30d">Últimos 30 dias</SelectItem>
|
||||
<SelectItem value="90d">Últimos 90 dias</SelectItem>
|
||||
<SelectItem value="1y">Último ano</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||
<TabsTrigger value="csat">CSAT</TabsTrigger>
|
||||
<TabsTrigger value="sla">SLA</TabsTrigger>
|
||||
<TabsTrigger value="agents">Agentes</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<p className="text-sm text-slate-500">Período: {periodLabel[period]}</p>
|
||||
|
||||
{/* Top KPI row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Conversas</p>
|
||||
<p className="text-3xl font-bold">{conv?.total || 0}</p>
|
||||
</div>
|
||||
<MessageSquare className="h-10 w-10 text-blue-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-blue-100">
|
||||
{conv?.resolved || 0} resolvidas ({conv?.total ? Math.round((conv.resolved / conv.total) * 100) : 0}%)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-orange-500 to-orange-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-orange-100 text-sm">Tickets</p>
|
||||
<p className="text-3xl font-bold">{tick?.total || 0}</p>
|
||||
</div>
|
||||
<Ticket className="h-10 w-10 text-orange-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-orange-100">
|
||||
{tick?.sla_breaches || 0} SLA violados
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-emerald-100 text-sm">Receita Ganha</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(deals?.won_value || 0)}</p>
|
||||
</div>
|
||||
<DollarSign className="h-10 w-10 text-emerald-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-emerald-100">
|
||||
{deals?.won || 0} deals • {deals?.win_rate_pct || 0}% taxa
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-violet-500 to-violet-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-violet-100 text-sm">Novos Contatos</p>
|
||||
<p className="text-3xl font-bold">{contacts?.new_in_period || 0}</p>
|
||||
</div>
|
||||
<Users className="h-10 w-10 text-violet-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-violet-100">
|
||||
{contacts?.leads || 0} leads • {contacts?.customers || 0} clientes
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detail cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||
Atendimento
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-slate-800">{conv?.avg_handle_minutes || 0}<span className="text-sm font-normal text-slate-500">min</span></p>
|
||||
<p className="text-xs text-slate-500 mt-1">Tempo Médio de Atendimento</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-slate-800">{conv?.channels_used || 0}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Canais Utilizados</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{conv?.resolved || 0}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Resolvidas</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-blue-700">{conv?.open || 0}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Em Aberto</p>
|
||||
</div>
|
||||
</div>
|
||||
{conv?.avg_csat ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-lg">
|
||||
<Star className="h-5 w-5 text-yellow-500 fill-yellow-500" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">{Number(conv.avg_csat).toFixed(1)} / 5</p>
|
||||
<p className="text-xs text-slate-500">CSAT Médio das Conversas</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
Pipeline de Vendas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-emerald-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{deals?.won || 0}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Ganhos</p>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-red-700">{deals?.lost || 0}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Perdidos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Taxa de conversão</span>
|
||||
<span className="font-semibold">{deals?.win_rate_pct || 0}%</span>
|
||||
</div>
|
||||
<Progress value={deals?.win_rate_pct || 0} className="h-3" />
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-xs text-slate-500">Valor Total Ganho</p>
|
||||
<p className="text-xl font-bold text-emerald-600">{formatCurrency(deals?.won_value || 0)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* CSAT */}
|
||||
<TabsContent value="csat" className="space-y-6">
|
||||
{csatSummary ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-gradient-to-br from-yellow-400 to-orange-500 text-white">
|
||||
<CardContent className="p-5 text-center">
|
||||
<Star className="h-10 w-10 mx-auto mb-2 fill-white" />
|
||||
<p className="text-4xl font-bold">{Number(csatSummary.avg_score).toFixed(1)}</p>
|
||||
<p className="text-yellow-100 text-sm mt-1">Nota Média / 5</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5 text-center">
|
||||
<CheckCircle2 className="h-10 w-10 mx-auto mb-2 text-emerald-500" />
|
||||
<p className="text-4xl font-bold text-emerald-600">{csatSummary.satisfaction_rate_pct}%</p>
|
||||
<p className="text-slate-500 text-sm mt-1">Taxa de Satisfação (4-5 ★)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5 text-center">
|
||||
<Users className="h-10 w-10 mx-auto mb-2 text-blue-500" />
|
||||
<p className="text-4xl font-bold text-blue-600">{csatSummary.total_responses}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">Total de Avaliações</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Distribuição de Notas</CardTitle>
|
||||
<CardDescription>Período: {periodLabel[period]}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{csatBar("⭐⭐⭐⭐⭐", csatSummary.distribution[5], csatSummary.total_responses, "[&>div]:bg-emerald-500")}
|
||||
{csatBar("⭐⭐⭐⭐", csatSummary.distribution[4], csatSummary.total_responses, "[&>div]:bg-green-400")}
|
||||
{csatBar("⭐⭐⭐", csatSummary.distribution[3], csatSummary.total_responses, "[&>div]:bg-yellow-400")}
|
||||
{csatBar("⭐⭐", csatSummary.distribution["1-2"], csatSummary.total_responses, "[&>div]:bg-red-400")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Star className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="text-slate-500">Nenhuma avaliação no período</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* SLA */}
|
||||
<TabsContent value="sla" className="space-y-6">
|
||||
{slaReport && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className={`${slaReport.protocols.compliance_rate_pct >= 90 ? "bg-gradient-to-br from-emerald-500 to-emerald-600" : slaReport.protocols.compliance_rate_pct >= 70 ? "bg-gradient-to-br from-yellow-500 to-yellow-600" : "bg-gradient-to-br from-red-500 to-red-600"} text-white`}>
|
||||
<CardContent className="p-5 text-center">
|
||||
<Shield className="h-10 w-10 mx-auto mb-2 opacity-80" />
|
||||
<p className="text-4xl font-bold">{slaReport.protocols.compliance_rate_pct}%</p>
|
||||
<p className="text-sm opacity-80 mt-1">Compliance de SLA</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5 text-center">
|
||||
<CheckCircle2 className="h-10 w-10 mx-auto mb-2 text-emerald-500" />
|
||||
<p className="text-4xl font-bold text-emerald-600">{slaReport.protocols.compliant}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">Protocolos dentro do SLA</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5 text-center">
|
||||
<AlertTriangle className="h-10 w-10 mx-auto mb-2 text-red-500" />
|
||||
<p className="text-4xl font-bold text-red-600">{slaReport.protocols.breached}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">Violações de SLA</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tickets por Prioridade</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{slaReport.tickets_by_priority.length === 0 ? (
|
||||
<p className="text-center text-slate-500 py-8">Nenhum ticket no período</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-sm font-medium text-slate-600">Prioridade</th>
|
||||
<th className="text-center p-3 text-sm font-medium text-slate-600">Total</th>
|
||||
<th className="text-center p-3 text-sm font-medium text-slate-600">Violações</th>
|
||||
<th className="text-center p-3 text-sm font-medium text-slate-600">Tempo Médio Res.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{slaReport.tickets_by_priority.map(row => (
|
||||
<tr key={row.priority} className="hover:bg-slate-50">
|
||||
<td className="p-3">
|
||||
<Badge className={getPriorityBadge(row.priority)}>{row.priority}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center font-medium">{row.total}</td>
|
||||
<td className="p-3 text-center">
|
||||
<span className={row.breached > 0 ? "text-red-600 font-semibold" : "text-emerald-600"}>
|
||||
{row.breached}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-center text-slate-600">
|
||||
{row.avg_resolution_minutes ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{Number(row.avg_resolution_minutes).toFixed(0)}min
|
||||
</div>
|
||||
) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Agents */}
|
||||
<TabsContent value="agents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Performance por Agente</CardTitle>
|
||||
<CardDescription>Período: {periodLabel[period]}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left p-4 text-sm font-medium text-slate-600">Agente</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">Conversas</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">Resolvidas</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">CSAT</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">TMA</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">Tickets</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{agentReport.map(agent => {
|
||||
const resolutionRate = agent.conversations_handled > 0
|
||||
? Math.round((agent.resolved / agent.conversations_handled) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={agent.agent_id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4 font-medium text-slate-800">{agent.agent_name}</td>
|
||||
<td className="p-4 text-center">{agent.conversations_handled}</td>
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-medium">{agent.resolved}</span>
|
||||
<span className="text-xs text-slate-500">{resolutionRate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
{agent.avg_csat ? (
|
||||
<div className="flex items-center justify-center gap-1 text-yellow-600 font-semibold">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
{Number(agent.avg_csat).toFixed(1)}
|
||||
</div>
|
||||
) : <span className="text-slate-400">—</span>}
|
||||
</td>
|
||||
<td className="p-4 text-center text-slate-600">
|
||||
{agent.avg_handle_minutes ? `${Number(agent.avg_handle_minutes).toFixed(0)}min` : "—"}
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className="text-sm">{agent.tickets_handled} / {agent.tickets_resolved}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{agentReport.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-12 text-slate-500">
|
||||
Nenhum dado de agente no período
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useXosSocket } from "@/hooks/use-xos-socket";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
Users, MessageSquare, Ticket, Clock, AlertTriangle, CheckCircle2,
|
||||
TrendingUp, ChevronRight, RefreshCw, Activity, UserCheck, PhoneOff,
|
||||
BarChart3, Inbox, Star, ArrowLeft, Circle
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface SupervisorOverview {
|
||||
conversations: {
|
||||
open: number;
|
||||
pending: number;
|
||||
resolved_today: number;
|
||||
unassigned: number;
|
||||
avg_handle_time_minutes: number;
|
||||
};
|
||||
tickets: {
|
||||
open: number;
|
||||
resolved_today: number;
|
||||
urgent_open: number;
|
||||
sla_breached: number;
|
||||
};
|
||||
agents_active: Array<{ agent_id: string; active_conversations: number }>;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
interface QueueStat {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
active_conversations: number;
|
||||
waiting: number;
|
||||
total_agents: number;
|
||||
avg_first_response_minutes: number;
|
||||
}
|
||||
|
||||
interface AgentStat {
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
avatar: string | null;
|
||||
active_conversations: number;
|
||||
resolved_today: number;
|
||||
avg_csat: number;
|
||||
avg_handle_time_minutes: number;
|
||||
queues: string[];
|
||||
}
|
||||
|
||||
interface LiveConversation {
|
||||
id: number;
|
||||
contact_name: string;
|
||||
contact_phone: string;
|
||||
queue_name: string;
|
||||
queue_color: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
assigned_to: string | null;
|
||||
age_minutes: number;
|
||||
last_message: string;
|
||||
}
|
||||
|
||||
export default function XosSupervisor() {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [queueFilter, setQueueFilter] = useState("all");
|
||||
const [autoRefresh] = useState(true);
|
||||
const { connected: socketConnected, supervisorStats: liveStats } = useXosSocket();
|
||||
|
||||
const refetchInterval = autoRefresh ? 30000 : false;
|
||||
|
||||
const { data: overview, isLoading: loadingOverview, refetch: refetchOverview } = useQuery<SupervisorOverview>({
|
||||
queryKey: ["/api/xos/supervisor/overview"],
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
const { data: queues = [] } = useQuery<QueueStat[]>({
|
||||
queryKey: ["/api/xos/supervisor/queues"],
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
const { data: agents = [] } = useQuery<AgentStat[]>({
|
||||
queryKey: ["/api/xos/supervisor/agents", queueFilter],
|
||||
queryFn: async () => {
|
||||
const url = queueFilter === "all"
|
||||
? "/api/xos/supervisor/agents"
|
||||
: `/api/xos/supervisor/agents?queueId=${queueFilter}`;
|
||||
const res = await fetch(url);
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
const { data: liveConvs = [] } = useQuery<LiveConversation[]>({
|
||||
queryKey: ["/api/xos/supervisor/conversations/live", queueFilter],
|
||||
queryFn: async () => {
|
||||
const url = queueFilter === "all"
|
||||
? "/api/xos/supervisor/conversations/live"
|
||||
: `/api/xos/supervisor/conversations/live?queueId=${queueFilter}`;
|
||||
const res = await fetch(url);
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
const getQueueColor = (color: string) => {
|
||||
const map: Record<string, string> = {
|
||||
blue: "bg-blue-500", green: "bg-green-500", red: "bg-red-500",
|
||||
yellow: "bg-yellow-500", purple: "bg-purple-500", orange: "bg-orange-500",
|
||||
cyan: "bg-cyan-500", pink: "bg-pink-500",
|
||||
};
|
||||
return map[color] || "bg-slate-500";
|
||||
};
|
||||
|
||||
const getAgentCsatColor = (score: number) => {
|
||||
if (!score) return "text-slate-400";
|
||||
if (score >= 4.5) return "text-emerald-600";
|
||||
if (score >= 3.5) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getAgeColor = (minutes: number) => {
|
||||
if (minutes < 10) return "text-emerald-600";
|
||||
if (minutes < 30) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
if (loadingOverview) {
|
||||
return (
|
||||
<BrowserFrame>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
<p className="mt-4 text-slate-500">Carregando dados do supervisor...</p>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer real-time socket stats over polling data when available
|
||||
const conv = overview?.conversations;
|
||||
const tick = overview?.tickets;
|
||||
const totalActive = liveStats?.openConversations ?? ((conv?.open || 0) + (conv?.pending || 0));
|
||||
const resolvedToday = liveStats?.resolvedToday ?? conv?.resolved_today ?? 0;
|
||||
const urgentTickets = liveStats?.urgentTickets ?? tick?.urgent_open ?? 0;
|
||||
const agentsOnline = liveStats?.agentsOnline ?? overview?.agents_active?.length ?? 0;
|
||||
|
||||
return (
|
||||
<BrowserFrame>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/xos">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="bg-gradient-to-br from-indigo-600 to-purple-700 p-2 rounded-xl">
|
||||
<Activity className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"}
|
||||
{socketConnected
|
||||
? <span className="ml-2 text-emerald-600">● tempo real</span>
|
||||
: autoRefresh && <span className="ml-2 text-yellow-600">○ polling 30s</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={queueFilter} onValueChange={setQueueFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Todas as filas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as filas</SelectItem>
|
||||
{queues.map(q => (
|
||||
<SelectItem key={q.id} value={String(q.id)}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={() => refetchOverview()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Em Atendimento</p>
|
||||
<p className="text-3xl font-bold">{totalActive}</p>
|
||||
</div>
|
||||
<MessageSquare className="h-10 w-10 text-blue-200" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||||
<span>{conv?.unassigned || 0} sem agente</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{conv?.pending || 0} pendentes</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-emerald-100 text-sm">Resolvidos Hoje</p>
|
||||
<p className="text-3xl font-bold">{resolvedToday}</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-emerald-100">
|
||||
TMA: {conv?.avg_handle_time_minutes || 0}min
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`text-white ${(tick?.sla_breached || 0) > 0 ? "bg-gradient-to-br from-red-500 to-red-600" : "bg-gradient-to-br from-orange-500 to-orange-600"}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-orange-100 text-sm">Tickets Urgentes</p>
|
||||
<p className="text-3xl font-bold">{urgentTickets}</p>
|
||||
</div>
|
||||
<Ticket className="h-10 w-10 text-orange-200" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-orange-100">
|
||||
{(tick?.sla_breached || 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-200">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{tick?.sla_breached} SLA violado
|
||||
</span>
|
||||
)}
|
||||
{(tick?.sla_breached || 0) === 0 && <span>SLA OK</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-violet-500 to-violet-600 text-white">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-violet-100 text-sm">Agentes Ativos</p>
|
||||
<p className="text-3xl font-bold">{agentsOnline}</p>
|
||||
</div>
|
||||
<Users className="h-10 w-10 text-violet-200" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-violet-100">
|
||||
{agents.length} no total
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Filas</TabsTrigger>
|
||||
<TabsTrigger value="agents">Agentes</TabsTrigger>
|
||||
<TabsTrigger value="live">Ao Vivo ({liveConvs.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Queues Tab */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{queues.map(queue => {
|
||||
const occupancy = queue.total_agents > 0
|
||||
? Math.min(100, Math.round((queue.active_conversations / queue.total_agents) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<Card key={queue.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-3 h-3 rounded-full ${getQueueColor(queue.color)}`} />
|
||||
<h3 className="font-semibold text-slate-800">{queue.name}</h3>
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{queue.total_agents} agentes
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center mb-3">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-blue-600">{queue.active_conversations}</p>
|
||||
<p className="text-xs text-slate-500">ativos</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${queue.waiting > 0 ? "text-orange-600" : "text-slate-400"}`}>
|
||||
{queue.waiting}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">aguardando</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-600">
|
||||
{queue.avg_first_response_minutes ? `${queue.avg_first_response_minutes}m` : "—"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">TMP</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Ocupação</span>
|
||||
<span>{occupancy}%</span>
|
||||
</div>
|
||||
<Progress value={occupancy} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{queues.length === 0 && (
|
||||
<div className="col-span-3 text-center py-12 text-slate-500">
|
||||
<Inbox className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>Nenhuma fila configurada</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Agents Tab */}
|
||||
<TabsContent value="agents">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left p-4 text-sm font-medium text-slate-600">Agente</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">Em Atend.</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">Resolvidos Hoje</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">CSAT Médio</th>
|
||||
<th className="text-center p-4 text-sm font-medium text-slate-600">TMA (min)</th>
|
||||
<th className="text-left p-4 text-sm font-medium text-slate-600">Filas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{agents.map(agent => (
|
||||
<tr key={agent.agent_id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-indigo-500 text-white text-sm">
|
||||
{(agent.agent_name || "?").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{agent.agent_name}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Circle className={`h-2 w-2 fill-current ${agent.active_conversations > 0 ? "text-emerald-500" : "text-slate-300"}`} />
|
||||
<span className="text-xs text-slate-500">
|
||||
{agent.active_conversations > 0 ? "online" : "disponível"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className={`text-xl font-bold ${agent.active_conversations > 5 ? "text-red-600" : agent.active_conversations > 2 ? "text-yellow-600" : "text-slate-700"}`}>
|
||||
{agent.active_conversations}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className="text-lg font-semibold text-emerald-600">{agent.resolved_today || 0}</span>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<div className={`flex items-center justify-center gap-1 font-semibold ${getAgentCsatColor(agent.avg_csat)}`}>
|
||||
{agent.avg_csat ? (
|
||||
<>
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
{Number(agent.avg_csat).toFixed(1)}
|
||||
</>
|
||||
) : "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center text-slate-600">
|
||||
{agent.avg_handle_time_minutes ? `${agent.avg_handle_time_minutes}m` : "—"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(agent.queues || []).filter(Boolean).map((q, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{q}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-12 text-slate-500">
|
||||
<UserCheck className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||
Nenhum agente encontrado
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Live Conversations Tab */}
|
||||
<TabsContent value="live">
|
||||
<div className="space-y-2">
|
||||
{liveConvs.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="text-slate-500">Nenhuma conversa em aberto</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{liveConvs.map(conv => (
|
||||
<Card key={conv.id} className={`hover:shadow-md transition-shadow ${Number(conv.age_minutes) > 30 ? "border-red-200 bg-red-50" : ""}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
<AvatarFallback className="bg-gradient-to-br from-slate-400 to-slate-600 text-white">
|
||||
{(conv.contact_name || "?").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-slate-800">{conv.contact_name || "Desconhecido"}</p>
|
||||
<Badge variant="outline" className="text-xs">{conv.channel}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 truncate">{conv.last_message || "—"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
{conv.queue_name && (
|
||||
<Badge variant="secondary" className="text-xs">{conv.queue_name}</Badge>
|
||||
)}
|
||||
{conv.assigned_to ? (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
<span className="hidden sm:block">{conv.assigned_to}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-orange-500">
|
||||
<PhoneOff className="h-3 w-3" />
|
||||
<span>Sem agente</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 text-sm font-medium ${getAgeColor(Number(conv.age_minutes))}`}>
|
||||
<Clock className="h-4 w-4" />
|
||||
{Number(conv.age_minutes).toFixed(0)}m
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -60,9 +60,23 @@ services:
|
|||
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}
|
||||
# ── Arcádia Plus (Laravel) ─────────────────────────────────────────────
|
||||
PLUS_HOST: plus
|
||||
PLUS_PORT: "8080"
|
||||
PLUS_AUTO_START: "false" # container separado em prod
|
||||
SSO_PLUS_BASE_URL: http://plus:8080
|
||||
# ── Apache Superset ───────────────────────────────────────────────────
|
||||
SUPERSET_HOST: superset
|
||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
|
||||
# ── ERPNext ───────────────────────────────────────────────────────────
|
||||
ERPNEXT_PROXY_ENABLED: ${ERPNEXT_PROXY_ENABLED:-false}
|
||||
ERPNEXT_CONTAINER_HOST: erpnext
|
||||
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
||||
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
||||
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
|
|
@ -79,6 +93,136 @@ services:
|
|||
- "traefik.http.routers.arcadia.tls=true"
|
||||
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
|
||||
|
||||
# ── Arcádia Plus (Laravel + MySQL) ──────────────────────────────────────────
|
||||
# Perfil `plus` — suba com: docker compose --profile plus up
|
||||
plus-db:
|
||||
image: mysql:8.0
|
||||
restart: always
|
||||
profiles: [plus]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${PLUS_DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
|
||||
MYSQL_USER: ${PLUS_DB_USER:-plus}
|
||||
MYSQL_PASSWORD: ${PLUS_DB_PASSWORD}
|
||||
volumes:
|
||||
- plus_db:/var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${PLUS_DB_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- arcadia-internal
|
||||
|
||||
plus:
|
||||
image: ${PLUS_IMAGE:-php:8.3-apache}
|
||||
restart: always
|
||||
profiles: [plus]
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_KEY: ${PLUS_APP_KEY}
|
||||
APP_URL: https://${DOMAIN}/plus
|
||||
DB_HOST: plus-db
|
||||
DB_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
|
||||
DB_USERNAME: ${PLUS_DB_USER:-plus}
|
||||
DB_PASSWORD: ${PLUS_DB_PASSWORD}
|
||||
SESSION_DRIVER: redis
|
||||
REDIS_HOST: redis
|
||||
ARCADIA_URL: https://${DOMAIN}
|
||||
ARCADIA_SSO_SECRET: ${SSO_SECRET}
|
||||
volumes:
|
||||
- plus_storage:/var/www/html/storage
|
||||
depends_on:
|
||||
plus-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- arcadia-internal
|
||||
|
||||
# ── Apache Superset (BI) ─────────────────────────────────────────────────────
|
||||
# Perfil `bi` — suba com: docker compose --profile bi up
|
||||
superset:
|
||||
image: apache/superset:4.1.0
|
||||
restart: always
|
||||
profiles: [bi]
|
||||
environment:
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
|
||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/arcadia_superset
|
||||
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
|
||||
SUPERSET_WEBSERVER_PORT: "8088"
|
||||
PYTHONPATH: /app/pythonpath
|
||||
volumes:
|
||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||
- superset_home:/app/superset_home
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- arcadia-internal
|
||||
|
||||
# ── ERPNext (Frappe Framework) ───────────────────────────────────────────────
|
||||
# Perfil `erpnext` — suba com: docker compose --profile erpnext up
|
||||
erpnext-db:
|
||||
image: mariadb:10.6
|
||||
restart: always
|
||||
profiles: [erpnext]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: _frappe
|
||||
MYSQL_USER: frappe
|
||||
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD}
|
||||
volumes:
|
||||
- erpnext_db:/var/lib/mysql
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--skip-character-set-client-handshake
|
||||
--skip-innodb-read-only-compressed
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${ERPNEXT_DB_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- arcadia-internal
|
||||
|
||||
erpnext:
|
||||
image: frappe/erpnext:version-15
|
||||
restart: always
|
||||
profiles: [erpnext]
|
||||
environment:
|
||||
FRAPPE_SITE_NAME_HEADER: erpnext.local
|
||||
ERPNEXT_DB_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
|
||||
ERPNEXT_ADMIN_PASSWORD: ${ERPNEXT_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- erpnext_sites:/home/frappe/frappe-bench/sites
|
||||
- erpnext_logs:/home/frappe/frappe-bench/logs
|
||||
- ./docker/erpnext/init.sh:/usr/local/bin/init-erpnext.sh:ro
|
||||
depends_on:
|
||||
erpnext-db:
|
||||
condition: service_healthy
|
||||
command: ["/bin/bash", "/usr/local/bin/init-erpnext.sh"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/method/frappe.ping || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 120s
|
||||
networks:
|
||||
- arcadia-internal
|
||||
|
||||
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||
contabil:
|
||||
build:
|
||||
|
|
@ -235,3 +379,9 @@ volumes:
|
|||
redis_data:
|
||||
ollama_models:
|
||||
open_webui_data:
|
||||
plus_db:
|
||||
plus_storage:
|
||||
superset_home:
|
||||
erpnext_db:
|
||||
erpnext_sites:
|
||||
erpnext_logs:
|
||||
|
|
|
|||
|
|
@ -167,12 +167,32 @@ services:
|
|||
restart: unless-stopped
|
||||
environment:
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia_superset
|
||||
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia_superset
|
||||
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD:-arcadia123}@db:5432/arcadia
|
||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
|
||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-arcadia2026}
|
||||
SUPERSET_WEBSERVER_PORT: 8088
|
||||
PYTHONPATH: /app/pythonpath
|
||||
FLASK_ENV: production
|
||||
ports:
|
||||
- "8088:8088"
|
||||
volumes:
|
||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||
- superset_home:/app/superset_home
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: ["/bin/bash", "/app/docker/init.sh"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- arcadia
|
||||
profiles: [bi]
|
||||
|
||||
# ─────────────── PERFIL: ai (Soberania de IA) ────────────────────────────────
|
||||
|
|
@ -227,7 +247,68 @@ services:
|
|||
- "4000:4000"
|
||||
profiles: [ai]
|
||||
|
||||
# ─────────────── PERFIL: erpnext (ERPNext local) ──────────────────────────────
|
||||
|
||||
# ── MariaDB para ERPNext ─────────────────────────────────────────────────────
|
||||
erpnext-db:
|
||||
image: mariadb:10.6
|
||||
restart: unless-stopped
|
||||
profiles: [erpnext]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}
|
||||
MYSQL_DATABASE: _frappe
|
||||
MYSQL_USER: frappe
|
||||
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD:-frappe2026}
|
||||
volumes:
|
||||
- erpnext_db:/var/lib/mysql
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--skip-character-set-client-handshake
|
||||
--skip-innodb-read-only-compressed
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# ── ERPNext (Frappe Framework + ERPNext app) ─────────────────────────────────
|
||||
erpnext:
|
||||
image: frappe/erpnext:version-15
|
||||
restart: unless-stopped
|
||||
profiles: [erpnext]
|
||||
environment:
|
||||
FRAPPE_SITE_NAME_HEADER: erpnext.local
|
||||
BACKEND_PORT: 8000
|
||||
volumes:
|
||||
- erpnext_sites:/home/frappe/frappe-bench/sites
|
||||
- erpnext_logs:/home/frappe/frappe-bench/logs
|
||||
- ./docker/erpnext/init.sh:/usr/local/bin/init-erpnext.sh:ro
|
||||
depends_on:
|
||||
erpnext-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8090:8080"
|
||||
command: ["/bin/bash", "/usr/local/bin/init-erpnext.sh"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/method/frappe.ping || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 120s
|
||||
networks:
|
||||
- arcadia
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
open_webui_data:
|
||||
superset_home:
|
||||
erpnext_db:
|
||||
erpnext_sites:
|
||||
erpnext_logs:
|
||||
|
||||
networks:
|
||||
arcadia:
|
||||
driver: bridge
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
#!/bin/bash
|
||||
# ─── ERPNext init — cria site e instala app se necessário ─────────────────────
|
||||
# Executado como entrypoint do container frappe/erpnext:version-15
|
||||
|
||||
set -e
|
||||
|
||||
SITE_NAME="${FRAPPE_SITE_NAME:-erpnext.local}"
|
||||
DB_HOST="${DB_HOST:-erpnext-db}"
|
||||
DB_ROOT_PASSWORD="${ERPNEXT_DB_ROOT_PASSWORD:-erpnext-root-2026}"
|
||||
ADMIN_PASSWORD="${ERPNEXT_ADMIN_PASSWORD:-admin2026}"
|
||||
BENCH_DIR="/home/frappe/frappe-bench"
|
||||
|
||||
echo "[ERPNext Init] Aguardando MariaDB em ${DB_HOST}..."
|
||||
until mysqladmin ping -h "${DB_HOST}" -u root --password="${DB_ROOT_PASSWORD}" --silent 2>/dev/null; do
|
||||
echo "[ERPNext Init] MariaDB ainda não disponível — aguardando 3s..."
|
||||
sleep 3
|
||||
done
|
||||
echo "[ERPNext Init] MariaDB disponível."
|
||||
|
||||
cd "${BENCH_DIR}"
|
||||
|
||||
SITES_DIR="${BENCH_DIR}/sites"
|
||||
SITE_DIR="${SITES_DIR}/${SITE_NAME}"
|
||||
|
||||
if [ ! -f "${SITE_DIR}/site_config.json" ]; then
|
||||
echo "[ERPNext Init] Criando site ${SITE_NAME}..."
|
||||
|
||||
bench new-site "${SITE_NAME}" \
|
||||
--mariadb-root-password "${DB_ROOT_PASSWORD}" \
|
||||
--admin-password "${ADMIN_PASSWORD}" \
|
||||
--db-host "${DB_HOST}" \
|
||||
--no-mariadb-socket \
|
||||
--verbose || true
|
||||
|
||||
echo "[ERPNext Init] Instalando app erpnext..."
|
||||
bench --site "${SITE_NAME}" install-app erpnext || true
|
||||
|
||||
echo "[ERPNext Init] Ativando modo developer..."
|
||||
bench --site "${SITE_NAME}" set-config developer_mode 1 || true
|
||||
|
||||
echo "[ERPNext Init] Desativando SSL para env local..."
|
||||
bench --site "${SITE_NAME}" set-config ssl_certificate "" || true
|
||||
|
||||
echo "[ERPNext Init] Site criado com sucesso."
|
||||
else
|
||||
echo "[ERPNext Init] Site ${SITE_NAME} já existe — pulando criação."
|
||||
fi
|
||||
|
||||
# Configura common_site_config.json para servir na porta 8080
|
||||
if [ ! -f "${SITES_DIR}/common_site_config.json" ]; then
|
||||
echo '{"serve_default_site": true, "socketio_port": 9000, "background_workers": 1}' \
|
||||
> "${SITES_DIR}/common_site_config.json"
|
||||
fi
|
||||
|
||||
echo "[ERPNext Init] Iniciando servidor Frappe na porta 8080..."
|
||||
exec bench serve --port 8080 --site "${SITE_NAME}"
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
#!/bin/bash
|
||||
# Arcádia Suite — Apache Superset Init Script
|
||||
# Executado ao iniciar o container
|
||||
set -e
|
||||
|
||||
SUPERSET_ADMIN_USER="${SUPERSET_ADMIN_USER:-admin}"
|
||||
SUPERSET_ADMIN_EMAIL="${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}"
|
||||
SUPERSET_ADMIN_PASSWORD="${SUPERSET_ADMIN_PASSWORD:-arcadia2026}"
|
||||
ARCADIA_DB_URL="${ARCADIA_DATABASE_URL:-postgresql://arcadia:arcadia123@db:5432/arcadia}"
|
||||
|
||||
echo "[Superset Init] Aguardando PostgreSQL..."
|
||||
until python -c "
|
||||
import psycopg2, os, sys
|
||||
try:
|
||||
url = os.environ.get('DATABASE_URL', 'postgresql://arcadia:arcadia123@db:5432/arcadia_superset')
|
||||
psycopg2.connect(url)
|
||||
sys.exit(0)
|
||||
except: sys.exit(1)
|
||||
" 2>/dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
echo "[Superset Init] PostgreSQL disponível!"
|
||||
|
||||
echo "[Superset Init] Rodando migrações do banco..."
|
||||
superset db upgrade
|
||||
|
||||
echo "[Superset Init] Criando admin..."
|
||||
superset fab create-admin \
|
||||
--username "${SUPERSET_ADMIN_USER}" \
|
||||
--firstname "Arcádia" \
|
||||
--lastname "Admin" \
|
||||
--email "${SUPERSET_ADMIN_EMAIL}" \
|
||||
--password "${SUPERSET_ADMIN_PASSWORD}" 2>/dev/null || echo "[Superset Init] Admin já existe."
|
||||
|
||||
echo "[Superset Init] Inicializando roles e permissões..."
|
||||
superset init
|
||||
|
||||
echo "[Superset Init] Registrando banco Arcádia Suite..."
|
||||
python - <<PYEOF
|
||||
import sys
|
||||
try:
|
||||
from superset import create_app
|
||||
from superset.extensions import db as sdb
|
||||
from superset.models.core import Database as SupersetDB
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
existing = sdb.session.query(SupersetDB).filter_by(database_name="Arcádia Suite").first()
|
||||
if not existing:
|
||||
new_db = SupersetDB(
|
||||
database_name="Arcádia Suite",
|
||||
sqlalchemy_uri="${ARCADIA_DB_URL}",
|
||||
expose_in_sqllab=True,
|
||||
allow_run_async=True,
|
||||
allow_csv_upload=False,
|
||||
allow_ctas=False,
|
||||
allow_cvas=False,
|
||||
allow_dml=False,
|
||||
extra='{"metadata_params":{},"engine_params":{},"metadata_cache_timeout":{},"schemas_allowed_for_file_upload":[]}',
|
||||
)
|
||||
sdb.session.add(new_db)
|
||||
sdb.session.commit()
|
||||
print("[Superset Init] Banco 'Arcádia Suite' registrado com sucesso!")
|
||||
else:
|
||||
print("[Superset Init] Banco 'Arcádia Suite' já está registrado.")
|
||||
except Exception as e:
|
||||
print(f"[Superset Init] Aviso ao registrar banco: {e}")
|
||||
sys.exit(0)
|
||||
PYEOF
|
||||
|
||||
echo "[Superset Init] Iniciando servidor Superset..."
|
||||
exec gunicorn \
|
||||
--bind "0.0.0.0:${SUPERSET_WEBSERVER_PORT:-8088}" \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
--workers "${SUPERSET_WORKERS:-4}" \
|
||||
--worker-class gthread \
|
||||
--threads 2 \
|
||||
--timeout 120 \
|
||||
--limit-request-line 0 \
|
||||
--limit-request-field_size 0 \
|
||||
"superset.app:create_app()"
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"""
|
||||
Arcádia Suite — Apache Superset Configuration
|
||||
Habilita embedding nativo via Guest Token JWT
|
||||
"""
|
||||
import os
|
||||
|
||||
# ── Segurança ─────────────────────────────────────────────────────────────────
|
||||
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY", "change-in-production-use-openssl-rand-hex-32")
|
||||
|
||||
# ── Banco de metadados do Superset ────────────────────────────────────────────
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://arcadia:arcadia123@db:5432/arcadia_superset"
|
||||
)
|
||||
|
||||
# ── CORS — permite o gateway Arcádia (:5000) chamar a API ────────────────────
|
||||
ENABLE_CORS = True
|
||||
CORS_OPTIONS = {
|
||||
"supports_credentials": True,
|
||||
"allow_headers": ["*"],
|
||||
"resources": {r"/api/*": {"origins": "*"}},
|
||||
}
|
||||
|
||||
# ── Feature Flags ─────────────────────────────────────────────────────────────
|
||||
FEATURE_FLAGS = {
|
||||
"EMBEDDED_SUPERSET": True, # Embedding via Guest Token
|
||||
"ENABLE_TEMPLATE_PROCESSING": True, # Jinja templates em queries
|
||||
"ALERT_REPORTS": True, # Alertas automáticos
|
||||
"DRILL_TO_DETAIL": True, # Drill-down em charts
|
||||
"DRILL_BY": True, # Drill-by em charts
|
||||
"DASHBOARD_NATIVE_FILTERS": True, # Filtros nativos em dashboards
|
||||
"DASHBOARD_CROSS_FILTERS": True, # Cross-filter entre charts
|
||||
"ENABLE_JAVASCRIPT_CONTROLS": False, # Desabilitado por segurança
|
||||
}
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
CACHE_CONFIG = {
|
||||
"CACHE_TYPE": "SimpleCache",
|
||||
"CACHE_DEFAULT_TIMEOUT": 300, # 5 minutos
|
||||
}
|
||||
|
||||
# ── Timeout de queries ────────────────────────────────────────────────────────
|
||||
SUPERSET_WEBSERVER_TIMEOUT = 300
|
||||
SQLLAB_TIMEOUT = 300
|
||||
SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
|
||||
|
||||
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
||||
APP_NAME = "Arcádia Insights"
|
||||
LOGO_TARGET_PATH = "/"
|
||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||
|
||||
# ── Segurança de sessão ───────────────────────────────────────────────────────
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = False # True em prod com HTTPS
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log"]
|
||||
|
||||
# ── Guest Token (para embedding) ──────────────────────────────────────────────
|
||||
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutos — frontend renova automaticamente
|
||||
GUEST_ROLE_NAME = "Public"
|
||||
GUEST_TOKEN_JWT_ALGO = "HS256"
|
||||
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@superset-ui/embedded-sdk": "^0.3.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@tiptap/extension-image": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { db } from "../../db/index";
|
|||
import { customers, suppliers, products, salesOrders, purchaseOrders, erpSegments, erpConfig, insertErpSegmentSchema, insertErpConfigSchema, persons, personRoles, mobileDevices, posSales, posSaleItems, finAccountsReceivable } from "@shared/schema";
|
||||
import { eq, and, sql, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { soeRuleEngine } from "../soe/rule-engine/index";
|
||||
import { soeEventBus } from "../soe/event-bus";
|
||||
|
||||
export function registerErpRoutes(app: Express): void {
|
||||
app.get("/api/erp/connections", async (req: Request, res: Response) => {
|
||||
|
|
@ -850,12 +852,24 @@ export function registerErpRoutes(app: Express): void {
|
|||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
const tenantId = req.user?.tenantId || 1;
|
||||
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = req.body;
|
||||
const user = req.user as any;
|
||||
|
||||
// SOE Rule Engine — aplica regras fiscais e de negócio antes de criar o pedido
|
||||
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||
"pre_sales_order",
|
||||
{ ...req.body, _tipo: "venda" },
|
||||
{ tenantId, empresaId: req.body.empresaId, userId: user?.id, motor: "local" }
|
||||
);
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(422).json({ error: "Validação falhou", details: validationErrors });
|
||||
}
|
||||
|
||||
const { orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes } = enriched;
|
||||
const [order] = await db.insert(salesOrders).values({
|
||||
tenantId,
|
||||
orderNumber, customerId, orderDate, deliveryDate, status, subtotal, discount, tax, total, paymentMethod, notes
|
||||
}).returning();
|
||||
res.status(201).json(order);
|
||||
res.status(201).json({ ...order, _rulesApplied: enriched._rulesApplied });
|
||||
} catch (error) {
|
||||
console.error("Error creating sales order:", error);
|
||||
res.status(500).json({ error: "Failed to create sales order" });
|
||||
|
|
@ -876,7 +890,16 @@ export function registerErpRoutes(app: Express): void {
|
|||
if (!updated) {
|
||||
return res.status(404).json({ error: "Sales order not found" });
|
||||
}
|
||||
res.json({ success: true, order: updated, message: "Pedido confirmado. Lançamentos contábeis gerados." });
|
||||
|
||||
// SOE Event Bus — dispara automações pós-confirmação
|
||||
soeEventBus.emit("venda_confirmada", {
|
||||
tenantId,
|
||||
empresaId: (req.user as any)?.empresaId,
|
||||
motorOrigem: "local",
|
||||
dados: { id, status: "confirmed", ...updated },
|
||||
}).catch((e) => console.error("[SOE] venda_confirmada error:", e.message));
|
||||
|
||||
res.json({ success: true, order: updated, message: "Pedido confirmado. Automações SOE disparadas." });
|
||||
} catch (error) {
|
||||
console.error("Error confirming sales order:", error);
|
||||
res.status(500).json({ error: "Failed to confirm sales order" });
|
||||
|
|
@ -890,6 +913,18 @@ export function registerErpRoutes(app: Express): void {
|
|||
}
|
||||
const id = parseInt(req.params.id);
|
||||
const tenantId = req.user?.tenantId || 1;
|
||||
const user = req.user as any;
|
||||
|
||||
// SOE Rule Engine — aplica regras fiscais antes de gerar a NF-e
|
||||
const { payload: enriched, validationErrors } = await soeRuleEngine.apply(
|
||||
"pre_nfe_emissao",
|
||||
{ ...req.body, tipoDocumento: "nfe", _tipo: "venda" },
|
||||
{ tenantId, empresaId: user?.empresaId, userId: user?.id, motor: "local" }
|
||||
);
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(422).json({ error: "Validação fiscal falhou", details: validationErrors });
|
||||
}
|
||||
|
||||
const [updated] = await db.update(salesOrders)
|
||||
.set({ status: "invoiced" })
|
||||
.where(and(eq(salesOrders.id, id), eq(salesOrders.tenantId, tenantId)))
|
||||
|
|
@ -897,7 +932,26 @@ export function registerErpRoutes(app: Express): void {
|
|||
if (!updated) {
|
||||
return res.status(404).json({ error: "Sales order not found" });
|
||||
}
|
||||
res.json({ success: true, order: updated, message: "NF-e gerada com sucesso." });
|
||||
|
||||
// SOE Event Bus — dispara lançamentos contábeis automáticos
|
||||
soeEventBus.emit("nfe_emitida", {
|
||||
tenantId,
|
||||
empresaId: user?.empresaId,
|
||||
motorOrigem: "local",
|
||||
dados: {
|
||||
...enriched,
|
||||
...updated,
|
||||
valorTotal: updated.total,
|
||||
dataEmissao: new Date().toISOString(),
|
||||
},
|
||||
}).catch((e) => console.error("[SOE] nfe_emitida error:", e.message));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
order: updated,
|
||||
tributacao: enriched.tributacao,
|
||||
message: "NF-e gerada. Lançamentos contábeis sendo processados.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating NF-e:", error);
|
||||
res.status(500).json({ error: "Failed to generate NF-e" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
// ERPNext Proxy — Transparente, igual ao Plus proxy
|
||||
// /erpnext-app/* → http://erpnext:8080/*
|
||||
|
||||
import { Express, Response } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
const ERPNEXT_HOST = process.env.ERPNEXT_CONTAINER_HOST || "erpnext";
|
||||
const ERPNEXT_CONTAINER_PORT = parseInt(process.env.ERPNEXT_CONTAINER_PORT || "8080", 10);
|
||||
const ERPNEXT_TIMEOUT = parseInt(process.env.ERPNEXT_TIMEOUT || "60000", 10);
|
||||
|
||||
export function setupErpNextProxy(app: Express): void {
|
||||
// Só ativa se o container estiver configurado (DOCKER_MODE ou flag explícita)
|
||||
const dockerMode = process.env.DOCKER_MODE === "true";
|
||||
const forceProxy = process.env.ERPNEXT_PROXY_ENABLED === "true";
|
||||
|
||||
if (!dockerMode && !forceProxy) {
|
||||
console.log("[ERPNext Proxy] Modo container desativado — proxy não configurado.");
|
||||
console.log("[ERPNext Proxy] Use ERPNEXT_URL para apontar para instância externa.");
|
||||
return;
|
||||
}
|
||||
|
||||
const erpnextTarget = `http://${ERPNEXT_HOST}:${ERPNEXT_CONTAINER_PORT}`;
|
||||
|
||||
const erpnextProxy = createProxyMiddleware({
|
||||
target: erpnextTarget,
|
||||
changeOrigin: true,
|
||||
timeout: ERPNEXT_TIMEOUT,
|
||||
proxyTimeout: ERPNEXT_TIMEOUT,
|
||||
pathRewrite: {
|
||||
"^/erpnext-app": "",
|
||||
},
|
||||
on: {
|
||||
error: (err, req, res) => {
|
||||
console.error("[ERPNext Proxy] Error:", err.message);
|
||||
if (res && typeof (res as Response).status === "function") {
|
||||
(res as Response).status(502).json({
|
||||
error: "ERPNext não disponível",
|
||||
message: `Frappe não está rodando em ${erpnextTarget}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
proxyReq: (proxyReq, req) => {
|
||||
console.log(`[ERPNext Proxy] ${req.method} ${req.url} -> ${erpnextTarget}`);
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
const host = (typeof forwardedHost === "string" ? forwardedHost : req.headers.host) || "localhost";
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
const proto = typeof forwardedProto === "string" ? forwardedProto : "https";
|
||||
proxyReq.setHeader("X-Forwarded-Host", host);
|
||||
proxyReq.setHeader("X-Forwarded-Proto", proto);
|
||||
proxyReq.setHeader("X-Forwarded-Prefix", "/erpnext-app");
|
||||
// Frappe usa este header para resolver URLs absolutas
|
||||
proxyReq.setHeader("X-Site-Name", "erpnext.local");
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
const location = proxyRes.headers["location"];
|
||||
if (location && typeof location === "string") {
|
||||
let newLocation = location;
|
||||
// Rewrite URLs absolutas do container → /erpnext-app
|
||||
newLocation = newLocation.replace(
|
||||
new RegExp(`http://${ERPNEXT_HOST}:${ERPNEXT_CONTAINER_PORT}`, "g"),
|
||||
"/erpnext-app"
|
||||
);
|
||||
newLocation = newLocation.replace(/http:\/\/erpnext:8080/g, "/erpnext-app");
|
||||
// Se é path relativo sem prefixo, adiciona /erpnext-app
|
||||
if (newLocation.startsWith("/") && !newLocation.startsWith("/erpnext-app")) {
|
||||
newLocation = "/erpnext-app" + newLocation;
|
||||
}
|
||||
proxyRes.headers["location"] = newLocation;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.use("/erpnext-app", erpnextProxy);
|
||||
|
||||
// Status check do container ERPNext
|
||||
app.get("/api/erpnext/container-status", async (_req, res) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`${erpnextTarget}/api/method/frappe.ping`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as any;
|
||||
res.json({
|
||||
status: "online",
|
||||
host: ERPNEXT_HOST,
|
||||
port: ERPNEXT_CONTAINER_PORT,
|
||||
message: data.message,
|
||||
mode: "container",
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
status: "error",
|
||||
host: ERPNEXT_HOST,
|
||||
port: ERPNEXT_CONTAINER_PORT,
|
||||
httpStatus: response.status,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.json({
|
||||
status: "offline",
|
||||
host: ERPNEXT_HOST,
|
||||
port: ERPNEXT_CONTAINER_PORT,
|
||||
error: err.name === "AbortError" ? "timeout" : err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[ERPNext Proxy] Configurado: /erpnext-app -> ${erpnextTarget}`);
|
||||
}
|
||||
|
|
@ -335,6 +335,13 @@ class ManusService extends EventEmitter {
|
|||
return this.toolRetailStats(input.period, input.storeId);
|
||||
case "retail_report":
|
||||
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":
|
||||
let finishOutput = input.answer || "";
|
||||
if (input.chart) {
|
||||
|
|
@ -3871,6 +3878,253 @@ class ManusService extends EventEmitter {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -733,6 +733,33 @@ export const MANUS_TOOLS: ManusToolDef[] = [
|
|||
dateTo: { type: "string", description: "Data final (YYYY-MM-DD)", 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 }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,34 @@ class WorkflowStepType(str, Enum):
|
|||
HTTP_REQUEST = "http"
|
||||
TRANSFORM = "transform"
|
||||
NOTIFY = "notify"
|
||||
SUB_WORKFLOW = "sub_workflow"
|
||||
SPLIT_BATCH = "split_batch"
|
||||
MERGE = "merge"
|
||||
ERROR_HANDLER = "error_handler"
|
||||
RETRY = "retry"
|
||||
SEND_EMAIL = "send_email"
|
||||
SEND_WHATSAPP = "send_whatsapp"
|
||||
UPDATE_RECORD = "update_record"
|
||||
CREATE_RECORD = "create_record"
|
||||
MANUS_TASK = "manus_task"
|
||||
|
||||
|
||||
class CrmEventType(str, Enum):
|
||||
CONTACT_CREATED = "crm.contact.created"
|
||||
CONTACT_UPDATED = "crm.contact.updated"
|
||||
DEAL_CREATED = "crm.deal.created"
|
||||
DEAL_STAGE_CHANGED = "crm.deal.stage_changed"
|
||||
DEAL_WON = "crm.deal.won"
|
||||
DEAL_LOST = "crm.deal.lost"
|
||||
TICKET_CREATED = "crm.ticket.created"
|
||||
TICKET_RESOLVED = "crm.ticket.resolved"
|
||||
FORM_SUBMITTED = "crm.form.submitted"
|
||||
MESSAGE_RECEIVED = "crm.message.received"
|
||||
CONVERSATION_CLOSED = "crm.conversation.closed"
|
||||
CAMPAIGN_SENT = "crm.campaign.sent"
|
||||
CSAT_RECEIVED = "crm.csat.received"
|
||||
SLA_BREACHED = "crm.sla.breached"
|
||||
PROTOCOL_CREATED = "crm.protocol.created"
|
||||
|
||||
|
||||
class CronExpression:
|
||||
|
|
@ -254,6 +282,9 @@ class WorkflowStep(BaseModel):
|
|||
config: Dict = {}
|
||||
on_success: Optional[str] = None
|
||||
on_failure: Optional[str] = None
|
||||
retry_count: int = 0
|
||||
retry_delay_seconds: int = 5
|
||||
error_branch: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowDefinition(BaseModel):
|
||||
|
|
@ -262,6 +293,8 @@ class WorkflowDefinition(BaseModel):
|
|||
steps: List[WorkflowStep]
|
||||
trigger: Optional[str] = None
|
||||
variables: Optional[Dict] = None
|
||||
error_handler: Optional[str] = None
|
||||
max_execution_time: int = 300
|
||||
|
||||
|
||||
class WorkflowExecution(BaseModel):
|
||||
|
|
@ -270,6 +303,12 @@ class WorkflowExecution(BaseModel):
|
|||
variables: Optional[Dict] = None
|
||||
|
||||
|
||||
class XosAutomationTrigger(BaseModel):
|
||||
event_type: str
|
||||
tenant_id: Optional[int] = None
|
||||
payload: Dict = {}
|
||||
|
||||
|
||||
class WorkflowExecutor:
|
||||
def __init__(self):
|
||||
self._workflows: Dict[str, WorkflowDefinition] = {}
|
||||
|
|
@ -308,21 +347,41 @@ class WorkflowExecutor:
|
|||
"variables": {**(workflow.variables or {}), **(variables or {}), **(trigger_data or {})},
|
||||
}
|
||||
|
||||
# Build step index for branching
|
||||
step_map = {s.id: s for s in workflow.steps}
|
||||
step_queue = list(workflow.steps)
|
||||
|
||||
try:
|
||||
for i, step in enumerate(workflow.steps):
|
||||
step_result = self._execute_step(step, execution["variables"])
|
||||
i = 0
|
||||
while i < len(step_queue):
|
||||
step = step_queue[i]
|
||||
step_result, step_status = self._execute_step_with_retry(step, execution["variables"])
|
||||
|
||||
execution["results"].append({
|
||||
"step_id": step.id,
|
||||
"type": step.type,
|
||||
"status": "completed",
|
||||
"status": step_status,
|
||||
"result": step_result,
|
||||
"executed_at": datetime.now().isoformat(),
|
||||
})
|
||||
execution["steps_completed"] = i + 1
|
||||
execution["steps_completed"] += 1
|
||||
|
||||
if isinstance(step_result, dict):
|
||||
execution["variables"].update(step_result.get("output", {}))
|
||||
|
||||
# Handle branching on condition results
|
||||
if step_status == "error" and step.error_branch and step.error_branch in step_map:
|
||||
# Jump to error branch
|
||||
step_queue = step_queue[:i+1] + [step_map[step.error_branch]] + step_queue[i+1:]
|
||||
elif step.type == WorkflowStepType.CONDITION:
|
||||
condition_result = step_result.get("result", False) if isinstance(step_result, dict) else False
|
||||
next_id = step.on_success if condition_result else step.on_failure
|
||||
if next_id and next_id in step_map:
|
||||
# Insert branch step next
|
||||
step_queue = step_queue[:i+1] + [step_map[next_id]] + step_queue[i+1:]
|
||||
|
||||
i += 1
|
||||
|
||||
execution["status"] = "completed"
|
||||
execution["completed_at"] = datetime.now().isoformat()
|
||||
except Exception as e:
|
||||
|
|
@ -336,25 +395,61 @@ class WorkflowExecutor:
|
|||
|
||||
return execution
|
||||
|
||||
def _execute_step_with_retry(self, step: WorkflowStep, variables: Dict):
|
||||
"""Execute step with retry logic. Returns (result, status)."""
|
||||
max_attempts = max(1, step.retry_count + 1)
|
||||
last_error = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
result = self._execute_step(step, variables)
|
||||
if isinstance(result, dict) and "error" in result and max_attempts > 1:
|
||||
last_error = result["error"]
|
||||
if attempt < max_attempts - 1:
|
||||
time.sleep(min(step.retry_delay_seconds * (attempt + 1), 60))
|
||||
continue
|
||||
return result, "completed"
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if attempt < max_attempts - 1:
|
||||
time.sleep(min(step.retry_delay_seconds * (attempt + 1), 60))
|
||||
return {"error": last_error, "attempts": max_attempts}, "error"
|
||||
|
||||
def _execute_step(self, step: WorkflowStep, variables: Dict) -> Any:
|
||||
if step.type == WorkflowStepType.CONDITION:
|
||||
stype = step.type
|
||||
if stype == WorkflowStepType.CONDITION:
|
||||
return self._exec_condition(step.config, variables)
|
||||
elif step.type == WorkflowStepType.ACTION:
|
||||
elif stype == WorkflowStepType.ACTION:
|
||||
return self._exec_action(step.config, variables)
|
||||
elif step.type == WorkflowStepType.DELAY:
|
||||
elif stype == WorkflowStepType.DELAY:
|
||||
delay_seconds = step.config.get("seconds", 1)
|
||||
time.sleep(min(delay_seconds, 30))
|
||||
time.sleep(min(delay_seconds, 300))
|
||||
return {"delayed": delay_seconds}
|
||||
elif step.type == WorkflowStepType.SQL_QUERY:
|
||||
elif stype == WorkflowStepType.SQL_QUERY:
|
||||
return self._exec_query(step.config, variables)
|
||||
elif step.type == WorkflowStepType.HTTP_REQUEST:
|
||||
elif stype == WorkflowStepType.HTTP_REQUEST:
|
||||
return self._exec_http(step.config, variables)
|
||||
elif step.type == WorkflowStepType.TRANSFORM:
|
||||
elif stype == WorkflowStepType.TRANSFORM:
|
||||
return self._exec_transform(step.config, variables)
|
||||
elif step.type == WorkflowStepType.NOTIFY:
|
||||
return {"notified": True, "message": step.config.get("message", ""), "channel": step.config.get("channel", "system")}
|
||||
elif stype == WorkflowStepType.NOTIFY:
|
||||
return self._exec_notify(step.config, variables)
|
||||
elif stype == WorkflowStepType.SUB_WORKFLOW:
|
||||
return self._exec_sub_workflow(step.config, variables)
|
||||
elif stype == WorkflowStepType.SPLIT_BATCH:
|
||||
return self._exec_split_batch(step.config, variables)
|
||||
elif stype == WorkflowStepType.SEND_EMAIL:
|
||||
return self._exec_send_email(step.config, variables)
|
||||
elif stype == WorkflowStepType.SEND_WHATSAPP:
|
||||
return self._exec_send_whatsapp(step.config, variables)
|
||||
elif stype == WorkflowStepType.UPDATE_RECORD:
|
||||
return self._exec_update_record(step.config, variables)
|
||||
elif stype == WorkflowStepType.CREATE_RECORD:
|
||||
return self._exec_create_record(step.config, variables)
|
||||
elif stype == WorkflowStepType.MANUS_TASK:
|
||||
return self._exec_manus_task(step.config, variables)
|
||||
elif stype == WorkflowStepType.LOOP:
|
||||
return self._exec_loop(step.config, variables)
|
||||
else:
|
||||
return {"type": step.type, "status": "unknown_step_type"}
|
||||
return {"type": stype, "status": "executed"}
|
||||
|
||||
def _exec_condition(self, config: Dict, variables: Dict) -> Dict:
|
||||
field = config.get("field", "")
|
||||
|
|
@ -443,8 +538,146 @@ class WorkflowExecutor:
|
|||
value = config.get("value")
|
||||
filtered = [item for item in data if isinstance(item, dict) and item.get(field) == value]
|
||||
return {"output": {"filtered": filtered}}
|
||||
elif operation == "map" and isinstance(data, list):
|
||||
field = config.get("field", "")
|
||||
mapped = [item.get(field) for item in data if isinstance(item, dict)]
|
||||
return {"output": {"mapped": mapped}}
|
||||
elif operation == "sort" and isinstance(data, list):
|
||||
field = config.get("field", "")
|
||||
reverse = config.get("reverse", False)
|
||||
sorted_data = sorted(data, key=lambda x: x.get(field, 0) if isinstance(x, dict) else 0, reverse=reverse)
|
||||
return {"output": {"sorted": sorted_data}}
|
||||
elif operation == "unique" and isinstance(data, list):
|
||||
field = config.get("field", "")
|
||||
seen = set()
|
||||
unique = []
|
||||
for item in data:
|
||||
key = item.get(field) if isinstance(item, dict) else item
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
return {"output": {"unique": unique}}
|
||||
return {"output": {}}
|
||||
|
||||
def _exec_notify(self, config: Dict, variables: Dict) -> Dict:
|
||||
message = self._interpolate(config.get("message", ""), variables)
|
||||
channel = config.get("channel", "system")
|
||||
# Emit as system event so Node.js can handle delivery
|
||||
event_bus.emit("system.notification", {
|
||||
"message": message,
|
||||
"channel": channel,
|
||||
"title": config.get("title", "Automação"),
|
||||
"level": config.get("level", "info"),
|
||||
})
|
||||
return {"notified": True, "message": message, "channel": channel}
|
||||
|
||||
def _exec_sub_workflow(self, config: Dict, variables: Dict) -> Dict:
|
||||
sub_id = config.get("workflow_id", "")
|
||||
if not sub_id or sub_id not in self._workflows:
|
||||
return {"error": f"Sub-workflow '{sub_id}' nao encontrado"}
|
||||
# Pass current variables merged with config overrides
|
||||
sub_vars = {**variables, **config.get("variables", {})}
|
||||
result = self.execute(sub_id, variables=sub_vars)
|
||||
return {"output": {"sub_workflow_result": result.get("status"), "sub_workflow_vars": result.get("variables", {})}}
|
||||
|
||||
def _exec_split_batch(self, config: Dict, variables: Dict) -> Dict:
|
||||
source = config.get("source", "")
|
||||
batch_size = config.get("batch_size", 10)
|
||||
data = variables.get(source, [])
|
||||
if not isinstance(data, list):
|
||||
return {"error": f"Source '{source}' nao e uma lista"}
|
||||
batches = [data[i:i+batch_size] for i in range(0, len(data), batch_size)]
|
||||
return {"output": {"batches": batches, "batch_count": len(batches), "total_items": len(data)}}
|
||||
|
||||
def _exec_loop(self, config: Dict, variables: Dict) -> Dict:
|
||||
source = config.get("source", "")
|
||||
max_iterations = min(config.get("max_iterations", 100), 1000)
|
||||
data = variables.get(source, [])
|
||||
if not isinstance(data, list):
|
||||
return {"error": f"Source '{source}' nao e uma lista"}
|
||||
results = []
|
||||
for i, item in enumerate(data[:max_iterations]):
|
||||
results.append({"index": i, "item": item})
|
||||
return {"output": {"loop_results": results, "iterations": len(results)}}
|
||||
|
||||
def _exec_send_email(self, config: Dict, variables: Dict) -> Dict:
|
||||
to = self._interpolate(config.get("to", ""), variables)
|
||||
subject = self._interpolate(config.get("subject", ""), variables)
|
||||
body = self._interpolate(config.get("body", ""), variables)
|
||||
# Emit event for Node.js email service to handle
|
||||
event_bus.emit("system.send_email", {"to": to, "subject": subject, "body": body})
|
||||
return {"output": {"email_queued": True, "to": to, "subject": subject}}
|
||||
|
||||
def _exec_send_whatsapp(self, config: Dict, variables: Dict) -> Dict:
|
||||
to = self._interpolate(config.get("to", ""), variables)
|
||||
message = self._interpolate(config.get("message", ""), variables)
|
||||
# Emit event for Node.js WhatsApp service to handle
|
||||
event_bus.emit("system.send_whatsapp", {"to": to, "message": message, "channel_id": config.get("channel_id")})
|
||||
return {"output": {"whatsapp_queued": True, "to": to}}
|
||||
|
||||
def _exec_update_record(self, config: Dict, variables: Dict) -> Dict:
|
||||
if not HAS_PSYCOPG2 or not DATABASE_URL:
|
||||
return {"error": "Database nao disponivel"}
|
||||
table = config.get("table", "")
|
||||
record_id = config.get("id") or variables.get("id")
|
||||
fields = config.get("fields", {})
|
||||
if not table or not record_id or not fields:
|
||||
return {"error": "table, id e fields sao obrigatorios"}
|
||||
# Resolve interpolated values
|
||||
resolved = {k: self._interpolate(str(v), variables) for k, v in fields.items()}
|
||||
set_clauses = ", ".join([f"{k} = %s" for k in resolved.keys()])
|
||||
values = list(resolved.values()) + [record_id]
|
||||
try:
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"UPDATE {table} SET {set_clauses}, updated_at = NOW() WHERE id = %s RETURNING id", values)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"output": {"updated": True, "table": table, "id": record_id}}
|
||||
except Exception as e:
|
||||
return {"error": f"Update falhou: {str(e)}"}
|
||||
|
||||
def _exec_create_record(self, config: Dict, variables: Dict) -> Dict:
|
||||
if not HAS_PSYCOPG2 or not DATABASE_URL:
|
||||
return {"error": "Database nao disponivel"}
|
||||
table = config.get("table", "")
|
||||
fields = config.get("fields", {})
|
||||
if not table or not fields:
|
||||
return {"error": "table e fields sao obrigatorios"}
|
||||
resolved = {k: self._interpolate(str(v), variables) for k, v in fields.items()}
|
||||
cols = ", ".join(resolved.keys())
|
||||
placeholders = ", ".join(["%s"] * len(resolved))
|
||||
values = list(resolved.values())
|
||||
try:
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders}) RETURNING id", values)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"output": {"created": True, "table": table, "new_id": new_id}}
|
||||
except Exception as e:
|
||||
return {"error": f"Insert falhou: {str(e)}"}
|
||||
|
||||
def _exec_manus_task(self, config: Dict, variables: Dict) -> Dict:
|
||||
prompt = self._interpolate(config.get("prompt", ""), variables)
|
||||
# Emit event — Node.js Manus service will handle execution
|
||||
event_bus.emit("system.manus_task", {
|
||||
"prompt": prompt,
|
||||
"user_id": config.get("user_id") or variables.get("user_id"),
|
||||
"automation_context": variables,
|
||||
})
|
||||
return {"output": {"manus_task_queued": True, "prompt": prompt[:200]}}
|
||||
|
||||
def _interpolate(self, template: str, variables: Dict) -> str:
|
||||
"""Replace {{variable}} placeholders with values from variables dict."""
|
||||
import re
|
||||
def replace(match):
|
||||
key = match.group(1).strip()
|
||||
val = variables.get(key, match.group(0))
|
||||
return str(val) if val is not None else ""
|
||||
return re.sub(r"\{\{(.+?)\}\}", replace, template)
|
||||
|
||||
def get_executions(self, workflow_id: str = None, limit: int = 50) -> List[Dict]:
|
||||
execs = self._executions
|
||||
if workflow_id:
|
||||
|
|
@ -497,8 +730,14 @@ async def health_check():
|
|||
async def version():
|
||||
return {
|
||||
"name": "Arcadia Automation Engine",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["scheduler", "event_bus", "workflow_executor", "cron", "http_actions", "sql_queries"],
|
||||
"version": "2.0.0",
|
||||
"capabilities": [
|
||||
"scheduler", "event_bus", "workflow_executor", "cron",
|
||||
"http_actions", "sql_queries", "sub_workflows", "loop",
|
||||
"split_batch", "retry", "error_branch", "send_email",
|
||||
"send_whatsapp", "update_record", "create_record",
|
||||
"manus_task", "crm_events", "variable_interpolation",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -584,7 +823,76 @@ async def event_stats():
|
|||
|
||||
@app.get("/events/types")
|
||||
async def event_types():
|
||||
return {"types": [e.value for e in EventType]}
|
||||
return {
|
||||
"types": [e.value for e in EventType],
|
||||
"crm_types": [e.value for e in CrmEventType],
|
||||
}
|
||||
|
||||
|
||||
# --- XOS CRM Automation trigger ---
|
||||
|
||||
@app.post("/xos/trigger")
|
||||
async def trigger_xos_automation(trigger: XosAutomationTrigger, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Receives CRM events (contact_created, deal_stage_changed, etc.)
|
||||
and emits them to the event bus so active xos_automations can react.
|
||||
Also calls the Node.js XOS webhook so database-persisted automations execute.
|
||||
"""
|
||||
full_payload = {"tenant_id": trigger.tenant_id, **trigger.payload}
|
||||
triggered = event_bus.emit(trigger.event_type, full_payload)
|
||||
|
||||
# Also emit to generic record.created for wildcard listeners
|
||||
if trigger.event_type.startswith("crm."):
|
||||
event_bus.emit(EventType.RECORD_CREATED, {
|
||||
"entity_type": trigger.event_type.replace("crm.", ""),
|
||||
**full_payload,
|
||||
})
|
||||
|
||||
# Forward to Node.js XOS automations engine (fire DB-persisted automations)
|
||||
background_tasks.add_task(_call_xos_webhook, trigger.event_type, full_payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"event_type": trigger.event_type,
|
||||
"triggered_handlers": triggered,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _call_xos_webhook(event_type: str, payload: Dict):
|
||||
"""Non-blocking call to Node.js to fire DB-persisted XOS automations."""
|
||||
import urllib.request
|
||||
node_port = os.environ.get("PORT", "5000")
|
||||
url = f"http://localhost:{node_port}/api/xos/automations/webhook/crm-event"
|
||||
try:
|
||||
body = json.dumps({"event_type": event_type, "payload": payload}).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-Internal-Call", "automation-engine")
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[XOS Webhook] Nao foi possivel notificar Node.js: {e}")
|
||||
|
||||
|
||||
@app.get("/xos/event-types")
|
||||
async def xos_event_types():
|
||||
return {"crm_event_types": [e.value for e in CrmEventType]}
|
||||
|
||||
|
||||
@app.post("/cron/validate")
|
||||
async def validate_cron_post(body: Dict):
|
||||
expression = body.get("expression", "")
|
||||
try:
|
||||
cron = CronExpression(expression)
|
||||
next_runs = []
|
||||
dt = datetime.now()
|
||||
for _ in range(5):
|
||||
dt = cron.next_run(dt)
|
||||
next_runs.append(dt.isoformat())
|
||||
dt += timedelta(minutes=1)
|
||||
return {"valid": True, "expression": expression, "next_runs": next_runs}
|
||||
except ValueError as e:
|
||||
return {"valid": False, "expression": expression, "error": str(e)}
|
||||
|
||||
|
||||
# --- Workflow endpoints ---
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ import lmsRoutes from "./lms/routes";
|
|||
import xosRoutes from "./xos/routes";
|
||||
import governanceRoutes from "./governance/routes";
|
||||
import { setupPlusProxy } from "./plus/proxy";
|
||||
import { setupMetabaseProxy } from "./metabase/proxy";
|
||||
import { setupSupersetProxy } from "./superset/proxy";
|
||||
import { registerSupersetRoutes } from "./superset/routes";
|
||||
import { setupErpNextProxy } from "./erpnext/proxy";
|
||||
import { importErpNextRulesForTenant } from "./soe/erpnext-rule-importer";
|
||||
import { registerEngineRoomRoutes } from "./engine-room/routes";
|
||||
import { registerMetaSetRoutes } from "./metaset/routes";
|
||||
import plusSsoRoutes from "./plus/sso";
|
||||
import migrationRoutes from "./migration/routes";
|
||||
import { githubRoutes } from "./integrations/github";
|
||||
|
|
@ -59,6 +61,8 @@ import pipelineRoutes from "./blackboard/pipelineRoutes";
|
|||
import { startAllAgents } from "./blackboard/agents";
|
||||
import { loadModuleRoutes } from "./modules/loader";
|
||||
import graphRoutes from "./graph/routes";
|
||||
import { initSocketIO } from "./socket-io";
|
||||
import { startXosScheduler } from "./xos/scheduler";
|
||||
|
||||
export async function registerRoutes(
|
||||
httpServer: Server,
|
||||
|
|
@ -69,13 +73,19 @@ export async function registerRoutes(
|
|||
|
||||
// Arcádia Plus - Proxy registered AFTER session but BEFORE auth-protected routes
|
||||
await setupPlusProxy(app);
|
||||
|
||||
// Metabase BI - Proxy to Metabase instance
|
||||
setupMetabaseProxy(app);
|
||||
|
||||
// Apache Superset - Arcádia Insights (substitui Metabase)
|
||||
setupSupersetProxy(app);
|
||||
registerSupersetRoutes(app);
|
||||
|
||||
// ERPNext container proxy (ativo apenas em DOCKER_MODE ou ERPNEXT_PROXY_ENABLED=true)
|
||||
setupErpNextProxy(app);
|
||||
|
||||
registerChatRoutes(app);
|
||||
registerSoeRoutes(app);
|
||||
registerInternalChatRoutes(app);
|
||||
// Initialize shared Socket.IO singleton (must be before other socket setups)
|
||||
initSocketIO(httpServer);
|
||||
setupChatSocket(httpServer);
|
||||
setupCommunitySocket(httpServer);
|
||||
registerWhatsappRoutes(app);
|
||||
|
|
@ -86,7 +96,6 @@ export async function registerRoutes(
|
|||
registerBiRoutes(app);
|
||||
app.use("/api/graph", graphRoutes);
|
||||
registerBiEngineRoutes(app);
|
||||
registerMetaSetRoutes(app);
|
||||
registerCommEngineRoutes(app);
|
||||
registerLearningRoutes(app);
|
||||
app.use("/api/compass", compassRoutes);
|
||||
|
|
@ -108,6 +117,17 @@ export async function registerRoutes(
|
|||
registerCommunityRoutes(app);
|
||||
app.use("/api/para", paraRoutes);
|
||||
app.use("/api/erpnext", erpnextRoutes);
|
||||
|
||||
// SOE — Importar regras do ERPNext para soe_regras
|
||||
app.post("/api/soe/erpnext/import-rules", async (req: any, res) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenantId || req.body?.tenantId;
|
||||
const result = await importErpNextRulesForTenant(tenantId);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
app.use("/api/quality", qualityRoutes);
|
||||
app.use("/api/lowcode", lowcodeRoutes);
|
||||
app.use("/api/dev-agent", devAgentRoutes);
|
||||
|
|
@ -128,6 +148,9 @@ export async function registerRoutes(
|
|||
|
||||
// Iniciar os 6 agentes do Blackboard
|
||||
startAllAgents();
|
||||
|
||||
// XOS Scheduler: SLA breach checker + supervisor stats broadcaster
|
||||
startXosScheduler();
|
||||
|
||||
// Central de Protocolos (MCP, A2A, AP2, UCP)
|
||||
app.use("/api", protocolsRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Singleton Socket.IO — instância compartilhada entre todos os módulos.
|
||||
* Inicializado em routes.ts via initSocketIO(httpServer).
|
||||
* Qualquer módulo pode importar getIO() para emitir eventos.
|
||||
*/
|
||||
import { Server as HttpServer } from "http";
|
||||
import { Server as SocketServer, Socket } from "socket.io";
|
||||
|
||||
let _io: SocketServer | null = null;
|
||||
|
||||
export function initSocketIO(httpServer: HttpServer): SocketServer {
|
||||
if (_io) return _io;
|
||||
_io = new SocketServer(httpServer, {
|
||||
path: "/socket.io",
|
||||
cors: { origin: "*", methods: ["GET", "POST"] },
|
||||
});
|
||||
return _io;
|
||||
}
|
||||
|
||||
export function getIO(): SocketServer {
|
||||
if (!_io) throw new Error("[socket-io] Not initialized. Call initSocketIO first.");
|
||||
return _io;
|
||||
}
|
||||
|
||||
/** Emite um evento XOS para todos os clientes conectados (não bloqueia). */
|
||||
export function broadcastXos(event: string, data: unknown) {
|
||||
try {
|
||||
getIO().emit(`xos:${event}`, data);
|
||||
} catch {
|
||||
// Socket ainda não iniciado ou sem clientes — ignorar silenciosamente
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
// SOE — ERPNext Rule Importer
|
||||
// Lê DocTypes e Workflows do ERPNext via API REST e converte
|
||||
// em soe_regras para o SOE Rule Engine.
|
||||
//
|
||||
// Uso:
|
||||
// import { importErpNextRules } from "./erpnext-rule-importer";
|
||||
// await importErpNextRules(tenantId);
|
||||
//
|
||||
// As regras geradas ficam na tabela soe_regras com origemPadrao=false
|
||||
// (origem: "erpnext") e podem ser sobrescritas por regras custom do tenant.
|
||||
|
||||
import { db } from "../db/index";
|
||||
import { soeRegras } from "@shared/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
// DocTypes de negócio que queremos importar regras
|
||||
const DOCTYPES_ALVO = [
|
||||
"Sales Order",
|
||||
"Purchase Order",
|
||||
"Sales Invoice",
|
||||
"Purchase Invoice",
|
||||
"Delivery Note",
|
||||
"Purchase Receipt",
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
"Item",
|
||||
"Customer",
|
||||
"Supplier",
|
||||
"Stock Entry",
|
||||
"Quotation",
|
||||
];
|
||||
|
||||
// Mapa DocType → trigger SOE correspondente
|
||||
const DOCTYPE_TRIGGER_MAP: Record<string, string> = {
|
||||
"Sales Order": "pre_sales_order",
|
||||
"Purchase Order": "pre_purchase_order",
|
||||
"Sales Invoice": "pre_nfe_emissao",
|
||||
"Purchase Invoice": "pre_compra_recebida",
|
||||
"Delivery Note": "pre_entrega",
|
||||
"Purchase Receipt": "pre_recebimento_mercadoria",
|
||||
"Payment Entry": "pre_pagamento",
|
||||
"Journal Entry": "pre_lancamento_contabil",
|
||||
"Item": "pre_cadastro_produto",
|
||||
"Customer": "pre_cadastro_cliente",
|
||||
"Supplier": "pre_cadastro_fornecedor",
|
||||
"Stock Entry": "pre_movimentacao_estoque",
|
||||
"Quotation": "pre_orcamento",
|
||||
};
|
||||
|
||||
interface FrappeDocTypeField {
|
||||
fieldname: string;
|
||||
label?: string;
|
||||
fieldtype: string;
|
||||
reqd?: number;
|
||||
options?: string;
|
||||
depends_on?: string;
|
||||
}
|
||||
|
||||
interface FrappeWorkflowState {
|
||||
state: string;
|
||||
doc_status?: string;
|
||||
allow_edit?: string;
|
||||
}
|
||||
|
||||
interface FrappeWorkflowTransition {
|
||||
state: string;
|
||||
action: string;
|
||||
next_state: string;
|
||||
allowed?: string;
|
||||
}
|
||||
|
||||
interface FrappeWorkflow {
|
||||
name: string;
|
||||
document_type: string;
|
||||
is_active: number;
|
||||
states?: FrappeWorkflowState[];
|
||||
transitions?: FrappeWorkflowTransition[];
|
||||
}
|
||||
|
||||
function getErpNextUrl(): string {
|
||||
// Em modo container, usa o serviço Docker interno
|
||||
const dockerMode = process.env.DOCKER_MODE === "true";
|
||||
if (dockerMode) {
|
||||
const host = process.env.ERPNEXT_CONTAINER_HOST || "erpnext";
|
||||
const port = process.env.ERPNEXT_CONTAINER_PORT || "8080";
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
return process.env.ERPNEXT_URL || "";
|
||||
}
|
||||
|
||||
function getAuthHeader(): Record<string, string> {
|
||||
const key = process.env.ERPNEXT_API_KEY || "";
|
||||
const secret = process.env.ERPNEXT_API_SECRET || "";
|
||||
return {
|
||||
Authorization: `token ${key}:${secret}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDocTypeFields(baseUrl: string, doctype: string): Promise<FrappeDocTypeField[]> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${baseUrl}/api/resource/DocType/${encodeURIComponent(doctype)}`,
|
||||
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
return (data?.data?.fields as FrappeDocTypeField[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkflows(baseUrl: string, doctype: string): Promise<FrappeWorkflow[]> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${baseUrl}/api/resource/Workflow?filters=[["document_type","=","${doctype}"],["is_active","=",1]]&fields=["name","document_type","is_active"]`,
|
||||
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
const workflows: FrappeWorkflow[] = data?.data || [];
|
||||
|
||||
// Busca detalhes de cada workflow (states/transitions)
|
||||
const detailed: FrappeWorkflow[] = [];
|
||||
for (const wf of workflows) {
|
||||
try {
|
||||
const detResp = await fetch(
|
||||
`${baseUrl}/api/resource/Workflow/${encodeURIComponent(wf.name)}`,
|
||||
{ headers: getAuthHeader(), signal: AbortSignal.timeout(10000) }
|
||||
);
|
||||
if (detResp.ok) {
|
||||
const det = await detResp.json() as any;
|
||||
detailed.push(det.data);
|
||||
}
|
||||
} catch {
|
||||
detailed.push(wf);
|
||||
}
|
||||
}
|
||||
return detailed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function campoParaSoe(field: FrappeDocTypeField, doctype: string, trigger: string) {
|
||||
// Mapeamento de nomes de campo Frappe → nomes SOE
|
||||
const fieldMap: Record<string, string> = {
|
||||
customer: "clienteId",
|
||||
supplier: "fornecedorId",
|
||||
item_code: "itemCodigo",
|
||||
qty: "quantidade",
|
||||
rate: "valorUnitario",
|
||||
amount: "valorTotal",
|
||||
posting_date: "dataEmissao",
|
||||
due_date: "vencimento",
|
||||
payment_terms_template: "condicaoPagamento",
|
||||
cost_center: "centroCusto",
|
||||
company: "empresaNome",
|
||||
};
|
||||
return fieldMap[field.fieldname] || field.fieldname;
|
||||
}
|
||||
|
||||
function gerarRegraObrigatorio(
|
||||
field: FrappeDocTypeField,
|
||||
doctype: string,
|
||||
trigger: string,
|
||||
tenantId: number | undefined
|
||||
) {
|
||||
const campoSoe = campoParaSoe(field, doctype, trigger);
|
||||
return {
|
||||
tenantId,
|
||||
dominio: "business" as const,
|
||||
trigger,
|
||||
nome: `ERPNext: ${doctype} — ${field.label || field.fieldname} obrigatório`,
|
||||
condicao: {},
|
||||
acao: {
|
||||
validar: {
|
||||
campo: campoSoe,
|
||||
obrigatorio: true,
|
||||
},
|
||||
},
|
||||
prioridade: 50,
|
||||
ativo: true,
|
||||
origemPadrao: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importErpNextRules(tenantId?: number): Promise<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const baseUrl = getErpNextUrl();
|
||||
|
||||
if (!baseUrl) {
|
||||
return { imported: 0, skipped: 0, errors: ["ERPNEXT_URL não configurado"] };
|
||||
}
|
||||
|
||||
console.log(`[SOE RuleImporter] Iniciando importação de regras ERPNext de ${baseUrl}...`);
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const doctype of DOCTYPES_ALVO) {
|
||||
const trigger = DOCTYPE_TRIGGER_MAP[doctype];
|
||||
if (!trigger) continue;
|
||||
|
||||
try {
|
||||
// 1. Campos obrigatórios do DocType
|
||||
const fields = await fetchDocTypeFields(baseUrl, doctype);
|
||||
const requiredFields = fields.filter((f) => f.reqd === 1 && f.fieldname !== "name");
|
||||
|
||||
for (const field of requiredFields) {
|
||||
const ruleId = `erpnext:${doctype.toLowerCase().replace(/ /g, "_")}:${field.fieldname}:reqd`;
|
||||
|
||||
// Verifica se já existe
|
||||
try {
|
||||
const existing = await db.query.soeRegras?.findFirst?.({
|
||||
where: (t: any, { eq }: any) => eq(t.id, ruleId),
|
||||
});
|
||||
if (existing) { skipped++; continue; }
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
|
||||
try {
|
||||
const ruleData = gerarRegraObrigatorio(field, doctype, trigger, tenantId);
|
||||
await db.insert(soeRegras).values({
|
||||
id: ruleId,
|
||||
...ruleData,
|
||||
condicao: ruleData.condicao,
|
||||
acao: ruleData.acao,
|
||||
} as any);
|
||||
imported++;
|
||||
} catch (e: any) {
|
||||
errors.push(`${doctype}/${field.fieldname}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Workflows ativos → regras de estado permitido
|
||||
const workflows = await fetchWorkflows(baseUrl, doctype);
|
||||
for (const wf of workflows) {
|
||||
if (!wf.transitions?.length) continue;
|
||||
|
||||
// Extrai ações permitidas por estado → regra de validação de status
|
||||
const allowedActions = wf.transitions.map((t) => t.action).filter(Boolean);
|
||||
if (allowedActions.length === 0) continue;
|
||||
|
||||
const ruleId = `erpnext:wf:${wf.name.toLowerCase().replace(/ /g, "_")}`;
|
||||
|
||||
try {
|
||||
const existing = await db.query.soeRegras?.findFirst?.({
|
||||
where: (t: any, { eq }: any) => eq(t.id, ruleId),
|
||||
});
|
||||
if (existing) { skipped++; continue; }
|
||||
} catch { /* ok */ }
|
||||
|
||||
try {
|
||||
await db.insert(soeRegras).values({
|
||||
id: ruleId,
|
||||
tenantId,
|
||||
dominio: "business",
|
||||
trigger,
|
||||
nome: `ERPNext Workflow: ${wf.name}`,
|
||||
condicao: {},
|
||||
acao: {
|
||||
set: {
|
||||
_erpnextWorkflow: wf.name,
|
||||
_erpnextAllowedActions: allowedActions,
|
||||
},
|
||||
},
|
||||
prioridade: 80,
|
||||
ativo: true,
|
||||
origemPadrao: false,
|
||||
} as any);
|
||||
imported++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Workflow ${wf.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SOE RuleImporter] ${doctype}: ${requiredFields.length} campos obrigatórios, ${workflows.length} workflow(s)`);
|
||||
} catch (e: any) {
|
||||
errors.push(`DocType ${doctype}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SOE RuleImporter] Concluído: ${imported} importadas, ${skipped} já existiam, ${errors.length} erros`);
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
// Endpoint para disparar importação via API
|
||||
export async function importErpNextRulesForTenant(tenantId: number): Promise<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
return importErpNextRules(tenantId);
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
// SOE Event Bus — Dispara eventos após execução do motor
|
||||
// Permite que lançamentos contábeis, alertas e automações
|
||||
// sejam disparados de forma desacoplada após cada operação SOE.
|
||||
|
||||
import { db } from "../db/index";
|
||||
import { soeEventos } from "@shared/schema";
|
||||
import { contabilHandler } from "./handlers/contabil-handler";
|
||||
import { financialHandler } from "./handlers/financial-handler";
|
||||
|
||||
export type SoeEventType =
|
||||
| "nfe_emitida"
|
||||
| "nfe_cancelada"
|
||||
| "venda_confirmada"
|
||||
| "venda_faturada"
|
||||
| "compra_recebida"
|
||||
| "pagamento_realizado"
|
||||
| "recebimento_realizado"
|
||||
| "estoque_baixo";
|
||||
|
||||
export interface SoeEventPayload {
|
||||
tenantId?: number;
|
||||
empresaId?: number;
|
||||
motorOrigem?: "plus" | "erpnext" | "local";
|
||||
dados: Record<string, any>;
|
||||
}
|
||||
|
||||
type EventHandler = (payload: SoeEventPayload) => Promise<void>;
|
||||
|
||||
// Mapa de handlers por evento
|
||||
const HANDLERS: Record<SoeEventType, EventHandler[]> = {
|
||||
nfe_emitida: [
|
||||
contabilHandler.lancamentoVenda,
|
||||
financialHandler.criarContaReceber,
|
||||
],
|
||||
nfe_cancelada: [
|
||||
contabilHandler.estornoLancamentoVenda,
|
||||
],
|
||||
venda_confirmada: [
|
||||
financialHandler.reservarEstoque,
|
||||
],
|
||||
venda_faturada: [
|
||||
contabilHandler.lancamentoVenda,
|
||||
],
|
||||
compra_recebida: [
|
||||
contabilHandler.lancamentoCompra,
|
||||
financialHandler.criarContaPagar,
|
||||
],
|
||||
pagamento_realizado: [
|
||||
contabilHandler.lancamentoPagamento,
|
||||
financialHandler.baixarContaPagar,
|
||||
],
|
||||
recebimento_realizado: [
|
||||
contabilHandler.lancamentoRecebimento,
|
||||
financialHandler.baixarContaReceber,
|
||||
],
|
||||
estoque_baixo: [
|
||||
financialHandler.alertaEstoqueBaixo,
|
||||
],
|
||||
};
|
||||
|
||||
export class SoeEventBus {
|
||||
private static instance: SoeEventBus;
|
||||
|
||||
static getInstance(): SoeEventBus {
|
||||
if (!SoeEventBus.instance) SoeEventBus.instance = new SoeEventBus();
|
||||
return SoeEventBus.instance;
|
||||
}
|
||||
|
||||
async emit(evento: SoeEventType, payload: SoeEventPayload): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
// Registra evento no banco
|
||||
let eventoId: string | undefined;
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(soeEventos)
|
||||
.values({
|
||||
tenantId: payload.tenantId,
|
||||
empresaId: payload.empresaId,
|
||||
evento,
|
||||
motorOrigem: payload.motorOrigem,
|
||||
payloadEntrada: payload.dados,
|
||||
status: "ok",
|
||||
})
|
||||
.returning({ id: soeEventos.id });
|
||||
eventoId = row?.id;
|
||||
} catch (e: any) {
|
||||
console.error("[SoeEventBus] Erro ao registrar evento:", e.message);
|
||||
}
|
||||
|
||||
// Dispara handlers em paralelo (falha isolada — não bloqueia o fluxo)
|
||||
const handlers = HANDLERS[evento] ?? [];
|
||||
const results = await Promise.allSettled(
|
||||
handlers.map((h) => h({ ...payload, dados: { ...payload.dados, _eventoId: eventoId } }))
|
||||
);
|
||||
|
||||
const erros = results
|
||||
.filter((r) => r.status === "rejected")
|
||||
.map((r) => (r as PromiseRejectedResult).reason?.message);
|
||||
|
||||
if (erros.length > 0) {
|
||||
console.warn(`[SoeEventBus] ${evento}: ${erros.length} handler(s) falharam:`, erros);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SoeEventBus] ${evento} processado em ${Date.now() - start}ms | ` +
|
||||
`${handlers.length} handlers | ${erros.length} erros`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const soeEventBus = SoeEventBus.getInstance();
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
// SOE — Handler Contábil
|
||||
// Gera lançamentos contábeis automáticos a partir de eventos SOE.
|
||||
// Os lançamentos são salvos em soe_lancamentos e depois sincronizados
|
||||
// com o Motor Contábil Python (:8003).
|
||||
|
||||
import { db } from "../../db/index";
|
||||
import { soeLancamentos } from "@shared/schema";
|
||||
import type { SoeEventPayload } from "../event-bus";
|
||||
|
||||
const CONTABIL_ENGINE_URL =
|
||||
`http://${process.env.CONTABIL_ENGINE_HOST || "localhost"}:${process.env.CONTABIL_PORT || "8003"}`;
|
||||
|
||||
async function inserirLancamento(
|
||||
tenantId: number | undefined,
|
||||
empresaId: number,
|
||||
data: string,
|
||||
contaDebito: string,
|
||||
contaCredito: string,
|
||||
valor: number,
|
||||
historico: string,
|
||||
origemEvento: string,
|
||||
origemEventoId?: string
|
||||
) {
|
||||
if (!valor || valor <= 0) return;
|
||||
try {
|
||||
await db.insert(soeLancamentos).values({
|
||||
tenantId,
|
||||
empresaId,
|
||||
data,
|
||||
contaDebito,
|
||||
contaCredito,
|
||||
valor: String(valor),
|
||||
historico,
|
||||
origemEvento,
|
||||
origemEventoId,
|
||||
periodo: data.substring(0, 7), // 'YYYY-MM'
|
||||
enviado8003: false,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[ContabilHandler] Erro ao inserir lançamento:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const contabilHandler = {
|
||||
|
||||
// NF-e de venda emitida → lançamentos de receita + impostos
|
||||
lancamentoVenda: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId, empresaId } = payload;
|
||||
if (!empresaId || !dados.valorTotal) return;
|
||||
|
||||
const data = dados.dataEmissao?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||
const numeroNfe = dados.numero || dados.nfeId || "?";
|
||||
const eventoId = dados._eventoId;
|
||||
|
||||
// D: Clientes / C: Receita Bruta de Vendas
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"1.1.3.01", "3.1.1.01",
|
||||
parseFloat(dados.valorTotal),
|
||||
`Venda NF-e ${numeroNfe}`,
|
||||
"nfe_emitida", eventoId
|
||||
);
|
||||
|
||||
// D: Deduções ICMS / C: ICMS a Recolher
|
||||
if (dados.valorIcms && parseFloat(dados.valorIcms) > 0) {
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"3.1.2.01", "2.1.2.01",
|
||||
parseFloat(dados.valorIcms),
|
||||
`ICMS s/ Venda NF-e ${numeroNfe}`,
|
||||
"nfe_emitida", eventoId
|
||||
);
|
||||
}
|
||||
|
||||
// D: Deduções PIS / C: PIS a Recolher
|
||||
if (dados.valorPis && parseFloat(dados.valorPis) > 0) {
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"3.1.2.02", "2.1.2.02",
|
||||
parseFloat(dados.valorPis),
|
||||
`PIS s/ Venda NF-e ${numeroNfe}`,
|
||||
"nfe_emitida", eventoId
|
||||
);
|
||||
}
|
||||
|
||||
// D: Deduções COFINS / C: COFINS a Recolher
|
||||
if (dados.valorCofins && parseFloat(dados.valorCofins) > 0) {
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"3.1.2.03", "2.1.2.03",
|
||||
parseFloat(dados.valorCofins),
|
||||
`COFINS s/ Venda NF-e ${numeroNfe}`,
|
||||
"nfe_emitida", eventoId
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Cancelamento de NF-e → estorno dos lançamentos
|
||||
estornoLancamentoVenda: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId, empresaId } = payload;
|
||||
if (!empresaId || !dados.valorTotal) return;
|
||||
|
||||
const data = new Date().toISOString().substring(0, 10);
|
||||
const numeroNfe = dados.numero || dados.nfeId || "?";
|
||||
|
||||
// Estorno: inverte débito/crédito
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"3.1.1.01", "1.1.3.01",
|
||||
parseFloat(dados.valorTotal),
|
||||
`Estorno NF-e ${numeroNfe} (cancelamento)`,
|
||||
"nfe_cancelada"
|
||||
);
|
||||
},
|
||||
|
||||
// Compra recebida (NF-e entrada) → entrada em estoque + fornecedor
|
||||
lancamentoCompra: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId, empresaId } = payload;
|
||||
if (!empresaId || !dados.valorMercadorias) return;
|
||||
|
||||
const data = dados.dataEntrada?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||
const numeroNfe = dados.numero || "?";
|
||||
const fornecedor = dados.fornecedor || "Fornecedor";
|
||||
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"1.1.4.01", "2.1.1.01",
|
||||
parseFloat(dados.valorMercadorias),
|
||||
`Compra NF-e ${numeroNfe} / ${fornecedor}`,
|
||||
"compra_recebida"
|
||||
);
|
||||
},
|
||||
|
||||
// Pagamento de conta a pagar
|
||||
lancamentoPagamento: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId, empresaId } = payload;
|
||||
if (!empresaId || !dados.valorPago) return;
|
||||
|
||||
const data = dados.dataPagamento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"2.1.1.01", "1.1.1.01",
|
||||
parseFloat(dados.valorPago),
|
||||
`Pagamento: ${dados.descricao || "Conta a pagar"}`,
|
||||
"pagamento_realizado"
|
||||
);
|
||||
},
|
||||
|
||||
// Recebimento de cliente
|
||||
lancamentoRecebimento: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId, empresaId } = payload;
|
||||
if (!empresaId || !dados.valorRecebido) return;
|
||||
|
||||
const data = dados.dataRecebimento?.substring(0, 10) || new Date().toISOString().substring(0, 10);
|
||||
|
||||
await inserirLancamento(
|
||||
tenantId, empresaId, data,
|
||||
"1.1.1.01", "1.1.3.01",
|
||||
parseFloat(dados.valorRecebido),
|
||||
`Recebimento: ${dados.descricao || "Conta a receber"}`,
|
||||
"recebimento_realizado"
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Sincroniza lançamentos pendentes com Motor Contábil Python (:8003)
|
||||
export async function sincronizarComMotorContabil(empresaId: number, periodo: string) {
|
||||
try {
|
||||
const resp = await fetch(`${CONTABIL_ENGINE_URL}/lancamentos/sincronizar`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ empresaId, periodo }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("[ContabilHandler] Motor :8003 retornou:", resp.status);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn("[ContabilHandler] Motor :8003 indisponível:", e.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// SOE — Handler Financeiro
|
||||
// Automações financeiras disparadas pelo Event Bus SOE.
|
||||
|
||||
import { db } from "../../db/index";
|
||||
import { finAccountsReceivable, finAccountsPayable } from "@shared/schema";
|
||||
import type { SoeEventPayload } from "../event-bus";
|
||||
|
||||
export const financialHandler = {
|
||||
|
||||
// Cria conta a receber automaticamente ao emitir NF-e de venda
|
||||
criarContaReceber: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId } = payload;
|
||||
if (!dados.valorTotal || !dados.clienteId) return;
|
||||
|
||||
try {
|
||||
// Verifica se já existe conta a receber para esta NF-e
|
||||
if (dados.nfeId) {
|
||||
const existente = await db.query.finAccountsReceivable?.findFirst?.({
|
||||
where: (t: any, { eq }: any) => eq(t.reference, `nfe:${dados.nfeId}`),
|
||||
});
|
||||
if (existente) return;
|
||||
}
|
||||
|
||||
const vencimento = dados.vencimento
|
||||
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
|
||||
|
||||
await db.insert(finAccountsReceivable).values({
|
||||
tenantId: tenantId ?? 1,
|
||||
customerId: dados.clienteId,
|
||||
description: `NF-e ${dados.numero || dados.nfeId || "?"} — ${dados.clienteNome || "Cliente"}`,
|
||||
amount: String(dados.valorTotal),
|
||||
dueDate: vencimento,
|
||||
status: "pending",
|
||||
category: "vendas",
|
||||
paymentMethod: dados.formaPagamento || "outros",
|
||||
notes: `Gerado automaticamente pelo SOE ao emitir NF-e`,
|
||||
} as any);
|
||||
|
||||
console.log("[FinancialHandler] Conta a receber criada para NF-e", dados.nfeId || dados.numero);
|
||||
} catch (e: any) {
|
||||
console.error("[FinancialHandler] criarContaReceber error:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Baixa conta a receber após recebimento confirmado
|
||||
baixarContaReceber: async (payload: SoeEventPayload) => {
|
||||
const { dados } = payload;
|
||||
if (!dados.contaReceiverId) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(finAccountsReceivable)
|
||||
.set({
|
||||
status: "received",
|
||||
receivedDate: dados.dataRecebimento || new Date().toISOString().substring(0, 10),
|
||||
paymentMethod: dados.formaPagamento || "outros",
|
||||
} as any)
|
||||
.where((finAccountsReceivable as any).id.eq(dados.contaReceiverId));
|
||||
} catch (e: any) {
|
||||
console.error("[FinancialHandler] baixarContaReceber error:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Cria conta a pagar ao receber mercadoria
|
||||
criarContaPagar: async (payload: SoeEventPayload) => {
|
||||
const { dados, tenantId } = payload;
|
||||
if (!dados.valorTotal || !dados.fornecedorId) return;
|
||||
|
||||
try {
|
||||
const vencimento = dados.vencimento
|
||||
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().substring(0, 10);
|
||||
|
||||
await db.insert(finAccountsPayable).values({
|
||||
tenantId: tenantId ?? 1,
|
||||
supplierId: dados.fornecedorId,
|
||||
description: `NF-e Entrada ${dados.numero || "?"} — ${dados.fornecedor || "Fornecedor"}`,
|
||||
amount: String(dados.valorTotal),
|
||||
dueDate: vencimento,
|
||||
status: "pending",
|
||||
category: "compras",
|
||||
notes: `Gerado automaticamente pelo SOE ao receber compra`,
|
||||
} as any);
|
||||
} catch (e: any) {
|
||||
console.error("[FinancialHandler] criarContaPagar error:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Baixa conta a pagar após pagamento
|
||||
baixarContaPagar: async (payload: SoeEventPayload) => {
|
||||
const { dados } = payload;
|
||||
if (!dados.contaPayableId) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(finAccountsPayable)
|
||||
.set({
|
||||
status: "paid",
|
||||
paidDate: dados.dataPagamento || new Date().toISOString().substring(0, 10),
|
||||
paymentMethod: dados.formaPagamento || "outros",
|
||||
} as any)
|
||||
.where((finAccountsPayable as any).id.eq(dados.contaPayableId));
|
||||
} catch (e: any) {
|
||||
console.error("[FinancialHandler] baixarContaPagar error:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Reserva estoque ao confirmar venda (notificação — execução no motor)
|
||||
reservarEstoque: async (payload: SoeEventPayload) => {
|
||||
const { dados } = payload;
|
||||
if (!dados.items || dados.motor === "local") return;
|
||||
// Log apenas — a reserva real é feita pelo motor Plus/ERPNext
|
||||
console.log(
|
||||
`[FinancialHandler] Venda ${dados.id} confirmada — ` +
|
||||
`${dados.items?.length || 0} item(s) para reserva de estoque`
|
||||
);
|
||||
},
|
||||
|
||||
// Alerta de estoque baixo (via console/log — webhook futuro)
|
||||
alertaEstoqueBaixo: async (payload: SoeEventPayload) => {
|
||||
const { dados } = payload;
|
||||
console.warn(
|
||||
`[FinancialHandler] ALERTA: Estoque baixo — ` +
|
||||
`Produto ${dados.produtoId} / ${dados.produtoNome} ` +
|
||||
`(atual: ${dados.estoqueAtual}, mínimo: ${dados.estoqueMinimo})`
|
||||
);
|
||||
// TODO Fase 5: enviar via WhatsApp/Email usando Motor Comunicação (:8006)
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// SOE — Regras de Negócio Padrão
|
||||
|
||||
import type { SoeRule } from "./types";
|
||||
|
||||
export const BUSINESS_RULES_PADRAO: SoeRule[] = [
|
||||
// ── Aprovação de pedidos ───────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "PEDIDO_APROVACAO_AUTO_BAIXO_VALOR",
|
||||
dominio: "business",
|
||||
trigger: "pre_sales_order_confirm",
|
||||
nome: "Aprovação automática para pedidos de baixo valor",
|
||||
condicao: { "totalAmount": { "_lt": 5000 } },
|
||||
acao: { set: { "_aprovacaoAutomatica": true, "status": "confirmed" } },
|
||||
prioridade: 10,
|
||||
ativo: false, // tenant habilita com seu limite
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Estoque ────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "RESERVA_ESTOQUE_AO_CONFIRMAR",
|
||||
dominio: "business",
|
||||
trigger: "post_sales_order_confirm",
|
||||
nome: "Reserva estoque ao confirmar pedido",
|
||||
condicao: { "status": "confirmed" },
|
||||
acao: { set: { "_reservarEstoque": true } },
|
||||
prioridade: 10,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Financeiro ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "CRIAR_CONTA_RECEBER_AO_FATURAR",
|
||||
dominio: "business",
|
||||
trigger: "post_nfe_emitida",
|
||||
nome: "Cria conta a receber automaticamente ao faturar",
|
||||
condicao: { "_tipoOperacao": "venda" },
|
||||
acao: { set: { "_criarContaReceber": true } },
|
||||
prioridade: 5,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "CRIAR_CONTA_PAGAR_AO_RECEBER_COMPRA",
|
||||
dominio: "business",
|
||||
trigger: "post_purchase_received",
|
||||
nome: "Cria conta a pagar ao receber mercadoria",
|
||||
condicao: {},
|
||||
acao: { set: { "_criarContaPagar": true } },
|
||||
prioridade: 5,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Alertas ────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "ALERTA_ESTOQUE_MINIMO",
|
||||
dominio: "business",
|
||||
trigger: "post_sales_order_confirm",
|
||||
nome: "Alerta quando estoque cai abaixo do mínimo",
|
||||
condicao: { "_estoqueAbaixoMinimo": true },
|
||||
acao: { set: { "_enviarAlertaEstoque": true } },
|
||||
prioridade: 15,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// SOE — Regras Fiscais Padrão
|
||||
// Estas regras enriquecem o payload ANTES de despachar ao motor (Plus/ERPNext)
|
||||
// O motor recebe o payload já correto e só executa.
|
||||
|
||||
import type { SoeRule } from "./types";
|
||||
|
||||
export const FISCAL_RULES_PADRAO: SoeRule[] = [
|
||||
// ── CFOP — Resolução automática por direção da operação ──────────────────
|
||||
|
||||
{
|
||||
id: "CFOP_VENDA_DENTRO_ESTADO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_sales_order",
|
||||
nome: "CFOP padrão — venda dentro do estado",
|
||||
condicao: { _cfopNaoDefinido: true, _operacao: "venda" },
|
||||
acao: { set: { "cfopPadrao": "5.102" } },
|
||||
prioridade: 20,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "CFOP_VENDA_OUTRO_ESTADO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_sales_order",
|
||||
nome: "CFOP padrão — venda para outro estado",
|
||||
condicao: { _cfopNaoDefinido: true, _operacao: "venda", _interestadual: true },
|
||||
acao: { set: { "cfopPadrao": "6.102" } },
|
||||
prioridade: 19,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "CFOP_COMPRA_DENTRO_ESTADO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_purchase_order",
|
||||
nome: "CFOP padrão — compra dentro do estado",
|
||||
condicao: { _cfopNaoDefinido: true },
|
||||
acao: { set: { "cfopPadrao": "1.102" } },
|
||||
prioridade: 20,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Tributação por Regime ──────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "TRIBUTACAO_SIMPLES_NACIONAL",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Simples Nacional — aplica CSOSN e remove destaque PIS/COFINS",
|
||||
condicao: { regimeTributario: "simples_nacional" },
|
||||
acao: {
|
||||
set: {
|
||||
"tributacao.csosnPadrao": "400",
|
||||
"tributacao.destacarPisCofins": false,
|
||||
"tributacao.aliquotaIcms": 0,
|
||||
},
|
||||
},
|
||||
prioridade: 5,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "TRIBUTACAO_LUCRO_PRESUMIDO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Lucro Presumido — CST 00 com alíquotas padrão",
|
||||
condicao: { regimeTributario: "lucro_presumido" },
|
||||
acao: {
|
||||
set: {
|
||||
"tributacao.cstIcmsPadrao": "00",
|
||||
"tributacao.cstPisPadrao": "01",
|
||||
"tributacao.cstCofinsPadrao": "01",
|
||||
"tributacao.destacarPisCofins": true,
|
||||
},
|
||||
},
|
||||
prioridade: 5,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "TRIBUTACAO_LUCRO_REAL",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Lucro Real — CST 00, PIS/COFINS não cumulativo",
|
||||
condicao: { regimeTributario: "lucro_real" },
|
||||
acao: {
|
||||
set: {
|
||||
"tributacao.cstIcmsPadrao": "00",
|
||||
"tributacao.cstPisPadrao": "50",
|
||||
"tributacao.cstCofinsPadrao": "50",
|
||||
"tributacao.destacarPisCofins": true,
|
||||
"tributacao.regimePisCofins": "nao_cumulativo",
|
||||
},
|
||||
},
|
||||
prioridade: 5,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Validações obrigatórias ────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: "VALIDAR_NCM_NFE",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Valida NCM obrigatório em NF-e",
|
||||
condicao: { tipoDocumento: ["nfe", "nfce"] },
|
||||
acao: { validar: { campo: "ncm", obrigatorio: true, formato: "^\\d{8}$" } },
|
||||
prioridade: 1,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "VALIDAR_CFOP_OBRIGATORIO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Valida CFOP obrigatório",
|
||||
condicao: {},
|
||||
acao: { validar: { campo: "cfop", obrigatorio: true, formato: "^\\d\\.\\d{3}$" } },
|
||||
prioridade: 1,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
{
|
||||
id: "VALIDAR_CNPJ_CPF_DESTINATARIO",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_nfe_emissao",
|
||||
nome: "Valida CNPJ/CPF do destinatário",
|
||||
condicao: { tipoDocumento: "nfe" },
|
||||
acao: { validar: { campo: "destinatario.cpfCnpj", obrigatorio: true } },
|
||||
prioridade: 1,
|
||||
ativo: true,
|
||||
origemPadrao: true,
|
||||
},
|
||||
|
||||
// ── Geração automática de NF-e ao faturar ─────────────────────────────────
|
||||
|
||||
{
|
||||
id: "NFE_AUTO_AO_FATURAR",
|
||||
dominio: "fiscal",
|
||||
trigger: "pre_sales_order_confirm",
|
||||
nome: "Gera NF-e automaticamente ao confirmar pedido (se configurado)",
|
||||
condicao: { "config.nfeAutomatica": true },
|
||||
acao: { set: { "_gerarNfe": true } },
|
||||
prioridade: 10,
|
||||
ativo: false, // desativado por padrão — tenant habilita
|
||||
origemPadrao: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
// SOE Rule Engine — Motor Central de Regras
|
||||
// Intercepta chamadas às rotas SOE antes de despachar ao motor (Plus/ERPNext/local)
|
||||
// e aplica regras configuráveis de negócio, fiscal, contábil e financeiro.
|
||||
|
||||
import { db } from "../../db/index";
|
||||
import { soeRegras, soeEventos } from "@shared/schema";
|
||||
import { eq, and, isNull, or } from "drizzle-orm";
|
||||
import type { SoeRule, RuleContext, EnrichedPayload, SoeRuleCondition } from "./types";
|
||||
import { FISCAL_RULES_PADRAO } from "./fiscal-rules";
|
||||
import { BUSINESS_RULES_PADRAO } from "./business-rules";
|
||||
|
||||
const DEFAULT_RULES: SoeRule[] = [...FISCAL_RULES_PADRAO, ...BUSINESS_RULES_PADRAO];
|
||||
|
||||
export class SoeRuleEngine {
|
||||
private static instance: SoeRuleEngine;
|
||||
|
||||
static getInstance(): SoeRuleEngine {
|
||||
if (!SoeRuleEngine.instance) SoeRuleEngine.instance = new SoeRuleEngine();
|
||||
return SoeRuleEngine.instance;
|
||||
}
|
||||
|
||||
// Aplica todas as regras ativas para um trigger + contexto
|
||||
async apply(
|
||||
trigger: string,
|
||||
payload: Record<string, any>,
|
||||
context: RuleContext
|
||||
): Promise<EnrichedPayload> {
|
||||
const start = Date.now();
|
||||
const appliedRules: string[] = [];
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
try {
|
||||
// Carrega regras do banco (custom do tenant) + defaults hardcoded
|
||||
const rules = await this.getActiveRules(trigger, context);
|
||||
|
||||
// Enriquece payload com contexto (empresa, regime tributário)
|
||||
let enriched = this.enrichWithContext(payload, context);
|
||||
|
||||
// Aplica cada regra em ordem de prioridade
|
||||
for (const rule of rules.sort((a, b) => a.prioridade - b.prioridade)) {
|
||||
if (!this.avaliarCondicao(rule.condicao, enriched, context)) continue;
|
||||
|
||||
const result = this.aplicarAcao(rule.acao, enriched);
|
||||
if (result.validationError) {
|
||||
validationErrors.push(`[${rule.id}] ${result.validationError}`);
|
||||
} else {
|
||||
enriched = result.payload;
|
||||
appliedRules.push(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Registra evento no banco (async, não bloqueia a response)
|
||||
this.logEvent(trigger, payload, enriched, appliedRules, context, Date.now() - start).catch(
|
||||
(e) => console.error("[SoeRuleEngine] log error:", e.message)
|
||||
);
|
||||
|
||||
return { payload: enriched, appliedRules, validationErrors };
|
||||
} catch (err: any) {
|
||||
console.error("[SoeRuleEngine] apply error:", err.message);
|
||||
return { payload, appliedRules, validationErrors };
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveRules(trigger: string, context: RuleContext): Promise<SoeRule[]> {
|
||||
// Regras do banco (custom do tenant)
|
||||
let dbRules: SoeRule[] = [];
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(soeRegras)
|
||||
.where(
|
||||
and(
|
||||
eq(soeRegras.trigger, trigger),
|
||||
eq(soeRegras.ativo, true),
|
||||
or(
|
||||
isNull(soeRegras.tenantId),
|
||||
eq(soeRegras.tenantId, context.tenantId ?? 0)
|
||||
)
|
||||
)
|
||||
);
|
||||
dbRules = rows.map((r) => ({
|
||||
id: r.id,
|
||||
dominio: r.dominio as any,
|
||||
trigger: r.trigger,
|
||||
nome: r.nome,
|
||||
condicao: r.condicao as any,
|
||||
acao: r.acao as any,
|
||||
prioridade: r.prioridade ?? 10,
|
||||
ativo: r.ativo ?? true,
|
||||
}));
|
||||
} catch {
|
||||
// Banco pode não ter a tabela ainda (antes de migration)
|
||||
}
|
||||
|
||||
// Regras padrão hardcoded (filtradas pelo trigger)
|
||||
const defaults = DEFAULT_RULES.filter((r) => r.trigger === trigger && r.ativo);
|
||||
|
||||
// Merge: regras do banco sobrescrevem defaults com mesmo ID
|
||||
const merged = new Map<string, SoeRule>();
|
||||
for (const r of defaults) merged.set(r.id, r);
|
||||
for (const r of dbRules) merged.set(r.id, r);
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
private enrichWithContext(
|
||||
payload: Record<string, any>,
|
||||
context: RuleContext
|
||||
): Record<string, any> {
|
||||
return {
|
||||
...payload,
|
||||
_contexto: {
|
||||
tenantId: context.tenantId,
|
||||
empresaId: context.empresaId,
|
||||
motor: context.motor,
|
||||
regimeTributario: context.empresa?.regimeTributario,
|
||||
uf: context.empresa?.uf,
|
||||
},
|
||||
// Atalhos para condições
|
||||
regimeTributario: context.empresa?.regimeTributario ?? payload.regimeTributario,
|
||||
};
|
||||
}
|
||||
|
||||
private avaliarCondicao(
|
||||
condicao: SoeRuleCondition,
|
||||
payload: Record<string, any>,
|
||||
_context: RuleContext
|
||||
): boolean {
|
||||
if (!condicao || Object.keys(condicao).length === 0) return true;
|
||||
|
||||
for (const [key, expected] of Object.entries(condicao)) {
|
||||
// Condições internas (_xxx)
|
||||
if (key === "_cfopNaoDefinido") {
|
||||
if (expected && payload.cfop) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === "_operacao") {
|
||||
// Infere operação pelo tipo (simplificado)
|
||||
if (expected === "venda" && payload._tipo !== "venda") return false;
|
||||
continue;
|
||||
}
|
||||
if (key === "_interestadual") {
|
||||
const interestadual = payload.ufDestinatario && payload.ufEmitente &&
|
||||
payload.ufDestinatario !== payload.ufEmitente;
|
||||
if (expected !== interestadual) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const actual = this.getNestedValue(payload, key);
|
||||
|
||||
if (Array.isArray(expected)) {
|
||||
if (!expected.includes(actual)) return false;
|
||||
} else if (typeof expected === "object" && expected !== null) {
|
||||
if ("_lt" in expected && !(actual < expected._lt)) return false;
|
||||
if ("_gt" in expected && !(actual > expected._gt)) return false;
|
||||
if ("_eq" in expected && actual !== expected._eq) return false;
|
||||
} else {
|
||||
if (actual !== expected) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private aplicarAcao(
|
||||
acao: any,
|
||||
payload: Record<string, any>
|
||||
): { payload: Record<string, any>; validationError?: string } {
|
||||
let result = { ...payload };
|
||||
|
||||
if (acao.set) {
|
||||
for (const [path, value] of Object.entries(acao.set)) {
|
||||
result = this.setNestedValue(result, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (acao.validar) {
|
||||
const { campo, obrigatorio, formato } = acao.validar;
|
||||
const valor = this.getNestedValue(payload, campo);
|
||||
if (obrigatorio && !valor) {
|
||||
return { payload, validationError: `Campo obrigatório ausente: ${campo}` };
|
||||
}
|
||||
if (formato && valor) {
|
||||
const re = typeof formato === "string" ? new RegExp(formato) : formato;
|
||||
if (!re.test(String(valor))) {
|
||||
return { payload, validationError: `Campo ${campo} com formato inválido: ${valor}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { payload: result };
|
||||
}
|
||||
|
||||
private getNestedValue(obj: Record<string, any>, path: string): any {
|
||||
return path.split(".").reduce((curr, key) => curr?.[key], obj);
|
||||
}
|
||||
|
||||
private setNestedValue(
|
||||
obj: Record<string, any>,
|
||||
path: string,
|
||||
value: any
|
||||
): Record<string, any> {
|
||||
const result = { ...obj };
|
||||
const keys = path.split(".");
|
||||
let curr: any = result;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!curr[keys[i]]) curr[keys[i]] = {};
|
||||
curr = curr[keys[i]];
|
||||
}
|
||||
curr[keys[keys.length - 1]] = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async logEvent(
|
||||
trigger: string,
|
||||
payloadEntrada: any,
|
||||
payloadSaida: any,
|
||||
regraIds: string[],
|
||||
context: RuleContext,
|
||||
duracaoMs: number
|
||||
) {
|
||||
try {
|
||||
await db.insert(soeEventos).values({
|
||||
tenantId: context.tenantId,
|
||||
empresaId: context.empresaId,
|
||||
evento: trigger,
|
||||
motorOrigem: context.motor,
|
||||
regraIds,
|
||||
payloadEntrada,
|
||||
payloadSaida,
|
||||
status: "ok",
|
||||
duracaoMs,
|
||||
});
|
||||
} catch {
|
||||
// Silencia erro de log — não deve quebrar o fluxo principal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton exportado
|
||||
export const soeRuleEngine = SoeRuleEngine.getInstance();
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// SOE Rule Engine — Types
|
||||
|
||||
export interface SoeRuleCondition {
|
||||
// Comparações simples: { campo: valor } ou { campo: { op: valor } }
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SoeRuleAction {
|
||||
// set: sobrescreve campos no payload
|
||||
set?: Record<string, any>;
|
||||
// validar: valida campos obrigatórios/formato
|
||||
validar?: { campo: string; obrigatorio?: boolean; formato?: RegExp | string };
|
||||
// addItem: adiciona item à lista
|
||||
addItem?: { campo: string; valor: any };
|
||||
// calcular: define um campo como resultado de expressão
|
||||
calcular?: { campo: string; expressao: string };
|
||||
}
|
||||
|
||||
export interface SoeRule {
|
||||
id: string;
|
||||
dominio: "fiscal" | "business" | "accounting" | "financial";
|
||||
trigger: string;
|
||||
nome: string;
|
||||
condicao: SoeRuleCondition;
|
||||
acao: SoeRuleAction;
|
||||
prioridade: number;
|
||||
ativo: boolean;
|
||||
origemPadrao?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleContext {
|
||||
tenantId?: number;
|
||||
empresaId?: number;
|
||||
userId?: string;
|
||||
motor?: "plus" | "erpnext" | "local";
|
||||
empresa?: {
|
||||
regimeTributario?: "simples_nacional" | "lucro_presumido" | "lucro_real";
|
||||
uf?: string;
|
||||
cnpj?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnrichedPayload {
|
||||
payload: Record<string, any>;
|
||||
appliedRules: string[];
|
||||
validationErrors: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Express, Response } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||
const SUPERSET_TIMEOUT = 60000;
|
||||
|
||||
export function setupSupersetProxy(app: Express): void {
|
||||
const target = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
||||
|
||||
const supersetProxy = createProxyMiddleware({
|
||||
target,
|
||||
changeOrigin: true,
|
||||
timeout: SUPERSET_TIMEOUT,
|
||||
proxyTimeout: SUPERSET_TIMEOUT,
|
||||
pathRewrite: { "^/superset": "" },
|
||||
on: {
|
||||
error: (err, _req, res) => {
|
||||
console.error("[Superset Proxy] Error:", err.message);
|
||||
if (res && typeof (res as Response).status === "function") {
|
||||
(res as Response).status(502).json({
|
||||
error: "Superset indisponível",
|
||||
message: "O Arcádia Insights está iniciando. Tente novamente em alguns segundos.",
|
||||
target,
|
||||
});
|
||||
}
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
const location = proxyRes.headers["location"];
|
||||
if (location && typeof location === "string" && location.startsWith("/")) {
|
||||
proxyRes.headers["location"] = `/superset${location}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.use("/superset", supersetProxy);
|
||||
console.log(`[Superset Proxy] Configurado -> /superset/* => ${target}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import type { Express, Request, Response } from "express";
|
||||
|
||||
const SUPERSET_HOST = process.env.SUPERSET_HOST || "localhost";
|
||||
const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10);
|
||||
const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`;
|
||||
const ADMIN_USER = process.env.SUPERSET_ADMIN_USER || "admin";
|
||||
const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia2026";
|
||||
|
||||
// Cache do service token (válido por 50 min)
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function getServiceToken(): Promise<string> {
|
||||
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
|
||||
|
||||
const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS, provider: "db", refresh: true }),
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error(`Falha ao autenticar no Superset (${resp.status})`);
|
||||
const data = await resp.json();
|
||||
cachedToken = data.access_token;
|
||||
tokenExpiry = Date.now() + 50 * 60 * 1000;
|
||||
return cachedToken!;
|
||||
}
|
||||
|
||||
export function registerSupersetRoutes(app: Express): void {
|
||||
|
||||
// Gera Guest Token para embedding em qualquer tela
|
||||
app.post("/api/superset/guest-token", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
const { dashboardId } = req.body;
|
||||
if (!dashboardId) return res.status(400).json({ error: "dashboardId é obrigatório" });
|
||||
|
||||
const token = await getServiceToken();
|
||||
const user = req.user as any;
|
||||
|
||||
const guestResp = await fetch(`${SUPERSET_URL}/api/v1/security/guest_token/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: {
|
||||
username: `arcadia_${user?.id || "guest"}`,
|
||||
first_name: user?.name?.split(" ")[0] || "Arcádia",
|
||||
last_name: user?.name?.split(" ").slice(1).join(" ") || "User",
|
||||
},
|
||||
resources: [{ type: "dashboard", id: dashboardId }],
|
||||
rls: [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!guestResp.ok) {
|
||||
const errText = await guestResp.text();
|
||||
return res.status(502).json({ error: "Falha ao gerar guest token", detail: errText });
|
||||
}
|
||||
|
||||
const { token: guestToken } = await guestResp.json();
|
||||
res.json({ token: guestToken, supersetUrl: "/superset" });
|
||||
} catch (err: any) {
|
||||
console.error("[Superset] guest-token error:", err.message);
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Lista dashboards disponíveis (para seletor na UI)
|
||||
app.get("/api/superset/dashboards", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
const token = await getServiceToken();
|
||||
const resp = await fetch(
|
||||
`${SUPERSET_URL}/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)`,
|
||||
{ headers: { "Authorization": `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
if (!resp.ok) return res.status(502).json({ error: "Superset indisponível", dashboards: [] });
|
||||
const data = await resp.json();
|
||||
res.json(data.result || []);
|
||||
} catch (err: any) {
|
||||
console.error("[Superset] dashboards error:", err.message);
|
||||
res.status(502).json({ error: err.message, dashboards: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get("/api/superset/health", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const resp = await fetch(`${SUPERSET_URL}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
const text = await resp.text();
|
||||
res.json({ online: resp.ok && text === "OK", url: SUPERSET_URL, status: text });
|
||||
} catch {
|
||||
res.json({ online: false, url: SUPERSET_URL });
|
||||
}
|
||||
});
|
||||
|
||||
// Autologin (compatibilidade — redireciona para /superset com sessão)
|
||||
app.get("/api/bi/superset/autologin", async (req: Request, res: Response) => {
|
||||
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
|
||||
res.redirect("/superset/login");
|
||||
});
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ interface AutoReplyConfig {
|
|||
outsideHoursMessage: string;
|
||||
aiEnabled: boolean;
|
||||
maxAutoRepliesPerContact: number;
|
||||
/** Optional: link to an XOS queue for schedule/out-of-hours config */
|
||||
xosQueueId?: number;
|
||||
}
|
||||
|
||||
interface WhatsAppSession {
|
||||
|
|
@ -130,6 +132,31 @@ Nome do cliente: ${contactName}`;
|
|||
}
|
||||
}
|
||||
|
||||
/** Check XOS queue schedule. Returns { isOpen, outOfHoursMessage }. */
|
||||
private async checkXosQueueIsOpen(queueId: number): Promise<{ isOpen: boolean; outOfHoursMessage: string | null }> {
|
||||
try {
|
||||
const result = await db.execute(sql`
|
||||
SELECT schedules, out_of_hours_message FROM xos_queues WHERE id = ${queueId}
|
||||
`);
|
||||
const queue = ((result as any).rows ?? [])[0];
|
||||
if (!queue) return { isOpen: true, outOfHoursMessage: null };
|
||||
|
||||
const schedules: Array<{ dayOfWeek: number; startTime: string; endTime: string; enabled: boolean }> = queue.schedules || [];
|
||||
if (!schedules.length) return { isOpen: true, outOfHoursMessage: null };
|
||||
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay(); // 0=Sun, 6=Sat
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
const todaySchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.enabled !== false);
|
||||
|
||||
if (!todaySchedule) return { isOpen: false, outOfHoursMessage: queue.out_of_hours_message };
|
||||
const isOpen = currentTime >= todaySchedule.startTime && currentTime < todaySchedule.endTime;
|
||||
return { isOpen, outOfHoursMessage: isOpen ? null : queue.out_of_hours_message };
|
||||
} catch {
|
||||
return { isOpen: true, outOfHoursMessage: null }; // fail-open: don't block messages if DB query fails
|
||||
}
|
||||
}
|
||||
|
||||
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
|
||||
try {
|
||||
const config = this.getAutoReplyConfig(msg.userId);
|
||||
|
|
@ -137,18 +164,29 @@ Nome do cliente: ${contactName}`;
|
|||
|
||||
const contactKey = `${msg.userId}_${contact.id}`;
|
||||
const currentCount = this.autoReplyCount.get(contactKey) || 0;
|
||||
|
||||
|
||||
if (currentCount >= config.maxAutoRepliesPerContact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHour = new Date().getHours();
|
||||
const isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
|
||||
// Check business hours — prefer XOS queue schedule when configured
|
||||
let isBusinessHours: boolean;
|
||||
let outsideHoursReply: string;
|
||||
|
||||
if (config.xosQueueId) {
|
||||
const { isOpen, outOfHoursMessage } = await this.checkXosQueueIsOpen(config.xosQueueId);
|
||||
isBusinessHours = isOpen;
|
||||
outsideHoursReply = outOfHoursMessage || config.outsideHoursMessage;
|
||||
} else {
|
||||
const currentHour = new Date().getHours();
|
||||
isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
|
||||
outsideHoursReply = config.outsideHoursMessage;
|
||||
}
|
||||
|
||||
let replyText: string;
|
||||
|
||||
if (!isBusinessHours) {
|
||||
replyText = config.outsideHoursMessage;
|
||||
replyText = outsideHoursReply;
|
||||
} else if (currentCount === 0) {
|
||||
replyText = config.welcomeMessage;
|
||||
} else if (config.aiEnabled) {
|
||||
|
|
|
|||
1098
server/xos/routes.ts
1098
server/xos/routes.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* XOS Scheduler
|
||||
* - Every 5 min: check SLA breaches and emit events
|
||||
* - Every 30 sec: broadcast live supervisor stats via Socket.IO
|
||||
*/
|
||||
import { db } from "../db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { broadcastXos } from "../socket-io";
|
||||
|
||||
const SLA_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const STATS_BROADCAST_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
/** Mark overdue protocols as SLA-breached and emit event per tenant */
|
||||
async function checkSlaBreaches() {
|
||||
try {
|
||||
// Mark protocols where sla_deadline has passed and still open/unbreached
|
||||
const breached = await db.execute(sql`
|
||||
UPDATE xos_protocols
|
||||
SET sla_breach = true, updated_at = NOW()
|
||||
WHERE status = 'open'
|
||||
AND sla_deadline IS NOT NULL
|
||||
AND sla_deadline < NOW()
|
||||
AND sla_breach = false
|
||||
RETURNING id, tenant_id, protocol_number, contact_id, queue_id, assigned_to
|
||||
`);
|
||||
|
||||
const rows = (breached as any).rows ?? [];
|
||||
for (const row of rows) {
|
||||
broadcastXos("sla.breach", {
|
||||
protocolId: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
protocolNumber: row.protocol_number,
|
||||
contactId: row.contact_id,
|
||||
queueId: row.queue_id,
|
||||
assignedTo: row.assigned_to,
|
||||
breachedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Forward to automation engine (non-blocking)
|
||||
try {
|
||||
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
|
||||
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
|
||||
await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event_type: "crm.sla.breached",
|
||||
payload: { protocol_id: row.id, protocol_number: row.protocol_number },
|
||||
tenant_id: row.tenant_id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Non-blocking — engine may be offline
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
console.log(`[xos:scheduler] SLA check: ${rows.length} breach(es) detected`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[xos:scheduler] SLA check error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast aggregate supervisor stats to all connected clients */
|
||||
async function broadcastSupervisorStats() {
|
||||
try {
|
||||
const [convsResult, ticketsResult, agentsResult] = await Promise.all([
|
||||
db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open_conversations,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved' AND updated_at > NOW() - INTERVAL '24h') AS resolved_today
|
||||
FROM xos_conversations
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('open','in_progress') AND priority = 'urgent') AS urgent_open
|
||||
FROM xos_tickets
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) FILTER (WHERE is_online = true) AS agents_online
|
||||
FROM xos_agents
|
||||
`),
|
||||
]);
|
||||
|
||||
const c = ((convsResult as any).rows ?? [])[0] ?? {};
|
||||
const t = ((ticketsResult as any).rows ?? [])[0] ?? {};
|
||||
const a = ((agentsResult as any).rows ?? [])[0] ?? {};
|
||||
|
||||
broadcastXos("supervisor.stats", {
|
||||
openConversations: Number(c.open_conversations ?? 0),
|
||||
resolvedToday: Number(c.resolved_today ?? 0),
|
||||
urgentTickets: Number(t.urgent_open ?? 0),
|
||||
agentsOnline: Number(a.agents_online ?? 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
// Suppress — tables may not exist in dev
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.debug("[xos:scheduler] stats broadcast skipped:", (err as any)?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startXosScheduler() {
|
||||
// Stagger initial runs slightly to avoid startup congestion
|
||||
setTimeout(() => {
|
||||
checkSlaBreaches();
|
||||
setInterval(checkSlaBreaches, SLA_CHECK_INTERVAL_MS);
|
||||
}, 15_000); // first SLA check 15s after boot
|
||||
|
||||
setTimeout(() => {
|
||||
broadcastSupervisorStats();
|
||||
setInterval(broadcastSupervisorStats, STATS_BROADCAST_INTERVAL_MS);
|
||||
}, 5_000); // first broadcast 5s after boot
|
||||
|
||||
console.log("[xos:scheduler] Started — SLA checks every 5min, stats every 30s");
|
||||
}
|
||||
114
shared/schema.ts
114
shared/schema.ts
|
|
@ -7099,6 +7099,53 @@ export const insertAgentMetricsSchema = createInsertSchema(xosAgentMetrics).omit
|
|||
export type XosAgentMetric = typeof xosAgentMetrics.$inferSelect;
|
||||
export type InsertXosAgentMetric = z.infer<typeof insertAgentMetricsSchema>;
|
||||
|
||||
// ── Protocolos de Atendimento ────────────────────────────────────────────────
|
||||
export const xosProtocols = pgTable("xos_protocols", {
|
||||
id: serial("id").primaryKey(),
|
||||
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
|
||||
protocolNumber: varchar("protocol_number", { length: 30 }).notNull().unique(),
|
||||
conversationId: integer("conversation_id").references(() => xosConversations.id, { onDelete: "set null" }),
|
||||
ticketId: integer("ticket_id").references(() => xosTickets.id, { onDelete: "set null" }),
|
||||
contactId: integer("contact_id").references(() => xosContacts.id, { onDelete: "set null" }),
|
||||
queueId: integer("queue_id").references(() => xosQueues.id, { onDelete: "set null" }),
|
||||
assignedTo: varchar("assigned_to").references(() => users.id, { onDelete: "set null" }),
|
||||
subject: text("subject"),
|
||||
status: varchar("status", { length: 30 }).default("open"), // open, resolved, cancelled
|
||||
openedAt: timestamp("opened_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
resolvedAt: timestamp("resolved_at"),
|
||||
slaDeadline: timestamp("sla_deadline"),
|
||||
slaBreach: boolean("sla_breach").default(false),
|
||||
satisfactionScore: integer("satisfaction_score"), // 1-5
|
||||
satisfactionComment: text("satisfaction_comment"),
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const insertXosProtocolSchema = createInsertSchema(xosProtocols).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type XosProtocol = typeof xosProtocols.$inferSelect;
|
||||
export type InsertXosProtocol = z.infer<typeof insertXosProtocolSchema>;
|
||||
|
||||
// ── Políticas de SLA ─────────────────────────────────────────────────────────
|
||||
export const xosSlaPolicies = pgTable("xos_sla_policies", {
|
||||
id: serial("id").primaryKey(),
|
||||
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
|
||||
queueId: integer("queue_id").references(() => xosQueues.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
priority: varchar("priority", { length: 20 }).default("normal"), // low, normal, high, urgent
|
||||
firstResponseMinutes: integer("first_response_minutes").default(60),
|
||||
resolutionMinutes: integer("resolution_minutes").default(480),
|
||||
escalationAgentId: varchar("escalation_agent_id").references(() => users.id, { onDelete: "set null" }),
|
||||
notifyOnBreach: boolean("notify_on_breach").default(true),
|
||||
isActive: boolean("is_active").default(true),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect;
|
||||
export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>;
|
||||
|
||||
export const xosDevPipelines = pgTable("xos_dev_pipelines", {
|
||||
id: serial("id").primaryKey(),
|
||||
correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`),
|
||||
|
|
@ -7315,3 +7362,70 @@ export const commEvents = pgTable("comm_events", {
|
|||
processedByAgents: boolean("processed_by_agents").default(false), // consumed by agents?
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SOE — Sistema Operacional Empresarial (Rule Engine + Event Bus)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Regras do SOE — configuráveis por tenant/empresa
|
||||
export const soeRegras = pgTable("soe_regras", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
|
||||
empresaId: integer("empresa_id"),
|
||||
dominio: varchar("dominio", { length: 50 }).notNull(), // 'fiscal' | 'business' | 'accounting' | 'financial'
|
||||
trigger: varchar("trigger", { length: 100 }).notNull(), // 'pre_sales_order' | 'pre_nfe_emissao' | etc.
|
||||
nome: varchar("nome", { length: 200 }).notNull(),
|
||||
condicao: jsonb("condicao").notNull().$type<Record<string, any>>(),
|
||||
acao: jsonb("acao").notNull().$type<Record<string, any>>(),
|
||||
prioridade: integer("prioridade").default(10),
|
||||
ativo: boolean("ativo").default(true),
|
||||
origemPadrao: boolean("origem_padrao").default(false), // true = regra padrão do sistema
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
// Log de todos os eventos SOE (audit trail completo)
|
||||
export const soeEventos = pgTable("soe_eventos", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: integer("tenant_id").references(() => tenants.id),
|
||||
empresaId: integer("empresa_id"),
|
||||
evento: varchar("evento", { length: 100 }).notNull(), // 'nfe_emitida' | 'venda_confirmada' | etc.
|
||||
motorOrigem: varchar("motor_origem", { length: 50 }), // 'plus' | 'erpnext' | 'local'
|
||||
regraIds: jsonb("regra_ids").$type<string[]>(), // IDs das regras aplicadas
|
||||
payloadEntrada: jsonb("payload_entrada").$type<Record<string, any>>(),
|
||||
payloadSaida: jsonb("payload_saida").$type<Record<string, any>>(),
|
||||
status: varchar("status", { length: 50 }).notNull().default("ok"), // 'ok' | 'error' | 'skipped'
|
||||
duracaoMs: integer("duracao_ms"),
|
||||
erro: text("erro"),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
// Lançamentos contábeis automáticos gerados pelo SOE Event Bus
|
||||
export const soeLancamentos = pgTable("soe_lancamentos", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: integer("tenant_id").references(() => tenants.id),
|
||||
empresaId: integer("empresa_id").notNull(),
|
||||
data: date("data").notNull(),
|
||||
contaDebito: varchar("conta_debito", { length: 30 }).notNull(),
|
||||
contaCredito: varchar("conta_credito", { length: 30 }).notNull(),
|
||||
valor: numeric("valor", { precision: 15, scale: 2 }).notNull(),
|
||||
historico: text("historico").notNull(),
|
||||
origemEvento: varchar("origem_evento", { length: 100 }), // 'nfe_emitida' | 'pagamento_realizado' | etc.
|
||||
origemEventoId: uuid("origem_evento_id").references(() => soeEventos.id),
|
||||
periodo: varchar("periodo", { length: 7 }), // '2026-03'
|
||||
enviado8003: boolean("enviado_8003").default(false), // sincronizado com Motor Contábil Python
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
// Insert schemas
|
||||
export const insertSoeRegraSchema = createInsertSchema(soeRegras);
|
||||
export const insertSoeEventoSchema = createInsertSchema(soeEventos);
|
||||
export const insertSoeLancamentoSchema = createInsertSchema(soeLancamentos);
|
||||
|
||||
// Types
|
||||
export type SoeRegra = typeof soeRegras.$inferSelect;
|
||||
export type InsertSoeRegra = z.infer<typeof insertSoeRegraSchema>;
|
||||
export type SoeEvento = typeof soeEventos.$inferSelect;
|
||||
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
|
||||
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
|
||||
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue