Files
SLACK_COORD_RELATORIO/relatorio/src/v2/queries.js
T
Gustavo Santos d21c4596e1 chore: import relatorio (subtree)
git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
2026-10-01 14:26:02 -03:00

254 lines
12 KiB
JavaScript

'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,
};