FEAT: Motor de regras único de status/fechamento + mandante configurável

Consolida os dois controladores legados que decidiam status/fechamento de
forma concorrente (processGlpiClosureController + processStatusController)
em um único processTicketLifecycleController, eliminando a dependência do
campo source_last (mantido só como rastro de auditoria) e a flag
ENABLE_STATUS_SYNC (removida). Isso corrige o bug de reabertura: chamados
reabertos no SN voltavam a ser fechados no GLPI porque a regra de
precedência de finalização do fluxo legado forçava o status do GLPI de
volta pro SN.

Correções de bugs encontrados em teste (Frente 1):
- Constraint UNIQUE em ticket_sync.sn_ticket_id + ON CONFLICT DO NOTHING
  na criação do registro, prevenindo duplicação de tickets.
- Fechamento no SN agora é confirmado por leitura ao vivo do status antes
  de marcar o ticket como solved/closed, em vez de confiar só no HTTP 200
  (a API do SN pode retornar sucesso e silenciosamente ignorar um valor de
  estado inválido — descoberto e corrigido também no mandante).
- Nota de encerramento do SN pro GLPI agora é idempotente e criada uma
  única vez por ticket.
- Erros de sincronização passam a ter um limite de tentativas
  (MAX_ERROR_RETRIES) antes de marcar o ticket como error_permanent,
  exigindo intervenção manual em vez de tentar para sempre.
- Chamados criados no GLPI passam a gravar o número do ticket do SN no
  campo externalid.

Nova funcionalidade (Frente 2): quando o SN resolve/encerra um chamado por
conta própria antes do GLPI, além da nota informativa no GLPI (agora com o
nome de quem encerrou, buscado ao vivo via resolved_by/closed_by/
sys_updated_by), o próprio SN recebe um aviso de que a Sothis não vai mais
atender aquele chamado e ele é transferido pra fila de TI da CAOA.

Nova funcionalidade (Mandante, regras 19-22): espelhamento opcional e
configurável de status intermediário (Aguardando Atendimento/Em
Atendimento/Em Espera) entre os dois sistemas, controlado pela env
MANDANTE=GLPI|SNOW. Sem essa env configurada, mantém o comportamento atual
(nenhum espelhamento de status intermediário). Nunca afeta os status finais
(resolvido/encerrado/fora de escopo), que continuam regidos pelas regras
fixas de fechamento.

Corrigido também o workflow de deploy (.gitea/workflows/deploy-production.yml),
que ainda validava os controladores antigos (já removidos) e exigia
ENABLE_STATUS_SYNC=true, o que quebraria o próximo deploy em produção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafael Alves Lopes 2026-07-10 09:45:34 -03:00
parent ed19cd76a7
commit bce9a52494
18 changed files with 601 additions and 502 deletions

View File

@ -12,10 +12,19 @@ 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
# Mandante (regras 19-22): sistema cujo status intermediario (Aguardando Atendimento/Em
# Atendimento/Em Espera) e espelhado automaticamente no outro sistema. Nunca afeta status
# finais (Resolvido/Encerrado/fora de escopo), so esses tres intermediarios.
# Valores: GLPI | SNOW | vazio (nenhum espelhamento, comportamento padrao)
MANDANTE=
# Numero maximo de vezes que um ticket em estado de erro e reprocessado
# automaticamente antes de ser marcado como 'error_permanent' (exige intervencao manual).
MAX_ERROR_RETRIES=5
# --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow
SERVICENOW_INSTANCE=https://your-instance.service-now.com

View File

@ -0,0 +1,93 @@
name: Deploy production
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
name: Deploy to production VM
runs-on: vm-prod
env:
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight
shell: bash
run: |
set -euo pipefail
echo "Runner: $(hostname)"
node --version
npm --version
pm2 --version
rsync --version | head -n 1
- name: Validate JavaScript
shell: bash
run: |
set -euo pipefail
node --check cron.js
node --check src/app.js
node --check src/controllers/processTicketLifecycleController.js
node --check src/services/servicenowService.js
node --check src/services/glpiTicketService.js
- name: Deploy files and restart PM2
shell: bash
run: |
set -euo pipefail
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
LOCK_FILE="/tmp/sn-glpi-sync-new.deploy.lock"
mkdir -p "$DEPLOY_PATH"
(
flock -n 9 || {
echo "Another deploy is already running."
exit 1
}
rsync -az --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.env*' \
--exclude='node_modules/' \
--exclude='logs/' \
./ "$DEPLOY_PATH"/
cd "$DEPLOY_PATH"
test -f .env.production || {
echo "Missing $DEPLOY_PATH/.env.production"
exit 1
}
grep -q '^SERVICENOW_CAOA_TI_GROUP_ID=.\+' .env.production || {
echo "SERVICENOW_CAOA_TI_GROUP_ID must be set in $DEPLOY_PATH/.env.production"
exit 1
}
npm ci --omit=dev
node --check cron.js
node --check src/app.js
node --check src/controllers/processTicketLifecycleController.js
node --check src/services/servicenowService.js
node --check src/services/glpiTicketService.js
pm2 startOrRestart ecosystem.config.js --env production
pm2 save
pm2 list
) 9>"$LOCK_FILE"
- name: Show sync logs
if: always()
shell: bash
run: |
pm2 logs sn-glpi-sync-cron --nostream --lines 80 || true

View File

@ -5,11 +5,13 @@ Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando Postgr
## Estado atual
1. Coleta de tickets SN por watermark.
2. Criacao/vinculo de tickets no GLPI.
2. Criacao/vinculo de tickets no GLPI (grava `external_id` no GLPI com o numero do ticket SN).
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.
5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura:
`status=5` (resolver SN), `status=6` (encerrar definitivamente no SN), nota no GLPI quando o SN
resolve/encerra por conta propria (com aviso e troca de fila no SN), reabertura automatica e
regra de fora de escopo.
## Fluxo do ciclo
@ -17,9 +19,8 @@ Executado pelo `main()`:
1. `processErrorController`
2. `processTicketsController`
3. `processCommentsController`
4. `processGlpiClosureController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
5. `processStatusAndClosureController` apenas quando `ENABLE_STATUS_SYNC=true`
3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
4. `processCommentsController`
## Documentacao detalhada
@ -32,12 +33,9 @@ Executado pelo `main()`:
### Feature flags
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
1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
- `false`: desliga o motor inteiro
### ServiceNow
@ -92,7 +90,7 @@ pm2 restart sn-glpi-sync-cron --update-env
O workflow `.gitea/workflows/deploy-production.yml` roda no runner com rotulo `vm-prod`.
1. Configure a variavel do repositorio `PROD_DEPLOY_PATH` com o caminho da aplicacao na VM de producao. Se nao configurar, o workflow usa `/opt/sn-glpi-sync-new`.
2. Garanta que o arquivo `.env.production` exista nesse caminho na VM e contenha `ENABLE_STATUS_SYNC=true`.
2. Garanta que o arquivo `.env.production` exista nesse caminho na VM e contenha `SERVICENOW_CAOA_TI_GROUP_ID` preenchido.
3. Faça push na branch `master` ou execute o workflow manualmente em Actions.
4. Acompanhe pelo Gitea Actions ou na VM:

View File

@ -4,9 +4,9 @@
1. `processErrorController`
2. `processTicketsController`
3. `processCommentsController`
4. `processGlpiClosureController` (se `ENABLE_GLPI_CLOSE_CRON=true`)
5. `processStatusAndClosureController` apenas se `ENABLE_STATUS_SYNC=true`
3. `processTicketLifecycleController` (se `ENABLE_GLPI_CLOSE_CRON=true`) — motor de regras unico
pra status/fechamento/reabertura, ver secao 4.
4. `processCommentsController`
## 2. Fluxo SN -> GLPI
@ -35,22 +35,55 @@
3. Envia comentario para SN evitando duplicidade.
4. Atualiza `destiny_id` para rastrear espelhamento.
## 4. Resolucao e fechamento GLPI -> SN (monitor cron)
## 4. Motor de regras unico (`processTicketLifecycleController`)
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."
Substitui os antigos `processGlpiClosureController` + `processStatusController`. Nao ha mais dois
controladores decidindo o mesmo ticket, e nenhuma decisao depende de `source_last` (campo mantido
so como rastro de auditoria). Roda pra todo ticket com `glpi_ticket_id` que nao esteja
`closed`/`ignored`/`error_permanent`:
1. **GLPI status `5` (Solucionado)**:
- se o ticket ja estava `solved` localmente, so verifica se o SN foi reaberto (ver secao 5) e
para por ai;
- senao: verifica fora de escopo; se nao for, busca nota de solucao, se SN estiver em
espera/aguardando move pra "Em Atendimento", resolve o SN **confirmando via leitura ao vivo**
antes de marcar `solved/solved` (nunca marca so por causa de um HTTP 200).
2. **GLPI status `6` (Fechado definitivamente)**:
- adiciona comentario idempotente 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
3. **GLPI ainda aberto (`< 5`) e SN resolvido/encerrado por conta propria**:
- adiciona nota idempotente no GLPI avisando o encerramento vindo do SN, sem fechar o chamado
(inclui o nome de quem encerrou no SN, buscado ao vivo: `resolved_by` -> `closed_by` ->
`sys_updated_by`, o que vier preenchido primeiro)
- (primeira vez apenas) avisa no proprio SN que a Sothis nao vai mais atender esse chamado e
transfere pra fila CAOA
- marca `ticket_sync` como `solved/solved` ou `closed/closed`
4. **GLPI ainda aberto (`< 5`) e SN tambem em status intermediario** (nenhum dos dois em
Resolvido/Encerrado): aplica o mandante, se configurado — ver secao 6.
## 5. Reabertura
1. Reabertura automatica permitida apenas para tickets em GLPI `5`.
2. Se GLPI `6`, reabertura automatica bloqueada.
1. Reabertura automatica permitida apenas para tickets em GLPI `5` — verificado a cada ciclo pelo
mesmo `processTicketLifecycleController`, sem depender de nenhuma flag.
2. Se GLPI `6`, reabertura automatica bloqueada (a regra nem entra nesse caminho).
## 6. Mandante (regras 19-22)
Espelhamento **opcional** de status intermediario (`Aguardando Atendimento`/`Em Atendimento`/
`Em Espera` no SN, equivalentes ao GLPI `1`/`2`/`4` — GLPI `3` tambem mapeia para
`Em Atendimento`). Nunca toca em status finais (5, 6, fora de escopo) — essas continuam sendo as
regras fixas das secoes 4.1-4.3, sempre ativas.
Controlado pelo env `MANDANTE`:
1. `MANDANTE=GLPI`: quando o status intermediario do GLPI muda, o SN e atualizado pra
acompanhar. Mudancas de status intermediario feitas diretamente no SN sao ignoradas.
2. `MANDANTE=SNOW`: o oposto — o SN manda, o GLPI acompanha.
3. `MANDANTE` vazio/ausente/invalido: nenhum espelhamento (comportamento padrao, igual a antes do
mandante existir).
Em qualquer configuracao, so escreve quando o valor desejado difere do atual (evita chamada de API
desnecessaria) e nunca le o status do lado seguidor pra decidir nada — o mandante so manda, nunca
escuta, entao nao ha risco de loop entre os dois sistemas.

View File

@ -23,20 +23,19 @@
2. Ordem de execucao:
- `processErrorController`
- `processTicketsController`
- `processGlpiClosureController` (se habilitado)
- `processTicketLifecycleController` (motor de regras unico de status/fechamento/reabertura, se habilitado)
- `processCommentsController`
- `processStatusAndClosureController` (somente legado, se habilitado)
3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo.
4. Nao ha mais dois controladores decidindo status do mesmo ticket — o fluxo legado
(`processStatusController`) foi removido e sua logica util (nota SN->GLPI, fora de escopo) foi
absorvida pelo motor unico.
## 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`
1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: executa o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`).
- `false`: desliga o motor inteiro.
2. `ENABLE_GLPI_WEBHOOK`
- reservado para rollout por evento (planejado), sem obrigatoriedade no fluxo atual.
## 5. Regras de coleta de tickets no ServiceNow
@ -66,7 +65,7 @@
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.
4. `source_last` existe no schema e continua sendo gravado como rastro de auditoria, mas nao e mais lido em nenhum lugar do codigo para decidir status.
## 8. Regras de criacao/vinculo no GLPI
@ -129,21 +128,17 @@
## 14. Regras de fechamento e resolucao
### 14.1 Fluxo novo (prioritario)
1. Fechamento definitivo GLPI `status=6` e refletido no SN via monitor cron.
1. Fechamento definitivo GLPI `status=6` e refletido no SN via `processTicketLifecycleController`.
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.
4. Quando o SN resolve/encerra por conta propria e o GLPI ainda nao chegou em 5/6: nota idempotente
no GLPI + aviso no SN (Sothis nao vai mais atender) + transferencia pra fila CAOA — ver
`docs/fluxo.md` secao 4.3. Nao depende de `source_last` nem de nenhuma flag de sincronizacao
legada (removida).
## 15. Regras de fora do escopo (Sothis)
@ -168,8 +163,11 @@
1. `closed`
2. `ignored`
3. `error_permanent` — ticket excedeu `MAX_ERROR_RETRIES` tentativas automaticas de reprocessamento
e exige intervencao manual.
Regra geral: `closed` e `ignored` nao participam das rotinas normais de sincronizacao.
Regra geral: `closed`, `ignored` e `error_permanent` nao participam das rotinas normais de
sincronizacao.
## 17. Regra de reabertura
@ -210,6 +208,21 @@ Regra geral: `closed` e `ignored` nao participam das rotinas normais de sincroni
3. Tickets fora do escopo saem de monitoramento com estado final correto.
4. Integracao nao entra em loop por status intermediario.
## 23. Regras de mandante (espelhamento de status intermediario)
1. Existe um sistema "mandante" configuravel via env `MANDANTE` (`GLPI`, `SNOW` ou vazio) cujo
status intermediario (`Aguardando Atendimento`/`Em Atendimento`/`Em Espera` no SN, GLPI
`1`/`2`/`3`/`4`) e espelhado automaticamente no outro sistema.
2. Nunca se aplica aos status finais (GLPI `5`/`6`, fora de escopo) - essas continuam sendo as
regras fixas 10-15, sempre ativas independente do mandante.
3. `MANDANTE` vazio, ausente ou com valor invalido mantem o comportamento padrao: nenhum
espelhamento de status intermediario (equivalente ao que a regra de contencao ja fazia antes do
mandante existir).
4. O mandante e unidirecional: so escreve no sistema seguidor, nunca le o status do seguidor para
decidir algo - evita loop de espelhamento entre os dois sistemas.
5. So escreve quando o valor espelhado realmente difere do atual, para nao gerar chamada de API
desnecessaria a cada ciclo.

View File

@ -12,8 +12,7 @@ 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 { processGlpiClosureController } = require('./controllers/processGlpiClosureController');
const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController');
const { logInfo } = require('./utils/logger');
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
@ -25,24 +24,15 @@ logInfo('Aplicacao iniciada', {
async function main() {
try {
const statusSyncEnabled = process.env.ENABLE_STATUS_SYNC === 'true';
const glpiCloseCronEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
const ticketLifecycleEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
await processErrorController();
await processTicketsController();
if (glpiCloseCronEnabled) {
await processGlpiClosureController();
if (ticketLifecycleEnabled) {
await processTicketLifecycleController();
} else {
logInfo('Monitor de encerramento definitivo GLPI desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
}
if (statusSyncEnabled) {
await processStatusAndClosureController();
} else {
logInfo('Sincronizacao legada de status desabilitada (ENABLE_STATUS_SYNC=false).');
logInfo('Motor de regras de status/fechamento desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
}
await processCommentsController();

View File

@ -1,58 +1,19 @@
const { fetchCommentsFromServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService');
const { fetchCommentsFromServiceNow } = 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) => {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const glpiIsSolved = glpiStatus === 5;
if (!glpiIsSolved) {
return { reopened: false, glpiSolved: false };
}
// REGRA DE CONTENÇÃO: Só reabre se o monitoramento de status estiver ativo.
// Se o usuário desligou o sync de status, ele quer controle manual.
if (process.env.ENABLE_STATUS_SYNC === 'false') {
return { reopened: false, glpiSolved: true };
}
// CORREÇÃO: Verificar se o ticket foi fechado recentemente.
// Se o status já é 'solved', significa que o processGlpiClosureController
// tentou fechar recentemente. O SN pode ainda estar processando o close,
// então ignoramos a detecção de reopen para evitar falsos positivos.
if (ticket.glpi_sync_status === 'solved' || ticket.sn_sync_status === 'solved') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fechou recentemente (status: ${ticket.glpi_sync_status}/${ticket.sn_sync_status}). Ignorando verificacao de reopen.`);
return { reopened: false, glpiSolved: true };
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
return { reopened: false, glpiSolved: true };
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const snIsFinal = liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido';
if (snIsFinal) {
return { reopened: false, glpiSolved: true };
}
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.`);
return { reopened: true, glpiSolved: true };
};
const processCommentsController = async () => {
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
for (const ticket of ticketsToMonitor) {
const reopenState = await ensureReopenFromSNWhenGlpiSolved(ticket);
const reopenedThisCycle = reopenState.reopened;
const glpiWasSolved = reopenState.glpiSolved;
if (glpiWasSolved && !reopenedThisCycle) {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === 5) {
// Regra de negocio: enquanto o GLPI estiver Solucionado, nao manda comentario novo
// do SN pra dentro do chamado. A decisao de reabertura acontece em
// processTicketLifecycleController.js, que roda antes deste controlador no ciclo.
logInfo(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`);
await syncCommentsGlpitoSN(ticket);
continue;
@ -60,7 +21,7 @@ const processCommentsController = async () => {
const comments = await fetchCommentsFromServiceNow(ticket);
if (comments.length !== 0) {
await syncCommentsSNtoGlpi(comments, ticket, { highlightReopenContext: reopenedThisCycle });
await syncCommentsSNtoGlpi(comments, ticket);
}
await syncCommentsGlpitoSN(ticket);

View File

@ -1,6 +1,8 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const MAX_ERROR_RETRIES = Number(process.env.MAX_ERROR_RETRIES || 5);
const processErrorController = async () => {
logInfo('Iniciando verificação de tickets com erro...');
try {
@ -14,6 +16,14 @@ const processErrorController = async () => {
logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`);
for (const ticket of errorTickets) {
const retryCount = await TicketSyncModel.bumpErrorRetryCount(ticket.sn_ticket_id);
if (retryCount > MAX_ERROR_RETRIES) {
await TicketSyncModel.markPermanentError(ticket.sn_ticket_id);
logWarning(`Ticket SN ID ${ticket.sn_ticket_id} excedeu ${MAX_ERROR_RETRIES} tentativas de reprocessamento. Marcado como 'error_permanent' (requer intervencao manual).`);
continue;
}
let newSnStatus = ticket.sn_sync_status;
let newGlpiStatus = ticket.glpi_sync_status;
@ -28,7 +38,7 @@ const processErrorController = async () => {
if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close';
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus);
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`);
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}, tentativa ${retryCount}/${MAX_ERROR_RETRIES}).`);
}
} catch (error) {

View File

@ -1,106 +0,0 @@
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, reassignTicketInServiceNow } = require('../services/servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const { logInfo, logError, logWarning } = require('../utils/logger');
const DEFINITIVE_CLOSE_NOTICE = 'Chamado encerrado definitivamente. Tempo para reabertura encerrado. Caso necessario, por favor abra um novo Ticket.';
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
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;
}
if (liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido') {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} ja estava refletido no SN como '${liveSnStatus}'.`);
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 fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
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();
const closeOk = await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt);
if (!closeOk) {
logWarning(`Fechamento do ticket SN ${ticketSn.ticket_number} falhou. Mantendo GLPI ${ticket.glpi_ticket_id} para nova tentativa.`);
continue;
}
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,206 +0,0 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { stripHTML } = require('../utils/commentSanitizer');
const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, fetchTicketLiveStatusFromServiceNow, reassignTicketInServiceNow } = require('../services/servicenowService');
const { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
const snCloseRetryMax = Number(process.env.SN_CLOSE_RETRY_MAX || 2);
const snCloseRetryDelayMs = Number(process.env.SN_CLOSE_RETRY_DELAY_MS || 1500);
const waitMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const attemptCloseInServiceNowWithRetry = async (ticketSnDetails, closeNotes, resolvedAt) => {
for (let attempt = 1; attempt <= snCloseRetryMax + 1; attempt += 1) {
const closeOk = await closeTicketInServiceNow(ticketSnDetails.id, closeNotes, resolvedAt);
if (closeOk) {
const liveStatus = await fetchTicketLiveStatusFromServiceNow(ticketSnDetails.sys_id, ticketSnDetails.tipo);
if (liveStatus === 'Resolvido' || liveStatus === 'Encerrado' || liveStatus === 'Encerrado - Omitido') {
return { ok: true, liveStatus };
}
logWarning(`Fechamento SN tentou atualizar, mas status ao vivo ficou '${liveStatus}'. Tentando novamente.`);
} else {
logWarning(`Tentativa ${attempt} de fechamento no SN falhou para ${ticketSnDetails.ticket_number}.`);
}
if (attempt <= snCloseRetryMax) {
await waitMs(snCloseRetryDelayMs);
}
}
return { ok: false, liveStatus: null };
};
const processStatusAndClosureController = async () => {
logInfo('Iniciando processamento de status e fechamento de tickets...');
try {
// --- FASE 1: Sincronização de Status para Tickets Abertos (Bidirecional) ---
logInfo('Iniciando verificação e sincronização de status...');
// Busca todos os tickets que não estão permanentemente fechados.
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
if (ticketsToMonitor.length === 0) {
logInfo('Nenhum ticket sincronizado (aberto) encontrado para verificação de status.');
} else {
logInfo(`Encontrados ${ticketsToMonitor.length} tickets para monitorar status (synced/solved).`);
}
for (const ticket of ticketsToMonitor) {
try {
// Obter detalhes completos do ticket SN para ter sys_id, tipo e status atual
const ticketSnDetails = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSnDetails) {
logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`);
continue;
}
let snCurrentStatus = ticketSnDetails.status;
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSnDetails.sys_id, ticketSnDetails.tipo);
if (liveSnStatus && liveSnStatus !== snCurrentStatus) {
logInfo(`Status ao vivo do SN para ${ticketSnDetails.ticket_number}: '${snCurrentStatus}' -> '${liveSnStatus}'.`);
snCurrentStatus = liveSnStatus;
await TicketSnModel.updateTicket(ticket.sn_ticket_id, { status: liveSnStatus });
}
const snIsFinalState = snCurrentStatus === 'Resolvido' || snCurrentStatus === 'Encerrado' || snCurrentStatus === 'Encerrado - Omitido';
let effectiveSourceLast = ticket.source_last;
if (snIsFinalState && ticket.source_last !== 'GLPI') {
effectiveSourceLast = 'SNOW';
}
// ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI =================
// Primeiro, verificamos se o GLPI mudou de estado por conta própria.
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const glpiIsFinalState = glpiLiveStatus !== null && glpiLiveStatus >= 5;
const glpiWasFinalLocally = ticket.glpi_sync_status === 'solved' || ticket.glpi_sync_status === 'closed';
// Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento')
const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus);
// Compara o status real do GLPI com o status do SN apenas para observabilidade.
// A direcao da sincronizacao deve respeitar o source_last persistido.
if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) {
logInfo(`Mudanca de status detectada para o ticket ${ticket.glpi_ticket_id}. GLPI='${glpiLiveStatusAsString}', SN='${snCurrentStatus}', source_last='${effectiveSourceLast}'.`);
}
// Regra de precedencia de finalizacao:
// quando GLPI ja esta finalizado e SN ainda nao, sempre prioriza GLPI->SN
// para evitar reabertura indevida no GLPI.
if (glpiIsFinalState && !snIsFinalState) {
effectiveSourceLast = 'GLPI';
}
// ================= LÓGICA SNOW -> GLPI =================
// Se a última atualização veio do ServiceNow, atualiza o GLPI
if (effectiveSourceLast === 'SNOW') {
logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`);
// **LÓGICA DE REABERTURA REFINADA**
// Busca o status atual do GLPI para tomar uma decisão inteligente.
const glpiCurrentStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
// Se o ticket no GLPI está permanentemente Fechado (6), não fazemos nada.
if (glpiCurrentStatus === 6) {
logWarning(`Ticket GLPI ${ticket.glpi_ticket_id} está permanentemente Fechado (6). Ação de reabertura pelo SN ('${snCurrentStatus}') foi bloqueada.`);
} else if (snCurrentStatus === 'Resolvido') {
logInfo(`SN ${ticketSnDetails.ticket_number} foi Resolvido. Enviando nota para GLPI sem fechar o chamado...`);
// Regra de negocio: resolucao vinda do SN nao fecha no GLPI.
// Apenas registra uma nota e marca o fluxo como solucionado.
const collaboratorName = ticketSnDetails.updated_by || ticketSnDetails.closed_by || null;
await addServiceNowClosureNoteToGlpi(ticket, collaboratorName, 'solucionado');
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'SNOW');
} else if (snCurrentStatus === 'Encerrado - Omitido' || snCurrentStatus === 'Encerrado') { // SN foi para 'Closed'
logInfo(`SN ${ticketSnDetails.ticket_number} foi Fechado. Enviando nota para GLPI sem fechar o chamado...`);
// Regra de negocio: encerramento vindo do SN nao fecha no GLPI.
// Apenas registra uma nota e remove o ticket do monitoramento da integracao.
const collaboratorName = ticketSnDetails.updated_by || ticketSnDetails.closed_by || null;
await addServiceNowClosureNoteToGlpi(ticket, collaboratorName, 'encerrado');
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'closed', 'closed', 'SNOW');
} else {
// Sincronização passiva: não propagamos status intermediários (Em Espera, Atribuído, etc.)
// para o GLPI para evitar conflitos com a operação manual sugerida pelo usuário.
logInfo(`SN ${ticketSnDetails.ticket_number} em status '${snCurrentStatus}'. Ignorando atualização automática de status no GLPI (Regra de Contenção).`);
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI');
}
}
// ================= LÓGICA GLPI -> SNOW =================
// Se a última atualização veio do GLPI, atualiza o ServiceNow
else if (effectiveSourceLast === 'GLPI') {
logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`);
const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos
if (glpiCurrentStatus !== null) {
if (glpiCurrentStatus === 5) { // GLPI foi para 'Solucionado'
// Apenas processa se o SN nao estiver resolvido/fechado de fato
if (!snIsFinalState) {
logInfo(`GLPI ${ticket.glpi_ticket_id} foi Solucionado. Sincronizando para SN...`);
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
// REGRA "FORA DO ESCOPO"
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id);
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
} else {
// REGRA DE NEGÓCIO: Não pode fechar um ticket que está "Em Espera" ou "Aguardando Atendimento" no SN.
// NOVA REGRA: Se estiver em espera/aguardando, primeiro muda para "Em Atendimento" e depois fecha.
if (snCurrentStatus === 'Em Espera' || snCurrentStatus === 'Aguardando Atendimento') {
logWarning(`Ticket SN ${ticketSnDetails.ticket_number} está '${snCurrentStatus}'. Atualizando para 'Em Atendimento' antes de fechar.`);
await updateStatusInServiceNow(ticket.sn_ticket_id, 'Em Atendimento');
}
// FLUXO NORMAL DE RESOLUÇÃO
const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integração GLPI.';
const resolvedAt = solution?.date_mod || new Date();
const closeResult = await attemptCloseInServiceNowWithRetry(ticketSnDetails, closeNotes, resolvedAt);
if (closeResult.ok) {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'GLPI');
} else {
await TicketSyncModel.updateSourceLast(ticket.sn_ticket_id, 'GLPI');
logWarning(`Falha ao confirmar fechamento no SN para ${ticketSnDetails.ticket_number}. Mantendo ticket para nova tentativa.`);
}
}
}
} else if (glpiCurrentStatus === 6) {
logInfo(`GLPI ${ticket.glpi_ticket_id} foi Fechado. Sincronizando para SN...`);
// Apenas marca nosso banco como fechado, pois o SN já deve estar fechado ou será fechado por automação interna.
if (ticket.glpi_sync_status !== 'closed') {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status);
}
// Sincronização passiva: não propagamos status intermediários (reaberturas parciais, etc.)
// do GLPI para o SN, exceto para fechamentos definitivos já tratados acima.
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} em status '${glpiCurrentStatus}'. Ignorando atualização automática de status no SN (Regra de Contenção).`);
}
// Passa o bastão de volta para o SN se a ação não for de finalização
if (glpiCurrentStatus && glpiCurrentStatus <= 4) {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
}
} else {
logWarning(`Não foi possível obter o status atual do ticket GLPI ${ticket.glpi_ticket_id}.`);
}
}
// Se source_last não está definido ou é desconhecido, podemos definir um padrão ou logar um aviso.
// Por enquanto, vamos apenas logar.
else {
logWarning(`source_last desconhecido para o ticket SN ${ticketSnDetails.ticket_number}. Pulando sincronização de status.`);
}
} catch (error) {
logError(error, `Erro ao sincronizar status do ticket SN ${ticket.sn_ticket_id} (GLPI ID: ${ticket.glpi_ticket_id}).`);
// Marcar o ticket como erro para revisão manual
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error_sync', 'error_sync');
}
}
} catch (error) {
logError(error, 'Erro geral no processamento de status e fechamento de tickets.');
}
};
module.exports = {
processStatusAndClosureController
};

View File

@ -54,6 +54,10 @@ const processAndSaveTickets = async (tickets, type) => {
logInfo(`Variaveis da requisicao ${ticketNumber}: justificativa=${ticket.justificativa ? 'ok' : 'vazia'}, telefone=${ticket.telefone ? 'ok' : 'vazio'}.`);
}
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
const existingTicketSn = await TicketSnModel.findByTicketNumber(ticketNumber);
const oldStatus = existingTicketSn?.status || null;
const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type);
const ticketStatus = ticket.state;
@ -78,8 +82,12 @@ const processAndSaveTickets = async (tickets, type) => {
if (!exists) {
await TicketSyncModel.createSyncRecord(dataSync);
} else if (wasInserted === false) {
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
if (oldStatus !== ticketStatus) {
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow (status '${oldStatus}' -> '${ticketStatus}'). 'source_last' definido como 'SNOW'.`);
} else {
logInfo(`Ticket ${ticket.number} recebido de novo pela margem de seguranca do watermark, sem mudanca de status ('${ticketStatus}'). Ignorando reset de 'source_last'.`);
}
} else {
logInfo(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
}

View File

@ -0,0 +1,219 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSnModel = require('../models/ticketSnModel');
const {
addCommentToServiceNow,
updateStatusInServiceNow,
fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
closeTicketInServiceNow,
reassignTicketInServiceNow
} = require('../services/servicenowService');
const { isOutOfScopeSolution, handleOutOfScopeResolution, addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
const { stripHTML } = require('../utils/commentSanitizer');
const { logInfo, logError, logWarning } = require('../utils/logger');
const DEFINITIVE_CLOSE_NOTICE = 'Chamado encerrado definitivamente. Tempo para reabertura encerrado. Caso necessario, por favor abra um novo Ticket.';
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
const MANDANTE = (process.env.MANDANTE || '').toUpperCase();
const isSnFinal = (liveSnStatus) => liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido';
/**
* Regras 19-22 (mandante): fora dos status fixos (5, 6, fora de escopo), quando MANDANTE esta
* configurado, espelha o status intermediario (Aguardando Atendimento/Em Atendimento/Em Espera)
* do sistema mandante pro outro. So o mandante escreve - nunca le o status do lado seguidor pra
* decidir nada, entao nao ha risco de ping-pong. Sem MANDANTE configurado (ou valor invalido),
* comportamento identico a antes (regra de contencao: nada e espelhado).
*/
const applyMandanteMirroring = async (ticket, ticketSn, glpiStatus, liveSnStatus) => {
if (MANDANTE === 'GLPI') {
const desiredSnStatus = TicketGlpiModel.mapGlpiStatusToString(glpiStatus);
if (desiredSnStatus === 'Desconhecido') {
logWarning(`Mandante=GLPI: status GLPI ${glpiStatus} sem mapeamento para o SN. Pulando espelhamento para o ticket ${ticket.glpi_ticket_id}.`);
return;
}
if (desiredSnStatus !== liveSnStatus) {
const updated = await updateStatusInServiceNow(ticket.sn_ticket_id, desiredSnStatus);
if (!updated) {
logWarning(`Mandante=GLPI: falha ao espelhar status '${desiredSnStatus}' no SN ${ticketSn.ticket_number}. Tentando novamente no proximo ciclo.`);
return;
}
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI');
logInfo(`Mandante=GLPI: status do SN ${ticketSn.ticket_number} espelhado para '${desiredSnStatus}'.`);
}
} else if (MANDANTE === 'SNOW') {
const desiredGlpiStatus = TicketGlpiModel.mapStatus(liveSnStatus);
if (glpiStatus !== desiredGlpiStatus) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, liveSnStatus);
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
logInfo(`Mandante=SNOW: status do GLPI ${ticket.glpi_ticket_id} espelhado para '${liveSnStatus}'.`);
}
}
};
/**
* Regra 16/17 (Frente 2): quando o SN resolve/encerra um chamado que a Sothis ainda estava
* atendendo, avisa no proprio SN que a Sothis nao vai mais atender e transfere pra fila CAOA.
* So roda uma vez por ticket (chamado pelo caller apenas quando a nota pro GLPI e nova).
*/
const notifyAndReassignInServiceNow = async (ticket) => {
try {
const latestGlpiUpdate = await TicketUpdateModel.getLatestUpdate(ticket.id, 'glpi');
const glpiContext = latestGlpiUpdate?.content || null;
const baseMessage = 'Este chamado nao sera mais atendido pela equipe Sothis, pois foi encerrado pelo ServiceNow. Caso ainda precise de suporte, por favor abra um novo chamado.';
const message = glpiContext
? `${baseMessage}\n\nUltima atualizacao registrada pela Sothis:\n${glpiContext}`
: baseMessage;
await addCommentToServiceNow(ticket.sn_ticket_id, message);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
logInfo(`Ticket SN ${ticket.sn_ticket_id} avisado e transferido para fila CAOA (encerramento veio do proprio SN).`);
} catch (error) {
logError(error, `Falha ao notificar/reatribuir no SN para o ticket ${ticket.sn_ticket_id}`);
}
};
/**
* Motor de regras unico para status/fechamento/reabertura (regras fixas 10-18 do inventario de
* negocio). Substitui processGlpiClosureController.js + processStatusController.js: nao ha mais
* dois controladores decidindo o mesmo ticket no mesmo ciclo, e nenhuma decisao aqui depende de
* `source_last` (ele pode continuar sendo escrito so como rastro de auditoria).
*/
const processTicketLifecycleController = async () => {
try {
const tickets = await TicketSyncModel.getTicketsForClosureMonitor();
if (!tickets.length) {
logInfo('Nenhum ticket ativo para monitoramento de status/fechamento.');
return;
}
for (const ticket of tickets) {
try {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === null) {
logWarning(`Nao foi possivel obter o status atual do ticket GLPI ${ticket.glpi_ticket_id}.`);
continue;
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
continue;
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const snIsFinal = isSnFinal(liveSnStatus);
// ================= GLPI = 5 (Solucionado) =================
if (glpiStatus === 5) {
if (ticket.sn_sync_status === 'solved') {
// Ja fechamos este ticket antes. So resta checar se foi reaberto pelo SN.
if (!snIsFinal) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento');
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`);
}
continue;
}
if (snIsFinal) {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} ja estava refletido no SN como '${liveSnStatus}'.`);
continue;
}
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
if (isOutOfScopeSolution(solution)) {
await handleOutOfScopeResolution(ticket, solution);
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();
const closeOk = await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt);
if (!closeOk) {
logWarning(`Fechamento do ticket SN ${ticketSn.ticket_number} falhou. Mantendo GLPI ${ticket.glpi_ticket_id} para nova tentativa.`);
continue;
}
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} solucionado e refletido no SN.`);
continue;
}
// ================= GLPI = 6 (Fechado definitivamente) =================
if (glpiStatus === 6) {
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: nao altera status no SN ao fechar definitivamente no GLPI.
// Apenas registra comentario e remove do monitoramento local.
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'closed', 'closed', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} encerrado definitivamente e refletido no SN.`);
continue;
}
// ================= GLPI < 5 (ainda aberto) =================
if (glpiStatus < 5) {
if (snIsFinal && ticket.glpi_sync_status !== 'solved' && ticket.glpi_sync_status !== 'closed') {
// SN pode ter resolvido/encerrado sozinho
const finalStatus = liveSnStatus === 'Resolvido' ? 'solucionado' : 'encerrado';
const collaboratorName = await fetchTicketCloserFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const noteResult = await addServiceNowClosureNoteToGlpi(ticket, collaboratorName, finalStatus);
if (noteResult.ok) {
if (noteResult.isNew) {
await notifyAndReassignInServiceNow(ticket);
}
const newStatus = finalStatus === 'solucionado' ? 'solved' : 'closed';
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, newStatus, newStatus, 'SNOW');
logInfo(`SN ${ticketSn.ticket_number} foi ${finalStatus === 'solucionado' ? 'resolvido' : 'encerrado'}. Nota registrada no GLPI ${ticket.glpi_ticket_id} sem fechar o chamado.`);
}
} else if (!snIsFinal) {
// Os dois lados estao em status intermediario - so mexe se houver mandante configurado
await applyMandanteMirroring(ticket, ticketSn, glpiStatus, liveSnStatus);
}
}
} catch (error) {
logError(error, `Falha ao processar ciclo de vida do ticket GLPI ${ticket.glpi_ticket_id}`);
}
}
} catch (error) {
logError(error, 'Erro geral no motor de regras de status/fechamento.');
}
};
module.exports = {
processTicketLifecycleController
};
/**
* @file processTicketLifecycleController.js
* @description Motor de regras unico para o ciclo de vida do ticket (regras fixas 10-18):
* fechamento GLPI->SN (status 5 e 6), reabertura (SN reabre enquanto GLPI ainda mostra
* solucionado), nota SN->GLPI quando o SN resolve/encerra por conta propria, e a regra de
* "fora de escopo". Substitui processGlpiClosureController.js e processStatusController.js -
* nao ha mais dois controladores competindo pelo mesmo ticket, e nenhuma decisao aqui depende de
* `source_last`. Por cima das regras fixas, `applyMandanteMirroring` implementa as regras 19-22
* (mandante) - espelhamento opcional de status intermediario, configuravel via env `MANDANTE`.
*/

View File

@ -39,6 +39,7 @@ class TicketGlpiModel {
const statusMap = {
1: 'Aguardando Atendimento', // Novo
2: 'Em Atendimento', // Em atendimento (atribuído)
3: 'Em Atendimento', // Em planejamento (tratado como variacao de "em andamento" pro SN)
4: 'Em Espera', // Pendente
5: 'Resolvido', // Solucionado
6: 'Encerrado - Omitido' // Fechado
@ -239,7 +240,8 @@ class TicketGlpiModel {
itilcategories_id: TicketGlpiModel.mapCategory(ticketData.short_description),
global_validation: 1,
date_creation: ticketData.opened_at || new Date(),
slas_id_ttr: slasIdTtr
slas_id_ttr: slasIdTtr,
externalid: ticketData.ticket_number
};
const fields = Object.keys(glpiTicketData).join(', ');

View File

@ -16,7 +16,9 @@ class TicketSyncModel {
sn_sync_status,
glpi_sync_status,
source_last
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (sn_ticket_id) DO NOTHING
RETURNING id`;
const values = [
data.sn_ticket_id,
@ -174,7 +176,8 @@ class TicketSyncModel {
FROM ticket_sync tsync
JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id
WHERE (tsync.glpi_sync_status IN ('synced', 'solved') OR tsync.sn_sync_status IN ('synced', 'solved'))
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`;
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')
AND (tsync.glpi_sync_status != 'error_permanent' AND tsync.sn_sync_status != 'error_permanent')`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
@ -195,8 +198,8 @@ class TicketSyncModel {
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')
AND tsync.glpi_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
AND tsync.sn_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
`;
const { rows } = await pool.query(query);
return rows;
@ -231,9 +234,10 @@ class TicketSyncModel {
static async getTicketsInErrorState() {
try {
const query = `
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status, error_retry_count
FROM ticket_sync
WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%'
WHERE (sn_sync_status LIKE '%error%' AND sn_sync_status != 'error_permanent')
OR (glpi_sync_status LIKE '%error%' AND glpi_sync_status != 'error_permanent')
`;
const { rows } = await pool.query(query);
return rows;
@ -243,6 +247,27 @@ class TicketSyncModel {
}
}
static async bumpErrorRetryCount(snTicketId) {
try {
const query = 'UPDATE ticket_sync SET error_retry_count = error_retry_count + 1 WHERE sn_ticket_id = $1 RETURNING error_retry_count';
const { rows } = await pool.query(query, [snTicketId]);
return rows[0] ? rows[0].error_retry_count : null;
} catch (error) {
logError(error, `ERRO: Erro ao incrementar error_retry_count para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
static async markPermanentError(snTicketId) {
try {
const query = "UPDATE ticket_sync SET glpi_sync_status = 'error_permanent', sn_sync_status = 'error_permanent' WHERE sn_ticket_id = $1";
await pool.query(query, [snTicketId]);
} catch (error) {
logError(error, `ERRO: Erro ao marcar error_permanent para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
static async updateLastSync(snTicketId, system) {
try {
let fieldToUpdate;

View File

@ -0,0 +1,11 @@
-- =============================================
-- Migracao: contador de tentativas de reprocessamento de erro em ticket_sync
-- Motivo: processErrorController resetava tickets em estado *error* para
-- reprocessamento a cada ciclo, sem limite - um ticket com falha
-- deterministica (dado invalido, ACL permanente, etc.) reprocessava
-- para sempre, gerando ruido de log/API sem nunca sinalizar que
-- precisa de intervencao manual.
-- =============================================
ALTER TABLE ticket_sync
ADD COLUMN IF NOT EXISTS error_retry_count INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,22 @@
-- =============================================
-- Migracao: adiciona UNIQUE(sn_ticket_id) em ticket_sync
-- Motivo: nada no banco impedia duas linhas de ticket_sync para o mesmo
-- ticket SN (check-then-act sem transacao em processSyncController.js),
-- o que permitiria criar dois tickets no GLPI para o mesmo chamado SN.
--
-- IMPORTANTE: rodar o passo 1 primeiro. Se aparecer alguma linha, o ALTER TABLE
-- do passo 2 vai falhar (constraint violada) e as duplicatas encontradas
-- precisam ser reconciliadas manualmente (decidir qual linha manter, atualizar
-- referencias em ticket_updates, apagar a(s) linha(s) excedente(s)) antes de
-- tentar novamente.
-- =============================================
-- 1. Verificar se ja existe alguma duplicata hoje.
SELECT sn_ticket_id, COUNT(*) AS total, array_agg(id) AS ticket_sync_ids
FROM ticket_sync
GROUP BY sn_ticket_id
HAVING COUNT(*) > 1;
-- 2. Se o passo 1 nao retornou nenhuma linha, aplicar a constraint.
ALTER TABLE ticket_sync
ADD CONSTRAINT ticket_sync_sn_ticket_id_unique UNIQUE (sn_ticket_id);

View File

@ -100,94 +100,68 @@ const processSingleTicket = async (ticket) => {
}
};
const checkGlpiStatusChanges = async () => {
try {
logInfo('📡 Verificando mudanças de status nos tickets do GLPI...');
const isOutOfScopeSolution = (solution) => solution?.solutiontypes_id === outOfScopeSolutionTypeId;
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
if (ticketsToMonitor.length === 0) {
logInfo('Nenhum ticket ativo para verificar status no GLPI.');
return;
}
for (const ticket of ticketsToMonitor) {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const currentDbStatus = ticket.glpi_sync_status;
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_');
if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`);
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
continue;
}
const sanitizedContent = solution?.content ? stripHTML(solution.content) : 'Chamado solucionado sem notas.';
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
source_system: 'glpi',
update_type: 'close_message',
content: sanitizedContent,
author: null,
created_at: solution?.date_mod || new Date(),
source_id: `solution-${ticket.glpi_ticket_id}`,
});
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'pending_solve', 'GLPI');
} else if (glpiStatus === 6 && currentDbStatus !== 'closed') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Fechado permanentemente. Marcando como 'closed'.`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status);
} else if (glpiStatus < 5 && glpiStatusAsString !== currentDbStatus && ticket.source_last !== 'GLPI') {
logInfo(`Mudança de status (aberto) detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. De '${currentDbStatus}' para '${glpiStatusAsString}'.`);
await TicketSyncModel.updateSourceLast(ticket.sn_ticket_id, 'GLPI');
}
}
} catch (error) {
logError(error, '🚨Falha ao verificar mudanças de status no GLPI');
}
/**
* Regra "fora de escopo": nota no SN + transferencia para fila CAOA + remove grupo do GLPI + fecha
* o ticket no GLPI. Antes existia uma copia quase identica em processGlpiClosureController.js e
* outra em processStatusController.js (uma delas nao removia o grupo do GLPI) - consolidado aqui.
*/
const handleOutOfScopeResolution = async (ticket, solution) => {
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id);
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fora do escopo: grupo removido, transferido para CAOA e fechado.`);
};
const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => {
const sourceId = `sn_closure_note:${ticket.glpi_ticket_id}:${finalStatus}`;
try {
const alreadySent = await TicketUpdateModel.getBySourceId(sourceId);
if (alreadySent) {
logInfo(`Nota de encerramento do ServiceNow ja registrada para o GLPI ${ticket.glpi_ticket_id} (status '${finalStatus}'). Ignorando reenvio.`);
return { ok: true, isNew: false };
}
const resolvedBy = collaboratorName && collaboratorName.trim()
? collaboratorName.trim()
: 'colaborador CAOA';
const statusText = finalStatus === 'solucionado'
? 'Chamado solucionado no Service Now pela CAOA.'
: 'Chamado encerrado no Service Now pela CAOA.';
? 'Chamado solucionado no Service Now e removido do monitoramento.'
: 'Chamado encerrado no Service Now e removido do monitoramento.';
const styledMessage = `
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico no ServiceNow</div>
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento realizado pelo Service Now</div>
<div style="color:#742a2a;">
${statusText}
</div>
<div style="margin-top:8px; color:#742a2a; font-size:12px;">
Responsavel informado: ${resolvedBy}
</div>
<div style="margin-top:6px; color:#742a2a; font-size:12px;">
Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao.
Responsavel: ${resolvedBy}
</div>
</div>`;
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
update_type: 'close_notice',
source_system: 'sn',
content: styledMessage,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: 'done'
});
logInfo(`Nota de encerramento do ServiceNow adicionada ao GLPI ${ticket.glpi_ticket_id}.`);
return true;
return { ok: true, isNew: true };
} catch (error) {
logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`);
return false;
return { ok: false, isNew: false };
}
};
@ -210,9 +184,10 @@ const closeTicketInGlpi = async (ticket) => {
module.exports = {
syncTicketsToGlpi,
checkGlpiStatusChanges,
closeTicketInGlpi,
addServiceNowClosureNoteToGlpi
addServiceNowClosureNoteToGlpi,
isOutOfScopeSolution,
handleOutOfScopeResolution
};
/**
@ -223,6 +198,6 @@ module.exports = {
* Principais Funcionalidades:
* - `syncTicketsToGlpi()`: Função principal que busca tickets pendentes no banco de dados local (previamente coletados do ServiceNow) e orquestra sua criação no GLPI.
* - `processSingleTicket(ticket)`: Processa um único ticket. Verifica se ele existe no GLPI para evitar duplicatas. Se não existir, formata os dados (incluindo o enriquecimento da descrição para requisições) e chama o `TicketGlpiModel` para criar o ticket. Se existir, apenas atualiza o registro de sincronização.
* - `checkGlpiStatusChanges()`: (Atualmente não utilizado no fluxo principal, sua lógica foi integrada ao `processStatusController`) Função projetada para detectar proativamente mudanças de status no GLPI.
* - `isOutOfScopeSolution(solution)` / `handleOutOfScopeResolution(ticket, solution)`: Regra "fora do escopo" compartilhada por `processGlpiClosureController` e `processStatusController`.
* - `closeTicketInGlpi(ticket)`: Orquestra o processo de fechamento de um ticket no GLPI. Ele busca a nota de resolução vinda do ServiceNow e a utiliza para adicionar uma solução no GLPI antes de mudar o status para "Solucionado".
*/

View File

@ -107,6 +107,29 @@ const fetchTicketLiveStatusFromServiceNow = async (sysId, ticketType) => {
}
}
const fetchTicketCloserFromServiceNow = async (sysId, ticketType) => {
try {
const baseUrl = ticketType === 'requisicao'
? apiConfig.snTableRequestConfig.baseUrl
: apiConfig.snTableIncidentConfig.baseUrl;
const url = `${baseUrl}/${sysId}?sysparm_display_value=true&sysparm_fields=resolved_by,closed_by,sys_updated_by`;
const response = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
const result = response?.data?.result;
if (!result) return null;
const extractName = (field) => (typeof field === 'object' ? field?.display_value : field) || null;
// Incidente resolvido preenche resolved_by; fechado formalmente preenche closed_by.
// sc_req_item nao tem resolved_by - sys_updated_by e o unico campo garantido nos dois tipos.
return extractName(result.resolved_by) || extractName(result.closed_by) || extractName(result.sys_updated_by) || null;
} catch (error) {
logError(error, `ERRO: Falha ao buscar quem encerrou o ticket no ServiceNow para sys_id ${sysId}`);
return null;
}
};
const getTicketSysId = async (ticketId) => {
try {
const ticket = await TicketSnModel.findById(ticketId);
@ -206,6 +229,7 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
} catch (error) {
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
throw error;
}
}
@ -417,7 +441,7 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
const stateMap = {
'Aguardando Atendimento': '1', // New
'Em Atendimento': '2', // In Progress
'Em Espera': '-5', // On Hold
'Em Espera': '3', // On Hold (confirmado via teste real - '-5' era invalido e o SN ignorava silenciosamente)
'Encerrado': '6', // Resolved
'Encerrado - Omitido': '7' // Closed
};
@ -459,6 +483,14 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`);
}
// Validacao de leitura: o SN pode aceitar o PATCH (200) e ainda assim ignorar um codigo de
// estado invalido silenciosamente (ja aconteceu com o codigo antigo de 'Em Espera').
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
if (liveStatus !== state) {
logError(`Atualizacao de status no SN retornou 200 mas o status ao vivo ficou '${liveStatus}' em vez de '${state}'.`, { sysId, ticketType, state: stateCode });
return null;
}
logInfo(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
return true;
@ -525,6 +557,15 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`);
}
// Validacao de leitura: o PATCH pode retornar 200 sem que o estado tenha realmente
// transicionado (regra de negocio/ACL do SN pode bloquear silenciosamente).
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
const closedForReal = liveStatus === 'Resolvido' || liveStatus === 'Encerrado' || liveStatus === 'Encerrado - Omitido';
if (!closedForReal) {
logError(`Fechamento no SN retornou 200 mas o status ao vivo nao confirmou. Status atual: '${liveStatus}'.`, { sysId, ticketType });
return false;
}
logInfo(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
return true;
@ -810,6 +851,7 @@ module.exports = {
fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow,
fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
getTicketSysId,
getTicketTypeById,
createCommentInServiceNow,