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:
parent
0f412a1588
commit
e129571812
@ -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
|
||||
@ -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
3
.gitignore
vendored
@ -6,4 +6,5 @@ config/*.json
|
||||
servicenow_requests.json
|
||||
servicenow_tickets.json
|
||||
location_mapping.log
|
||||
.env.production
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
25
README.md
25
README.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
25
src/scripts/database/maintenance_reset.sql
Normal file
25
src/scripts/database/maintenance_reset.sql
Normal 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;
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user