diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json new file mode 100644 index 0000000..c823769 --- /dev/null +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -0,0 +1,124 @@ +{ + "name": "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)", + "nodes": [ + { + "id": "manual-trigger-v2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 180, + 300 + ], + "parameters": {} + }, + { + "id": "schedule-trigger-v2-desligado", + "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 180, + 640 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + } + }, + { + "id": "config-v2", + "name": "Config - Coordenador GLPI v2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 440, + 300 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "glpi-base-url-v2", + "name": "glpi_base_url", + "type": "string", + "value": "https://glpi.suaempresa.com.br" + } + ] + }, + "options": {} + } + }, + { + "id": "code-montar-sql-candidatos", + "name": "Code - Montar SQL Candidatos", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 220 + ], + "parameters": { + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nconst config = parseConfig($env);\nconst glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];" + } + }, + { + "id": "code-montar-sql-legado", + "name": "Code - Montar SQL Legado", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 460 + ], + "parameters": { + "jsCode": "const config = $('Config - Coordenador GLPI v2').first().json;\nconst glpiBaseUrl = String(config.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Top 10 chamados abertos por blocos operacionais.\n-- Blocos:\n-- - 3 chamados com SLA vencido mais criticos\n-- - 3 chamados com maior tempo sem interacao\n-- - 2 chamados sem tecnico atribuido\n-- - 2 chamados de prioridade alta mais recentes\n-- Se faltar item em algum bloco, o complemento vem do ranking geral.\n--\n-- Ajuste os parametros abaixo no workflow n8n:\n-- {DIAS_SEM_INTERACAO_ALERTA}\n-- {HORAS_SLA_RISCO}\n-- {GLPI_BASE_URL}\n\nWITH ultima_interacao AS (\n SELECT\n tickets_id,\n MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_mod AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_creation AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n\n UNION ALL\n\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_mod AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_creation AS data_interacao\n FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT\n tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id,\n t.name AS titulo,\n t.status,\n CASE t.status\n WHEN 1 THEN 'Novo'\n WHEN 2 THEN 'Em atendimento'\n WHEN 3 THEN 'Planejado'\n WHEN 4 THEN 'Pendente'\n ELSE CONCAT('Status ', t.status)\n END AS status_nome,\n t.date AS aberto_em,\n t.date_mod AS modificado_em,\n t.time_to_resolve AS sla_resolver_ate,\n t.priority,\n t.urgency,\n t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE\n WHEN t.time_to_resolve IS NOT NULL\n AND t.time_to_resolve >= NOW()\n AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1\n ELSE 0\n END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id,\n titulo,\n status_nome,\n aberto_em,\n modificado_em,\n ultima_interacao_em,\n dias_aberto,\n dias_sem_interacao,\n sla_resolver_ate,\n horas_ate_sla,\n sla_vencido,\n sla_em_risco,\n priority AS prioridade,\n urgency AS urgencia,\n impact AS impacto,\n entidade,\n categoria,\n tecnico_atribuido,\n sem_tecnico,\n link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE\n WHEN dias_sem_interacao >= 7 THEN 35\n WHEN dias_sem_interacao >= 3 THEN 25\n WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10\n ELSE 0\n END\n + LEAST(dias_aberto, 30)\n + (priority * 8)\n + (urgency * 5)\n + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n), sla_top AS (\n SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem\n FROM chamados_ranked\n WHERE sla_vencido = 1\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 3\n), sem_interacao_top AS (\n SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM sla_top)\n ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC\n LIMIT 3\n), sem_tecnico_top AS (\n SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem\n FROM chamados_ranked\n WHERE sem_tecnico = 1\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 2\n), prioridade_alta_recente_top AS (\n SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem\n FROM chamados_ranked\n WHERE prioridade >= 4\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n AND id NOT IN (SELECT id FROM sem_tecnico_top)\n ORDER BY aberto_em DESC, score_criticidade DESC\n LIMIT 2\n), selecionados AS (\n SELECT * FROM sla_top\n UNION ALL\n SELECT * FROM sem_interacao_top\n UNION ALL\n SELECT * FROM sem_tecnico_top\n UNION ALL\n SELECT * FROM prioridade_alta_recente_top\n), complemento AS (\n SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM selecionados)\n ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 10\n)\nSELECT *\nFROM (\n SELECT * FROM selecionados\n UNION ALL\n SELECT * FROM complemento\n) ranking_final\nORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT 10;\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')\n .replaceAll('{HORAS_SLA_RISCO}', '8')\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);\n\nreturn [{ json: { query } }];" + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI v2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Config - Coordenador GLPI v2": { + "main": [ + [ + { + "node": "Code - Montar SQL Candidatos", + "type": "main", + "index": 0 + }, + { + "node": "Code - Montar SQL Legado", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} \ No newline at end of file diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json new file mode 100644 index 0000000..5c6c879 --- /dev/null +++ b/n8n/workflow.template.json @@ -0,0 +1,94 @@ +{ + "name": "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)", + "nodes": [ + { + "id": "manual-trigger-v2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [180, 300], + "parameters": {} + }, + { + "id": "schedule-trigger-v2-desligado", + "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [180, 640], + "parameters": { + "rule": { + "interval": [ + { "field": "cronExpression", "expression": "0 8 * * 1-5" } + ] + } + } + }, + { + "id": "config-v2", + "name": "Config - Coordenador GLPI v2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [440, 300], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "glpi-base-url-v2", + "name": "glpi_base_url", + "type": "string", + "value": "https://glpi.suaempresa.com.br" + } + ] + }, + "options": {} + } + }, + { + "id": "code-montar-sql-candidatos", + "name": "Code - Montar SQL Candidatos", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [700, 220], + "parameters": { + "jsCode": "__BUILD__:build-sql-candidatos.node.js" + } + }, + { + "id": "code-montar-sql-legado", + "name": "Code - Montar SQL Legado", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [700, 460], + "parameters": { + "jsCode": "__BUILD__:build-sql-legado.node.js" + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 } + ] + ] + }, + "Config - Coordenador GLPI v2": { + "main": [ + [ + { "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 }, + { "node": "Code - Montar SQL Legado", "type": "main", "index": 0 } + ] + ] + } + }, + "pinData": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/tools/build-workflow.js b/tools/build-workflow.js new file mode 100644 index 0000000..6ce5765 --- /dev/null +++ b/tools/build-workflow.js @@ -0,0 +1,59 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +function readLogicSource(fileName) { + const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8'); + // Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node. + return raw.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '').trim(); +} + +function inlinedLogic(fileNames) { + return fileNames.map(readLogicSource).join('\n\n'); +} + +function sqlAsJsTemplateLiteral(sqlRelativePath) { + const sql = fs.readFileSync(path.join(ROOT, sqlRelativePath), 'utf8'); + const escaped = sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + return `\`${escaped}\``; +} + +function buildDriverCode(driverRelativePath) { + const driverPath = path.join(ROOT, 'n8n', 'nodes', driverRelativePath); + let driver = fs.readFileSync(driverPath, 'utf8'); + + const requiresMatch = driver.match(/^\/\/ @requires (.+)$/m); + const requiredLogicFiles = requiresMatch ? requiresMatch[1].trim().split(/\s+/) : []; + driver = driver.replace(/^\/\/ @requires .+\n/m, ''); + + driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql')); + driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql')); + driver = driver.replace('/* __SQL_CONTEXTO_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql')); + driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_glpi.sql')); + + const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : ''; + return `${logicBlock}${driver.trim()}`; +} + +function buildWorkflow() { + const templatePath = path.join(ROOT, 'n8n', 'workflow.template.json'); + const template = JSON.parse(fs.readFileSync(templatePath, 'utf8')); + + for (const node of template.nodes) { + if (node.type === 'n8n-nodes-base.code' && typeof node.parameters.jsCode === 'string') { + const marker = node.parameters.jsCode.match(/^__BUILD__:(.+)$/); + if (marker) { + node.parameters.jsCode = buildDriverCode(marker[1].trim()); + } + } + } + + return template; +} + +if (require.main === module) { + process.stdout.write(JSON.stringify(buildWorkflow(), null, 2)); +} + +module.exports = { buildWorkflow, inlinedLogic, readLogicSource, sqlAsJsTemplateLiteral, buildDriverCode }; diff --git a/tools/build-workflow.test.js b/tools/build-workflow.test.js new file mode 100644 index 0000000..5ea25d6 --- /dev/null +++ b/tools/build-workflow.test.js @@ -0,0 +1,28 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildWorkflow } = require('./build-workflow'); + +test('workflow montado tem nome e nodes esperados', () => { + const wf = buildWorkflow(); + assert.equal(wf.name, '[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)'); + const names = wf.nodes.map((n) => n.name); + assert.ok(names.includes('Code - Montar SQL Candidatos')); +}); + +test('Code - Montar SQL Candidatos tem a logica de config e o SQL inlined (sem marcador de build)', () => { + const wf = buildWorkflow(); + const node = wf.nodes.find((n) => n.name === 'Code - Montar SQL Candidatos'); + assert.ok(node.parameters.jsCode.includes('function parseConfig')); + assert.ok(node.parameters.jsCode.includes('WITH ultima_interacao')); + assert.ok(!node.parameters.jsCode.includes('__BUILD__')); + assert.ok(!node.parameters.jsCode.includes('__SQL_CANDIDATOS_V2__')); +}); + +test('nenhum node de Code sobra com marcador nao resolvido', () => { + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type === 'n8n-nodes-base.code') { + assert.ok(!node.parameters.jsCode.includes('__BUILD__'), `node ${node.name} tem marcador nao resolvido`); + } + } +});