From 0cc27df2cff15807adb4f0ce532631f1364b69e5 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Thu, 23 Oct 2025 17:30:03 -0300 Subject: [PATCH] =?UTF-8?q?FEAT:=20Implementa=20sincroniza=C3=A7=C3=A3o=20?= =?UTF-8?q?bidirecional=20de=20status=20e=20ciclo=20de=20vida=20de=20ticke?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Sincronização Inteligente:** O sistema agora detecta mudanças de estado em qualquer uma das plataformas e propaga a atualização para a outra, respeitando a 'fonte da verdade' (`source_last`). - **Regra 'Fora do Escopo':** Implementada a regra de negócio para tickets marcados como 'Fora do Escopo' no GLPI (solutiontypes_id = 27). Esses tickets agora são ignorados na sincronização, têm seu status finalizado no GLPI e uma nota informativa é adicionada no ServiceNow. - **Watermark (Marca d'água):** A coleta de novos tickets do ServiceNow foi otimizada com um sistema de marca d'água, buscando apenas registros atualizados desde a última execução bem-sucedida, o que melhora significativamente a performance. - **Resiliência:** Adicionado um `processErrorController` que reseta tickets com status de erro, permitindo que sejam reprocessados automaticamente no próximo ciclo. Testes de fluxo foram realizados com sucesso, validando a criação, resolução (SN <-> GLPI) e a regra 'Fora do Escopo'. Pendências para a próxima fase: 1. Validar melhor o fluxo de reabertura de chamados em ambas as direções. 2. Garantir que a `work_note` enviada para o ServiceNow na regra 'Fora do Escopo' seja devidamente sanitizada." --- src/app.js | 12 +- src/controllers/processCommentsController.js | 9 +- src/controllers/processErrorController.js | 47 +++ src/controllers/processStatusController.js | 144 ++++++++ src/controllers/processSyncController.js | 111 ++++++ src/controllers/processTicketsController.js | 8 +- src/data/glpiDataBase.js | 3 + src/models/syncControlModel.js | 37 ++ src/models/ticketGlpiModel.js | 154 +++++++- src/models/ticketSnModel.js | 23 +- src/models/ticketSyncModel.js | 156 +++++++- src/models/ticketUpdateModel.js | 54 ++- src/scripts/database/scriptBD.sql | 30 +- src/services/glpiCommentService.js | 37 +- src/services/glpiTicketService.js | 97 ++++- src/services/servicenowService.js | 370 +++++++++++++------ 16 files changed, 1112 insertions(+), 180 deletions(-) create mode 100644 src/controllers/processErrorController.js create mode 100644 src/controllers/processStatusController.js create mode 100644 src/controllers/processSyncController.js create mode 100644 src/models/syncControlModel.js diff --git a/src/app.js b/src/app.js index fed4d7b..8580389 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,9 @@ // src/app.js require('dotenv').config(); const { processTicketsController } = require('./controllers/processTicketsController'); -const { processCommentsController } = require('./controllers/processCommentsController'); +const { processCommentsController } = require('./controllers/processCommentsController'); +const { processErrorController } = require('./controllers/processErrorController'); +const { processStatusAndClosureController } = require('./controllers/processStatusController'); const { logInfo } = require('./utils/logger'); logInfo('Aplicação iniciada', { @@ -11,11 +13,15 @@ logInfo('Aplicação iniciada', { async function main() { try { - //await processTicketsController(); - await processCommentsController(); + await processErrorController(); // Reseta os erros + await processTicketsController(); // Coleta tickets do SN e cria no GLPI + await processCommentsController(); // Sincroniza chamados Bidirecionalmente + await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente + logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉'); } catch (error) { logInfo('Erro na aplicação:', error); } + } main(); \ No newline at end of file diff --git a/src/controllers/processCommentsController.js b/src/controllers/processCommentsController.js index 4c0e75e..6839f39 100644 --- a/src/controllers/processCommentsController.js +++ b/src/controllers/processCommentsController.js @@ -5,13 +5,14 @@ const TicketSyncModel = require('../models/ticketSyncModel'); const processCommentsController = async () => { - const ticketsSynced = await TicketSyncModel.getTicketsSynced(); + // Usa getTicketsToMonitor para incluir tickets 'synced' e 'solved'. + const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); - for (ticket of ticketsSynced){ + for (const ticket of ticketsToMonitor){ const comments = await fetchCommentsFromServiceNow(ticket); - if(comments.length > 0){await syncCommentsSNtoGlpi(comments, ticket);} + if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);} await syncCommentsGlpitoSN(ticket); - } + } } module.exports = { diff --git a/src/controllers/processErrorController.js b/src/controllers/processErrorController.js new file mode 100644 index 0000000..3a0f325 --- /dev/null +++ b/src/controllers/processErrorController.js @@ -0,0 +1,47 @@ +// src/controllers/processErrorController.js +const TicketSyncModel = require('../models/ticketSyncModel'); +const { logInfo, logError, logWarning } = require('../utils/logger'); + +const processErrorController = async () => { + logInfo('Iniciando verificação de tickets com erro...'); + + try { + const errorTickets = await TicketSyncModel.getTicketsInErrorState(); + + if (errorTickets.length === 0) { + logInfo('Nenhum ticket com erro encontrado para reprocessamento.'); + return; + } + + logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`); + + for (const ticket of errorTickets) { + let newSnStatus = ticket.sn_sync_status; + let newGlpiStatus = ticket.glpi_sync_status; + + // Reset 'error' na criação do GLPI para tentar de novo + if (ticket.glpi_sync_status === 'error') { + newGlpiStatus = 'pending_check'; + } + + // Reset 'error_sync' para tentar sincronizar status/comentários de novo + if (ticket.sn_sync_status === 'error_sync') newSnStatus = 'synced'; + if (ticket.glpi_sync_status === 'error_sync') newGlpiStatus = 'synced'; + + // Reset 'error_closing' para tentar fechar de novo + if (ticket.sn_sync_status === 'error_closing') newSnStatus = 'pending_close'; + if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close'; + + // Atualiza o status para que o ticket reentre no fluxo normal + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus); + logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`); + } + + } catch (error) { + logError(error, 'Erro ao processar tickets com falha.'); + } +}; + +module.exports = { + processErrorController +}; diff --git a/src/controllers/processStatusController.js b/src/controllers/processStatusController.js new file mode 100644 index 0000000..075905b --- /dev/null +++ b/src/controllers/processStatusController.js @@ -0,0 +1,144 @@ +const TicketSyncModel = require('../models/ticketSyncModel'); +const TicketSnModel = require('../models/ticketSnModel'); +const TicketGlpiModel = require('../models/ticketGlpiModel'); +const TicketUpdateModel = require('../models/ticketUpdateModel'); +const { logInfo, logError, logWarning } = require('../utils/logger'); +const { stripHTML } = require('../utils/commentSanitizer'); +const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService'); +const { closeTicketInGlpi } = require('../services/glpiTicketService'); + +const processStatusAndClosureController = async () => { + logInfo('Iniciando processamento de status e fechamento de tickets...'); + + try { + // --- FASE 1: Sincronização de Status para Tickets Abertos (Bidirecional) --- + logInfo('Iniciando verificação e sincronização de status...'); + // Busca todos os tickets que não estão permanentemente fechados. + const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); + + if (ticketsToMonitor.length === 0) { + logInfo('Nenhum ticket sincronizado (aberto) encontrado para verificação de status.'); + } else { + logInfo(`Encontrados ${ticketsToMonitor.length} tickets para monitorar status (synced/solved).`); + } + + for (const ticket of ticketsToMonitor) { + try { + // Obter detalhes completos do ticket SN para ter sys_id, tipo e status atual + const ticketSnDetails = await TicketSnModel.findById(ticket.sn_ticket_id); + if (!ticketSnDetails) { + logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`); + continue; + } + const snCurrentStatus = ticketSnDetails.status; + + // ================= 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 glpiDbStatus = ticket.glpi_sync_status; + + // Converte o status numérico do GLPI para a string que usamos no nosso banco (ex: 'solved') + const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus).toLowerCase().replace(/ /g, '_').replace('-', '_'); + + if (glpiLiveStatus !== null && glpiLiveStatusAsString !== glpiDbStatus) { + logInfo(`Mudança de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. De '${glpiDbStatus}' para '${glpiLiveStatusAsString}'.`); + // Se uma mudança foi detectada no GLPI, forçamos o "bastão" para ele. + ticket.source_last = 'GLPI'; + } + + // ================= LÓGICA SNOW -> GLPI ================= + // Se a última atualização veio do ServiceNow, atualiza o GLPI + if (ticket.source_last === 'SNOW') { + logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`); + + // **LÓGICA DE REABERTURA REFINADA** + // Busca o status atual do GLPI para tomar uma decisão inteligente. + const glpiCurrentStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + // Se o ticket no GLPI está permanentemente Fechado (6), não fazemos nada. + if (glpiCurrentStatus === 6) { + logWarning(`Ticket GLPI ${ticket.glpi_ticket_id} está permanentemente Fechado (6). Ação de reabertura pelo SN ('${snCurrentStatus}') foi bloqueada.`); + } else if (snCurrentStatus === 'Resolvido' && glpiCurrentStatus !== 5) { // SN foi para 'Resolved' e GLPI ainda não está 'Solucionado' + logInfo(`SN ${ticketSnDetails.ticket_number} foi Resolvido. Sincronizando para GLPI...`); + await closeTicketInGlpi(ticket); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved'); + } else if (snCurrentStatus === 'Resolvido' && glpiCurrentStatus === 5) { + // Ambos já estão resolvidos, nenhuma ação necessária. + logInfo(`SN ${ticketSnDetails.ticket_number} e GLPI ${ticket.glpi_ticket_id} já estão no estado Resolvido/Solucionado. Nenhuma ação necessária.`); + } else if (snCurrentStatus === 'Encerrado - Omitido') { // SN foi para 'Closed' + logInfo(`SN ${ticketSnDetails.ticket_number} foi Fechado. Sincronizando para GLPI...`); + await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'closed'); + } else { + // Se estiver Solucionado (5) ou em qualquer outro estado, permite a atualização (reabertura). + await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, snCurrentStatus); + logInfo(`Status do ticket GLPI ${ticket.glpi_ticket_id} atualizado para '${snCurrentStatus}'.`); + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); // Passa o bastão + } + } + // ================= LÓGICA GLPI -> SNOW ================= + // Se a última atualização veio do GLPI, atualiza o ServiceNow + else if (ticket.source_last === 'GLPI') { + logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`); + const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos + if (glpiCurrentStatus !== null) { + if (glpiCurrentStatus === 5) { // GLPI foi para 'Solucionado' + // Apenas processa se o SN não estiver já resolvido/fechado + if (ticket.sn_sync_status !== 'solved' && ticket.sn_sync_status !== 'closed') { + logInfo(`GLPI ${ticket.glpi_ticket_id} foi Solucionado. Sincronizando para SN...`); + 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.'; + 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); + } else { + // FLUXO NORMAL DE RESOLUÇÃO + const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integração GLPI.'; + const resolvedAt = solution?.date_mod || new Date(); + await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved'); + } + } + } else if (glpiCurrentStatus === 6) { + logInfo(`GLPI ${ticket.glpi_ticket_id} foi Fechado. Sincronizando para SN...`); + // Apenas marca nosso banco como fechado, pois o SN já deve estar fechado ou será fechado por automação interna. + if (ticket.glpi_sync_status !== 'closed') { + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status); + } + } else { + // Se o status for de um ticket aberto (reabertura, etc.), propaga a mudança. + const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiCurrentStatus); + if (glpiStatusAsString !== snCurrentStatus) { + logInfo(`Diferença de status detectada (GLPI: ${glpiStatusAsString}, SN: ${snCurrentStatus}). Sincronizando...`); + await updateStatusInServiceNow(ticket.sn_ticket_id, glpiStatusAsString); + } + } + // Passa o bastão de volta para o SN se a ação não for de finalização + if (glpiCurrentStatus < 5) { + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); + } + } else { + logWarning(`Não foi possível obter o status atual do ticket GLPI ${ticket.glpi_ticket_id}.`); + } + } + // Se source_last não está definido ou é desconhecido, podemos definir um padrão ou logar um aviso. + // Por enquanto, vamos apenas logar. + else { + logWarning(`source_last desconhecido para o ticket SN ${ticketSnDetails.ticket_number}. Pulando sincronização de status.`); + } + } catch (error) { + logError(error, `Erro ao sincronizar status do ticket SN ${ticket.sn_ticket_id} (GLPI ID: ${ticket.glpi_ticket_id}).`); + // Marcar o ticket como erro para revisão manual + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error_sync', 'error_sync'); + } + } + } catch (error) { + logError(error, 'Erro geral no processamento de status e fechamento de tickets.'); + } +}; +module.exports = { + processStatusAndClosureController +}; diff --git a/src/controllers/processSyncController.js b/src/controllers/processSyncController.js new file mode 100644 index 0000000..77dcd20 --- /dev/null +++ b/src/controllers/processSyncController.js @@ -0,0 +1,111 @@ +const TicketSnModel = require('../models/ticketSnModel'); +const TicketSyncModel = require('../models/ticketSyncModel'); +const SyncControlModel = require('../models/syncControlModel'); +const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi } = require('../services/servicenowService'); +const { logInfo, logError, logSync } = require('../utils/logger'); + +/** + * Processa e salva um lote de tickets (incidentes ou requisições). + * @param {Array} tickets - A lista de tickets da API do ServiceNow. + * @param {('incidente'|'requisicao')} type - O tipo de ticket. + */ +const processAndSaveTickets = async (tickets, type) => { + if (!tickets || tickets.length === 0) { + logInfo(`Nenhum ticket do tipo '${type}' para processar.`); + return ''; // Retorna string vazia se não houver tickets + } + + logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`); + let latestUpdateTimestamp = ''; + for (const ticket of tickets) { + try { + // 1. Salva o ticket na tabela 'tickets_sn' + const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type); + + // 2. Prepara os dados para a tabela de sincronização 'ticket_sync' + const ticketStatus = ticket.state?.display_value; + const dataSync = { + sn_ticket_id: idTableTicketSn, + last_sn_sync: new Date(), + glpi_ticket_id: null, + created_at: new Date(), + updated_at: new Date() + }; + + // 3. Define o status inicial de sincronização + if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') { + dataSync.sn_sync_status = 'closed'; + dataSync.glpi_sync_status = 'ignored'; + dataSync.source_last = 'SNOW'; + } else { + dataSync.sn_sync_status = 'collected'; // Status que indica que foi coletado do SN + dataSync.glpi_sync_status = 'pending_check'; // Status que indica que precisa ser checkado no GLPI + dataSync.source_last = 'SNOW'; + } + + // 4. Cria o registro de sincronização se ele não existir + const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn); + if (!exists) { + await TicketSyncModel.createSyncRecord(dataSync); + } else if (wasInserted === false) { + // Se o ticket foi atualizado (não inserido), significa que uma mudança veio do SN. + // Então, definimos o SN como a última fonte da verdade. + await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); + logInfo(`Ticket ${ticket.number?.value} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); + } else { + logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`); + } + // Se já existe, não faz nada, pois a lógica de atualização de status será em outro processo. + + // Guarda o timestamp mais recente do lote para usar como a próxima marca d'água + const currentUpdate = ticket.sys_updated_on?.value; + if (currentUpdate && currentUpdate > latestUpdateTimestamp) { + latestUpdateTimestamp = currentUpdate; + } + + } catch (error) { + logError(error, `Erro ao processar o ticket ${ticket.number?.value} do tipo ${type}.`); + } + } + logSync('ServiceNow', tickets.length, `${type}s`); + return latestUpdateTimestamp; +}; + +/** + * Controller para buscar tickets e requisições do ServiceNow e salvá-los localmente. + */ +const processSyncController = async () => { + try { + // 1. Pega a marca d'água da última execução bem-sucedida + const watermark = await SyncControlModel.getWatermark('servicenow'); + logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`); + + // Busca incidentes + const incidents = await fetchTicketsApi(watermark); + const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente'); + + // Busca requisições + //logInfo('📡 Buscando requisições do ServiceNow...'); + const requests = await fetchRequestsApi(watermark); + const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao'); + + // 2. Determina a nova marca d'água (o timestamp mais recente entre todos os tickets processados) + const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate; + + // 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução + if (newWatermark) { + // Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais + const nextWatermark = new Date(new Date(newWatermark).getTime() + 1000); + await SyncControlModel.setWatermark('servicenow', nextWatermark); + } else { + logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada."); + } + + } catch (error) { + logError(error, 'Erro no processo de sincronização de tickets do ServiceNow.'); + } +}; + +module.exports = { + processSyncController +}; \ No newline at end of file diff --git a/src/controllers/processTicketsController.js b/src/controllers/processTicketsController.js index dcf7f6f..443ce64 100644 --- a/src/controllers/processTicketsController.js +++ b/src/controllers/processTicketsController.js @@ -1,13 +1,13 @@ // src/controllers/glpiController.js const { syncTicketsToGlpi } = require('../services/glpiTicketService'); -const { fetchTicketsFromServiceNow, fetchRequestsFromServiceNow } = require('../services/servicenowService'); +const { processSyncController } = require('./processSyncController'); const processTicketsController = async () => { - //Busca Incidentes e Requisições do Service Now e salva no banco intermediario - await fetchTicketsFromServiceNow(); - await fetchRequestsFromServiceNow(); + // 1. Busca Incidentes e Requisições do Service Now e salva no banco intermediário + // Esta etapa prepara os dados para a sincronização com o GLPI. + await processSyncController(); //Sincroniza os tickets com o GLPI await syncTicketsToGlpi(); diff --git a/src/data/glpiDataBase.js b/src/data/glpiDataBase.js index 0f86927..1aea981 100644 --- a/src/data/glpiDataBase.js +++ b/src/data/glpiDataBase.js @@ -9,6 +9,9 @@ const glpiPool = mysql.createPool({ database: process.env.GLPI_DB_NAME, user: process.env.GLPI_DB_USER, password: process.env.GLPI_DB_PASSWORD, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 }); // Testar conexão diff --git a/src/models/syncControlModel.js b/src/models/syncControlModel.js new file mode 100644 index 0000000..2462858 --- /dev/null +++ b/src/models/syncControlModel.js @@ -0,0 +1,37 @@ +// src/models/syncControlModel.js +const pool = require('../data/database'); +const { logError, logInfo } = require('../utils/logger'); + +class SyncControlModel { + static async getWatermark(systemName) { + try { + const query = 'SELECT last_watermark FROM sync_control WHERE system_name = $1'; + const { rows } = await pool.query(query, [systemName]); + if (rows.length === 0) { + throw new Error(`Watermark para o sistema '${systemName}' não encontrada no banco de dados.`); + } + // Retorna no formato que o ServiceNow espera: 'YYYY-MM-DD HH:MM:SS' + return rows[0].last_watermark.toISOString().replace('T', ' ').substring(0, 19); + } catch (error) { + logError(error, `Erro ao buscar watermark para ${systemName}`); + throw error; + } + } + + static async setWatermark(systemName, watermark) { + try { + const query = ` + UPDATE sync_control + SET last_watermark = $1, last_run_at = NOW(), last_run_status = 'success' + WHERE system_name = $2 + `; + await pool.query(query, [watermark, systemName]); + logInfo(`🌊 Nova marca d'água para '${systemName}' definida para: ${watermark}`); + } catch (error) { + logError(error, `Erro ao definir watermark para ${systemName}`); + throw error; + } + } +} + +module.exports = SyncControlModel; diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 0306c3f..f4f4dbe 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -11,12 +11,24 @@ class TicketGlpiModel { 'Aguardando Atendimento': 1, 'Em Atendimento': 2, 'Em Espera': 4, - 'Encerrado': 5, + 'Resolvido': 5, 'Encerrado - Omitido': 6 }; return statusMap[snState] || 1; } + // Mapeamento de status do GLPI (numérico) para ServiceNow (string) + static mapGlpiStatusToString(glpiStatus) { + const statusMap = { + 1: 'Aguardando Atendimento', // Novo + 2: 'Em Atendimento', // Em atendimento (atribuído) + 4: 'Em Espera', // Pendente + 5: 'Resolvido', // Solucionado + 6: 'Encerrado - Omitido' // Fechado + }; + return statusMap[glpiStatus] || 'Desconhecido'; + } + // Mapeamento de prioridade static mapPriority(snPriority) { const priorityMap = { @@ -268,9 +280,10 @@ class TicketGlpiModel { } // Buscar followups (notas) de um ticket no GLPI static async getFollowupsByItemId(itemId) { + const connection = await glpiPool.getConnection(); try { const query = 'SELECT id, content, date, date_creation, date_mod FROM glpi_itilfollowups WHERE items_id = ? AND users_id != 971'; - const [rows] = await glpiPool.execute(query, [itemId]); + const [rows] = await connection.execute(query, [itemId]); return rows; } catch (error) { logError(error, 'Erro ao buscar followups do ticket'); @@ -279,19 +292,144 @@ 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(?, ?, ?, ?, ?, ?, ?, ?) RETURNING id'; + 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, comment.created_at, 971, comment.content , comment.created_at, comment.created_at, 1]; - const [rows] = await glpiPool.execute(query, values); - return rows; + const [result] = await connection.execute(query, values); + return [{ id: result.insertId }]; // Retorna o ID inserido }catch(error){ - logError(`Erro ao inserir o ticket: ${error}`); + logError(`Erro ao inserir o comentário no GLPI: ${error}`); + } finally { + if (connection) connection.release(); } } + + // Busca tickets que foram fechados ou solucionados no GLPI + static async getClosedTickets(watermark, limit = 100) { + const connection = await glpiPool.getConnection(); + try { + // Status 5 = Solucionado, Status 6 = Fechado + const query = ` + SELECT + t.id, + t.solvedate, + t.closedate, + s.content as solution + FROM glpi_tickets t + LEFT JOIN glpi_itilsolutions s ON t.id = s.items_id AND s.itemtype = 'Ticket' + WHERE t.status IN (5, 6) AND t.date_mod >= ? LIMIT ? + `; + const [rows] = await connection.execute(query, [watermark, limit]); + return rows; + } catch (error) { + logError(error, '❌ Erro ao buscar tickets fechados no GLPI'); + throw error; + } finally { + connection.release(); + } + } + + // Fecha um ticket no GLPI, adicionando uma solução + static async closeTicket(ticketId, solutionContent) { + const connection = await glpiPool.getConnection(); + try { + await connection.beginTransaction(); + + // Adiciona a solução + const solutionQuery = `INSERT INTO glpi_itilsolutions (itemtype, items_id, content, users_id_editor, date_creation, date_mod) VALUES ('Ticket', ?, ?, ?, NOW(), NOW())`; + await connection.execute(solutionQuery, [ticketId, solutionContent, 971]); // 971 = ID do usuário da integração + + // Atualiza o status do ticket para "Solucionado" (status 5) + const updateTicketQuery = `UPDATE glpi_tickets SET status = 5, solvedate = NOW() WHERE id = ?`; + await connection.execute(updateTicketQuery, [ticketId]); + + await connection.commit(); + } catch (error) { + await connection.rollback(); + logError(error, `❌ Erro ao fechar ticket ${ticketId} no GLPI`); + throw error; + } finally { + connection.release(); + } + } + + // Busca a solução de um ticket no GLPI + static async getTicketSolution(glpiTicketId) { + const connection = await glpiPool.getConnection(); + try { + const query = ` + SELECT content, date_mod, solutiontypes_id + FROM glpi_itilsolutions + WHERE items_id = ? AND itemtype = 'Ticket' + ORDER BY date_mod DESC LIMIT 1`; + const [rows] = await connection.execute(query, [glpiTicketId]); + return rows[0] || null; + } catch (error) { + logError(error, `❌ Erro ao buscar solução do ticket GLPI ${glpiTicketId}`); + throw error; + } finally { + connection.release(); + } + } + + // Força o fechamento de um ticket (muda status para 6) + static async forceCloseTicket(ticketId) { + const connection = await glpiPool.getConnection(); + try { + // Atualiza o status do ticket para "Fechado" (status 6) + const updateTicketQuery = `UPDATE glpi_tickets SET status = 6, closedate = NOW() WHERE id = ?`; + await connection.execute(updateTicketQuery, [ticketId]); + logInfo(`✅ Ticket GLPI ${ticketId} forçado para o estado Fechado (6).`); + } catch (error) { + logError(error, `❌ Erro ao forçar fechamento do ticket ${ticketId} no GLPI`); + // Não lança o erro para não parar o fluxo principal por uma ação secundária + } finally { + if (connection) connection.release(); + } + } + + + static async updateStatus(ticketId, status) { + const connection = await glpiPool.getConnection(); + try { + await connection.beginTransaction(); + + status = this.mapStatus(status); + + const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`; + await connection.execute(updateQuery, [status, ticketId]); + + await connection.commit(); + } catch (error) { + await connection.rollback(); + logError(error, `❌ Erro ao atualiza ticket ${ticketId} no GLPI`); + throw error; + } finally { + connection.release(); + } + } + + // Busca o status atual de um ticket no GLPI + static async getTicketStatus(glpiTicketId) { + const connection = await glpiPool.getConnection(); + try { + const query = `SELECT status FROM glpi_tickets WHERE id = ?`; + const [rows] = await connection.execute(query, [glpiTicketId]); + if (rows.length > 0) { + return rows[0].status; + } + return null; + } catch (error) { + logError(error, `❌ Erro ao buscar status do ticket GLPI ${glpiTicketId}`); + throw error; + } finally { + connection.release(); + } + } + } - - /** * @module TicketGlpiModel * @description Este módulo fornece métodos para gerenciar tickets no GLPI. diff --git a/src/models/ticketSnModel.js b/src/models/ticketSnModel.js index a54de3d..348a260 100644 --- a/src/models/ticketSnModel.js +++ b/src/models/ticketSnModel.js @@ -25,7 +25,7 @@ class TicketSnModel { telefone = EXCLUDED.telefone, opened_at = EXCLUDED.opened_at, tipo = EXCLUDED.tipo - RETURNING id, xmax = 0 as inserted`; + RETURNING id, (xmax = 0) AS "wasInserted"`; // xmax=0 é um truque do PostgreSQL para saber se foi INSERT const values = [ ticketData.number?.value || null, @@ -46,7 +46,7 @@ class TicketSnModel { try { const result = await pool.query(query, values); - const wasInserted = result.rows[0].inserted; + const wasInserted = result.rows[0].wasInserted; // Corrigido para corresponder ao alias "wasInserted" if (wasInserted) { logInfo(`✅ NOVO ticket salvo! ID: ${result.rows[0].id}`, { @@ -62,7 +62,7 @@ class TicketSnModel { }); } - return result.rows[0].id; + return { id: result.rows[0].id, wasInserted }; } catch (error) { logError(error, `❌ Erro ao salvar ticket ${values[0]}`); throw error; @@ -123,6 +123,23 @@ class TicketSnModel { } } + + // Busca o status de um ticket pelo ID + static async getStatus(ticketId) { + try { + const query = ` + SELECT status,tipo FROM tickets_sn + WHERE id = $1`; + const result = await pool.query(query, [ticketId]); + return result.rows || null; + } catch (error) { + logError(error, `❌ Erro ao buscar status do ticket: ${ticketId}`); + return null; + } + } + + + // Busca um ticket pelo ID static async findById(ticketId) { try { diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index ba3deee..4c9cb1b 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -18,8 +18,9 @@ class TicketSyncModel { created_at, updated_at, sn_sync_status, - glpi_sync_status - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`; + glpi_sync_status, + source_last + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`; const values = [ data.sn_ticket_id, @@ -29,8 +30,8 @@ class TicketSyncModel { data.created_at, data.updated_at, data.sn_sync_status, - data.glpi_sync_status - + data.glpi_sync_status, + data.source_last ] @@ -56,6 +57,17 @@ class TicketSyncModel { } } + // Atualiza apenas o source_last de um ticket + static async updateSourceLast(snTicketId, source) { + try { + const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; + await pool.query(query, [source, snTicketId]); + } catch (error) { + logError(error, `❌ Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`); + throw error; + } + } + // Atualiza o status de sincronização de um ticket static async updateStatus(ticketSyncId, glpiStatus, snStatus) { try { @@ -66,6 +78,30 @@ class TicketSyncModel { throw error; } } + // Atualiza o status de sincronização de um ticket e o source last + static async updateStatusAndSourceLast(ticketSyncId, glpiStatus, snStatus, source_last) { + try { + + const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status= $2, source_last = $4 WHERE sn_ticket_id = $3'; + await pool.query(query, [glpiStatus, snStatus, ticketSyncId, source_last]); + } catch (error) { + logError(`${error} ❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`); + throw error; + } + } + + // Atualiza apenas o source_last de um ticket + static async updateSourceLast(snTicketId, source) { + try { + const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; + await pool.query(query, [source, snTicketId]); + } catch (error) { + logError(error, `❌ Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`); + throw error; + } + } + + static async updateStatusAndGlpiTicket(ticketSyncId, glpiStatus, snStatus, glpiTicketId) { try { @@ -89,6 +125,21 @@ class TicketSyncModel { } } + // Buscar o ID de sincronização baseado no sys_id do SN + static async getBySysId(sysId) { + try { + const query = ` + SELECT tsync.* FROM ticket_sync tsync + JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id + WHERE tsn.sys_id = $1`; + const { rows } = await pool.query(query, [sysId]); + return rows[0] || null; + } catch (error) { + logError(error, `❌ Erro ao buscar registro de sincronização por sys_id: ${sysId}`); + throw error; + } + } + // Buscar o ID de sincronização baseado no sn_ticket_id static async getIdSyncBySnId(snTicketId) { try { @@ -96,7 +147,7 @@ class TicketSyncModel { const { rows } = await pool.query(query, [snTicketId]); return rows[0] ? rows[0].id : null; } catch (error) { - logError(error, `❌ Erro ao buscar ID de sincronização por GLPI ID: ${glpiTicketId}`); + logError(error, `❌ Erro ao buscar ID de sincronização por SN ID: ${snTicketId}`); throw error; } } @@ -121,7 +172,8 @@ class TicketSyncModel { tsync.id, tsync.glpi_ticket_id, tsync.sn_ticket_id, - tsn.sys_id + tsn.sys_id, + tsync.source_last FROM ticket_sync tsync JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id WHERE tsync.glpi_sync_status = 'synced' AND tsync.sn_sync_status = 'synced'`; @@ -133,6 +185,98 @@ class TicketSyncModel { } } + // Buscar tickets que estão ativos para monitoramento (synced ou solved) + static async getTicketsToMonitor() { + try { + const query = ` + SELECT + tsync.id, + tsync.glpi_ticket_id, + tsync.sn_ticket_id, + tsn.sys_id, + tsync.source_last, + tsync.sn_sync_status, + tsync.glpi_sync_status + FROM ticket_sync tsync + JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id + WHERE (tsync.glpi_sync_status IN ('synced', 'solved') OR tsync.sn_sync_status IN ('synced', 'solved')) + AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`; + const { rows } = await pool.query(query); + return rows; + } catch (error) { + logError(error, '❌ Erro ao buscar tickets para monitorar'); + throw error; + } + } + + // Buscar tickets que precisam ter o fechamento sincronizado + static async getTicketsPendingSolve() { + try { + const query = ` + SELECT + tsync.id, + tsync.glpi_ticket_id, + tsync.sn_ticket_id, + tsync.glpi_sync_status, + tsync.sn_sync_status, + tsn.sys_id, + tsn.tipo + FROM ticket_sync tsync + JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id + WHERE tsync.glpi_sync_status = 'pending_solve' OR tsync.sn_sync_status = 'pending_solve'`; + const { rows } = await pool.query(query); + return rows; + } catch (error) { + logError(error, '❌ Erro ao buscar tickets pendentes de fechamento'); + throw error; + } + } + + // Busca todos os tickets que estão em algum estado de erro + static async getTicketsInErrorState() { + try { + const query = ` + SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status + FROM ticket_sync + WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%' + `; + const { rows } = await pool.query(query); + return rows; + } catch (error) { + logError(error, '❌ Erro ao buscar tickets em estado de erro'); + throw error; + } + } + + // Atualiza os timestamps de última sincronização + static async updateLastSync(snTicketId, system) { + try { + let fieldToUpdate; + if (system === 'servicenow') { + fieldToUpdate = 'last_sn_sync'; + } else if (system === 'glpi') { + fieldToUpdate = 'last_glpi_sync'; + } else { + return; // Não faz nada se o sistema for inválido + } + const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`; + await pool.query(query, [snTicketId]); + } catch (error) { + logError(`${error} ❌ Erro ao atualizar last_sync para o ID: ${snTicketId}`); + // Não lançar erro para não parar o fluxo principal + } + } + + // Atualiza o status de sincronização de um ticket usando o GLPI ID + static async updateStatusByGlpiId(glpiTicketId, snStatus, glpiStatus) { + try { + const query = 'UPDATE ticket_sync SET sn_sync_status = $1, glpi_sync_status = $2 WHERE glpi_ticket_id = $3'; + await pool.query(query, [snStatus, glpiStatus, glpiTicketId]); + } catch (error) { + logError(`${error} ❌ Erro ao atualizar status de sincronização para o GLPI ID: ${glpiTicketId}`); + throw error; + } + } } diff --git a/src/models/ticketUpdateModel.js b/src/models/ticketUpdateModel.js index 8926784..de0717b 100644 --- a/src/models/ticketUpdateModel.js +++ b/src/models/ticketUpdateModel.js @@ -23,7 +23,7 @@ class TicketUpdateModel { `; const values = [ updateData.ticket_sync_id, - 'note', + updateData.update_type, // Corrigido para usar o tipo de atualização dinâmico updateData.source_system, updateData.content, updateData.author, @@ -41,41 +41,16 @@ class TicketUpdateModel { } } - //Buscar o ID de sincronização baseado no source_id - static async getIdSyncBySourceId(sourceId) { + static async getBySourceId(sourceId) { try { - const query = 'SELECT tsync.id FROM ticket_updates tupd JOIN ticket_sync tsync ON tupd.ticket_sync_id = tsync.id WHERE source_id = $1'; + const query = 'SELECT * FROM ticket_updates WHERE source_id = $1'; const { rows } = await pool.query(query, [sourceId]); - return rows[0] ? rows[0].id : null; - } catch (error) { - logError(error, `❌ Erro ao buscar ID de sincronização por GLPI ID: ${sourceId}`); - throw error; - } - } - - - static async getByTicketSyncIdAndSourceId(ticketSyncId, sourceId) { - try { - const query = ` - SELECT * FROM ticket_updates - WHERE ticket_sync_id = $1 AND source_id = $2 - `; - const { rows } = await pool.query(query, [ticketSyncId, sourceId]); return rows[0] || null; } catch (error) { - logError(error, `❌ Erro ao buscar update por ticket_sync_id e source_id`); + logError(error, `❌ Erro ao buscar ticket_update por source_id: ${sourceId}`); throw error; } } - static async getCommentSync(sourceId) { - - const ticketSyncId = await TicketUpdateModel.getIdSyncBySourceId(sourceId); - if (!ticketSyncId) { - return null; - } - - return await this.getByTicketSyncIdAndSourceId(ticketSyncId, sourceId); - } static async getDestinyIdBySourceId(sourceId) { try { @@ -106,5 +81,26 @@ class TicketUpdateModel { } } + static async getLatestUpdate(ticketSyncId, sourceSystem, updateType = null) { + try { + let query = ` + SELECT * FROM ticket_updates + WHERE ticket_sync_id = $1 AND source_system = $2 + `; + const values = [ticketSyncId, sourceSystem]; + + if (updateType) { + query += ` AND update_type = $3`; + values.push(updateType); + } + query += ` ORDER BY created_at DESC LIMIT 1`; + + const { rows } = await pool.query(query, values); + return rows[0] || null; + } catch (error) { + logError(error, `❌ Erro ao buscar último update para ticket_sync_id: ${ticketSyncId}`); + throw error; + } + } } module.exports = TicketUpdateModel; \ No newline at end of file diff --git a/src/scripts/database/scriptBD.sql b/src/scripts/database/scriptBD.sql index 9783898..226f463 100644 --- a/src/scripts/database/scriptBD.sql +++ b/src/scripts/database/scriptBD.sql @@ -61,7 +61,7 @@ CREATE TABLE ticket_sync ( glpi_sync_status VARCHAR(20) DEFAULT 'pending', -- Status da sincronização com GLPI: 'pending', 'completed', 'error' created_at TIMESTAMP DEFAULT NOW(), -- Data e hora de criação do registro updated_at TIMESTAMP DEFAULT NOW(), -- Data e hora da última atualização do registro - + source_last VARCHAR (4), -- Chaves estrangeiras CONSTRAINT fk_ticket_sync_sn_ticket FOREIGN KEY (sn_ticket_id) @@ -257,4 +257,30 @@ COMMENT ON COLUMN location_mapping.sn_location_name IS 'Nome da localização no COMMENT ON COLUMN location_mapping.glpi_entity_id IS 'ID da entidade correspondente no GLPI'; COMMENT ON COLUMN location_mapping.glpi_entity_name IS 'Nome da entidade no GLPI'; COMMENT ON COLUMN location_mapping.created_at IS 'Data e hora de criação do registro'; -COMMENT ON COLUMN location_mapping.updated_at IS 'Data e hora da última atualização do registro'; \ No newline at end of file +COMMENT ON COLUMN location_mapping.updated_at IS 'Data e hora da última atualização do registro'; + +-- ============================================= +-- TABELA: sync_control +-- DESCRIÇÃO: Armazena metadados do processo de sincronização, como marcas d'água. +-- ============================================= +CREATE TABLE sync_control ( + id SERIAL PRIMARY KEY, + system_name VARCHAR(50) NOT NULL UNIQUE, + last_watermark TIMESTAMP WITH TIME ZONE, + last_run_status VARCHAR(20), + last_run_at TIMESTAMP WITH TIME ZONE +); + +-- Registro inicial para a marca d'água do ServiceNow. +-- Começa em uma data muito antiga para garantir que a primeira execução pegue tudo. +INSERT INTO sync_control (system_name, last_watermark) +VALUES ('servicenow', '1970-01-01 00:00:00+00'); + +COMMENT ON TABLE sync_control IS 'Controla o estado do processo de sincronização, como a última data de busca (watermark).'; +COMMENT ON COLUMN sync_control.system_name IS 'Nome do sistema para o qual a marca d''água se aplica (ex: servicenow)'; +COMMENT ON COLUMN sync_control.last_watermark IS 'O timestamp (sys_updated_on) do último registro processado com sucesso.'; +COMMENT ON COLUMN sync_control.last_run_status IS 'Status da última execução (ex: success, failed).'; +COMMENT ON COLUMN sync_control.last_run_at IS 'Timestamp da última vez que o job rodou.'; + +-- 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'); \ No newline at end of file diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index 3255ab9..d8021b3 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -14,6 +14,7 @@ const syncCommentsGlpitoSN = async (ticket) => { logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...'); const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id); + await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp if (comments.length === 0) { logInfo(`Nenhum comentário encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return; @@ -25,21 +26,23 @@ const syncCommentsGlpitoSN = async (ticket) => { for (const comment of comments) { try { const sanitized = sanitizeGLPIComment(comment); - const existingComment = await TicketUpdateModel.getCommentSync(ticket.glpi_ticket_id, comment.id); + const existingComment = await TicketUpdateModel.getBySourceId(comment.id); const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized); const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); // Caso 1: já sincronizado if (existingComment && existsInSN) { logInfo(`🔁 Comentário ${comment.id} já sincronizado.`); - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'synced', 'synced'); + // Não muda o source_last aqui, pois não houve nova sincronização continue; } + // Caso 2: está no SN mas não no banco if (!existingComment && existsInSN) { await TicketUpdateModel.insert({ ticket_sync_id: syncId, + update_type: 'comment', source_system: 'glpi', content: sanitized, created_at: new Date(comment.date_creation), @@ -47,35 +50,41 @@ const syncCommentsGlpitoSN = async (ticket) => { source_id: comment.id, destiny_id: existsInSN.destiny_id }); - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'synced', 'synced'); + // Não muda o source_last aqui continue; } // Caso 3: existe no banco mas falta no SN if (existingComment && !existsInSN) { - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'synced', 'pending_cmt_sync'); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_sync'); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); await TicketUpdateModel.updateDestinyId(comment.id, snCommentId); + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); continue; } - + // Caso 4: não existe em nenhum lugar → sincroniza ambos - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync'); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync'); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); await TicketUpdateModel.insert({ ticket_sync_id: syncId, source_system: 'glpi', + update_type: 'comment', content: sanitized, created_at: new Date(comment.date_creation), updated_at: new Date(comment.date_mod), source_id: comment.id, destiny_id: snCommentId }); - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'synced', 'synced'); + // Apenas aqui, quando um comentário é efetivamente sincronizado, passamos o bastão, + // mas SOMENTE se o ticket não estiver já resolvido ou fechado. + if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); + } } catch (err) { logError(`Erro ao sincronizar comentário ${comment.id}: ${err}`); - await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'error', 'error'); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error'); } } @@ -92,9 +101,12 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => { for (const comment of comments) { try { const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id); - - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced'); - await TicketUpdateModel.updateDestinyId(comment.sys_id, commentId); + await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp + // Passa o bastão para o SN, mas SOMENTE se o ticket não estiver já resolvido ou fechado. + 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); logInfo(`✅ Comentário inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`); @@ -118,7 +130,7 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => { * @module glpiCommentService * @description Este módulo fornece serviços para sincronizar comentários do GLPI para o banco de dados local. * A função syncCommentsGlpi busca tickets que foram criados com sucesso e sincroniza seus comentários, - * garantindo que apenas novos comentários sejam inseridos no banco de dados. + * garantindo que apenas novos comentários sejam inseridos no banco de dados.synced * Utiliza os modelos TicketSnModel, TicketGlpiModel, TicketSyncModel e TicketUpdateModel para interagir com o banco de dados. * Atualiza o status de sincronização após cada comentário processado. * Inclui logging detalhado para monitorar o progresso e identificar erros durante o processo de sincronização. @@ -128,4 +140,3 @@ module.exports = { syncCommentsGlpitoSN, syncCommentsSNtoGlpi }; - diff --git a/src/services/glpiTicketService.js b/src/services/glpiTicketService.js index fc8fd1b..be9045f 100644 --- a/src/services/glpiTicketService.js +++ b/src/services/glpiTicketService.js @@ -1,8 +1,11 @@ // src/services/glpiService.js const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); +const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const { logInfo, logError } = require('../utils/logger'); +const { addWorkNoteToServiceNow } = require('./servicenowService'); + // Método principal que o controller vai chamar const syncTicketsToGlpi = async () => { @@ -46,6 +49,7 @@ const processSingleTicket = async (ticket) => { // 2. Se não tem ID local, verifica se o ticket existe no GLPI (buscando pelo número). const existingTicket = await TicketGlpiModel.ticketExists(ticket.ticket_number); + await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp if (existingTicket) { logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${existingTicket}`); @@ -75,9 +79,10 @@ const processSingleTicket = async (ticket) => { // 3. Inserir no GLPI const glpiTicketId = await TicketGlpiModel.createTicket(glpiData); + await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp // 4. Atualizar registro de sincronização - await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced'); + await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId); logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`); @@ -86,13 +91,99 @@ const processSingleTicket = async (ticket) => { // Se temos um registro de sync, atualizar o status para error try { - await TicketSyncModel.updateStatus(idSyncRecord.id, 'error', 'synced'); + await TicketSyncModel.updateStatus(ticket.id, 'error', 'collected'); } catch (innerError) { logError(innerError, `❌ Erro ao registrar falha de sincronização para ticket ${ticket.id}`); } } }; +const checkGlpiStatusChanges = async () => { + try { + logInfo('📡 Verificando mudanças de status nos tickets do GLPI...'); + + // 1. Busca todos os tickets que estão sendo monitorados + const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); + + if (ticketsToMonitor.length === 0) { + logInfo('Nenhum ticket ativo para verificar status no GLPI.'); + return; + } + + for (const ticket of ticketsToMonitor) { + // 2. Para cada ticket, busca seu status real no GLPI + const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + + // 3. Compara o status real com o que temos no nosso banco + const currentDbStatus = ticket.glpi_sync_status; + const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); // ex: 'em_atendimento' + + // Se o status no GLPI mudou para Solucionado (5) e nosso banco ainda não reflete isso + if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') { + logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`); + + 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.'; + 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); + continue; // Pula para o próximo ticket + } + + // FLUXO NORMAL DE RESOLUÇÃO + const sanitizedContent = solution?.content ? stripHTML(solution.content) : 'Chamado solucionado sem notas.'; + await TicketUpdateModel.insert({ + ticket_sync_id: ticket.id, + source_system: 'glpi', + update_type: 'close_message', + content: sanitizedContent, + author: null, + created_at: solution?.date_mod || new Date(), + source_id: `solution-${ticket.glpi_ticket_id}`, + }); + // Marca o ticket para ser resolvido no SN + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'pending_solve', 'GLPI'); + + } else if (glpiStatus === 6 && currentDbStatus !== 'closed') { + logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Fechado permanentemente. Marcando como 'closed'.`); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status); + + } else if (glpiStatus < 5 && glpiStatusAsString !== currentDbStatus && ticket.source_last !== 'GLPI') { + // Para outros status (reabertura, etc.), apenas passa o bastão. + logInfo(`Mudança de status (aberto) detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. De '${currentDbStatus}' para '${glpiStatusAsString}'.`); + await TicketSyncModel.updateSourceLast(ticket.sn_ticket_id, 'GLPI'); + } + } + } catch (error) { + logError(error, '🚨Falha ao verificar mudanças de status no GLPI'); + } +}; + +const closeTicketInGlpi = async (ticket) => { + try { + logInfo(`🔒 Iniciando fechamento do chamado GLPI ${ticket.glpi_ticket_id}...`); + + // Busca a mensagem de fechamento vinda do ServiceNow + const closeMessageRecord = await TicketUpdateModel.getLatestUpdate(ticket.id, 'servicenow'); + const solutionContent = closeMessageRecord?.content || 'Chamado encerrado via integração com o ServiceNow.'; + + // Chama o model para fechar o ticket no GLPI + await TicketGlpiModel.closeTicket(ticket.glpi_ticket_id, solutionContent); + await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp + + logInfo(`✅ Chamado ${ticket.glpi_ticket_id} solucionado com sucesso no GLPI.`); + } catch (error) { + logError(error, `🚨Falha ao fechar chamado no GLPI: ${ticket.glpi_ticket_id}`); + throw error; // Propaga o erro para o controller tratar + } +}; + module.exports = { - syncTicketsToGlpi + syncTicketsToGlpi, + checkGlpiStatusChanges, + closeTicketInGlpi }; \ No newline at end of file diff --git a/src/services/servicenowService.js b/src/services/servicenowService.js index a989236..3252063 100644 --- a/src/services/servicenowService.js +++ b/src/services/servicenowService.js @@ -3,98 +3,35 @@ const axios = require('axios'); const apiConfig = require('../../config/apiConfig'); const TicketSnModel = require('../models/ticketSnModel'); const { logInfo, logError, logSync } = require('../utils/logger'); -const fs = require('fs'); const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); +const { log } = require('winston'); -const fetchTicketsFromServiceNow = async () => { +const fetchTicketsFromServiceNow = async (watermark) => { try { - logInfo('📡 Buscando incidentes do ServiceNow...', { service: 'servicenow' }); - - const ticketsResponse = await axios.get(apiConfig.snIncidentConfig.baseUrl, { + const query = `active=true^sys_updated_on>=${watermark}`; + const url = `${apiConfig.snIncidentConfig.baseUrl}?sysparm_query=${query}&sysparm_display_value=true`; + const ticketsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); - - logInfo('💾 Iniciando salvamento de incidentes no banco...'); - for (const ticket of ticketsResponse.data.result) { - - //Insere no banco de dados como Incidente - const idTableTicketSn = await TicketSnModel.saveTicket(ticket, 'incidente'); - - //Salvando atualização no banco intermediario e defininfo sync status - const ticketStatus = ticket.state?.display_value; - - const dataSync = { - glpi_ticket_id: null, - sn_ticket_id: idTableTicketSn, - last_sn_sync: new Date(), - last_glpi_sync: null, - created_at: null, - updated_at: null - } - - if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') { - dataSync.sn_sync_status ='closed' - dataSync.glpi_sync_status = 'ignored' - } else { - dataSync.sn_sync_status ='synced' - dataSync.glpi_sync_status = 'pending_check' - } - //Cria apenas se não existir - const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn); - if (!exists) await TicketSyncModel.createSyncRecord(dataSync); - else await TicketSyncModel.updateStatus(exists, 'synced', 'pending_check'); - } - - logSync('ServiceNow', ticketsResponse.data.result.length, 'incidentes'); - + return ticketsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Tickets'); + throw error; // Lança o erro para que o controller não atualize a watermark } } -const fetchRequestsFromServiceNow = async () => { +const fetchRequestsFromServiceNow = async (watermark) => { try { - logInfo('📡 Buscando requisições do ServiceNow...', { service: 'servicenow' }); - - const requestsResponse = await axios.get(apiConfig.snRequestConfig.baseUrl, { + const query = `active=true^sys_updated_on>=${watermark}`; + const url = `${apiConfig.snRequestConfig.baseUrl}?sysparm_query=${query}&sysparm_display_value=true`; + const requestsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); - - // Salvar as requisições no banco de dados - logInfo('💾 Iniciando salvamento de requisições no banco...'); - for (const request of requestsResponse.data.result) { - - - //Salvando a requisicao no banco intermediario - const idTableTicketSn = await TicketSnModel.saveTicket(request, 'requisicao'); - - const dataSync = { - glpi_ticket_id: null, - sn_ticket_id: idTableTicketSn, - last_sn_sync: new Date(), - last_glpi_sync: null, - created_at: null, - updated_at: null - } - - if (request.state?.display_value === 'Encerrado' || request.state?.display_value === 'Encerrado - Omitido') { - dataSync.sn_sync_status ='closed' - dataSync.glpi_sync_status = 'ignored' - } else { - dataSync.sn_sync_status ='collected' - dataSync.glpi_sync_status = 'pending_check' - } - - //Cria apenas se não existir - const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn); - if (!exists) await TicketSyncModel.createSyncRecord(dataSync); - - - } - logSync('ServiceNow', requestsResponse.data.result.length, 'requisições'); + return requestsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Requests'); + throw error; // Lança o erro } } @@ -102,17 +39,25 @@ const fetchRequestsFromServiceNow = async () => { const getTicketSysId = async (ticketId) => { try { const ticket = await TicketSnModel.findById(ticketId); + if (!ticket) { + logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketSysId'); + return null; + } return ticket.sys_id; } catch (error) { logError(error, '🚨Falha ao obter sys_id do ticket'); return null; } } - + // Função para obter o tipo de um ticket pela tabela de sincronização do banco de dados local const getTicketTypeById = async (ticketId) => { try { const ticket = await TicketSnModel.findById(ticketId); + if (!ticket) { + logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketTypeById'); + return null; + } const ticketType = ticket.tipo; logInfo(`⚠️ Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType }); return ticketType; @@ -148,7 +93,7 @@ const getCommentId = async (sysId, comment) => { } } // Função para criar um comentário em um ticket no ServiceNow -const createCommentInServiceNow = async (sysId, comment, ticketType) => { +const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => { try { let url = ''; let payload = {}; @@ -170,6 +115,7 @@ const createCommentInServiceNow = async (sysId, comment, ticketType) => { const response = await axios.patch(url, payload, { auth: apiConfig.servicenowAuthentication.auth }); + await TicketSyncModel.updateLastSync(snId, 'servicenow'); // Atualiza o timestamp // Verificar se retorna status 200 para sucesso if (response.status !== 200) { @@ -204,7 +150,7 @@ const syncCommentToServiceNow = async (ticketId, comment) => { logError(`❌ sys_id do ServiceNow não encontrado para o ticket SN Ticket: ${ticketId}`); return null; } - const snCommentId = await createCommentInServiceNow(snSysId, comment, ticketType); + const snCommentId = await createCommentInServiceNow(ticketId,snSysId, comment, ticketType); return snCommentId; } @@ -229,8 +175,6 @@ const fetchCommentsFromServiceNow = async (ticket) => { logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_check'); - const sys_id = ticket.sys_id; @@ -253,6 +197,7 @@ const fetchCommentsFromServiceNow = async (ticket) => { auth: apiConfig.servicenowAuthentication.auth, params }); + await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'servicenow'); // Atualiza o timestamp const comments = response?.data?.result || []; @@ -276,11 +221,10 @@ const fetchCommentsFromServiceNow = async (ticket) => { break; } } - + // Traz apenas os tickets que não são do sys_created_by: "SOTHIS.CAOA" const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA'); if (filteredComments.length === 0) { - TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced ', 'synced'); logInfo(`Nenhum comentário encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return []; } @@ -291,20 +235,16 @@ const fetchCommentsFromServiceNow = async (ticket) => { const commentsInserted = []; // Insere cada comment no banco intermediario for (const comment of filteredComments) { - - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pendind_cmt_sync'); - - - const existingComment = await TicketUpdateModel.getCommentSync(comment.sys_id); + const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id); if (existingComment) { - TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced'); logInfo(`Comentário: ${comment.sys_id} já existe no banco de dados`, { step: 3 }); continue; } else { const commentData = { ticket_sync_id: ticket.id, + update_type: 'comment', source_system: 'servicenow', content: comment.value, created_at: new Date(comment.sys_created_on), @@ -316,33 +256,253 @@ const fetchCommentsFromServiceNow = async (ticket) => { const commentInserted = await TicketUpdateModel.insert(commentData); - if (commentInserted.success) { - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pendind_cmt_sync', 'synced'); - commentsInserted.push(commentInserted); - logInfo(`✅ Comentário ID ${comment.sys_id} do ${comment.source_system} inserido no banco!`, { step: 4 }); + if (commentInserted.success) { + commentsInserted.push(commentData); + logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 }); } else { logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 }); return []; } - - - } } + return commentsInserted; + + } catch (error) { logError(error, '🚨Falha ao buscar comentários do ServiceNow'); return []; } }; +// Adiciona no final do arquivo src/services/servicenowService.js +const { stripHTML } = require('../utils/commentSanitizer'); +const fetchAndProcessClosedTicketsFromSN = async () => { + try { + logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...'); + + // 1. Busca apenas os tickets que estão abertos e sincronizados no nosso banco. + const syncedTickets = await TicketSyncModel.getTicketsSynced(); + + if (syncedTickets.length === 0) { + logInfo('Nenhum ticket sincronizado para verificar status no ServiceNow.'); + return; + } + + // 2. Para cada ticket, verifica seu status real no ServiceNow. + for (const ticket of syncedTickets) { + const ticketSnDetails = await TicketSnModel.findById(ticket.sn_ticket_id); + + // 3. Se o status for de fechamento, inicia o processo. + if (ticketSnDetails && ticketSnDetails.status === 'Encerrado') { // 'Encerrado' mapeia para 'Resolved' (6) + logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Resolvido. Marcando como 'solved'...`); + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'solved', 'SNOW'); + + } else if (ticketSnDetails && ticketSnDetails.status === 'Encerrado - Omitido') { // 'Encerrado - Omitido' mapeia para 'Closed' (7) + logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronização de fechamento...`); + + // Sanitiza o conteúdo da nota de fechamento antes de salvar. + const sanitizedContent = stripHTML(ticketSnDetails.close_notes || 'Chamado encerrado sem notas.'); + + // Salva a nota de fechamento no nosso banco intermediário. + await TicketUpdateModel.insert({ + ticket_sync_id: ticket.id, + source_system: 'servicenow', + update_type: 'close_message', + content: sanitizedContent, + author: 'ServiceNow', + created_at: ticketSnDetails.updated_at, + source_id: `close-${ticketSnDetails.sys_id}`, + }); + + // Atualiza o status no nosso banco para 'pending_close' no GLPI. + await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'pending_close', 'closed', 'SNOW'); + } + } + + } catch (error) { + logError(error, '🚨Falha ao buscar chamados encerrados do ServiceNow'); + } +}; + +/** + * Atualiza o status (state) de um ticket no ServiceNow (incidente ou requisição). + */ +const updateStatusInServiceNow = async (snTicketId, state) => { + // Mapeamento de status de string para código numérico do ServiceNow + const stateMap = { + 'Aguardando Atendimento': '1', // New + 'Em Atendimento': '2', // In Progress + 'Em Espera': '3', // On Hold + 'Encerrado': '6', // Resolved + 'Encerrado - Omitido': '7' // Closed + }; + + const stateCode = stateMap[state] || state; // Usa o código mapeado ou o valor original se não encontrar + + let sysId = null; // Declarar sysId fora do try para estar disponível no catch + try { + const ticketSn = await TicketSnModel.findById(snTicketId); + if (!ticketSn) { + logError(`Ticket SN com ID ${snTicketId} não encontrado para atualização de status.`, 'updateStatusInServiceNow'); + return null; + } + + sysId = ticketSn.sys_id; // Atribuir valor + const ticketType = ticketSn.tipo; + + let url = ''; + let payload = {}; + + if (ticketType === 'incidente') { + url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; + payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state + } else if (ticketType === 'requisicao') { + url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; + payload = { state: stateCode }; // Requisições podem ter um fluxo diferente + } else { + logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); + throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); + } + + const response = await axios.patch(url, payload, { + auth: apiConfig.servicenowAuthentication.auth + }); + await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Atualiza o timestamp + + if (response.status !== 200) { + logError(`❌ Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode }); + throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`); + } + + logInfo(`✅ Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode }); + return true; + + } catch (error) { + logError(error, `🚨 Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`); + return null; + } +}; + + +/** + * Resolve um ticket no ServiceNow (incidente ou requisição), + * atualizando o estado para 'Resolved' (6) e enviando as notas de resolução. + */ +const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => { + let sysId = null; // Declarar sysId fora do try + try { + const ticketSn = await TicketSnModel.findById(snTicketId); + if (!ticketSn) { + logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow'); + return null; + } + + sysId = ticketSn.sys_id; + const ticketType = ticketSn.tipo; + let url = ''; // Declarar url fora do if + let payload = {}; + + // O ID do usuário que está resolvendo o chamado. Pegue o sys_id do usuário da integração no SN. + // O nome do usuário que está resolvendo o chamado. + const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu + + if (ticketType === 'incidente') { + url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; + payload = { + state: '6', // 6 = Resolved + incident_state: '6', // 6 = Resolved + resolved_by: resolvedBySysId, + resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '), + close_code: 'Closed/Resolved by caller', + close_notes: closeNotes, + work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}` + }; + } else if (ticketType === 'requisicao') { + url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; + payload = { + state: '6', // Assumindo que '6' também é um estado de resolução para requisições + // Requisições podem não ter os mesmos campos de resolução. Ajuste se necessário. + work_notes: closeNotes || 'Requisição resolvida automaticamente via integração GLPI.' + }; + } else { + logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); + throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); + } + const response = await axios.patch(url, payload, { + auth: apiConfig.servicenowAuthentication.auth, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Corrigido para usar snTicketId + + if (response.status !== 200) { + logError(`❌ Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType }); + throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`); + } + + logInfo(`✅ Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType }); + return true; + + } catch (error) { + logError(error, `🚨 Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`); + return null; + } +}; + +/** + * Adiciona uma work_note a um ticket no ServiceNow sem alterar seu status. + */ +const addWorkNoteToServiceNow = async (snTicketId, workNote) => { + let sysId = null; + try { + const ticketSn = await TicketSnModel.findById(snTicketId); + if (!ticketSn) { + logError(`Ticket SN com ID ${snTicketId} não encontrado para adicionar work note.`, 'addWorkNoteToServiceNow'); + return null; + } + + sysId = ticketSn.sys_id; + const ticketType = ticketSn.tipo; + let url = ''; + + if (ticketType === 'incidente') { + url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; + } else if (ticketType === 'requisicao') { + url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; + } else { + throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); + } + + const payload = { work_notes: workNote }; + + await axios.patch(url, payload, { + auth: apiConfig.servicenowAuthentication.auth, + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } + }); + + await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); + logInfo(`✅ Work note adicionada com sucesso ao ticket SN ${sysId}.`); + return true; + + } catch (error) { + logError(error, `🚨 Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`); + return null; + } +}; module.exports = { - fetchTicketsFromServiceNow, - fetchRequestsFromServiceNow, - getTicketSysId, - createCommentInServiceNow, - getCommentId, - existingCommentInServiceNow, - syncCommentToServiceNow, - fetchCommentsFromServiceNow -} \ No newline at end of file + fetchTicketsFromServiceNow, + fetchRequestsFromServiceNow, + getTicketSysId, + createCommentInServiceNow, + getCommentId, + existingCommentInServiceNow, + syncCommentToServiceNow, + fetchCommentsFromServiceNow, + fetchAndProcessClosedTicketsFromSN, + closeTicketInServiceNow, + updateStatusInServiceNow, + addWorkNoteToServiceNow +};