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:
parent
f7eaf34e7d
commit
0cc27df2cf
12
src/app.js
12
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();
|
||||
@ -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 = {
|
||||
|
||||
47
src/controllers/processErrorController.js
Normal file
47
src/controllers/processErrorController.js
Normal 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
|
||||
};
|
||||
144
src/controllers/processStatusController.js
Normal file
144
src/controllers/processStatusController.js
Normal 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
|
||||
};
|
||||
111
src/controllers/processSyncController.js
Normal file
111
src/controllers/processSyncController.js
Normal 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
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
37
src/models/syncControlModel.js
Normal file
37
src/models/syncControlModel.js
Normal 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;
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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');
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user