Motor de regras único, mandante configurável e deploy via secret do Gitea #1

Merged
rafael.lopes merged 5 commits from dev into master 2026-07-15 13:54:58 -03:00
25 changed files with 885 additions and 540 deletions

View File

@ -12,10 +12,19 @@ PORT=3000
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos) # Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *' CRON_SCHEDULE='* * * * *'
ENABLE_STATUS_SYNC=false
ENABLE_GLPI_WEBHOOK=true ENABLE_GLPI_WEBHOOK=true
ENABLE_GLPI_CLOSE_CRON=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 --- # --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow # URL base da sua instancia do ServiceNow
SERVICENOW_INSTANCE=https://your-instance.service-now.com SERVICENOW_INSTANCE=https://your-instance.service-now.com
@ -38,6 +47,8 @@ SERVICENOW_TABLE_REQUEST_URL=
SERVICENOW_TABLE_JOURNAL_URL= SERVICENOW_TABLE_JOURNAL_URL=
SERVICENOW_SC_ITEM_OPTION_URL= SERVICENOW_SC_ITEM_OPTION_URL=
SERVICENOW_EXTERNAL_TICKET_FIELD=u_external_tickets SERVICENOW_EXTERNAL_TICKET_FIELD=u_external_tickets
# Sys_id do grupo de TI da CAOA (destino de tickets fora do escopo Sothis)
SERVICENOW_CAOA_TI_GROUP_ID=
# --- Configuracao do Banco de Dados do GLPI (MySQL) --- # --- Configuracao do Banco de Dados do GLPI (MySQL) ---
GLPI_DB_TYPE=mysql GLPI_DB_TYPE=mysql
@ -68,7 +79,7 @@ SNGLPI_DB_PASSWORD=
LOCATION_MAPPING_CSV_PATH=/path/to/your/location_mapping.csv LOCATION_MAPPING_CSV_PATH=/path/to/your/location_mapping.csv
# Logging # Logging
LOG_LEVEL=debug LOG_LEVEL=info
LOG_TO_CONSOLE=true LOG_TO_CONSOLE=true
LOG_RETENTION_DAYS=10 LOG_RETENTION_DAYS=10
LOG_DIR=logs LOG_DIR=logs

View File

@ -0,0 +1,107 @@
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: Check required secrets
shell: bash
run: |
set -euo pipefail
if [ -z "${{ secrets.ENV_PRODUCTION }}" ]; then
echo "Missing secret ENV_PRODUCTION. Configure it in Settings > Actions > Secrets before deploying."
exit 1
fi
- name: Validate JavaScript
shell: bash
run: |
set -euo pipefail
find . -path ./node_modules -prune -o -name '*.js' -print | xargs -n1 node --check
- name: Write .env.production from secret
shell: bash
run: |
set -euo pipefail
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
mkdir -p "$DEPLOY_PATH"
printf '%s\n' "${{ secrets.ENV_PRODUCTION }}" > "$DEPLOY_PATH/.env.production"
chmod 600 "$DEPLOY_PATH/.env.production"
- 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
}
if [ -d "$DEPLOY_PATH" ] && [ -n "$(ls -A "$DEPLOY_PATH" 2>/dev/null)" ]; then
rsync -a --delete "$DEPLOY_PATH"/ "$DEPLOY_PATH.previous"/
fi
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
find . -path ./node_modules -prune -o -name '*.js' -print | xargs -n1 node --check
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 ## Estado atual
1. Coleta de tickets SN por watermark. 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. 3. Sincronizacao de comentarios e tasks em duas direcoes.
4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI. 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). 5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura:
6. Flags para desligar sincronizacao legada de status. `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 ## Fluxo do ciclo
@ -17,27 +19,24 @@ Executado pelo `main()`:
1. `processErrorController` 1. `processErrorController`
2. `processTicketsController` 2. `processTicketsController`
3. `processCommentsController` 3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
4. `processGlpiClosureController` (quando `ENABLE_GLPI_CLOSE_CRON=true`) 4. `processCommentsController`
5. `processStatusAndClosureController` apenas quando `ENABLE_STATUS_SYNC=true`
## Documentacao detalhada ## Documentacao detalhada
1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md) 1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md)
2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md) 2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md)
3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md) 3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md)
4. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md) 4. Manual do usuario (se eu fizer X, acontece Y): [docs/manualusuario.md](docs/manualusuario.md)
5. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md)
## Variaveis de ambiente importantes ## Variaveis de ambiente importantes
### Feature flags ### Feature flags
1. `ENABLE_STATUS_SYNC` 1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: liga fluxo legado de status bidirecional - `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
- `false`: desliga fluxo legado de status - `false`: desliga o motor inteiro
2. `ENABLE_GLPI_CLOSE_CRON`
- `true`: liga monitor de fechamento definitivo GLPI=6
- `false`: desliga monitor de fechamento definitivo
### ServiceNow ### ServiceNow
@ -72,9 +71,63 @@ npm run dev
## Producao ## Producao
1. PM2: ### Deploy manual na VM
```bash ```bash
git pull origin master
npm ci --omit=dev
pm2 start ecosystem.config.js --env production pm2 start ecosystem.config.js --env production
pm2 save
```
Se o processo ja estiver rodando:
```bash
pm2 restart sn-glpi-sync-cron --update-env
```
### Deploy via Gitea Actions
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 `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:
```bash
pm2 list
pm2 logs sn-glpi-sync-cron
```
### Onde fica o `.env.production`
O `.env.production` nunca e versionado no git — o rsync do deploy exclui `.env*` de proposito,
exatamente para nunca sobrescrever esse arquivo com o codigo. O proprio workflow ja materializa
esse arquivo a cada deploy a partir do secret `ENV_PRODUCTION` (Settings > Actions > Secrets no
Gitea), entao a fonte da verdade das credenciais de producao e esse secret, nao um arquivo mantido
a mao na VM. Detalhes:
1. Ele existe dentro da propria pasta de deploy (`$PROD_DEPLOY_PATH/.env.production`, ex.:
`/opt/sn-glpi-sync-new/.env.production`) — nao na raiz de nenhum usuario (nem `dev`, nem
`root`). `cron.js`/`src/app.js` resolvem o caminho do env relativo a propria raiz do repo, entao
e ali que ele precisa estar.
2. O workflow ja aplica `chmod 600` nele apos escrever (dono = usuario que roda o PM2), evitando
outro usuario local da VM lendo credencial de banco/API em texto puro.
3. Pra atualizar uma credencial/config de producao, edite o secret `ENV_PRODUCTION` no Gitea e rode
o deploy de novo (push em `master` ou `workflow_dispatch`) — nao precisa mais SSH na VM pra
editar arquivo a mao. Como o secret fica guardado no Gitea (fora da VM), tambem resolve o risco
de perder a unica copia das credenciais se o disco da VM falhar.
### Rollback manual
O deploy guarda a versao anterior em `$PROD_DEPLOY_PATH.previous` antes de sincronizar o codigo
novo. Se o deploy subir uma versao com problema (o `node --check` so pega erro de sintaxe, nao de
logica), o rollback e:
```bash
rsync -a --delete "$PROD_DEPLOY_PATH.previous"/ "$PROD_DEPLOY_PATH"/
cd "$PROD_DEPLOY_PATH" && pm2 restart ecosystem.config.js --update-env
``` ```
## Observacoes ## Observacoes

View File

@ -4,9 +4,9 @@
1. `processErrorController` 1. `processErrorController`
2. `processTicketsController` 2. `processTicketsController`
3. `processCommentsController` 3. `processTicketLifecycleController` (se `ENABLE_GLPI_CLOSE_CRON=true`) — motor de regras unico
4. `processGlpiClosureController` (se `ENABLE_GLPI_CLOSE_CRON=true`) pra status/fechamento/reabertura, ver secao 4.
5. `processStatusAndClosureController` apenas se `ENABLE_STATUS_SYNC=true` 4. `processCommentsController`
## 2. Fluxo SN -> GLPI ## 2. Fluxo SN -> GLPI
@ -35,22 +35,55 @@
3. Envia comentario para SN evitando duplicidade. 3. Envia comentario para SN evitando duplicidade.
4. Atualiza `destiny_id` para rastrear espelhamento. 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`. Substitui os antigos `processGlpiClosureController` + `processStatusController`. Nao ha mais dois
2. Busca status real no GLPI. controladores decidindo o mesmo ticket, e nenhuma decisao depende de `source_last` (campo mantido
3. Se status GLPI = `5`: so como rastro de auditoria). Roda pra todo ticket com `glpi_ticket_id` que nao esteja
- busca nota de solucao `closed`/`ignored`/`error_permanent`:
- se SN estiver em espera, move para em atendimento
- resolve SN 1. **GLPI status `5` (Solucionado)**:
- marca `ticket_sync` como `solved/solved` - se o ticket ja estava `solved` localmente, so verifica se o SN foi reaberto (ver secao 5) e
4. Se status GLPI = `6`: para por ai;
- adiciona comentario no SN: - senao: verifica fora de escopo; se nao for, busca nota de solucao, se SN estiver em
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." 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 - nao altera status do chamado no SN
- marca `ticket_sync` como `closed/closed` para retirar do monitoramento - 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 ## 5. Reabertura
1. Reabertura automatica permitida apenas para tickets em GLPI `5`. 1. Reabertura automatica permitida apenas para tickets em GLPI `5` — verificado a cada ciclo pelo
2. Se GLPI `6`, reabertura automatica bloqueada. 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.

103
docs/manualusuario.md Normal file
View File

@ -0,0 +1,103 @@
# Manual do Usuario - Integracao ServiceNow <-> GLPI
Este documento e um guia pratico pra quem usa o ServiceNow (SN) ou o GLPI no dia a dia e quer saber
"se eu fizer X, o que acontece do outro lado?". Nao e um documento tecnico - pra detalhes de
implementacao, ver `docs/regrasdenegocio.md` e `docs/fluxo.md`.
## 1. Abertura de chamado
- Se um chamado novo e aberto no ServiceNow, atribuido ao grupo Sothis, entao um chamado
correspondente e criado automaticamente no GLPI.
- O campo "Ticket Externo" no SN e preenchido com o numero do chamado GLPI logo apos a criacao.
## 2. Comentarios e tarefas
- Se voce escreve um comentario no SN, entao ele aparece como um followup no GLPI.
- Se o time tecnico escreve um followup/comentario no GLPI, entao ele aparece como comentario no SN
(visivel pro solicitante).
- Se uma work note e registrada no SN, entao ela vira uma tarefa (task) no GLPI.
- Se uma task e criada no GLPI, entao ela **nao** aparece no SN - tasks do GLPI sao uso interno do
time tecnico, nao chegam ao solicitante.
- Comentarios e tarefas nunca duplicam, mesmo se o sistema processar o mesmo chamado varias vezes
seguidas.
- Hoje a integracao so sincroniza texto - imagens e anexos nao atravessam de um sistema pro outro.
## 3. Quando o GLPI resolve o chamado
- Se o time tecnico marca o chamado como "Solucionado" no GLPI, entao o chamado e resolvido
automaticamente no ServiceNow tambem, usando a nota de solucao do GLPI como base.
- Se o chamado no SN estava em "Em Espera" ou "Aguardando Atendimento" nesse momento, entao ele e
movido primeiro pra "Em Atendimento" e so depois resolvido (o SN exige isso pra permitir a
resolucao).
## 4. Quando o GLPI encerra definitivamente o chamado
- Se o time tecnico marca o chamado como "Fechado" no GLPI, entao um aviso e publicado
automaticamente no SN: "Chamado encerrado definitivamente. Reabertura deste chamado nao sera
atendida. Caso necessario, abra um novo chamado."
- O status do chamado no SN **nao** e alterado por esse evento - so o comentario e adicionado.
- O chamado sai do monitoramento automatico da integracao - nenhuma acao futura acontece nele.
## 5. Reabertura de chamado
- Se o solicitante reabre o chamado no SN e o chamado no GLPI estava "Solucionado", entao o GLPI
tambem volta pra "Em Atendimento" automaticamente.
- Se o chamado no GLPI ja estava "Fechado" (encerramento definitivo), entao a reabertura **nao** e
refletida no GLPI - nesse caso e preciso abrir um chamado novo.
## 6. Quando o SN resolve ou encerra por conta propria (antes do GLPI)
Se o solicitante ou alguem no SN resolve/encerra o chamado, mas o GLPI ainda nao tinha chegado em
"Solucionado"/"Fechado", o GLPI **nao** fecha automaticamente. Em vez disso:
1. Uma nota e adicionada no GLPI avisando que o chamado foi resolvido/encerrado pelo SN (com o nome
de quem fez isso).
2. Um aviso e publicado de volta no SN dizendo que a Sothis nao vai mais atender esse chamado.
3. O chamado e transferido pra fila de TI da CAOA no SN.
4. O chamado sai do monitoramento automatico da integracao.
## 7. Chamados fora do escopo da Sothis
- Se o time tecnico usa o tipo de solucao "fora de escopo" ao solucionar no GLPI, entao:
1. A justificativa e enviada como nota pro SN.
2. O grupo responsavel no GLPI e removido do chamado.
3. O chamado e transferido pra fila de TI da CAOA no SN.
4. O chamado sai do monitoramento automatico - isso nao e um encerramento normal.
## 8. Mandante - espelhamento de status intermediario (recurso opcional, hoje desligado)
- Por padrao, mudancas de status "no meio do caminho" (Aguardando Atendimento / Em Atendimento / Em
Espera) em qualquer um dos dois sistemas **nao** sao refletidas automaticamente no outro - cada
time so ve as mudancas que ele mesmo fizer no proprio sistema.
- Existe uma configuracao (`MANDANTE`) que pode ligar esse espelhamento numa direcao so - fazendo um
sistema "mandar" nesses status intermediarios sobre o outro. **Hoje esse recurso esta desligado em
producao.**
- Esse recurso, mesmo quando ligado, nunca muda o comportamento dos itens 3 a 7 acima (resolucao,
fechamento definitivo, fora de escopo, reabertura) - essas regras sao sempre fixas.
## 9. O que nao acontece automaticamente hoje
- Imagens e anexos nao sao sincronizados entre os sistemas (nem SN -> GLPI, nem GLPI -> SN), so
texto.
- Um chamado com erro de sincronizacao e reprocessado automaticamente, mas so ate um numero maximo
de tentativas. Depois disso ele fica marcado pra intervencao manual - vale avisar o time tecnico
se um chamado parecer "parado".
- Mudanca de status intermediario (item 8) so e refletida entre os sistemas se o recurso Mandante
estiver ligado - por padrao, nao esta.
## 10. Resumo rapido
| Se voce... | O que acontece |
| --- | --- |
| Abre um chamado no SN pro grupo Sothis | Cria automaticamente no GLPI |
| Comenta no SN | Vira followup no GLPI |
| Comenta/responde no GLPI | Vira comentario no SN |
| Cria work note no SN | Vira task no GLPI |
| Cria task no GLPI | Fica so no GLPI, nao vai pro SN |
| Marca "Solucionado" no GLPI | Resolve automaticamente no SN |
| Marca "Fechado" no GLPI | Publica aviso definitivo no SN, sem reabertura |
| Reabre no SN (GLPI estava Solucionado) | GLPI volta pra Em Atendimento |
| Reabre no SN (GLPI estava Fechado) | Nao reflete - precisa abrir chamado novo |
| Resolve/encerra no SN antes do GLPI | GLPI recebe nota, SN recebe aviso de que a Sothis nao atende mais e vai pra fila CAOA |
| Marca solucao "fora de escopo" no GLPI | Chamado sai do fluxo Sothis, vai pra fila CAOA no SN |
| Muda status intermediario em qualquer sistema | Nao reflete no outro, a menos que o Mandante esteja ligado (hoje desligado) |

View File

@ -23,20 +23,19 @@
2. Ordem de execucao: 2. Ordem de execucao:
- `processErrorController` - `processErrorController`
- `processTicketsController` - `processTicketsController`
- `processGlpiClosureController` (se habilitado) - `processTicketLifecycleController` (motor de regras unico de status/fechamento/reabertura, se habilitado)
- `processCommentsController` - `processCommentsController`
- `processStatusAndClosureController` (somente legado, se habilitado)
3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo. 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 ## 4. Feature flags e comportamento
1. `ENABLE_STATUS_SYNC` 1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: executa sincronizacao legada de status bidirecional. - `true`: executa o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`).
- `false`: desliga fluxo legado de status. - `false`: desliga o motor inteiro.
2. `ENABLE_GLPI_CLOSE_CRON` 2. `ENABLE_GLPI_WEBHOOK`
- `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. - reservado para rollout por evento (planejado), sem obrigatoriedade no fluxo atual.
## 5. Regras de coleta de tickets no ServiceNow ## 5. Regras de coleta de tickets no ServiceNow
@ -66,7 +65,7 @@
3. Se status SN vier aberto: 3. Se status SN vier aberto:
- `sn_sync_status = collected` - `sn_sync_status = collected`
- `glpi_sync_status = pending_check` - `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 ## 8. Regras de criacao/vinculo no GLPI
@ -129,21 +128,17 @@
## 14. Regras de fechamento e resolucao ## 14. Regras de fechamento e resolucao
### 14.1 Fluxo novo (prioritario) 1. Fechamento definitivo GLPI `status=6` e refletido no SN via `processTicketLifecycleController`.
1. Fechamento definitivo GLPI `status=6` e refletido no SN via monitor cron.
2. Ao detectar GLPI=6: 2. Ao detectar GLPI=6:
- enviar comentario no SN: - enviar comentario no SN:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." - "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
- nao alterar status do chamado no SN - nao alterar status do chamado no SN
- marcar `ticket_sync` como `closed/closed` para retirar do monitoramento - 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`. 3. Aviso de encerramento definitivo no SN e idempotente por `source_id` em `ticket_updates`.
4. Quando o SN resolve/encerra por conta propria e o GLPI ainda nao chegou em 5/6: nota idempotente
### 14.2 Fluxo legado de status (quando habilitado) 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
1. Pode propagar estado entre SN e GLPI. legada (removida).
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) ## 15. Regras de fora do escopo (Sothis)
@ -168,8 +163,11 @@
1. `closed` 1. `closed`
2. `ignored` 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 ## 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. 3. Tickets fora do escopo saem de monitoramento com estado final correto.
4. Integracao nao entra em loop por status intermediario. 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 { processTicketsController } = require('./controllers/processTicketsController');
const { processCommentsController } = require('./controllers/processCommentsController'); const { processCommentsController } = require('./controllers/processCommentsController');
const { processErrorController } = require('./controllers/processErrorController'); const { processErrorController } = require('./controllers/processErrorController');
const { processStatusAndClosureController } = require('./controllers/processStatusController'); const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController');
const { processGlpiClosureController } = require('./controllers/processGlpiClosureController');
const { logInfo } = require('./utils/logger'); const { logInfo } = require('./utils/logger');
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`); logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
@ -25,24 +24,15 @@ logInfo('Aplicacao iniciada', {
async function main() { async function main() {
try { try {
const statusSyncEnabled = process.env.ENABLE_STATUS_SYNC === 'true'; const ticketLifecycleEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
const glpiCloseCronEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
await processErrorController(); await processErrorController();
await processTicketsController(); await processTicketsController();
if (glpiCloseCronEnabled) { if (ticketLifecycleEnabled) {
await processGlpiClosureController(); await processTicketLifecycleController();
} else { } else {
logInfo('Monitor de encerramento definitivo GLPI desabilitado (ENABLE_GLPI_CLOSE_CRON=false).'); logInfo('Motor de regras de status/fechamento desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
}
if (statusSyncEnabled) {
await processStatusAndClosureController();
} else {
logInfo('Sincronizacao legada de status desabilitada (ENABLE_STATUS_SYNC=false).');
} }
await processCommentsController(); await processCommentsController();

View File

@ -1,66 +1,27 @@
const { fetchCommentsFromServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService'); const { fetchCommentsFromServiceNow } = require('../services/servicenowService');
const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService'); const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logInfo } = require('../utils/logger'); const { logDebug } = 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 processCommentsController = async () => {
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`); logDebug(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
for (const ticket of ticketsToMonitor) { for (const ticket of ticketsToMonitor) {
const reopenState = await ensureReopenFromSNWhenGlpiSolved(ticket); const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const reopenedThisCycle = reopenState.reopened; if (glpiStatus === 5) {
const glpiWasSolved = reopenState.glpiSolved; // Regra de negocio: enquanto o GLPI estiver Solucionado, nao manda comentario novo
if (glpiWasSolved && !reopenedThisCycle) { // do SN pra dentro do chamado. A decisao de reabertura acontece em
logInfo(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`); // processTicketLifecycleController.js, que roda antes deste controlador no ciclo.
logDebug(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`);
await syncCommentsGlpitoSN(ticket); await syncCommentsGlpitoSN(ticket);
continue; continue;
} }
const comments = await fetchCommentsFromServiceNow(ticket); const comments = await fetchCommentsFromServiceNow(ticket);
if (comments.length !== 0) { if (comments.length !== 0) {
await syncCommentsSNtoGlpi(comments, ticket, { highlightReopenContext: reopenedThisCycle }); await syncCommentsSNtoGlpi(comments, ticket);
} }
await syncCommentsGlpitoSN(ticket); await syncCommentsGlpitoSN(ticket);

View File

@ -1,19 +1,29 @@
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning, logDebug } = require('../utils/logger');
const MAX_ERROR_RETRIES = Number(process.env.MAX_ERROR_RETRIES || 5);
const processErrorController = async () => { const processErrorController = async () => {
logInfo('Iniciando verificação de tickets com erro...'); logDebug('Iniciando verificação de tickets com erro...');
try { try {
const errorTickets = await TicketSyncModel.getTicketsInErrorState(); const errorTickets = await TicketSyncModel.getTicketsInErrorState();
if (errorTickets.length === 0) { if (errorTickets.length === 0) {
logInfo('Nenhum ticket com erro encontrado para reprocessamento.'); logDebug('Nenhum ticket com erro encontrado para reprocessamento.');
return; return;
} }
logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`); logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`);
for (const ticket of errorTickets) { 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 newSnStatus = ticket.sn_sync_status;
let newGlpiStatus = ticket.glpi_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'; if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close';
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus); 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) { } catch (error) {

View File

@ -1,93 +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 } = require('../services/servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const { logInfo, logError } = 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 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,204 +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 } = require('../services/servicenowService');
const { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
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 não será mais exibido para equipe Sothis, com a justificativa: ${justification}`);
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

@ -2,7 +2,7 @@ const TicketSnModel = require('../models/ticketSnModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const SyncControlModel = require('../models/syncControlModel'); const SyncControlModel = require('../models/syncControlModel');
const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService'); const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService');
const { logInfo, logError, logSync } = require('../utils/logger'); const { logInfo, logError, logSync, logDebug } = require('../utils/logger');
/** /**
* Processa e salva um lote de tickets (incidentes ou requisicoes). * Processa e salva um lote de tickets (incidentes ou requisicoes).
@ -22,7 +22,7 @@ const processAndSaveTickets = async (tickets, type) => {
}; };
if (!tickets || tickets.length === 0) { if (!tickets || tickets.length === 0) {
logInfo(`Nenhum ticket do tipo '${type}' para processar.`); logDebug(`Nenhum ticket do tipo '${type}' para processar.`);
return ''; return '';
} }
@ -54,6 +54,10 @@ const processAndSaveTickets = async (tickets, type) => {
logInfo(`Variaveis da requisicao ${ticketNumber}: justificativa=${ticket.justificativa ? 'ok' : 'vazia'}, telefone=${ticket.telefone ? 'ok' : 'vazio'}.`); 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 { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type);
const ticketStatus = ticket.state; const ticketStatus = ticket.state;
@ -78,10 +82,14 @@ const processAndSaveTickets = async (tickets, type) => {
if (!exists) { if (!exists) {
await TicketSyncModel.createSyncRecord(dataSync); await TicketSyncModel.createSyncRecord(dataSync);
} else if (wasInserted === false) { } else if (wasInserted === false) {
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); if (oldStatus !== ticketStatus) {
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); 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 { } else {
logInfo(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`); logDebug(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
} }
const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on); const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
@ -106,12 +114,12 @@ const processAndSaveTickets = async (tickets, type) => {
const processSyncController = async () => { const processSyncController = async () => {
try { try {
const watermark = await SyncControlModel.getWatermark('servicenow'); const watermark = await SyncControlModel.getWatermark('servicenow');
logInfo(`INFO: Buscando tickets do ServiceNow atualizados desde: ${watermark}`); logDebug(`Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
const incidents = await fetchTicketsApi(watermark); const incidents = await fetchTicketsApi(watermark);
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente'); const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
logInfo('INFO: Buscando requisicoes do ServiceNow...'); logDebug('Buscando requisicoes do ServiceNow...');
const requests = await fetchRequestsApi(watermark); const requests = await fetchRequestsApi(watermark);
const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao'); const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao');
@ -123,7 +131,7 @@ const processSyncController = async () => {
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis); const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
await SyncControlModel.setWatermark('servicenow', nextWatermark); await SyncControlModel.setWatermark('servicenow', nextWatermark);
} else { } else {
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada."); logDebug("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
} }
} catch (error) { } catch (error) {

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, logDebug } = 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) {
logDebug('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

@ -1,7 +1,7 @@
// src/data/database.js // src/data/database.js
// Configuração da conexão com o banco de dados PostgreSQL // Configuração da conexão com o banco de dados PostgreSQL
const { Pool } = require('pg'); const { Pool } = require('pg');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError, logDebug } = require('../utils/logger');
const poolConfig = { const poolConfig = {
host: process.env.SNGLPI_DB_HOST , host: process.env.SNGLPI_DB_HOST ,
@ -15,13 +15,11 @@ const pool = new Pool(poolConfig);
// Log da configuração do pool para depuração (sem a senha) // Log da configuração do pool para depuração (sem a senha)
const sanitizedConfig = { ...poolConfig, password: '*****' }; const sanitizedConfig = { ...poolConfig, password: '*****' };
logInfo('--- Configuração do Pool PostgreSQL (Banco Intermediário) ---'); logInfo('Configuração do Pool PostgreSQL (Banco Intermediário)', sanitizedConfig);
logInfo(sanitizedConfig);
logInfo('----------------------------------------------------------');
// testa a conexao // testa a conexao
pool.on('connect', () => { pool.on('connect', () => {
logInfo('Conectado ao PostgreSQL'); logDebug('Conectado ao PostgreSQL');
}); });
pool.on('error', (err) => { pool.on('error', (err) => {

View File

@ -1,7 +1,7 @@
// src/data/glpiDatabase.js // src/data/glpiDatabase.js
// Configuração da conexão com o banco de dados mariaDB do GLPI // Configuração da conexão com o banco de dados mariaDB do GLPI
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logError, logDebug } = require('../utils/logger');
const glpiPool = mysql.createPool({ const glpiPool = mysql.createPool({
host: process.env.GLPI_DB_HOST, host: process.env.GLPI_DB_HOST,
@ -18,7 +18,7 @@ const glpiPool = mysql.createPool({
// Testar conexão // Testar conexão
glpiPool.on('connection', (connection) => { glpiPool.on('connection', (connection) => {
logInfo('Nova conexão GLPI estabelecida'); logDebug('Nova conexão GLPI estabelecida');
}); });
glpiPool.on('error', (err) => { glpiPool.on('error', (err) => {

View File

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

View File

@ -1,5 +1,5 @@
const pool = require('../data/database'); const pool = require('../data/database');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError, logDebug } = require('../utils/logger');
class TicketSnModel { class TicketSnModel {
@ -94,7 +94,10 @@ class TicketSnModel {
action: 'insert' action: 'insert'
}); });
} else { } else {
logInfo(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, { // Fica em debug: a margem de seguranca de 6h do watermark reenvia o mesmo ticket
// toda vez que ele ainda esta "quente", mesmo sem mudanca real de campo. O sinal
// de mudanca de status de verdade ja e logado em processSyncController.js.
logDebug(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
ticket_id: result.rows[0].id, ticket_id: result.rows[0].id,
ticket_number: values[0], ticket_number: values[0],
action: 'update' action: 'update'
@ -137,7 +140,7 @@ class TicketSnModel {
`; `;
const result = await pool.query(query); const result = await pool.query(query);
logInfo(`📋 Tickets pendentes encontrados: ${result.rows.length}`); logDebug(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
return result.rows; return result.rows;
} catch (error) { } catch (error) {

View File

@ -16,7 +16,9 @@ class TicketSyncModel {
sn_sync_status, sn_sync_status,
glpi_sync_status, glpi_sync_status,
source_last 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 = [ const values = [
data.sn_ticket_id, data.sn_ticket_id,
@ -174,7 +176,8 @@ class TicketSyncModel {
FROM ticket_sync tsync FROM ticket_sync tsync
JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id 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')) 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); const { rows } = await pool.query(query);
return rows; return rows;
} catch (error) { } catch (error) {
@ -195,8 +198,8 @@ class TicketSyncModel {
tsync.glpi_sync_status tsync.glpi_sync_status
FROM ticket_sync tsync FROM ticket_sync tsync
WHERE tsync.glpi_ticket_id IS NOT NULL WHERE tsync.glpi_ticket_id IS NOT NULL
AND tsync.glpi_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') AND tsync.sn_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
`; `;
const { rows } = await pool.query(query); const { rows } = await pool.query(query);
return rows; return rows;
@ -231,9 +234,10 @@ class TicketSyncModel {
static async getTicketsInErrorState() { static async getTicketsInErrorState() {
try { try {
const query = ` 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 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); const { rows } = await pool.query(query);
return rows; 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) { static async updateLastSync(snTicketId, system) {
try { try {
let fieldToUpdate; 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

@ -1,7 +1,7 @@
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError, logDebug } = require('../utils/logger');
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService'); const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
@ -81,29 +81,37 @@ const formatSNUpdateForGlpi = (update, options = {}) => {
const syncCommentsGlpitoSN = async (ticket) => { const syncCommentsGlpitoSN = async (ticket) => {
try { try {
logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...'); logDebug('Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id); const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
if (!Array.isArray(comments) || comments.length === 0) { if (!Array.isArray(comments) || comments.length === 0) {
logInfo(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); logDebug(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return; return;
} }
logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`); logDebug(`${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
let hasError = false; let hasError = false;
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
for (const comment of comments) { for (const comment of comments) {
try { try {
const sanitized = sanitizeGLPIComment(comment); // Checa idempotencia local (barato, sem chamada externa) antes de sanitizar e consultar o SN.
// A maioria dos comentarios de um ticket ja monitorado ha varios ciclos ja tem destiny_id
// gravado, entao nem precisa sanitizar nem chamar a API do SN pra saber disso.
const existingComment = await TicketUpdateModel.getBySourceId(comment.id); const existingComment = await TicketUpdateModel.getBySourceId(comment.id);
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized); if (existingComment && existingComment.destiny_id) {
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); logDebug(`Comentario ${comment.id} ja sincronizado.`);
if (existingComment && existsInSN) {
logInfo(`INFO: Comentario ${comment.id} ja sincronizado.`);
continue; continue;
} }
const sanitized = sanitizeGLPIComment(comment);
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized);
if (existingComment && existsInSN) {
logDebug(`Comentario ${comment.id} ja sincronizado.`);
continue;
}
if (!existingComment && existsInSN) { if (!existingComment && existsInSN) {
await TicketUpdateModel.insert({ await TicketUpdateModel.insert({
ticket_sync_id: syncId, ticket_sync_id: syncId,
@ -157,7 +165,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus);
} }
logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!'); logDebug('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
} catch (error) { } catch (error) {
logError(`Erro geral na sincronizacao: ${error}`); logError(`Erro geral na sincronizacao: ${error}`);
} }
@ -165,7 +173,7 @@ const syncCommentsGlpitoSN = async (ticket) => {
const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => { const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...'); logDebug('Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
const orderedComments = [...comments].sort((a, b) => { const orderedComments = [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = new Date(a.created_at).getTime();

View File

@ -2,19 +2,20 @@ const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError, logDebug } = require('../utils/logger');
const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry } = require('./servicenowService'); const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry, reassignTicketInServiceNow } = require('./servicenowService');
const { stripHTML } = require('../utils/commentSanitizer'); const { stripHTML } = require('../utils/commentSanitizer');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
const syncTicketsToGlpi = async () => { const syncTicketsToGlpi = async () => {
try { try {
logInfo('🔄 Iniciando sincronização para GLPI...'); logDebug('🔄 Iniciando sincronização para GLPI...');
const pendingTickets = await TicketSnModel.getPendingTickets(); const pendingTickets = await TicketSnModel.getPendingTickets();
if (pendingTickets.length === 0) { if (pendingTickets.length === 0) {
logInfo('✅ Nenhum ticket pendente para sincronizar'); logDebug('✅ Nenhum ticket pendente para sincronizar');
return; return;
} }
@ -99,93 +100,68 @@ const processSingleTicket = async (ticket) => {
} }
}; };
const checkGlpiStatusChanges = async () => { const isOutOfScopeSolution = (solution) => solution?.solutiontypes_id === outOfScopeSolutionTypeId;
try {
logInfo('📡 Verificando mudanças de status nos tickets do GLPI...');
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); /**
* Regra "fora de escopo": nota no SN + transferencia para fila CAOA + remove grupo do GLPI + fecha
if (ticketsToMonitor.length === 0) { * o ticket no GLPI. Antes existia uma copia quase identica em processGlpiClosureController.js e
logInfo('Nenhum ticket ativo para verificar status no GLPI.'); * outra em processStatusController.js (uma delas nao removia o grupo do GLPI) - consolidado aqui.
return; */
} const handleOutOfScopeResolution = async (ticket, solution) => {
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
for (const ticket of ticketsToMonitor) { await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id);
const currentDbStatus = ticket.glpi_sync_status; await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); 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.`);
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 fechado no GLPI com a justificativa: ${justification}`);
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');
}
}; };
const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => { const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => {
const sourceId = `sn_closure_note:${ticket.glpi_ticket_id}:${finalStatus}`;
try { 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() const resolvedBy = collaboratorName && collaboratorName.trim()
? collaboratorName.trim() ? collaboratorName.trim()
: 'colaborador CAOA'; : 'colaborador CAOA';
const statusText = finalStatus === 'solucionado' const statusText = finalStatus === 'solucionado'
? 'Chamado solucionado no Service Now pela CAOA.' ? 'Chamado solucionado no Service Now e removido do monitoramento.'
: 'Chamado encerrado no Service Now pela CAOA.'; : 'Chamado encerrado no Service Now e removido do monitoramento.';
const styledMessage = ` const styledMessage = `
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;"> <div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#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;"> <div style="color:#742a2a;">
${statusText} ${statusText}
</div> </div>
<div style="margin-top:8px; color:#742a2a; font-size:12px;"> <div style="margin-top:8px; color:#742a2a; font-size:12px;">
Responsavel informado: ${resolvedBy} Responsavel: ${resolvedBy}
</div>
<div style="margin-top:6px; color:#742a2a; font-size:12px;">
Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao.
</div> </div>
</div>`; </div>`;
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id); await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); 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}.`); logInfo(`Nota de encerramento do ServiceNow adicionada ao GLPI ${ticket.glpi_ticket_id}.`);
return true; return { ok: true, isNew: true };
} catch (error) { } catch (error) {
logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`); logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`);
return false; return { ok: false, isNew: false };
} }
}; };
@ -208,9 +184,10 @@ const closeTicketInGlpi = async (ticket) => {
module.exports = { module.exports = {
syncTicketsToGlpi, syncTicketsToGlpi,
checkGlpiStatusChanges,
closeTicketInGlpi, closeTicketInGlpi,
addServiceNowClosureNoteToGlpi addServiceNowClosureNoteToGlpi,
isOutOfScopeSolution,
handleOutOfScopeResolution
}; };
/** /**
@ -221,6 +198,6 @@ module.exports = {
* Principais Funcionalidades: * 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. * - `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. * - `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". * - `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

@ -3,7 +3,7 @@ const axios = require('axios');
const apiConfig = require('../../config/apiConfig'); const apiConfig = require('../../config/apiConfig');
const { stripHTML } = require('../utils/commentSanitizer'); const { stripHTML } = require('../utils/commentSanitizer');
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const { logInfo, logError, logSync } = require('../utils/logger'); const { logInfo, logError, logSync, logDebug } = require('../utils/logger');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
@ -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) => { const getTicketSysId = async (ticketId) => {
try { try {
const ticket = await TicketSnModel.findById(ticketId); const ticket = await TicketSnModel.findById(ticketId);
@ -206,6 +229,7 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
} catch (error) { } catch (error) {
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow'); logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
throw error;
} }
} }
@ -237,7 +261,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
const fetchCommentsFromServiceNow = async (ticket) => { const fetchCommentsFromServiceNow = async (ticket) => {
try { try {
logInfo(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); logDebug(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const sys_id = ticket.sys_id; const sys_id = ticket.sys_id;
@ -257,7 +281,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
sysparm_limit: pageSize, sysparm_limit: pageSize,
sysparm_offset: offset sysparm_offset: offset
}; };
logInfo(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`); logDebug(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, { const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, {
auth: apiConfig.servicenowAuthentication.auth, auth: apiConfig.servicenowAuthentication.auth,
params params
@ -314,11 +338,11 @@ const fetchCommentsFromServiceNow = async (ticket) => {
return aTime - bTime; return aTime - bTime;
}); });
if (orderedComments.length === 0) { if (orderedComments.length === 0) {
logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); logDebug(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return []; return [];
} }
logInfo(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) logDebug(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
const commentsInserted = []; const commentsInserted = [];
@ -327,7 +351,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id); const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
if (existingComment) { if (existingComment) {
logInfo(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 }); logDebug(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 });
continue; continue;
} else { } else {
@ -417,7 +441,7 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
const stateMap = { const stateMap = {
'Aguardando Atendimento': '1', // New 'Aguardando Atendimento': '1', // New
'Em Atendimento': '2', // In Progress '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': '6', // Resolved
'Encerrado - Omitido': '7' // Closed 'Encerrado - Omitido': '7' // Closed
}; };
@ -458,7 +482,15 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
logError(`ERRO: Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode }); logError(`ERRO: Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`); 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 }); logInfo(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
return true; 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}`); 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 }); logInfo(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
return true; return true;
@ -685,6 +726,44 @@ const updateExternalTicketInServiceNow = async (snTicketId, glpiTicketId) => {
} }
}; };
const reassignTicketInServiceNow = async (snTicketId, groupSysId) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para reatribuicao de grupo.`, 'reassignTicketInServiceNow');
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 response = await axios.patch(url, { assignment_group: groupSysId }, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
if (response.status !== 200) {
logError(`Falha ao reatribuir grupo no SN. Status: ${response.status}`, { snTicketId, groupSysId });
return false;
}
logInfo(`OK: Ticket SN ${snTicketId} reatribuido para grupo ${groupSysId}.`, { snTicketId, sysId });
return true;
} catch (error) {
logError(error, `ERRO: Falha ao reatribuir grupo no SN (sysId: ${sysId})`);
return false;
}
};
const queueExternalTicketLinkRetry = async (ticketSyncId, snTicketId, glpiTicketId, shouldMarkDone = false) => { const queueExternalTicketLinkRetry = async (ticketSyncId, snTicketId, glpiTicketId, shouldMarkDone = false) => {
const sourceId = `external_link:${snTicketId}:${glpiTicketId}`; const sourceId = `external_link:${snTicketId}:${glpiTicketId}`;
const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField }); const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField });
@ -772,6 +851,7 @@ module.exports = {
fetchTicketsFromServiceNow, fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow, fetchRequestsFromServiceNow,
fetchTicketLiveStatusFromServiceNow, fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
getTicketSysId, getTicketSysId,
getTicketTypeById, getTicketTypeById,
createCommentInServiceNow, createCommentInServiceNow,
@ -786,6 +866,7 @@ module.exports = {
addCommentToServiceNow, addCommentToServiceNow,
fetchScItemOptionValue, fetchScItemOptionValue,
updateExternalTicketInServiceNow, updateExternalTicketInServiceNow,
reassignTicketInServiceNow,
queueExternalTicketLinkRetry, queueExternalTicketLinkRetry,
processPendingExternalTicketLinks processPendingExternalTicketLinks
}; };

View File

@ -1,4 +1,4 @@
const { logInfo, logWarning } = require('./logger'); const { logDebug, logWarning } = require('./logger');
const stripHTML = (html) => { const stripHTML = (html) => {
if (!html) return ''; if (!html) return '';
@ -76,7 +76,7 @@ const sanitizeGLPIComment = (commentObj) => {
content = stripHTML(content); content = stripHTML(content);
if (content !== commentObj.content) { if (content !== commentObj.content) {
logInfo('🔧 Comentário sanitizado', { logDebug('🔧 Comentário sanitizado', {
original: commentObj.content.substring(0, 100) + '...', original: commentObj.content.substring(0, 100) + '...',
cleaned: content.substring(0, 100) + '...' cleaned: content.substring(0, 100) + '...'
}); });

View File

@ -94,12 +94,18 @@ const logWarning = (message, meta = {}) => {
logger.warn(message, meta); logger.warn(message, meta);
}; };
// Log de sincronização específico const logDebug = (message, meta = {}) => {
logger.debug(message, meta);
};
// Log de sincronização específico. Fica em debug: por causa da margem de seguranca do
// watermark, esse log dispara toda vez que ha qualquer ticket "quente" no lote, mesmo sem
// mudanca real de campo - o sinal de mudanca de verdade e logado no chamador.
const logSync = (service, count, type) => { const logSync = (service, count, type) => {
logger.info(`SYNC: ${service} - ${count} ${type} sincronizados`, { logger.debug(`SYNC: ${service} - ${count} ${type} sincronizados`, {
service, service,
count, count,
type type
}); });
}; };
@ -108,5 +114,6 @@ module.exports = {
logError, logError,
logInfo, logInfo,
logWarning, logWarning,
logDebug,
logSync, logSync,
}; };