FIX : corrige bugs silenciosos

- normaliza comentarios e logs (ASCII)
- corrige campo no createSyncRecord (glpi_ticket_id)
- remove metodo duplicado updateSourceLast
- corrige uso de ID errado na atualizacao de status/comentarios
- atualiza o README
This commit is contained in:
Rafael Alves Lopes 2026-02-19 11:21:40 -03:00
parent 019837acab
commit e84647a2ba
7 changed files with 337 additions and 329 deletions

View File

@ -4,7 +4,7 @@
NODE_ENV=development
PORT=3000
# Frequência de execução do cron job (formato cron). Padrão: '* * * * *' (a cada 1 minutos)
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *'
# ServiceNow API (Dev)
@ -23,9 +23,10 @@ SERVICENOW_TABLE_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_re
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
# Usuário padrão pra criação de chamados (ServiceNow)
# Usuario padrao pra criacao de chamados (ServiceNow)
SERVICENOW_DEFAULT_USER=SOTHIS.CAOA
SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98
SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77
# GLPI (banco real)
GLPI_DB_TYPE=mysql
@ -36,10 +37,10 @@ GLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
GLPI_DB_NAME=glpi_data
GLPI_DB_CHARSET=utf8mb4
# ID do usuário padrão do GLPI para criação de tickets e comentários
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
GLPI_DEFAULT_USER_ID=11111
# Banco intermediário (PostgreSQL)
# Banco intermediario (PostgreSQL)
SNGLPI_DB_HOST=10.0.120.75
SNGLPI_DB_PORT=5432
SNGLPI_DB_NAME=snglpi-development

View File

@ -1,31 +1,39 @@
# ===================================================================
# EXEMPLO DE ARQUIVO DE CONFIGURAÇÃO DE AMBIENTE
# EXEMPLO DE ARQUIVO DE CONFIGURACAO DE AMBIENTE
#
# Copie este arquivo para .env.development e .env.production
# e preencha com os valores corretos para cada ambiente.
# Não adicione senhas ou chaves secretas diretamente neste arquivo.
# Nao adicione senhas ou chaves secretas diretamente neste arquivo.
# ===================================================================
# --- Configuração Geral da Aplicação ---
# --- Configuracao Geral da Aplicacao ---
NODE_ENV=development
PORT=3000
# Frequência de execução do cron job (formato cron). Padrão: '* * * * *' (a cada 1 minutos)
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *'
# --- Configuração da API do ServiceNow ---
# URL base da sua instância do ServiceNow
# --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow
SERVICENOW_INSTANCE=https://your-instance.service-now.com
SERVICENOW_USERNAME=
SERVICENOW_PASSWORD=
# Sys_id do grupo de atribuição no ServiceNow que será monitorado para novos tickets
# Sys_id do grupo de atribuicao no ServiceNow que sera monitorado para novos tickets
SERVICENOW_ASSIGNMENT_GROUP=
# Sys_id do usuário padrão no ServiceNow que será usado pela integração para criar/atualizar tickets
# Sys_id do usuario padrao no ServiceNow que sera usado pela integracao para criar/atualizar tickets
SERVICENOW_DEFAULT_USER_SYSID=
SERVICENOW_DEFAULT_USER=
SERVICENOW_RESOLVED_BY_SYSID=
# --- Configuração do Banco de Dados do GLPI (MySQL) ---
# Endpoints de tabela usados pela aplicacao
SERVICENOW_TABLE_INCIDENT_URL=
SERVICENOW_TABLE_REQUEST_URL=
SERVICENOW_TABLE_JOURNAL_URL=
SERVICENOW_SC_ITEM_OPTION_URL=
# --- Configuracao do Banco de Dados do GLPI (MySQL) ---
GLPI_DB_TYPE=mysql
GLPI_DB_HOST=localhost
GLPI_DB_PORT=3306
@ -34,17 +42,17 @@ GLPI_DB_PASSWORD=
GLPI_DB_NAME=glpi
GLPI_DB_CHARSET=utf8mb4
# ID do usuário padrão do GLPI para criação de tickets e comentários
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
GLPI_DEFAULT_USER_ID=1118
# --- Configuração do Banco de Dados Intermediário (PostgreSQL) ---
# --- Configuracao do Banco de Dados Intermediario (PostgreSQL) ---
SNGLPI_DB_HOST=localhost
SNGLPI_DB_PORT=5432
SNGLPI_DB_NAME=snglpi_sync
SNGLPI_DB_USER=
SNGLPI_DB_PASSWORD=
# --- Configuração de Logs ---
# --- Configuracao de Logs ---
LOG_LEVEL=debug
# --- Mapeamento de Localidades (Opcional) ---

321
README.md
View File

@ -1,206 +1,217 @@
# Sistema de Sincronização ServiceNow <> GLPI
# Sistema de Sincronizacao ServiceNow <> GLPI
## Visão Geral
Middleware em Node.js para sincronizacao bidirecional de tickets, comentarios e status entre ServiceNow e GLPI, com banco intermediario PostgreSQL e acesso direto ao banco MySQL/MariaDB do GLPI.
Esta aplicação Node.js é um middleware projetado para realizar a sincronização bidirecional de tickets entre uma instância do **ServiceNow (SNOW)** e uma do **GLPI**. O objetivo é manter os tickets, seus status e comentários consistentes entre as duas plataformas, automatizando o fluxo de trabalho para as equipes de suporte.
## O que o projeto faz hoje
A aplicação funciona como um job agendado (por exemplo, via cron) que executa um ciclo completo de sincronização.
- Busca incidentes e requisicoes no ServiceNow por watermark (`sync_control`).
- Salva/atualiza tickets no banco intermediario (`tickets_sn`).
- Cria tickets no GLPI para registros pendentes (`ticket_sync.glpi_sync_status = 'pending_check'`).
- Sincroniza comentarios em duas direcoes:
- ServiceNow -> GLPI
- GLPI -> ServiceNow
- Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`).
- Trata regras de negocio especificas de fechamento/reabertura.
- Reprocessa tickets em estado de erro no inicio de cada ciclo.
- Executa em job agendado (`node-cron`) com protecao contra concorrencia local (`isCronRunning`).
---
## Arquitetura
## Como Executar a Aplicação
### Componentes
Esta seção contém as instruções essenciais para configurar e rodar a aplicação em diferentes ambientes.
- `cron.js`: agenda e dispara o ciclo de sincronizacao.
- `src/app.js`: orquestra o ciclo principal.
- `src/controllers/*`: fluxo de tickets, comentarios, status e recuperacao de erro.
- `src/services/*`: integracoes com ServiceNow e GLPI.
- `src/models/*`: acesso a dados (PostgreSQL e GLPI MySQL).
- `src/data/*`: pools de conexao (`pg` e `mysql2`).
- `src/scripts/python/update_location_mapping.py`: atualiza tabela `location_mapping` a partir de CSV.
### 1. Pré-requisitos
### Bancos envolvidos
- PostgreSQL (`snglpi`): estado da integracao.
- MySQL/MariaDB (GLPI): criacao/consulta/atualizacao de tickets e followups.
## Fluxo do ciclo
Executado em ordem pelo `main()`:
1. `processErrorController`
2. `processTicketsController`
3. `processCommentsController`
4. `processStatusAndClosureController`
## Regras de negocio importantes
- Controle de precedencia por `source_last` para reduzir conflito de atualizacao.
- Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo):
- Nao resolve no SN.
- Adiciona `work_note` no SN.
- Marca sincronizacao como encerrada/ignorada.
- Se GLPI resolver e SN estiver em `Em Espera` ou `Aguardando Atendimento`, integracao forca `Em Atendimento` antes de resolver no SN.
- Comentarios GLPI sao sanitizados (HTML/imagens/metadados) antes de envio ao SN.
- Watermark de coleta no ServiceNow usa margem de seguranca de 6 horas para tras (`newWatermark - 6h`).
- Motivo: reduzir risco de perda de eventos em casos de atraso de replicacao, diferenca de timezone e clock skew entre sistemas.
- Efeito colateral esperado: releitura de uma janela recente e maior chance de reprocessamento controlado (idempotencia pelo banco local).
## Regras de negocio detalhadas
1. Controle de origem (`source_last`)
- `SNOW` ou `GLPI` define quem teve a ultima escrita valida para o ticket.
- Evita corrida de atualizacao de status/comentario entre os dois sistemas.
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`.
3. Status ServiceNow -> GLPI
- Mudancas de estado no SN sao refletidas no GLPI respeitando o estado final de fechado.
- Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao.
4. Comentarios GLPI -> SN
- Fluxo com verificacao de existencia local/remota para evitar duplicatas.
- Conteudo passa por sanitizacao para remover HTML, normalizar texto e tratar imagens.
5. Comentarios SN -> GLPI
- Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI.
- IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade.
6. Reprocessamento de erro
- Inicio de cada ciclo tenta resetar estados de erro para recolocar tickets no fluxo automatico.
## Pre-requisitos
- Node.js 18+
- NPM
- PostgreSQL
- MySQL/MariaDB com base do GLPI acessivel
- Python 3.10+ (para o mapeador de localidades)
## Instalacao
- **Node.js**: Certifique-se de ter o Node.js (versão 18 ou superior) instalado.
- **Dependências**: Na raiz do projeto, execute o comando abaixo para instalar todas as dependências necessárias:
```bash
npm install
```
- **Arquivos de Ambiente**: Crie os arquivos `.env.development` e `.env.production` na raiz do projeto, baseando-se no exemplo `.env.example` (se houver) e preenchendo com as credenciais e URLs corretas para cada ambiente.
### 2. Executando com Scripts NPM
### 2. Configuração do Ambiente Python
A aplicação utiliza um script Python para sincronizar o mapeamento de localidades.
#### a) Dependências Python
Navegue até a pasta do script e instale as dependências listadas no `requirements.txt`:
### Dependencias Python
```bash
cd src/scripts/python
pip install -r requirements.txt
```
#### b) Montagem do Compartilhamento de Arquivos (CIFS/Samba)
## Configuracao de ambiente
O script Python lê um arquivo `.csv` de um compartilhamento de rede. No servidor de produção, é necessário montar este compartilhamento para que o caminho definido em `LOCATION_MAPPING_CSV_PATH` seja acessível.
Crie `.env.development` e `.env.production` com base em `.env.example` e inclua tambem as variaveis usadas no codigo:
Adicione a seguinte linha ao seu arquivo `/etc/fstab` para montar o compartilhamento automaticamente durante o boot do sistema. **Ajuste o IP, credenciais e caminhos conforme necessário.**
### ServiceNow
```fstab
//10.0.121.40/Tecnica/Controle\040CAOA/ENTIDADE\040SERVICE\040NOW /mnt/csv_share cifs username=<seu_usuario>,password=<sua_senha>,uid=1000,gid=1000,iocharset=utf8 0 0
```
- `SERVICENOW_USERNAME`
- `SERVICENOW_PASSWORD`
- `SERVICENOW_ASSIGNMENT_GROUP`
- `SERVICENOW_TABLE_INCIDENT_URL`
- `SERVICENOW_TABLE_REQUEST_URL`
- `SERVICENOW_TABLE_JOURNAL_URL`
- `SERVICENOW_SC_ITEM_OPTION_URL`
Após editar o `/etc/fstab`, execute `sudo mount -a` para montar o compartilhamento imediatamente.
### GLPI (MySQL/MariaDB)
Os comandos abaixo utilizam a variável `NODE_ENV` para carregar o arquivo de ambiente (`.env`) correto.
- `GLPI_DB_HOST`
- `GLPI_DB_PORT`
- `GLPI_DB_USER`
- `GLPI_DB_PASSWORD`
- `GLPI_DB_NAME`
- `GLPI_DB_CHARSET`
- `GLPI_DEFAULT_USER_ID`
### Banco intermediario (PostgreSQL)
- `SNGLPI_DB_HOST`
- `SNGLPI_DB_PORT`
- `SNGLPI_DB_NAME`
- `SNGLPI_DB_USER`
- `SNGLPI_DB_PASSWORD`
### Agendamento e mapeamento
- `CRON_SCHEDULE` (ex.: `*/5 * * * *`)
- `LOCATION_MAPPING_CSV_PATH` (arquivo CSV para mapeamento SN -> GLPI)
## Execucao
### Desenvolvimento
#### Ambiente de Desenvolvimento
Para rodar em modo de desenvolvimento com reinício automático a cada alteração de arquivo (usando `nodemon`):
```bash
npm run dev
```
#### Ambiente de Produção
Para rodar a aplicação em modo de produção (usando `node`):
```bash
npm start
```
ou o comando explícito:
### Producao
```bash
npm run start:prod
```
### 3. Executando com PM2
## Execucao com PM2
O PM2 é o gerenciador de processos recomendado para ambientes de produção. Ele garante que a aplicação reinicie automaticamente em caso de falhas e facilita o gerenciamento.
Subir os dois processos (sync + mapeador):
- **Para iniciar em modo de produção:**
```bash
pm2 start ecosystem.config.js --env production
```
- **Para iniciar em modo de desenvolvimento:**
Comandos uteis:
```bash
pm2 start ecosystem.config.js --env development
```
- **Comandos úteis do PM2:**
```bash
pm2 list # Lista todos os processos
pm2 logs sn-glpi-sync-cron # Exibe os logs em tempo real
pm2 restart sn-glpi-sync-cron # Reinicia a aplicação
pm2 stop sn-glpi-sync-cron # Para a aplicação
pm2 list
pm2 logs sn-glpi-sync-cron
pm2 logs sn-glpi-location-mapper
pm2 restart sn-glpi-sync-cron
```
## Fluxo de Execução
## Script de mapeamento de localidades
A aplicação segue uma ordem de execução estrita para garantir a consistência dos dados. O ponto de entrada é o arquivo `src/app.js`, que orquestra a chamada dos seguintes controladores:
Arquivo: `src/scripts/python/update_location_mapping.py`
1. **`processErrorController` - Limpeza de Erros**
- **Objetivo**: Aumentar a resiliência do sistema.
- **Ação**: Busca por tickets que falharam em execuções anteriores (marcados com status de `error`). Ele reseta o status desses tickets para um estado anterior válido (ex: `pending_check` ou `synced`), permitindo que eles sejam reprocessados no ciclo atual.
- Carrega `NODE_ENV` e respectivo `.env.*`.
- Monitora alteracoes recentes no CSV (`LOCATION_MAPPING_CSV_PATH`).
- 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).
2. **`processTicketsController` - Criação de Tickets**
- **Objetivo**: Trazer novos tickets do ServiceNow para o GLPI.
- **Ação**:
- **Busca no SNOW**: Invoca o `processSyncController` para buscar incidentes e requisições novas ou atualizadas no ServiceNow, utilizando uma "marca d'água" (timestamp) para otimização.
- **Criação no GLPI**: Para cada novo ticket coletado, o `glpiTicketService` é chamado para:
- Verificar se o ticket já existe no GLPI para evitar duplicatas.
- Formatar os dados (título, descrição, categoria, etc.).
- Criar o ticket correspondente no banco de dados do GLPI.
## Estrutura do banco intermediario
3. **`processCommentsController` - Sincronização de Comentários**
- **Objetivo**: Manter as conversas dos tickets sincronizadas.
- **Ação**: Para cada ticket ativo, realiza uma sincronização bidirecional de comentários (notas/follow-ups):
- **SNOW -> GLPI**: Busca novos comentários no ServiceNow, os salva no banco intermediário e os insere como follow-ups no GLPI.
- **GLPI -> SNOW**: Busca novos follow-ups no GLPI, os sanitiza (remove HTML, trata imagens) e os envia como `work_notes` para o ServiceNow.
Definicao base em `src/scripts/database/scriptBD.sql`:
4. **`processStatusAndClosureController` - Sincronização de Status e Fechamento**
- **Objetivo**: Manter os status dos tickets alinhados e gerenciar o ciclo de vida (resolução e fechamento).
- **Ação**: Realiza uma sincronização bidirecional de status, tratando reaberturas, pausas, resoluções e fechamentos em ambas as direções.
- `tickets_sn`
- `ticket_sync`
- `ticket_updates`
- `location_mapping`
- `sync_control`
---
## Limitacoes conhecidas (estado atual)
## Regras de Negócio Principais
- Nao ha suite de testes automatizados (`npm test` e placeholder).
- Ha regras hardcoded que deveriam vir de configuracao (ex.: `resolved_by` em fechamento no SN).
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto).
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
A aplicação implementa várias regras de negócio para gerenciar a complexidade da sincronização:
## Proximas melhorias recomendadas
### Controle de Fluxo com "Bastão" (`source_last`)
- Para evitar conflitos de atualização (race conditions), o sistema utiliza um campo `source_last` na tabela `ticket_sync`.
- Este campo funciona como um "bastão da palavra": o sistema que realiza a última atualização (SNOW ou GLPI) "segura o bastão".
- O sistema oposto só pode atualizar o ticket se ele estiver com o bastão. Isso garante que as atualizações de status e comentários ocorram de forma ordenada.
- Cobertura de testes (unitario e integracao com mocks de API/DB).
- Externalizar IDs e regras hardcoded para variaveis de ambiente.
- 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).
### Criação e Enriquecimento de Tickets
- **Mapeamento**: Tickets do ServiceNow são mapeados para o GLPI com base em regras de categoria, prioridade e SLA definidas no `ticketGlpiModel.js`.
- **Título**: O título do ticket no GLPI é padronizado como `[TIPO] - [ENTIDADE] - [TÍTULO ORIGINAL]`.
- **Enriquecimento de Descrição**: Se uma requisição do ServiceNow chega com a descrição vazia, o sistema busca automaticamente o conteúdo de variáveis de catálogo (como `justificativa` e `telefone`) e as utiliza para preencher a descrição e os dados do ticket no GLPI.
## Arquivos principais
### Sincronização de Comentários
- **Lógica de 4 Casos (GLPI -> SNOW)**: Para evitar duplicatas, a sincronização de comentários do GLPI para o SNOW verifica 4 cenários:
1. **Já Sincronizado**: O comentário existe em ambos os locais. Nenhuma ação.
2. **Existe no SN, Falta no BD Local**: O registro local é criado para corrigir o estado.
3. **Existe no BD Local, Falta no SN**: O comentário é reenviado para o ServiceNow.
4. **Novo Comentário**: O comentário é enviado para o SNOW e registrado localmente.
- **Sanitização**: Comentários do GLPI passam por um processo de limpeza (`commentSanitizer.js`) que remove tags HTML e substitui imagens por um texto placeholder, garantindo que o conteúdo seja legível no ServiceNow.
### Sincronização de Status
- **Fechamento de Ticket em Espera**: Se um ticket é resolvido no GLPI, mas seu correspondente no ServiceNow está com o status "Em Espera" ou "Aguardando Atendimento", o sistema primeiro altera o status no ServiceNow para "Em Atendimento" e só então prossegue com a resolução. Isso automatiza o fluxo e evita falhas.
- **Regra "Fora do Escopo"**: Se um ticket no GLPI é solucionado com o tipo de solução "Fora do Escopo", o sistema não o resolve no ServiceNow. Em vez disso, ele adiciona uma nota de trabalho (`work_note`) explicando o motivo e fecha o registro de sincronização, tratando-o como um caso especial.
- **Reabertura**: O sistema permite a reabertura de tickets em ambas as direções, desde que o ticket não esteja permanentemente fechado.
### Ambientes (`.env`)
- A aplicação suporta múltiplos ambientes através de arquivos `.env`.
- Se a variável de ambiente `NODE_ENV` for definida como `production`, o arquivo `.env.production` será carregado.
- Caso contrário (ou se `NODE_ENV` for `development`), o arquivo `.env.development` será utilizado.
### Atualização do Mapeamento de Localidades (`update_location.py`)
- A aplicação conta com um script Python (`src/scripts/python/update_location.py`) para manter o mapeamento entre as localidades do ServiceNow e as entidades do GLPI.
- **Fonte da Verdade**: Um arquivo `.csv` localizado em um servidor de arquivos (`LOCATION_MAPPING_CSV_PATH` no `.env`) é a fonte da verdade para este mapeamento.
- **Processo**:
1. Um usuário (`sothis`) atualiza o arquivo `.csv` com as correspondências corretas entre os nomes das localidades (SNOW) e os nomes das entidades (GLPI).
2. Um `cron job` no servidor executa o script `update_location.py` periodicamente.
3. O script lê o `.csv`, busca os IDs correspondentes nos bancos de dados de ambos os sistemas e recria a tabela `location_mapping` no banco de dados intermediário (PostgreSQL).
- **Resultado**: Garante que os tickets criados no GLPI sejam sempre associados à entidade correta, com base em um mapeamento centralizado e de fácil manutenção.
---
## Estrutura do Banco de Dados Intermediário (PostgreSQL)
- **`tickets_sn`**: Armazena uma cópia local dos dados dos tickets do ServiceNow para otimizar consultas.
- **`ticket_sync`**: Tabela central que controla o estado da sincronização de cada ticket, incluindo os IDs de ambos os sistemas e o campo `source_last`.
- **`ticket_updates`**: Log de todas as atualizações (comentários, etc.), usado para evitar duplicidade e rastrear o histórico.
- **`location_mapping`**: Tabela de mapeamento entre as localizações do ServiceNow e as entidades do GLPI.
- **`sync_control`**: Armazena metadados do processo, como a marca d'água (timestamp) da última sincronização bem-sucedida.
---
## Diagrama de Fluxo (Exemplo)
### Sincronização de Resolução: GLPI -> ServiceNow
O diagrama abaixo ilustra como o sistema lida com um ticket que foi marcado como "Solucionado" no GLPI.
```mermaid
sequenceDiagram
participant App as app.js
participant StatusCtrl as processStatusAndClosureController
participant GlpiModel as TicketGlpiModel
participant SyncModel as TicketSyncModel
participant SnowService as servicenowService
participant ServiceNow as ServiceNow API
App->>StatusCtrl: Iniciar ciclo de status
StatusCtrl->>SyncModel: getTicketsToMonitor()
SyncModel-->>StatusCtrl: Lista de tickets ativos
loop Para cada ticket monitorado
StatusCtrl->>GlpiModel: getTicketStatus(glpi_id)
GlpiModel-->>StatusCtrl: Status 'Solucionado' (5)
alt Status mudou para 'Solucionado'
StatusCtrl->>GlpiModel: getTicketSolution(glpi_id)
GlpiModel-->>StatusCtrl: Detalhes da solução
StatusCtrl->>SnowService: closeTicketInServiceNow(sn_id, solucao)
SnowService->>ServiceNow: PATCH /api/now/table/incident/{sys_id} (state: 'Resolved')
ServiceNow-->>SnowService: 200 OK
SnowService-->>StatusCtrl: Sucesso
StatusCtrl->>SyncModel: updateStatus(sn_id, 'solved', 'solved')
SyncModel-->>StatusCtrl: OK
end
end
```
- `cron.js`
- `src/app.js`
- `src/controllers/processTicketsController.js`
- `src/controllers/processCommentsController.js`
- `src/controllers/processStatusController.js`
- `src/controllers/processErrorController.js`
- `src/services/servicenowService.js`
- `src/services/glpiTicketService.js`
- `src/services/glpiCommentService.js`
- `src/scripts/python/update_location_mapping.py`

View File

@ -3,10 +3,9 @@ const TicketSyncModel = require('../models/ticketSyncModel');
const SyncControlModel = require('../models/syncControlModel');
const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService');
const { logInfo, logError, logSync } = require('../utils/logger');
const e = require('express');
/**
* Processa e salva um lote de tickets (incidentes ou requisições).
* Processa e salva um lote de tickets (incidentes ou requisicoes).
* @param {Array} tickets - A lista de tickets da API do ServiceNow.
* @param {('incidente'|'requisicao')} type - O tipo de ticket.
*/
@ -18,7 +17,7 @@ const processAndSaveTickets = async (tickets, type) => {
}
const parts = dateString.match(/(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})/);
if (!parts) return null; // Retorna nulo se o formato for inesperado
// Formato: new Date(ano, mês-1, dia, hora, minuto, segundo)
// Formato: new Date(ano, mes-1, dia, hora, minuto, segundo)
return new Date(parts[3], parts[2] - 1, parts[1], parts[4], parts[5], parts[6]);
};
@ -27,12 +26,12 @@ const processAndSaveTickets = async (tickets, type) => {
return '';
}
logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
logInfo(`INFO: Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
let latestUpdateTimestamp = new Date(0);
for (const ticket of tickets) {
try {
if (type === 'requisicao' && (!ticket.description || ticket.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.number}. Buscando em sc_item_option...`);
logInfo(`Descricao vazia para requisicao ${ticket.number}. Buscando em sc_item_option...`);
const extraData = await fetchScItemOptionValue(ticket.sys_id, 'justificativa');
const vars = {};
(extraData || []).forEach(item => {
@ -47,11 +46,11 @@ const processAndSaveTickets = async (tickets, type) => {
if (vars.justification) {
ticket.description = vars.justification;
logInfo(`Descrição da requisição ${ticket.number} atualizada com valor de sc_item_option.`);
logInfo(`Descricao da requisicao ${ticket.number} atualizada com valor de sc_item_option.`);
}
if (vars.telephone){
ticket.telefone = vars.telephone;
logInfo(`Telefone da requisição ${ticket.number} atualizado com valor de sc_item_option.`);
logInfo(`Telefone da requisicao ${ticket.number} atualizado com valor de sc_item_option.`);
}
}
@ -82,7 +81,7 @@ const processAndSaveTickets = async (tickets, type) => {
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
} else {
logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`);
logInfo(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
}
const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
@ -102,33 +101,33 @@ const processAndSaveTickets = async (tickets, type) => {
};
/**
* Controller para buscar tickets e requisições do ServiceNow e salvá-los localmente.
* Controller para buscar tickets e requisicoes do ServiceNow e salva-los localmente.
*/
const processSyncController = async () => {
try {
const watermark = await SyncControlModel.getWatermark('servicenow');
logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
logInfo(`INFO: Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
const incidents = await fetchTicketsApi(watermark);
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
logInfo('📡 Buscando requisições do ServiceNow...');
logInfo('INFO: Buscando requisicoes do ServiceNow...');
const requests = await fetchRequestsApi(watermark);
const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao');
const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate;
if (newWatermark) {
// Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais
// Mantem margem de seguranca de 6 horas para evitar perda de eventos por atraso de replicacao, timezone ou clock skew entre sistemas.
const sixHoursInMillis = 6 * 60 * 60 * 1000;
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
await SyncControlModel.setWatermark('servicenow', nextWatermark);
} else {
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada.");
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
}
} catch (error) {
logError(error, 'Erro no processo de sincronização de tickets do ServiceNow.');
logError(error, 'Erro no processo de sincronizacao de tickets do ServiceNow.');
}
};
@ -139,14 +138,14 @@ module.exports = {
/**
* @file processSyncController.js
* @description
* Este controlador é responsável por buscar novos tickets e atualizações do ServiceNow.
* Ele utiliza uma "marca d'água" (watermark) para buscar apenas os registros modificados desde a última execução,
* Este controlador e responsavel por buscar novos tickets e atualizacoes do ServiceNow.
* Ele utiliza uma "marca d'agua" (watermark) para buscar apenas os registros modificados desde a ultima execucao,
* otimizando o processo.
*
* Funcionalidades:
* - Busca incidentes e requisições do ServiceNow com base na data da última atualização.
* - Para cada ticket, salva ou atualiza o registro no banco de dados intermediário (`tickets_sn`).
* - Cria ou atualiza o registro de controle na tabela `ticket_sync`, definindo o status inicial e a origem da atualização (`source_last`).
* - Implementa uma lógica de enriquecimento de dados para requisições com descrição vazia, buscando informações de variáveis de catálogo (`sc_item_option`).
* - Ao final, atualiza a marca d'água para a próxima execução, apenas se houverem tickets atualizados na execução vigente.
* - Busca incidentes e requisicoes do ServiceNow com base na data da ultima atualizacao.
* - Para cada ticket, salva ou atualiza o registro no banco de dados intermediario (`tickets_sn`).
* - Cria ou atualiza o registro de controle na tabela `ticket_sync`, definindo o status inicial e a origem da atualizacao (`source_last`).
* - Implementa uma logica de enriquecimento de dados para requisicoes com descricao vazia, buscando informacoes de variaveis de catalogo (`sc_item_option`).
* - Ao final, atualiza a marca d'agua para a proxima execucao, apenas se houverem tickets atualizados na execucao vigente.
*/

View File

@ -21,7 +21,7 @@ class TicketSyncModel {
const values = [
data.sn_ticket_id,
data.glpi_ticket,
data.glpi_ticket_id,
data.last_sn_sync,
data.last_glpi_sync,
data.created_at,
@ -35,7 +35,7 @@ class TicketSyncModel {
await pool.query(query, values);
} catch (error) {
logError(error, '❌ Erro ao criar registro de sincronização');
logError(error, 'ERRO: Erro ao criar registro de sincronizacao');
throw error;
}
}
@ -47,7 +47,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query, values);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets para sincronizar');
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
throw error;
}
}
@ -57,7 +57,7 @@ class TicketSyncModel {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2';
await pool.query(query, [source, snTicketId]);
} catch (error) {
logError(error, ` Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
logError(error, `ERRO: Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
@ -67,7 +67,7 @@ class TicketSyncModel {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
@ -78,17 +78,7 @@ class TicketSyncModel {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status= $2, source_last = $4 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId, source_last]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
throw error;
}
}
static async updateSourceLast(snTicketId, source) {
try {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2';
await pool.query(query, [source, snTicketId]);
} catch (error) {
logError(error, `❌ Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
@ -98,7 +88,7 @@ class TicketSyncModel {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2, glpi_ticket_id = $4 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId, glpiTicketId]);
} catch (error) {
logError(error, `❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
logError(error, `ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
@ -109,7 +99,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query, [glpiTicketId]);
return rows[0] ? rows[0].id : null;
} catch (error) {
logError(error, `❌ Erro ao buscar ID de sincronização por GLPI ID: ${glpiTicketId}`);
logError(error, `ERRO: Erro ao buscar ID de sincronizacao por GLPI ID: ${glpiTicketId}`);
throw error;
}
}
@ -123,7 +113,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query, [sysId]);
return rows[0] || null;
} catch (error) {
logError(error, `❌ Erro ao buscar registro de sincronização por sys_id: ${sysId}`);
logError(error, `ERRO: Erro ao buscar registro de sincronizacao por sys_id: ${sysId}`);
throw error;
}
}
@ -134,7 +124,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query, [snTicketId]);
return rows[0] ? rows[0].id : null;
} catch (error) {
logError(error, `❌ Erro ao buscar ID de sincronização por SN ID: ${snTicketId}`);
logError(error, `ERRO: Erro ao buscar ID de sincronizacao por SN ID: ${snTicketId}`);
throw error;
}
}
@ -146,7 +136,7 @@ class TicketSyncModel {
return rows[0] || null;
} catch (error) {
logError(`${error}, Erro ao buscar ticket ${ticketNumber}}` )
logError(`${error}, ERRO: Erro ao buscar ticket ${ticketNumber}}` )
}
}
@ -166,7 +156,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets para sincronizar');
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
throw error;
}
}
@ -189,7 +179,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets para monitorar');
logError(error, 'ERRO: Erro ao buscar tickets para monitorar');
throw error;
}
}
@ -211,7 +201,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets pendentes de fechamento');
logError(error, 'ERRO: Erro ao buscar tickets pendentes de fechamento');
throw error;
}
}
@ -226,7 +216,7 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets em estado de erro');
logError(error, 'ERRO: Erro ao buscar tickets em estado de erro');
throw error;
}
}
@ -244,7 +234,7 @@ class TicketSyncModel {
const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`;
await pool.query(query, [snTicketId]);
} catch (error) {
logError(`${error} Erro ao atualizar last_sync para o ID: ${snTicketId}`);
logError(`${error} ERRO: Erro ao atualizar last_sync para o ID: ${snTicketId}`);
}
}
@ -253,7 +243,7 @@ class TicketSyncModel {
const query = 'UPDATE ticket_sync SET sn_sync_status = $1, glpi_sync_status = $2 WHERE glpi_ticket_id = $3';
await pool.query(query, [snStatus, glpiStatus, glpiTicketId]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o GLPI ID: ${glpiTicketId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o GLPI ID: ${glpiTicketId}`);
throw error;
}
}
@ -262,16 +252,16 @@ class TicketSyncModel {
}
/**
* @module TicketSyncModel
* @description Este módulo é a camada de acesso à tabela `ticket_sync`, que atua como o cérebro do processo de sincronização.
* Ele armazena o estado de cada ticket em ambos os sistemas (GLPI e ServiceNow) e controla o fluxo de atualizações.
* @description Este modulo e a camada de acesso a tabela `ticket_sync`, que atua como o cerebro do processo de sincronizacao.
* Ele armazena o estado de cada ticket em ambos os sistemas (GLPI e ServiceNow) e controla o fluxo de atualizacoes.
*
* Principais Funcionalidades:
* - **Gerenciamento de Estado**: Métodos como `updateStatus`, `updateStatusAndSourceLast` e `updateStatusAndGlpiTicket` são usados para modificar os status de sincronização (ex: 'pending_check', 'synced', 'solved', 'closed', 'error').
* - **Controle de Fluxo (`source_last`)**: A coluna `source_last` é gerenciada por este modelo para indicar qual sistema realizou a última modificação, prevenindo atualizações conflitantes (race conditions).
* - **Seleção de Tickets para Processamento**: Métodos como `getTicketsToMonitor`, `getTicketsInErrorState`, e `getTicketsSynced` fornecem listas de tickets para os diferentes controladores processarem, com base em seus status atuais.
* - **Criação e Consulta**: `createSyncRecord` cria o registro inicial de um ticket, e várias funções de busca (`getBySysId`, `getIdSyncByGlpiId`, etc.) permitem localizar registros de sincronização.
* - **Gerenciamento de Estado**: Metodos como `updateStatus`, `updateStatusAndSourceLast` e `updateStatusAndGlpiTicket` sao usados para modificar os status de sincronizacao (ex: 'pending_check', 'synced', 'solved', 'closed', 'error').
* - **Controle de Fluxo (`source_last`)**: A coluna `source_last` e gerenciada por este modelo para indicar qual sistema realizou a ultima modificacao, prevenindo atualizacoes conflitantes (race conditions).
* - **Selecao de Tickets para Processamento**: Metodos como `getTicketsToMonitor`, `getTicketsInErrorState`, e `getTicketsSynced` fornecem listas de tickets para os diferentes controladores processarem, com base em seus status atuais.
* - **Criacao e Consulta**: `createSyncRecord` cria o registro inicial de um ticket, e varias funcoes de busca (`getBySysId`, `getIdSyncByGlpiId`, etc.) permitem localizar registros de sincronizacao.
*
* A tabela `ticket_sync` é a fonte da verdade para o estado da integração de cada ticket individualmente.
* A tabela `ticket_sync` e a fonte da verdade para o estado da integracao de cada ticket individualmente.
*/

View File

@ -9,16 +9,16 @@ const { log } = require('winston');
const syncCommentsGlpitoSN = async (ticket) => {
try {
logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...');
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) {
logInfo(`Nenhum comentário 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;
}
logInfo(`📝 ${comments.length} comentários encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'comment_collected', 'pending_cmt_check');
logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'comment_collected', 'pending_cmt_check');
for (const comment of comments) {
try {
@ -28,7 +28,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
if (existingComment && existsInSN) {
logInfo(`🔁 Comentário ${comment.id} sincronizado.`);
logInfo(`INFO: Comentario ${comment.id} ja sincronizado.`);
continue;
}
@ -71,20 +71,20 @@ const syncCommentsGlpitoSN = async (ticket) => {
}
} catch (err) {
logError(`Erro ao sincronizar comentário ${comment.id}: ${err}`);
logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error');
}
}
logInfo('✅ Sincronização de comentários GLPI → SN concluída!');
logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
} catch (error) {
logError(`Erro geral na sincronização: ${error}`);
logError(`Erro geral na sincronizacao: ${error}`);
}
};
const syncCommentsSNtoGlpi = async (comments, ticket) => {
logInfo('🔄 Iniciando sincronização de comments do ServiceNow para o GLPI...');
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
for (const comment of comments) {
try {
@ -95,10 +95,10 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
}
await TicketUpdateModel.updateDestinyId(comment.source_id, commentId[0].id);
logInfo(`✅ Comentário inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
logInfo(`OK: Comentario inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
} catch (error) {
logError(`⚠️ Erro ao processar comentário do ticket ${ticket.sn_ticket_id}: ${error}`);
logError(`ERRO: Erro ao processar comentario do ticket ${ticket.sn_ticket_id}: ${error}`);
try {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error');
@ -111,18 +111,18 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
/**
* @module glpiCommentService
* @description Este serviço orquestra a sincronização de comentários (follow-ups/notas) entre GLPI e ServiceNow.
* @description Este servico orquestra a sincronizacao de comentarios (follow-ups/notas) entre GLPI e ServiceNow.
*
* Principais Funcionalidades:
* - `syncCommentsGlpitoSN(ticket)`: Sincroniza comentários do GLPI para o ServiceNow. Esta é a função mais complexa, pois implementa uma lógica de 4 casos para garantir a consistência e evitar duplicatas:
* 1. **Já Sincronizado**: O comentário existe no banco local e no ServiceNow. Nenhuma ação é necessária.
* 2. **Existe no SN, Falta no Local**: O comentário foi sincronizado em uma execução anterior que falhou. O registro local é criado para refletir a realidade.
* 3. **Existe no Local, Falta no SN**: O comentário foi registrado localmente, mas a sincronização com o SN falhou. A função reenvia o comentário para o ServiceNow.
* 4. **Não Existe em Nenhum**: Um novo comentário do GLPI. Ele é enviado para o ServiceNow e um novo registro é criado no banco local.
* - `syncCommentsGlpitoSN(ticket)`: Sincroniza comentarios do GLPI para o ServiceNow. Esta e a funcao mais complexa, pois implementa uma logica de 4 casos para garantir a consistencia e evitar duplicatas:
* 1. **Ja Sincronizado**: O comentario existe no banco local e no ServiceNow. Nenhuma acao e necessaria.
* 2. **Existe no SN, Falta no Local**: O comentario foi sincronizado em uma execucao anterior que falhou. O registro local e criado para refletir a realidade.
* 3. **Existe no Local, Falta no SN**: O comentario foi registrado localmente, mas a sincronizacao com o SN falhou. A funcao reenvia o comentario para o ServiceNow.
* 4. **Nao Existe em Nenhum**: Um novo comentario do GLPI. Ele e enviado para o ServiceNow e um novo registro e criado no banco local.
*
* - `syncCommentsSNtoGlpi(comments, ticket)`: Sincroniza comentários do ServiceNow para o GLPI. Este fluxo é mais simples: para cada novo comentário do SN ( filtrado e salvo localmente pelo `servicenowService`), ele o insere no GLPI.
* - `syncCommentsSNtoGlpi(comments, ticket)`: Sincroniza comentarios do ServiceNow para o GLPI. Este fluxo e mais simples: para cada novo comentario do SN (ja filtrado e salvo localmente pelo `servicenowService`), ele o insere no GLPI.
*
* Em ambos os fluxos, o "bastão" (`source_last`) é atualizado para indicar qual sistema originou a última atualização, mantendo o controle do fluxo de status.
* Em ambos os fluxos, o "bastao" (`source_last`) e atualizado para indicar qual sistema originou a ultima atualizacao, mantendo o controle do fluxo de status.
*/
module.exports = {

View File

@ -8,6 +8,8 @@ const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston');
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
const fetchTicketsFromServiceNow = async (watermark) => {
try {
const watermarkDate = new Date(watermark);
@ -47,12 +49,12 @@ const getTicketSysId = async (ticketId) => {
try {
const ticket = await TicketSnModel.findById(ticketId);
if (!ticket) {
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketSysId');
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketSysId');
return null;
}
return ticket.sys_id;
} catch (error) {
logError(error, '🚨Falha ao obter sys_id do ticket');
logError(error, 'ERRO: Falha ao obter sys_id do ticket');
return null;
}
}
@ -61,14 +63,14 @@ const getTicketTypeById = async (ticketId) => {
try {
const ticket = await TicketSnModel.findById(ticketId);
if (!ticket) {
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketTypeById');
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketTypeById');
return null;
}
const ticketType = ticket.tipo;
logInfo(`⚠️ Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
logInfo(`INFO: Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
return ticketType;
} catch (error) {
logError(error, '🚨Falha ao obter tipo do ticket');
logError(error, 'ERRO: Falha ao obter tipo do ticket');
return null;
}
}
@ -85,15 +87,15 @@ const getCommentId = async (sysId, comment) => {
const data = response.data;
const comments = data.result;
// Procurar o comentário exato na lista retornada
// Procurar o comentario exato na lista retornada
for (const item of comments) {
if (item.value && item.value.includes(comment)) {
return item.sys_id; // Retorna o sys_id do comentário encontrado
return item.sys_id; // Retorna o sys_id do comentario encontrado
}
}
} catch (error) {
logError(error, '🚨Falha ao obter ID do comentário no ServiceNow');
logError(error, 'ERRO: Falha ao obter ID do comentario no ServiceNow');
return null;
}
}
@ -124,34 +126,34 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
// Verificar se retorna status 200 para sucesso
if (response.status !== 200) {
logError(`❌ Falha ao criar comentário. Status: ${response.status}`, { sysId, ticketType });
logError(`ERRO: Falha ao criar comentario. Status: ${response.status}`, { sysId, ticketType });
return null;
}
// retornar id do comentário criado
// retornar id do comentario criado
const commentId = await getCommentId(sysId, comment);
if (!commentId) {
logError('❌ Comentário criado, mas não foi possível obter o ID do comentário', { sysId });
logError('ERRO: Comentario criado, mas nao foi possivel obter o ID do comentario', { sysId });
return null;
}
logInfo(`✅ Comentário criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
logInfo(`OK: Comentario criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
return commentId;
} catch (error) {
logError(error, '🚨Falha ao criar comentário no ServiceNow');
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
}
}
const syncCommentToServiceNow = async (ticketId, comment) => {
logInfo(`Iniciando sincronização do comentário para o ticket SN Ticket: ${ticketId}`);
logInfo(`Iniciando sincronizacao do comentario para o ticket SN Ticket: ${ticketId}`);
const snSysId = await getTicketSysId(ticketId);
const ticketType = await getTicketTypeById(ticketId);
if (!snSysId) {
logError(`❌ sys_id do ServiceNow não encontrado para o ticket SN Ticket: ${ticketId}`);
logError(`ERRO: sys_id do ServiceNow nao encontrado para o ticket SN Ticket: ${ticketId}`);
return null;
}
const snCommentId = await createCommentInServiceNow(ticketId,snSysId, comment, ticketType);
@ -165,7 +167,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
return commentExists;
} catch (error) {
logError(error, '🚨Falha ao verificar existência do comentário no ServiceNow');
logError(error, 'ERRO: Falha ao verificar existencia do comentario no ServiceNow');
return false;
}
}
@ -173,7 +175,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
const fetchCommentsFromServiceNow = async (ticket) => {
try {
logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
logInfo(`Iniciando busca de comentarios do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const sys_id = ticket.sys_id;
@ -192,7 +194,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
sysparm_limit: pageSize,
sysparm_offset: offset
};
logInfo(`Buscando página ${page} de comentários do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
logInfo(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, {
auth: apiConfig.servicenowAuthentication.auth,
params
@ -203,33 +205,33 @@ const fetchCommentsFromServiceNow = async (ticket) => {
if (!Array.isArray(comments) || comments.length === 0) {
break; // não há mais comentários
break; // nao ha mais comentarios
}
allComments.push(...comments);
if (comments.length < pageSize) {
break; // última página
break; // ultima pagina
}
// avançar para próxima página
// avancar para proxima pagina
offset += pageSize;
// proteção 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) {
logError(new Error('Max pages reached while paginating comments'), { ticketId, page });
break;
}
}
// Traz apenas os tickets que não são do sys_created_by: "SOTHIS.CAOA"
// Traz apenas os tickets que nao sao do sys_created_by: "SOTHIS.CAOA"
const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA');
if (filteredComments.length === 0) {
logInfo(`Nenhum comentário encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
logInfo(`Nenhum comentario encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return [];
}
logInfo(`Encontrado ${filteredComments.length} comentários para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
logInfo(`Encontrado ${filteredComments.length} comentarios para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
const commentsInserted = [];
@ -238,7 +240,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
if (existingComment) {
logInfo(`Comentário: ${comment.sys_id} existe no banco de dados`, { step: 3 });
logInfo(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 });
continue;
} else {
@ -259,9 +261,9 @@ const fetchCommentsFromServiceNow = async (ticket) => {
if (commentInserted.success) {
commentsInserted.push(commentData);
logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
logInfo(`OK: Comentario ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
} else {
logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
logError(`ERRO: Erro ao inserir comentario ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
return [];
}
}
@ -270,16 +272,16 @@ const fetchCommentsFromServiceNow = async (ticket) => {
} catch (error) {
logError(error, '🚨Falha ao buscar comentários do ServiceNow');
logError(error, 'ERRO: Falha ao buscar comentarios do ServiceNow');
return [];
}
};
const fetchAndProcessClosedTicketsFromSN = async () => {
try {
logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...');
logInfo('INFO: Verificando status de tickets sincronizados no ServiceNow...');
// 1. Busca apenas os tickets que estão abertos e sincronizados no nosso banco.
// 1. Busca apenas os tickets que estao abertos e sincronizados no nosso banco.
const syncedTickets = await TicketSyncModel.getTicketsSynced();
if (syncedTickets.length === 0) {
@ -297,12 +299,12 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'solved', 'SNOW');
} else if (ticketSnDetails && ticketSnDetails.status === 'Encerrado - Omitido') { // 'Encerrado - Omitido' mapeia para 'Closed' (7)
logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronização de fechamento...`);
logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronizacao de fechamento...`);
// Sanitiza o conteúdo da nota de fechamento antes de salvar.
// Sanitiza o conteudo da nota de fechamento antes de salvar.
const sanitizedContent = stripHTML(ticketSnDetails.close_notes || 'Chamado encerrado sem notas.');
// Salva a nota de fechamento no nosso banco intermediário.
// Salva a nota de fechamento no nosso banco intermediario.
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
source_system: 'servicenow',
@ -319,12 +321,12 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
}
} catch (error) {
logError(error, '🚨Falha ao buscar chamados encerrados do ServiceNow');
logError(error, 'ERRO: Falha ao buscar chamados encerrados do ServiceNow');
}
};
const updateStatusInServiceNow = async (snTicketId, state) => {
// Mapeamento de status de string para código numérico do ServiceNow
// Mapeamento de status de string para codigo numerico do ServiceNow
const stateMap = {
'Aguardando Atendimento': '1', // New
'Em Atendimento': '2', // In Progress
@ -333,13 +335,13 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
'Encerrado - Omitido': '7' // Closed
};
const stateCode = stateMap[state] || state; // Usa o código mapeado ou o valor original se não encontrar
const stateCode = stateMap[state] || state; // Usa o codigo mapeado ou o valor original se nao encontrar
let sysId = null; // Declarar sysId fora do try para estar disponível no catch
let sysId = null; // Declarar sysId fora do try para estar disponivel no catch
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para atualização de status.`, 'updateStatusInServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizacao de status.`, 'updateStatusInServiceNow');
return null;
}
@ -354,7 +356,7 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = { state: stateCode }; // Requisições podem ter um fluxo diferente
payload = { state: stateCode }; // Requisicoes podem ter um fluxo diferente
} else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
@ -366,15 +368,15 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Atualiza o timestamp
if (response.status !== 200) {
logError(` Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
logError(`ERRO: Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`);
}
logInfo(` Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
logInfo(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
return true;
} catch (error) {
logError(error, `🚨 Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
logError(error, `ERRO: Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
return null;
}
};
@ -385,7 +387,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para fechamento.`, 'closeTicketInServiceNow');
return null;
}
@ -394,9 +396,6 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
let url = ''; // Declarar url fora do if
let payload = {};
// O ID do usuário que está resolvendo o chamado. Pegue o sys_id do usuário da integração no SN.
// O nome do usuário que está resolvendo o chamado.
const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
@ -407,7 +406,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
close_code: 'Closed/Resolved by caller',
close_notes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`
};
} else if (ticketType === 'requisicao') {
@ -418,7 +417,7 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
resolved_by: resolvedBySysId,
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
closeNotes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`,
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`,
};
} else {
@ -435,15 +434,15 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Corrigido para usar snTicketId
if (response.status !== 200) {
logError(` Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
logError(`ERRO: Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`);
}
logInfo(` Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
logInfo(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
return true;
} catch (error) {
logError(error, `🚨 Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
logError(error, `ERRO: Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
return null;
}
};
@ -453,7 +452,7 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
return null;
}
@ -477,20 +476,20 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
logInfo(` Work note adicionada com sucesso ao ticket SN ${sysId}.`);
logInfo(`OK: Work note adicionada com sucesso ao ticket SN ${sysId}.`);
return true;
} catch (error) {
logError(error, `🚨 Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
logError(error, `ERRO: Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
return null;
}
};
/**
* Busca o valor de uma variável de catálogo (sc_item_option) para um Request Item (RITM) específico.
* Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico.
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
* @param {string} variableName - O nome interno (name) da variável de catálogo.
* @returns {Promise<string|null>} O valor da variável ou null se o encontrada/erro.
* @param {string} variableName - O nome interno (name) da variavel de catalogo.
* @returns {Promise<string|null>} O valor da variavel ou null se nao encontrada/erro.
*/
const fetchScItemOptionValue = async (ritmSysId, variableName) => {
try {
@ -505,7 +504,7 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => {
const result = response.data.result;
return (result && result.length > 0) ? result : null;
} catch (error) {
logError(error, `🚨 Falha ao buscar valor da variável '${variableName}' para RITM ${ritmSysId}`);
logError(error, `ERRO: Falha ao buscar valor da variavel '${variableName}' para RITM ${ritmSysId}`);
return null;
}
};
@ -529,18 +528,18 @@ module.exports = {
/**
* @module servicenowService
* @description Este serviço encapsula todas as interações com a API REST do ServiceNow.
* Ele é responsável por buscar, criar e atualizar dados na plataforma ServiceNow.
* @description Este servico encapsula todas as interacoes com a API REST do ServiceNow.
* Ele e responsavel por buscar, criar e atualizar dados na plataforma ServiceNow.
*
* Principais Funcionalidades:
* - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisições, respectivamente, utilizando uma marca d'água para obter apenas os registros novos ou atualizados.
* - **Busca de Comentários (`fetchCommentsFromServiceNow`)**: Busca todos os comentários (journal entries) de um ticket, com lógica de paginação para lidar com grandes volumes de dados. Ele também filtra os comentários gerados pelo próprio sistema de integração para evitar loops.
* - **Criação de Comentários**: `createCommentInServiceNow` e a função wrapper `syncCommentToServiceNow` adicionam comentários (work notes/comments) a um ticket existente no ServiceNow.
* - **Atualização de Status**: `updateStatusInServiceNow` modifica o estado de um ticket (ex: para 'Em Atendimento').
* - **Fechamento de Tickets (`closeTicketInServiceNow`)**: Altera o estado de um ticket para "Resolvido", preenchendo os campos obrigatórios como notas de fechamento e data de resolução.
* - **Adição de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, útil para registrar informações ou avisos.
* - **Busca de Variáveis de Catálogo (`fetchScItemOptionValue`)**: Função especializada para buscar valores de variáveis em itens de requisição (RITMs), usada para enriquecer a descrição de tickets.
* - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisicoes, respectivamente, utilizando uma marca d'agua para obter apenas os registros novos ou atualizados.
* - **Busca de Comentarios (`fetchCommentsFromServiceNow`)**: Busca todos os comentarios (journal entries) de um ticket, com logica de paginacao para lidar com grandes volumes de dados. Ele tambem filtra os comentarios gerados pelo proprio sistema de integracao para evitar loops.
* - **Criacao de Comentarios**: `createCommentInServiceNow` e a funcao wrapper `syncCommentToServiceNow` adicionam comentarios (work notes/comments) a um ticket existente no ServiceNow.
* - **Atualizacao de Status**: `updateStatusInServiceNow` modifica o estado de um ticket (ex: para 'Em Atendimento').
* - **Fechamento de Tickets (`closeTicketInServiceNow`)**: Altera o estado de um ticket para "Resolvido", preenchendo os campos obrigatorios como notas de fechamento e data de resolucao.
* - **Adicao de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, util para registrar informacoes ou avisos.
* - **Busca de Variaveis de Catalogo (`fetchScItemOptionValue`)**: Funcao especializada para buscar valores de variaveis em itens de requisicao (RITMs), usada para enriquecer a descricao de tickets.
*
* Todas as funções de interação com a API o envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuração.
* Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao.
*/