FIX: Correções de sincronização SN<->GLPI

- feito ajuste de direção de status para respeitar source_last e evitar reabertura indevida

- feito tratamento de reabertura SN->GLPI após resolução, mantendo bloqueio para fechamento permanente

- feito ajuste de finalização GLPI->SN para propagar resolução/fechamento corretamente

- feito correção de runtime no processStatusController (uso de variável antes da inicialização)

- feito correção para não prender ticket em status transitório de comentários

- feito preservação de ordem cronológica e datas reais de comments/work_notes SN no GLPI

- feito normalização de autor (ex.: email/login para nome amigável)

- feito destaque visual para mensagens automáticas e encerramento no GLPI

- feito sanitização de justificativa na regra de fora de escopo

- feito padronização de variáveis de ambiente e remoção de inconsistências de config

- feito separação de SQL destrutivo para arquivo de manutenção dedicado
This commit is contained in:
Rafael Lopes 2026-02-24 17:40:39 -03:00
parent 0f412a1588
commit e129571812
13 changed files with 253 additions and 127 deletions

View File

@ -1,59 +0,0 @@
# =========================================
# DEVELOPMENT CONFIGURATION
# =========================================
NODE_ENV=development
PORT=3000
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *'
# ServiceNow API (Dev)
SERVICENOW_INSTANCE=https://caoadev.service-now.com
SERVICENOW_USERNAME=SOTHIS.CAOA
SERVICENOW_PASSWORD=#Sothis20
# GRUPO SOTHIS PARA BUSCA
SERVICENOW_ASSIGNMENT_GROUP="1132c9e01bfad410e77eb9121b4bcbab"
# Endpoints
SERVICENOW_INCIDENT_URL=https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20
SERVICENOW_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_display_value=all&sysparm_limit=20
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_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
# Usuario padrao pra criacao de chamados (ServiceNow)
SERVICENOW_DEFAULT_USER=SOTHIS.CAOA
SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98
SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=false
# GLPI (banco real)
GLPI_DB_TYPE=mysql
GLPI_DB_HOST=177.73.177.44
GLPI_DB_PORT=3306
GLPI_DB_USER=desenvolvimento
GLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
GLPI_DB_NAME=glpi_data
GLPI_DB_CHARSET=utf8mb4
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
GLPI_DEFAULT_USER_ID=11111
GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa
GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao
# Banco intermediario (PostgreSQL)
SNGLPI_DB_HOST=10.0.120.75
SNGLPI_DB_PORT=5432
SNGLPI_DB_NAME=snglpi-development
SNGLPI_DB_USER=desenvolvimento
SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
# CSV de mapeamento
LOCATION_MAPPING_CSV_PATH=\\10.0.121.40\Tecnica\Controle CAOA\ENTIDADE SERVICE NOW\LOCATION_MAPPING.csv
# Logging
LOG_LEVEL=debug
LOG_RETENTION_DAYS=10
LOG_DIR=logs

View File

@ -44,6 +44,9 @@ GLPI_DB_CHARSET=utf8mb4
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
GLPI_DEFAULT_USER_ID=1118
GLPI_DEFAULT_GROUP_ID=30
GLPI_DEFAULT_ENTITY_ID=127
GLPI_OOS_SOLUTION_TYPE_ID=27
GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa
GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao

3
.gitignore vendored
View File

@ -6,4 +6,5 @@ config/*.json
servicenow_requests.json
servicenow_tickets.json
location_mapping.log
.env.production
.env*
!.env.example

View File

@ -52,7 +52,7 @@ Executado em ordem pelo `main()`:
- Nao fecha ticket no GLPI automaticamente.
- Insere nota formatada no GLPI.
- Marca sync como `closed/closed` para remover do monitoramento.
- Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo):
- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (fora do escopo):
- Nao resolve no SN.
- Adiciona `work_note` no SN.
- Marca sincronizacao como encerrada/ignorada.
@ -71,7 +71,7 @@ Executado em ordem pelo `main()`:
2. Status GLPI -> ServiceNow
- Solucao no GLPI dispara tentativa de resolucao no SN.
- Se SN estiver em status bloqueante (`Em Espera` ou `Aguardando Atendimento`), a integracao seta `Em Atendimento` antes da resolucao.
- Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`.
- Regra fora do escopo (`solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`.
3. Status ServiceNow -> GLPI
- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
@ -129,6 +129,7 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua
- `SERVICENOW_TABLE_JOURNAL_URL`
- `SERVICENOW_SC_ITEM_OPTION_URL`
- `SERVICENOW_DEFAULT_USER`
- `SERVICENOW_RESOLVED_BY_SYSID`
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
### GLPI (MySQL/MariaDB)
@ -140,6 +141,9 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua
- `GLPI_DB_NAME`
- `GLPI_DB_CHARSET`
- `GLPI_DEFAULT_USER_ID`
- `GLPI_DEFAULT_GROUP_ID` (padrao `30`)
- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`)
- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`)
### Banco intermediario (PostgreSQL)
@ -194,6 +198,16 @@ Arquivo: `src/scripts/python/update_location_mapping.py`
- Recria os dados da tabela `location_mapping` com base no CSV e IDs validos em ambos os bancos.
- Roda em loop continuo (ideal via PM2 como processo separado).
## Como os dados chegam do ServiceNow
- Incidentes: consulta na tabela definida por `SERVICENOW_TABLE_INCIDENT_URL`, filtrando por `assignment_group` e `sys_updated_on >= watermark`.
- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro.
- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`).
- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone.
- Campos principais persistidos localmente:
- Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`.
- Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`.
## Estrutura do banco intermediario
Definicao base em `src/scripts/database/scriptBD.sql`:
@ -204,10 +218,14 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
- `location_mapping`
- `sync_control`
Arquivo auxiliar de manutencao/reset (com comandos destrutivos):
- `src/scripts/database/maintenance_reset.sql`
## Limitacoes conhecidas (estado atual)
- Nao ha suite de testes automatizados (`npm test` e placeholder).
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto).
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão)
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
## Proximas melhorias recomendadas
@ -217,6 +235,7 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
- Revisar estrategia de watermark/timezone e idempotencia.
- Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados).
- Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria).
- Alterar uso de banco do GLPI para a API do GLPI.
## Arquivos principais

View File

@ -24,11 +24,6 @@ const snIncidentConfig = {
baseUrl: process.env.SERVICENOW_INCIDENT_URL,
}
const snRequestConfig = {
// A URL base deve ser limpa, sem parâmetros. Os filtros serão adicionados no serviço.
baseUrl: process.env.SERVICENOW_REQUEST_URL,
}
const snTableIncidentConfig = {
baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL,
}
@ -53,11 +48,10 @@ module.exports = {
// configuração do ServiceNow e outras já existentes podem permanecer
glpiDbConfig,
snIncidentConfig,
snRequestConfig,
snTableIncidentConfig,
snTableJournalConfig,
servicenowAuthentication,
snTableRequestConfig,
snScItemOptionConfig,
assignmentGroup
};
};

View File

@ -3,9 +3,11 @@ const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { stripHTML } = require('../utils/commentSanitizer');
const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService');
const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService');
const { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const processStatusAndClosureController = async () => {
logInfo('Iniciando processamento de status e fechamento de tickets...');
@ -29,25 +31,50 @@ const processStatusAndClosureController = async () => {
logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`);
continue;
}
const snCurrentStatus = ticketSnDetails.status;
let snCurrentStatus = ticketSnDetails.status;
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSnDetails.sys_id, ticketSnDetails.tipo);
if (liveSnStatus && liveSnStatus !== snCurrentStatus) {
logInfo(`Status ao vivo do SN para ${ticketSnDetails.ticket_number}: '${snCurrentStatus}' -> '${liveSnStatus}'.`);
snCurrentStatus = liveSnStatus;
await TicketSnModel.updateTicket(ticket.sn_ticket_id, { status: liveSnStatus });
}
const snIsFinalState = snCurrentStatus === 'Resolvido' || snCurrentStatus === 'Encerrado' || snCurrentStatus === 'Encerrado - Omitido';
let effectiveSourceLast = ticket.source_last;
if (snIsFinalState) {
effectiveSourceLast = 'SNOW';
}
// ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI =================
// Primeiro, verificamos se o GLPI mudou de estado por conta própria.
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const glpiIsFinalState = glpiLiveStatus !== null && glpiLiveStatus >= 5;
const glpiWasFinalLocally = ticket.glpi_sync_status === 'solved' || ticket.glpi_sync_status === 'closed';
// Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento')
const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus);
// Compara o status real do GLPI com o status que temos do ServiceNow
// Compara o status real do GLPI com o status do SN apenas para observabilidade.
// A direcao da sincronizacao deve respeitar o source_last persistido.
if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) {
logInfo(`Mudanca de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. GLPI esta como '${glpiLiveStatusAsString}', SN esta como '${snCurrentStatus}'.`);
// Hotfix: quando houver divergencia real de status, prioriza GLPI para restaurar fluxo GLPI -> SN.
ticket.source_last = 'GLPI';
logInfo(`Mudanca de status detectada para o ticket ${ticket.glpi_ticket_id}. GLPI='${glpiLiveStatusAsString}', SN='${snCurrentStatus}', source_last='${effectiveSourceLast}'.`);
}
// Regra de precedencia de finalizacao:
// quando GLPI ja esta finalizado e SN ainda nao, prioriza GLPI->SN
// apenas se o bastao ainda estiver com GLPI.
// Se o bastao estiver com SNOW, permitimos reabertura SN->GLPI.
if (
glpiIsFinalState &&
!snIsFinalState &&
(!glpiWasFinalLocally || ticket.source_last === 'GLPI')
) {
effectiveSourceLast = 'GLPI';
}
// ================= LÓGICA SNOW -> GLPI =================
// Se a última atualização veio do ServiceNow, atualiza o GLPI
if (ticket.source_last === 'SNOW') {
if (effectiveSourceLast === 'SNOW') {
logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`);
// **LÓGICA DE REABERTURA REFINADA**
@ -81,7 +108,7 @@ const processStatusAndClosureController = async () => {
}
// ================= LÓGICA GLPI -> SNOW =================
// Se a última atualização veio do GLPI, atualiza o ServiceNow
else if (ticket.source_last === 'GLPI') {
else if (effectiveSourceLast === 'GLPI') {
logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`);
const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos
if (glpiCurrentStatus !== null) {
@ -92,9 +119,9 @@ const processStatusAndClosureController = async () => {
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
// REGRA "FORA DO ESCOPO"
if (solution?.solutiontypes_id === 27) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket não será mais exibido para equipe Sothis, com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);

View File

@ -4,9 +4,23 @@ const LocationMappingModel = require('./locationMappingModel');
// Carrega o ID do usuário padrão do GLPI a partir das variáveis de ambiente
const GLPI_DEFAULT_USER_ID = parseInt(process.env.GLPI_DEFAULT_USER_ID, 10) || 1118;
const GLPI_DEFAULT_GROUP_ID = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30;
const GLPI_DEFAULT_ENTITY_ID = parseInt(process.env.GLPI_DEFAULT_ENTITY_ID, 10) || 127;
const GLPI_REQ_DEFAULT_JUSTIFICATIVA = process.env.GLPI_REQ_DEFAULT_JUSTIFICATIVA || 'Chamado aberto por colaborador CAOA sem Justificativa';
const GLPI_REQ_DEFAULT_DESCRIPTION = process.env.GLPI_REQ_DEFAULT_DESCRIPTION || 'Chamado aberto por colaborador CAOA sem Descricao';
const toMySqlDateTime = (value) => {
if (!value) return new Date();
if (typeof value === 'string') {
const m = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
class TicketGlpiModel {
static mapStatus(snState) {
@ -91,14 +105,14 @@ class TicketGlpiModel {
return {
title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
entityId: entityData?.id || 127
entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID
};
} catch (error) {
logError(error, 'Erro ao formatar título do ticket');
const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE';
return {
title: `${tipo} - ${ticketData.short_description || 'Sem título'}`,
entityId: 127
entityId: GLPI_DEFAULT_ENTITY_ID
};
}
}
@ -188,7 +202,7 @@ class TicketGlpiModel {
try {
await connection.beginTransaction();
let entityId = 127;
let entityId = GLPI_DEFAULT_ENTITY_ID;
if (ticketData.location_id) {
entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId;
} else if (ticketData.location_name) {
@ -247,7 +261,7 @@ class TicketGlpiModel {
`INSERT INTO glpi_groups_tickets
(tickets_id, groups_id, type)
VALUES (?, ?, 2)`,
[ticketId, 30]
[ticketId, GLPI_DEFAULT_GROUP_ID]
);
await connection.commit();
@ -301,8 +315,14 @@ class TicketGlpiModel {
static async insertComment(comment, items_id) {
const connection = await glpiPool.getConnection();
try {
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, GLPI_DEFAULT_USER_ID, comment.content , 1];
const createdAt = toMySqlDateTime(comment.created_at);
const updatedAt = toMySqlDateTime(comment.updated_at || comment.created_at);
const query = `
INSERT INTO glpi_itilfollowups
(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = ['Ticket', items_id, createdAt, GLPI_DEFAULT_USER_ID, comment.content, createdAt, updatedAt, 1];
const [result] = await connection.execute(query, values);
return [{ id: result.insertId }];
@ -316,17 +336,21 @@ class TicketGlpiModel {
static async insertTask(task, items_id) {
const connection = await glpiPool.getConnection();
try {
const createdAt = toMySqlDateTime(task.created_at);
const updatedAt = toMySqlDateTime(task.updated_at || task.created_at);
// Registra work_notes do ServiceNow como tarefa no GLPI.
const query = `
INSERT INTO glpi_tickettasks
(tickets_id, users_id, state, content, date, date_mod, actiontime)
VALUES (?, ?, ?, ?, NOW(), NOW(), ?)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const values = [
items_id,
GLPI_DEFAULT_USER_ID,
2,
task.content,
createdAt,
updatedAt,
0
];
@ -420,11 +444,10 @@ class TicketGlpiModel {
const connection = await glpiPool.getConnection();
try {
await connection.beginTransaction();
status = this.mapStatus(status);
const mappedStatus = this.mapStatus(status);
const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`;
await connection.execute(updateQuery, [status, ticketId]);
await connection.execute(updateQuery, [mappedStatus, ticketId]);
await connection.commit();
} catch (error) {

View File

@ -1,4 +1,3 @@
const { query } = require('winston');
const pool = require('../data/database');
const { logError, logInfo } = require('../utils/logger');
@ -265,4 +264,4 @@ class TicketSyncModel {
*/
module.exports = TicketSyncModel;
module.exports = TicketSyncModel;

View File

@ -0,0 +1,25 @@
-- =============================================
-- MANUTENCAO / RESET CONTROLADO - SN <-> GLPI
-- =============================================
-- Use este arquivo apenas para manutencao operacional.
-- Nao execute em producao sem backup e janela aprovada.
BEGIN;
-- Diagnostico rapido
SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored';
SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido';
SELECT * FROM public.location_mapping;
-- Limpeza de tickets abertos (mantem chamados encerrados)
DELETE FROM tickets_sn
WHERE status != 'Encerrado'
AND status != 'Encerrado - Omitido';
-- Reinicia watermarks para reprocessar coleta completa
UPDATE sync_control
SET last_watermark = '1970-01-01 00:00:00+00',
last_run_status = 'manual_reset',
last_run_at = NOW();
COMMIT;

View File

@ -287,16 +287,6 @@ COMMENT ON COLUMN sync_control.last_run_at IS 'Timestamp da última vez que o jo
-- Registro inicial para a marca d'água do GLPI.
INSERT INTO sync_control (system_name, last_watermark) VALUES ('glpi', '1970-01-01 00:00:00+00');
SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored';
SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido';
SELECT * FROM public.location_mapping
DELETE FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido';
UPDATE sync_control
SET last_watermark = '1970-01-01 00:00:00+00';
-- Migracao para bases existentes:
ALTER TABLE tickets_sn ADD COLUMN IF NOT EXISTS justificativa TEXT;

View File

@ -1,11 +1,32 @@
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const { logInfo, logError } = require('../utils/logger');
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
const { log } = require('winston');
const toTitleCase = (text) => text
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
const normalizeAuthorName = (rawAuthor) => {
const source = String(rawAuthor || 'ServiceNow').trim();
const localPart = source.includes('@') ? source.split('@')[0] : source;
const normalizedRaw = localPart.replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!normalizedRaw) {
return 'ServiceNow';
}
const parts = normalizedRaw.split(' ');
if (parts[0].toLowerCase() === 't' && parts.length > 1) {
return `Tecnico ${toTitleCase(parts.slice(1).join(' '))}`;
}
return toTitleCase(normalizedRaw);
};
const formatSNUpdateForGlpi = (update) => {
const escapeHtml = (value) => String(value || '')
@ -13,8 +34,30 @@ const formatSNUpdateForGlpi = (update) => {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const author = escapeHtml(update.author || 'ServiceNow');
const normalizedAuthor = normalizeAuthorName(update.author);
const author = escapeHtml(normalizedAuthor);
const content = escapeHtml(update.content || '');
const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || ''));
const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || ''));
if (isAutomaticTask) {
return `
<div style="border:1px solid #f6d084; border-left:4px solid #b7791f; background:#fffaf0; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#744210; margin-bottom:6px;">Tarefa automatica do ServiceNow</div>
<div style="color:#5f370e; font-size:12px; white-space: pre-wrap;">
${content}
</div>
</div>`;
}
if (isSnClosureMessage) {
return `
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico do ServiceNow</div>
<div style="color:#742a2a; font-size:12px; white-space: pre-wrap;">${content}</div>
<div style="margin-top:8px; color:#742a2a; font-size:12px;">Chamado resolvido no ServiceNow e removido do monitoramento automatico de atualizacoes.</div>
</div>`;
}
return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
@ -30,13 +73,13 @@ const syncCommentsGlpitoSN = async (ticket) => {
logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
if (comments.length === 0) {
if (!Array.isArray(comments) || comments.length === 0) {
logInfo(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return;
}
logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'comment_collected', 'pending_cmt_check');
let hasError = false;
for (const comment of comments) {
try {
@ -59,7 +102,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
created_at: new Date(comment.date_creation),
updated_at: new Date(comment.date_mod),
source_id: comment.id,
destiny_id: existsInSN.destiny_id
destiny_id: existsInSN
});
continue;
}
@ -90,10 +133,19 @@ const syncCommentsGlpitoSN = async (ticket) => {
} catch (err) {
logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`);
hasError = true;
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error');
}
}
// Evita deixar ticket preso em status transitorio de comentario,
// o que pode bloquear o processamento de status/fechamento.
if (!hasError) {
const finalGlpiStatus = ['solved', 'closed'].includes(ticket.glpi_sync_status) ? ticket.glpi_sync_status : 'synced';
const finalSnStatus = ['solved', 'closed'].includes(ticket.sn_sync_status) ? ticket.sn_sync_status : 'synced';
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus);
}
logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
} catch (error) {
logError(`Erro geral na sincronizacao: ${error}`);
@ -104,7 +156,13 @@ const syncCommentsGlpitoSN = async (ticket) => {
const syncCommentsSNtoGlpi = async (comments, ticket) => {
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
for (const comment of comments) {
const orderedComments = [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
for (const comment of orderedComments) {
try {
const formattedPayload = {
...comment,

View File

@ -4,6 +4,9 @@ const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError } = require('../utils/logger');
const { addWorkNoteToServiceNow } = require('./servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const syncTicketsToGlpi = async () => {
try {
@ -112,9 +115,9 @@ const checkGlpiStatusChanges = async () => {
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
if (solution?.solutiontypes_id === 27) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
@ -157,14 +160,17 @@ const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalSta
: 'Chamado encerrado no Service Now pela CAOA.';
const styledMessage = `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">Atualizacao automatica de encerramento</div>
<div style="color:#2d3748;">
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico no ServiceNow</div>
<div style="color:#742a2a;">
${statusText}
</div>
<div style="margin-top:8px; color:#4a5568; font-size:12px;">
<div style="margin-top:8px; color:#742a2a; font-size:12px;">
Responsavel informado: ${resolvedBy}
</div>
<div style="margin-top:6px; color:#742a2a; font-size:12px;">
Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao.
</div>
</div>`;
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);

View File

@ -6,12 +6,21 @@ const TicketSnModel = require('../models/ticketSnModel');
const { logInfo, logError, logSync } = require('../utils/logger');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston');
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 normalizeServiceNowDateTime = (value) => {
if (!value) return null;
const asString = String(value).trim();
const m = asString.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
const parsed = new Date(asString);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 19).replace('T', ' ');
};
const fetchTicketsFromServiceNow = async (watermark) => {
try {
const watermarkDate = new Date(watermark);
@ -47,6 +56,29 @@ const fetchRequestsFromServiceNow = async (watermark) => {
}
}
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);
@ -193,6 +225,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
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
};
@ -221,7 +254,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
// 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'), { ticketId, page });
logError(new Error('Max pages reached while paginating comments'), { sn_ticket_id: ticket.sn_ticket_id, page });
break;
}
}
@ -240,17 +273,24 @@ const fetchCommentsFromServiceNow = async (ticket) => {
}
return true;
});
if (filteredComments.length === 0) {
// 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 ${filteredComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
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 filteredComments) {
for (const comment of orderedComments) {
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
if (existingComment) {
@ -264,7 +304,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
typeof normalizedContent === 'string' &&
normalizedContent.includes('Registro alterado por:')
) {
normalizedContent = `Anotacao de trabalho adicionada pelo Service Now (registro automatico).\n\nMensagem original:\n${normalizedContent}`;
normalizedContent = `Tarefa automatica originada no ServiceNow (registro automatico).\n\nMensagem original:\n${normalizedContent}`;
}
const commentData = {
@ -272,9 +312,9 @@ const fetchCommentsFromServiceNow = async (ticket) => {
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow',
content: normalizedContent,
created_at: new Date(comment.sys_created_on),
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
author: comment.sys_created_by || 'ServiceNow',
updated_at: new Date(),
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
source_id: comment.sys_id,
destiny_id: null
};
@ -439,7 +479,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
state: '6',
resolved_by: resolvedBySysId,
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
closeNotes: closeNotes,
close_notes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`,
};
@ -550,6 +590,7 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => {
module.exports = {
fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow,
fetchTicketLiveStatusFromServiceNow,
getTicketSysId,
getTicketTypeById,
createCommentInServiceNow,
@ -580,4 +621,3 @@ module.exports = {
*
* Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao.
*/