Compare commits

...

28 Commits

Author SHA1 Message Date
be6d4a891e Merge pull request 'FIX: Logger escondia contexto do erro atras do stack trace' (#7) from dev into master
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 43s
Reviewed-on: #7
2026-07-20 17:00:57 -03:00
3b050e626c FIX: Logger escondia contexto do erro atras do stack trace
winston.format.printf usava "stack || message", entao qualquer erro
logado com stack trace (todo uso de logError) perdia a mensagem de
contexto (ex: "Falha ao buscar status... sysId: X") e so mostrava o
stack puro, sem dizer qual ticket/operacao falhou. Agora mostra os
dois: mensagem de contexto primeiro, stack trace logo abaixo.
2026-07-20 16:59:17 -03:00
bc0fc282a3 CI: Deploy roda como desenvolvimento ao inves de root
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 43s
Reviewed-on: #6
2026-07-20 15:42:05 -03:00
0df7443379 CI: Deploy roda como desenvolvimento ao inves de root
O runner (gitea-runner) roda como root e é compartilhado com outras
automações deste servidor, então não dá pra trocar o usuário dele
globalmente. O rsync que lê o checkout continua como root (é a única
forma de ler, o workspace do runner é privado dele), mas logo depois
do sync a posse dos arquivos passa pro 'desenvolvimento', e dali em
diante (npm ci, node --check, pm2 startOrRestart/save/list) tudo roda
via sudo -u desenvolvimento - resolve o processo órfão de root que
duplicava o cron ao lado do PM2 do desenvolvimento.
2026-07-20 15:39:23 -03:00
e67d376c4f FIX: Alterado criador da pasta previous para o root
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 36s
2026-07-20 15:34:56 -03:00
f934b8f4ae FIX: Alterado criador da pasta previous para o root 2026-07-20 15:33:12 -03:00
64e384036a Merge pull request 'FIX: BugFix relacionado ao status de requisições' (#4) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 9s
Reviewed-on: #4
2026-07-20 15:26:33 -03:00
dd1d1e0851 CI: Definido para que automação rode como user desenvolvimento ao invés de root 2026-07-20 15:23:28 -03:00
8e4e79d059 FIX: Corrigido status em espera para requisições
- Requisições possui um código diferente de incidentes para em espera
- Adicionado if tipo === 'reuqusicao' em espera = -5
2026-07-20 15:22:37 -03:00
b16a420fab Merge pull request 'FIX: Secret ENV_PRODUCTION colado direto no script bash quebrava com $ dentro do conteudo' (#3) from dev into master
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 45s
Reviewed-on: #3
2026-07-15 14:02:26 -03:00
e838081aae FIX: Secret ENV_PRODUCTION colado direto no script bash quebrava com $ dentro do conteudo
${{ secrets.ENV_PRODUCTION }} usado direto dentro de um run: substitui o texto
antes do bash rodar - qualquer '$algo' dentro do conteudo do .env.production
(por exemplo em senhas) era interpretado como expansao de variavel de shell.
Com set -u ligado isso quebrou o step "Check required secrets" (variavel nao
definida = erro fatal). Sem set -u, o step "Write .env.production from
secret" teria o mesmo problema de forma silenciosa: qualquer '$algo' dentro
das credenciais reais seria substituido por vazio no arquivo escrito na VM,
corrompendo senha sem nenhum aviso.

Corrige passando o secret via env: e referenciando como variavel de ambiente
normal ($ENV_PRODUCTION) nos dois steps - bash trata o valor como dado opaco,
sem tentar expandir nada dentro dele.
2026-07-15 14:01:49 -03:00
0daefc243c Merge pull request 'FIX: Preflight falhava por causa de pipefail com rsync --version | head -n 1' (#2) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 5s
Reviewed-on: #2
2026-07-15 13:59:39 -03:00
cefeb3ea25 FIX: Preflight falhava por causa de pipefail com rsync --version | head -n 1
O head fecha o pipe apos ler a primeira linha, o rsync recebe broken pipe e sai
com codigo de erro, e o pipefail propaga isso como falha do step inteiro -
mesmo a versao ja tendo sido impressa com sucesso. Confirmado no primeiro run
real do workflow em producao: Preflight falhou logo depois de imprimir as
versoes, todos os steps seguintes (secrets, validacao, deploy) ficaram
pulados, sem tocar na VM.
2026-07-15 13:58:36 -03:00
5b30849f79 Merge pull request 'Motor de regras único, mandante configurável e deploy via secret do Gitea' (#1) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 16s
Reviewed-on: #1
2026-07-15 13:54:57 -03:00
2cf6e9f2fb CI: Materializa .env.production via secret, backup pra rollback e node --check dinamico
Adiciona um step que escreve o .env.production a partir do secret ENV_PRODUCTION
do Gitea Actions (com chmod 600), eliminando o modelo anterior de manter esse
arquivo manualmente na VM sem nenhuma copia de seguranca. Um step anterior falha
cedo e com mensagem clara se o secret nao estiver configurado.

Antes de sincronizar o codigo novo, guarda a versao anterior em
$DEPLOY_PATH.previous, permitindo rollback manual rapido caso o deploy suba
algo quebrado (o node --check so pega erro de sintaxe, nao de logica).

Troca a lista fixa de arquivos validados por node --check (que ja estava
desatualizada, sem cobrir arquivos tocados em mudancas recentes) por uma
varredura em todo .js do projeto.
2026-07-15 13:31:35 -03:00
2437864133 DOCS: Manual do usuario + documentacao de onde fica o .env.production e rollback
Cria docs/manualusuario.md: guia pratico "se eu fizer X no SN/GLPI, acontece Y",
pra quem usa os sistemas no dia a dia sem precisar entender a implementacao.

README ganha duas secoes na parte de Producao: onde o .env.production fica e
por que (agora materializado a partir do secret ENV_PRODUCTION do Gitea, nao
mais um arquivo mantido a mao na VM), e o passo a passo de rollback manual
usando o backup .previous que o deploy passa a manter.
2026-07-15 13:31:07 -03:00
cc17d957c7 PERF: Reduz verbosidade de logs em nivel info no ciclo do cron
Rebaixa para debug uma serie de logs "heartbeat" que disparavam todo ciclo
(a cada 1 minuto) independente de qualquer mudanca real, competindo com os
logs de acao/erro que realmente importam: buscas por watermark, contagem
de tickets pendentes/monitorados, checagens de comentario ja sincronizado,
abertura de conexao GLPI/PostgreSQL, e o resumo de sincronizacao do SN
(que a margem de seguranca de 6h do watermark sempre retorna nao-vazio,
mesmo sem mudanca real de campo).

Corrige tambem um bug real encontrado em teste: no fluxo GLPI -> SN, todo
comentario historico de um ticket era sanitizado e checado contra a API do
SN a cada ciclo antes mesmo de verificar se ja tinha sido sincronizado
(ticket_updates.destiny_id). Em tickets com historico longo isso
significava dezenas de chamadas desnecessarias por minuto. Agora a
checagem local (barata) roda primeiro, e so sanitiza/consulta o SN quando
o comentario realmente ainda nao foi sincronizado.

Corrige tambem o log de configuracao do pool PostgreSQL, que imprimia
"[object Object]" por passar o objeto de config como mensagem em vez de
metadado.

LOG_LEVEL default muda de debug para info em .env.example e
.env.production, unica forma do rebaixamento acima ter efeito pratico.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:29 -03:00
bce9a52494 FEAT: Motor de regras único de status/fechamento + mandante configurável
Consolida os dois controladores legados que decidiam status/fechamento de
forma concorrente (processGlpiClosureController + processStatusController)
em um único processTicketLifecycleController, eliminando a dependência do
campo source_last (mantido só como rastro de auditoria) e a flag
ENABLE_STATUS_SYNC (removida). Isso corrige o bug de reabertura: chamados
reabertos no SN voltavam a ser fechados no GLPI porque a regra de
precedência de finalização do fluxo legado forçava o status do GLPI de
volta pro SN.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:45:34 -03:00
ed19cd76a7 FEAT: RN atualizada. Chamados que estão fechados fora do escopo saem da fila sothis e entram na fila CAOA no ServiceNow 2026-06-26 11:39:57 -03:00
00b573d60d HOTFIX: Correção do Bug Em PROD
- Travas foram adicionadas para que a reabertura nao seja feita
- Regra de desatribuição de usuario caso seja "Fora de Escopo"
2026-03-11 17:02:27 -03:00
686c2f0a54 HOTFIX: Reordenação de regras de negócio
- Prioriza encerramento
- Secundariamente a reabertura
2026-03-11 10:44:09 -03:00
Gabriel Amancio
b4a7e6c366 HOTFIX: Alterado condição de status para poder sincronizar como SNOW. 2026-03-11 08:21:38 -03:00
Rafael Lopes
b2cbeb66e5 Merge branch 'feature/snow-worknotes-glpi-task-review' 2026-03-10 13:22:18 -03:00
6b67d5002f FIX: Correção na lógica de detecção de reabertura de ticket
- Adicionada lógica para detectar quando um ticket é reaberto, verificando se o status do ticket mudou de "Fechado" para "Aberto".
- Isso garante que os comentários sejam processados corretamente mesmo em casos de reabertura de tickets.
2026-03-09 16:38:06 -03:00
c964bcf18a FEATURE: Chamados reabertos possuem uma nova coloração 2026-03-04 18:15:53 -03:00
ce294d4220 FIX: Mensagem de fechamento de chamado atualizado para versão menos agressiva 2026-03-04 17:33:57 -03:00
dfabf699a5 DOC: Ajuste final para release em PROD 2026-03-04 16:48:23 -03:00
35b4a1fef9 FIX/FEATURE: Ajustes de fluxo SN<->GLPI e documentação operacional
- feito ajuste do fluxo GLPI->SN para tratar status 5 (resolve no SN) e status 6 (aviso definitivo e retirada de monitoramento)
- feito ajuste de reabertura SN->GLPI quando ticket estiver solved no GLPI
- feito ajuste de data/hora de comments/worknotes para evitar inversão de data
- feito ajuste de vínculo Ticket Externo no SN com validação de persistência e retry idempotente
- feito ajuste de filtro de journals para ignorar SOTHIS.CAOA e admin.caoa por configuração
- feito inclusão de monitor dedicado de encerramento definitivo (processGlpiClosureController)
- feito atualização de flags de controle (ENABLE_STATUS_SYNC, ENABLE_GLPI_CLOSE_CRON, ENABLE_GLPI_WEBHOOK)
- feito atualização da documentação (README, docs/regrasdenegocio.md, docs/fluxo.md, docs/casosdeuso.md, docs/checklist.md)
2026-03-04 16:37:04 -03:00
28 changed files with 1660 additions and 629 deletions

View File

@ -12,6 +12,18 @@ 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_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 --- # --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow # URL base da sua instancia do ServiceNow
@ -26,12 +38,17 @@ SERVICENOW_ASSIGNMENT_GROUP=
SERVICENOW_DEFAULT_USER_SYSID= SERVICENOW_DEFAULT_USER_SYSID=
SERVICENOW_DEFAULT_USER= SERVICENOW_DEFAULT_USER=
SERVICENOW_RESOLVED_BY_SYSID= SERVICENOW_RESOLVED_BY_SYSID=
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=true
SERVICENOW_IGNORE_USERS_JOURNAL=SOTHIS.CAOA,admin.caoa
# Endpoints de tabela usados pela aplicacao # Endpoints de tabela usados pela aplicacao
SERVICENOW_TABLE_INCIDENT_URL= SERVICENOW_TABLE_INCIDENT_URL=
SERVICENOW_TABLE_REQUEST_URL= 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
# 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
@ -57,15 +74,12 @@ SNGLPI_DB_NAME=snglpi_sync
SNGLPI_DB_USER= SNGLPI_DB_USER=
SNGLPI_DB_PASSWORD= SNGLPI_DB_PASSWORD=
# --- Configuracao de Logs ---
LOG_LEVEL=debug
LOG_TO_CONSOLE=true
# --- Mapeamento de Localidades (Opcional) --- # --- Mapeamento de Localidades (Opcional) ---
# Caminho absoluto para o arquivo .csv que mapeia localidades do SNOW para entidades do GLPI # Caminho absoluto para o arquivo .csv que mapeia localidades do SNOW para entidades do GLPI
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_RETENTION_DAYS=10 LOG_RETENTION_DAYS=10
LOG_DIR=logs LOG_DIR=logs

View File

@ -0,0 +1,131 @@
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 || true
- name: Check required secrets
shell: bash
env:
ENV_PRODUCTION: ${{ secrets.ENV_PRODUCTION }}
run: |
set -euo pipefail
if [ -z "${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
env:
ENV_PRODUCTION: ${{ secrets.ENV_PRODUCTION }}
run: |
set -euo pipefail
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
mkdir -p "$DEPLOY_PATH"
printf '%s\n' "$ENV_PRODUCTION" > "$DEPLOY_PATH/.env.production"
chmod 600 "$DEPLOY_PATH/.env.production"
chown desenvolvimento:desenvolvimento "$DEPLOY_PATH/.env.production"
- name: Deploy files and restart PM2
shell: bash
run: |
set -euo pipefail
# O checkout do runner (~/.cache/act/.../hostexecutor) so e legivel pelo root - o
# 'desenvolvimento' nem consegue entrar la. Por isso o rsync (que le de la e escreve em
# $DEPLOY_PATH) roda como root, igual sempre rodou. So DEPOIS de copiado a posse passa
# pro 'desenvolvimento' (chown), e dali em diante (npm ci, node --check, pm2) roda como
# 'desenvolvimento' via sudo -u - sem mudar o usuario do runner nem mexer em /opt em si
# (evita afetar outras automacoes deste servidor).
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
LOCK_FILE="/tmp/sn-glpi-sync-new.deploy.lock"
POST_SCRIPT="/tmp/sn-glpi-sync-new.deploy-post.sh"
mkdir -p "$DEPLOY_PATH"
mkdir -p "$DEPLOY_PATH.previous"
chown desenvolvimento:desenvolvimento "$DEPLOY_PATH.previous"
cat > "$POST_SCRIPT" <<'POST_EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$DEPLOY_PATH"
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
POST_EOF
chmod +x "$POST_SCRIPT"
(
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"/
chown -R desenvolvimento:desenvolvimento "$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
}
sudo -u desenvolvimento -H DEPLOY_PATH="$DEPLOY_PATH" bash "$POST_SCRIPT"
) 9>"$LOCK_FILE"
rm -f "$POST_SCRIPT"
- name: Show sync logs
if: always()
shell: bash
run: |
pm2 logs sn-glpi-sync-cron --nostream --lines 80 || true

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ servicenow_tickets.json
location_mapping.log location_mapping.log
.env* .env*
!.env.example !.env.example
docs/checklist.md

303
README.md
View File

@ -1,251 +1,138 @@
# Sistema de Sincronizacao ServiceNow <> GLPI # Sistema de Integracao ServiceNow <-> GLPI
Middleware em Node.js para sincronizacao bidirecional de tickets, comentarios e status entre ServiceNow e GLPI, com banco intermediario PostgreSQL e acesso direto ao banco MySQL/MariaDB do GLPI. Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando PostgreSQL como banco intermediario e acesso ao banco do GLPI.
## O que o projeto faz hoje ## Estado atual
- Busca incidentes e requisicoes no ServiceNow por watermark (`sync_control`). 1. Coleta de tickets SN por watermark.
- Salva/atualiza tickets no banco intermediario (`tickets_sn`). 2. Criacao/vinculo de tickets no GLPI (grava `external_id` no GLPI com o numero do ticket SN).
- Cria tickets no GLPI para registros pendentes (`ticket_sync.glpi_sync_status = 'pending_check'`). 3. Sincronizacao de comentarios e tasks em duas direcoes.
- Sincroniza comentarios em duas direcoes: 4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI.
- ServiceNow -> GLPI 5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura:
- GLPI -> ServiceNow `status=5` (resolver SN), `status=6` (encerrar definitivamente no SN), nota no GLPI quando o SN
- No fluxo ServiceNow -> GLPI: resolve/encerra por conta propria (com aviso e troca de fila no SN), reabertura automatica e
- `comments` viram comentarios (followups) no GLPI. regra de fora de escopo.
- `work_notes` viram tarefas no GLPI.
- Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`).
- Trata regras de negocio especificas de fechamento/reabertura.
- Reprocessa tickets em estado de erro no inicio de cada ciclo.
- Executa em job agendado (`node-cron`) com protecao contra concorrencia local (`isCronRunning`).
## Arquitetura
### Componentes
- `cron.js`: agenda e dispara o ciclo de sincronizacao.
- `src/app.js`: orquestra o ciclo principal.
- `src/controllers/*`: fluxo de tickets, comentarios, status e recuperacao de erro.
- `src/services/*`: integracoes com ServiceNow e GLPI.
- `src/models/*`: acesso a dados (PostgreSQL e GLPI MySQL).
- `src/data/*`: pools de conexao (`pg` e `mysql2`).
- `src/scripts/python/update_location_mapping.py`: atualiza tabela `location_mapping` a partir de CSV.
### Bancos envolvidos
- PostgreSQL (`snglpi`): estado da integracao.
- MySQL/MariaDB (GLPI): criacao/consulta/atualizacao de tickets e followups.
## Fluxo do ciclo ## Fluxo do ciclo
Executado em ordem pelo `main()`: Executado pelo `main()`:
1. `processErrorController` 1. `processErrorController`
2. `processTicketsController` 2. `processTicketsController`
3. `processCommentsController` 3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
4. `processStatusAndClosureController` 4. `processCommentsController`
## Regras de negocio importantes ## Documentacao detalhada
- Controle de precedencia por `source_last` para reduzir conflito de atualizacao. 1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md)
- Ao detectar divergencia de status, o bastao nao e invertido para `GLPI` quando a ultima origem valida ja e `SNOW`. 2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md)
- Encerramento/Resolucao vindo do ServiceNow: 3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md)
- Nao fecha ticket no GLPI automaticamente. 4. Manual do usuario (se eu fizer X, acontece Y): [docs/manualusuario.md](docs/manualusuario.md)
- Insere nota formatada no GLPI. 5. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md)
- Marca sync como `closed/closed` para remover do monitoramento.
- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (fora do escopo):
- Nao resolve no SN.
- Adiciona `work_note` no SN.
- Marca sincronizacao como encerrada/ignorada.
- Se GLPI resolver e SN estiver em `Em Espera` ou `Aguardando Atendimento`, integracao forca `Em Atendimento` antes de resolver no SN.
- Comentarios GLPI sao sanitizados (HTML/imagens/metadados) antes de envio ao SN.
- Watermark de coleta no ServiceNow usa margem de seguranca de 6 horas para tras (`newWatermark - 6h`).
- Motivo: reduzir risco de perda de eventos em casos de atraso de replicacao, diferenca de timezone e clock skew entre sistemas.
- Efeito colateral esperado: releitura de uma janela recente e maior chance de reprocessamento controlado (idempotencia pelo banco local).
## Regras de negocio detalhadas ## Variaveis de ambiente importantes
1. Controle de origem (`source_last`) ### Feature flags
- `SNOW` ou `GLPI` define quem teve a ultima escrita valida para o ticket.
- Evita corrida de atualizacao de status/comentario entre os dois sistemas.
2. Status GLPI -> ServiceNow 1. `ENABLE_GLPI_CLOSE_CRON`
- Solucao no GLPI dispara tentativa de resolucao no SN. - `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
- Se SN estiver em status bloqueante (`Em Espera` ou `Aguardando Atendimento`), a integracao seta `Em Atendimento` antes da resolucao. - `false`: desliga o motor inteiro
- Regra fora do escopo (`solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`.
3. Status ServiceNow -> GLPI
- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
- Para `Resolvido`, `Encerrado` e `Encerrado - Omitido` no SN:
- GLPI recebe nota de encerramento/resolucao.
- Integracao remove ticket do monitoramento (`closed/closed`), sem fechar ticket no GLPI.
- Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao.
4. Comentarios GLPI -> SN
- Fluxo com verificacao de existencia local/remota para evitar duplicatas.
- Conteudo passa por sanitizacao para remover HTML, normalizar texto e tratar imagens.
5. Comentarios SN -> GLPI
- Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI.
- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI.
- Campo `author` no banco intermediario usa `sys_created_by` do SN.
- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho.
- IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade.
6. Reprocessamento de erro
- Inicio de cada ciclo tenta resetar estados de erro para recolocar tickets no fluxo automatico.
## Pre-requisitos
- Node.js 18+
- NPM
- PostgreSQL
- MySQL/MariaDB com base do GLPI acessivel
- Python 3.10+ (para o mapeador de localidades)
## Instalacao
```bash
npm install
```
### Dependencias Python
```bash
cd src/scripts/python
pip install -r requirements.txt
```
## Configuracao de ambiente
Crie `.env.development` e `.env.production` com base em `.env.example` e inclua tambem as variaveis usadas no codigo:
### ServiceNow ### ServiceNow
- `SERVICENOW_USERNAME` 1. `SERVICENOW_TABLE_INCIDENT_URL`
- `SERVICENOW_PASSWORD` 2. `SERVICENOW_TABLE_REQUEST_URL`
- `SERVICENOW_ASSIGNMENT_GROUP` 3. `SERVICENOW_TABLE_JOURNAL_URL`
- `SERVICENOW_TABLE_INCIDENT_URL` 4. `SERVICENOW_SC_ITEM_OPTION_URL`
- `SERVICENOW_TABLE_REQUEST_URL` 5. `SERVICENOW_DEFAULT_USER`
- `SERVICENOW_TABLE_JOURNAL_URL` 6. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`
- `SERVICENOW_SC_ITEM_OPTION_URL` 7. `SERVICENOW_EXTERNAL_TICKET_FIELD` (default: `u_external_ticket`)
- `SERVICENOW_DEFAULT_USER`
- `SERVICENOW_RESOLVED_BY_SYSID`
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
### GLPI (MySQL/MariaDB) ### GLPI e banco intermediario
- `GLPI_DB_HOST` 1. `GLPI_DB_*`
- `GLPI_DB_PORT` 2. `SNGLPI_DB_*`
- `GLPI_DB_USER` 3. `GLPI_OOS_SOLUTION_TYPE_ID`
- `GLPI_DB_PASSWORD`
- `GLPI_DB_NAME`
- `GLPI_DB_CHARSET`
- `GLPI_DEFAULT_USER_ID`
- `GLPI_DEFAULT_GROUP_ID` (padrao `30`)
- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`)
- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`)
### Banco intermediario (PostgreSQL) ## Desenvolvimento
- `SNGLPI_DB_HOST`
- `SNGLPI_DB_PORT`
- `SNGLPI_DB_NAME`
- `SNGLPI_DB_USER`
- `SNGLPI_DB_PASSWORD`
### Agendamento e mapeamento
- `CRON_SCHEDULE` (ex.: `*/5 * * * *`)
- `LOCATION_MAPPING_CSV_PATH` (arquivo CSV para mapeamento SN -> GLPI)
## Execucao
### Desenvolvimento
1. Instalar dependencias:
```bash
npm install
```
2. Criar env:
```bash
copy .env.example .env.development
```
3. Executar:
```bash ```bash
npm run dev npm run dev
``` ```
### Producao ## Producao
```bash ### Deploy manual na VM
npm run start:prod
```
## Execucao com PM2
Subir os dois processos (sync + mapeador):
```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
``` ```
Comandos uteis: 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 ```bash
pm2 list pm2 list
pm2 logs sn-glpi-sync-cron pm2 logs sn-glpi-sync-cron
pm2 logs sn-glpi-location-mapper
pm2 restart sn-glpi-sync-cron
``` ```
## Script de mapeamento de localidades ### Onde fica o `.env.production`
Arquivo: `src/scripts/python/update_location_mapping.py` 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:
- Carrega `NODE_ENV` e respectivo `.env.*`. 1. Ele existe dentro da propria pasta de deploy (`$PROD_DEPLOY_PATH/.env.production`, ex.:
- Monitora alteracoes recentes no CSV (`LOCATION_MAPPING_CSV_PATH`). `/opt/sn-glpi-sync-new/.env.production`) — nao na raiz de nenhum usuario (nem `dev`, nem
- Recria os dados da tabela `location_mapping` com base no CSV e IDs validos em ambos os bancos. `root`). `cron.js`/`src/app.js` resolvem o caminho do env relativo a propria raiz do repo, entao
- Roda em loop continuo (ideal via PM2 como processo separado). 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.
## Como os dados chegam do ServiceNow ### Rollback manual
- Incidentes: consulta na tabela definida por `SERVICENOW_TABLE_INCIDENT_URL`, filtrando por `assignment_group` e `sys_updated_on >= watermark`. O deploy guarda a versao anterior em `$PROD_DEPLOY_PATH.previous` antes de sincronizar o codigo
- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro. novo. Se o deploy subir uma versao com problema (o `node --check` so pega erro de sintaxe, nao de
- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`). logica), o rollback e:
- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone.
- Campos principais persistidos localmente:
- Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`.
- Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`.
## Estrutura do banco intermediario ```bash
rsync -a --delete "$PROD_DEPLOY_PATH.previous"/ "$PROD_DEPLOY_PATH"/
cd "$PROD_DEPLOY_PATH" && pm2 restart ecosystem.config.js --update-env
```
Definicao base em `src/scripts/database/scriptBD.sql`: ## Observacoes
- `tickets_sn` 1. O projeto ainda nao tem suite automatizada robusta.
- `ticket_sync` 2. O rollout recomendado e por fases com feature flags.
- `ticket_updates` 3. Nao remover schema legado antes de estabilizar o fluxo novo.
- `location_mapping` 4. Consulte `docs/checklist.md` antes de cada deploy para validar pre e pos-subida.
- `sync_control`
Arquivo auxiliar de manutencao/reset (com comandos destrutivos):
- `src/scripts/database/maintenance_reset.sql`
## Limitacoes conhecidas (estado atual)
- Nao ha suite de testes automatizados (`npm test` e placeholder).
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão)
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
## Proximas melhorias recomendadas
- Cobertura de testes (unitario e integracao com mocks de API/DB).
- Externalizar IDs e regras hardcoded para variaveis de ambiente.
- Revisar estrategia de watermark/timezone e idempotencia.
- Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados).
- Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria).
- Alterar uso de banco do GLPI para a API do GLPI.
## Arquivos principais
- `cron.js`
- `src/app.js`
- `src/controllers/processTicketsController.js`
- `src/controllers/processCommentsController.js`
- `src/controllers/processStatusController.js`
- `src/controllers/processErrorController.js`
- `src/services/servicenowService.js`
- `src/services/glpiTicketService.js`
- `src/services/glpiCommentService.js`
- `src/scripts/python/update_location_mapping.py`

153
docs/casosdeuso.md Normal file
View File

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

89
docs/fluxo.md Normal file
View File

@ -0,0 +1,89 @@
# Fluxo Operacional
## 1. Fluxo do ciclo (`main`)
1. `processErrorController`
2. `processTicketsController`
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
1. Coleta incidentes/requisicoes no SN por watermark.
2. UPSERT em `tickets_sn`.
3. Cria/atualiza `ticket_sync` com estados iniciais.
4. Cria ticket no GLPI quando `pending_check`.
5. Grava `glpi_ticket_id` em `ticket_sync`.
6. Atualiza campo "Ticket Externo" no SN com ID GLPI.
7. Se falhar atualizar "Ticket Externo", agenda retry em `ticket_updates`.
## 3. Fluxo de comentarios/tasks
### 3.1 SN -> GLPI
1. Busca `comments` e `work_notes` no SN.
2. Persiste em `ticket_updates` (idempotente por `source_id`).
3. Envia:
- `comment` -> followup GLPI
- `task` -> task GLPI
### 3.2 GLPI -> SN
1. Busca followups GLPI.
2. Sanitiza conteudo HTML/imagens.
3. Envia comentario para SN evitando duplicidade.
4. Atualiza `destiny_id` para rastrear espelhamento.
## 4. Motor de regras unico (`processTicketLifecycleController`)
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` — 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.

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) |

228
docs/regrasdenegocio.md Normal file
View File

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

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

View File

@ -1,30 +1,33 @@
const {fetchCommentsFromServiceNow } = 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 { logInfo } = require('../utils/logger'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logDebug } = require('../utils/logger');
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) {
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;
}
for (const ticket of ticketsToMonitor){
// Sincroniza comentários do ServiceNow para o GLPI
const comments = await fetchCommentsFromServiceNow(ticket); const comments = await fetchCommentsFromServiceNow(ticket);
// Se houver comentários novos no ServiceNow, sincroniza para o GLPI if (comments.length !== 0) {
if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);} await syncCommentsSNtoGlpi(comments, ticket);
// Sincroniza comentários do GLPI para o ServiceNow }
await syncCommentsGlpitoSN(ticket); await syncCommentsGlpitoSN(ticket);
} }
} };
module.exports = { module.exports = {
processCommentsController processCommentsController
} };
/**
* @file processCommentsController.js
* @description Este controlador orquestra a sincronização bidirecional de comentários para todos os tickets ativos.
* Ele busca tickets que estão sendo monitorados e, para cada um, executa a sincronização de comentários do ServiceNow para o GLPI e vice-versa.
*/

View File

@ -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,214 +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 {
// Se estiver Solucionado (5) ou em qualquer outro estado, permite a atualização (reabertura).
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, snCurrentStatus);
logInfo(`Status do ticket GLPI ${ticket.glpi_ticket_id} atualizado para '${snCurrentStatus}'.`);
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); // Passa o bastão
}
}
// ================= 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.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);
}
} else {
// Se o status for de um ticket aberto (reabertura, etc.), propaga a mudança.
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiCurrentStatus);
if (glpiStatusAsString !== snCurrentStatus) {
if (snCurrentStatus === 'Encerrado' || snCurrentStatus === 'Encerrado - Omitido'){
logInfo(`SN ${ticketSnDetails.ticket_number} foi Fechado. Sincronizando para GLPI...`);
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'closed');
}
else{
logInfo(`Diferença de status detectada (GLPI: ${glpiStatusAsString}, SN: ${snCurrentStatus}). Sincronizando...`);
await updateStatusInServiceNow(ticket.sn_ticket_id, glpiStatusAsString);
}
}
}
// Passa o bastão de volta para o SN se a ação não for de finalização
if (glpiCurrentStatus < 5) {
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,10 +1,12 @@
const { syncTicketsToGlpi } = require('../services/glpiTicketService'); const { syncTicketsToGlpi } = require('../services/glpiTicketService');
const { processSyncController } = require('./processSyncController'); const { processSyncController } = require('./processSyncController');
const { processPendingExternalTicketLinks } = require('../services/servicenowService');
const processTicketsController = async () => { const processTicketsController = async () => {
await processSyncController(); await processSyncController();
await syncTicketsToGlpi(); await syncTicketsToGlpi();
await processPendingExternalTicketLinks();
} }
module.exports = { module.exports = {

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(', ');
@ -476,6 +478,21 @@ class TicketGlpiModel {
} }
} }
static async unassignGroupFromTicket(ticketId) {
const connection = await glpiPool.getConnection();
const groupId = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30;
try {
const query = `DELETE FROM glpi_groups_tickets WHERE tickets_id = ? AND groups_id = ?`;
await connection.execute(query, [ticketId, groupId]);
logInfo(`✅ Grupo ${groupId} removido do ticket GLPI ${ticketId}.`);
} catch (error) {
logError(error, `❌ Erro ao remover grupo ${groupId} do ticket ${ticketId} no GLPI`);
throw error;
} finally {
if (connection) connection.release();
}
}
} }
/** /**

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'
@ -112,6 +115,7 @@ class TicketSnModel {
try { try {
const query = ` const query = `
SELECT SELECT
tsync.id AS ticket_sync_id,
ts.id, ts.id,
ts.ticket_number, ts.ticket_number,
ts.short_description, ts.short_description,
@ -136,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) {
@ -183,6 +186,29 @@ class TicketSyncModel {
} }
} }
static async getTicketsForClosureMonitor() {
try {
const query = `
SELECT
tsync.id,
tsync.glpi_ticket_id,
tsync.sn_ticket_id,
tsync.source_last,
tsync.sn_sync_status,
tsync.glpi_sync_status
FROM ticket_sync tsync
WHERE tsync.glpi_ticket_id IS NOT NULL
AND tsync.glpi_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
AND tsync.sn_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, 'ERRO: Falha ao buscar tickets para monitoramento de fechamento GLPI');
throw error;
}
}
static async getTicketsPendingSolve() { static async getTicketsPendingSolve() {
try { try {
const query = ` const query = `
@ -208,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;
@ -220,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

@ -101,6 +101,23 @@ class TicketUpdateModel {
throw error; throw error;
} }
} }
static async getPendingBySourcePrefix(sourcePrefix) {
try {
const query = `
SELECT *
FROM ticket_updates
WHERE source_id LIKE $1
AND destiny_id IS NULL
ORDER BY created_at ASC NULLS FIRST, id ASC
`;
const { rows } = await pool.query(query, [`${sourcePrefix}%`]);
return rows;
} catch (error) {
logError(error, `Erro ao buscar updates pendentes por prefixo: ${sourcePrefix}`);
throw error;
}
}
} }
module.exports = TicketUpdateModel; module.exports = TicketUpdateModel;
@ -115,4 +132,4 @@ module.exports = TicketUpdateModel;
* - `getBySourceId(sourceId)`: Verifica se uma atualização específica (ex: um comentário com um determinado ID) foi registrada no banco de dados. * - `getBySourceId(sourceId)`: Verifica se uma atualização específica (ex: um comentário com um determinado ID) foi registrada no banco de dados.
* - `updateDestinyId(sourceId, destinyId)`: Após uma atualização ser sincronizada com o sistema de destino, este método armazena o ID gerado no destino, completando o ciclo de mapeamento. * - `updateDestinyId(sourceId, destinyId)`: Após uma atualização ser sincronizada com o sistema de destino, este método armazena o ID gerado no destino, completando o ciclo de mapeamento.
* - `getLatestUpdate(...)`: Busca a atualização mais recente para um determinado ticket, útil para obter notas de fechamento, por exemplo. * - `getLatestUpdate(...)`: Busca a atualização mais recente para um determinado ticket, útil para obter notas de fechamento, por exemplo.
*/ */

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');
@ -28,7 +28,7 @@ const normalizeAuthorName = (rawAuthor) => {
return toTitleCase(normalizedRaw); return toTitleCase(normalizedRaw);
}; };
const formatSNUpdateForGlpi = (update) => { const formatSNUpdateForGlpi = (update, options = {}) => {
const escapeHtml = (value) => String(value || '') const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
@ -39,6 +39,9 @@ const formatSNUpdateForGlpi = (update) => {
const content = escapeHtml(update.content || ''); const content = escapeHtml(update.content || '');
const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || '')); const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || ''));
const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || '')); const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || ''));
const isSnReopenMessageByText = update.update_type === 'comment' && /reabert|reopen/i.test(String(update.content || ''));
const isSnReopenMessageByContext = Boolean(options.isReopenContextComment);
const isSnReopenMessage = isSnReopenMessageByText || isSnReopenMessageByContext;
if (isAutomaticTask) { if (isAutomaticTask) {
return ` return `
@ -59,6 +62,14 @@ const formatSNUpdateForGlpi = (update) => {
</div>`; </div>`;
} }
if (isSnReopenMessage) {
return `
<div style="border:1px solid #9ae6b4; border-left:4px solid #2f855a; background:#f0fff4; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#22543d; margin-bottom:6px;">Reabertura de chamado no ServiceNow</div>
<div style="color:#276749; font-size:12px; white-space: pre-wrap;">${content}</div>
</div>`;
}
return ` return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;"> <div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">${author}</div> <div style="font-weight:700; color:#1a365d; margin-bottom:6px;">${author}</div>
@ -70,29 +81,37 @@ const formatSNUpdateForGlpi = (update) => {
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,
@ -146,32 +165,37 @@ 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}`);
} }
}; };
const syncCommentsSNtoGlpi = async (comments, 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 orderedComments = [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime(); const bTime = new Date(b.created_at).getTime();
return aTime - bTime; return aTime - bTime;
}); });
let reopenHighlightPending = Boolean(options.highlightReopenContext);
for (const comment of orderedComments) { for (const comment of orderedComments) {
try { try {
const isReopenContextComment = reopenHighlightPending && comment.update_type === 'comment';
const formattedPayload = { const formattedPayload = {
...comment, ...comment,
content: formatSNUpdateForGlpi(comment) content: formatSNUpdateForGlpi(comment, { isReopenContextComment })
}; };
const isTask = comment.update_type === 'task'; const isTask = comment.update_type === 'task';
const insertedId = isTask const insertedId = isTask
? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id) ? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id)
: await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id); : await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id);
if (isReopenContextComment) {
reopenHighlightPending = false;
}
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');

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 } = 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;
} }
@ -40,6 +41,8 @@ const processSingleTicket = async (ticket) => {
if (ticket.glpi_ticket_id) { if (ticket.glpi_ticket_id) {
logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`); logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`);
await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced'); await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced');
const linkedOk = await updateExternalTicketInServiceNow(ticket.id, ticket.glpi_ticket_id);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, ticket.glpi_ticket_id, linkedOk);
return; return;
} }
@ -56,6 +59,8 @@ const processSingleTicket = async (ticket) => {
return; return;
} }
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
const existingLinkOk = await updateExternalTicketInServiceNow(ticket.id, existingTicket);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, existingTicket, existingLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`); logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
return; return;
} }
@ -80,6 +85,8 @@ const processSingleTicket = async (ticket) => {
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData); const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId);
const externalLinkOk = await updateExternalTicketInServiceNow(ticket.id, glpiTicketId);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, glpiTicketId, externalLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`); logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`);
@ -93,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 };
} }
}; };
@ -202,9 +184,10 @@ const closeTicketInGlpi = async (ticket) => {
module.exports = { module.exports = {
syncTicketsToGlpi, syncTicketsToGlpi,
checkGlpiStatusChanges,
closeTicketInGlpi, closeTicketInGlpi,
addServiceNowClosureNoteToGlpi addServiceNowClosureNoteToGlpi,
isOutOfScopeSolution,
handleOutOfScopeResolution
}; };
/** /**
@ -215,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,22 +3,50 @@ 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');
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77'; const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA'; const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false'; const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
const externalTicketField = process.env.SERVICENOW_EXTERNAL_TICKET_FIELD || 'u_external_tickets';
const ignoredJournalUsers = (
process.env.SERVICENOW_IGNORE_USERS_JOURNAL ||
`${servicenowDefaultUser},admin.caoa`
)
.split(',')
.map((u) => String(u || '').trim().toLowerCase())
.filter(Boolean);
const normalizeServiceNowDateTime = (value) => { const normalizeServiceNowDateTime = (value) => {
if (!value) return null; if (!value) return null;
const asString = String(value).trim(); const asString = String(value).trim();
const m = asString.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`; // Formato ISO/local: YYYY-MM-DD HH:mm:ss
const ymd = asString.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
if (ymd) {
return `${ymd[1]}-${ymd[2]}-${ymd[3]} ${ymd[4]}:${ymd[5]}:${ymd[6]}`;
}
// Formato SN comum com barra: DD/MM/YYYY HH:mm:ss
const dmy = asString.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
if (dmy) {
return `${dmy[3]}-${dmy[2]}-${dmy[1]} ${dmy[4]}:${dmy[5]}:${dmy[6]}`;
}
const parsed = new Date(asString); const parsed = new Date(asString);
if (Number.isNaN(parsed.getTime())) return null; if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 19).replace('T', ' ');
// Fallback preservando timestamp local sem converter para UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
};
const isAutomaticServiceNowTask = (entry) => {
if (!entry || entry.element !== 'work_notes') return false;
const content = String(entry.value || '');
return /registro alterado por:/i.test(content);
}; };
const fetchTicketsFromServiceNow = async (watermark) => { const fetchTicketsFromServiceNow = async (watermark) => {
@ -79,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);
@ -178,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;
} }
} }
@ -209,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;
@ -229,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
@ -263,13 +315,18 @@ const fetchCommentsFromServiceNow = async (ticket) => {
// Em ambiente de teste, a regra pode ser desligada por env. // Em ambiente de teste, a regra pode ser desligada por env.
const filteredComments = allComments.filter((entry) => { const filteredComments = allComments.filter((entry) => {
if (!shouldIgnoreDefaultUserJournal) { if (!shouldIgnoreDefaultUserJournal) {
return true; return !isAutomaticServiceNowTask(entry);
}
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
const ignoredUser = ignoredJournalUsers.includes(createdBy);
if (isAutomaticServiceNowTask(entry)) {
return false;
} }
if (entry.element === 'comments') { if (entry.element === 'comments') {
return entry.sys_created_by !== servicenowDefaultUser; return !ignoredUser;
} }
if (entry.element === 'work_notes') { if (entry.element === 'work_notes') {
return entry.sys_created_by !== servicenowDefaultUser; return !ignoredUser;
} }
return true; return true;
}); });
@ -281,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 = [];
@ -294,24 +351,15 @@ 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 {
let normalizedContent = comment.value;
if (
comment.element === 'work_notes' &&
typeof normalizedContent === 'string' &&
normalizedContent.includes('Registro alterado por:')
) {
normalizedContent = `Tarefa automatica originada no ServiceNow (registro automatico).\n\nMensagem original:\n${normalizedContent}`;
}
const commentData = { const commentData = {
ticket_sync_id: ticket.id, ticket_sync_id: ticket.id,
update_type: comment.element === 'work_notes' ? 'task' : 'comment', update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow', source_system: 'servicenow',
content: normalizedContent, content: comment.value,
created_at: normalizeServiceNowDateTime(comment.sys_created_on), created_at: normalizeServiceNowDateTime(comment.sys_created_on),
author: comment.sys_created_by || 'ServiceNow', author: comment.sys_created_by || 'ServiceNow',
updated_at: normalizeServiceNowDateTime(comment.sys_created_on), updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
@ -389,17 +437,6 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
}; };
const updateStatusInServiceNow = async (snTicketId, state) => { const updateStatusInServiceNow = async (snTicketId, state) => {
// Mapeamento de status de string para codigo numerico do ServiceNow
const stateMap = {
'Aguardando Atendimento': '1', // New
'Em Atendimento': '2', // In Progress
'Em Espera': '-5', // On Hold
'Encerrado': '6', // Resolved
'Encerrado - Omitido': '7' // Closed
};
const stateCode = stateMap[state] || state; // Usa o codigo mapeado ou o valor original se nao encontrar
let sysId = null; // Declarar sysId fora do try para estar disponivel no catch let sysId = null; // Declarar sysId fora do try para estar disponivel no catch
try { try {
const ticketSn = await TicketSnModel.findById(snTicketId); const ticketSn = await TicketSnModel.findById(snTicketId);
@ -411,6 +448,19 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
sysId = ticketSn.sys_id; // Atribuir valor sysId = ticketSn.sys_id; // Atribuir valor
const ticketType = ticketSn.tipo; const ticketType = ticketSn.tipo;
// Mapeamento de status de string para codigo numerico do ServiceNow. incident e sc_req_item
// (requisicao) tem modelos de status diferentes - confirmado via teste real em dev que
// 'Em Espera' e '-5' para requisicao, e '3' para incidente (o mesmo codigo que e invalido/
// bloqueado por ACL do outro lado - ja aconteceu erro 403 em producao usando '3' numa requisicao).
const stateMap = {
'Aguardando Atendimento': '1', // New / Open
'Em Atendimento': '2', // In Progress / Work in Progress
'Em Espera': ticketType === 'requisicao' ? '-5' : '3', // On Hold
'Encerrado': '6', // Resolved
'Encerrado - Omitido': '7' // Closed
};
const stateCode = stateMap[state] || state; // Usa o codigo mapeado ou o valor original se nao encontrar
let url = ''; let url = '';
let payload = {}; let payload = {};
@ -434,7 +484,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;
@ -501,6 +559,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;
@ -548,6 +615,201 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
} }
}; };
const addCommentToServiceNow = async (snTicketId, comment) => {
let sysId = null;
let ticketType = null;
let url = '';
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar comentario.`, 'addCommentToServiceNow');
return null;
}
sysId = ticketSn.sys_id;
ticketType = ticketSn.tipo;
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { comments: comment };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao adicionar comentario no SN. Status: ${response.status}`, { snTicketId, sysId });
return null;
}
logInfo(`OK: Comentario adicionado com sucesso ao ticket SN ${sysId}.`);
return true;
} catch (error) {
const status = error?.response?.status;
if (status === 403) {
logError('ERRO: 403 ao adicionar comentario no SN (ACL/permissao/regra do estado).', {
snTicketId,
sysId,
ticketType,
endpoint: url
});
return null;
}
logError(error, `ERRO: Falha ao adicionar comentario ao ticket SN (sysId: ${sysId})`);
return null;
}
};
const updateExternalTicketInServiceNow = async (snTicketId, glpiTicketId) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizar Ticket Externo.`, 'updateExternalTicketInServiceNow');
return false;
}
sysId = ticketSn.sys_id;
const ticketType = ticketSn.tipo;
let url = '';
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { [externalTicketField]: String(glpiTicketId) };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao atualizar Ticket Externo no SN. Status: ${response.status}`, { snTicketId, glpiTicketId, externalTicketField });
return false;
}
// Validacao de leitura: alguns campos customizados podem retornar 200 e nao persistir por ACL/schema.
const verifyResp = await axios.get(`${url}?sysparm_fields=${externalTicketField}&sysparm_display_value=true`, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Accept': 'application/json' }
});
const result = verifyResp?.data?.result || {};
const rawField = result[externalTicketField];
const persistedValue = typeof rawField === 'object'
? (rawField.value || rawField.display_value || '')
: (rawField || '');
if (String(persistedValue) !== String(glpiTicketId)) {
logError(`Ticket Externo nao persistiu no SN (campo ${externalTicketField}).`, {
snTicketId,
sysId,
expected: String(glpiTicketId),
got: String(persistedValue)
});
return false;
}
logInfo(`OK: Ticket Externo confirmado no SN (${externalTicketField}=${glpiTicketId}).`, { snTicketId, sysId });
return true;
} catch (error) {
logError(error, `ERRO: Falha ao atualizar Ticket Externo no SN (sysId: ${sysId})`);
return false;
}
};
const 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 sourceId = `external_link:${snTicketId}:${glpiTicketId}`;
const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField });
await TicketUpdateModel.insert({
ticket_sync_id: ticketSyncId,
update_type: 'external_link',
source_system: 'sync',
content: payload,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: shouldMarkDone ? 'done' : null
});
};
const processPendingExternalTicketLinks = async () => {
try {
const pendings = await TicketUpdateModel.getPendingBySourcePrefix('external_link:');
if (!pendings.length) return;
for (const pending of pendings) {
try {
const payload = JSON.parse(pending.content || '{}');
const snTicketId = payload.sn_ticket_id;
const glpiTicketId = payload.glpi_ticket_id;
if (!snTicketId || !glpiTicketId) {
logError(`Payload invalido para retry de Ticket Externo. update_id=${pending.id}`);
continue;
}
const ok = await updateExternalTicketInServiceNow(snTicketId, glpiTicketId);
if (ok) {
await TicketUpdateModel.updateDestinyId(pending.source_id, 'done');
}
} catch (error) {
logError(error, `Falha ao processar retry de Ticket Externo. update_id=${pending.id}`);
}
}
} catch (error) {
logError(error, 'ERRO: Falha geral no retry de Ticket Externo');
}
};
/** /**
* Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico. * Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico.
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM). * @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
@ -591,6 +853,7 @@ module.exports = {
fetchTicketsFromServiceNow, fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow, fetchRequestsFromServiceNow,
fetchTicketLiveStatusFromServiceNow, fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
getTicketSysId, getTicketSysId,
getTicketTypeById, getTicketTypeById,
createCommentInServiceNow, createCommentInServiceNow,
@ -602,7 +865,12 @@ module.exports = {
closeTicketInServiceNow, closeTicketInServiceNow,
updateStatusInServiceNow, updateStatusInServiceNow,
addWorkNoteToServiceNow, addWorkNoteToServiceNow,
fetchScItemOptionValue addCommentToServiceNow,
fetchScItemOptionValue,
updateExternalTicketInServiceNow,
reassignTicketInServiceNow,
queueExternalTicketLinkRetry,
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

@ -30,7 +30,7 @@ const logger = winston.createLogger({
format: winston.format.combine( format: winston.format.combine(
winston.format.printf((info) => { winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info; const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${stack || message}`; let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) { if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`; logMessage += ` ${JSON.stringify(meta, null, 2)}`;
} }
@ -49,7 +49,7 @@ const logger = winston.createLogger({
format: winston.format.combine( format: winston.format.combine(
winston.format.printf((info) => { winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info; const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${stack || message}`; let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) { if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`; logMessage += ` ${JSON.stringify(meta, null, 2)}`;
} }
@ -67,7 +67,7 @@ if (logToConsole) {
winston.format.colorize(), winston.format.colorize(),
winston.format.printf((info) => { winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info; const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${stack || message}`; let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) { if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`; logMessage += ` ${JSON.stringify(meta, null, 2)}`;
} }
@ -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,
}; };