FEAT: Implementa sincronização bidirecional de status e ciclo de vida de tickets

- **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."
This commit is contained in:
Rafael Alves Lopes 2025-10-23 17:30:03 -03:00
parent f7eaf34e7d
commit 0cc27df2cf
16 changed files with 1112 additions and 180 deletions

View File

@ -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();

View File

@ -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 = {

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};

View File

@ -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();

View File

@ -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

View File

@ -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;

View File

@ -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.

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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';
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');

View File

@ -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
};

View File

@ -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
};

View File

@ -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
}
fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow,
getTicketSysId,
createCommentInServiceNow,
getCommentId,
existingCommentInServiceNow,
syncCommentToServiceNow,
fetchCommentsFromServiceNow,
fetchAndProcessClosedTicketsFromSN,
closeTicketInServiceNow,
updateStatusInServiceNow,
addWorkNoteToServiceNow
};