FEATURE: Chamados reabertos possuem uma nova coloração

This commit is contained in:
Rafael Alves Lopes 2026-03-04 18:15:53 -03:00
parent ce294d4220
commit c964bcf18a
4 changed files with 51 additions and 27 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ servicenow_tickets.json
location_mapping.log
.env*
!.env.example
docs/checklist.md

View File

@ -6,27 +6,27 @@ const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logInfo } = require('../utils/logger');
const ensureReopenFromSNWhenGlpiSolved = async (ticket) => {
if (ticket.glpi_sync_status !== 'solved') {
return;
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const glpiIsSolved = glpiStatus === 5;
if (!glpiIsSolved) {
return { reopened: false, glpiSolved: false };
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
return;
return { reopened: false, glpiSolved: true };
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const snIsFinal = liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido';
if (snIsFinal) {
return;
return { reopened: false, glpiSolved: true };
}
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === 5) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento');
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced');
logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`);
}
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento');
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced');
logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`);
return { reopened: true, glpiSolved: true };
};
const processCommentsController = async () => {
@ -34,11 +34,18 @@ const processCommentsController = async () => {
logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
for (const ticket of ticketsToMonitor) {
await ensureReopenFromSNWhenGlpiSolved(ticket);
const reopenState = await ensureReopenFromSNWhenGlpiSolved(ticket);
const reopenedThisCycle = reopenState.reopened;
const glpiWasSolved = reopenState.glpiSolved;
if (glpiWasSolved && !reopenedThisCycle) {
logInfo(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`);
await syncCommentsGlpitoSN(ticket);
continue;
}
const comments = await fetchCommentsFromServiceNow(ticket);
if (comments.length !== 0) {
await syncCommentsSNtoGlpi(comments, ticket);
await syncCommentsSNtoGlpi(comments, ticket, { highlightReopenContext: reopenedThisCycle });
}
await syncCommentsGlpitoSN(ticket);
@ -47,4 +54,4 @@ const processCommentsController = async () => {
module.exports = {
processCommentsController
};
};

View File

@ -28,7 +28,7 @@ const normalizeAuthorName = (rawAuthor) => {
return toTitleCase(normalizedRaw);
};
const formatSNUpdateForGlpi = (update) => {
const formatSNUpdateForGlpi = (update, options = {}) => {
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
@ -39,6 +39,9 @@ const formatSNUpdateForGlpi = (update) => {
const content = escapeHtml(update.content || '');
const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || ''));
const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || ''));
const isSnReopenMessageByText = update.update_type === 'comment' && /reabert|reopen/i.test(String(update.content || ''));
const isSnReopenMessageByContext = Boolean(options.isReopenContextComment);
const isSnReopenMessage = isSnReopenMessageByText || isSnReopenMessageByContext;
if (isAutomaticTask) {
return `
@ -59,6 +62,14 @@ const formatSNUpdateForGlpi = (update) => {
</div>`;
}
if (isSnReopenMessage) {
return `
<div style="border:1px solid #9ae6b4; border-left:4px solid #2f855a; background:#f0fff4; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#22543d; margin-bottom:6px;">Reabertura de chamado no ServiceNow</div>
<div style="color:#276749; font-size:12px; white-space: pre-wrap;">${content}</div>
</div>`;
}
return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">${author}</div>
@ -153,7 +164,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
};
const syncCommentsSNtoGlpi = async (comments, ticket) => {
const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
const orderedComments = [...comments].sort((a, b) => {
@ -161,17 +172,22 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
let reopenHighlightPending = Boolean(options.highlightReopenContext);
for (const comment of orderedComments) {
try {
const isReopenContextComment = reopenHighlightPending && comment.update_type === 'comment';
const formattedPayload = {
...comment,
content: formatSNUpdateForGlpi(comment)
content: formatSNUpdateForGlpi(comment, { isReopenContextComment })
};
const isTask = comment.update_type === 'task';
const insertedId = isTask
? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id)
: await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id);
if (isReopenContextComment) {
reopenHighlightPending = false;
}
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');

View File

@ -43,6 +43,12 @@ const normalizeServiceNowDateTime = (value) => {
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
};
const isAutomaticServiceNowTask = (entry) => {
if (!entry || entry.element !== 'work_notes') return false;
const content = String(entry.value || '');
return /registro alterado por:/i.test(content);
};
const fetchTicketsFromServiceNow = async (watermark) => {
try {
const watermarkDate = new Date(watermark);
@ -285,10 +291,13 @@ const fetchCommentsFromServiceNow = async (ticket) => {
// Em ambiente de teste, a regra pode ser desligada por env.
const filteredComments = allComments.filter((entry) => {
if (!shouldIgnoreDefaultUserJournal) {
return true;
return !isAutomaticServiceNowTask(entry);
}
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
const ignoredUser = ignoredJournalUsers.includes(createdBy);
if (isAutomaticServiceNowTask(entry)) {
return false;
}
if (entry.element === 'comments') {
return !ignoredUser;
}
@ -322,20 +331,11 @@ const fetchCommentsFromServiceNow = async (ticket) => {
continue;
} else {
let normalizedContent = comment.value;
if (
comment.element === 'work_notes' &&
typeof normalizedContent === 'string' &&
normalizedContent.includes('Registro alterado por:')
) {
normalizedContent = `Tarefa automatica originada no ServiceNow (registro automatico).\n\nMensagem original:\n${normalizedContent}`;
}
const commentData = {
ticket_sync_id: ticket.id,
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow',
content: normalizedContent,
content: comment.value,
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
author: comment.sys_created_by || 'ServiceNow',
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),