Files
SLACK_COORD_RELATORIO/tools/build_v2_workflow_assemble.js
T
Automacao GLPIandClaude Sonnet 5 9475e68cb1 Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key
Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a
IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo
(mesmo report_type+data) geraram insights com textos diferentes, portanto
message_hash diferente, portanto send_key diferente — o retry do MESMO
periodo passou pela deteccao de duplicata como se fosse outro relatorio.

Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5
para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir
(execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi
revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true,
workflow desativado) assim que o padrao foi identificado.

Correcao: src/v2/sendGuard.js#buildSendKey agora usa so
report_type+period_start+period_end+destination (SEM message_hash) — a
identidade de um relatorio e o PERIODO, nao o texto exato produzido pela
IA. message_hash continua calculado e armazenado, so para diagnostico.

As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram
normalizadas manualmente (send_key sem sufixo de hash) para que a
protecao de duplicata funcione corretamente para qualquer nova tentativa
do mesmo periodo daqui pra frente.

Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de
regressao reproduzindo exatamente este cenario (retry do mesmo periodo
com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada).

Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado
primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou
por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI,
onde o bot ja e membro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:44:44 -03:00

588 lines
23 KiB
JavaScript

'use strict';
const fs = require('fs');
// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql).
// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a
// que foi de fato provisionada e validada ao vivo (2026-08-25).
const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: '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_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) {
return { id, name, type, value };
}
module.exports = function build(codeBodies) {
const {
CODE_PERIODO_QUERIES,
CODE_CONTEXTO_IA,
CODE_NORMALIZAR_IA,
CODE_IA_DESATIVADA,
CODE_MOTOR_V2,
CODE_PREPARAR_HISTORICO,
OUT_FILE,
} = codeBodies;
const nodes = [
{
id: 'manual-trigger-v2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 200],
parameters: {},
},
{
id: 'schedule-trigger-v2',
name: 'Schedule Trigger - Dias uteis 08h',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 380],
parameters: {
rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] },
},
},
{
id: 'config-v2',
name: 'Config - V2',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [260, 290],
parameters: {
assignments: {
assignments: [
setAssignment('data_alvo', 'data_alvo', 'string', ''),
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000),
setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6),
setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000),
setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000),
setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'),
setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20),
setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4),
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
setAssignment('DEV_MODE', 'DEV_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. Confirmado
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
// missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado
// primeiro e falhou ao vivo com "channel_not_found" (o bot nao
// tem acesso); o usuario optou por reaproveitar o MESMO canal
// ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5),
// onde o bot ja e membro confirmado.
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'),
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4),
setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15),
setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300),
],
},
options: {},
},
},
{
id: 'code-periodo-queries',
name: 'Code - Periodo e Queries SQL',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [520, 290],
parameters: { jsCode: CODE_PERIODO_QUERIES },
},
mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'),
mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'),
mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'),
mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'),
mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'),
mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'),
{
id: 'dt-buscar-memoria',
name: 'Data Table - Buscar Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2340, 100],
// executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM
// recebido do node anterior (causa raiz real do timeout em Baseline
// Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em
// buildBaselineDailyQuery). alwaysOutputData: garante que o node
// emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria
// ainda) — sem isso, 0 itens aqui paravam o resto do pipeline
// (n8n pula nodes que recebem 0 itens de entrada).
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_MEMORIA,
filters: { conditions: [] },
returnAll: true,
},
},
{
id: 'dt-buscar-historico',
name: 'Data Table - Buscar Historico Anterior',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2600, 100],
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_HISTORICO,
filters: {
conditions: [
{
keyName: 'history_bucket',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
},
],
},
returnAll: false,
limit: 5,
orderBy: true,
orderByColumn: 'data_inicio',
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',
name: 'Code - Preparar Contexto IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [2860, 100],
parameters: { jsCode: CODE_CONTEXTO_IA },
},
{
id: 'if-ia-habilitada',
name: 'IF - IA Habilitada?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [3120, 100],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
{
id: 'cond-ia-habilitada',
leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}",
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'http-openai',
name: 'HTTP Request - OpenAI Chat Completions',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [3380, -40],
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 5000,
alwaysOutputData: true,
parameters: {
method: 'POST',
url: 'https://api.openai.com/v1/chat/completions',
authentication: 'predefinedCredentialType',
nodeCredentialType: 'openAiApi',
sendBody: true,
specifyBody: 'json',
jsonBody: '={{ $json.openai_request_body }}',
options: {
timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}",
response: { response: { responseFormat: 'json' } },
},
},
credentials: OPENAI_CRED,
},
{
id: 'code-normalizar-ia',
name: 'Code - Normalizar Resposta IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3640, -40],
parameters: { jsCode: CODE_NORMALIZAR_IA },
},
{
id: 'code-ia-desativada',
name: 'Code - IA Desativada',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3380, 240],
parameters: { jsCode: CODE_IA_DESATIVADA },
},
{
id: 'code-motor-v2',
name: 'Code - Motor V2: Relatorio Completo',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3900, 100],
parameters: { jsCode: CODE_MOTOR_V2 },
},
{
id: 'split-out-memoria',
name: 'Split Out - Memoria Atualizacoes',
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [4160, -120],
parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} },
},
{
id: 'dt-upsert-memoria',
name: 'Data Table - Upsert Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, -120],
parameters: {
resource: 'row',
operation: 'upsert',
dataTableId: DATATABLE_MEMORIA,
matchType: 'anyCondition',
filters: {
conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }],
},
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] },
options: {},
},
},
{
id: 'code-preparar-historico',
name: 'Code - Preparar Historico',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4160, 100],
parameters: { jsCode: CODE_PREPARAR_HISTORICO },
},
{
id: 'dt-insert-historico',
name: 'Data Table - Insert Historico do Periodo',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, 100],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_HISTORICO,
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'if-pode-enviar',
name: 'IF - Pode Enviar (fora de Shadow/Dev)?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [4160, 320],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
// idempotencia) ja foi calculada em JS testado por unidade
// (src/v2/sendGuard.js#canSend, chamado dentro de
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
// 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-pode-enviar',
leftValue: '={{ $json.envio.pode_enviar }}',
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'slack-enviar-mensagem-v2',
name: 'Slack - Enviar Mensagem V2',
type: 'n8n-nodes-base.slack',
typeVersion: 2.5,
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: {
select: 'channel',
// channelId.value fica vazio ate o usuario confirmar o canal de
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
// 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' +
'// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n' +
"// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n" +
'// name \'erro\'\", bug real encontrado e corrigido em 2026-08-25 no teste\n' +
'// controlado de envio). Fica so no console.log, visivel no log de\n' +
'// execucao do n8n.\n' +
'if (erro) console.log(\'Falha no envio Slack:\', erro);\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' +
'} }];\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: {},
},
},
{
id: 'set-envio-suprimido',
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [4420, 440],
parameters: {
assignments: {
assignments: [
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
],
},
options: { includeOtherFields: true },
},
},
];
const connections = {
'Manual Trigger': mainTo('Config - V2'),
'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'),
'Config - V2': mainTo('Code - Periodo e Queries SQL'),
'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'),
'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'),
'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'),
'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'),
'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'),
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
'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 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?'),
'IF - IA Habilitada?': {
main: [
[{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }],
[{ node: 'Code - IA Desativada', type: 'main', index: 0 }],
],
},
'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'),
'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - Motor V2: Relatorio Completo': {
main: [
[
{ node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 },
{ node: 'Code - Preparar Historico', type: 'main', index: 0 },
{ node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 },
],
],
},
'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'),
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
'IF - Pode Enviar (fora de Shadow/Dev)?': {
main: [
[{ node: 'Slack - Enviar Mensagem V2', 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 = {
name: '[DEV] GLPI - Relatorio Operacional IA v2',
nodes,
connections,
pinData: {},
active: false,
settings: { executionOrder: 'v1' },
versionId: '',
meta: { templateCredsSetupCompleted: false },
tags: [],
};
fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8');
console.log(`Workflow gerado: ${OUT_FILE}`);
console.log(`Nodes: ${nodes.length}`);
};
function mainTo(nodeName) {
return { main: [[{ node: nodeName, type: 'main', index: 0 }]] };
}
function mysqlNode(id, name, position, queryFieldPath) {
return {
id,
name,
type: 'n8n-nodes-base.mySql',
typeVersion: 2.4,
position,
// executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR
// ITEM recebido do node anterior, e como cada query aqui retorna varias
// linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 ->
// Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes,
// gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e
// deu timeout). Causa raiz real, validada com execution telemetry do
// n8n em 2026-08-25 (execucao 16209 do workflow DEV).
executeOnce: true,
parameters: {
operation: 'executeQuery',
query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`,
options: {},
},
credentials: MYSQL_CRED,
};
}