diff --git a/.env.example b/.env.example index 313e573..c4b4b6f 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,19 @@ PORT=3000 # Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos) CRON_SCHEDULE='* * * * *' -ENABLE_STATUS_SYNC=false ENABLE_GLPI_WEBHOOK=true ENABLE_GLPI_CLOSE_CRON=true +# Mandante (regras 19-22): sistema cujo status intermediario (Aguardando Atendimento/Em +# Atendimento/Em Espera) e espelhado automaticamente no outro sistema. Nunca afeta status +# finais (Resolvido/Encerrado/fora de escopo), so esses tres intermediarios. +# Valores: GLPI | SNOW | vazio (nenhum espelhamento, comportamento padrao) +MANDANTE= + +# Numero maximo de vezes que um ticket em estado de erro e reprocessado +# automaticamente antes de ser marcado como 'error_permanent' (exige intervencao manual). +MAX_ERROR_RETRIES=5 + # --- Configuracao da API do ServiceNow --- # URL base da sua instancia do ServiceNow SERVICENOW_INSTANCE=https://your-instance.service-now.com @@ -38,6 +47,8 @@ SERVICENOW_TABLE_REQUEST_URL= SERVICENOW_TABLE_JOURNAL_URL= SERVICENOW_SC_ITEM_OPTION_URL= 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) --- GLPI_DB_TYPE=mysql @@ -68,7 +79,7 @@ SNGLPI_DB_PASSWORD= LOCATION_MAPPING_CSV_PATH=/path/to/your/location_mapping.csv # Logging -LOG_LEVEL=debug +LOG_LEVEL=info LOG_TO_CONSOLE=true LOG_RETENTION_DAYS=10 LOG_DIR=logs diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml new file mode 100644 index 0000000..f37529f --- /dev/null +++ b/.gitea/workflows/deploy-production.yml @@ -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 diff --git a/README.md b/README.md index 97a256d..cf607fb 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando Postgr ## Estado atual 1. Coleta de tickets SN por watermark. -2. Criacao/vinculo de tickets no GLPI. +2. Criacao/vinculo de tickets no GLPI (grava `external_id` no GLPI com o numero do ticket SN). 3. Sincronizacao de comentarios e tasks em duas direcoes. 4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI. -5. Monitor GLPI para `status=5` (resolver SN) e `status=6` (encerrar definitivamente no SN). -6. Flags para desligar sincronizacao legada de status. +5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura: + `status=5` (resolver SN), `status=6` (encerrar definitivamente no SN), nota no GLPI quando o SN + resolve/encerra por conta propria (com aviso e troca de fila no SN), reabertura automatica e + regra de fora de escopo. ## Fluxo do ciclo @@ -17,27 +19,24 @@ Executado pelo `main()`: 1. `processErrorController` 2. `processTicketsController` -3. `processCommentsController` -4. `processGlpiClosureController` (quando `ENABLE_GLPI_CLOSE_CRON=true`) -5. `processStatusAndClosureController` apenas quando `ENABLE_STATUS_SYNC=true` +3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`) +4. `processCommentsController` ## Documentacao detalhada 1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md) 2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md) 3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md) -4. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md) +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 ### Feature flags -1. `ENABLE_STATUS_SYNC` - - `true`: liga fluxo legado de status bidirecional - - `false`: desliga fluxo legado de status -2. `ENABLE_GLPI_CLOSE_CRON` - - `true`: liga monitor de fechamento definitivo GLPI=6 - - `false`: desliga monitor de fechamento definitivo +1. `ENABLE_GLPI_CLOSE_CRON` + - `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`) + - `false`: desliga o motor inteiro ### ServiceNow @@ -72,9 +71,63 @@ npm run dev ## Producao -1. PM2: +### Deploy manual na VM + ```bash +git pull origin master +npm ci --omit=dev 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 diff --git a/docs/fluxo.md b/docs/fluxo.md index 213fe1c..90b2340 100644 --- a/docs/fluxo.md +++ b/docs/fluxo.md @@ -4,9 +4,9 @@ 1. `processErrorController` 2. `processTicketsController` -3. `processCommentsController` -4. `processGlpiClosureController` (se `ENABLE_GLPI_CLOSE_CRON=true`) -5. `processStatusAndClosureController` apenas se `ENABLE_STATUS_SYNC=true` +3. `processTicketLifecycleController` (se `ENABLE_GLPI_CLOSE_CRON=true`) — motor de regras unico + pra status/fechamento/reabertura, ver secao 4. +4. `processCommentsController` ## 2. Fluxo SN -> GLPI @@ -35,22 +35,55 @@ 3. Envia comentario para SN evitando duplicidade. 4. Atualiza `destiny_id` para rastrear espelhamento. -## 4. Resolucao e fechamento GLPI -> SN (monitor cron) +## 4. Motor de regras unico (`processTicketLifecycleController`) -1. Monitor consulta tickets ativos no `ticket_sync`. -2. Busca status real no GLPI. -3. Se status GLPI = `5`: - - busca nota de solucao - - se SN estiver em espera, move para em atendimento - - resolve SN - - marca `ticket_sync` como `solved/solved` -4. Se status GLPI = `6`: - - adiciona comentario no SN: - - "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." +Substitui os antigos `processGlpiClosureController` + `processStatusController`. Nao ha mais dois +controladores decidindo o mesmo ticket, e nenhuma decisao depende de `source_last` (campo mantido +so como rastro de auditoria). Roda pra todo ticket com `glpi_ticket_id` que nao esteja +`closed`/`ignored`/`error_permanent`: + +1. **GLPI status `5` (Solucionado)**: + - se o ticket ja estava `solved` localmente, so verifica se o SN foi reaberto (ver secao 5) e + para por ai; + - senao: verifica fora de escopo; se nao for, busca nota de solucao, se SN estiver em + espera/aguardando move pra "Em Atendimento", resolve o SN **confirmando via leitura ao vivo** + antes de marcar `solved/solved` (nunca marca so por causa de um HTTP 200). +2. **GLPI status `6` (Fechado definitivamente)**: + - adiciona comentario idempotente no SN: "Chamado encerrado definitivamente. Reabertura deste + chamado nao sera atendida. Caso necessario, abra um novo chamado." - nao altera status do chamado no SN - marca `ticket_sync` como `closed/closed` para retirar do monitoramento +3. **GLPI ainda aberto (`< 5`) e SN resolvido/encerrado por conta propria**: + - adiciona nota idempotente no GLPI avisando o encerramento vindo do SN, sem fechar o chamado + (inclui o nome de quem encerrou no SN, buscado ao vivo: `resolved_by` -> `closed_by` -> + `sys_updated_by`, o que vier preenchido primeiro) + - (primeira vez apenas) avisa no proprio SN que a Sothis nao vai mais atender esse chamado e + transfere pra fila CAOA + - marca `ticket_sync` como `solved/solved` ou `closed/closed` +4. **GLPI ainda aberto (`< 5`) e SN tambem em status intermediario** (nenhum dos dois em + Resolvido/Encerrado): aplica o mandante, se configurado — ver secao 6. ## 5. Reabertura -1. Reabertura automatica permitida apenas para tickets em GLPI `5`. -2. Se GLPI `6`, reabertura automatica bloqueada. +1. Reabertura automatica permitida apenas para tickets em GLPI `5` — verificado a cada ciclo pelo + mesmo `processTicketLifecycleController`, sem depender de nenhuma flag. +2. Se GLPI `6`, reabertura automatica bloqueada (a regra nem entra nesse caminho). + +## 6. Mandante (regras 19-22) + +Espelhamento **opcional** de status intermediario (`Aguardando Atendimento`/`Em Atendimento`/ +`Em Espera` no SN, equivalentes ao GLPI `1`/`2`/`4` — GLPI `3` tambem mapeia para +`Em Atendimento`). Nunca toca em status finais (5, 6, fora de escopo) — essas continuam sendo as +regras fixas das secoes 4.1-4.3, sempre ativas. + +Controlado pelo env `MANDANTE`: + +1. `MANDANTE=GLPI`: quando o status intermediario do GLPI muda, o SN e atualizado pra + acompanhar. Mudancas de status intermediario feitas diretamente no SN sao ignoradas. +2. `MANDANTE=SNOW`: o oposto — o SN manda, o GLPI acompanha. +3. `MANDANTE` vazio/ausente/invalido: nenhum espelhamento (comportamento padrao, igual a antes do + mandante existir). + +Em qualquer configuracao, so escreve quando o valor desejado difere do atual (evita chamada de API +desnecessaria) e nunca le o status do lado seguidor pra decidir nada — o mandante so manda, nunca +escuta, entao nao ha risco de loop entre os dois sistemas. diff --git a/docs/manualusuario.md b/docs/manualusuario.md new file mode 100644 index 0000000..4e10137 --- /dev/null +++ b/docs/manualusuario.md @@ -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) | diff --git a/docs/regrasdenegocio.md b/docs/regrasdenegocio.md index 020335c..57c2402 100644 --- a/docs/regrasdenegocio.md +++ b/docs/regrasdenegocio.md @@ -23,20 +23,19 @@ 2. Ordem de execucao: - `processErrorController` - `processTicketsController` - - `processGlpiClosureController` (se habilitado) + - `processTicketLifecycleController` (motor de regras unico de status/fechamento/reabertura, se habilitado) - `processCommentsController` - - `processStatusAndClosureController` (somente legado, se habilitado) 3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo. +4. Nao ha mais dois controladores decidindo status do mesmo ticket — o fluxo legado + (`processStatusController`) foi removido e sua logica util (nota SN->GLPI, fora de escopo) foi + absorvida pelo motor unico. ## 4. Feature flags e comportamento -1. `ENABLE_STATUS_SYNC` - - `true`: executa sincronizacao legada de status bidirecional. - - `false`: desliga fluxo legado de status. -2. `ENABLE_GLPI_CLOSE_CRON` - - `true`: executa monitor de fechamento definitivo GLPI=6. - - `false`: desliga monitor. -3. `ENABLE_GLPI_WEBHOOK` +1. `ENABLE_GLPI_CLOSE_CRON` + - `true`: executa o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`). + - `false`: desliga o motor inteiro. +2. `ENABLE_GLPI_WEBHOOK` - reservado para rollout por evento (planejado), sem obrigatoriedade no fluxo atual. ## 5. Regras de coleta de tickets no ServiceNow @@ -66,7 +65,7 @@ 3. Se status SN vier aberto: - `sn_sync_status = collected` - `glpi_sync_status = pending_check` -4. `source_last` existe no schema e legado, mas deve ser deprecado para decisao de status no modelo novo. +4. `source_last` existe no schema e continua sendo gravado como rastro de auditoria, mas nao e mais lido em nenhum lugar do codigo para decidir status. ## 8. Regras de criacao/vinculo no GLPI @@ -129,21 +128,17 @@ ## 14. Regras de fechamento e resolucao -### 14.1 Fluxo novo (prioritario) - -1. Fechamento definitivo GLPI `status=6` e refletido no SN via monitor cron. +1. Fechamento definitivo GLPI `status=6` e refletido no SN via `processTicketLifecycleController`. 2. Ao detectar GLPI=6: - enviar comentario no SN: - "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." - nao alterar status do chamado no SN - marcar `ticket_sync` como `closed/closed` para retirar do monitoramento 3. Aviso de encerramento definitivo no SN e idempotente por `source_id` em `ticket_updates`. - -### 14.2 Fluxo legado de status (quando habilitado) - -1. Pode propagar estado entre SN e GLPI. -2. Usa regras de precedencia historicas com `source_last`. -3. Deve ser considerado transitorio e desligavel por flag. +4. Quando o SN resolve/encerra por conta propria e o GLPI ainda nao chegou em 5/6: nota idempotente + no GLPI + aviso no SN (Sothis nao vai mais atender) + transferencia pra fila CAOA — ver + `docs/fluxo.md` secao 4.3. Nao depende de `source_last` nem de nenhuma flag de sincronizacao + legada (removida). ## 15. Regras de fora do escopo (Sothis) @@ -168,8 +163,11 @@ 1. `closed` 2. `ignored` +3. `error_permanent` — ticket excedeu `MAX_ERROR_RETRIES` tentativas automaticas de reprocessamento + e exige intervencao manual. -Regra geral: `closed` e `ignored` nao participam das rotinas normais de sincronizacao. +Regra geral: `closed`, `ignored` e `error_permanent` nao participam das rotinas normais de +sincronizacao. ## 17. Regra de reabertura @@ -210,6 +208,21 @@ Regra geral: `closed` e `ignored` nao participam das rotinas normais de sincroni 3. Tickets fora do escopo saem de monitoramento com estado final correto. 4. Integracao nao entra em loop por status intermediario. +## 23. Regras de mandante (espelhamento de status intermediario) + +1. Existe um sistema "mandante" configuravel via env `MANDANTE` (`GLPI`, `SNOW` ou vazio) cujo + status intermediario (`Aguardando Atendimento`/`Em Atendimento`/`Em Espera` no SN, GLPI + `1`/`2`/`3`/`4`) e espelhado automaticamente no outro sistema. +2. Nunca se aplica aos status finais (GLPI `5`/`6`, fora de escopo) - essas continuam sendo as + regras fixas 10-15, sempre ativas independente do mandante. +3. `MANDANTE` vazio, ausente ou com valor invalido mantem o comportamento padrao: nenhum + espelhamento de status intermediario (equivalente ao que a regra de contencao ja fazia antes do + mandante existir). +4. O mandante e unidirecional: so escreve no sistema seguidor, nunca le o status do seguidor para + decidir algo - evita loop de espelhamento entre os dois sistemas. +5. So escreve quando o valor espelhado realmente difere do atual, para nao gerar chamada de API + desnecessaria a cada ciclo. + diff --git a/src/app.js b/src/app.js index 78bf1aa..70cc276 100644 --- a/src/app.js +++ b/src/app.js @@ -12,8 +12,7 @@ dotenv.config({ path: path.resolve(__dirname, '..', envFile) }); const { processTicketsController } = require('./controllers/processTicketsController'); const { processCommentsController } = require('./controllers/processCommentsController'); const { processErrorController } = require('./controllers/processErrorController'); -const { processStatusAndClosureController } = require('./controllers/processStatusController'); -const { processGlpiClosureController } = require('./controllers/processGlpiClosureController'); +const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController'); const { logInfo } = require('./utils/logger'); logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`); @@ -25,24 +24,15 @@ logInfo('Aplicacao iniciada', { async function main() { try { - const statusSyncEnabled = process.env.ENABLE_STATUS_SYNC === 'true'; - const glpiCloseCronEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false'; + const ticketLifecycleEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false'; await processErrorController(); await processTicketsController(); - if (glpiCloseCronEnabled) { - await processGlpiClosureController(); + if (ticketLifecycleEnabled) { + await processTicketLifecycleController(); } else { - logInfo('Monitor de encerramento definitivo GLPI desabilitado (ENABLE_GLPI_CLOSE_CRON=false).'); - } - - - - if (statusSyncEnabled) { - await processStatusAndClosureController(); - } else { - logInfo('Sincronizacao legada de status desabilitada (ENABLE_STATUS_SYNC=false).'); + logInfo('Motor de regras de status/fechamento desabilitado (ENABLE_GLPI_CLOSE_CRON=false).'); } await processCommentsController(); diff --git a/src/controllers/processCommentsController.js b/src/controllers/processCommentsController.js index 21927b9..843c83b 100644 --- a/src/controllers/processCommentsController.js +++ b/src/controllers/processCommentsController.js @@ -1,66 +1,27 @@ -const { fetchCommentsFromServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService'); +const { fetchCommentsFromServiceNow } = require('../services/servicenowService'); const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService'); const TicketSyncModel = require('../models/ticketSyncModel'); -const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); -const { logInfo } = require('../utils/logger'); - -const ensureReopenFromSNWhenGlpiSolved = async (ticket) => { - const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); - const glpiIsSolved = glpiStatus === 5; - if (!glpiIsSolved) { - return { reopened: false, glpiSolved: false }; - } - - // REGRA DE CONTENÇÃO: Só reabre se o monitoramento de status estiver ativo. - // Se o usuário desligou o sync de status, ele quer controle manual. - if (process.env.ENABLE_STATUS_SYNC === 'false') { - return { reopened: false, glpiSolved: true }; - } - - // CORREÇÃO: Verificar se o ticket foi fechado recentemente. - // Se o status já é 'solved', significa que o processGlpiClosureController - // tentou fechar recentemente. O SN pode ainda estar processando o close, - // então ignoramos a detecção de reopen para evitar falsos positivos. - if (ticket.glpi_sync_status === 'solved' || ticket.sn_sync_status === 'solved') { - logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fechou recentemente (status: ${ticket.glpi_sync_status}/${ticket.sn_sync_status}). Ignorando verificacao de reopen.`); - return { reopened: false, glpiSolved: true }; - } - - const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id); - if (!ticketSn) { - return { reopened: false, glpiSolved: true }; - } - - const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo); - const snIsFinal = liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido'; - if (snIsFinal) { - return { reopened: false, glpiSolved: true }; - } - - await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento'); - await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced'); - logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`); - return { reopened: true, glpiSolved: true }; -}; +const { logDebug } = require('../utils/logger'); const processCommentsController = async () => { const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); - logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`); + logDebug(`Tickets em monitoramento: ${ticketsToMonitor.length}`); for (const ticket of ticketsToMonitor) { - const reopenState = await ensureReopenFromSNWhenGlpiSolved(ticket); - const reopenedThisCycle = reopenState.reopened; - const glpiWasSolved = reopenState.glpiSolved; - if (glpiWasSolved && !reopenedThisCycle) { - logInfo(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`); + const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + if (glpiStatus === 5) { + // Regra de negocio: enquanto o GLPI estiver Solucionado, nao manda comentario novo + // do SN pra dentro do chamado. A decisao de reabertura acontece em + // processTicketLifecycleController.js, que roda antes deste controlador no ciclo. + logDebug(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`); await syncCommentsGlpitoSN(ticket); continue; } const comments = await fetchCommentsFromServiceNow(ticket); if (comments.length !== 0) { - await syncCommentsSNtoGlpi(comments, ticket, { highlightReopenContext: reopenedThisCycle }); + await syncCommentsSNtoGlpi(comments, ticket); } await syncCommentsGlpitoSN(ticket); diff --git a/src/controllers/processErrorController.js b/src/controllers/processErrorController.js index c1cbcda..5acb246 100644 --- a/src/controllers/processErrorController.js +++ b/src/controllers/processErrorController.js @@ -1,19 +1,29 @@ 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 () => { - logInfo('Iniciando verificação de tickets com erro...'); + logDebug('Iniciando verificação de tickets com erro...'); try { const errorTickets = await TicketSyncModel.getTicketsInErrorState(); if (errorTickets.length === 0) { - logInfo('Nenhum ticket com erro encontrado para reprocessamento.'); + logDebug('Nenhum ticket com erro encontrado para reprocessamento.'); return; } logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`); for (const ticket of errorTickets) { + const retryCount = await TicketSyncModel.bumpErrorRetryCount(ticket.sn_ticket_id); + + if (retryCount > MAX_ERROR_RETRIES) { + await TicketSyncModel.markPermanentError(ticket.sn_ticket_id); + logWarning(`Ticket SN ID ${ticket.sn_ticket_id} excedeu ${MAX_ERROR_RETRIES} tentativas de reprocessamento. Marcado como 'error_permanent' (requer intervencao manual).`); + continue; + } + let newSnStatus = ticket.sn_sync_status; let newGlpiStatus = ticket.glpi_sync_status; @@ -28,7 +38,7 @@ const processErrorController = async () => { if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close'; await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus); - logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`); + logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}, tentativa ${retryCount}/${MAX_ERROR_RETRIES}).`); } } catch (error) { diff --git a/src/controllers/processGlpiClosureController.js b/src/controllers/processGlpiClosureController.js deleted file mode 100644 index 465990e..0000000 --- a/src/controllers/processGlpiClosureController.js +++ /dev/null @@ -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 -}; diff --git a/src/controllers/processStatusController.js b/src/controllers/processStatusController.js deleted file mode 100644 index dc682cb..0000000 --- a/src/controllers/processStatusController.js +++ /dev/null @@ -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 -}; diff --git a/src/controllers/processSyncController.js b/src/controllers/processSyncController.js index 1378393..f7fff58 100644 --- a/src/controllers/processSyncController.js +++ b/src/controllers/processSyncController.js @@ -2,7 +2,7 @@ const TicketSnModel = require('../models/ticketSnModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const SyncControlModel = require('../models/syncControlModel'); const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService'); -const { logInfo, logError, logSync } = require('../utils/logger'); +const { logInfo, logError, logSync, logDebug } = require('../utils/logger'); /** * Processa e salva um lote de tickets (incidentes ou requisicoes). @@ -22,7 +22,7 @@ const processAndSaveTickets = async (tickets, type) => { }; if (!tickets || tickets.length === 0) { - logInfo(`Nenhum ticket do tipo '${type}' para processar.`); + logDebug(`Nenhum ticket do tipo '${type}' para processar.`); 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'}.`); } + const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number; + const existingTicketSn = await TicketSnModel.findByTicketNumber(ticketNumber); + const oldStatus = existingTicketSn?.status || null; + const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type); const ticketStatus = ticket.state; @@ -78,10 +82,14 @@ const processAndSaveTickets = async (tickets, type) => { if (!exists) { await TicketSyncModel.createSyncRecord(dataSync); } else if (wasInserted === false) { - await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); - logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); + if (oldStatus !== ticketStatus) { + await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); + logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow (status '${oldStatus}' -> '${ticketStatus}'). 'source_last' definido como 'SNOW'.`); + } else { + logInfo(`Ticket ${ticket.number} recebido de novo pela margem de seguranca do watermark, sem mudanca de status ('${ticketStatus}'). Ignorando reset de 'source_last'.`); + } } else { - logInfo(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`); + logDebug(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`); } const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on); @@ -106,12 +114,12 @@ const processAndSaveTickets = async (tickets, type) => { const processSyncController = async () => { try { 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 latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente'); - logInfo('INFO: Buscando requisicoes do ServiceNow...'); + logDebug('Buscando requisicoes do ServiceNow...'); const requests = await fetchRequestsApi(watermark); const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao'); @@ -123,7 +131,7 @@ const processSyncController = async () => { const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis); await SyncControlModel.setWatermark('servicenow', nextWatermark); } else { - logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada."); + logDebug("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada."); } } catch (error) { diff --git a/src/controllers/processTicketLifecycleController.js b/src/controllers/processTicketLifecycleController.js new file mode 100644 index 0000000..60203b5 --- /dev/null +++ b/src/controllers/processTicketLifecycleController.js @@ -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`. + */ diff --git a/src/data/database.js b/src/data/database.js index 000c37e..1fa331d 100644 --- a/src/data/database.js +++ b/src/data/database.js @@ -1,7 +1,7 @@ // src/data/database.js // Configuração da conexão com o banco de dados PostgreSQL const { Pool } = require('pg'); -const { logInfo, logError } = require('../utils/logger'); +const { logInfo, logError, logDebug } = require('../utils/logger'); const poolConfig = { 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) const sanitizedConfig = { ...poolConfig, password: '*****' }; -logInfo('--- Configuração do Pool PostgreSQL (Banco Intermediário) ---'); -logInfo(sanitizedConfig); -logInfo('----------------------------------------------------------'); +logInfo('Configuração do Pool PostgreSQL (Banco Intermediário)', sanitizedConfig); // testa a conexao pool.on('connect', () => { - logInfo('Conectado ao PostgreSQL'); + logDebug('Conectado ao PostgreSQL'); }); pool.on('error', (err) => { diff --git a/src/data/glpiDataBase.js b/src/data/glpiDataBase.js index e5bd960..4c1eab8 100644 --- a/src/data/glpiDataBase.js +++ b/src/data/glpiDataBase.js @@ -1,7 +1,7 @@ // src/data/glpiDatabase.js // Configuração da conexão com o banco de dados mariaDB do GLPI const mysql = require('mysql2/promise'); -const { logInfo, logError, logWarning } = require('../utils/logger'); +const { logError, logDebug } = require('../utils/logger'); const glpiPool = mysql.createPool({ host: process.env.GLPI_DB_HOST, @@ -18,7 +18,7 @@ const glpiPool = mysql.createPool({ // Testar conexão glpiPool.on('connection', (connection) => { - logInfo('Nova conexão GLPI estabelecida'); + logDebug('Nova conexão GLPI estabelecida'); }); glpiPool.on('error', (err) => { diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 3a563fe..5bf99c5 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -39,6 +39,7 @@ class TicketGlpiModel { const statusMap = { 1: 'Aguardando Atendimento', // Novo 2: 'Em Atendimento', // Em atendimento (atribuído) + 3: 'Em Atendimento', // Em planejamento (tratado como variacao de "em andamento" pro SN) 4: 'Em Espera', // Pendente 5: 'Resolvido', // Solucionado 6: 'Encerrado - Omitido' // Fechado @@ -239,7 +240,8 @@ class TicketGlpiModel { itilcategories_id: TicketGlpiModel.mapCategory(ticketData.short_description), global_validation: 1, date_creation: ticketData.opened_at || new Date(), - slas_id_ttr: slasIdTtr + slas_id_ttr: slasIdTtr, + externalid: ticketData.ticket_number }; const fields = Object.keys(glpiTicketData).join(', '); diff --git a/src/models/ticketSnModel.js b/src/models/ticketSnModel.js index fb996f5..17b44ec 100644 --- a/src/models/ticketSnModel.js +++ b/src/models/ticketSnModel.js @@ -1,5 +1,5 @@ const pool = require('../data/database'); -const { logInfo, logError } = require('../utils/logger'); +const { logInfo, logError, logDebug } = require('../utils/logger'); class TicketSnModel { @@ -94,7 +94,10 @@ class TicketSnModel { action: 'insert' }); } 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_number: values[0], action: 'update' @@ -137,7 +140,7 @@ class TicketSnModel { `; const result = await pool.query(query); - logInfo(`📋 Tickets pendentes encontrados: ${result.rows.length}`); + logDebug(`📋 Tickets pendentes encontrados: ${result.rows.length}`); return result.rows; } catch (error) { diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index 0978224..a02885e 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -16,7 +16,9 @@ class TicketSyncModel { sn_sync_status, glpi_sync_status, source_last - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`; + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (sn_ticket_id) DO NOTHING + RETURNING id`; const values = [ data.sn_ticket_id, @@ -174,7 +176,8 @@ class TicketSyncModel { FROM ticket_sync tsync JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id WHERE (tsync.glpi_sync_status IN ('synced', 'solved') OR tsync.sn_sync_status IN ('synced', 'solved')) - AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`; + AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed') + AND (tsync.glpi_sync_status != 'error_permanent' AND tsync.sn_sync_status != 'error_permanent')`; const { rows } = await pool.query(query); return rows; } catch (error) { @@ -195,8 +198,8 @@ class TicketSyncModel { tsync.glpi_sync_status FROM ticket_sync tsync WHERE tsync.glpi_ticket_id IS NOT NULL - AND tsync.glpi_sync_status NOT IN ('closed', 'ignored') - AND tsync.sn_sync_status NOT IN ('closed', 'ignored') + AND tsync.glpi_sync_status NOT IN ('closed', 'ignored', 'error_permanent') + AND tsync.sn_sync_status NOT IN ('closed', 'ignored', 'error_permanent') `; const { rows } = await pool.query(query); return rows; @@ -231,9 +234,10 @@ class TicketSyncModel { static async getTicketsInErrorState() { try { const query = ` - SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status + SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status, error_retry_count FROM ticket_sync - WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%' + WHERE (sn_sync_status LIKE '%error%' AND sn_sync_status != 'error_permanent') + OR (glpi_sync_status LIKE '%error%' AND glpi_sync_status != 'error_permanent') `; const { rows } = await pool.query(query); return rows; @@ -243,6 +247,27 @@ class TicketSyncModel { } } + static async bumpErrorRetryCount(snTicketId) { + try { + const query = 'UPDATE ticket_sync SET error_retry_count = error_retry_count + 1 WHERE sn_ticket_id = $1 RETURNING error_retry_count'; + const { rows } = await pool.query(query, [snTicketId]); + return rows[0] ? rows[0].error_retry_count : null; + } catch (error) { + logError(error, `ERRO: Erro ao incrementar error_retry_count para o ticket SN ID: ${snTicketId}`); + throw error; + } + } + + static async markPermanentError(snTicketId) { + try { + const query = "UPDATE ticket_sync SET glpi_sync_status = 'error_permanent', sn_sync_status = 'error_permanent' WHERE sn_ticket_id = $1"; + await pool.query(query, [snTicketId]); + } catch (error) { + logError(error, `ERRO: Erro ao marcar error_permanent para o ticket SN ID: ${snTicketId}`); + throw error; + } + } + static async updateLastSync(snTicketId, system) { try { let fieldToUpdate; diff --git a/src/scripts/database/add_error_retry_count_ticket_sync.sql b/src/scripts/database/add_error_retry_count_ticket_sync.sql new file mode 100644 index 0000000..fe5a8e3 --- /dev/null +++ b/src/scripts/database/add_error_retry_count_ticket_sync.sql @@ -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; diff --git a/src/scripts/database/add_unique_ticket_sync_sn_ticket_id.sql b/src/scripts/database/add_unique_ticket_sync_sn_ticket_id.sql new file mode 100644 index 0000000..1c21152 --- /dev/null +++ b/src/scripts/database/add_unique_ticket_sync_sn_ticket_id.sql @@ -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); diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index f5aaf6f..42fe504 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -1,7 +1,7 @@ const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const TicketUpdateModel = require('../models/ticketUpdateModel'); -const { logInfo, logError } = require('../utils/logger'); +const { logInfo, logError, logDebug } = require('../utils/logger'); const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); @@ -81,29 +81,37 @@ const formatSNUpdateForGlpi = (update, options = {}) => { const syncCommentsGlpitoSN = async (ticket) => { 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); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp 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; } - 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; + const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); for (const comment of comments) { 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 existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized); - const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); - - if (existingComment && existsInSN) { - logInfo(`INFO: Comentario ${comment.id} ja sincronizado.`); + if (existingComment && existingComment.destiny_id) { + logDebug(`Comentario ${comment.id} ja sincronizado.`); 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) { await TicketUpdateModel.insert({ ticket_sync_id: syncId, @@ -157,7 +165,7 @@ const syncCommentsGlpitoSN = async (ticket) => { 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) { logError(`Erro geral na sincronizacao: ${error}`); } @@ -165,7 +173,7 @@ const syncCommentsGlpitoSN = async (ticket) => { 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 aTime = new Date(a.created_at).getTime(); diff --git a/src/services/glpiTicketService.js b/src/services/glpiTicketService.js index a231928..8ed0073 100644 --- a/src/services/glpiTicketService.js +++ b/src/services/glpiTicketService.js @@ -2,19 +2,20 @@ const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); -const { logInfo, logError } = require('../utils/logger'); -const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry } = require('./servicenowService'); +const { logInfo, logError, logDebug } = require('../utils/logger'); +const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry, reassignTicketInServiceNow } = require('./servicenowService'); const { stripHTML } = require('../utils/commentSanitizer'); const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); +const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID; const syncTicketsToGlpi = async () => { try { - logInfo('🔄 Iniciando sincronização para GLPI...'); + logDebug('🔄 Iniciando sincronização para GLPI...'); const pendingTickets = await TicketSnModel.getPendingTickets(); if (pendingTickets.length === 0) { - logInfo('✅ Nenhum ticket pendente para sincronizar'); + logDebug('✅ Nenhum ticket pendente para sincronizar'); return; } @@ -99,93 +100,68 @@ const processSingleTicket = async (ticket) => { } }; -const checkGlpiStatusChanges = async () => { - try { - logInfo('📡 Verificando mudanças de status nos tickets do GLPI...'); +const isOutOfScopeSolution = (solution) => solution?.solutiontypes_id === outOfScopeSolutionTypeId; - const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); - - if (ticketsToMonitor.length === 0) { - logInfo('Nenhum ticket ativo para verificar status no GLPI.'); - return; - } - - for (const ticket of ticketsToMonitor) { - const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); - - const currentDbStatus = ticket.glpi_sync_status; - const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); - - if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') { - logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`); - - const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); - - if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) { - logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`); - const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.'; - await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket 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'); - } +/** + * Regra "fora de escopo": nota no SN + transferencia para fila CAOA + remove grupo do GLPI + fecha + * o ticket no GLPI. Antes existia uma copia quase identica em processGlpiClosureController.js e + * outra em processStatusController.js (uma delas nao removia o grupo do GLPI) - consolidado aqui. + */ +const handleOutOfScopeResolution = async (ticket, solution) => { + const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.'; + await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`); + await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId); + await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id); + await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); + logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fora do escopo: grupo removido, transferido para CAOA e fechado.`); }; const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => { + const sourceId = `sn_closure_note:${ticket.glpi_ticket_id}:${finalStatus}`; try { + const alreadySent = await TicketUpdateModel.getBySourceId(sourceId); + if (alreadySent) { + logInfo(`Nota de encerramento do ServiceNow ja registrada para o GLPI ${ticket.glpi_ticket_id} (status '${finalStatus}'). Ignorando reenvio.`); + return { ok: true, isNew: false }; + } + const resolvedBy = collaboratorName && collaboratorName.trim() ? collaboratorName.trim() : 'colaborador CAOA'; const statusText = finalStatus === 'solucionado' - ? 'Chamado solucionado no Service Now pela CAOA.' - : 'Chamado encerrado no Service Now pela CAOA.'; + ? 'Chamado solucionado no Service Now e removido do monitoramento.' + : 'Chamado encerrado no Service Now e removido do monitoramento.'; const styledMessage = `