diff --git a/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md index de5bd34..391e6d0 100644 --- a/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md +++ b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md @@ -1372,7 +1372,7 @@ ORDER BY tickets_id, date ASC; `n8n/nodes/build-sql-candidatos.node.js` (assume `parseConfig` já inlined no escopo pelo build script, Task 12): ```js -// @requires logic/config.js +// @requires config.js const config = parseConfig($env); const glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''"); diff --git a/n8n/nodes/build-sql-candidatos.node.js b/n8n/nodes/build-sql-candidatos.node.js new file mode 100644 index 0000000..148df1c --- /dev/null +++ b/n8n/nodes/build-sql-candidatos.node.js @@ -0,0 +1,13 @@ +// @requires config.js +const config = parseConfig($env); +const glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''"); + +const queryTemplate = /* __SQL_CANDIDATOS_V2__ */; + +const query = queryTemplate + .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta)) + .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco)) + .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl) + .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize)); + +return [{ json: { query, config } }]; diff --git a/n8n/nodes/build-sql-candidatos.node.test.js b/n8n/nodes/build-sql-candidatos.node.test.js new file mode 100644 index 0000000..c851f37 --- /dev/null +++ b/n8n/nodes/build-sql-candidatos.node.test.js @@ -0,0 +1,19 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +test('SQL de candidatos V2 preserva a exclusao administrativa do V1', () => { + const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8'); + assert.ok(sql.includes("NOT LIKE 'ADMINISTRATIVO%'")); + assert.ok(sql.includes("NOT LIKE 'DESENVOLVIMENTO%'")); + assert.ok(sql.includes("NOT LIKE 'INFRA%'")); + assert.ok(sql.includes('is_deleted = false')); + assert.ok(sql.includes('status NOT IN (5, 6)')); +}); + +test('SQL de candidatos V2 NAO tem mais LIMIT fixo em 10 (funil maior)', () => { + const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8'); + assert.ok(sql.includes('LIMIT {CANDIDATE_POOL_SIZE}')); + assert.ok(!sql.includes('LIMIT 10')); +}); diff --git a/n8n/nodes/build-sql-legado.node.js b/n8n/nodes/build-sql-legado.node.js new file mode 100644 index 0000000..b3b80a6 --- /dev/null +++ b/n8n/nodes/build-sql-legado.node.js @@ -0,0 +1,11 @@ +const config = $('Config - Coordenador GLPI v2').first().json; +const glpiBaseUrl = String(config.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''"); + +const queryTemplate = /* __SQL_LEGADO_V1_VERBATIM__ */; + +const query = queryTemplate + .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2') + .replaceAll('{HORAS_SLA_RISCO}', '8') + .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl); + +return [{ json: { query } }]; diff --git a/sql/candidatos_v2_glpi.sql b/sql/candidatos_v2_glpi.sql new file mode 100644 index 0000000..7735022 --- /dev/null +++ b/sql/candidatos_v2_glpi.sql @@ -0,0 +1,82 @@ +-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos. +-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE} + +WITH ultima_interacao AS ( + SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em + FROM ( + SELECT tickets_id, MAX(data_interacao) AS data_interacao + FROM ( + SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0 + UNION ALL + SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0 + UNION ALL + SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0 + ) followup_datas + WHERE data_interacao IS NOT NULL + GROUP BY tickets_id + UNION ALL + SELECT tickets_id, MAX(data_interacao) AS data_interacao + FROM ( + SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks + UNION ALL + SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks + UNION ALL + SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks + ) task_datas + WHERE data_interacao IS NOT NULL + GROUP BY tickets_id + ) interacoes + GROUP BY tickets_id +), tecnicos AS ( + SELECT tu.tickets_id, + GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos + FROM glpi_tickets_users tu + LEFT JOIN glpi_users u ON u.id = tu.users_id + WHERE tu.type = 2 + GROUP BY tu.tickets_id +), chamados_base AS ( + SELECT + t.id, t.name AS titulo, t.status, + 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, + t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate, + t.priority, t.urgency, t.impact, + COALESCE(e.completename, e.name, 'Nao informado') AS entidade, + COALESCE(c.completename, c.name, 'Sem categoria') AS categoria, + COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido, + 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, + TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto, + 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, + TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla, + CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido, + 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, + CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico, + CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi + FROM glpi_tickets t + LEFT JOIN glpi_entities e ON e.id = t.entities_id + LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id + LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id + LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id + WHERE t.is_deleted = false + AND t.status NOT IN (5, 6) + AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%' + AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%' + AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%' +), chamados_ranked AS ( + SELECT + id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em, + dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco, + priority AS prioridade, urgency AS urgencia, impact AS impacto, + entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi, + ( + CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END + + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END + + 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 + + LEAST(dias_aberto, 30) + + (priority * 8) + (urgency * 5) + (impact * 5) + + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END + ) AS score_criticidade + FROM chamados_base +) +SELECT * FROM chamados_ranked +ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC +LIMIT {CANDIDATE_POOL_SIZE}; diff --git a/sql/contexto_followups_glpi.sql b/sql/contexto_followups_glpi.sql new file mode 100644 index 0000000..e99cc12 --- /dev/null +++ b/sql/contexto_followups_glpi.sql @@ -0,0 +1,5 @@ +-- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros). +SELECT items_id AS tickets_id, id, date, content AS texto +FROM glpi_itilfollowups +WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS}) +ORDER BY items_id, date ASC; diff --git a/sql/contexto_tarefas_glpi.sql b/sql/contexto_tarefas_glpi.sql new file mode 100644 index 0000000..cb7a4d5 --- /dev/null +++ b/sql/contexto_tarefas_glpi.sql @@ -0,0 +1,6 @@ +-- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks +-- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. +SELECT tickets_id, id, date, state AS status, content AS texto +FROM glpi_tickettasks +WHERE tickets_id IN ({IDS}) +ORDER BY tickets_id, date ASC;