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:
parent
e436fe17af
commit
c0b47bb7f1
5
.env
5
.env
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
@ -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! 🎉');
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,22 +11,63 @@ 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,
|
||||||
last_sn_sync: new Date(),
|
last_sn_sync: new Date(),
|
||||||
@ -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', ' ')
|
||||||
|
: '';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,11 +139,12 @@ const processSyncController = async () => {
|
|||||||
|
|
||||||
// 2. Determina a nova marca d'água (o timestamp mais recente entre todos os tickets processados)
|
// 2. Determina a nova marca d'água (o timestamp mais recente entre todos os tickets processados)
|
||||||
const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate;
|
const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate;
|
||||||
|
|
||||||
// 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
|
||||||
};
|
};
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 });
|
||||||
@ -406,7 +413,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
|
|||||||
logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow');
|
logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
sysId = ticketSn.sys_id;
|
sysId = ticketSn.sys_id;
|
||||||
const ticketType = ticketSn.tipo;
|
const ticketType = ticketSn.tipo;
|
||||||
let url = ''; // Declarar url fora do if
|
let url = ''; // Declarar url fora do if
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user