git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
254 lines
12 KiB
JavaScript
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,
|
|
};
|