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 41645422fe
4 changed files with 51 additions and 27 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -28,7 +28,7 @@ const normalizeAuthorName = (rawAuthor) => {
return toTitleCase(normalizedRaw); return toTitleCase(normalizedRaw);
}; };
const formatSNUpdateForGlpi = (update) => { const formatSNUpdateForGlpi = (update, options = {}) => {
const escapeHtml = (value) => String(value || '') const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&') .replace(/&/g, '&')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
@ -39,6 +39,9 @@ const formatSNUpdateForGlpi = (update) => {
const content = escapeHtml(update.content || ''); const content = escapeHtml(update.content || '');
const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(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 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) { if (isAutomaticTask) {
return ` return `
@ -59,6 +62,14 @@ const formatSNUpdateForGlpi = (update) => {
</div>`; </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 ` return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;"> <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> <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...'); logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
const orderedComments = [...comments].sort((a, b) => { const orderedComments = [...comments].sort((a, b) => {
@ -161,17 +172,22 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
const bTime = new Date(b.created_at).getTime(); const bTime = new Date(b.created_at).getTime();
return aTime - bTime; return aTime - bTime;
}); });
let reopenHighlightPending = Boolean(options.highlightReopenContext);
for (const comment of orderedComments) { for (const comment of orderedComments) {
try { try {
const isReopenContextComment = reopenHighlightPending && comment.update_type === 'comment';
const formattedPayload = { const formattedPayload = {
...comment, ...comment,
content: formatSNUpdateForGlpi(comment) content: formatSNUpdateForGlpi(comment, { isReopenContextComment })
}; };
const isTask = comment.update_type === 'task'; const isTask = comment.update_type === 'task';
const insertedId = isTask const insertedId = isTask
? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id) ? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id)
: await TicketGlpiModel.insertComment(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'); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); 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())}`; 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) => { const fetchTicketsFromServiceNow = async (watermark) => {
try { try {
const watermarkDate = new Date(watermark); const watermarkDate = new Date(watermark);
@ -285,10 +291,13 @@ const fetchCommentsFromServiceNow = async (ticket) => {
// Em ambiente de teste, a regra pode ser desligada por env. // Em ambiente de teste, a regra pode ser desligada por env.
const filteredComments = allComments.filter((entry) => { const filteredComments = allComments.filter((entry) => {
if (!shouldIgnoreDefaultUserJournal) { if (!shouldIgnoreDefaultUserJournal) {
return true; return !isAutomaticServiceNowTask(entry);
} }
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase(); const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
const ignoredUser = ignoredJournalUsers.includes(createdBy); const ignoredUser = ignoredJournalUsers.includes(createdBy);
if (isAutomaticServiceNowTask(entry)) {
return false;
}
if (entry.element === 'comments') { if (entry.element === 'comments') {
return !ignoredUser; return !ignoredUser;
} }
@ -322,20 +331,11 @@ const fetchCommentsFromServiceNow = async (ticket) => {
continue; continue;
} else { } 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 = { const commentData = {
ticket_sync_id: ticket.id, ticket_sync_id: ticket.id,
update_type: comment.element === 'work_notes' ? 'task' : 'comment', update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow', source_system: 'servicenow',
content: normalizedContent, content: comment.value,
created_at: normalizeServiceNowDateTime(comment.sys_created_on), created_at: normalizeServiceNowDateTime(comment.sys_created_on),
author: comment.sys_created_by || 'ServiceNow', author: comment.sys_created_by || 'ServiceNow',
updated_at: normalizeServiceNowDateTime(comment.sys_created_on), updated_at: normalizeServiceNowDateTime(comment.sys_created_on),