arcadiasuite/server/soe/erpnext-rule-importer.ts

300 lines
8.7 KiB
TypeScript

// 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);
}