241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
// 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();
|