chore: import relatorio (subtree)
git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user