- feito ajuste do fluxo GLPI->SN para tratar status 5 (resolve no SN) e status 6 (aviso definitivo e retirada de monitoramento) - feito ajuste de reabertura SN->GLPI quando ticket estiver solved no GLPI - feito ajuste de data/hora de comments/worknotes para evitar inversão de data - feito ajuste de vínculo Ticket Externo no SN com validação de persistência e retry idempotente - feito ajuste de filtro de journals para ignorar SOTHIS.CAOA e admin.caoa por configuração - feito inclusão de monitor dedicado de encerramento definitivo (processGlpiClosureController) - feito atualização de flags de controle (ENABLE_STATUS_SYNC, ENABLE_GLPI_CLOSE_CRON, ENABLE_GLPI_WEBHOOK) - feito atualização da documentação (README, docs/regrasdenegocio.md, docs/fluxo.md, docs/casosdeuso.md, docs/checklist.md)
798 lines
33 KiB
JavaScript
798 lines
33 KiB
JavaScript
// src/services/servicenowService.js
|
|
const axios = require('axios');
|
|
const apiConfig = require('../../config/apiConfig');
|
|
const { stripHTML } = require('../utils/commentSanitizer');
|
|
const TicketSnModel = require('../models/ticketSnModel');
|
|
const { logInfo, logError, logSync } = require('../utils/logger');
|
|
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
|
const TicketSyncModel = require('../models/ticketSyncModel');
|
|
|
|
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
|
|
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
|
|
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
|
|
const externalTicketField = process.env.SERVICENOW_EXTERNAL_TICKET_FIELD || 'u_external_tickets';
|
|
const ignoredJournalUsers = (
|
|
process.env.SERVICENOW_IGNORE_USERS_JOURNAL ||
|
|
`${servicenowDefaultUser},admin.caoa`
|
|
)
|
|
.split(',')
|
|
.map((u) => String(u || '').trim().toLowerCase())
|
|
.filter(Boolean);
|
|
|
|
const normalizeServiceNowDateTime = (value) => {
|
|
if (!value) return null;
|
|
const asString = String(value).trim();
|
|
|
|
// Formato ISO/local: YYYY-MM-DD HH:mm:ss
|
|
const ymd = asString.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
if (ymd) {
|
|
return `${ymd[1]}-${ymd[2]}-${ymd[3]} ${ymd[4]}:${ymd[5]}:${ymd[6]}`;
|
|
}
|
|
|
|
// Formato SN comum com barra: DD/MM/YYYY HH:mm:ss
|
|
const dmy = asString.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
if (dmy) {
|
|
return `${dmy[3]}-${dmy[2]}-${dmy[1]} ${dmy[4]}:${dmy[5]}:${dmy[6]}`;
|
|
}
|
|
|
|
const parsed = new Date(asString);
|
|
if (Number.isNaN(parsed.getTime())) return null;
|
|
|
|
// Fallback preservando timestamp local sem converter para UTC.
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
|
|
};
|
|
|
|
const fetchTicketsFromServiceNow = async (watermark) => {
|
|
try {
|
|
const watermarkDate = new Date(watermark);
|
|
const watermarkBRT = new Date(watermarkDate.getTime());
|
|
const formattedWatermark = watermarkBRT.toISOString().replace('T', ' ').substring(0, 19);
|
|
const fields = 'number,sys_id,short_description,state,description,caller_id,caller_id.email,caller_id.phone,caller_id.mobile_phone,location,opened_at,sys_updated_on';
|
|
const baseUrl = apiConfig.snTableIncidentConfig.baseUrl.replace(/\/$/, "");
|
|
const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${formattedWatermark}`;
|
|
const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
|
|
const ticketsResponse = await axios.get(url, {
|
|
auth: apiConfig.servicenowAuthentication.auth
|
|
});
|
|
return ticketsResponse.data.result || [];
|
|
} catch (error) {
|
|
logError(error, 'SERVICE NOW Tickets');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const fetchRequestsFromServiceNow = async (watermark) => {
|
|
try {
|
|
const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${watermark}`;
|
|
const fields ='number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables';
|
|
const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, "");
|
|
const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
|
|
const requestsResponse = await axios.get(url, {
|
|
auth: apiConfig.servicenowAuthentication.auth
|
|
});
|
|
return requestsResponse.data.result || [];
|
|
} catch (error) {
|
|
logError(error, 'SERVICE NOW Requests');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const fetchTicketLiveStatusFromServiceNow = async (sysId, ticketType) => {
|
|
try {
|
|
const baseUrl = ticketType === 'requisicao'
|
|
? apiConfig.snTableRequestConfig.baseUrl
|
|
: apiConfig.snTableIncidentConfig.baseUrl;
|
|
const url = `${baseUrl}/${sysId}?sysparm_display_value=true&sysparm_fields=state`;
|
|
const response = await axios.get(url, {
|
|
auth: apiConfig.servicenowAuthentication.auth
|
|
});
|
|
const result = response?.data?.result;
|
|
if (!result) return null;
|
|
|
|
if (typeof result.state === 'object') {
|
|
return result.state.display_value || result.state.value || null;
|
|
}
|
|
|
|
return result.state || null;
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao buscar status em tempo real no ServiceNow para sys_id ${sysId}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const getTicketSysId = async (ticketId) => {
|
|
try {
|
|
const ticket = await TicketSnModel.findById(ticketId);
|
|
if (!ticket) {
|
|
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketSysId');
|
|
return null;
|
|
}
|
|
return ticket.sys_id;
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao obter sys_id do ticket');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const getTicketTypeById = async (ticketId) => {
|
|
try {
|
|
const ticket = await TicketSnModel.findById(ticketId);
|
|
if (!ticket) {
|
|
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketTypeById');
|
|
return null;
|
|
}
|
|
const ticketType = ticket.tipo;
|
|
logInfo(`INFO: Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
|
|
return ticketType;
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao obter tipo do ticket');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const getCommentId = async (sysId, comment) => {
|
|
try {
|
|
|
|
const url = `${apiConfig.snTableJournalConfig.baseUrl}?sysparm_query=element_id=${sysId}^element=comments^ORDERBYDESCsys_created_on&sysparm_fields=sys_id,element,value,sys_created_on,sys_created_by,element_id`;
|
|
|
|
const response = await axios.get(url, {
|
|
auth: apiConfig.servicenowAuthentication.auth
|
|
});
|
|
|
|
const data = response.data;
|
|
const comments = data.result;
|
|
|
|
// Procurar o comentario exato na lista retornada
|
|
for (const item of comments) {
|
|
if (item.value && item.value.includes(comment)) {
|
|
return item.sys_id; // Retorna o sys_id do comentario encontrado
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao obter ID do comentario no ServiceNow');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
|
|
try {
|
|
let url = '';
|
|
let payload = {};
|
|
if (ticketType == 'incidente') {
|
|
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
|
|
payload = {
|
|
comments: comment
|
|
};
|
|
} else if (ticketType == 'requisicao') {
|
|
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
|
|
payload = {
|
|
comments: comment
|
|
};
|
|
} else {
|
|
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
|
|
return null;
|
|
}
|
|
|
|
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) {
|
|
logError(`ERRO: Falha ao criar comentario. Status: ${response.status}`, { sysId, ticketType });
|
|
return null;
|
|
}
|
|
|
|
// retornar id do comentario criado
|
|
const commentId = await getCommentId(sysId, comment);
|
|
|
|
if (!commentId) {
|
|
logError('ERRO: Comentario criado, mas nao foi possivel obter o ID do comentario', { sysId });
|
|
return null;
|
|
}
|
|
|
|
logInfo(`OK: Comentario criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
|
|
|
|
return commentId;
|
|
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
|
|
}
|
|
}
|
|
|
|
const syncCommentToServiceNow = async (ticketId, comment) => {
|
|
logInfo(`Iniciando sincronizacao do comentario para o ticket SN Ticket: ${ticketId}`);
|
|
const snSysId = await getTicketSysId(ticketId);
|
|
const ticketType = await getTicketTypeById(ticketId);
|
|
|
|
if (!snSysId) {
|
|
logError(`ERRO: sys_id do ServiceNow nao encontrado para o ticket SN Ticket: ${ticketId}`);
|
|
return null;
|
|
}
|
|
const snCommentId = await createCommentInServiceNow(ticketId,snSysId, comment, ticketType);
|
|
return snCommentId;
|
|
}
|
|
|
|
const existingCommentInServiceNow = async (ticketId, comment) => {
|
|
try {
|
|
const sys_id = await getTicketSysId(ticketId);
|
|
const commentExists = await getCommentId(sys_id, comment);
|
|
return commentExists;
|
|
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao verificar existencia do comentario no ServiceNow');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const fetchCommentsFromServiceNow = async (ticket) => {
|
|
try {
|
|
|
|
logInfo(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
|
|
|
const sys_id = ticket.sys_id;
|
|
|
|
|
|
const allComments = [];
|
|
const pageSize = 100;
|
|
let offset = 0;
|
|
let page = 0;
|
|
const internalMaxPages = 1000; // safeguard interno para evitar loop infinito
|
|
|
|
while (true) {
|
|
page++;
|
|
const params = {
|
|
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`,
|
|
sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id',
|
|
sysparm_display_value: true,
|
|
sysparm_limit: pageSize,
|
|
sysparm_offset: offset
|
|
};
|
|
logInfo(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
|
const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, {
|
|
auth: apiConfig.servicenowAuthentication.auth,
|
|
params
|
|
});
|
|
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'servicenow'); // Atualiza o timestamp
|
|
|
|
const comments = response?.data?.result || [];
|
|
|
|
|
|
if (!Array.isArray(comments) || comments.length === 0) {
|
|
break; // nao ha mais comentarios
|
|
}
|
|
|
|
allComments.push(...comments);
|
|
|
|
if (comments.length < pageSize) {
|
|
break; // ultima pagina
|
|
}
|
|
|
|
// avancar para proxima pagina
|
|
offset += pageSize;
|
|
|
|
// protecao extra para evitar loop infinito em caso de comportamento inesperado da API
|
|
if (page >= internalMaxPages) {
|
|
logError(new Error('Max pages reached while paginating comments'), { sn_ticket_id: ticket.sn_ticket_id, page });
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Mantem comportamento legado para comments (evita loop da integracao).
|
|
// Em ambiente de teste, a regra pode ser desligada por env.
|
|
const filteredComments = allComments.filter((entry) => {
|
|
if (!shouldIgnoreDefaultUserJournal) {
|
|
return true;
|
|
}
|
|
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
|
|
const ignoredUser = ignoredJournalUsers.includes(createdBy);
|
|
if (entry.element === 'comments') {
|
|
return !ignoredUser;
|
|
}
|
|
if (entry.element === 'work_notes') {
|
|
return !ignoredUser;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Mantem ordem cronologica de criacao para preservar timeline no GLPI.
|
|
const orderedComments = filteredComments.sort((a, b) => {
|
|
const aTime = new Date(a.sys_created_on).getTime();
|
|
const bTime = new Date(b.sys_created_on).getTime();
|
|
return aTime - bTime;
|
|
});
|
|
if (orderedComments.length === 0) {
|
|
logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
|
return [];
|
|
}
|
|
|
|
logInfo(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
|
|
|
|
|
|
const commentsInserted = [];
|
|
// Insere cada comment no banco intermediario
|
|
for (const comment of orderedComments) {
|
|
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
|
|
|
|
if (existingComment) {
|
|
logInfo(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 });
|
|
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,
|
|
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
|
author: comment.sys_created_by || 'ServiceNow',
|
|
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
|
source_id: comment.sys_id,
|
|
destiny_id: null
|
|
};
|
|
|
|
const commentInserted = await TicketUpdateModel.insert(commentData);
|
|
|
|
|
|
if (commentInserted.success) {
|
|
commentsInserted.push(commentData);
|
|
logInfo(`OK: Comentario ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
|
|
} else {
|
|
logError(`ERRO: Erro ao inserir comentario ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
return commentsInserted;
|
|
|
|
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha ao buscar comentarios do ServiceNow');
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const fetchAndProcessClosedTicketsFromSN = async () => {
|
|
try {
|
|
logInfo('INFO: Verificando status de tickets sincronizados no ServiceNow...');
|
|
|
|
// 1. Busca apenas os tickets que estao 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 sincronizacao de fechamento...`);
|
|
|
|
// Sanitiza o conteudo 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 intermediario.
|
|
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, 'ERRO: Falha ao buscar chamados encerrados do ServiceNow');
|
|
}
|
|
};
|
|
|
|
const updateStatusInServiceNow = async (snTicketId, state) => {
|
|
// Mapeamento de status de string para codigo numerico do ServiceNow
|
|
const stateMap = {
|
|
'Aguardando Atendimento': '1', // New
|
|
'Em Atendimento': '2', // In Progress
|
|
'Em Espera': '-5', // On Hold
|
|
'Encerrado': '6', // Resolved
|
|
'Encerrado - Omitido': '7' // Closed
|
|
};
|
|
|
|
const stateCode = stateMap[state] || state; // Usa o codigo mapeado ou o valor original se nao encontrar
|
|
|
|
let sysId = null; // Declarar sysId fora do try para estar disponivel no catch
|
|
try {
|
|
const ticketSn = await TicketSnModel.findById(snTicketId);
|
|
if (!ticketSn) {
|
|
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizacao 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 }; // Requisicoes 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(`ERRO: 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(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
|
|
return true;
|
|
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
|
|
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} nao encontrado para fechamento.`, 'closeTicketInServiceNow');
|
|
return null;
|
|
}
|
|
|
|
sysId = ticketSn.sys_id;
|
|
const ticketType = ticketSn.tipo;
|
|
let url = ''; // Declarar url fora do if
|
|
let payload = {};
|
|
|
|
|
|
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 Tecnico Sothis: ${closeNotes}`
|
|
};
|
|
|
|
} else if (ticketType === 'requisicao') {
|
|
|
|
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
|
|
payload = {
|
|
state: '6',
|
|
resolved_by: resolvedBySysId,
|
|
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
|
|
close_notes: closeNotes,
|
|
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`,
|
|
|
|
};
|
|
} 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(`ERRO: 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(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
|
|
return true;
|
|
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
|
|
let sysId = null;
|
|
try {
|
|
const ticketSn = await TicketSnModel.findById(snTicketId);
|
|
if (!ticketSn) {
|
|
logError(`Ticket SN com ID ${snTicketId} nao 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(`OK: Work note adicionada com sucesso ao ticket SN ${sysId}.`);
|
|
return true;
|
|
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const addCommentToServiceNow = async (snTicketId, comment) => {
|
|
let sysId = null;
|
|
try {
|
|
const ticketSn = await TicketSnModel.findById(snTicketId);
|
|
if (!ticketSn) {
|
|
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar comentario.`, 'addCommentToServiceNow');
|
|
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 = { comments: comment };
|
|
const response = await axios.patch(url, payload, {
|
|
auth: apiConfig.servicenowAuthentication.auth,
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
|
|
});
|
|
|
|
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
|
|
if (response.status !== 200) {
|
|
logError(`Falha ao adicionar comentario no SN. Status: ${response.status}`, { snTicketId, sysId });
|
|
return null;
|
|
}
|
|
|
|
logInfo(`OK: Comentario adicionado com sucesso ao ticket SN ${sysId}.`);
|
|
return true;
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao adicionar comentario ao ticket SN (sysId: ${sysId})`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const updateExternalTicketInServiceNow = async (snTicketId, glpiTicketId) => {
|
|
let sysId = null;
|
|
try {
|
|
const ticketSn = await TicketSnModel.findById(snTicketId);
|
|
if (!ticketSn) {
|
|
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizar Ticket Externo.`, 'updateExternalTicketInServiceNow');
|
|
return false;
|
|
}
|
|
|
|
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 = { [externalTicketField]: String(glpiTicketId) };
|
|
const response = await axios.patch(url, payload, {
|
|
auth: apiConfig.servicenowAuthentication.auth,
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
|
|
});
|
|
|
|
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
|
|
if (response.status !== 200) {
|
|
logError(`Falha ao atualizar Ticket Externo no SN. Status: ${response.status}`, { snTicketId, glpiTicketId, externalTicketField });
|
|
return false;
|
|
}
|
|
|
|
// Validacao de leitura: alguns campos customizados podem retornar 200 e nao persistir por ACL/schema.
|
|
const verifyResp = await axios.get(`${url}?sysparm_fields=${externalTicketField}&sysparm_display_value=true`, {
|
|
auth: apiConfig.servicenowAuthentication.auth,
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
const result = verifyResp?.data?.result || {};
|
|
const rawField = result[externalTicketField];
|
|
const persistedValue = typeof rawField === 'object'
|
|
? (rawField.value || rawField.display_value || '')
|
|
: (rawField || '');
|
|
|
|
if (String(persistedValue) !== String(glpiTicketId)) {
|
|
logError(`Ticket Externo nao persistiu no SN (campo ${externalTicketField}).`, {
|
|
snTicketId,
|
|
sysId,
|
|
expected: String(glpiTicketId),
|
|
got: String(persistedValue)
|
|
});
|
|
return false;
|
|
}
|
|
|
|
logInfo(`OK: Ticket Externo confirmado no SN (${externalTicketField}=${glpiTicketId}).`, { snTicketId, sysId });
|
|
return true;
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao atualizar Ticket Externo no SN (sysId: ${sysId})`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const queueExternalTicketLinkRetry = async (ticketSyncId, snTicketId, glpiTicketId, shouldMarkDone = false) => {
|
|
const sourceId = `external_link:${snTicketId}:${glpiTicketId}`;
|
|
const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField });
|
|
await TicketUpdateModel.insert({
|
|
ticket_sync_id: ticketSyncId,
|
|
update_type: 'external_link',
|
|
source_system: 'sync',
|
|
content: payload,
|
|
author: 'integration',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
source_id: sourceId,
|
|
destiny_id: shouldMarkDone ? 'done' : null
|
|
});
|
|
};
|
|
|
|
const processPendingExternalTicketLinks = async () => {
|
|
try {
|
|
const pendings = await TicketUpdateModel.getPendingBySourcePrefix('external_link:');
|
|
if (!pendings.length) return;
|
|
|
|
for (const pending of pendings) {
|
|
try {
|
|
const payload = JSON.parse(pending.content || '{}');
|
|
const snTicketId = payload.sn_ticket_id;
|
|
const glpiTicketId = payload.glpi_ticket_id;
|
|
if (!snTicketId || !glpiTicketId) {
|
|
logError(`Payload invalido para retry de Ticket Externo. update_id=${pending.id}`);
|
|
continue;
|
|
}
|
|
|
|
const ok = await updateExternalTicketInServiceNow(snTicketId, glpiTicketId);
|
|
if (ok) {
|
|
await TicketUpdateModel.updateDestinyId(pending.source_id, 'done');
|
|
}
|
|
} catch (error) {
|
|
logError(error, `Falha ao processar retry de Ticket Externo. update_id=${pending.id}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logError(error, 'ERRO: Falha geral no retry de Ticket Externo');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico.
|
|
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
|
|
* @param {string} variableName - O nome interno (name) da variavel de catalogo.
|
|
* @returns {Promise<string|null>} O valor da variavel ou null se nao encontrada/erro.
|
|
*/
|
|
const fetchScItemOptionValue = async (ritmSysId, variableName) => {
|
|
try {
|
|
|
|
const baseUrl = apiConfig.snScItemOptionConfig.baseUrl.replace(/\/$/, "");
|
|
const url = `${baseUrl}?sysparm_query=request_item=${ritmSysId}^sc_item_option.item_option_new.question_textINPlease justify the need,Telephone,Select what you want&sysparm_fields=sc_item_option.item_option_new.question_text,sc_item_option.item_option_new.name,sc_item_option.value`;
|
|
|
|
const response = await axios.get(url, {
|
|
auth: apiConfig.servicenowAuthentication.auth
|
|
});
|
|
|
|
const result = response.data.result || [];
|
|
if (result.length === 0) return null;
|
|
|
|
const normalize = (value) => {
|
|
if (value === null || value === undefined) return '';
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'object') {
|
|
return value.value || value.display_value || '';
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
return result.map((item) => ({
|
|
question_text: normalize(item['sc_item_option.item_option_new.question_text']),
|
|
variable_name: normalize(item['sc_item_option.item_option_new.name']),
|
|
value: normalize(item['sc_item_option.value'])
|
|
}));
|
|
} catch (error) {
|
|
logError(error, `ERRO: Falha ao buscar valor da variavel '${variableName}' para RITM ${ritmSysId}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
fetchTicketsFromServiceNow,
|
|
fetchRequestsFromServiceNow,
|
|
fetchTicketLiveStatusFromServiceNow,
|
|
getTicketSysId,
|
|
getTicketTypeById,
|
|
createCommentInServiceNow,
|
|
getCommentId,
|
|
existingCommentInServiceNow,
|
|
syncCommentToServiceNow,
|
|
fetchCommentsFromServiceNow,
|
|
fetchAndProcessClosedTicketsFromSN,
|
|
closeTicketInServiceNow,
|
|
updateStatusInServiceNow,
|
|
addWorkNoteToServiceNow,
|
|
addCommentToServiceNow,
|
|
fetchScItemOptionValue,
|
|
updateExternalTicketInServiceNow,
|
|
queueExternalTicketLinkRetry,
|
|
processPendingExternalTicketLinks
|
|
};
|
|
|
|
/**
|
|
* @module servicenowService
|
|
* @description Este servico encapsula todas as interacoes com a API REST do ServiceNow.
|
|
* Ele e responsavel por buscar, criar e atualizar dados na plataforma ServiceNow.
|
|
*
|
|
* Principais Funcionalidades:
|
|
* - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisicoes, respectivamente, utilizando uma marca d'agua para obter apenas os registros novos ou atualizados.
|
|
* - **Busca de Comentarios (`fetchCommentsFromServiceNow`)**: Busca todos os comentarios (journal entries) de um ticket, com logica de paginacao para lidar com grandes volumes de dados. Ele tambem filtra os comentarios gerados pelo proprio sistema de integracao para evitar loops.
|
|
* - **Criacao de Comentarios**: `createCommentInServiceNow` e a funcao wrapper `syncCommentToServiceNow` adicionam comentarios (work notes/comments) a um ticket existente no ServiceNow.
|
|
* - **Atualizacao de Status**: `updateStatusInServiceNow` modifica o estado de um ticket (ex: para 'Em Atendimento').
|
|
* - **Fechamento de Tickets (`closeTicketInServiceNow`)**: Altera o estado de um ticket para "Resolvido", preenchendo os campos obrigatorios como notas de fechamento e data de resolucao.
|
|
* - **Adicao de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, util para registrar informacoes ou avisos.
|
|
* - **Busca de Variaveis de Catalogo (`fetchScItemOptionValue`)**: Funcao especializada para buscar valores de variaveis em itens de requisicao (RITMs), usada para enriquecer a descricao de tickets.
|
|
*
|
|
* Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao.
|
|
*/
|