FIX/FEATURE: Ajustes de fluxo SN<->GLPI e documentação operacional

- feito ajuste do fluxo GLPI->SN para tratar status 5 (resolve no SN) e status 6 (aviso definitivo e retirada de monitoramento)
- feito ajuste de reabertura SN->GLPI quando ticket estiver solved no GLPI
- feito ajuste de data/hora de comments/worknotes para evitar inversão de data
- feito ajuste de vínculo Ticket Externo no SN com validação de persistência e retry idempotente
- feito ajuste de filtro de journals para ignorar SOTHIS.CAOA e admin.caoa por configuração
- feito inclusão de monitor dedicado de encerramento definitivo (processGlpiClosureController)
- feito atualização de flags de controle (ENABLE_STATUS_SYNC, ENABLE_GLPI_CLOSE_CRON, ENABLE_GLPI_WEBHOOK)
- feito atualização da documentação (README, docs/regrasdenegocio.md, docs/fluxo.md, docs/casosdeuso.md, docs/checklist.md)
This commit is contained in:
Rafael Alves Lopes 2026-03-04 16:37:04 -03:00
parent a7f9f7f714
commit 35b4a1fef9
14 changed files with 864 additions and 267 deletions

View File

@ -12,6 +12,9 @@ PORT=3000
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *'
ENABLE_STATUS_SYNC=false
ENABLE_GLPI_WEBHOOK=true
ENABLE_GLPI_CLOSE_CRON=true
# --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow
@ -26,12 +29,14 @@ SERVICENOW_ASSIGNMENT_GROUP=
SERVICENOW_DEFAULT_USER_SYSID=
SERVICENOW_DEFAULT_USER=
SERVICENOW_RESOLVED_BY_SYSID=
SERVICENOW_IGNORE_USERS_JOURNAL=SOTHIS.CAOA,admin.caoa
# Endpoints de tabela usados pela aplicacao
SERVICENOW_TABLE_INCIDENT_URL=
SERVICENOW_TABLE_REQUEST_URL=
SERVICENOW_TABLE_JOURNAL_URL=
SERVICENOW_SC_ITEM_OPTION_URL=
SERVICENOW_EXTERNAL_TICKET_FIELD=u_external_ticket
# --- Configuracao do Banco de Dados do GLPI (MySQL) ---
GLPI_DB_TYPE=mysql

271
README.md
View File

@ -1,251 +1,84 @@
# Sistema de Sincronizacao ServiceNow <> GLPI
# Sistema de Integracao ServiceNow <-> GLPI
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.
Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando PostgreSQL como banco intermediario e acesso ao banco do GLPI.
## O que o projeto faz hoje
## Estado atual
- 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
- No fluxo ServiceNow -> GLPI:
- `comments` viram comentarios (followups) no GLPI.
- `work_notes` viram tarefas no GLPI.
- 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
### Componentes
- `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.
### Bancos envolvidos
- PostgreSQL (`snglpi`): estado da integracao.
- MySQL/MariaDB (GLPI): criacao/consulta/atualizacao de tickets e followups.
1. Coleta de tickets SN por watermark.
2. Criacao/vinculo de tickets no GLPI.
3. Sincronizacao de comentarios e tasks em duas direcoes.
4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI.
5. Monitor GLPI para `status=5` (resolver SN) e `status=6` (encerrar definitivamente no SN).
6. Flags para desligar sincronizacao legada de status.
## Fluxo do ciclo
Executado em ordem pelo `main()`:
Executado pelo `main()`:
1. `processErrorController`
2. `processTicketsController`
3. `processCommentsController`
4. `processStatusAndClosureController`
4. `processGlpiClosureController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
5. `processStatusAndClosureController` apenas quando `ENABLE_STATUS_SYNC=true`
## Regras de negocio importantes
## Documentacao detalhada
- Controle de precedencia por `source_last` para reduzir conflito de atualizacao.
- Ao detectar divergencia de status, o bastao nao e invertido para `GLPI` quando a ultima origem valida ja e `SNOW`.
- Encerramento/Resolucao vindo do ServiceNow:
- Nao fecha ticket no GLPI automaticamente.
- Insere nota formatada no GLPI.
- Marca sync como `closed/closed` para remover do monitoramento.
- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (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).
1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md)
2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md)
3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md)
4. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md)
## Regras de negocio detalhadas
## Variaveis de ambiente importantes
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.
### Feature flags
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 = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`.
3. Status ServiceNow -> GLPI
- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
- Para `Resolvido`, `Encerrado` e `Encerrado - Omitido` no SN:
- GLPI recebe nota de encerramento/resolucao.
- Integracao remove ticket do monitoramento (`closed/closed`), sem fechar ticket no GLPI.
- 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.
- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI.
- Campo `author` no banco intermediario usa `sys_created_by` do SN.
- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho.
- 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
```bash
npm install
```
### Dependencias Python
```bash
cd src/scripts/python
pip install -r requirements.txt
```
## Configuracao de ambiente
Crie `.env.development` e `.env.production` com base em `.env.example` e inclua tambem as variaveis usadas no codigo:
1. `ENABLE_STATUS_SYNC`
- `true`: liga fluxo legado de status bidirecional
- `false`: desliga fluxo legado de status
2. `ENABLE_GLPI_CLOSE_CRON`
- `true`: liga monitor de fechamento definitivo GLPI=6
- `false`: desliga monitor de fechamento definitivo
### ServiceNow
- `SERVICENOW_USERNAME`
- `SERVICENOW_PASSWORD`
- `SERVICENOW_ASSIGNMENT_GROUP`
- `SERVICENOW_TABLE_INCIDENT_URL`
- `SERVICENOW_TABLE_REQUEST_URL`
- `SERVICENOW_TABLE_JOURNAL_URL`
- `SERVICENOW_SC_ITEM_OPTION_URL`
- `SERVICENOW_DEFAULT_USER`
- `SERVICENOW_RESOLVED_BY_SYSID`
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
1. `SERVICENOW_TABLE_INCIDENT_URL`
2. `SERVICENOW_TABLE_REQUEST_URL`
3. `SERVICENOW_TABLE_JOURNAL_URL`
4. `SERVICENOW_SC_ITEM_OPTION_URL`
5. `SERVICENOW_DEFAULT_USER`
6. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`
7. `SERVICENOW_EXTERNAL_TICKET_FIELD` (default: `u_external_ticket`)
### GLPI (MySQL/MariaDB)
### GLPI e banco intermediario
- `GLPI_DB_HOST`
- `GLPI_DB_PORT`
- `GLPI_DB_USER`
- `GLPI_DB_PASSWORD`
- `GLPI_DB_NAME`
- `GLPI_DB_CHARSET`
- `GLPI_DEFAULT_USER_ID`
- `GLPI_DEFAULT_GROUP_ID` (padrao `30`)
- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`)
- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`)
1. `GLPI_DB_*`
2. `SNGLPI_DB_*`
3. `GLPI_OOS_SOLUTION_TYPE_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
## Desenvolvimento
1. Instalar dependencias:
```bash
npm install
```
2. Criar env:
```bash
copy .env.example .env.development
```
3. Executar:
```bash
npm run dev
```
### Producao
```bash
npm run start:prod
```
## Execucao com PM2
Subir os dois processos (sync + mapeador):
## Producao
1. PM2:
```bash
pm2 start ecosystem.config.js --env production
```
Comandos uteis:
## Observacoes
```bash
pm2 list
pm2 logs sn-glpi-sync-cron
pm2 logs sn-glpi-location-mapper
pm2 restart sn-glpi-sync-cron
```
## Script de mapeamento de localidades
Arquivo: `src/scripts/python/update_location_mapping.py`
- 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).
## Como os dados chegam do ServiceNow
- Incidentes: consulta na tabela definida por `SERVICENOW_TABLE_INCIDENT_URL`, filtrando por `assignment_group` e `sys_updated_on >= watermark`.
- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro.
- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`).
- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone.
- Campos principais persistidos localmente:
- Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`.
- Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`.
## Estrutura do banco intermediario
Definicao base em `src/scripts/database/scriptBD.sql`:
- `tickets_sn`
- `ticket_sync`
- `ticket_updates`
- `location_mapping`
- `sync_control`
Arquivo auxiliar de manutencao/reset (com comandos destrutivos):
- `src/scripts/database/maintenance_reset.sql`
## Limitacoes conhecidas (estado atual)
- Nao ha suite de testes automatizados (`npm test` e placeholder).
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão)
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
## Proximas melhorias recomendadas
- 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).
- Alterar uso de banco do GLPI para a API do GLPI.
## Arquivos principais
- `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`
1. O projeto ainda nao tem suite automatizada robusta.
2. O rollout recomendado e por fases com feature flags.
3. Nao remover schema legado antes de estabilizar o fluxo novo.

153
docs/casosdeuso.md Normal file
View File

@ -0,0 +1,153 @@
# Casos de Uso (Formato RPG)
## Capitulo 1 - Abertura do chamado
No reino da CAOA, um usuario abre um chamado no ServiceNow.
O oraculo da integracao observa o novo registro e grava no banco intermediario.
Sem demora, o chamado atravessa o portal SN -> GLPI.
No GLPI, o ticket nasce com seu vinculo e recebe seu identificador.
De volta ao ServiceNow, o campo "Ticket Externo" e preenchido com o ID GLPI.
Resultado esperado:
1. Chamado criado no GLPI.
2. `ticket_sync` em `synced/synced`.
3. Ticket Externo preenchido no SN.
---
## Capitulo 2 - O chamado fora do escopo
Um atendente da Sothis analisa o chamado e percebe:
"Problema relacionado a Rede e nao Telefonia".
Ele registra essa justificativa na nota de solucao do GLPI (fora do escopo).
A integracao reconhece o tipo de solucao configurado para fora de escopo.
Entao envia a justificativa para o ServiceNow como nota.
Nao encerra o chamado no SN nesse passo de negocio de fora de escopo.
O chamado sai do monitoramento automatico da integracao.
Estado final esperado no banco intermediario:
1. `glpi_sync_status = 'closed'`
2. `sn_sync_status = 'ignored'`
Interpretacao operacional:
1. Chamado nao sera mais tratado pela fila automatica da integracao.
2. Evita retrabalho e loops de sincronizacao.
---
## Capitulo 3 - Encerramento definitivo
Quando um chamado chega ao encerramento definitivo no GLPI (`status=6`),
o cron de fechamento detecta o estado final e executa:
1. Publica no SN a mensagem:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
2. Nao altera o status do chamado no SN.
3. Marca `ticket_sync` como `closed/closed` para retirar do monitoramento.
Resultado esperado:
1. Nao fica chamado fechado no GLPI e aberto no SN.
2. Nao ocorre tentativa de reabertura automatica para GLPI `6`.
---
## Capitulo 4 - Comunicacao entre os reinos (comentarios)
No salao da CAOA (ServiceNow), o usuario escreve:
"Equipe Sothis, conseguimos reproduzir o erro as 14h10."
A mensagem atravessa o portal SN -> GLPI e aparece como followup.
Do outro lado, no castelo da Sothis (GLPI), o atendente responde:
"Recebido. Estamos validando o circuito."
A resposta volta pelo portal GLPI -> SN como comentario.
Os escribas da integracao registram origem e destino para evitar duplicidade.
Resultado esperado:
1. Comentario SN vira followup GLPI.
2. Followup GLPI vira comentario SN.
3. Sem mensagens duplicadas no vai-e-volta.
---
## Capitulo 5 - As tarefas e os segredos do reino
No reino da CAOA, toda `work_note` do SN e uma ordem de batalha.
Essas ordens sao enviadas ao GLPI como tarefas visiveis para o time tecnico.
No reino da Sothis, tarefas internas do GLPI sao consideradas secretas.
Elas nao atravessam o portal para o SN.
Somente followups/comentarios publicos participam da comunicacao entre reinos.
Resultado esperado:
1. Toda `work_note` SN vira task GLPI.
2. Task criada no GLPI nao volta para SN.
3. O SN recebe somente comentarios/followups do GLPI.
---
## Capitulo 6 - A solucao no reino Sothis (GLPI)
Quando o atendente Sothis conclui o trabalho tecnico no GLPI,
ele registra a solucao e coloca o chamado em **Solucionado** (`status=5`).
O cron da integracao observa esse estado e realiza:
1. Busca a nota de solucao do GLPI.
2. Resolve o chamado correspondente no SN.
3. Atualiza o estado local para `solved/solved`.
Se o chamado no SN estiver em estado que bloqueia encerramento
(como espera/aguardando), a integracao primeiro ajusta para em atendimento
e depois aplica a resolucao.
Resultado esperado:
1. GLPI solucionado reflete SN resolvido.
2. A nota de resolucao do GLPI vira base do fechamento no SN.
3. Chamado continua monitorado ate o encerramento definitivo.
---
## Capitulo 7 - A solucao vinda do reino CAOA (SN)
Quando a CAOA marca o chamado como resolvido no SN,
a integracao **nao fecha o chamado no GLPI automaticamente**.
Em vez disso:
1. Uma nota informativa e adicionada no GLPI.
2. O chamado sai do monitoramento automatico de status.
Essa regra evita conflito de fluxo e protege o time tecnico de mudancas
de estado inesperadas do outro reino.
Resultado esperado:
1. GLPI recebe contexto da resolucao do SN.
2. Nao ocorre fechamento forcado no GLPI por esse evento.
3. Fluxo fica estavel para evitar reaberturas em cadeia.
---
## Capitulo 8 - Regras de ouro para os guardioes dos dois reinos
1. Comentario e diplomacia entre reinos: sempre pode cruzar o portal.
2. Task do SN e ordem operacional para Sothis: cruza para o GLPI.
3. Task interna do GLPI e sigilo tecnico: nao cruza para SN.
4. GLPI `5` resolve SN.
5. GLPI `6` encerra monitoramento e publica aviso definitivo no SN.
6. Reabertura automatica:
- permitida se GLPI estiver `5`
- bloqueada se GLPI estiver `6`
7. Fora de escopo:
- justifica no SN
- remove do monitoramento
- estado final `closed/ignored`.

56
docs/fluxo.md Normal file
View File

@ -0,0 +1,56 @@
# Fluxo Operacional
## 1. Fluxo do ciclo (`main`)
1. `processErrorController`
2. `processTicketsController`
3. `processCommentsController`
4. `processGlpiClosureController` (se `ENABLE_GLPI_CLOSE_CRON=true`)
5. `processStatusAndClosureController` apenas se `ENABLE_STATUS_SYNC=true`
## 2. Fluxo SN -> GLPI
1. Coleta incidentes/requisicoes no SN por watermark.
2. UPSERT em `tickets_sn`.
3. Cria/atualiza `ticket_sync` com estados iniciais.
4. Cria ticket no GLPI quando `pending_check`.
5. Grava `glpi_ticket_id` em `ticket_sync`.
6. Atualiza campo "Ticket Externo" no SN com ID GLPI.
7. Se falhar atualizar "Ticket Externo", agenda retry em `ticket_updates`.
## 3. Fluxo de comentarios/tasks
### 3.1 SN -> GLPI
1. Busca `comments` e `work_notes` no SN.
2. Persiste em `ticket_updates` (idempotente por `source_id`).
3. Envia:
- `comment` -> followup GLPI
- `task` -> task GLPI
### 3.2 GLPI -> SN
1. Busca followups GLPI.
2. Sanitiza conteudo HTML/imagens.
3. Envia comentario para SN evitando duplicidade.
4. Atualiza `destiny_id` para rastrear espelhamento.
## 4. Resolucao e fechamento GLPI -> SN (monitor cron)
1. Monitor consulta tickets ativos no `ticket_sync`.
2. Busca status real no GLPI.
3. Se status GLPI = `5`:
- busca nota de solucao
- se SN estiver em espera, move para em atendimento
- resolve SN
- marca `ticket_sync` como `solved/solved`
4. Se status GLPI = `6`:
- adiciona comentario no SN:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
- nao altera status do chamado no SN
- marca `ticket_sync` como `closed/closed` para retirar do monitoramento
## 5. Reabertura
1. Reabertura automatica permitida apenas para tickets em GLPI `5`.
2. Se GLPI `6`, reabertura automatica bloqueada.

211
docs/regrasdenegocio.md Normal file
View File

@ -0,0 +1,211 @@
# Regras de Negocio Completas
## 1. Escopo funcional da integracao
1. A origem oficial de novos chamados e o ServiceNow (SN).
2. O GLPI recebe chamados criados no SN e passa a participar do atendimento.
3. A integracao cobre:
- criacao/vinculo de chamados SN -> GLPI
- sincronizacao de comentarios (SN <-> GLPI)
- sincronizacao de tasks (SN -> GLPI via `work_notes`)
- fechamento definitivo GLPI -> SN (status GLPI = 6)
4. Sincronizacao legada bidirecional de status existe, mas deve ficar desligada no modelo novo (por flag).
## 2. Governanca e responsabilidade entre sistemas
1. SN e a origem da demanda.
2. GLPI e o sistema tecnico de atendimento e encerramento final.
3. Banco intermediario (PostgreSQL) e a fonte de controle operacional da integracao.
## 3. Orquestracao do ciclo
1. O ciclo roda por `cron`.
2. Ordem de execucao:
- `processErrorController`
- `processTicketsController`
- `processCommentsController`
- `processGlpiClosureController` (se habilitado)
- `processStatusAndClosureController` (somente legado, se habilitado)
3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo.
## 4. Feature flags e comportamento
1. `ENABLE_STATUS_SYNC`
- `true`: executa sincronizacao legada de status bidirecional.
- `false`: desliga fluxo legado de status.
2. `ENABLE_GLPI_CLOSE_CRON`
- `true`: executa monitor de fechamento definitivo GLPI=6.
- `false`: desliga monitor.
3. `ENABLE_GLPI_WEBHOOK`
- reservado para rollout por evento (planejado), sem obrigatoriedade no fluxo atual.
## 5. Regras de coleta de tickets no ServiceNow
1. Busca incidentes e requisicoes por `assignment_group` e `sys_updated_on >= watermark`.
2. Watermark vem de `sync_control`.
3. Novo watermark usa maior `sys_updated_on` encontrado no ciclo.
4. Watermark salvo aplica margem de seguranca de 6 horas para tras.
5. Se nao houver tickets novos/atualizados, watermark nao e alterado.
## 6. Regras de persistencia local (`tickets_sn`)
1. UPSERT por `ticket_number`.
2. `sys_id` e unico.
3. Campos de incidente e requisicao tem mapeamento diferente.
4. Para requisicao, pode haver enriquecimento por variaveis de catalogo:
- justificativa
- telefone
5. `updated_at` e atualizado automaticamente (trigger no banco).
## 7. Regras de criacao de estado de sincronizacao (`ticket_sync`)
1. Novo ticket entra com `sn_ticket_id` vinculado.
2. Se status SN vier final (`Encerrado` ou `Encerrado - Omitido`):
- `sn_sync_status = closed`
- `glpi_sync_status = ignored`
3. Se status SN vier aberto:
- `sn_sync_status = collected`
- `glpi_sync_status = pending_check`
4. `source_last` existe no schema e legado, mas deve ser deprecado para decisao de status no modelo novo.
## 8. Regras de criacao/vinculo no GLPI
1. Apenas tickets com `glpi_sync_status = pending_check` entram na criacao/vinculo.
2. Se `glpi_ticket_id` ja existir no banco intermediario:
- manter `synced/synced`
3. Se nao houver `glpi_ticket_id` local:
- procurar ticket no GLPI por numero SN no titulo/conteudo
4. Se encontrar ticket no GLPI:
- vincular `glpi_ticket_id`
- se status GLPI=6, marcar `ignored/closed` e nao monitorar
- caso contrario, marcar `synced/synced`
5. Se nao encontrar:
- criar ticket no GLPI
- vincular e marcar `synced/synced`
6. Erro de criacao:
- marcar estado de erro para reprocessamento.
## 9. Regras de formatacao ao criar ticket no GLPI
1. Titulo segue padrao: tipo + entidade + short_description.
2. Entidade vem de `location_mapping`; fallback para entidade padrao.
3. Descricao e montada em HTML com dados de solicitante.
4. Requisicao sem justificativa/descricao pode usar valores default configurados.
5. Prioridade, categoria e SLA usam mapeamentos predefinidos.
## 10. Regra do campo "Ticket Externo" no SN
1. Ao criar/vincular ticket no GLPI, escrever ID GLPI no SN.
2. Campo usado e configuravel por `SERVICENOW_EXTERNAL_TICKET_FIELD` (default `u_external_ticket`).
3. Se a escrita falhar, nao recriar ticket no GLPI.
4. Falha gera pendencia de retry idempotente em `ticket_updates` (`update_type=external_link`).
5. Retry processa pendencias com `destiny_id IS NULL`.
6. Ao sucesso, `destiny_id` da pendencia vira `done`.
## 11. Regras de sincronizacao de comentarios SN -> GLPI
1. Busca journal entries `comments` e `work_notes` do SN.
2. Ordena cronologicamente antes de enviar.
3. Idempotencia por `ticket_updates.source_id` (sys_id do journal).
4. `comments` viram followup no GLPI.
5. `work_notes` viram task no GLPI.
6. Conteudo enviado para GLPI e formatado com card visual e autor.
7. Em falha de envio, registra estado de erro para novo ciclo.
## 12. Regras de sincronizacao de comentarios GLPI -> SN
1. Busca followups do GLPI ignorando usuario padrao tecnico da integracao.
2. Sanitiza HTML, metadados e imagens.
3. Evita duplicidade com verificacao local e remota.
4. Atualiza mapeamento origem/destino em `ticket_updates.destiny_id`.
5. Ao final, evita deixar ticket preso em estado transitorio de comentario.
## 13. Regras de filtro de usuario padrao no SN (ambiente de teste)
1. `SERVICENOW_DEFAULT_USER` define usuario da integracao.
2. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`:
- `true` (padrao): ignora journals desse usuario para evitar loop.
- `false`: nao ignora (util para dev com usuario unico).
## 14. Regras de fechamento e resolucao
### 14.1 Fluxo novo (prioritario)
1. Fechamento definitivo GLPI `status=6` e refletido no SN via monitor cron.
2. Ao detectar GLPI=6:
- enviar comentario no SN:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
- nao alterar status do chamado no SN
- marcar `ticket_sync` como `closed/closed` para retirar do monitoramento
3. Aviso de encerramento definitivo no SN e idempotente por `source_id` em `ticket_updates`.
### 14.2 Fluxo legado de status (quando habilitado)
1. Pode propagar estado entre SN e GLPI.
2. Usa regras de precedencia historicas com `source_last`.
3. Deve ser considerado transitorio e desligavel por flag.
## 15. Regras de fora do escopo (Sothis)
1. Quando solucao no GLPI usa `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`:
- envia justificativa ao SN em nota
- nao segue fluxo normal de atendimento
- estado final esperado no intermediario:
- `glpi_sync_status = closed`
- `sn_sync_status = ignored`
2. Chamado sai do monitoramento automatico.
## 16. Regras de estados monitorados vs terminais
### 16.1 Monitorados
1. `pending_check`
2. `synced`
3. `solved`
4. `error` e variantes de erro para reprocesso
### 16.2 Terminais
1. `closed`
2. `ignored`
Regra geral: `closed` e `ignored` nao participam das rotinas normais de sincronizacao.
## 17. Regra de reabertura
1. Reabertura automatica permitida apenas quando GLPI estiver em `5` (solved).
2. Se GLPI estiver em `6` (fechado definitivo), reabertura automatica nao e permitida.
## 18. Regras de idempotencia e rastreabilidade
1. `ticket_updates.source_id` e unico.
2. Toda sincronizacao relevante registra origem e destino quando possivel.
3. `ticket_sync.last_sn_sync` e `last_glpi_sync` registram ultima interacao por sistema.
## 19. Regras de erro e reprocessamento
1. Inicio de ciclo executa reset de estados de erro processaveis.
2. Erro em etapa nao deve causar duplicidade de criacao.
3. Tickets em erro voltam para fila valida de processamento conforme tipo do erro.
## 20. Regras de monitoramento operacional
1. Logs devem permitir rastrear:
- criacao de ticket
- sincronizacao de comentario/task
- fechamento definitivo
- falhas e retries
2. Rollout deve ser faseado por flag e com possibilidade de rollback.
## 21. Regras de schema (situacao atual)
1. Nao remover colunas legadas neste momento.
2. Nao criar tabela nova para eventos nesta fase.
3. Reaproveitar `ticket_updates` para pendencias e idempotencia.
## 22. Criterios de sucesso de negocio
1. Ticket fechado definitivamente no GLPI nao permanece aberto no SN.
2. Comentarios e tasks nao duplicam.
3. Tickets fora do escopo saem de monitoramento com estado final correto.
4. Integracao nao entra em loop por status intermediario.

View File

@ -2,50 +2,53 @@ const path = require('path');
const dotenv = require('dotenv');
const nodeEnv = process.env.NODE_ENV;
let envFile = '.env.development';
if (nodeEnv === 'production') {envFile = '.env.production';} else {envFile = '.env.development';}
if (nodeEnv === 'production') {
envFile = '.env.production';
} else {
envFile = '.env.development';
}
dotenv.config({ path: path.resolve(__dirname, '..', envFile) });
const { processTicketsController } = require('./controllers/processTicketsController');
const { processCommentsController } = require('./controllers/processCommentsController');
const { processErrorController } = require('./controllers/processErrorController');
const { processStatusAndClosureController } = require('./controllers/processStatusController');
const { logInfo, logger } = require('./utils/logger');
const { processGlpiClosureController } = require('./controllers/processGlpiClosureController');
const { logInfo } = require('./utils/logger');
logInfo(`🌎 Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
logInfo('Aplicação iniciada', {
logInfo('Aplicacao iniciada', {
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
async function main() {
try {
const statusSyncEnabled = process.env.ENABLE_STATUS_SYNC === 'true';
const glpiCloseCronEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
await processErrorController();
await processTicketsController();
await processCommentsController();
if (statusSyncEnabled) {
await processStatusAndClosureController();
logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉');
} else {
logInfo('Sincronizacao legada de status desabilitada (ENABLE_STATUS_SYNC=false).');
}
if (glpiCloseCronEnabled) {
await processGlpiClosureController();
} else {
logInfo('Monitor de encerramento definitivo GLPI desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
}
logInfo('Ciclo de sincronizacao concluido com sucesso.');
} catch (error) {
logInfo('Erro na aplicação:', error);
logInfo('Erro na aplicacao:', error);
}
}
// A chamada main() foi removida daqui.
// Agora, o app.js apenas define e exporta a função main.
// A responsabilidade de EXECUTAR a função main é do cron.js,
// que a importará e a chamará dentro do agendamento.
module.exports = { main };
/**
* @file app.js
* @description
* Ponto de entrada principal da aplicação de sincronização. Este arquivo é responsável por:
* 1. Carregar as variáveis de ambiente corretas (`.env.development` ou `.env.production`) com base na variável `NODE_ENV`.
* 2. Orquestrar a execução sequencial dos controladores que compõem o ciclo de sincronização.
*
* O fluxo de execução é o seguinte:
* 1. `processErrorController`: Verifica e reseta tickets que falharam em execuções anteriores, permitindo que sejam reprocessados.
* 2. `processTicketsController`: Busca novos tickets (incidentes e requisições) do ServiceNow, os salva no banco de dados intermediário e cria os tickets correspondentes no GLPI.
* 3. `processCommentsController`: Realiza a sincronização bidirecional de comentários (notas/follow-ups) para os tickets que estão sincronizados.
* 4. `processStatusAndClosureController`: Executa a sincronização bidirecional de status (aberto, em atendimento, resolvido, fechado) e gerencia o fechamento de tickets em ambas as plataformas.
*/

View File

@ -1,30 +1,50 @@
const {fetchCommentsFromServiceNow } = require('../services/servicenowService');
const { fetchCommentsFromServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService');
const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService');
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logInfo } = require('../utils/logger');
const ensureReopenFromSNWhenGlpiSolved = async (ticket) => {
if (ticket.glpi_sync_status !== 'solved') {
return;
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
return;
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const snIsFinal = liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido';
if (snIsFinal) {
return;
}
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === 5) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento');
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced');
logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`);
}
};
const processCommentsController = async () => {
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
logInfo(`📋Tickets em monitoramento: ${ticketsToMonitor.length}`);
logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
for (const ticket of ticketsToMonitor) {
await ensureReopenFromSNWhenGlpiSolved(ticket);
for (const ticket of ticketsToMonitor){
// Sincroniza comentários do ServiceNow para o GLPI
const comments = await fetchCommentsFromServiceNow(ticket);
// Se houver comentários novos no ServiceNow, sincroniza para o GLPI
if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);}
// Sincroniza comentários do GLPI para o ServiceNow
if (comments.length !== 0) {
await syncCommentsSNtoGlpi(comments, ticket);
}
await syncCommentsGlpitoSN(ticket);
}
}
};
module.exports = {
processCommentsController
}
/**
* @file processCommentsController.js
* @description Este controlador orquestra a sincronização bidirecional de comentários para todos os tickets ativos.
* Ele busca tickets que estão sendo monitorados e, para cada um, executa a sincronização de comentários do ServiceNow para o GLPI e vice-versa.
*/
};

View File

@ -0,0 +1,93 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSnModel = require('../models/ticketSnModel');
const { addCommentToServiceNow, addWorkNoteToServiceNow, updateStatusInServiceNow, fetchTicketLiveStatusFromServiceNow, closeTicketInServiceNow } = require('../services/servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const { logInfo, logError } = require('../utils/logger');
const DEFINITIVE_CLOSE_NOTICE = 'Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado.';
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const processGlpiClosureController = async () => {
try {
const tickets = await TicketSyncModel.getTicketsForClosureMonitor();
if (!tickets.length) {
logInfo('Nenhum ticket ativo para monitoramento de fechamento definitivo no GLPI.');
return;
}
for (const ticket of tickets) {
try {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus !== 5 && glpiStatus !== 6) {
continue;
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
continue;
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
if (glpiStatus === 5) {
if (ticket.sn_sync_status === 'solved') {
continue;
}
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como fora do escopo.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket nao sera mais exibido para equipe Sothis, com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
continue;
}
if (liveSnStatus === 'Em Espera' || liveSnStatus === 'Aguardando Atendimento') {
await updateStatusInServiceNow(ticket.sn_ticket_id, 'Em Atendimento');
}
const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integracao GLPI.';
const resolvedAt = solution?.date_mod || new Date();
await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} solucionado e refletido no SN.`);
continue;
}
const sourceId = `glpi_closed_notice:${ticket.glpi_ticket_id}`;
const alreadyNotified = await TicketUpdateModel.getBySourceId(sourceId);
if (!alreadyNotified) {
const commentSent = await addCommentToServiceNow(ticket.sn_ticket_id, DEFINITIVE_CLOSE_NOTICE);
if (commentSent) {
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
update_type: 'close_notice',
source_system: 'glpi',
content: DEFINITIVE_CLOSE_NOTICE,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: 'done'
});
}
}
// Regra nova: nao altera status no SN ao fechar definitivamente no GLPI.
// Apenas registra comentario e remove do monitoramento local.
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'closed');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} encerrado definitivamente e refletido no SN.`);
} catch (error) {
logError(error, `Falha ao processar fechamento definitivo GLPI para ticket ${ticket.glpi_ticket_id}`);
}
}
} catch (error) {
logError(error, 'Erro geral no monitoramento de fechamento definitivo GLPI');
}
};
module.exports = {
processGlpiClosureController
};

View File

@ -1,10 +1,12 @@
const { syncTicketsToGlpi } = require('../services/glpiTicketService');
const { processSyncController } = require('./processSyncController');
const { processPendingExternalTicketLinks } = require('../services/servicenowService');
const processTicketsController = async () => {
await processSyncController();
await syncTicketsToGlpi();
await processPendingExternalTicketLinks();
}
module.exports = {

View File

@ -112,6 +112,7 @@ class TicketSnModel {
try {
const query = `
SELECT
tsync.id AS ticket_sync_id,
ts.id,
ts.ticket_number,
ts.short_description,

View File

@ -183,6 +183,29 @@ class TicketSyncModel {
}
}
static async getTicketsForClosureMonitor() {
try {
const query = `
SELECT
tsync.id,
tsync.glpi_ticket_id,
tsync.sn_ticket_id,
tsync.source_last,
tsync.sn_sync_status,
tsync.glpi_sync_status
FROM ticket_sync tsync
WHERE tsync.glpi_ticket_id IS NOT NULL
AND tsync.glpi_sync_status NOT IN ('closed', 'ignored')
AND tsync.sn_sync_status NOT IN ('closed', 'ignored')
`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, 'ERRO: Falha ao buscar tickets para monitoramento de fechamento GLPI');
throw error;
}
}
static async getTicketsPendingSolve() {
try {
const query = `

View File

@ -101,6 +101,23 @@ class TicketUpdateModel {
throw error;
}
}
static async getPendingBySourcePrefix(sourcePrefix) {
try {
const query = `
SELECT *
FROM ticket_updates
WHERE source_id LIKE $1
AND destiny_id IS NULL
ORDER BY created_at ASC NULLS FIRST, id ASC
`;
const { rows } = await pool.query(query, [`${sourcePrefix}%`]);
return rows;
} catch (error) {
logError(error, `Erro ao buscar updates pendentes por prefixo: ${sourcePrefix}`);
throw error;
}
}
}
module.exports = TicketUpdateModel;

View File

@ -3,7 +3,7 @@ const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError } = require('../utils/logger');
const { addWorkNoteToServiceNow } = require('./servicenowService');
const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry } = require('./servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
@ -40,6 +40,8 @@ const processSingleTicket = async (ticket) => {
if (ticket.glpi_ticket_id) {
logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`);
await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced');
const linkedOk = await updateExternalTicketInServiceNow(ticket.id, ticket.glpi_ticket_id);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, ticket.glpi_ticket_id, linkedOk);
return;
}
@ -56,6 +58,8 @@ const processSingleTicket = async (ticket) => {
return;
}
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
const existingLinkOk = await updateExternalTicketInServiceNow(ticket.id, existingTicket);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, existingTicket, existingLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
return;
}
@ -80,6 +84,8 @@ const processSingleTicket = async (ticket) => {
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId);
const externalLinkOk = await updateExternalTicketInServiceNow(ticket.id, glpiTicketId);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, glpiTicketId, externalLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`);

View File

@ -10,15 +10,37 @@ const TicketSyncModel = require('../models/ticketSyncModel');
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
const externalTicketField = process.env.SERVICENOW_EXTERNAL_TICKET_FIELD || 'u_external_tickets';
const ignoredJournalUsers = (
process.env.SERVICENOW_IGNORE_USERS_JOURNAL ||
`${servicenowDefaultUser},admin.caoa`
)
.split(',')
.map((u) => String(u || '').trim().toLowerCase())
.filter(Boolean);
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]}`;
// Formato ISO/local: YYYY-MM-DD HH:mm:ss
const ymd = asString.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
if (ymd) {
return `${ymd[1]}-${ymd[2]}-${ymd[3]} ${ymd[4]}:${ymd[5]}:${ymd[6]}`;
}
// Formato SN comum com barra: DD/MM/YYYY HH:mm:ss
const dmy = asString.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
if (dmy) {
return `${dmy[3]}-${dmy[2]}-${dmy[1]} ${dmy[4]}:${dmy[5]}:${dmy[6]}`;
}
const parsed = new Date(asString);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 19).replace('T', ' ');
// Fallback preservando timestamp local sem converter para UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
};
const fetchTicketsFromServiceNow = async (watermark) => {
@ -265,11 +287,13 @@ const fetchCommentsFromServiceNow = async (ticket) => {
if (!shouldIgnoreDefaultUserJournal) {
return true;
}
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
const ignoredUser = ignoredJournalUsers.includes(createdBy);
if (entry.element === 'comments') {
return entry.sys_created_by !== servicenowDefaultUser;
return !ignoredUser;
}
if (entry.element === 'work_notes') {
return entry.sys_created_by !== servicenowDefaultUser;
return !ignoredUser;
}
return true;
});
@ -548,6 +572,152 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
}
};
const addCommentToServiceNow = async (snTicketId, comment) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar comentario.`, 'addCommentToServiceNow');
return null;
}
sysId = ticketSn.sys_id;
const ticketType = ticketSn.tipo;
let url = '';
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { comments: comment };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao adicionar comentario no SN. Status: ${response.status}`, { snTicketId, sysId });
return null;
}
logInfo(`OK: Comentario adicionado com sucesso ao ticket SN ${sysId}.`);
return true;
} catch (error) {
logError(error, `ERRO: Falha ao adicionar comentario ao ticket SN (sysId: ${sysId})`);
return null;
}
};
const updateExternalTicketInServiceNow = async (snTicketId, glpiTicketId) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizar Ticket Externo.`, 'updateExternalTicketInServiceNow');
return false;
}
sysId = ticketSn.sys_id;
const ticketType = ticketSn.tipo;
let url = '';
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { [externalTicketField]: String(glpiTicketId) };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao atualizar Ticket Externo no SN. Status: ${response.status}`, { snTicketId, glpiTicketId, externalTicketField });
return false;
}
// Validacao de leitura: alguns campos customizados podem retornar 200 e nao persistir por ACL/schema.
const verifyResp = await axios.get(`${url}?sysparm_fields=${externalTicketField}&sysparm_display_value=true`, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Accept': 'application/json' }
});
const result = verifyResp?.data?.result || {};
const rawField = result[externalTicketField];
const persistedValue = typeof rawField === 'object'
? (rawField.value || rawField.display_value || '')
: (rawField || '');
if (String(persistedValue) !== String(glpiTicketId)) {
logError(`Ticket Externo nao persistiu no SN (campo ${externalTicketField}).`, {
snTicketId,
sysId,
expected: String(glpiTicketId),
got: String(persistedValue)
});
return false;
}
logInfo(`OK: Ticket Externo confirmado no SN (${externalTicketField}=${glpiTicketId}).`, { snTicketId, sysId });
return true;
} catch (error) {
logError(error, `ERRO: Falha ao atualizar Ticket Externo no SN (sysId: ${sysId})`);
return false;
}
};
const queueExternalTicketLinkRetry = async (ticketSyncId, snTicketId, glpiTicketId, shouldMarkDone = false) => {
const sourceId = `external_link:${snTicketId}:${glpiTicketId}`;
const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField });
await TicketUpdateModel.insert({
ticket_sync_id: ticketSyncId,
update_type: 'external_link',
source_system: 'sync',
content: payload,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: shouldMarkDone ? 'done' : null
});
};
const processPendingExternalTicketLinks = async () => {
try {
const pendings = await TicketUpdateModel.getPendingBySourcePrefix('external_link:');
if (!pendings.length) return;
for (const pending of pendings) {
try {
const payload = JSON.parse(pending.content || '{}');
const snTicketId = payload.sn_ticket_id;
const glpiTicketId = payload.glpi_ticket_id;
if (!snTicketId || !glpiTicketId) {
logError(`Payload invalido para retry de Ticket Externo. update_id=${pending.id}`);
continue;
}
const ok = await updateExternalTicketInServiceNow(snTicketId, glpiTicketId);
if (ok) {
await TicketUpdateModel.updateDestinyId(pending.source_id, 'done');
}
} catch (error) {
logError(error, `Falha ao processar retry de Ticket Externo. update_id=${pending.id}`);
}
}
} catch (error) {
logError(error, 'ERRO: Falha geral no retry de Ticket Externo');
}
};
/**
* 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).
@ -602,7 +772,11 @@ module.exports = {
closeTicketInServiceNow,
updateStatusInServiceNow,
addWorkNoteToServiceNow,
fetchScItemOptionValue
addCommentToServiceNow,
fetchScItemOptionValue,
updateExternalTicketInServiceNow,
queueExternalTicketLinkRetry,
processPendingExternalTicketLinks
};
/**