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
|
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
|
||||||
GLPI_DEFAULT_USER_ID=1118
|
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_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa
|
||||||
GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao
|
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_requests.json
|
||||||
servicenow_tickets.json
|
servicenow_tickets.json
|
||||||
location_mapping.log
|
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.
|
- Nao fecha ticket no GLPI automaticamente.
|
||||||
- Insere nota formatada no GLPI.
|
- Insere nota formatada no GLPI.
|
||||||
- Marca sync como `closed/closed` para remover do monitoramento.
|
- 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.
|
- Nao resolve no SN.
|
||||||
- Adiciona `work_note` no SN.
|
- Adiciona `work_note` no SN.
|
||||||
- Marca sincronizacao como encerrada/ignorada.
|
- Marca sincronizacao como encerrada/ignorada.
|
||||||
@ -71,7 +71,7 @@ Executado em ordem pelo `main()`:
|
|||||||
2. Status GLPI -> ServiceNow
|
2. Status GLPI -> ServiceNow
|
||||||
- Solucao no GLPI dispara tentativa de resolucao no SN.
|
- 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.
|
- 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
|
3. Status ServiceNow -> GLPI
|
||||||
- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
|
- 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_TABLE_JOURNAL_URL`
|
||||||
- `SERVICENOW_SC_ITEM_OPTION_URL`
|
- `SERVICENOW_SC_ITEM_OPTION_URL`
|
||||||
- `SERVICENOW_DEFAULT_USER`
|
- `SERVICENOW_DEFAULT_USER`
|
||||||
|
- `SERVICENOW_RESOLVED_BY_SYSID`
|
||||||
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
|
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
|
||||||
|
|
||||||
### GLPI (MySQL/MariaDB)
|
### 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_NAME`
|
||||||
- `GLPI_DB_CHARSET`
|
- `GLPI_DB_CHARSET`
|
||||||
- `GLPI_DEFAULT_USER_ID`
|
- `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)
|
### 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.
|
- 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).
|
- 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
|
## Estrutura do banco intermediario
|
||||||
|
|
||||||
Definicao base em `src/scripts/database/scriptBD.sql`:
|
Definicao base em `src/scripts/database/scriptBD.sql`:
|
||||||
@ -204,10 +218,14 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
|
|||||||
- `location_mapping`
|
- `location_mapping`
|
||||||
- `sync_control`
|
- `sync_control`
|
||||||
|
|
||||||
|
Arquivo auxiliar de manutencao/reset (com comandos destrutivos):
|
||||||
|
|
||||||
|
- `src/scripts/database/maintenance_reset.sql`
|
||||||
|
|
||||||
## Limitacoes conhecidas (estado atual)
|
## Limitacoes conhecidas (estado atual)
|
||||||
|
|
||||||
- Nao ha suite de testes automatizados (`npm test` e placeholder).
|
- 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.
|
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
|
||||||
|
|
||||||
## Proximas melhorias recomendadas
|
## Proximas melhorias recomendadas
|
||||||
@ -217,6 +235,7 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
|
|||||||
- Revisar estrategia de watermark/timezone e idempotencia.
|
- Revisar estrategia de watermark/timezone e idempotencia.
|
||||||
- Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados).
|
- Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados).
|
||||||
- Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria).
|
- 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
|
## Arquivos principais
|
||||||
|
|
||||||
|
|||||||
@ -24,11 +24,6 @@ const snIncidentConfig = {
|
|||||||
baseUrl: process.env.SERVICENOW_INCIDENT_URL,
|
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 = {
|
const snTableIncidentConfig = {
|
||||||
baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL,
|
baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL,
|
||||||
}
|
}
|
||||||
@ -53,7 +48,6 @@ module.exports = {
|
|||||||
// configuração do ServiceNow e outras já existentes podem permanecer
|
// configuração do ServiceNow e outras já existentes podem permanecer
|
||||||
glpiDbConfig,
|
glpiDbConfig,
|
||||||
snIncidentConfig,
|
snIncidentConfig,
|
||||||
snRequestConfig,
|
|
||||||
snTableIncidentConfig,
|
snTableIncidentConfig,
|
||||||
snTableJournalConfig,
|
snTableJournalConfig,
|
||||||
servicenowAuthentication,
|
servicenowAuthentication,
|
||||||
|
|||||||
@ -3,9 +3,11 @@ const TicketSnModel = require('../models/ticketSnModel');
|
|||||||
const TicketGlpiModel = require('../models/ticketGlpiModel');
|
const TicketGlpiModel = require('../models/ticketGlpiModel');
|
||||||
const { logInfo, logError, logWarning } = require('../utils/logger');
|
const { logInfo, logError, logWarning } = require('../utils/logger');
|
||||||
const { stripHTML } = require('../utils/commentSanitizer');
|
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 { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
|
||||||
|
|
||||||
|
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
|
||||||
|
|
||||||
const processStatusAndClosureController = async () => {
|
const processStatusAndClosureController = async () => {
|
||||||
logInfo('Iniciando processamento de status e fechamento de tickets...');
|
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.`);
|
logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`);
|
||||||
continue;
|
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 =================
|
// ================= 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 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')
|
// Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento')
|
||||||
const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus);
|
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) {
|
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}'.`);
|
logInfo(`Mudanca de status detectada para o ticket ${ticket.glpi_ticket_id}. GLPI='${glpiLiveStatusAsString}', SN='${snCurrentStatus}', source_last='${effectiveSourceLast}'.`);
|
||||||
// Hotfix: quando houver divergencia real de status, prioriza GLPI para restaurar fluxo GLPI -> SN.
|
}
|
||||||
ticket.source_last = 'GLPI';
|
|
||||||
|
// 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 =================
|
// ================= LÓGICA SNOW -> GLPI =================
|
||||||
// Se a última atualização veio do ServiceNow, atualiza o 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}'`);
|
logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`);
|
||||||
|
|
||||||
// **LÓGICA DE REABERTURA REFINADA**
|
// **LÓGICA DE REABERTURA REFINADA**
|
||||||
@ -81,7 +108,7 @@ const processStatusAndClosureController = async () => {
|
|||||||
}
|
}
|
||||||
// ================= LÓGICA GLPI -> SNOW =================
|
// ================= LÓGICA GLPI -> SNOW =================
|
||||||
// Se a última atualização veio do GLPI, atualiza o ServiceNow
|
// 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.`);
|
logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`);
|
||||||
const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos
|
const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos
|
||||||
if (glpiCurrentStatus !== null) {
|
if (glpiCurrentStatus !== null) {
|
||||||
@ -92,9 +119,9 @@ const processStatusAndClosureController = async () => {
|
|||||||
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
|
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
|
||||||
|
|
||||||
// REGRA "FORA DO ESCOPO"
|
// REGRA "FORA DO ESCOPO"
|
||||||
if (solution?.solutiontypes_id === 27) {
|
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
|
||||||
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
|
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
|
||||||
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
|
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 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 TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
|
||||||
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
|
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
|
// 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_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_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 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 {
|
class TicketGlpiModel {
|
||||||
|
|
||||||
static mapStatus(snState) {
|
static mapStatus(snState) {
|
||||||
@ -91,14 +105,14 @@ class TicketGlpiModel {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
|
title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
|
||||||
entityId: entityData?.id || 127
|
entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Erro ao formatar título do ticket');
|
logError(error, 'Erro ao formatar título do ticket');
|
||||||
const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE';
|
const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE';
|
||||||
return {
|
return {
|
||||||
title: `${tipo} - ${ticketData.short_description || 'Sem título'}`,
|
title: `${tipo} - ${ticketData.short_description || 'Sem título'}`,
|
||||||
entityId: 127
|
entityId: GLPI_DEFAULT_ENTITY_ID
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,7 +202,7 @@ class TicketGlpiModel {
|
|||||||
try {
|
try {
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
|
|
||||||
let entityId = 127;
|
let entityId = GLPI_DEFAULT_ENTITY_ID;
|
||||||
if (ticketData.location_id) {
|
if (ticketData.location_id) {
|
||||||
entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId;
|
entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId;
|
||||||
} else if (ticketData.location_name) {
|
} else if (ticketData.location_name) {
|
||||||
@ -247,7 +261,7 @@ class TicketGlpiModel {
|
|||||||
`INSERT INTO glpi_groups_tickets
|
`INSERT INTO glpi_groups_tickets
|
||||||
(tickets_id, groups_id, type)
|
(tickets_id, groups_id, type)
|
||||||
VALUES (?, ?, 2)`,
|
VALUES (?, ?, 2)`,
|
||||||
[ticketId, 30]
|
[ticketId, GLPI_DEFAULT_GROUP_ID]
|
||||||
);
|
);
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
@ -301,8 +315,14 @@ 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(?, ?, NOW(), ?, ?, NOW(), NOW(), ?)';
|
const createdAt = toMySqlDateTime(comment.created_at);
|
||||||
const values = ['Ticket', items_id, GLPI_DEFAULT_USER_ID, comment.content , 1];
|
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);
|
const [result] = await connection.execute(query, values);
|
||||||
return [{ id: result.insertId }];
|
return [{ id: result.insertId }];
|
||||||
@ -316,17 +336,21 @@ class TicketGlpiModel {
|
|||||||
static async insertTask(task, items_id) {
|
static async insertTask(task, items_id) {
|
||||||
const connection = await glpiPool.getConnection();
|
const connection = await glpiPool.getConnection();
|
||||||
try {
|
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.
|
// Registra work_notes do ServiceNow como tarefa no GLPI.
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO glpi_tickettasks
|
INSERT INTO glpi_tickettasks
|
||||||
(tickets_id, users_id, state, content, date, date_mod, actiontime)
|
(tickets_id, users_id, state, content, date, date_mod, actiontime)
|
||||||
VALUES (?, ?, ?, ?, NOW(), NOW(), ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
const values = [
|
const values = [
|
||||||
items_id,
|
items_id,
|
||||||
GLPI_DEFAULT_USER_ID,
|
GLPI_DEFAULT_USER_ID,
|
||||||
2,
|
2,
|
||||||
task.content,
|
task.content,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
0
|
0
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -420,11 +444,10 @@ class TicketGlpiModel {
|
|||||||
const connection = await glpiPool.getConnection();
|
const connection = await glpiPool.getConnection();
|
||||||
try {
|
try {
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
|
const mappedStatus = this.mapStatus(status);
|
||||||
status = this.mapStatus(status);
|
|
||||||
|
|
||||||
const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`;
|
const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`;
|
||||||
await connection.execute(updateQuery, [status, ticketId]);
|
await connection.execute(updateQuery, [mappedStatus, ticketId]);
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
const { query } = require('winston');
|
|
||||||
const pool = require('../data/database');
|
const pool = require('../data/database');
|
||||||
const { logError, logInfo } = require('../utils/logger');
|
const { logError, logInfo } = require('../utils/logger');
|
||||||
|
|
||||||
|
|||||||
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.
|
-- 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');
|
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:
|
-- Migracao para bases existentes:
|
||||||
ALTER TABLE tickets_sn ADD COLUMN IF NOT EXISTS justificativa TEXT;
|
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 TicketGlpiModel = require('../models/ticketGlpiModel');
|
||||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||||
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
||||||
const { logInfo, logError } = require('../utils/logger');
|
const { logInfo, logError } = require('../utils/logger');
|
||||||
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
|
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
|
||||||
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
|
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 formatSNUpdateForGlpi = (update) => {
|
||||||
const escapeHtml = (value) => String(value || '')
|
const escapeHtml = (value) => String(value || '')
|
||||||
@ -13,8 +34,30 @@ const formatSNUpdateForGlpi = (update) => {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.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 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 `
|
return `
|
||||||
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
|
<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...');
|
logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
|
||||||
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
|
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
|
||||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
|
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}`);
|
logInfo(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
|
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) {
|
for (const comment of comments) {
|
||||||
try {
|
try {
|
||||||
@ -59,7 +102,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
|
|||||||
created_at: new Date(comment.date_creation),
|
created_at: new Date(comment.date_creation),
|
||||||
updated_at: new Date(comment.date_mod),
|
updated_at: new Date(comment.date_mod),
|
||||||
source_id: comment.id,
|
source_id: comment.id,
|
||||||
destiny_id: existsInSN.destiny_id
|
destiny_id: existsInSN
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -90,10 +133,19 @@ const syncCommentsGlpitoSN = async (ticket) => {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`);
|
logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`);
|
||||||
|
hasError = true;
|
||||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error');
|
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!');
|
logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Erro geral na sincronizacao: ${error}`);
|
logError(`Erro geral na sincronizacao: ${error}`);
|
||||||
@ -104,7 +156,13 @@ const syncCommentsGlpitoSN = async (ticket) => {
|
|||||||
const syncCommentsSNtoGlpi = async (comments, ticket) => {
|
const syncCommentsSNtoGlpi = async (comments, ticket) => {
|
||||||
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
|
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 {
|
try {
|
||||||
const formattedPayload = {
|
const formattedPayload = {
|
||||||
...comment,
|
...comment,
|
||||||
|
|||||||
@ -4,6 +4,9 @@ const TicketUpdateModel = require('../models/ticketUpdateModel');
|
|||||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||||
const { logInfo, logError } = require('../utils/logger');
|
const { logInfo, logError } = require('../utils/logger');
|
||||||
const { addWorkNoteToServiceNow } = require('./servicenowService');
|
const { addWorkNoteToServiceNow } = require('./servicenowService');
|
||||||
|
const { stripHTML } = require('../utils/commentSanitizer');
|
||||||
|
|
||||||
|
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
|
||||||
|
|
||||||
const syncTicketsToGlpi = async () => {
|
const syncTicketsToGlpi = async () => {
|
||||||
try {
|
try {
|
||||||
@ -112,9 +115,9 @@ const checkGlpiStatusChanges = async () => {
|
|||||||
|
|
||||||
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
|
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
|
||||||
|
|
||||||
if (solution?.solutiontypes_id === 27) {
|
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
|
||||||
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
|
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
|
||||||
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
|
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 addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
|
||||||
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);
|
||||||
@ -157,14 +160,17 @@ const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalSta
|
|||||||
: 'Chamado encerrado no Service Now pela CAOA.';
|
: 'Chamado encerrado no Service Now pela CAOA.';
|
||||||
|
|
||||||
const styledMessage = `
|
const styledMessage = `
|
||||||
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
|
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
|
||||||
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">Atualizacao automatica de encerramento</div>
|
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico no ServiceNow</div>
|
||||||
<div style="color:#2d3748;">
|
<div style="color:#742a2a;">
|
||||||
${statusText}
|
${statusText}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:8px; color:#4a5568; font-size:12px;">
|
<div style="margin-top:8px; color:#742a2a; font-size:12px;">
|
||||||
Responsavel informado: ${resolvedBy}
|
Responsavel informado: ${resolvedBy}
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:6px; color:#742a2a; font-size:12px;">
|
||||||
|
Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao.
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
|
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 { logInfo, logError, logSync } = require('../utils/logger');
|
||||||
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
||||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||||
const { log } = require('winston');
|
|
||||||
|
|
||||||
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
|
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
|
||||||
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
|
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
|
||||||
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
|
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) => {
|
const fetchTicketsFromServiceNow = async (watermark) => {
|
||||||
try {
|
try {
|
||||||
const watermarkDate = new Date(watermark);
|
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) => {
|
const getTicketSysId = async (ticketId) => {
|
||||||
try {
|
try {
|
||||||
const ticket = await TicketSnModel.findById(ticketId);
|
const ticket = await TicketSnModel.findById(ticketId);
|
||||||
@ -193,6 +225,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
|||||||
const params = {
|
const params = {
|
||||||
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`,
|
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_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id',
|
||||||
|
sysparm_display_value: true,
|
||||||
sysparm_limit: pageSize,
|
sysparm_limit: pageSize,
|
||||||
sysparm_offset: offset
|
sysparm_offset: offset
|
||||||
};
|
};
|
||||||
@ -221,7 +254,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
|||||||
|
|
||||||
// protecao extra para evitar loop infinito em caso de comportamento inesperado da API
|
// protecao extra para evitar loop infinito em caso de comportamento inesperado da API
|
||||||
if (page >= internalMaxPages) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,17 +273,24 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
|||||||
}
|
}
|
||||||
return true;
|
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}`);
|
logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||||
return [];
|
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 = [];
|
const commentsInserted = [];
|
||||||
// Insere cada comment no banco intermediario
|
// Insere cada comment no banco intermediario
|
||||||
for (const comment of filteredComments) {
|
for (const comment of orderedComments) {
|
||||||
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
|
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
|
||||||
|
|
||||||
if (existingComment) {
|
if (existingComment) {
|
||||||
@ -264,7 +304,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
|||||||
typeof normalizedContent === 'string' &&
|
typeof normalizedContent === 'string' &&
|
||||||
normalizedContent.includes('Registro alterado por:')
|
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 = {
|
const commentData = {
|
||||||
@ -272,9 +312,9 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
|||||||
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
|
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
|
||||||
source_system: 'servicenow',
|
source_system: 'servicenow',
|
||||||
content: normalizedContent,
|
content: normalizedContent,
|
||||||
created_at: new Date(comment.sys_created_on),
|
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||||
author: comment.sys_created_by || 'ServiceNow',
|
author: comment.sys_created_by || 'ServiceNow',
|
||||||
updated_at: new Date(),
|
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||||
source_id: comment.sys_id,
|
source_id: comment.sys_id,
|
||||||
destiny_id: null
|
destiny_id: null
|
||||||
};
|
};
|
||||||
@ -439,7 +479,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
|
|||||||
state: '6',
|
state: '6',
|
||||||
resolved_by: resolvedBySysId,
|
resolved_by: resolvedBySysId,
|
||||||
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
|
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}`,
|
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`,
|
||||||
|
|
||||||
};
|
};
|
||||||
@ -550,6 +590,7 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
fetchTicketsFromServiceNow,
|
fetchTicketsFromServiceNow,
|
||||||
fetchRequestsFromServiceNow,
|
fetchRequestsFromServiceNow,
|
||||||
|
fetchTicketLiveStatusFromServiceNow,
|
||||||
getTicketSysId,
|
getTicketSysId,
|
||||||
getTicketTypeById,
|
getTicketTypeById,
|
||||||
createCommentInServiceNow,
|
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.
|
* 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