'use strict'; /** * Construcao das queries SQL (somente leitura) contra o banco do GLPI. * * A query principal (buildIndicatorsQuery) e uma PORTA do node * "Code - Montar SQL diario" da V1 — mesma logica, mesmos filtros, * apenas com o filtro de categorias centralizado (era repetido 5x * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS). * * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser * usado pelo Code node do workflow (defesa em profundidade, alem de nunca * usarmos INSERT/UPDATE/DELETE/... no template). */ const { assertReadOnlySql } = require('./sqlGuard'); function sqlDateLiteral(iso) { // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda // assim validamos o formato aqui para nunca interpolar texto arbitrario. if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) { throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`); } return `'${iso}'`; } function buildCategoryFilterClause(categoryTreeIds) { const condicoes = categoryTreeIds .map((id) => { const n = Number(id); if (!Number.isInteger(n) || n <= 0) { throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`); } return `JSON_CONTAINS(ancestors_cache, '{"${n}":${n}}') OR id = ${n}`; }) .join('\n OR '); return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`; } function buildMovementFilter(dataInicio, dataFim) { const ini = sqlDateLiteral(dataInicio); const fim = sqlDateLiteral(dataFim); return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`; } /** * Query principal de indicadores do periodo — mesma logica da V1. */ function buildIndicatorsQuery(period, config) { const { data_inicio: dataInicio, data_fim: dataFim } = period; const ini = sqlDateLiteral(dataInicio); const fim = sqlDateLiteral(dataFim); const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`; const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`; const filtroMovimento = buildMovementFilter(dataInicio, dataFim); const sql = ` SELECT '${period.tipo_periodo}' AS tipo_periodo, '${period.periodo_label}' AS periodo_label, ${ini} AS data_inicio, ${fim} AS data_fim, COALESCE(abertos.abertos, 0) AS abertos, COALESCE(fechados.fechados, 0) AS fechados, COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo, COALESCE(pendentes.pendentes, 0) AS pendentes, COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes, COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes, COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume FROM (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos CROSS JOIN (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados CROSS JOIN (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes CROSS JOIN (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade CROSS JOIN (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias CROSS JOIN (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim(); return assertReadOnlySql(sql); } /** * Ranking completo (nao só top 5) de categorias e clientes do periodo, para * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13). * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente * para nao pesar a query. */ function buildRankingCompletoQuery(period, config, dimensao) { const { data_inicio: dataInicio, data_fim: dataFim } = period; const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); const filtroMovimento = buildMovementFilter(dataInicio, dataFim); const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; const join = dimensao === 'cliente' ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id` : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`; const nomeExpr = dimensao === 'cliente' ? `COALESCE(e.completename, e.name, 'Nao informado')` : `COALESCE(c.completename, c.name, 'Nao informado')`; const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name'; const sql = ` SELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total FROM glpi_tickets t ${join} WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY ${groupBy} ORDER BY total DESC;`.trim(); return assertReadOnlySql(sql); } /** * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL; * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS * (src/v2/aiSchema.js) depois que os dados chegam. * * NAO seleciona "solution": validado ao vivo contra o GLPI DEV em * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do * schema (erro real: "Unknown column 't.solution' in 'field list'"). * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions, * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext * ja trata solution ausente/null sem quebrar. */ function buildTicketContextQuery(period, config) { const { data_inicio: dataInicio, data_fim: dataFim } = period; const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); const filtroMovimento = buildMovementFilter(dataInicio, dataFim); const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300; const sql = ` SELECT t.id AS id, t.name AS title, COALESCE(c.completename, c.name, 'Nao informado') AS category, COALESCE(e.completename, e.name, 'Nao informado') AS client, t.priority AS priority, t.status AS status, t.date AS opened_at, t.closedate AS closed_at, t.solvedate AS solved_at, LEFT(t.content, 600) AS description FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) ORDER BY t.priority DESC, t.date DESC LIMIT ${limit};`.trim(); return assertReadOnlySql(sql); } /** * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13). * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro * de movimento (abertura OU fechamento) usado nos indicadores principais — * isso evita contar o mesmo ticket em dois dias diferentes do baseline. * * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24, * "Baseline Diario Cliente" dando timeout mesmo em janela curta): * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/ * entidade) — nao resolveu sozinho. * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda * nao resolveu ao vivo. * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar * o id, nesta mesma correcao) e e casado por ID no codigo * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer * dependencia de performance da tabela de entidades/categorias nesta * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas * outras queries do mesmo periodo. */ function buildBaselineDailyQuery(window, config, dimensao, periodoAtual) { const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); const ini = sqlDateLiteral(window.start); const fim = sqlDateLiteral(window.end); const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim); const idsAtivosNoPeriodoAtual = ` SELECT DISTINCT t2.${idColuna} FROM glpi_tickets t2 WHERE t2.is_deleted = false AND ${filtroMovimentoAtual.replace(/\bt\./g, 't2.')} AND t2.itilcategories_id IN (${filtroCategorias})`; const sql = ` SELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total FROM glpi_tickets t WHERE t.is_deleted = false AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY) AND t.itilcategories_id IN (${filtroCategorias}) AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual}) GROUP BY dia, t.${idColuna} ORDER BY dia;`.trim(); return assertReadOnlySql(sql); } /** * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram * confirmados contra o banco real deste ambiente. */ function buildExtendedMetricsQuery(period, config) { const { data_inicio: dataInicio, data_fim: dataFim } = period; const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`; const sql = ` SELECT COUNT(*) AS fechados_no_periodo, SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido, SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia, AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas, GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias});`.trim(); return assertReadOnlySql(sql); } module.exports = { buildCategoryFilterClause, buildMovementFilter, buildIndicatorsQuery, buildRankingCompletoQuery, buildTicketContextQuery, buildBaselineDailyQuery, buildExtendedMetricsQuery, };