feat: ERPNext container + SOE Rule Importer (cherry-pick eb09267)
- docker-compose.yml: perfil erpnext (MariaDB + ERPNext v15) - docker/erpnext/init.sh - server/erpnext/proxy.ts - server/soe/erpnext-rule-importer.ts - server/routes.ts: ERPNext proxy + SOE import endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
75ef36eac9
commit
37a9fbe801
16
.env.example
16
.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=
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,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}`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue