commit 1eb1f80b4f6d696e2022a27a737e1032999a4340 Author: Gustavo Date: Mon Aug 24 15:00:26 2026 -0300 chore: baseline import of existing Coordenador GLPI V1 project Snapshot of the pre-existing, working V1 automation (n8n workflow + SQL query + docker-compose + README) exactly as found on disk, before any V2 changes. .env is intentionally not tracked (gitignored, contains local secrets). Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40f33dd --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +N8N_PORT=5678 +N8N_HOST=localhost +N8N_PROTOCOL=http +GENERIC_TIMEZONE=America/Sao_Paulo +TZ=America/Sao_Paulo + +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER=admin +N8N_BASIC_AUTH_PASSWORD=troque-esta-senha +N8N_ENCRYPTION_KEY=troque-esta-chave-com-32-caracteres diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Coordenador GLPI - Chamados Abertos Slack.n8n.json b/Coordenador GLPI - Chamados Abertos Slack.n8n.json new file mode 100644 index 0000000..3443b28 --- /dev/null +++ b/Coordenador GLPI - Chamados Abertos Slack.n8n.json @@ -0,0 +1,314 @@ +{ + "name": "Coordenador GLPI - Chamados Abertos Slack", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger-coordenador-glpi", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 180, + 420 + ] + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + }, + "id": "schedule-trigger-dias-uteis-08h", + "name": "Schedule Trigger - Dias uteis 08h", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 180, + 180 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "slack-webhook-url", + "name": "slack_webhook_url", + "type": "string", + "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" + }, + { + "id": "glpi-base-url", + "name": "glpi_base_url", + "type": "string", + "value": "https://glpi.suaempresa.com.br" + }, + { + "id": "dias-sem-interacao-alerta", + "name": "dias_sem_interacao_alerta", + "type": "number", + "value": 2 + }, + { + "id": "horas-sla-risco", + "name": "horas_sla_risco", + "type": "number", + "value": 8 + } + ] + }, + "options": {} + }, + "id": "config-coordenador-glpi", + "name": "Config - Coordenador GLPI", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 440, + 300 + ] + }, + { + "parameters": { + "jsCode": "const config = $('Config - Coordenador GLPI').first().json;\n\nconst diasSemInteracaoAlerta = Number(config.dias_sem_interacao_alerta || 2);\nconst horasSlaRisco = Number(config.horas_sla_risco || 8);\nconst glpiBaseUrl = String(config.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Top 10 chamados abertos por blocos operacionais.\n-- Blocos:\n-- - 3 chamados com SLA vencido mais criticos\n-- - 3 chamados com maior tempo sem interacao\n-- - 2 chamados sem tecnico atribuido\n-- - 2 chamados de prioridade alta mais recentes\n-- Se faltar item em algum bloco, o complemento vem do ranking geral.\n--\n-- Ajuste os parametros abaixo no workflow n8n:\n-- {DIAS_SEM_INTERACAO_ALERTA}\n-- {HORAS_SLA_RISCO}\n-- {GLPI_BASE_URL}\n\nWITH ultima_interacao AS (\n SELECT\n tickets_id,\n MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_mod AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_creation AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n\n UNION ALL\n\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_mod AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_creation AS data_interacao\n FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT\n tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id,\n t.name AS titulo,\n t.status,\n CASE t.status\n WHEN 1 THEN 'Novo'\n WHEN 2 THEN 'Em atendimento'\n WHEN 3 THEN 'Planejado'\n WHEN 4 THEN 'Pendente'\n ELSE CONCAT('Status ', t.status)\n END AS status_nome,\n t.date AS aberto_em,\n t.date_mod AS modificado_em,\n t.time_to_resolve AS sla_resolver_ate,\n t.priority,\n t.urgency,\n t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE\n WHEN t.time_to_resolve IS NOT NULL\n AND t.time_to_resolve >= NOW()\n AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1\n ELSE 0\n END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id,\n titulo,\n status_nome,\n aberto_em,\n modificado_em,\n ultima_interacao_em,\n dias_aberto,\n dias_sem_interacao,\n sla_resolver_ate,\n horas_ate_sla,\n sla_vencido,\n sla_em_risco,\n priority AS prioridade,\n urgency AS urgencia,\n impact AS impacto,\n entidade,\n categoria,\n tecnico_atribuido,\n sem_tecnico,\n link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE\n WHEN dias_sem_interacao >= 7 THEN 35\n WHEN dias_sem_interacao >= 3 THEN 25\n WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10\n ELSE 0\n END\n + LEAST(dias_aberto, 30)\n + (priority * 8)\n + (urgency * 5)\n + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n), sla_top AS (\n SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem\n FROM chamados_ranked\n WHERE sla_vencido = 1\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 3\n), sem_interacao_top AS (\n SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM sla_top)\n ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC\n LIMIT 3\n), sem_tecnico_top AS (\n SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem\n FROM chamados_ranked\n WHERE sem_tecnico = 1\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 2\n), prioridade_alta_recente_top AS (\n SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem\n FROM chamados_ranked\n WHERE prioridade >= 4\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n AND id NOT IN (SELECT id FROM sem_tecnico_top)\n ORDER BY aberto_em DESC, score_criticidade DESC\n LIMIT 2\n), selecionados AS (\n SELECT * FROM sla_top\n UNION ALL\n SELECT * FROM sem_interacao_top\n UNION ALL\n SELECT * FROM sem_tecnico_top\n UNION ALL\n SELECT * FROM prioridade_alta_recente_top\n), complemento AS (\n SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM selecionados)\n ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 10\n)\nSELECT *\nFROM (\n SELECT * FROM selecionados\n UNION ALL\n SELECT * FROM complemento\n) ranking_final\nORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT 10;\n`;\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);\n\nreturn [{ json: { query } }];" + }, + "id": "code-montar-sql-top-10", + "name": "Code - Montar SQL Top 10", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 300 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + }, + "id": "mysql-top-10-chamados-abertos", + "name": "MySQL - Top 10 chamados abertos", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 960, + 300 + ], + "credentials": { + "mySql": { + "id": "", + "name": "MySQL GLPI" + } + } + }, + { + "parameters": { + "jsCode": "const chamados = $input.all().map(item => item.json);\nconst hoje = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst hojeIso = new Date().toLocaleDateString('sv-SE', { timeZone: 'America/Sao_Paulo' });\nconst staticData = $getWorkflowStaticData('global');\nconst historicoAnterior = staticData.reincidencia_chamados || {};\nconst idsUltimoDisparo = new Set(staticData.ultimos_chamados_enviados || []);\nconst ultimaDataDisparo = staticData.ultima_data_disparo;\nconst mesmaData = ultimaDataDisparo === hojeIso;\n\nconst chamadosComReincidencia = chamados.map(chamado => {\n const id = String(chamado.id);\n const anterior = historicoAnterior[id] || {};\n let diasSeguidos;\n\n if (mesmaData && anterior.ultima_data === hojeIso) {\n diasSeguidos = Number(anterior.dias_seguidos || 1);\n } else if (idsUltimoDisparo.has(id)) {\n diasSeguidos = Number(anterior.dias_seguidos || 1) + 1;\n } else {\n diasSeguidos = 1;\n }\n\n return { ...chamado, reincidencia_dias: diasSeguidos };\n});\n\nstaticData.reincidencia_chamados = Object.fromEntries(\n chamadosComReincidencia.map(chamado => [String(chamado.id), {\n dias_seguidos: chamado.reincidencia_dias,\n ultima_data: hojeIso\n }])\n);\nstaticData.ultimos_chamados_enviados = chamadosComReincidencia.map(chamado => String(chamado.id));\nstaticData.ultima_data_disparo = hojeIso;\n\nfunction slaTexto(chamado) {\n if (Number(chamado.sla_vencido) === 1) return 'Vencido';\n if (Number(chamado.sla_em_risco) === 1) return `Risco ${chamado.horas_ate_sla}h`;\n return chamado.sla_resolver_ate ? 'OK' : 'Sem SLA';\n}\n\nfunction reincidenciaTexto(dias) {\n if (dias >= 3) return `${dias} dias - ESCALAR`;\n if (dias === 2) return '2 dias - reincidente';\n return '1 dia';\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 80 ? `${limpo.slice(0, 77)}...` : limpo;\n}\n\nconst resumo = {\n sla_vencido: chamadosComReincidencia.filter(c => Number(c.sla_vencido) === 1).length,\n sem_tecnico: chamadosComReincidencia.filter(c => Number(c.sem_tecnico) === 1).length,\n reincidentes_2_dias: chamadosComReincidencia.filter(c => Number(c.reincidencia_dias) === 2).length,\n escalar_3_dias_ou_mais: chamadosComReincidencia.filter(c => Number(c.reincidencia_dias) >= 3).length\n};\n\nconst linhas = chamadosComReincidencia.map((chamado, index) => [\n `${index + 1}. #${chamado.id} - ${tituloCurto(chamado.titulo)}`,\n `Tecnico: ${chamado.tecnico_atribuido} | Fila: ${chamado.bloco_origem || 'Ranking geral'}`,\n `SLA: ${slaTexto(chamado)} | Sem interacao: ${chamado.dias_sem_interacao}d | Aberto: ${chamado.dias_aberto}d | Reinc.: ${reincidenciaTexto(Number(chamado.reincidencia_dias || 1))}`,\n `${chamado.link_glpi}`\n].join('\\n'));\n\nconst escalados = chamadosComReincidencia\n .filter(chamado => Number(chamado.reincidencia_dias) >= 3)\n .map(chamado => `#${chamado.id}`);\n\nconst textoPronto = chamadosComReincidencia.length\n ? [\n `@time Top 10 chamados para atencao hoje - ${hoje}`,\n '',\n `Resumo: ${resumo.sla_vencido} SLA vencido(s) | ${resumo.sem_tecnico} sem tecnico | ${resumo.reincidentes_2_dias} reincidente(s) 2d | ${resumo.escalar_3_dias_ou_mais} escalar 3d+`,\n '',\n ...linhas,\n '',\n escalados.length ? `Escalar para coordenacao: ${escalados.join(', ')}` : 'Sem chamados com 3 dias seguidos na lista.',\n 'Atualizem o GLPI para o chamado sair da reincidencia.'\n ].join('\\n')\n : [`@time Coordenador GLPI - ${hoje}`, '', 'Nenhum chamado aberto encontrado pelos criterios da query.'].join('\\n');\n\nconst promptIa = [\n 'Revise a mensagem abaixo para Slack.',\n 'Regras obrigatorias:',\n '- Nao adicione fatos.',\n '- Nao crie motivo, acao ou prazo.',\n '- Nao altere IDs, tecnicos, datas, SLA, dias, reincidencia ou links.',\n '- Nao use tom agressivo.',\n '- Mantenha a mensagem curta e facil de escanear.',\n '- Retorne somente a mensagem final.',\n '',\n textoPronto\n].join('\\n');\n\nreturn [{ json: { chamados: chamadosComReincidencia, resumo, texto_pronto: textoPronto, prompt_ia: promptIa, total: chamadosComReincidencia.length, data_relatorio: hoje } }];" + }, + "id": "code-reincidencia-mensagem", + "name": "Code - Reincidencia e mensagem curta", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1220, + 300 + ] + }, + { + "parameters": { + "model": "glm-4.5-flash", + "options": { + "temperature": 0.1 + } + }, + "id": "z-ia-chat-model-coordenador", + "name": "Z IA Chat Model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "position": [ + 1480, + 520 + ], + "credentials": { + "openAiApi": { + "id": "", + "name": "Z IA" + } + } + }, + { + "parameters": { + "promptType": "define", + "text": "={{ $json.prompt_ia }}", + "options": { + "systemMessage": "Voce e um revisor de mensagem operacional para Slack. Sua funcao e apenas melhorar clareza e reduzir texto. Nao invente dados. Nao adicione motivo, acao, prazo, diagnostico ou cobranca agressiva. Preserve IDs, nomes, tecnicos, links e numeros exatamente como recebidos." + } + }, + "id": "ai-agent-coordenador-glpi", + "name": "AI Agent - Revisar mensagem", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.7, + "position": [ + 1480, + 300 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const resposta = $input.first().json;\nconst textoIa = String(resposta.output || resposta.text || resposta.response || resposta.result || '').trim();\nconst contexto = $('Code - Reincidencia e mensagem curta').first().json;\nconst fallback = contexto.texto_pronto;\nconst chamados = contexto.chamados || [];\n\nconst contemTodosIds = chamados.every(chamado => textoIa.includes(`#${chamado.id}`));\nconst contemTodosLinks = chamados.every(chamado => !chamado.link_glpi || textoIa.includes(chamado.link_glpi));\nconst contemCampoProibido = /(^|\\n)\\s*\\*{0,2}(motivo|acao|ação|prazo)\\*{0,2}\\s*:/i.test(textoIa);\nconst textoSeguro = textoIa && contemTodosIds && contemTodosLinks && !contemCampoProibido;\n\nreturn [{ json: { text: textoSeguro ? textoIa : fallback } }];" + }, + "id": "code-preparar-slack", + "name": "Code - Preparar mensagem Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1740, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Config - Coordenador GLPI').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $json.text }}" + } + ] + }, + "options": {} + }, + "id": "http-slack-enviar", + "name": "HTTP - Slack enviar mensagem", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + 300 + ] + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger - Dias uteis 08h": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI", + "type": "main", + "index": 0 + } + ] + ] + }, + "Config - Coordenador GLPI": { + "main": [ + [ + { + "node": "Code - Montar SQL Top 10", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Montar SQL Top 10": { + "main": [ + [ + { + "node": "MySQL - Top 10 chamados abertos", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Top 10 chamados abertos": { + "main": [ + [ + { + "node": "Code - Reincidencia e mensagem curta", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Reincidencia e mensagem curta": { + "main": [ + [ + { + "node": "AI Agent - Revisar mensagem", + "type": "main", + "index": 0 + } + ] + ] + }, + "Z IA Chat Model": { + "ai_languageModel": [ + [ + { + "node": "AI Agent - Revisar mensagem", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "AI Agent - Revisar mensagem": { + "main": [ + [ + { + "node": "Code - Preparar mensagem Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar mensagem Slack": { + "main": [ + [ + { + "node": "HTTP - Slack enviar mensagem", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c12a996 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +# Coordenador GLPI - Chamados Abertos + +Projeto n8n para atuar como coordenador diario de Suporte de TI, analisando chamados abertos no GLPI e enviando no Slack os 10 chamados que precisam de acao imediata. + +## Objetivo + +Todos os dias uteis, o workflow consulta o banco do GLPI, calcula um score operacional para cada chamado aberto e envia uma lista priorizada no Slack. + +O foco nao e apenas listar chamados antigos. O ranking considera: + +- SLA vencido ou proximo de vencer +- tempo sem interacao humana +- prioridade, urgencia e impacto +- idade do chamado +- chamado sem tecnico atribuido +- chamados em categorias e entidades reais do GLPI + +## Arquivos + +- `Coordenador GLPI - Chamados Abertos Slack.n8n.json`: workflow importavel no n8n +- `sql/top_10_chamados_abertos_glpi.sql`: query base usada pelo workflow +- `.env.example`: variaveis para subir n8n localmente +- `docker-compose.yml`: n8n local via Docker + +## Como configurar + +1. Suba o n8n local, se necessario: + +```powershell +docker compose up -d +``` + +2. Acesse: + +```text +http://localhost:5678 +``` + +3. Importe o workflow: + +```text +Coordenador GLPI - Chamados Abertos Slack.n8n.json +``` + +4. Configure a credencial MySQL no node: + +```text +MySQL - Top 10 chamados abertos +``` + +5. Abra o node `Config - Coordenador GLPI` e preencha: + +- `slack_webhook_url`: webhook do canal Slack +- `dias_sem_interacao_alerta`: padrao `2` +- `horas_sla_risco`: padrao `8` + +6. Execute pelo `Manual Trigger` para validar. + +7. Ative o workflow para rodar pelo `Schedule Trigger - Dias uteis 08h`. + +## Metodologia de priorizacao + +Cada chamado aberto recebe um `score_criticidade`, mas a lista final nao e mais apenas o top 10 geral. O workflow monta a lista por blocos para evitar que chamados muito antigos monopolizem todos os dias. + +Blocos do top 10: + +- 3 chamados com SLA vencido mais criticos +- 3 chamados com maior tempo sem interacao +- 2 chamados sem tecnico atribuido +- 2 chamados de prioridade alta mais recentes + +Se algum bloco nao tiver chamados suficientes, o complemento vem do ranking geral. + +O mesmo chamado nao entra duas vezes. Se ele ja apareceu em um bloco anterior, e removido dos blocos seguintes. + +### Score + +Pesos principais: + +- SLA vencido: `+100` +- SLA vencendo nas proximas horas configuradas: `+45` +- chamado sem interacao ha 7 dias ou mais: `+35` +- chamado sem interacao ha 3 a 6 dias: `+25` +- chamado sem interacao ha 1 a 2 dias: `+10` +- prioridade GLPI: `priority * 8` +- urgencia GLPI: `urgency * 5` +- impacto GLPI: `impact * 5` +- idade do chamado: ate `+30` +- sem tecnico atribuido: `+25` + +O resultado e ordenado por: + +1. `score_criticidade` desc +2. chamados com SLA vencido primeiro +3. maior tempo sem interacao +4. chamados mais antigos + +## Reincidencia + +O workflow controla reincidencia usando `staticData` global do n8n, sem gravar nada no banco do GLPI. + +Regra: + +- apareceu 1 vez: normal +- apareceu 2 dias seguidos: marcado como reincidente +- apareceu 3 dias seguidos ou mais: escalar para coordenacao + +Campos salvos no `staticData`: + +- `ultima_data_disparo` +- `ultimos_chamados_enviados` +- `reincidencia_chamados` + +Se o workflow for duplicado, recriado ou importado como novo workflow, esse historico pode ser perdido porque pertence ao workflow no n8n. + +## Tempo sem interacao + +O campo `dias_sem_interacao` considera a maior data encontrada entre: + +- followups publicos do chamado: `date`, `date_mod`, `date_creation` +- tarefas do chamado: `date`, `date_mod`, `date_creation` +- modificacao do chamado: `glpi_tickets.date_mod` +- abertura do chamado: `glpi_tickets.date` + +Isso evita marcar um chamado como parado quando alguem acabou de adicionar ou alterar uma tarefa no GLPI, mesmo que a tarefa esteja planejada para outro dia. + +Se a maior data encontrada estiver no futuro por causa de uma tarefa planejada, o contador fica em `0` dias sem interacao, evitando valores negativos no relatorio. + +## Regra de chamados abertos + +O SQL considera abertos todos os chamados com: + +```sql +is_deleted = false +AND status NOT IN (5, 6) +``` + +Este gatilho principal ignora chamados das categorias: + +- `ADMINISTRATIVO` +- `DESENVOLVIMENTO` +- `INFRA` + +O filtro usa o nome completo da categoria (`completename`) ou o nome simples (`name`) iniciando com esses termos, entao tambem remove subcategorias dessas filas. + +No GLPI, normalmente: + +- `5`: solucionado +- `6`: fechado + +## Campos usados + +A query usa tabelas comuns do GLPI: + +- `glpi_tickets` +- `glpi_itilcategories` +- `glpi_entities` +- `glpi_itilfollowups` +- `glpi_tickettasks` +- `glpi_tickets_users` +- `glpi_users` + +Se sua versao do GLPI tiver customizacoes, valide primeiro a query `sql/top_10_chamados_abertos_glpi.sql` diretamente no banco. + +## Saida no Slack + +O Slack recebe uma mensagem curta com: + +- top 10 chamados para atacar hoje +- tecnico atribuido, quando existir +- SLA +- dias sem interacao +- dias aberto +- reincidencia +- link do chamado, se `glpi_base_url` estiver configurado no workflow + +Campos removidos da mensagem para reduzir ruido: + +- motivo +- acao +- prazo + +Se o agente de IA falhar, o workflow envia a mensagem curta gerada por codigo, sem depender da IA. + +## Agente de IA do n8n com Z IA + +O workflow usa o node: + +```text +AI Agent - Revisar mensagem +``` + +Por padrao, ele esta conectado ao node: + +```text +Z IA Chat Model +``` + +Esse node usa o chat model compativel com OpenAI do n8n, mas a credencial deve ser configurada para a Z IA. + +A IA nao monta mais a mensagem do zero. O node `Code - Reincidencia e mensagem curta` monta a mensagem final de forma deterministica, e a IA apenas revisa clareza e reduz texto. + +Regras do prompt: + +- nao adicionar fatos +- nao criar motivo +- nao criar acao +- nao criar prazo +- nao alterar IDs, tecnicos, datas, SLA, dias, reincidencia ou links +- nao usar tom agressivo + +No n8n, abra o node: + +```text +Z IA Chat Model +``` + +Configure: + +- credencial `Z IA` +- endpoint/base URL da Z IA, se a credencial permitir +- modelo correto disponibilizado pela Z IA, por exemplo `glm-4.5-flash` + +No arquivo importavel deixei o modelo como `glm-4.5-flash`. Se sua conta Z IA usar outro nome de modelo, troque pelo nome exato exibido na plataforma. + +Se aparecer `The resource you are requesting could not be found`, revise principalmente: + +- Base URL da credencial: normalmente `https://api.z.ai/api/paas/v4` +- Modelo: precisa existir na sua conta, por exemplo `glm-4.5-flash` +- Chave API: precisa ser da Z IA, nao da OpenAI +- Node usado: mantenha `Z IA Chat Model` conectado ao `AI Agent - Revisar mensagem` + +Se a Z IA aparecer no seu n8n como node proprio, mantenha o `AI Agent - Revisar mensagem` e substitua somente o subnode `Z IA Chat Model` pelo modelo oficial da Z IA. + +## Recomendacao direta + +Nao trate isso como relatorio bonito. Trate como fila diaria de comando. + +Minha sugestao operacional: + +- rodar as 08h no canal da lideranca de suporte +- exigir responsavel e prazo para cada um dos 10 chamados ate 10h +- repetir apenas os chamados que continuarem parados no dia seguinte +- se um chamado aparecer 3 dias seguidos, escalar para coordenacao/gestao +- separar um segundo workflow para chamados sem tecnico atribuido, porque isso e falha de triagem, nao backlog comum +- criar outro alerta intradiario para SLA vencendo nas proximas 2 horas + +Se quiser resultado real, nao basta notificar. Tem que transformar a mensagem do Slack em ritual diario de cobranca. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa72b92 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + n8n: + image: docker.n8n.io/n8nio/n8n:latest + container_name: n8n-coordenador-glpi + restart: unless-stopped + ports: + - "${N8N_PORT:-5678}:5678" + environment: + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo} + - TZ=${TZ:-America/Sao_Paulo} + - N8N_HOST=${N8N_HOST:-localhost} + - N8N_PORT=5678 + - N8N_PROTOCOL=${N8N_PROTOCOL:-http} + - N8N_BASIC_AUTH_ACTIVE=${N8N_BASIC_AUTH_ACTIVE:-true} + - N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin} + - N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-troque-esta-senha} + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-troque-esta-chave-com-32-caracteres} + volumes: + - n8n_data:/home/node/.n8n + +volumes: + n8n_data: diff --git a/sql/top_10_chamados_abertos_glpi.sql b/sql/top_10_chamados_abertos_glpi.sql new file mode 100644 index 0000000..b206f6b --- /dev/null +++ b/sql/top_10_chamados_abertos_glpi.sql @@ -0,0 +1,213 @@ +-- Top 10 chamados abertos por blocos operacionais. +-- Blocos: +-- - 3 chamados com SLA vencido mais criticos +-- - 3 chamados com maior tempo sem interacao +-- - 2 chamados sem tecnico atribuido +-- - 2 chamados de prioridade alta mais recentes +-- Se faltar item em algum bloco, o complemento vem do ranking geral. +-- +-- Ajuste os parametros abaixo no workflow n8n: +-- {DIAS_SEM_INTERACAO_ALERTA} +-- {HORAS_SLA_RISCO} +-- {GLPI_BASE_URL} + +WITH ultima_interacao AS ( + SELECT + tickets_id, + MAX(data_interacao) AS ultima_interacao_em + FROM ( + SELECT tickets_id, MAX(data_interacao) AS data_interacao + FROM ( + SELECT items_id AS tickets_id, date AS data_interacao + FROM glpi_itilfollowups + WHERE itemtype = 'Ticket' + AND is_private = 0 + + UNION ALL + + SELECT items_id AS tickets_id, date_mod AS data_interacao + FROM glpi_itilfollowups + WHERE itemtype = 'Ticket' + AND is_private = 0 + + UNION ALL + + SELECT items_id AS tickets_id, date_creation AS data_interacao + FROM glpi_itilfollowups + WHERE itemtype = 'Ticket' + AND is_private = 0 + ) followup_datas + WHERE data_interacao IS NOT NULL + GROUP BY tickets_id + + UNION ALL + + SELECT tickets_id, MAX(data_interacao) AS data_interacao + FROM ( + SELECT tickets_id, date AS data_interacao + FROM glpi_tickettasks + + UNION ALL + + SELECT tickets_id, date_mod AS data_interacao + FROM glpi_tickettasks + + UNION ALL + + SELECT tickets_id, date_creation AS data_interacao + FROM glpi_tickettasks + ) task_datas + WHERE data_interacao IS NOT NULL + GROUP BY tickets_id + ) interacoes + GROUP BY tickets_id +), tecnicos AS ( + SELECT + tu.tickets_id, + GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos + FROM glpi_tickets_users tu + LEFT JOIN glpi_users u ON u.id = tu.users_id + WHERE tu.type = 2 + GROUP BY tu.tickets_id +), chamados_base AS ( + SELECT + t.id, + t.name AS titulo, + t.status, + CASE t.status + WHEN 1 THEN 'Novo' + WHEN 2 THEN 'Em atendimento' + WHEN 3 THEN 'Planejado' + WHEN 4 THEN 'Pendente' + ELSE CONCAT('Status ', t.status) + END AS status_nome, + t.date AS aberto_em, + t.date_mod AS modificado_em, + t.time_to_resolve AS sla_resolver_ate, + t.priority, + t.urgency, + t.impact, + COALESCE(e.completename, e.name, 'Nao informado') AS entidade, + COALESCE(c.completename, c.name, 'Sem categoria') AS categoria, + COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido, + GREATEST( + COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), + COALESCE(t.date_mod, '1970-01-01 00:00:00'), + COALESCE(t.date, '1970-01-01 00:00:00') + ) AS ultima_interacao_em, + TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto, + GREATEST(TIMESTAMPDIFF(DAY, GREATEST( + COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), + COALESCE(t.date_mod, '1970-01-01 00:00:00'), + COALESCE(t.date, '1970-01-01 00:00:00') + ), NOW()), 0) AS dias_sem_interacao, + TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla, + CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido, + CASE + WHEN t.time_to_resolve IS NOT NULL + AND t.time_to_resolve >= NOW() + AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 + ELSE 0 + END AS sla_em_risco, + CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico, + CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi + FROM glpi_tickets t + LEFT JOIN glpi_entities e ON e.id = t.entities_id + LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id + LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id + LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id + WHERE t.is_deleted = false + AND t.status NOT IN (5, 6) + AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%' + AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%' + AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%' +), chamados_ranked AS ( + SELECT + id, + titulo, + status_nome, + aberto_em, + modificado_em, + ultima_interacao_em, + dias_aberto, + dias_sem_interacao, + sla_resolver_ate, + horas_ate_sla, + sla_vencido, + sla_em_risco, + priority AS prioridade, + urgency AS urgencia, + impact AS impacto, + entidade, + categoria, + tecnico_atribuido, + sem_tecnico, + link_glpi, + ( + CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END + + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END + + CASE + WHEN dias_sem_interacao >= 7 THEN 35 + WHEN dias_sem_interacao >= 3 THEN 25 + WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 + ELSE 0 + END + + LEAST(dias_aberto, 30) + + (priority * 8) + + (urgency * 5) + + (impact * 5) + + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END + ) AS score_criticidade + FROM chamados_base +), sla_top AS ( + SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem + FROM chamados_ranked + WHERE sla_vencido = 1 + ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC + LIMIT 3 +), sem_interacao_top AS ( + SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem + FROM chamados_ranked + WHERE id NOT IN (SELECT id FROM sla_top) + ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC + LIMIT 3 +), sem_tecnico_top AS ( + SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem + FROM chamados_ranked + WHERE sem_tecnico = 1 + AND id NOT IN (SELECT id FROM sla_top) + AND id NOT IN (SELECT id FROM sem_interacao_top) + ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC + LIMIT 2 +), prioridade_alta_recente_top AS ( + SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem + FROM chamados_ranked + WHERE prioridade >= 4 + AND id NOT IN (SELECT id FROM sla_top) + AND id NOT IN (SELECT id FROM sem_interacao_top) + AND id NOT IN (SELECT id FROM sem_tecnico_top) + ORDER BY aberto_em DESC, score_criticidade DESC + LIMIT 2 +), selecionados AS ( + SELECT * FROM sla_top + UNION ALL + SELECT * FROM sem_interacao_top + UNION ALL + SELECT * FROM sem_tecnico_top + UNION ALL + SELECT * FROM prioridade_alta_recente_top +), complemento AS ( + SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem + FROM chamados_ranked + WHERE id NOT IN (SELECT id FROM selecionados) + ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC + LIMIT 10 +) +SELECT * +FROM ( + SELECT * FROM selecionados + UNION ALL + SELECT * FROM complemento +) ranking_final +ORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC +LIMIT 10;