diff --git a/README.md b/README.md index 74b7667..589fa8c 100644 --- a/README.md +++ b/README.md @@ -33,32 +33,41 @@ docker compose down - `sql/indicadores_diario_glpi.sql` - `sql/indicadores_semana_glpi.sql` -## Workflow Diário Recomendado +## Workflow Recomendado -Use este workflow para o relatório diário correto: +Use este workflow para o relatório automatico correto: - `Resumo Diario GLPI - SQL Slack.n8n.json` -Essa versão usa a regra aprendida com a query semanal, mas aplicada por dia: +Essa versão roda de segunda a sexta as 08:00 e escolhe o periodo automaticamente: -- chamados abertos no dia: `DATE(glpi_tickets.date) = data_alvo` -- chamados fechados no dia: `DATE(glpi_tickets.closedate) = data_alvo` -- chamados fechados no dia podem ter sido abertos em qualquer data anterior +- terca-feira: envia o relatorio da segunda-feira +- quarta-feira: envia o relatorio da terca-feira +- quinta-feira: envia o relatorio da quarta-feira +- sexta-feira: envia o relatorio da quinta-feira +- segunda-feira: envia o relatorio da semana anterior inteira, de segunda a domingo + +Essa versão usa a regra aprendida com a query semanal, aplicada ao periodo selecionado: + +- chamados abertos no periodo: `glpi_tickets.date` entre `data_inicio` e `data_fim` +- chamados fechados no periodo: `glpi_tickets.closedate` entre `data_inicio` e `data_fim` +- chamados fechados no periodo podem ter sido abertos em qualquer data anterior - filtro `is_deleted = false` - filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` -### Como configurar o diário SQL +### Como configurar o SQL automatico 1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`. 2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`. 3. Abra `Config - Diario SQL` e preencha: -- `data_alvo`: deixe vazio para o dia atual ou use `2026-07-03` +- `data_alvo`: deixe vazio para o modo automatico ou use `2026-07-03` para teste manual de um dia especifico - `slack_webhook_url` - `openai_api_key` - `openai_model`, padrão `gpt-5.5` -4. Execute pelo `Manual Trigger`. +4. Execute pelo `Manual Trigger` para testar. +5. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`. Se a OpenAI falhar, o workflow envia o resumo fallback sem IA com os números SQL. diff --git a/Resumo Diario GLPI - SQL Slack.n8n.json b/Resumo Diario GLPI - SQL Slack.n8n.json index b46750d..a01ca80 100644 --- a/Resumo Diario GLPI - SQL Slack.n8n.json +++ b/Resumo Diario GLPI - SQL Slack.n8n.json @@ -1,5 +1,5 @@ { - "name": "Resumo Diário GLPI - SQL Slack", + "name": "Resumo GLPI - SQL Slack", "nodes": [ { "parameters": {}, @@ -7,7 +7,24 @@ "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [180, 300] + "position": [180, 420] + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + }, + "id": "schedule-trigger-glpi-sql", + "name": "Schedule Trigger - Dias uteis 08h", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [180, 180] }, { "parameters": { @@ -25,17 +42,17 @@ "name": "Config - Diario SQL", "type": "n8n-nodes-base.set", "typeVersion": 3.4, - "position": [420, 300] + "position": [440, 300] }, { "parameters": { - "jsCode": "function normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst config = $('Config - Diario SQL').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst filtroCategorias = `\n SELECT id FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}') OR id = 18\n`;\n\nconst query = `\nSELECT\n '${dataAlvo}' AS data_alvo,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND DATE(date) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE closedate IS NOT NULL AND is_deleted = false AND DATE(closedate) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (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\nCROSS JOIN\n (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 (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (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 (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`;\n\nreturn [{ json: { ...config, data_alvo_normalizada: dataAlvo, query } }];" + "jsCode": "function formatarData(data) {\n const ano = data.getFullYear();\n const mes = String(data.getMonth() + 1).padStart(2, '0');\n const dia = String(data.getDate()).padStart(2, '0');\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseData(valor) {\n const texto = String(valor || '').trim();\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction adicionarDias(data, dias) {\n const copia = new Date(data);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nconst config = $('Config - Diario SQL').first().json;\nconst dataManual = parseData(config.data_alvo);\nconst hoje = new Date();\n\nlet inicio;\nlet fim;\nlet tipoPeriodo;\nlet periodoLabel;\n\nif (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n periodoLabel = formatarData(inicio);\n} else if (hoje.getDay() === 1) {\n inicio = adicionarDias(hoje, -7);\n fim = adicionarDias(hoje, -1);\n tipoPeriodo = 'semanal';\n periodoLabel = `${formatarData(inicio)} a ${formatarData(fim)}`;\n} else {\n inicio = adicionarDias(hoje, -1);\n fim = adicionarDias(hoje, -1);\n tipoPeriodo = 'diario';\n periodoLabel = formatarData(inicio);\n}\n\nconst dataInicio = formatarData(inicio);\nconst dataFim = formatarData(fim);\n\nconst filtroCategorias = `\n SELECT id FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}') OR id = 18\n`;\n\nconst filtroAbertura = `date >= '${dataInicio}' AND date < DATE_ADD('${dataFim}', INTERVAL 1 DAY)`;\nconst filtroFechamento = `closedate IS NOT NULL AND closedate >= '${dataInicio}' AND closedate < DATE_ADD('${dataFim}', INTERVAL 1 DAY)`;\nconst filtroMovimentoTicket = `((t.date >= '${dataInicio}' AND t.date < DATE_ADD('${dataFim}', INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= '${dataInicio}' AND t.closedate < DATE_ADD('${dataFim}', INTERVAL 1 DAY)))`;\n\nconst query = `\nSELECT\n '${tipoPeriodo}' AS tipo_periodo,\n '${periodoLabel}' AS periodo_label,\n '${dataInicio}' AS data_inicio,\n '${dataFim}' AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (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\nCROSS JOIN\n (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 ${filtroMovimentoTicket} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (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 ${filtroMovimentoTicket} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`;\n\nreturn [{ json: { ...config, tipo_periodo: tipoPeriodo, periodo_label: periodoLabel, data_inicio: dataInicio, data_fim: dataFim, query } }];" }, "id": "code-montar-sql-diario", "name": "Code - Montar SQL diario", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [680, 300] + "position": [700, 300] }, { "parameters": { @@ -47,18 +64,18 @@ "name": "MySQL - Indicadores diario GLPI", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [940, 300], + "position": [960, 300], "credentials": {"mySql": {"id": "", "name": "MySQL GLPI"}} }, { "parameters": { - "jsCode": "const row = $input.first().json;\n\nconst textoBase = [\n '*Resumo diário do suporte GLPI*',\n '',\n `Data do relatório: ${row.data_alvo}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${row.abertos}`,\n `- Chamados fechados no dia: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n String(row.categorias_recorrentes || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Clientes com maior volume*',\n String(row.clientes_volume || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Observação*',\n '- Chamados fechados no dia podem ter sido abertos em dias ou semanas anteriores.'\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" + "jsCode": "const row = $input.first().json;\nconst ehSemanal = row.tipo_periodo === 'semanal';\nconst titulo = ehSemanal ? '*Resumo semanal do suporte GLPI*' : '*Resumo diario do suporte GLPI*';\nconst escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\nconst unidade = ehSemanal ? 'no periodo' : 'no dia';\n\nconst textoBase = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n String(row.categorias_recorrentes || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Clientes com maior volume*',\n String(row.clientes_volume || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" }, "id": "code-preparar-diario", "name": "Code - Preparar indicadores", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1200, 300] + "position": [1220, 300] }, { "parameters": { @@ -69,14 +86,14 @@ "sendBody": true, "contentType": "json", "specifyBody": "json", - "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo diário curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados diários GLPI:\\n' + JSON.stringify($json) + '\\n\\nExplique que fechados podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", + "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados GLPI:\n' + JSON.stringify($json) + '\n\nExplique que fechados no período podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", "options": {} }, "id": "openai-diario-sql", "name": "OpenAI - Gerar resumo", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1460, 300], + "position": [1480, 300], "continueOnFail": true, "retryOnFail": true, "maxTries": 3, @@ -90,7 +107,7 @@ "name": "Code - Preparar mensagem Slack", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1720, 300] + "position": [1740, 300] }, { "parameters": { @@ -106,12 +123,13 @@ "name": "HTTP - Slack enviar mensagem", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1980, 300] + "position": [2000, 300] } ], "pinData": {}, "connections": { "Manual Trigger": {"main": [[{"node": "Config - Diario SQL", "type": "main", "index": 0}]]}, + "Schedule Trigger - Dias uteis 08h": {"main": [[{"node": "Config - Diario SQL", "type": "main", "index": 0}]]}, "Config - Diario SQL": {"main": [[{"node": "Code - Montar SQL diario", "type": "main", "index": 0}]]}, "Code - Montar SQL diario": {"main": [[{"node": "MySQL - Indicadores diario GLPI", "type": "main", "index": 0}]]}, "MySQL - Indicadores diario GLPI": {"main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]]}, diff --git a/sql/indicadores_diario_glpi.sql b/sql/indicadores_diario_glpi.sql index 1c71365..47efc1b 100644 --- a/sql/indicadores_diario_glpi.sql +++ b/sql/indicadores_diario_glpi.sql @@ -1,12 +1,13 @@ --- Query diaria base. No workflow N8N, a data alvo e substituida dinamicamente. +-- Query base por periodo. No workflow N8N, as datas sao substituidas dinamicamente. -- Regra: --- - abertos: glpi_tickets.date na data alvo --- - fechados: glpi_tickets.closedate na data alvo +-- - abertos: glpi_tickets.date entre DATA_INICIO e DATA_FIM +-- - fechados: glpi_tickets.closedate entre DATA_INICIO e DATA_FIM -- - fechados podem ter sido abertos em dias/semanas anteriores -- - categorias filtradas pelas arvores 7, 16, 17 e 18 SELECT - '{DATA_ALVO}' AS data_alvo, + '{DATA_INICIO}' AS data_inicio, + '{DATA_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, @@ -19,7 +20,8 @@ FROM SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false - AND DATE(date) = '{DATA_ALVO}' + AND date >= '{DATA_INICIO}' + AND date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY) AND itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -34,7 +36,8 @@ CROSS JOIN FROM glpi_tickets WHERE closedate IS NOT NULL AND is_deleted = false - AND DATE(closedate) = '{DATA_ALVO}' + AND closedate >= '{DATA_INICIO}' + AND closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY) AND itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -80,7 +83,10 @@ CROSS JOIN FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false - AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND ( + (t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + ) AND t.itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -101,7 +107,10 @@ CROSS JOIN FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false - AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND ( + (t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + ) AND t.itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7