diff --git a/.env.example b/.env.example index bcc19a1..359d87e 100644 --- a/.env.example +++ b/.env.example @@ -84,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= diff --git a/docker-compose.yml b/docker-compose.yml index e22222f..7b7f641 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -247,11 +247,67 @@ 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: diff --git a/docker/erpnext/init.sh b/docker/erpnext/init.sh new file mode 100644 index 0000000..d845f45 --- /dev/null +++ b/docker/erpnext/init.sh @@ -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}" diff --git a/server/erpnext/proxy.ts b/server/erpnext/proxy.ts new file mode 100644 index 0000000..552b487 --- /dev/null +++ b/server/erpnext/proxy.ts @@ -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}`); +} diff --git a/server/routes.ts b/server/routes.ts index d8b0a73..dc96902 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -49,6 +49,8 @@ import governanceRoutes from "./governance/routes"; import { setupPlusProxy } from "./plus/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 plusSsoRoutes from "./plus/sso"; import migrationRoutes from "./migration/routes"; @@ -75,6 +77,9 @@ export async function registerRoutes( // 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); @@ -112,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); diff --git a/server/soe/erpnext-rule-importer.ts b/server/soe/erpnext-rule-importer.ts new file mode 100644 index 0000000..fa10800 --- /dev/null +++ b/server/soe/erpnext-rule-importer.ts @@ -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 = { + "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 { + 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 { + 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 { + 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 = { + 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); +}