From c21c00075c43f2fa46bed1d8b5acf9638a302749 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Mon, 23 Feb 2026 16:45:46 -0300 Subject: [PATCH] feat(sync): mapear work_notes SN para tarefas GLPI e ajustar regras/README - coleta comments e work_notes do SN\n- envia comments como followup e work_notes como tarefa no GLPI\n- usa sys_created_by como author no banco intermediario\n- formata mensagem SN->GLPI com autor no cabecalho\n- aplica regras de resolucao/encerramento SN sem fechar GLPI\n- documenta regras de negocio no README --- .env.development | 1 + README.md | 19 ++++++++++++-- src/models/ticketGlpiModel.js | 27 ++++++++++++++++++++ src/services/glpiCommentService.js | 33 +++++++++++++++++++++--- src/services/servicenowService.js | 41 +++++++++++++++++++++++------- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/.env.development b/.env.development index f5dfbcc..4fa1039 100644 --- a/.env.development +++ b/.env.development @@ -27,6 +27,7 @@ SERVICENOW_SC_ITEM_OPTION_URL=https://caoadev.service-now.com/api/now/table/sc_i SERVICENOW_DEFAULT_USER=SOTHIS.CAOA SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98 SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77 +SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=false # GLPI (banco real) GLPI_DB_TYPE=mysql diff --git a/README.md b/README.md index 3eeb87f..e541fb6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Middleware em Node.js para sincronizacao bidirecional de tickets, comentarios e - Sincroniza comentarios em duas direcoes: - ServiceNow -> GLPI - GLPI -> ServiceNow +- No fluxo ServiceNow -> GLPI: + - `comments` viram comentarios (followups) no GLPI. + - `work_notes` viram tarefas no GLPI. - Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`). - Trata regras de negocio especificas de fechamento/reabertura. - Reprocessa tickets em estado de erro no inicio de cada ciclo. @@ -44,6 +47,11 @@ Executado em ordem pelo `main()`: ## Regras de negocio importantes - Controle de precedencia por `source_last` para reduzir conflito de atualizacao. +- Ao detectar divergencia de status, o bastao nao e invertido para `GLPI` quando a ultima origem valida ja e `SNOW`. +- Encerramento/Resolucao vindo do ServiceNow: + - Nao fecha ticket no GLPI automaticamente. + - Insere nota formatada no GLPI. + - Marca sync como `closed/closed` para remover do monitoramento. - Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo): - Nao resolve no SN. - Adiciona `work_note` no SN. @@ -66,7 +74,10 @@ Executado em ordem pelo `main()`: - Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`. 3. Status ServiceNow -> GLPI -- Mudancas de estado no SN sao refletidas no GLPI respeitando o estado final de fechado. +- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos. +- Para `Resolvido`, `Encerrado` e `Encerrado - Omitido` no SN: + - GLPI recebe nota de encerramento/resolucao. + - Integracao remove ticket do monitoramento (`closed/closed`), sem fechar ticket no GLPI. - Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao. 4. Comentarios GLPI -> SN @@ -75,6 +86,9 @@ Executado em ordem pelo `main()`: 5. Comentarios SN -> GLPI - Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI. +- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI. +- Campo `author` no banco intermediario usa `sys_created_by` do SN. +- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho. - IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade. 6. Reprocessamento de erro @@ -114,6 +128,8 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua - `SERVICENOW_TABLE_REQUEST_URL` - `SERVICENOW_TABLE_JOURNAL_URL` - `SERVICENOW_SC_ITEM_OPTION_URL` +- `SERVICENOW_DEFAULT_USER` +- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais) ### GLPI (MySQL/MariaDB) @@ -191,7 +207,6 @@ Definicao base em `src/scripts/database/scriptBD.sql`: ## Limitacoes conhecidas (estado atual) - Nao ha suite de testes automatizados (`npm test` e placeholder). -- Ha regras hardcoded que deveriam vir de configuracao (ex.: `resolved_by` em fechamento no SN). - Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). - Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento. diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 6d1d1df..3c13625 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -313,6 +313,33 @@ class TicketGlpiModel { } } + static async insertTask(task, items_id) { + const connection = await glpiPool.getConnection(); + try { + // Registra work_notes do ServiceNow como tarefa no GLPI. + const query = ` + INSERT INTO glpi_tickettasks + (tickets_id, users_id, state, content, date, date_mod, actiontime) + VALUES (?, ?, ?, ?, NOW(), NOW(), ?) + `; + const values = [ + items_id, + GLPI_DEFAULT_USER_ID, + 2, + task.content, + 0 + ]; + + const [result] = await connection.execute(query, values); + return [{ id: result.insertId }]; + } catch(error){ + logError(`Erro ao inserir tarefa no GLPI: ${error}`); + throw error; + } finally { + if (connection) connection.release(); + } + } + static async getClosedTickets(watermark, limit = 100) { const connection = await glpiPool.getConnection(); try { diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index 82b5553..79400b7 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -7,6 +7,24 @@ const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./serv const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); const { log } = require('winston'); +const formatSNUpdateForGlpi = (update) => { + const escapeHtml = (value) => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>'); + + const author = escapeHtml(update.author || 'ServiceNow'); + const content = escapeHtml(update.content || ''); + + return ` +
+
${author}
+
+ ${content} +
+
`; +}; + const syncCommentsGlpitoSN = async (ticket) => { try { logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...'); @@ -88,17 +106,24 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => { for (const comment of comments) { try { - const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id); + const formattedPayload = { + ...comment, + content: formatSNUpdateForGlpi(comment) + }; + const isTask = comment.update_type === 'task'; + const insertedId = isTask + ? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id) + : await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); } - await TicketUpdateModel.updateDestinyId(comment.source_id, commentId[0].id); + await TicketUpdateModel.updateDestinyId(comment.source_id, insertedId[0].id); - logInfo(`OK: Comentario inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`); + logInfo(`OK: ${isTask ? 'Tarefa' : 'Comentario'} inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`); } catch (error) { - logError(`ERRO: Erro ao processar comentario do ticket ${ticket.sn_ticket_id}: ${error}`); + logError(`ERRO: Erro ao processar atualizacao do ticket ${ticket.sn_ticket_id}: ${error}`); try { await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error'); diff --git a/src/services/servicenowService.js b/src/services/servicenowService.js index a11ebbe..1469b8c 100644 --- a/src/services/servicenowService.js +++ b/src/services/servicenowService.js @@ -9,6 +9,8 @@ const TicketSyncModel = require('../models/ticketSyncModel'); const { log } = require('winston'); const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77'; +const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA'; +const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false'; const fetchTicketsFromServiceNow = async (watermark) => { try { @@ -175,7 +177,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => { const fetchCommentsFromServiceNow = async (ticket) => { try { - logInfo(`Iniciando busca de comentarios do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); + logInfo(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); const sys_id = ticket.sys_id; @@ -189,7 +191,7 @@ const fetchCommentsFromServiceNow = async (ticket) => { while (true) { page++; const params = { - sysparm_query: `element_id=${encodeURIComponent(sys_id)}^element=comments`, + sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`, sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id', sysparm_limit: pageSize, sysparm_offset: offset @@ -224,14 +226,26 @@ const fetchCommentsFromServiceNow = async (ticket) => { } } - // Traz apenas os tickets que nao sao do sys_created_by: "SOTHIS.CAOA" - const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA'); + // Mantem comportamento legado para comments (evita loop da integracao). + // Em ambiente de teste, a regra pode ser desligada por env. + const filteredComments = allComments.filter((entry) => { + if (!shouldIgnoreDefaultUserJournal) { + return true; + } + if (entry.element === 'comments') { + return entry.sys_created_by !== servicenowDefaultUser; + } + if (entry.element === 'work_notes') { + return entry.sys_created_by !== servicenowDefaultUser; + } + return true; + }); if (filteredComments.length === 0) { - logInfo(`Nenhum comentario encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); + logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return []; } - logInfo(`Encontrado ${filteredComments.length} comentarios para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) + logInfo(`Encontrado ${filteredComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) const commentsInserted = []; @@ -244,13 +258,22 @@ const fetchCommentsFromServiceNow = async (ticket) => { continue; } else { + let normalizedContent = comment.value; + if ( + comment.element === 'work_notes' && + typeof normalizedContent === 'string' && + normalizedContent.includes('Registro alterado por:') + ) { + normalizedContent = `Anotacao de trabalho adicionada pelo Service Now (registro automatico).\n\nMensagem original:\n${normalizedContent}`; + } + const commentData = { ticket_sync_id: ticket.id, - update_type: 'comment', + update_type: comment.element === 'work_notes' ? 'task' : 'comment', source_system: 'servicenow', - content: comment.value, + content: normalizedContent, created_at: new Date(comment.sys_created_on), - author: "CAOA", + author: comment.sys_created_by || 'ServiceNow', updated_at: new Date(), source_id: comment.sys_id, destiny_id: null