'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: prefixo "D" indica // uma conversa direta (DM)/IM, nao um canal publico/privado // ("C"/"G") — comportamento identico via chat.postMessage, so // registrando aqui para quem for auditar depois. setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'D06JK4GEK8E'), 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, }; }