V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo
- src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/ SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente (secao 15 do ciclo final: matriz completa de guarda de seguranca). computeMessageHash/buildSendKey/isDuplicate implementam a chave logica de idempotencia da secao 13 (report_type+periodo+destino+hash). - src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION. - src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/ motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType (.mode) e sendLogRows (Data Table de log de envios). - tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios', troca do HTTP Slack com webhook placeholder por node Slack nativo (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao Coordenador GLPI), branches de sucesso/falha gravando o log de envio so apos confirmacao real do Slack. - test/sendGuard.test.js, test/workflowStructure.test.js (novos): cobrem a matriz de guarda de seguranca e travam regressao do bug de timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug de sendGuard.js nao concatenado no Code node gerado. - Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava na lista de modulos concatenados pelo gerador, deixando canSend/ computeMessageHash/buildSendKey undefined em runtime. - Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6): n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado nos 9 nodes de leitura em lote. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bdfa675d89
commit
64ddfb9b7b
File diff suppressed because one or more lines are too long
@@ -42,6 +42,21 @@ const DEFAULT_CONFIG = {
|
|||||||
DEV_MODE: true,
|
DEV_MODE: true,
|
||||||
SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)
|
SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)
|
||||||
|
|
||||||
|
// --- Guarda de envio real (secoes 12/13/15 do ciclo final) ---
|
||||||
|
// Mesmo vocabulario do projeto irmao "[DEV] Coordenador GLPI - Chamados
|
||||||
|
// Abertos v2 (Shadow)", para nao inventar um padrao novo no ambiente.
|
||||||
|
// SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real
|
||||||
|
// (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL —
|
||||||
|
// destrava o "manual nao envia por padrao"; nunca sobrepoe SHADOW_MODE,
|
||||||
|
// DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend.
|
||||||
|
SEND_ENABLED: true,
|
||||||
|
FORCE_SEND: false,
|
||||||
|
// Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso
|
||||||
|
// no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o
|
||||||
|
// usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser
|
||||||
|
// adivinhado: enviar para o canal errado e uma acao irreversivel).
|
||||||
|
SLACK_DESTINATION: '',
|
||||||
|
|
||||||
// --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---
|
// --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---
|
||||||
// Desligado por padrao ate o schema real do GLPI DEV ser validado com
|
// Desligado por padrao ate o schema real do GLPI DEV ser validado com
|
||||||
// DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).
|
// DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
* previousPendentes -> pendentes do periodo comparavel anterior (ou null)
|
* previousPendentes -> pendentes do periodo comparavel anterior (ou null)
|
||||||
* altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)
|
* altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)
|
||||||
* todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria
|
* todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria
|
||||||
|
* triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)
|
||||||
|
* sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)
|
||||||
*
|
*
|
||||||
* aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).
|
* aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).
|
||||||
* aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n
|
* aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n
|
||||||
@@ -44,6 +46,7 @@ const {
|
|||||||
} = require('./insights');
|
} = require('./insights');
|
||||||
const { buildTicketContext, parseAiResponse } = require('./aiSchema');
|
const { buildTicketContext, parseAiResponse } = require('./aiSchema');
|
||||||
const { formatFallbackMessage, formatShortMessage } = require('./format');
|
const { formatFallbackMessage, formatShortMessage } = require('./format');
|
||||||
|
const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard');
|
||||||
|
|
||||||
function computeEvidenceHash(evidenceTicketIds) {
|
function computeEvidenceHash(evidenceTicketIds) {
|
||||||
const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);
|
const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);
|
||||||
@@ -205,6 +208,35 @@ function buildReport(input, config) {
|
|||||||
config
|
config
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---
|
||||||
|
// triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —
|
||||||
|
// fornecidos pelo Code node do n8n a partir de $execution.mode e da
|
||||||
|
// Data Table de log de envios. Ver src/v2/sendGuard.js.
|
||||||
|
const messageHash = computeMessageHash(mensagemV2);
|
||||||
|
const sendKey = buildSendKey({
|
||||||
|
reportType: period.history_bucket || period.tipo_periodo,
|
||||||
|
periodStart: period.data_inicio,
|
||||||
|
periodEnd: period.data_fim,
|
||||||
|
destination: config.SLACK_DESTINATION || 'destino_nao_configurado',
|
||||||
|
messageHash,
|
||||||
|
});
|
||||||
|
const guarda = canSend({ triggerType: input.triggerType, config });
|
||||||
|
const jaEnviado = (input.sendLogRows || []).some(
|
||||||
|
(r) => r && r.send_key === sendKey && r.status === 'success'
|
||||||
|
);
|
||||||
|
const envio = {
|
||||||
|
trigger_type: input.triggerType || 'desconhecido',
|
||||||
|
report_type: period.history_bucket || period.tipo_periodo,
|
||||||
|
period_start: period.data_inicio,
|
||||||
|
period_end: period.data_fim,
|
||||||
|
destino: config.SLACK_DESTINATION || null,
|
||||||
|
send_key: sendKey,
|
||||||
|
message_hash: messageHash,
|
||||||
|
pode_enviar: guarda.podeEnviar && !jaEnviado,
|
||||||
|
motivo_guarda: guarda.motivo,
|
||||||
|
duplicata_bloqueada: guarda.podeEnviar && jaEnviado,
|
||||||
|
};
|
||||||
|
|
||||||
const observabilidade = {
|
const observabilidade = {
|
||||||
periodo: period.periodo_label,
|
periodo: period.periodo_label,
|
||||||
tipo_periodo: period.tipo_periodo,
|
tipo_periodo: period.tipo_periodo,
|
||||||
@@ -238,6 +270,7 @@ function buildReport(input, config) {
|
|||||||
insights_suprimidos: suppressed,
|
insights_suprimidos: suppressed,
|
||||||
memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],
|
memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],
|
||||||
anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },
|
anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },
|
||||||
|
envio,
|
||||||
observabilidade,
|
observabilidade,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da
|
||||||
|
* missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)
|
||||||
|
* ja em uso no projeto irmao "[DEV] Coordenador GLPI - Chamados Abertos
|
||||||
|
* v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no
|
||||||
|
* ambiente em vez de inventar um padrao novo.
|
||||||
|
*
|
||||||
|
* Regra de decisao (todas as condicoes descritas na missao reduzidas a uma
|
||||||
|
* formula unica, testada exaustivamente em sendGuard.test.js):
|
||||||
|
*
|
||||||
|
* podeEnviar =
|
||||||
|
* SHADOW_MODE === false (absoluto — nada sobrepoe)
|
||||||
|
* && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este
|
||||||
|
* workflow viver em DEV, NUNCA envia)
|
||||||
|
* && SEND_ENABLED === true (absoluto)
|
||||||
|
* && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)
|
||||||
|
*
|
||||||
|
* FORCE_SEND so tem efeito na execucao MANUAL (destrava o "manual nao
|
||||||
|
* envia por padrao"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function canSend({ triggerType, config }) {
|
||||||
|
const isScheduled = triggerType === 'scheduled';
|
||||||
|
|
||||||
|
if (config.SHADOW_MODE === true) {
|
||||||
|
return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };
|
||||||
|
}
|
||||||
|
if (config.DEV_MODE === true) {
|
||||||
|
return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };
|
||||||
|
}
|
||||||
|
if (config.SEND_ENABLED !== true) {
|
||||||
|
return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };
|
||||||
|
}
|
||||||
|
if (!isScheduled && config.FORCE_SEND !== true) {
|
||||||
|
return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash estavel (nao criptografico — so precisa ser deterministico e
|
||||||
|
* compacto) do texto da mensagem, para a chave de idempotencia detectar
|
||||||
|
* se o CONTEUDO mudou entre duas tentativas do mesmo periodo.
|
||||||
|
*/
|
||||||
|
function computeMessageHash(texto) {
|
||||||
|
const str = String(texto || '');
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i += 1) {
|
||||||
|
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
// >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chave logica de idempotencia (secao 13): duas execucoes com a MESMA
|
||||||
|
* chave representam o MESMO relatorio logico e nao devem ser enviadas
|
||||||
|
* duas vezes, mesmo com retry/execucao manual.
|
||||||
|
*/
|
||||||
|
function buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) {
|
||||||
|
return [reportType, periodStart, periodEnd, destination, messageHash].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se ja existe um envio com status=success para a mesma chave
|
||||||
|
* logica (report_type + period + destination + message_hash).
|
||||||
|
*/
|
||||||
|
function isDuplicate(sendLogRows, key) {
|
||||||
|
return (sendLogRows || []).some(
|
||||||
|
(row) => row && row.send_key === key && row.status === 'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { canSend, computeMessageHash, buildSendKey, isDuplicate };
|
||||||
@@ -316,3 +316,46 @@ test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita
|
|||||||
// daria media >= 7).
|
// daria media >= 7).
|
||||||
assert.equal(spike.baseline_media, 1.43);
|
assert.equal(spike.baseline_media, 1.43);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) ---
|
||||||
|
|
||||||
|
test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, true);
|
||||||
|
assert.equal(r.envio.duplicata_bloqueada, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, false);
|
||||||
|
assert.match(r.envio.motivo_guarda, /SHADOW_MODE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] });
|
||||||
|
const primeira = buildReport(input, cfg);
|
||||||
|
assert.equal(primeira.envio.pode_enviar, true);
|
||||||
|
|
||||||
|
const segunda = buildReport(
|
||||||
|
baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
assert.equal(segunda.envio.pode_enviar, false);
|
||||||
|
assert.equal(segunda.envio.duplicata_bloqueada, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => {
|
||||||
|
const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(a.envio.send_key, b.envio.send_key);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { canSend, computeMessageHash, buildSendKey, isDuplicate } = require('../src/v2/sendGuard');
|
||||||
|
|
||||||
|
const BASE = { SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false };
|
||||||
|
|
||||||
|
// --- Matriz de guarda de seguranca completa (secao 15 do ciclo final) ---
|
||||||
|
|
||||||
|
test('SHADOW_MODE=true nunca envia, mesmo scheduled com tudo mais ligado', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SHADOW_MODE: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SHADOW_MODE=true nunca envia, mesmo manual com FORCE_SEND=true', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, SHADOW_MODE: true, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DEV_MODE=true sem override nunca envia para producao (scheduled)', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, DEV_MODE: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DEV_MODE=true bloqueia mesmo manual+FORCE_SEND=true (sem override possivel)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, DEV_MODE: true, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEND_ENABLED=false nunca envia (scheduled)', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SEND_ENABLED: false } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEND_ENABLED=false nunca envia (manual + FORCE_SEND=true)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, SEND_ENABLED: false, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled + producao (SEND_ENABLED=true, SHADOW_MODE=false, DEV_MODE=false) pode enviar', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
|
||||||
|
assert.equal(r.podeEnviar, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual + FORCE_SEND=false nunca envia', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: false } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual + FORCE_SEND=true pode enviar (sujeito a idempotencia a parte)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('motivo sempre presente e legivel, mesmo quando pode enviar', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
|
||||||
|
assert.equal(typeof r.motivo, 'string');
|
||||||
|
assert.ok(r.motivo.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Hash / chave / duplicata (secao 13) ---
|
||||||
|
|
||||||
|
test('computeMessageHash e deterministico para o mesmo texto', () => {
|
||||||
|
const a = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
|
||||||
|
const b = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeMessageHash muda quando o texto muda', () => {
|
||||||
|
const a = computeMessageHash('Resumo diario: 11 abertos');
|
||||||
|
const b = computeMessageHash('Resumo diario: 12 abertos');
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => {
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(null));
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(undefined));
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSendKey combina os 5 campos da chave logica de idempotencia', () => {
|
||||||
|
const key = buildSendKey({
|
||||||
|
reportType: 'diario',
|
||||||
|
periodStart: '2026-08-24',
|
||||||
|
periodEnd: '2026-08-24',
|
||||||
|
destination: 'C0BJNBTPFT5',
|
||||||
|
messageHash: 'abc123',
|
||||||
|
});
|
||||||
|
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate detecta uma chave ja registrada com status=success', () => {
|
||||||
|
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
|
||||||
|
const rows = [{ send_key: key, status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, key), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate ignora registros com status diferente de success', () => {
|
||||||
|
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
|
||||||
|
const rows = [{ send_key: key, status: 'failed' }, { send_key: key, status: 'blocked_duplicate' }];
|
||||||
|
assert.equal(isDuplicate(rows, key), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate retorna false quando a lista de logs esta vazia ou ausente', () => {
|
||||||
|
assert.equal(isDuplicate([], 'qualquer'), false);
|
||||||
|
assert.equal(isDuplicate(null, 'qualquer'), false);
|
||||||
|
assert.equal(isDuplicate(undefined, 'qualquer'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate nao confunde chaves de periodos diferentes', () => {
|
||||||
|
const rows = [{ send_key: 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123', status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, 'diario|2026-08-25|2026-08-25|C0BJNBTPFT5|abc123'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste de regressao estrutural (secao 16 do ciclo final).
|
||||||
|
*
|
||||||
|
* Contexto: a causa raiz do timeout que travou "MySQL - Baseline Diario
|
||||||
|
* Cliente" foi a ausencia de `executeOnce: true` nesse (e em outros) node
|
||||||
|
* MySQL/Data Table — sem ele, o n8n reexecuta o node UMA VEZ POR ITEM de
|
||||||
|
* entrada (76.440 execucoes observadas), em vez de uma unica vez. Este
|
||||||
|
* teste garante que essa regressao especifica nao pode voltar a acontecer
|
||||||
|
* silenciosamente: ele le o workflow gerado (o mesmo arquivo que e
|
||||||
|
* reimportado no n8n) e falha se qualquer um dos nodes criticos perder a
|
||||||
|
* flag.
|
||||||
|
*
|
||||||
|
* Roda contra o ARQUIVO GERADO (nao contra tools/build_v2_workflow_assemble.js
|
||||||
|
* diretamente) porque e o arquivo gerado que de fato e importado no n8n —
|
||||||
|
* e o que importa e o resultado final, nao a implementacao do gerador.
|
||||||
|
* Rode `node tools/build_v2_workflow.js` antes de `npm test` sempre que
|
||||||
|
* alterar o assemble script (o proprio README/uso do script ja pede isso).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const WORKFLOW_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||||
|
|
||||||
|
// Os nodes que buscam dados em lote (uma query cobrindo todos os itens) e
|
||||||
|
// que, se rodarem "por item" em vez de uma unica vez, causam o mesmo tipo
|
||||||
|
// de explosao combinatoria que gerou o timeout original.
|
||||||
|
const NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ = [
|
||||||
|
'MySQL - Indicadores do Periodo',
|
||||||
|
'MySQL - Ranking Categorias',
|
||||||
|
'MySQL - Ranking Clientes',
|
||||||
|
'MySQL - Contexto de Chamados',
|
||||||
|
'MySQL - Baseline Diario Categoria',
|
||||||
|
'MySQL - Baseline Diario Cliente',
|
||||||
|
'Data Table - Buscar Memoria de Insights',
|
||||||
|
'Data Table - Buscar Historico Anterior',
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadWorkflow() {
|
||||||
|
if (!fs.existsSync(WORKFLOW_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Workflow gerado nao encontrado em ${WORKFLOW_PATH}. Rode "node tools/build_v2_workflow.js" antes de rodar os testes.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(WORKFLOW_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workflow gerado existe e tem a lista de nodes esperada', () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
assert.ok(Array.isArray(wf.nodes));
|
||||||
|
assert.ok(wf.nodes.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const nodeName of NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ) {
|
||||||
|
test(`node "${nodeName}" mantem executeOnce=true (regressao do timeout de 76.440 execucoes)`, () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const node = wf.nodes.find((n) => n.name === nodeName);
|
||||||
|
assert.ok(node, `node "${nodeName}" nao existe mais no workflow gerado — renomeado? atualize este teste.`);
|
||||||
|
assert.equal(
|
||||||
|
node.executeOnce,
|
||||||
|
true,
|
||||||
|
`node "${nodeName}" perdeu executeOnce=true — isso reintroduz o bug do timeout (76.440 execucoes).`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Code - Motor V2 inclui as funcoes de sendGuard.js (regressao: modulo requerido mas nao concatenado)', () => {
|
||||||
|
// Bug real encontrado e corrigido em 2026-08-25: pipeline.js passou a
|
||||||
|
// `require('./sendGuard')`, mas tools/build_v2_workflow.js remove TODA
|
||||||
|
// linha de require ao montar o Code node (para o n8n nao tentar dar
|
||||||
|
// require de um arquivo que nao existe no ambiente remoto) — sem incluir
|
||||||
|
// sendGuard.js na lista de modulos concatenados, canSend/computeMessageHash/
|
||||||
|
// buildSendKey ficariam undefined em runtime, quebrando o node inteiro.
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const node = wf.nodes.find((n) => n.name === 'Code - Motor V2: Relatorio Completo');
|
||||||
|
assert.ok(node, 'node "Code - Motor V2: Relatorio Completo" nao encontrado');
|
||||||
|
const codigo = node.parameters && node.parameters.jsCode;
|
||||||
|
assert.ok(codigo, 'node sem parameters.jsCode');
|
||||||
|
for (const nomeFuncao of ['function canSend', 'function computeMessageHash', 'function buildSendKey']) {
|
||||||
|
assert.ok(
|
||||||
|
codigo.includes(nomeFuncao),
|
||||||
|
`Code - Motor V2 nao contem "${nomeFuncao}" — sendGuard.js nao esta sendo concatenado (ver MOD.sendGuard em tools/build_v2_workflow.js)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nenhum node MySQL do workflow roda sem executeOnce=true (varredura ampla, protege contra novo node MySQL futuro)', () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const mysqlNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.mySql');
|
||||||
|
assert.ok(mysqlNodes.length > 0, 'esperava pelo menos um node MySQL no workflow');
|
||||||
|
for (const node of mysqlNodes) {
|
||||||
|
assert.equal(node.executeOnce, true, `node MySQL "${node.name}" sem executeOnce=true`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ const MOD = {
|
|||||||
format: readModuleBody('format.js'),
|
format: readModuleBody('format.js'),
|
||||||
sqlGuard: readModuleBody('sqlGuard.js'),
|
sqlGuard: readModuleBody('sqlGuard.js'),
|
||||||
queries: readModuleBody('queries.js'),
|
queries: readModuleBody('queries.js'),
|
||||||
|
sendGuard: readModuleBody('sendGuard.js'),
|
||||||
pipeline: readModuleBody('pipeline.js'),
|
pipeline: readModuleBody('pipeline.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
|
|||||||
// Node: Code - Motor V2: Relatorio Completo
|
// Node: Code - Motor V2: Relatorio Completo
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
const CODE_MOTOR_V2 = `${HEADER_AVISO}
|
const CODE_MOTOR_V2 = `${HEADER_AVISO}
|
||||||
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)}
|
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)}
|
||||||
|
|
||||||
const base = $('Code - Periodo e Queries SQL').first().json;
|
const base = $('Code - Periodo e Queries SQL').first().json;
|
||||||
const config = base.config;
|
const config = base.config;
|
||||||
@@ -194,6 +195,16 @@ const historicoRows = $('Data Table - Buscar Historico Anterior')
|
|||||||
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
|
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow";
|
||||||
|
// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule
|
||||||
|
// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com
|
||||||
|
// FORCE_SEND=true explicito.
|
||||||
|
const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
|
||||||
|
const sendLogRows = $('Data Table - Buscar Log de Envios')
|
||||||
|
.all()
|
||||||
|
.map((i) => i.json)
|
||||||
|
.filter((r) => r && r.send_key);
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
period,
|
period,
|
||||||
indicators,
|
indicators,
|
||||||
@@ -207,6 +218,8 @@ const input = {
|
|||||||
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
|
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
|
||||||
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
|
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
|
||||||
todayIso,
|
todayIso,
|
||||||
|
triggerType,
|
||||||
|
sendLogRows,
|
||||||
};
|
};
|
||||||
|
|
||||||
const relatorio = buildReport(input, config);
|
const relatorio = buildReport(input, config);
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
|
|||||||
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
|
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
|
||||||
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
|
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
|
||||||
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
|
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
|
||||||
|
// Data Table de log de envios (idempotencia, secao 13) — criada em
|
||||||
|
// 2026-08-25 via n8n_manage_datatable (createTable).
|
||||||
|
const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' };
|
||||||
|
// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV]
|
||||||
|
// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2,
|
||||||
|
// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma
|
||||||
|
// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja
|
||||||
|
// existe nas credenciais do ambiente").
|
||||||
|
const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } };
|
||||||
|
|
||||||
function setAssignment(id, name, type, value) {
|
function setAssignment(id, name, type, value) {
|
||||||
return { id, name, type, value };
|
return { id, name, type, value };
|
||||||
@@ -54,7 +63,6 @@ module.exports = function build(codeBodies) {
|
|||||||
assignments: {
|
assignments: {
|
||||||
assignments: [
|
assignments: [
|
||||||
setAssignment('data_alvo', 'data_alvo', 'string', ''),
|
setAssignment('data_alvo', 'data_alvo', 'string', ''),
|
||||||
setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'),
|
|
||||||
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
|
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
|
||||||
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
|
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
|
||||||
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
|
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
|
||||||
@@ -68,6 +76,16 @@ module.exports = function build(codeBodies) {
|
|||||||
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
|
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
|
||||||
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
|
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
|
||||||
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
|
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
|
||||||
|
// Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js.
|
||||||
|
// SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e
|
||||||
|
// SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto,
|
||||||
|
// ate a promocao explicita deste workflow para producao.
|
||||||
|
setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true),
|
||||||
|
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
|
||||||
|
// Canal Slack de destino do Resumo Diario/Semanal. Vazio ate
|
||||||
|
// confirmacao explicita do usuario sobre qual canal deve receber
|
||||||
|
// (nao deve ser adivinhado — ver secao 6 da missao).
|
||||||
|
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', ''),
|
||||||
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
||||||
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
||||||
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
||||||
@@ -145,6 +163,42 @@ module.exports = function build(codeBodies) {
|
|||||||
orderByDirection: 'DESC',
|
orderByDirection: 'DESC',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-buscar-log-envios',
|
||||||
|
name: 'Data Table - Buscar Log de Envios',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [2860, 260],
|
||||||
|
// executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table
|
||||||
|
// reads (ver Data Table - Buscar Memoria de Insights acima).
|
||||||
|
executeOnce: true,
|
||||||
|
alwaysOutputData: true,
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'get',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
filters: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
keyName: 'report_type',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyName: 'period_start',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyName: 'period_end',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
returnAll: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'code-contexto-ia',
|
id: 'code-contexto-ia',
|
||||||
name: 'Code - Preparar Contexto IA',
|
name: 'Code - Preparar Contexto IA',
|
||||||
@@ -283,23 +337,19 @@ module.exports = function build(codeBodies) {
|
|||||||
conditions: {
|
conditions: {
|
||||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||||
conditions: [
|
conditions: [
|
||||||
// ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO
|
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
|
||||||
// 'true'. Bug real encontrado e corrigido em 2026-08-25: com
|
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
|
||||||
// operation:'true' a condicao checava "SHADOW_MODE e true" (o
|
// idempotencia) ja foi calculada em JS testado por unidade
|
||||||
// OPOSTO do que o nome do node promete), e quase mandou uma
|
// (src/v2/sendGuard.js#canSend, chamado dentro de
|
||||||
// mensagem real ao Slack durante validacao em DEV — so nao
|
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
|
||||||
// enviou porque a URL do webhook ainda era o placeholder.
|
// Isso evita reproduzir a logica de guarda como condicoes soltas
|
||||||
|
// no n8n (fonte do bug real de operation:'true'/'false' invertido
|
||||||
|
// encontrado e corrigido em 2026-08-25).
|
||||||
{
|
{
|
||||||
id: 'cond-shadow',
|
id: 'cond-pode-enviar',
|
||||||
leftValue: '={{ $json.config.SHADOW_MODE }}',
|
leftValue: '={{ $json.envio.pode_enviar }}',
|
||||||
rightValue: false,
|
rightValue: true,
|
||||||
operator: { type: 'boolean', operation: 'false', singleValue: true },
|
operator: { type: 'boolean', operation: 'true', singleValue: true },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cond-dev',
|
|
||||||
leftValue: '={{ $json.config.DEV_MODE }}',
|
|
||||||
rightValue: false,
|
|
||||||
operator: { type: 'boolean', operation: 'false', singleValue: true },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
combinator: 'and',
|
combinator: 'and',
|
||||||
@@ -308,37 +358,122 @@ module.exports = function build(codeBodies) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'http-slack-v2',
|
id: 'slack-enviar-mensagem-v2',
|
||||||
name: 'HTTP - Slack Enviar Mensagem V2',
|
name: 'Slack - Enviar Mensagem V2',
|
||||||
type: 'n8n-nodes-base.httpRequest',
|
type: 'n8n-nodes-base.slack',
|
||||||
typeVersion: 4.2,
|
typeVersion: 2.5,
|
||||||
position: [4420, 260],
|
position: [4420, 200],
|
||||||
|
retryOnFail: true,
|
||||||
|
maxTries: 2,
|
||||||
|
waitBetweenTries: 3000,
|
||||||
|
// continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar
|
||||||
|
// Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas
|
||||||
|
// saidas, para so gravar status=success no log de idempotencia DEPOIS
|
||||||
|
// que o Slack de fato confirmar o envio (secao 13 da missao).
|
||||||
|
onError: 'continueErrorOutput',
|
||||||
parameters: {
|
parameters: {
|
||||||
method: 'POST',
|
select: 'channel',
|
||||||
url: "={{ $('Config - V2').first().json.slack_webhook_url }}",
|
// channelId.value fica vazio ate o usuario confirmar o canal de
|
||||||
sendBody: true,
|
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
|
||||||
contentType: 'json',
|
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
|
||||||
specifyBody: 'keypair',
|
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
|
||||||
bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] },
|
// controlado de envio (secao 14) so roda apos essa confirmacao.
|
||||||
|
channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" },
|
||||||
|
text: '={{ $json.mensagem_v2 }}',
|
||||||
|
otherOptions: { includeLinkToWorkflow: false },
|
||||||
|
},
|
||||||
|
credentials: SLACK_CRED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-preparar-log-envio-sucesso',
|
||||||
|
name: 'Code - Preparar Log de Envio (Sucesso)',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [4680, 100],
|
||||||
|
parameters: {
|
||||||
|
jsCode:
|
||||||
|
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||||
|
'const envio = relatorio.envio;\n' +
|
||||||
|
'return [{ json: {\n' +
|
||||||
|
' send_key: envio.send_key,\n' +
|
||||||
|
' report_type: envio.report_type,\n' +
|
||||||
|
' period_start: envio.period_start,\n' +
|
||||||
|
' period_end: envio.period_end,\n' +
|
||||||
|
' destination: envio.destino,\n' +
|
||||||
|
' message_hash: envio.message_hash,\n' +
|
||||||
|
' trigger_type: envio.trigger_type,\n' +
|
||||||
|
" status: 'success',\n" +
|
||||||
|
' sent_at: new Date().toISOString(),\n' +
|
||||||
|
'} }];\n',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-registrar-log-envio-sucesso',
|
||||||
|
name: 'Data Table - Registrar Log de Envio (Sucesso)',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4940, 100],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'insert',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-preparar-log-envio-falha',
|
||||||
|
name: 'Code - Preparar Log de Envio (Falha)',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [4680, 300],
|
||||||
|
parameters: {
|
||||||
|
jsCode:
|
||||||
|
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||||
|
'const envio = relatorio.envio;\n' +
|
||||||
|
'const erro = $input.first().json.error || null;\n' +
|
||||||
|
'return [{ json: {\n' +
|
||||||
|
' send_key: envio.send_key,\n' +
|
||||||
|
' report_type: envio.report_type,\n' +
|
||||||
|
' period_start: envio.period_start,\n' +
|
||||||
|
' period_end: envio.period_end,\n' +
|
||||||
|
' destination: envio.destino,\n' +
|
||||||
|
' message_hash: envio.message_hash,\n' +
|
||||||
|
' trigger_type: envio.trigger_type,\n' +
|
||||||
|
" status: 'failed',\n" +
|
||||||
|
' sent_at: new Date().toISOString(),\n' +
|
||||||
|
' erro: erro ? String((erro && erro.message) || erro) : null,\n' +
|
||||||
|
'} }];\n',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-registrar-log-envio-falha',
|
||||||
|
name: 'Data Table - Registrar Log de Envio (Falha)',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4940, 300],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'insert',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
// "erro" nao faz parte do schema fixo da Data Table (imutavel apos
|
||||||
|
// criacao) — autoMapInputData ignora campos sem coluna correspondente,
|
||||||
|
// entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela.
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'set-envio-suprimido',
|
id: 'set-envio-suprimido',
|
||||||
name: 'Set - Envio Suprimido (Shadow/Dev Mode)',
|
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
|
||||||
type: 'n8n-nodes-base.set',
|
type: 'n8n-nodes-base.set',
|
||||||
typeVersion: 3.4,
|
typeVersion: 3.4,
|
||||||
position: [4420, 400],
|
position: [4420, 440],
|
||||||
parameters: {
|
parameters: {
|
||||||
assignments: {
|
assignments: {
|
||||||
assignments: [
|
assignments: [
|
||||||
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
|
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
|
||||||
setAssignment(
|
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
|
||||||
'motivo',
|
|
||||||
'motivo',
|
|
||||||
'string',
|
|
||||||
'=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}'
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: { includeOtherFields: true },
|
options: { includeOtherFields: true },
|
||||||
@@ -358,7 +493,8 @@ module.exports = function build(codeBodies) {
|
|||||||
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
|
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
|
||||||
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
|
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
|
||||||
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
|
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
|
||||||
'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'),
|
'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'),
|
||||||
|
'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'),
|
||||||
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
|
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
|
||||||
'IF - IA Habilitada?': {
|
'IF - IA Habilitada?': {
|
||||||
main: [
|
main: [
|
||||||
@@ -382,10 +518,18 @@ module.exports = function build(codeBodies) {
|
|||||||
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
|
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
|
||||||
'IF - Pode Enviar (fora de Shadow/Dev)?': {
|
'IF - Pode Enviar (fora de Shadow/Dev)?': {
|
||||||
main: [
|
main: [
|
||||||
[{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }],
|
[{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }],
|
||||||
[{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }],
|
[{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'Slack - Enviar Mensagem V2': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'),
|
||||||
|
'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const workflow = {
|
const workflow = {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ function runCodeNode(nodeName, inputItems, extraGlobals) {
|
|||||||
first: () => inputItems[0],
|
first: () => inputItems[0],
|
||||||
all: () => inputItems,
|
all: () => inputItems,
|
||||||
},
|
},
|
||||||
|
// $execution.mode: 'trigger' simula o Schedule Trigger (producao real);
|
||||||
|
// o smoke test usa isso por padrao porque e o caminho mais exercitado
|
||||||
|
// pelo relatorio (execucao manual e coberta por sendGuard.test.js).
|
||||||
|
$execution: { mode: 'trigger' },
|
||||||
console,
|
console,
|
||||||
Date,
|
Date,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -87,6 +91,9 @@ const configItem = [
|
|||||||
MAX_REPORT_INSIGHTS: 5,
|
MAX_REPORT_INSIGHTS: 5,
|
||||||
DEV_MODE: true,
|
DEV_MODE: true,
|
||||||
SHADOW_MODE: true,
|
SHADOW_MODE: true,
|
||||||
|
SEND_ENABLED: true,
|
||||||
|
FORCE_SEND: false,
|
||||||
|
SLACK_DESTINATION: 'C0000TESTE',
|
||||||
EXTENDED_METRICS_ENABLED: false,
|
EXTENDED_METRICS_ENABLED: false,
|
||||||
ANOMALY_MULTIPLIER: 2.0,
|
ANOMALY_MULTIPLIER: 2.0,
|
||||||
ANOMALY_MIN_DELTA: 3,
|
ANOMALY_MIN_DELTA: 3,
|
||||||
@@ -155,6 +162,7 @@ registry.set('MySQL - Baseline Diario Cliente', [
|
|||||||
].map((r) => ({ json: r })));
|
].map((r) => ({ json: r })));
|
||||||
registry.set('Data Table - Buscar Memoria de Insights', []);
|
registry.set('Data Table - Buscar Memoria de Insights', []);
|
||||||
registry.set('Data Table - Buscar Historico Anterior', []);
|
registry.set('Data Table - Buscar Historico Anterior', []);
|
||||||
|
registry.set('Data Table - Buscar Log de Envios', []);
|
||||||
|
|
||||||
console.log('\n--- 2) Code - Preparar Contexto IA ---');
|
console.log('\n--- 2) Code - Preparar Contexto IA ---');
|
||||||
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
|
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
|
||||||
@@ -193,6 +201,7 @@ const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0]
|
|||||||
const relatorio = out4[0].json;
|
const relatorio = out4[0].json;
|
||||||
console.log('status_operacional:', relatorio.status_operacional);
|
console.log('status_operacional:', relatorio.status_operacional);
|
||||||
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
|
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
|
||||||
|
console.log('envio:', JSON.stringify(relatorio.envio, null, 2));
|
||||||
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
|
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
|
||||||
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
|
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
|
||||||
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
|
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
|
||||||
@@ -201,4 +210,11 @@ console.log('\n--- 5) Code - Preparar Historico ---');
|
|||||||
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
|
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
|
||||||
console.log(JSON.stringify(out5[0].json, null, 2));
|
console.log(JSON.stringify(out5[0].json, null, 2));
|
||||||
|
|
||||||
|
console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---');
|
||||||
|
registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]);
|
||||||
|
const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]);
|
||||||
|
console.log('log sucesso:', JSON.stringify(out6a[0].json));
|
||||||
|
const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]);
|
||||||
|
console.log('log falha:', JSON.stringify(out6b[0].json));
|
||||||
|
|
||||||
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');
|
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');
|
||||||
|
|||||||
Reference in New Issue
Block a user