FEAT: Coletado mais dados de incidentes e requisições.

*   **Enriquecimento de Dados e Criação de Tickets:**
    *   Requisições do ServiceNow com descrição vazia agora são enriquecidas buscando dados de variáveis de catálogo (`sc_item_option`), como justificativas e telefones, melhorando a qualidade dos tickets criados no GLPI.
    *   O `ticketGlpiModel` agora formata descrições em tabelas HTML para melhor legibilidade no GLPI.

*   **Refatoração e Novas Funções:**
    *   O `servicenowService` foi expandido com funções para fechar, atualizar status e adicionar notas de trabalho (`work_notes`) diretamente nos tickets do ServiceNow.

*   **Coleta de dados de telefone e ajustes para coletar o Assigment correro
    *   Water Mark Ajustado para que processo o chamado apenas uma vez.
This commit is contained in:
Rafael Alves Lopes 2025-10-28 14:06:00 -03:00
parent e436fe17af
commit c0b47bb7f1
10 changed files with 184 additions and 76 deletions

5
.env
View File

@ -3,11 +3,12 @@
# This is an example of the environment variables needed for the application. # This is an example of the environment variables needed for the application.
# ServiceNow API Configuration # ServiceNow API Configuration
SERVICENOW_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20&sysparm_query=ORDERBYDESCsys_created_on&sysparm_fields=number%2state%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cvariables.caller_id%2Cvariables.location%2Cvariables.telephone_favorecido_rh%2Cvariables.numero_ramal%2Copened_by.email%2Csys_id%5Eassignment_group%3D46d44f91c611227500713c12f0b7c78d" SERVICENOW_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20&sysparm_query=ORDERBYDESCsys_created_on&sysparm_fields=number%2state%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cvariables.caller_id%2Cvariables.location%2Cvariables.telephone_favorecido_rh%2Cvariables.numero_ramal%2Copened_by.email%2Csys_id%5Eassignment_group%3D1132c9e01bfad410e77eb9121b4bcbab"
SERVICENOW_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_fields=number%2Cstate%2Crequest%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cassignment_group%2Csys_id&sysparm_display_value=all&sysparm_query=assignment_group%3D1132c9e01bfad410e77eb9121b4bcbab" SERVICENOW_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_fields=number%2Cstate%2Crequest%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cassignment_group%2Csys_id&sysparm_display_value=all&sysparm_query=assignment_group%3D1132c9e01bfad410e77eb9121b4bcbab"
SERVICENOW_TABLE_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident/" SERVICENOW_TABLE_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident"
SERVICENOW_TABLE_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item" SERVICENOW_TABLE_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item"
SERVICENOW_TABLE_JOURNAL_URL="https://caoadev.service-now.com/api/now/table/sys_journal_field" SERVICENOW_TABLE_JOURNAL_URL="https://caoadev.service-now.com/api/now/table/sys_journal_field"
SERVICENOW_SC_ITEM_OPTION_URL="https://caoadev.service-now.com/api/now/table/sc_item_option_mtom"
SERVICENOW_USERNAME=SOTHIS.CAOA SERVICENOW_USERNAME=SOTHIS.CAOA
SERVICENOW_PASSWORD=#Sothis20 SERVICENOW_PASSWORD=#Sothis20

View File

@ -41,6 +41,10 @@ const snTableJournalConfig = {
baseUrl: process.env.SERVICENOW_TABLE_JOURNAL_URL, baseUrl: process.env.SERVICENOW_TABLE_JOURNAL_URL,
} }
const snScItemOptionConfig = {
baseUrl: process.env.SERVICENOW_SC_ITEM_OPTION_URL,
}
module.exports = { module.exports = {
// configuração do ServiceNow e outras já existentes podem permanecer // configuração do ServiceNow e outras já existentes podem permanecer
glpiDbConfig, glpiDbConfig,
@ -49,5 +53,6 @@ module.exports = {
snTableIncidentConfig, snTableIncidentConfig,
snTableJournalConfig, snTableJournalConfig,
servicenowAuthentication, servicenowAuthentication,
snTableRequestConfig snTableRequestConfig,
snScItemOptionConfig
}; };

View File

@ -14,7 +14,7 @@ logInfo('Aplicação iniciada', {
async function main() { async function main() {
try { try {
await processErrorController(); // Reseta os erros await processErrorController(); // Reseta os erros
+await processTicketsController(); // Coleta tickets do SN e cria no GLPI await processTicketsController(); // Coleta tickets do SN e cria no GLPI
await processCommentsController(); // Sincroniza chamados Bidirecionalmente await processCommentsController(); // Sincroniza chamados Bidirecionalmente
await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente
logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉'); logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉');

View File

@ -35,13 +35,13 @@ const processStatusAndClosureController = async () => {
// ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI ================= // ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI =================
// Primeiro, verificamos se o GLPI mudou de estado por conta própria. // Primeiro, verificamos se o GLPI mudou de estado por conta própria.
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); 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') // Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento')
const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus).toLowerCase().replace(/ /g, '_').replace('-', '_'); const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus);
if (glpiLiveStatus !== null && glpiLiveStatusAsString !== glpiDbStatus) { // Compara o status real do GLPI com o status que temos do ServiceNow
logInfo(`Mudança de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. De '${glpiDbStatus}' para '${glpiLiveStatusAsString}'.`); if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) {
logInfo(`Mudança de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. GLPI está como '${glpiLiveStatusAsString}', SN está como '${snCurrentStatus}'.`);
// Se uma mudança foi detectada no GLPI, forçamos o "bastão" para ele. // Se uma mudança foi detectada no GLPI, forçamos o "bastão" para ele.
ticket.source_last = 'GLPI'; ticket.source_last = 'GLPI';
} }
@ -95,6 +95,17 @@ const processStatusAndClosureController = async () => {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
} else { } else {
// REGRA DE NEGÓCIO: Não pode fechar um ticket que está "Em Espera" ou "Aguardando Atendimento" no SN.
if (snCurrentStatus === 'Em Espera' || snCurrentStatus === 'Aguardando Atendimento') {
logWarning(`Tentativa de fechar o ticket SN ${ticketSnDetails.ticket_number} que está '${snCurrentStatus}'. Ação bloqueada.`);
// Não Adiciona uma nota no SN e reabre o ticket no GLPI para refletir o status do SN.
//await addWorkNoteToServiceNow(ticket.sn_ticket_id, `O ticket correspondente no GLPI (${ticket.glpi_ticket_id}) foi solucionado, mas o chamado no ServiceNow está '${snCurrentStatus}'. É necessário retomar o atendimento para prosseguir com o encerramento.`);
//Atualiza o status do GLPI
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, snCurrentStatus); // Reabre o GLPI imediatamente
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); // Passa o bastão para o SN
continue; // Pula para o próximo ticket
}
// FLUXO NORMAL DE RESOLUÇÃO // FLUXO NORMAL DE RESOLUÇÃO
const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integração GLPI.'; const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integração GLPI.';
const resolvedAt = solution?.date_mod || new Date(); const resolvedAt = solution?.date_mod || new Date();

View File

@ -1,8 +1,9 @@
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const SyncControlModel = require('../models/syncControlModel'); const SyncControlModel = require('../models/syncControlModel');
const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi } = require('../services/servicenowService'); const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService');
const { logInfo, logError, logSync } = require('../utils/logger'); const { logInfo, logError, logSync } = require('../utils/logger');
const e = require('express');
/** /**
* Processa e salva um lote de tickets (incidentes ou requisições). * Processa e salva um lote de tickets (incidentes ou requisições).
@ -10,21 +11,62 @@ const { logInfo, logError, logSync } = require('../utils/logger');
* @param {('incidente'|'requisicao')} type - O tipo de ticket. * @param {('incidente'|'requisicao')} type - O tipo de ticket.
*/ */
const processAndSaveTickets = async (tickets, type) => { const processAndSaveTickets = async (tickets, type) => {
// Helper para converter a data do formato 'DD/MM/YYYY HH:mm:ss' para um objeto Date.
const parseServiceNowDate = (dateString) => {
if (!dateString) return null;
// Verifica se já está em um formato reconhecível (como ISO 8601)
if (!dateString.includes('/')) {
return new Date(dateString);
}
const parts = dateString.match(/(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})/);
if (!parts) return null; // Retorna nulo se o formato for inesperado
// Formato: new Date(ano, mês-1, dia, hora, minuto, segundo)
return new Date(parts[3], parts[2] - 1, parts[1], parts[4], parts[5], parts[6]);
};
if (!tickets || tickets.length === 0) { if (!tickets || tickets.length === 0) {
logInfo(`Nenhum ticket do tipo '${type}' para processar.`); logInfo(`Nenhum ticket do tipo '${type}' para processar.`);
return ''; // Retorna string vazia se não houver tickets return ''; // Retorna string vazia se não houver tickets
} }
logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`); logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
let latestUpdateTimestamp = ''; let latestUpdateTimestamp = new Date(0); // Inicia com a data mínima (epoch)
for (const ticket of tickets) { for (const ticket of tickets) {
try { try {
// --- LÓGICA DE ENRIQUECIMENTO DA DESCRIÇÃO ---
// Se for uma requisição e a descrição estiver vazia, busca na sc_item_option.
if (type === 'requisicao' && (!ticket.description || ticket.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.number}. Buscando em sc_item_option...`);
// O nome 'justificativa' é um exemplo, use o nome real da sua variável.
const extraData = await fetchScItemOptionValue(ticket.sys_id, 'justificativa');
const vars = {};
(extraData || []).forEach(item => {
const question = item['sc_item_option.item_option_new.question_text'];
const value = item['sc_item_option.value'];
if (question === 'Select what you want') vars.whatYouWant = value;
if (question === 'Please justify the need') vars.justification = value;
if (question === 'Telephone') vars.telephone = value;
});
if (vars.justification) {
ticket.description = vars.justification; // Atualiza o objeto do ticket em memória
logInfo(`Descrição da requisição ${ticket.number} atualizada com valor de sc_item_option.`);
}
if (vars.telephone){
ticket.telefone = vars.telephone;
logInfo(`Telefone da requisição ${ticket.number} atualizado com valor de sc_item_option.`);
}
}
// --- FIM DA LÓGICA DE ENRIQUECIMENTO ---
// 1. Salva o ticket na tabela 'tickets_sn' // 1. Salva o ticket na tabela 'tickets_sn'
const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type); const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type);
// 2. Prepara os dados para a tabela de sincronização 'ticket_sync' // 2. Prepara os dados para a tabela de sincronização 'ticket_sync'
// Para requisições, o status está diretamente em ticket.state // Para requisições, o status está diretamente em ticket.state
const ticketStatus = type === 'incidente' ? ticket.state?.display_value : ticket.state; const ticketStatus = ticket.state;
const dataSync = { const dataSync = {
sn_ticket_id: idTableTicketSn, sn_ticket_id: idTableTicketSn,
@ -52,17 +94,16 @@ const processAndSaveTickets = async (tickets, type) => {
// Se o ticket foi atualizado (não inserido), significa que uma mudança veio do SN. // 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. // Então, definimos o SN como a última fonte da verdade.
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number?.value} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
} else { } else {
logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`); 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. // 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 // Guarda o timestamp mais recente do lote para usar como a próxima marca d'água
let currentUpdate = type === 'incidente' ? ticket.sys_updated_on?.value : ticket.sys_updated_on; const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
currentUpdate = defaultDate(currentUpdate); if (currentUpdateDate && currentUpdateDate.getTime() > latestUpdateTimestamp.getTime()) {
if (currentUpdate && currentUpdate > latestUpdateTimestamp) { latestUpdateTimestamp = currentUpdateDate;
latestUpdateTimestamp = currentUpdate;
} }
} catch (error) { } catch (error) {
@ -70,7 +111,11 @@ const processAndSaveTickets = async (tickets, type) => {
} }
} }
logSync('ServiceNow', tickets.length, `${type}s`); logSync('ServiceNow', tickets.length, `${type}s`);
return latestUpdateTimestamp;
// Se encontramos uma data válida, a formata para 'YYYY-MM-DD HH:MM:SS'. Senão, retorna string vazia.
return latestUpdateTimestamp.getTime() > 0
? latestUpdateTimestamp.toISOString().slice(0, 19).replace('T', ' ')
: '';
}; };
/** /**
@ -98,7 +143,8 @@ const processSyncController = async () => {
// 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução // 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução
if (newWatermark) { if (newWatermark) {
// Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais // Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais
const nextWatermark = new Date(new Date(newWatermark).getTime() + 1000); const sixHoursInMillis = 6 * 60 * 60 * 1000;
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
await SyncControlModel.setWatermark('servicenow', nextWatermark); await SyncControlModel.setWatermark('servicenow', nextWatermark);
} else { } else {
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada."); logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada.");
@ -109,29 +155,6 @@ const processSyncController = async () => {
} }
}; };
//Função auxiliar para padronizar data
function defaultDate(dataStr) {
if (!dataStr) return null;
const [data, hora] = dataStr.split(' ');
let ano, mes, dia;
if (data.includes('/')) {
// Ex: 31/12/2024 → 2024-12-31
[dia, mes, ano] = data.split('/');
} else {
// Ex: 2025-10-24 → já está no formato
[ano, mes, dia] = data.split('-');
}
// Garante formato consistente (YYYY-MM-DD HH:mm:ss)
return `${ano.padStart(4, '0')}-${mes.padStart(2, '0')}-${dia.padStart(2, '0')} ${hora}`;
}
module.exports = { module.exports = {
processSyncController processSyncController
}; };

View File

@ -11,7 +11,9 @@ const glpiPool = mysql.createPool({
password: process.env.GLPI_DB_PASSWORD, password: process.env.GLPI_DB_PASSWORD,
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0 queueLimit: 0,
// Fecha conexões ociosas após 60 segundos, evitando o erro ECONNRESET.
idleTimeout: 60000
}); });
// Testar conexão // Testar conexão

View File

@ -295,8 +295,8 @@ class TicketGlpiModel {
static async insertComment(comment, items_id) { static async insertComment(comment, items_id) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
const query = 'INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) VALUES(?, ?, ?, ?, ?, ?, ?, ?)'; const query = 'INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) VALUES(?, ?, NOW(), ?, ?, NOW(), NOW(), ?)';
const values = ['Ticket', items_id, comment.created_at, 971, comment.content , comment.created_at, comment.created_at, 1]; const values = ['Ticket', items_id, 971, comment.content , 1];
const [result] = await connection.execute(query, values); const [result] = await connection.execute(query, values);
return [{ id: result.insertId }]; // Retorna o ID inserido return [{ id: result.insertId }]; // Retorna o ID inserido

View File

@ -33,25 +33,28 @@ class TicketSnModel {
// Mapeamento inteligente de campos baseado no tipo de ticket // Mapeamento inteligente de campos baseado no tipo de ticket
let callerName, callerEmail, ramal, telefone, sysId, shortDescription, state, description, locationId, locationName, openedAt; let callerName, callerEmail, ramal, telefone, sysId, shortDescription, state, description, locationId, locationName, openedAt;
if (typeTicket === 'incidente') { if (typeTicket === 'incidente') {
callerName = ticketData.caller_id?.display_value; callerName = ticketData.caller_id?.display_value;
// O campo de email para incidentes vem como um objeto, precisamos extrair o valor. // O campo de email para incidentes vem como um objeto, precisamos extrair o valor.
const emailObject = ticketData['caller_id.email'] || {}; const emailObject = ticketData['caller_id.email'] || {};
callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null; callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null;
ramal = null; // Incidentes não têm essa variável por padrão ramal = null; // Incidentes não têm essa variável por padrão
telefone = null; // Incidentes não têm essa variável por padrão telefone = ticketData['caller_id.phone'] || ticketData['caller_id.mobile_phone'] || null; // Incidentes não têm essa variável por padrão
sysId = ticketData.sys_id?.value || null; sysId = ticketData.sys_id || null;
shortDescription = ticketData.short_description?.value || null; shortDescription = ticketData.short_description || null;
state = ticketData.state?.display_value || null; state = ticketData.state || null;
description = ticketData.description?.value || null; description = ticketData.description || null;
locationId = ticketData.location?.value || null; const locationLink = ticketData.location?.link;
locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null;
locationName = ticketData.location?.display_value || null; locationName = ticketData.location?.display_value || null;
openedAt = ticketData.opened_at?.value || null; openedAt = ticketData.opened_at || null;
} else { // 'requisicao' } else { // 'requisicao'
callerName = ticketData.opened_by?.display_value || null; callerName = ticketData.opened_by?.display_value || null;
callerEmail = ticketData['opened_by.email'] || null; callerEmail = ticketData['opened_by.email'] || null;
ramal = ticketData['variables.numero_ramal'] || null; ramal = ticketData['variables.numero_ramal'] || null;
telefone = ticketData['variables.telephone_favorecido_rh'] || null; telefone = ticketData['variables.telephone_favorecido_rh'] || ticketData.telefone || null;
sysId = ticketData.sys_id || null; sysId = ticketData.sys_id || null;
shortDescription = ticketData.short_description || null; shortDescription = ticketData.short_description || null;
state = ticketData.state || null; state = ticketData.state || null;

View File

@ -3,7 +3,7 @@ const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
const { addWorkNoteToServiceNow } = require('./servicenowService'); const { addWorkNoteToServiceNow } = require('./servicenowService');
@ -53,10 +53,20 @@ const processSingleTicket = async (ticket) => {
if (existingTicket) { if (existingTicket) {
logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${existingTicket}`); logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${existingTicket}`);
// --- NOVA VALIDAÇÃO: Verificar status do ticket existente no GLPI ---
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(existingTicket);
if (glpiLiveStatus === 6) { // Status 6 = Fechado no GLPI
logInfo(`Ticket GLPI ${existingTicket} já está Fechado. Marcando ticket SN ${ticket.ticket_number} como 'closed'/'ignored' e não monitorado.`);
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'ignored', 'closed', existingTicket);
return; // Não processa mais este ticket
}
// --- FIM DA NOVA VALIDAÇÃO ---
// Atualiza nosso registro de sincronização com o ID do GLPI encontrado. // Atualiza nosso registro de sincronização com o ID do GLPI encontrado.
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`); logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
return; return; // Adiciona este return para evitar a criação de um novo ticket
} }
// 3. Converter dados para formato GLPI // 3. Converter dados para formato GLPI
@ -77,6 +87,21 @@ const processSingleTicket = async (ticket) => {
updated_at: ticket.updated_at updated_at: ticket.updated_at
}; };
// Lógica para buscar descrição de sc_item_option se a descrição atual estiver vazia
// e for um ticket de requisição (apenas para criação de novos tickets)
if (glpiData.type === 'requisicao' && (!glpiData.description || glpiData.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.ticket_number}. Tentando buscar de sc_item_option.`);
// Você precisará saber o nome interno da variável de catálogo que contém a descrição.
// Por exemplo, se for 'justificativa', use 'justificativa'.
const scItemOptionDescription = await require('./servicenowService').fetchScItemOptionValue(ticket.sys_id, 'justificativa');
if (scItemOptionDescription) {
glpiData.description = scItemOptionDescription;
logInfo(`Descrição atualizada para requisição ${ticket.ticket_number} a partir de sc_item_option.`);
} else {
logWarning(`Não foi possível obter descrição de sc_item_option para requisição ${ticket.ticket_number}.`);
}
}
// 3. Inserir no GLPI // 3. Inserir no GLPI
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData); const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp

View File

@ -9,9 +9,14 @@ const { log } = require('winston');
const fetchTicketsFromServiceNow = async (watermark) => { const fetchTicketsFromServiceNow = async (watermark) => {
try { try {
const fields = 'number,sys_id,short_description,state,description,caller_id,caller_id.email,location,opened_at,sys_updated_on'; // A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date.
const query = `active=true^sys_updated_on>=${watermark}`; const watermarkDate = new Date(watermark);
const url = `${apiConfig.snIncidentConfig.baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`; // Ajusta para o fuso horário correto se necessário e formata para a query do SN.
const watermarkBRT = new Date(watermarkDate.getTime()); // Se precisar de ajuste de fuso, faça aqui.
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 query = `assignment_group=1132c9e01bfad410e77eb9121b4bcbab^sys_updated_on>=${formattedWatermark}`;
const url = `${apiConfig.snTableIncidentConfig.baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const ticketsResponse = await axios.get(url, { const ticketsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth auth: apiConfig.servicenowAuthentication.auth
}); });
@ -30,10 +35,12 @@ const fetchRequestsFromServiceNow = async (watermark) => {
// Construímos a query completa aqui, combinando o grupo de atribuição com a watermark. // Construímos a query completa aqui, combinando o grupo de atribuição com a watermark.
const assignmentGroupSysId = '1132c9e01bfad410e77eb9121b4bcbab'; // Sys ID do grupo 'Sothis' const assignmentGroupSysId = '1132c9e01bfad410e77eb9121b4bcbab'; // Sys ID do grupo 'Sothis'
const query = `assignment_group=${assignmentGroupSysId}^sys_updated_on>=${watermark}`; const query = `assignment_group=${assignmentGroupSysId}^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.numero_ramal,variables.telephone_favorecido_rh'; 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';
// Garante que a URL base não tenha uma barra no final para evitar URL malformada. // Garante que a URL base não tenha uma barra no final para evitar URL malformada.
const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, ""); const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, "");
const url = `${baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`; //const url = `${baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`;
const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const requestsResponse = await axios.get(url, { const requestsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth auth: apiConfig.servicenowAuthentication.auth
@ -108,12 +115,12 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
let url = ''; let url = '';
let payload = {}; let payload = {};
if (ticketType == 'incidente') { if (ticketType == 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
payload = { payload = {
comments: comment comments: comment
}; };
} else if (ticketType == 'requisicao') { } else if (ticketType == 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = { payload = {
comments: comment comments: comment
}; };
@ -364,10 +371,10 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
let payload = {}; let payload = {};
if (ticketType === 'incidente') { if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state
} else if (ticketType === 'requisicao') { } else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = { state: stateCode }; // Requisições podem ter um fluxo diferente payload = { state: stateCode }; // Requisições podem ter um fluxo diferente
} else { } else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
@ -417,7 +424,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu
if (ticketType === 'incidente') { if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
payload = { payload = {
state: '6', // 6 = Resolved state: '6', // 6 = Resolved
incident_state: '6', // 6 = Resolved incident_state: '6', // 6 = Resolved
@ -427,13 +434,19 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
close_notes: closeNotes, close_notes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}` work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`
}; };
} else if (ticketType === 'requisicao') { } else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`;
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = { payload = {
state: '6', // Assumindo que '6' também é um estado de resolução para requisições state: '6',
// 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.' work_notes: closeNotes || 'Requisição resolvida automaticamente via integração GLPI.'
}; };
console.log(url);
console.log(payload);
} else { } else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
@ -478,9 +491,9 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
let url = ''; let url = '';
if (ticketType === 'incidente') { if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') { } else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}${sysId}`; url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else { } else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
} }
@ -502,6 +515,30 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
} }
}; };
/**
* Busca o valor de uma variável de catálogo (sc_item_option) para um Request Item (RITM) específico.
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
* @param {string} variableName - O nome interno (name) da variável de catálogo.
* @returns {Promise<string|null>} O valor da variável ou null se não 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.value`;
const response = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
const result = response.data.result;
return (result && result.length > 0) ? result : null;
} catch (error) {
logError(error, `🚨 Falha ao buscar valor da variável '${variableName}' para RITM ${ritmSysId}`);
return null;
}
};
module.exports = { module.exports = {
fetchTicketsFromServiceNow, fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow, fetchRequestsFromServiceNow,
@ -512,7 +549,8 @@ module.exports = {
syncCommentToServiceNow, syncCommentToServiceNow,
fetchCommentsFromServiceNow, fetchCommentsFromServiceNow,
fetchAndProcessClosedTicketsFromSN, fetchAndProcessClosedTicketsFromSN,
closeTicketInServiceNow, closeTicketInServiceNow, // Adicionei a vírgula aqui
updateStatusInServiceNow, updateStatusInServiceNow, // Adicionei a vírgula aqui
addWorkNoteToServiceNow addWorkNoteToServiceNow, // Adicionei a vírgula aqui
fetchScItemOptionValue // Nova função
}; };