From e12957181203da283748dc68dfb96c3de595b541 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Tue, 24 Feb 2026 17:40:39 -0300 Subject: [PATCH] =?UTF-8?q?FIX:=20Corre=C3=A7=C3=B5es=20de=20sincroniza?= =?UTF-8?q?=C3=A7=C3=A3o=20SN<->GLPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feito ajuste de direção de status para respeitar source_last e evitar reabertura indevida - feito tratamento de reabertura SN->GLPI após resolução, mantendo bloqueio para fechamento permanente - feito ajuste de finalização GLPI->SN para propagar resolução/fechamento corretamente - feito correção de runtime no processStatusController (uso de variável antes da inicialização) - feito correção para não prender ticket em status transitório de comentários - feito preservação de ordem cronológica e datas reais de comments/work_notes SN no GLPI - feito normalização de autor (ex.: email/login para nome amigável) - feito destaque visual para mensagens automáticas e encerramento no GLPI - feito sanitização de justificativa na regra de fora de escopo - feito padronização de variáveis de ambiente e remoção de inconsistências de config - feito separação de SQL destrutivo para arquivo de manutenção dedicado --- .env.development | 59 ------------------ .env.example | 3 + .gitignore | 3 +- README.md | 25 +++++++- config/apiConfig.js | 8 +-- src/controllers/processStatusController.js | 49 +++++++++++---- src/models/ticketGlpiModel.js | 43 ++++++++++--- src/models/ticketSyncModel.js | 3 +- src/scripts/database/maintenance_reset.sql | 25 ++++++++ src/scripts/database/scriptBD.sql | 10 --- src/services/glpiCommentService.js | 72 +++++++++++++++++++--- src/services/glpiTicketService.js | 20 +++--- src/services/servicenowService.js | 60 +++++++++++++++--- 13 files changed, 253 insertions(+), 127 deletions(-) delete mode 100644 .env.development create mode 100644 src/scripts/database/maintenance_reset.sql diff --git a/.env.development b/.env.development deleted file mode 100644 index 4fa1039..0000000 --- a/.env.development +++ /dev/null @@ -1,59 +0,0 @@ -# ========================================= -# DEVELOPMENT CONFIGURATION -# ========================================= -NODE_ENV=development -PORT=3000 - -# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos) -CRON_SCHEDULE='* * * * *' - -# ServiceNow API (Dev) -SERVICENOW_INSTANCE=https://caoadev.service-now.com -SERVICENOW_USERNAME=SOTHIS.CAOA -SERVICENOW_PASSWORD=#Sothis20 - -# GRUPO SOTHIS PARA BUSCA -SERVICENOW_ASSIGNMENT_GROUP="1132c9e01bfad410e77eb9121b4bcbab" - -# Endpoints -SERVICENOW_INCIDENT_URL=https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20 -SERVICENOW_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_display_value=all&sysparm_limit=20 -SERVICENOW_TABLE_INCIDENT_URL=https://caoadev.service-now.com/api/now/table/incident -SERVICENOW_TABLE_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_req_item -SERVICENOW_TABLE_JOURNAL_URL=https://caoadev.service-now.com/api/now/table/sys_journal_field -SERVICENOW_SC_ITEM_OPTION_URL=https://caoadev.service-now.com/api/now/table/sc_item_option_mtom - -# Usuario padrao pra criacao de chamados (ServiceNow) -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 -GLPI_DB_HOST=177.73.177.44 -GLPI_DB_PORT=3306 -GLPI_DB_USER=desenvolvimento -GLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W -GLPI_DB_NAME=glpi_data -GLPI_DB_CHARSET=utf8mb4 - -# ID do usuario padrao do GLPI para criacao de tickets e comentarios -GLPI_DEFAULT_USER_ID=11111 -GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa -GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao - -# Banco intermediario (PostgreSQL) -SNGLPI_DB_HOST=10.0.120.75 -SNGLPI_DB_PORT=5432 -SNGLPI_DB_NAME=snglpi-development -SNGLPI_DB_USER=desenvolvimento -SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W - -# CSV de mapeamento -LOCATION_MAPPING_CSV_PATH=\\10.0.121.40\Tecnica\Controle CAOA\ENTIDADE SERVICE NOW\LOCATION_MAPPING.csv - -# Logging -LOG_LEVEL=debug -LOG_RETENTION_DAYS=10 -LOG_DIR=logs diff --git a/.env.example b/.env.example index 10f7f06..1791e5c 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ GLPI_DB_CHARSET=utf8mb4 # ID do usuario padrao do GLPI para criacao de tickets e comentarios GLPI_DEFAULT_USER_ID=1118 +GLPI_DEFAULT_GROUP_ID=30 +GLPI_DEFAULT_ENTITY_ID=127 +GLPI_OOS_SOLUTION_TYPE_ID=27 GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao diff --git a/.gitignore b/.gitignore index 1661cd7..c4c84b1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ config/*.json servicenow_requests.json servicenow_tickets.json location_mapping.log -.env.production +.env* +!.env.example diff --git a/README.md b/README.md index e541fb6..0700047 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Executado em ordem pelo `main()`: - 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): +- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (fora do escopo): - Nao resolve no SN. - Adiciona `work_note` no SN. - Marca sincronizacao como encerrada/ignorada. @@ -71,7 +71,7 @@ Executado em ordem pelo `main()`: 2. Status GLPI -> ServiceNow - Solucao no GLPI dispara tentativa de resolucao no SN. - Se SN estiver em status bloqueante (`Em Espera` ou `Aguardando Atendimento`), a integracao seta `Em Atendimento` antes da resolucao. -- Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`. +- Regra fora do escopo (`solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`. 3. Status ServiceNow -> GLPI - Mudancas de estado no SN sao refletidas no GLPI para tickets ativos. @@ -129,6 +129,7 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua - `SERVICENOW_TABLE_JOURNAL_URL` - `SERVICENOW_SC_ITEM_OPTION_URL` - `SERVICENOW_DEFAULT_USER` +- `SERVICENOW_RESOLVED_BY_SYSID` - `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais) ### GLPI (MySQL/MariaDB) @@ -140,6 +141,9 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua - `GLPI_DB_NAME` - `GLPI_DB_CHARSET` - `GLPI_DEFAULT_USER_ID` +- `GLPI_DEFAULT_GROUP_ID` (padrao `30`) +- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`) +- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`) ### Banco intermediario (PostgreSQL) @@ -194,6 +198,16 @@ Arquivo: `src/scripts/python/update_location_mapping.py` - Recria os dados da tabela `location_mapping` com base no CSV e IDs validos em ambos os bancos. - Roda em loop continuo (ideal via PM2 como processo separado). +## Como os dados chegam do ServiceNow + +- Incidentes: consulta na tabela definida por `SERVICENOW_TABLE_INCIDENT_URL`, filtrando por `assignment_group` e `sys_updated_on >= watermark`. +- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro. +- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`). +- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone. +- Campos principais persistidos localmente: + - Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`. + - Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`. + ## Estrutura do banco intermediario Definicao base em `src/scripts/database/scriptBD.sql`: @@ -204,10 +218,14 @@ Definicao base em `src/scripts/database/scriptBD.sql`: - `location_mapping` - `sync_control` +Arquivo auxiliar de manutencao/reset (com comandos destrutivos): + +- `src/scripts/database/maintenance_reset.sql` + ## Limitacoes conhecidas (estado atual) - Nao ha suite de testes automatizados (`npm test` e placeholder). -- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). +- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão) - Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento. ## Proximas melhorias recomendadas @@ -217,6 +235,7 @@ Definicao base em `src/scripts/database/scriptBD.sql`: - Revisar estrategia de watermark/timezone e idempotencia. - Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados). - Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria). +- Alterar uso de banco do GLPI para a API do GLPI. ## Arquivos principais diff --git a/config/apiConfig.js b/config/apiConfig.js index f47444d..460fc52 100644 --- a/config/apiConfig.js +++ b/config/apiConfig.js @@ -24,11 +24,6 @@ const snIncidentConfig = { baseUrl: process.env.SERVICENOW_INCIDENT_URL, } -const snRequestConfig = { - // A URL base deve ser limpa, sem parâmetros. Os filtros serão adicionados no serviço. - baseUrl: process.env.SERVICENOW_REQUEST_URL, -} - const snTableIncidentConfig = { baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL, } @@ -53,11 +48,10 @@ module.exports = { // configuração do ServiceNow e outras já existentes podem permanecer glpiDbConfig, snIncidentConfig, - snRequestConfig, snTableIncidentConfig, snTableJournalConfig, servicenowAuthentication, snTableRequestConfig, snScItemOptionConfig, assignmentGroup -}; \ No newline at end of file +}; diff --git a/src/controllers/processStatusController.js b/src/controllers/processStatusController.js index a1c3804..5ad2399 100644 --- a/src/controllers/processStatusController.js +++ b/src/controllers/processStatusController.js @@ -3,9 +3,11 @@ const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const { logInfo, logError, logWarning } = require('../utils/logger'); const { stripHTML } = require('../utils/commentSanitizer'); -const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService'); +const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService'); const { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService'); +const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); + const processStatusAndClosureController = async () => { logInfo('Iniciando processamento de status e fechamento de tickets...'); @@ -29,25 +31,50 @@ const processStatusAndClosureController = async () => { logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`); continue; } - const snCurrentStatus = ticketSnDetails.status; + let snCurrentStatus = ticketSnDetails.status; + const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSnDetails.sys_id, ticketSnDetails.tipo); + if (liveSnStatus && liveSnStatus !== snCurrentStatus) { + logInfo(`Status ao vivo do SN para ${ticketSnDetails.ticket_number}: '${snCurrentStatus}' -> '${liveSnStatus}'.`); + snCurrentStatus = liveSnStatus; + await TicketSnModel.updateTicket(ticket.sn_ticket_id, { status: liveSnStatus }); + } + + const snIsFinalState = snCurrentStatus === 'Resolvido' || snCurrentStatus === 'Encerrado' || snCurrentStatus === 'Encerrado - Omitido'; + let effectiveSourceLast = ticket.source_last; + if (snIsFinalState) { + effectiveSourceLast = 'SNOW'; + } // ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI ================= // Primeiro, verificamos se o GLPI mudou de estado por conta própria. const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + const glpiIsFinalState = glpiLiveStatus !== null && glpiLiveStatus >= 5; + const glpiWasFinalLocally = ticket.glpi_sync_status === 'solved' || ticket.glpi_sync_status === 'closed'; // Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento') const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus); - // Compara o status real do GLPI com o status que temos do ServiceNow + // Compara o status real do GLPI com o status do SN apenas para observabilidade. + // A direcao da sincronizacao deve respeitar o source_last persistido. if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) { - logInfo(`Mudanca de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. GLPI esta como '${glpiLiveStatusAsString}', SN esta como '${snCurrentStatus}'.`); - // Hotfix: quando houver divergencia real de status, prioriza GLPI para restaurar fluxo GLPI -> SN. - ticket.source_last = 'GLPI'; + logInfo(`Mudanca de status detectada para o ticket ${ticket.glpi_ticket_id}. GLPI='${glpiLiveStatusAsString}', SN='${snCurrentStatus}', source_last='${effectiveSourceLast}'.`); + } + + // Regra de precedencia de finalizacao: + // quando GLPI ja esta finalizado e SN ainda nao, prioriza GLPI->SN + // apenas se o bastao ainda estiver com GLPI. + // Se o bastao estiver com SNOW, permitimos reabertura SN->GLPI. + if ( + glpiIsFinalState && + !snIsFinalState && + (!glpiWasFinalLocally || ticket.source_last === 'GLPI') + ) { + effectiveSourceLast = 'GLPI'; } // ================= LÓGICA SNOW -> GLPI ================= // Se a última atualização veio do ServiceNow, atualiza o GLPI - if (ticket.source_last === 'SNOW') { + if (effectiveSourceLast === 'SNOW') { logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`); // **LÓGICA DE REABERTURA REFINADA** @@ -81,7 +108,7 @@ const processStatusAndClosureController = async () => { } // ================= LÓGICA GLPI -> SNOW ================= // Se a última atualização veio do GLPI, atualiza o ServiceNow - else if (ticket.source_last === 'GLPI') { + else if (effectiveSourceLast === 'GLPI') { logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`); const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos if (glpiCurrentStatus !== null) { @@ -92,9 +119,9 @@ const processStatusAndClosureController = async () => { const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); // REGRA "FORA DO ESCOPO" - if (solution?.solutiontypes_id === 27) { - logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`); - const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.'; + if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) { + logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`); + const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.'; await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket não será mais exibido para equipe Sothis, com a justificativa: ${justification}`); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 3c13625..652b8e4 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -4,9 +4,23 @@ const LocationMappingModel = require('./locationMappingModel'); // Carrega o ID do usuário padrão do GLPI a partir das variáveis de ambiente const GLPI_DEFAULT_USER_ID = parseInt(process.env.GLPI_DEFAULT_USER_ID, 10) || 1118; +const GLPI_DEFAULT_GROUP_ID = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30; +const GLPI_DEFAULT_ENTITY_ID = parseInt(process.env.GLPI_DEFAULT_ENTITY_ID, 10) || 127; const GLPI_REQ_DEFAULT_JUSTIFICATIVA = process.env.GLPI_REQ_DEFAULT_JUSTIFICATIVA || 'Chamado aberto por colaborador CAOA sem Justificativa'; const GLPI_REQ_DEFAULT_DESCRIPTION = process.env.GLPI_REQ_DEFAULT_DESCRIPTION || 'Chamado aberto por colaborador CAOA sem Descricao'; +const toMySqlDateTime = (value) => { + if (!value) return new Date(); + if (typeof value === 'string') { + const m = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/); + if (m) return `${m[1]} ${m[2]}`; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +}; + class TicketGlpiModel { static mapStatus(snState) { @@ -91,14 +105,14 @@ class TicketGlpiModel { return { title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado - entityId: entityData?.id || 127 + entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID }; } catch (error) { logError(error, 'Erro ao formatar título do ticket'); const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE'; return { title: `${tipo} - ${ticketData.short_description || 'Sem título'}`, - entityId: 127 + entityId: GLPI_DEFAULT_ENTITY_ID }; } } @@ -188,7 +202,7 @@ class TicketGlpiModel { try { await connection.beginTransaction(); - let entityId = 127; + let entityId = GLPI_DEFAULT_ENTITY_ID; if (ticketData.location_id) { entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId; } else if (ticketData.location_name) { @@ -247,7 +261,7 @@ class TicketGlpiModel { `INSERT INTO glpi_groups_tickets (tickets_id, groups_id, type) VALUES (?, ?, 2)`, - [ticketId, 30] + [ticketId, GLPI_DEFAULT_GROUP_ID] ); await connection.commit(); @@ -301,8 +315,14 @@ class TicketGlpiModel { static async insertComment(comment, items_id) { const connection = await glpiPool.getConnection(); try { - const query = 'INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) VALUES(?, ?, NOW(), ?, ?, NOW(), NOW(), ?)'; - const values = ['Ticket', items_id, GLPI_DEFAULT_USER_ID, comment.content , 1]; + const createdAt = toMySqlDateTime(comment.created_at); + const updatedAt = toMySqlDateTime(comment.updated_at || comment.created_at); + const query = ` + INSERT INTO glpi_itilfollowups + (itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) + VALUES(?, ?, ?, ?, ?, ?, ?, ?) + `; + const values = ['Ticket', items_id, createdAt, GLPI_DEFAULT_USER_ID, comment.content, createdAt, updatedAt, 1]; const [result] = await connection.execute(query, values); return [{ id: result.insertId }]; @@ -316,17 +336,21 @@ class TicketGlpiModel { static async insertTask(task, items_id) { const connection = await glpiPool.getConnection(); try { + const createdAt = toMySqlDateTime(task.created_at); + const updatedAt = toMySqlDateTime(task.updated_at || task.created_at); // 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(), ?) + VALUES (?, ?, ?, ?, ?, ?, ?) `; const values = [ items_id, GLPI_DEFAULT_USER_ID, 2, task.content, + createdAt, + updatedAt, 0 ]; @@ -420,11 +444,10 @@ class TicketGlpiModel { const connection = await glpiPool.getConnection(); try { await connection.beginTransaction(); - - status = this.mapStatus(status); + const mappedStatus = this.mapStatus(status); const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`; - await connection.execute(updateQuery, [status, ticketId]); + await connection.execute(updateQuery, [mappedStatus, ticketId]); await connection.commit(); } catch (error) { diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index 5f113ff..39b7a60 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -1,4 +1,3 @@ -const { query } = require('winston'); const pool = require('../data/database'); const { logError, logInfo } = require('../utils/logger'); @@ -265,4 +264,4 @@ class TicketSyncModel { */ -module.exports = TicketSyncModel; \ No newline at end of file +module.exports = TicketSyncModel; diff --git a/src/scripts/database/maintenance_reset.sql b/src/scripts/database/maintenance_reset.sql new file mode 100644 index 0000000..1c91e8b --- /dev/null +++ b/src/scripts/database/maintenance_reset.sql @@ -0,0 +1,25 @@ +-- ============================================= +-- MANUTENCAO / RESET CONTROLADO - SN <-> GLPI +-- ============================================= +-- Use este arquivo apenas para manutencao operacional. +-- Nao execute em producao sem backup e janela aprovada. + +BEGIN; + +-- Diagnostico rapido +SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored'; +SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; +SELECT * FROM public.location_mapping; + +-- Limpeza de tickets abertos (mantem chamados encerrados) +DELETE FROM tickets_sn +WHERE status != 'Encerrado' + AND status != 'Encerrado - Omitido'; + +-- Reinicia watermarks para reprocessar coleta completa +UPDATE sync_control +SET last_watermark = '1970-01-01 00:00:00+00', + last_run_status = 'manual_reset', + last_run_at = NOW(); + +COMMIT; diff --git a/src/scripts/database/scriptBD.sql b/src/scripts/database/scriptBD.sql index ad4ad17..0bcb6e8 100644 --- a/src/scripts/database/scriptBD.sql +++ b/src/scripts/database/scriptBD.sql @@ -287,16 +287,6 @@ COMMENT ON COLUMN sync_control.last_run_at IS 'Timestamp da última vez que o jo -- Registro inicial para a marca d'água do GLPI. INSERT INTO sync_control (system_name, last_watermark) VALUES ('glpi', '1970-01-01 00:00:00+00'); -SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored'; -SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; -SELECT * FROM public.location_mapping - -DELETE FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; - -UPDATE sync_control -SET last_watermark = '1970-01-01 00:00:00+00'; - -- Migracao para bases existentes: ALTER TABLE tickets_sn ADD COLUMN IF NOT EXISTS justificativa TEXT; - diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index 79400b7..aac446e 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -1,11 +1,32 @@ -const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const TicketUpdateModel = require('../models/ticketUpdateModel'); const { logInfo, logError } = require('../utils/logger'); const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); -const { log } = require('winston'); + +const toTitleCase = (text) => text + .split(' ') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + +const normalizeAuthorName = (rawAuthor) => { + const source = String(rawAuthor || 'ServiceNow').trim(); + const localPart = source.includes('@') ? source.split('@')[0] : source; + const normalizedRaw = localPart.replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim(); + + if (!normalizedRaw) { + return 'ServiceNow'; + } + + const parts = normalizedRaw.split(' '); + if (parts[0].toLowerCase() === 't' && parts.length > 1) { + return `Tecnico ${toTitleCase(parts.slice(1).join(' '))}`; + } + + return toTitleCase(normalizedRaw); +}; const formatSNUpdateForGlpi = (update) => { const escapeHtml = (value) => String(value || '') @@ -13,8 +34,30 @@ const formatSNUpdateForGlpi = (update) => { .replace(//g, '>'); - const author = escapeHtml(update.author || 'ServiceNow'); + const normalizedAuthor = normalizeAuthorName(update.author); + const author = escapeHtml(normalizedAuthor); const content = escapeHtml(update.content || ''); + const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || '')); + const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || '')); + + if (isAutomaticTask) { + return ` +
+
Tarefa automatica do ServiceNow
+
+ ${content} +
+
`; + } + + if (isSnClosureMessage) { + return ` +
+
Encerramento automatico do ServiceNow
+
${content}
+
Chamado resolvido no ServiceNow e removido do monitoramento automatico de atualizacoes.
+
`; + } return `
@@ -30,13 +73,13 @@ const syncCommentsGlpitoSN = async (ticket) => { logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...'); const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp - if (comments.length === 0) { + if (!Array.isArray(comments) || comments.length === 0) { logInfo(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return; } logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`); - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'comment_collected', 'pending_cmt_check'); + let hasError = false; for (const comment of comments) { try { @@ -59,7 +102,7 @@ const syncCommentsGlpitoSN = async (ticket) => { created_at: new Date(comment.date_creation), updated_at: new Date(comment.date_mod), source_id: comment.id, - destiny_id: existsInSN.destiny_id + destiny_id: existsInSN }); continue; } @@ -90,10 +133,19 @@ const syncCommentsGlpitoSN = async (ticket) => { } catch (err) { logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`); + hasError = true; await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error'); } } + // Evita deixar ticket preso em status transitorio de comentario, + // o que pode bloquear o processamento de status/fechamento. + if (!hasError) { + const finalGlpiStatus = ['solved', 'closed'].includes(ticket.glpi_sync_status) ? ticket.glpi_sync_status : 'synced'; + const finalSnStatus = ['solved', 'closed'].includes(ticket.sn_sync_status) ? ticket.sn_sync_status : 'synced'; + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus); + } + logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!'); } catch (error) { logError(`Erro geral na sincronizacao: ${error}`); @@ -104,7 +156,13 @@ const syncCommentsGlpitoSN = async (ticket) => { const syncCommentsSNtoGlpi = async (comments, ticket) => { logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...'); - for (const comment of comments) { + const orderedComments = [...comments].sort((a, b) => { + const aTime = new Date(a.created_at).getTime(); + const bTime = new Date(b.created_at).getTime(); + return aTime - bTime; + }); + + for (const comment of orderedComments) { try { const formattedPayload = { ...comment, diff --git a/src/services/glpiTicketService.js b/src/services/glpiTicketService.js index cf8fb95..c56b647 100644 --- a/src/services/glpiTicketService.js +++ b/src/services/glpiTicketService.js @@ -4,6 +4,9 @@ const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const { logInfo, logError } = require('../utils/logger'); const { addWorkNoteToServiceNow } = require('./servicenowService'); +const { stripHTML } = require('../utils/commentSanitizer'); + +const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); const syncTicketsToGlpi = async () => { try { @@ -112,9 +115,9 @@ const checkGlpiStatusChanges = async () => { const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); - if (solution?.solutiontypes_id === 27) { - logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`); - const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.'; + if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) { + logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`); + const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.'; await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); @@ -157,14 +160,17 @@ const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalSta : 'Chamado encerrado no Service Now pela CAOA.'; const styledMessage = ` -
-
Atualizacao automatica de encerramento
-
+
+
Encerramento automatico no ServiceNow
+
${statusText}
-
+
Responsavel informado: ${resolvedBy}
+
+ Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao. +
`; await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id); diff --git a/src/services/servicenowService.js b/src/services/servicenowService.js index 1469b8c..aefe296 100644 --- a/src/services/servicenowService.js +++ b/src/services/servicenowService.js @@ -6,12 +6,21 @@ const TicketSnModel = require('../models/ticketSnModel'); const { logInfo, logError, logSync } = require('../utils/logger'); const TicketUpdateModel = require('../models/ticketUpdateModel'); 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 normalizeServiceNowDateTime = (value) => { + if (!value) return null; + const asString = String(value).trim(); + const m = asString.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/); + if (m) return `${m[1]} ${m[2]}`; + const parsed = new Date(asString); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toISOString().slice(0, 19).replace('T', ' '); +}; + const fetchTicketsFromServiceNow = async (watermark) => { try { const watermarkDate = new Date(watermark); @@ -47,6 +56,29 @@ const fetchRequestsFromServiceNow = async (watermark) => { } } +const fetchTicketLiveStatusFromServiceNow = async (sysId, ticketType) => { + try { + const baseUrl = ticketType === 'requisicao' + ? apiConfig.snTableRequestConfig.baseUrl + : apiConfig.snTableIncidentConfig.baseUrl; + const url = `${baseUrl}/${sysId}?sysparm_display_value=true&sysparm_fields=state`; + const response = await axios.get(url, { + auth: apiConfig.servicenowAuthentication.auth + }); + const result = response?.data?.result; + if (!result) return null; + + if (typeof result.state === 'object') { + return result.state.display_value || result.state.value || null; + } + + return result.state || null; + } catch (error) { + logError(error, `ERRO: Falha ao buscar status em tempo real no ServiceNow para sys_id ${sysId}`); + return null; + } +} + const getTicketSysId = async (ticketId) => { try { const ticket = await TicketSnModel.findById(ticketId); @@ -193,6 +225,7 @@ const fetchCommentsFromServiceNow = async (ticket) => { const params = { 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_display_value: true, sysparm_limit: pageSize, sysparm_offset: offset }; @@ -221,7 +254,7 @@ const fetchCommentsFromServiceNow = async (ticket) => { // protecao extra para evitar loop infinito em caso de comportamento inesperado da API if (page >= internalMaxPages) { - logError(new Error('Max pages reached while paginating comments'), { ticketId, page }); + logError(new Error('Max pages reached while paginating comments'), { sn_ticket_id: ticket.sn_ticket_id, page }); break; } } @@ -240,17 +273,24 @@ const fetchCommentsFromServiceNow = async (ticket) => { } return true; }); - if (filteredComments.length === 0) { + + // Mantem ordem cronologica de criacao para preservar timeline no GLPI. + const orderedComments = filteredComments.sort((a, b) => { + const aTime = new Date(a.sys_created_on).getTime(); + const bTime = new Date(b.sys_created_on).getTime(); + return aTime - bTime; + }); + if (orderedComments.length === 0) { logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return []; } - logInfo(`Encontrado ${filteredComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) + logInfo(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) const commentsInserted = []; // Insere cada comment no banco intermediario - for (const comment of filteredComments) { + for (const comment of orderedComments) { const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id); if (existingComment) { @@ -264,7 +304,7 @@ const fetchCommentsFromServiceNow = async (ticket) => { typeof normalizedContent === 'string' && normalizedContent.includes('Registro alterado por:') ) { - normalizedContent = `Anotacao de trabalho adicionada pelo Service Now (registro automatico).\n\nMensagem original:\n${normalizedContent}`; + normalizedContent = `Tarefa automatica originada no ServiceNow (registro automatico).\n\nMensagem original:\n${normalizedContent}`; } const commentData = { @@ -272,9 +312,9 @@ const fetchCommentsFromServiceNow = async (ticket) => { update_type: comment.element === 'work_notes' ? 'task' : 'comment', source_system: 'servicenow', content: normalizedContent, - created_at: new Date(comment.sys_created_on), + created_at: normalizeServiceNowDateTime(comment.sys_created_on), author: comment.sys_created_by || 'ServiceNow', - updated_at: new Date(), + updated_at: normalizeServiceNowDateTime(comment.sys_created_on), source_id: comment.sys_id, destiny_id: null }; @@ -439,7 +479,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => { state: '6', resolved_by: resolvedBySysId, resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '), - closeNotes: closeNotes, + close_notes: closeNotes, work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`, }; @@ -550,6 +590,7 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => { module.exports = { fetchTicketsFromServiceNow, fetchRequestsFromServiceNow, + fetchTicketLiveStatusFromServiceNow, getTicketSysId, getTicketTypeById, createCommentInServiceNow, @@ -580,4 +621,3 @@ module.exports = { * * Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao. */ -