{ "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": [] }