Compare commits
28 Commits
feature/sn
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| be6d4a891e | |||
| 3b050e626c | |||
| bc0fc282a3 | |||
| 0df7443379 | |||
| e67d376c4f | |||
| f934b8f4ae | |||
| 64e384036a | |||
| dd1d1e0851 | |||
| 8e4e79d059 | |||
| b16a420fab | |||
| e838081aae | |||
| 0daefc243c | |||
| cefeb3ea25 | |||
| 5b30849f79 | |||
| 2cf6e9f2fb | |||
| 2437864133 | |||
| cc17d957c7 | |||
| bce9a52494 | |||
| ed19cd76a7 | |||
| 00b573d60d | |||
| 686c2f0a54 | |||
|
|
b4a7e6c366 | ||
|
|
b2cbeb66e5 | ||
| 6b67d5002f | |||
| c964bcf18a | |||
| ce294d4220 | |||
| dfabf699a5 | |||
| 35b4a1fef9 |
24
.env.example
24
.env.example
@ -12,6 +12,18 @@ PORT=3000
|
||||
|
||||
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
|
||||
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 ---
|
||||
# URL base da sua instancia do ServiceNow
|
||||
@ -26,12 +38,17 @@ SERVICENOW_ASSIGNMENT_GROUP=
|
||||
SERVICENOW_DEFAULT_USER_SYSID=
|
||||
SERVICENOW_DEFAULT_USER=
|
||||
SERVICENOW_RESOLVED_BY_SYSID=
|
||||
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=true
|
||||
SERVICENOW_IGNORE_USERS_JOURNAL=SOTHIS.CAOA,admin.caoa
|
||||
|
||||
# Endpoints de tabela usados pela aplicacao
|
||||
SERVICENOW_TABLE_INCIDENT_URL=
|
||||
SERVICENOW_TABLE_REQUEST_URL=
|
||||
SERVICENOW_TABLE_JOURNAL_URL=
|
||||
SERVICENOW_SC_ITEM_OPTION_URL=
|
||||
SERVICENOW_EXTERNAL_TICKET_FIELD=u_external_tickets
|
||||
# Sys_id do grupo de TI da CAOA (destino de tickets fora do escopo Sothis)
|
||||
SERVICENOW_CAOA_TI_GROUP_ID=
|
||||
|
||||
# --- Configuracao do Banco de Dados do GLPI (MySQL) ---
|
||||
GLPI_DB_TYPE=mysql
|
||||
@ -57,15 +74,12 @@ SNGLPI_DB_NAME=snglpi_sync
|
||||
SNGLPI_DB_USER=
|
||||
SNGLPI_DB_PASSWORD=
|
||||
|
||||
# --- Configuracao de Logs ---
|
||||
LOG_LEVEL=debug
|
||||
LOG_TO_CONSOLE=true
|
||||
|
||||
# --- Mapeamento de Localidades (Opcional) ---
|
||||
# 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
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_LEVEL=info
|
||||
LOG_TO_CONSOLE=true
|
||||
LOG_RETENTION_DAYS=10
|
||||
LOG_DIR=logs
|
||||
|
||||
131
.gitea/workflows/deploy-production.yml
Normal file
131
.gitea/workflows/deploy-production.yml
Normal 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
1
.gitignore
vendored
@ -8,3 +8,4 @@ servicenow_tickets.json
|
||||
location_mapping.log
|
||||
.env*
|
||||
!.env.example
|
||||
docs/checklist.md
|
||||
303
README.md
303
README.md
@ -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`).
|
||||
- Salva/atualiza tickets no banco intermediario (`tickets_sn`).
|
||||
- Cria tickets no GLPI para registros pendentes (`ticket_sync.glpi_sync_status = 'pending_check'`).
|
||||
- Sincroniza comentarios em duas direcoes:
|
||||
- ServiceNow -> GLPI
|
||||
- GLPI -> ServiceNow
|
||||
- No fluxo ServiceNow -> GLPI:
|
||||
- `comments` viram comentarios (followups) no GLPI.
|
||||
- `work_notes` viram tarefas no GLPI.
|
||||
- Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`).
|
||||
- Trata regras de negocio especificas de fechamento/reabertura.
|
||||
- Reprocessa tickets em estado de erro no inicio de cada ciclo.
|
||||
- Executa em job agendado (`node-cron`) com protecao contra concorrencia local (`isCronRunning`).
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Componentes
|
||||
|
||||
- `cron.js`: agenda e dispara o ciclo de sincronizacao.
|
||||
- `src/app.js`: orquestra o ciclo principal.
|
||||
- `src/controllers/*`: fluxo de tickets, comentarios, status e recuperacao de erro.
|
||||
- `src/services/*`: integracoes com ServiceNow e GLPI.
|
||||
- `src/models/*`: acesso a dados (PostgreSQL e GLPI MySQL).
|
||||
- `src/data/*`: pools de conexao (`pg` e `mysql2`).
|
||||
- `src/scripts/python/update_location_mapping.py`: atualiza tabela `location_mapping` a partir de CSV.
|
||||
|
||||
### Bancos envolvidos
|
||||
|
||||
- PostgreSQL (`snglpi`): estado da integracao.
|
||||
- MySQL/MariaDB (GLPI): criacao/consulta/atualizacao de tickets e followups.
|
||||
1. Coleta de tickets SN por watermark.
|
||||
2. Criacao/vinculo de tickets no GLPI (grava `external_id` no GLPI com o numero do ticket SN).
|
||||
3. Sincronizacao de comentarios e tasks em duas direcoes.
|
||||
4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI.
|
||||
5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura:
|
||||
`status=5` (resolver SN), `status=6` (encerrar definitivamente no SN), nota no GLPI quando o SN
|
||||
resolve/encerra por conta propria (com aviso e troca de fila no SN), reabertura automatica e
|
||||
regra de fora de escopo.
|
||||
|
||||
## Fluxo do ciclo
|
||||
|
||||
Executado em ordem pelo `main()`:
|
||||
Executado pelo `main()`:
|
||||
|
||||
1. `processErrorController`
|
||||
2. `processTicketsController`
|
||||
3. `processCommentsController`
|
||||
4. `processStatusAndClosureController`
|
||||
3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
|
||||
4. `processCommentsController`
|
||||
|
||||
## Regras de negocio importantes
|
||||
## Documentacao detalhada
|
||||
|
||||
- Controle de precedencia por `source_last` para reduzir conflito de atualizacao.
|
||||
- Ao detectar divergencia de status, o bastao nao e invertido para `GLPI` quando a ultima origem valida ja e `SNOW`.
|
||||
- Encerramento/Resolucao vindo do ServiceNow:
|
||||
- Nao fecha ticket no GLPI automaticamente.
|
||||
- Insere nota formatada no GLPI.
|
||||
- Marca sync como `closed/closed` para remover do monitoramento.
|
||||
- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (fora do escopo):
|
||||
- Nao resolve no SN.
|
||||
- Adiciona `work_note` no SN.
|
||||
- Marca sincronizacao como encerrada/ignorada.
|
||||
- Se GLPI resolver e SN estiver em `Em Espera` ou `Aguardando Atendimento`, integracao forca `Em Atendimento` antes de resolver no SN.
|
||||
- Comentarios GLPI sao sanitizados (HTML/imagens/metadados) antes de envio ao SN.
|
||||
- Watermark de coleta no ServiceNow usa margem de seguranca de 6 horas para tras (`newWatermark - 6h`).
|
||||
- Motivo: reduzir risco de perda de eventos em casos de atraso de replicacao, diferenca de timezone e clock skew entre sistemas.
|
||||
- Efeito colateral esperado: releitura de uma janela recente e maior chance de reprocessamento controlado (idempotencia pelo banco local).
|
||||
1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md)
|
||||
2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md)
|
||||
3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md)
|
||||
4. Manual do usuario (se eu fizer X, acontece Y): [docs/manualusuario.md](docs/manualusuario.md)
|
||||
5. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md)
|
||||
|
||||
## Regras de negocio detalhadas
|
||||
## Variaveis de ambiente importantes
|
||||
|
||||
1. Controle de origem (`source_last`)
|
||||
- `SNOW` ou `GLPI` define quem teve a ultima escrita valida para o ticket.
|
||||
- Evita corrida de atualizacao de status/comentario entre os dois sistemas.
|
||||
### Feature flags
|
||||
|
||||
2. Status GLPI -> ServiceNow
|
||||
- Solucao no GLPI dispara tentativa de resolucao no SN.
|
||||
- Se SN estiver em status bloqueante (`Em Espera` ou `Aguardando Atendimento`), a integracao seta `Em Atendimento` antes da resolucao.
|
||||
- Regra fora do escopo (`solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`.
|
||||
|
||||
3. Status ServiceNow -> GLPI
|
||||
- Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
|
||||
- Para `Resolvido`, `Encerrado` e `Encerrado - Omitido` no SN:
|
||||
- GLPI recebe nota de encerramento/resolucao.
|
||||
- Integracao remove ticket do monitoramento (`closed/closed`), sem fechar ticket no GLPI.
|
||||
- Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao.
|
||||
|
||||
4. Comentarios GLPI -> SN
|
||||
- Fluxo com verificacao de existencia local/remota para evitar duplicatas.
|
||||
- Conteudo passa por sanitizacao para remover HTML, normalizar texto e tratar imagens.
|
||||
|
||||
5. Comentarios SN -> GLPI
|
||||
- Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI.
|
||||
- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI.
|
||||
- Campo `author` no banco intermediario usa `sys_created_by` do SN.
|
||||
- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho.
|
||||
- IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade.
|
||||
|
||||
6. Reprocessamento de erro
|
||||
- Inicio de cada ciclo tenta resetar estados de erro para recolocar tickets no fluxo automatico.
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Node.js 18+
|
||||
- NPM
|
||||
- PostgreSQL
|
||||
- MySQL/MariaDB com base do GLPI acessivel
|
||||
- Python 3.10+ (para o mapeador de localidades)
|
||||
|
||||
## Instalacao
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dependencias Python
|
||||
|
||||
```bash
|
||||
cd src/scripts/python
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuracao de ambiente
|
||||
|
||||
Crie `.env.development` e `.env.production` com base em `.env.example` e inclua tambem as variaveis usadas no codigo:
|
||||
1. `ENABLE_GLPI_CLOSE_CRON`
|
||||
- `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
|
||||
- `false`: desliga o motor inteiro
|
||||
|
||||
### ServiceNow
|
||||
|
||||
- `SERVICENOW_USERNAME`
|
||||
- `SERVICENOW_PASSWORD`
|
||||
- `SERVICENOW_ASSIGNMENT_GROUP`
|
||||
- `SERVICENOW_TABLE_INCIDENT_URL`
|
||||
- `SERVICENOW_TABLE_REQUEST_URL`
|
||||
- `SERVICENOW_TABLE_JOURNAL_URL`
|
||||
- `SERVICENOW_SC_ITEM_OPTION_URL`
|
||||
- `SERVICENOW_DEFAULT_USER`
|
||||
- `SERVICENOW_RESOLVED_BY_SYSID`
|
||||
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
|
||||
1. `SERVICENOW_TABLE_INCIDENT_URL`
|
||||
2. `SERVICENOW_TABLE_REQUEST_URL`
|
||||
3. `SERVICENOW_TABLE_JOURNAL_URL`
|
||||
4. `SERVICENOW_SC_ITEM_OPTION_URL`
|
||||
5. `SERVICENOW_DEFAULT_USER`
|
||||
6. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`
|
||||
7. `SERVICENOW_EXTERNAL_TICKET_FIELD` (default: `u_external_ticket`)
|
||||
|
||||
### GLPI (MySQL/MariaDB)
|
||||
### GLPI e banco intermediario
|
||||
|
||||
- `GLPI_DB_HOST`
|
||||
- `GLPI_DB_PORT`
|
||||
- `GLPI_DB_USER`
|
||||
- `GLPI_DB_PASSWORD`
|
||||
- `GLPI_DB_NAME`
|
||||
- `GLPI_DB_CHARSET`
|
||||
- `GLPI_DEFAULT_USER_ID`
|
||||
- `GLPI_DEFAULT_GROUP_ID` (padrao `30`)
|
||||
- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`)
|
||||
- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`)
|
||||
1. `GLPI_DB_*`
|
||||
2. `SNGLPI_DB_*`
|
||||
3. `GLPI_OOS_SOLUTION_TYPE_ID`
|
||||
|
||||
### Banco intermediario (PostgreSQL)
|
||||
|
||||
- `SNGLPI_DB_HOST`
|
||||
- `SNGLPI_DB_PORT`
|
||||
- `SNGLPI_DB_NAME`
|
||||
- `SNGLPI_DB_USER`
|
||||
- `SNGLPI_DB_PASSWORD`
|
||||
|
||||
### Agendamento e mapeamento
|
||||
|
||||
- `CRON_SCHEDULE` (ex.: `*/5 * * * *`)
|
||||
- `LOCATION_MAPPING_CSV_PATH` (arquivo CSV para mapeamento SN -> GLPI)
|
||||
|
||||
## Execucao
|
||||
|
||||
### Desenvolvimento
|
||||
## Desenvolvimento
|
||||
|
||||
1. Instalar dependencias:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. Criar env:
|
||||
```bash
|
||||
copy .env.example .env.development
|
||||
```
|
||||
3. Executar:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Producao
|
||||
|
||||
```bash
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
## Execucao com PM2
|
||||
|
||||
Subir os dois processos (sync + mapeador):
|
||||
## Producao
|
||||
|
||||
### Deploy manual na VM
|
||||
|
||||
```bash
|
||||
git pull origin master
|
||||
npm ci --omit=dev
|
||||
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
|
||||
pm2 list
|
||||
pm2 logs sn-glpi-sync-cron
|
||||
pm2 logs sn-glpi-location-mapper
|
||||
pm2 restart sn-glpi-sync-cron
|
||||
```
|
||||
|
||||
## Script de mapeamento de localidades
|
||||
### 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.*`.
|
||||
- Monitora alteracoes recentes no CSV (`LOCATION_MAPPING_CSV_PATH`).
|
||||
- Recria os dados da tabela `location_mapping` com base no CSV e IDs validos em ambos os bancos.
|
||||
- Roda em loop continuo (ideal via PM2 como processo separado).
|
||||
1. Ele existe dentro da propria pasta de deploy (`$PROD_DEPLOY_PATH/.env.production`, ex.:
|
||||
`/opt/sn-glpi-sync-new/.env.production`) — nao na raiz de nenhum usuario (nem `dev`, nem
|
||||
`root`). `cron.js`/`src/app.js` resolvem o caminho do env relativo a propria raiz do repo, entao
|
||||
e ali que ele precisa estar.
|
||||
2. O workflow ja aplica `chmod 600` nele apos escrever (dono = usuario que roda o PM2), evitando
|
||||
outro usuario local da VM lendo credencial de banco/API em texto puro.
|
||||
3. Pra atualizar uma credencial/config de producao, edite o secret `ENV_PRODUCTION` no Gitea e rode
|
||||
o deploy de novo (push em `master` ou `workflow_dispatch`) — nao precisa mais SSH na VM pra
|
||||
editar arquivo a mao. Como o secret fica guardado no Gitea (fora da VM), tambem resolve o risco
|
||||
de perder a unica copia das credenciais se o disco da VM falhar.
|
||||
|
||||
## 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`.
|
||||
- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro.
|
||||
- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`).
|
||||
- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone.
|
||||
- Campos principais persistidos localmente:
|
||||
- Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`.
|
||||
- Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`.
|
||||
O deploy guarda a versao anterior em `$PROD_DEPLOY_PATH.previous` antes de sincronizar o codigo
|
||||
novo. Se o deploy subir uma versao com problema (o `node --check` so pega erro de sintaxe, nao de
|
||||
logica), o rollback e:
|
||||
|
||||
## 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`
|
||||
- `ticket_sync`
|
||||
- `ticket_updates`
|
||||
- `location_mapping`
|
||||
- `sync_control`
|
||||
|
||||
Arquivo auxiliar de manutencao/reset (com comandos destrutivos):
|
||||
|
||||
- `src/scripts/database/maintenance_reset.sql`
|
||||
|
||||
## Limitacoes conhecidas (estado atual)
|
||||
|
||||
- Nao ha suite de testes automatizados (`npm test` e placeholder).
|
||||
- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão)
|
||||
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
|
||||
|
||||
## Proximas melhorias recomendadas
|
||||
|
||||
- Cobertura de testes (unitario e integracao com mocks de API/DB).
|
||||
- Externalizar IDs e regras hardcoded para variaveis de ambiente.
|
||||
- Revisar estrategia de watermark/timezone e idempotencia.
|
||||
- Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados).
|
||||
- Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria).
|
||||
- Alterar uso de banco do GLPI para a API do GLPI.
|
||||
|
||||
## Arquivos principais
|
||||
|
||||
- `cron.js`
|
||||
- `src/app.js`
|
||||
- `src/controllers/processTicketsController.js`
|
||||
- `src/controllers/processCommentsController.js`
|
||||
- `src/controllers/processStatusController.js`
|
||||
- `src/controllers/processErrorController.js`
|
||||
- `src/services/servicenowService.js`
|
||||
- `src/services/glpiTicketService.js`
|
||||
- `src/services/glpiCommentService.js`
|
||||
- `src/scripts/python/update_location_mapping.py`
|
||||
1. O projeto ainda nao tem suite automatizada robusta.
|
||||
2. O rollout recomendado e por fases com feature flags.
|
||||
3. Nao remover schema legado antes de estabilizar o fluxo novo.
|
||||
4. Consulte `docs/checklist.md` antes de cada deploy para validar pre e pos-subida.
|
||||
|
||||
153
docs/casosdeuso.md
Normal file
153
docs/casosdeuso.md
Normal 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
89
docs/fluxo.md
Normal 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
103
docs/manualusuario.md
Normal 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
228
docs/regrasdenegocio.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
48
src/app.js
48
src/app.js
@ -2,50 +2,48 @@ const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
let envFile = '.env.development';
|
||||
if (nodeEnv === 'production') {envFile = '.env.production';} else {envFile = '.env.development';}
|
||||
if (nodeEnv === 'production') {
|
||||
envFile = '.env.production';
|
||||
} else {
|
||||
envFile = '.env.development';
|
||||
}
|
||||
dotenv.config({ path: path.resolve(__dirname, '..', envFile) });
|
||||
|
||||
const { processTicketsController } = require('./controllers/processTicketsController');
|
||||
const { processCommentsController } = require('./controllers/processCommentsController');
|
||||
const { processErrorController } = require('./controllers/processErrorController');
|
||||
const { processStatusAndClosureController } = require('./controllers/processStatusController');
|
||||
const { logInfo, logger } = require('./utils/logger');
|
||||
const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController');
|
||||
const { logInfo } = require('./utils/logger');
|
||||
|
||||
logInfo(`🌎 Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
|
||||
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
logInfo('Aplicação iniciada', {
|
||||
logInfo('Aplicacao iniciada', {
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const ticketLifecycleEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
|
||||
|
||||
await processErrorController();
|
||||
await processTicketsController();
|
||||
|
||||
if (ticketLifecycleEnabled) {
|
||||
await processTicketLifecycleController();
|
||||
} else {
|
||||
logInfo('Motor de regras de status/fechamento desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
|
||||
}
|
||||
|
||||
await processCommentsController();
|
||||
await processStatusAndClosureController();
|
||||
logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉');
|
||||
|
||||
logInfo('Ciclo de sincronizacao concluido com sucesso.');
|
||||
} catch (error) {
|
||||
logInfo('Erro na aplicação:', error);
|
||||
logInfo('Erro na aplicacao:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// A chamada main() foi removida daqui.
|
||||
// Agora, o app.js apenas define e exporta a função main.
|
||||
// A responsabilidade de EXECUTAR a função main é do cron.js,
|
||||
// que a importará e a chamará dentro do agendamento.
|
||||
module.exports = { main };
|
||||
|
||||
/**
|
||||
* @file app.js
|
||||
* @description
|
||||
* Ponto de entrada principal da aplicação de sincronização. Este arquivo é responsável por:
|
||||
* 1. Carregar as variáveis de ambiente corretas (`.env.development` ou `.env.production`) com base na variável `NODE_ENV`.
|
||||
* 2. Orquestrar a execução sequencial dos controladores que compõem o ciclo de sincronização.
|
||||
*
|
||||
* O fluxo de execução é o seguinte:
|
||||
* 1. `processErrorController`: Verifica e reseta tickets que falharam em execuções anteriores, permitindo que sejam reprocessados.
|
||||
* 2. `processTicketsController`: Busca novos tickets (incidentes e requisições) do ServiceNow, os salva no banco de dados intermediário e cria os tickets correspondentes no GLPI.
|
||||
* 3. `processCommentsController`: Realiza a sincronização bidirecional de comentários (notas/follow-ups) para os tickets que já 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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@ -1,30 +1,33 @@
|
||||
const {fetchCommentsFromServiceNow } = require('../services/servicenowService');
|
||||
const { fetchCommentsFromServiceNow } = require('../services/servicenowService');
|
||||
const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService');
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const { logInfo } = require('../utils/logger');
|
||||
|
||||
const TicketGlpiModel = require('../models/ticketGlpiModel');
|
||||
const { logDebug } = require('../utils/logger');
|
||||
|
||||
const processCommentsController = async () => {
|
||||
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
|
||||
logInfo(`📋Tickets em monitoramento: ${ticketsToMonitor.length}`);
|
||||
logDebug(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
|
||||
|
||||
for (const ticket of ticketsToMonitor) {
|
||||
const 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);
|
||||
// Se houver comentários novos no ServiceNow, sincroniza para o GLPI
|
||||
if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);}
|
||||
// Sincroniza comentários do GLPI para o ServiceNow
|
||||
if (comments.length !== 0) {
|
||||
await syncCommentsSNtoGlpi(comments, ticket);
|
||||
}
|
||||
|
||||
await syncCommentsGlpitoSN(ticket);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
processCommentsController
|
||||
}
|
||||
|
||||
/**
|
||||
* @file processCommentsController.js
|
||||
* @description Este controlador orquestra a sincronização bidirecional de comentários para todos os tickets ativos.
|
||||
* Ele busca tickets que estão sendo monitorados e, para cada um, executa a sincronização de comentários do ServiceNow para o GLPI e vice-versa.
|
||||
*/
|
||||
};
|
||||
|
||||
@ -1,19 +1,29 @@
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const { logInfo, logError, logWarning } = require('../utils/logger');
|
||||
const { logInfo, logError, logWarning, logDebug } = require('../utils/logger');
|
||||
|
||||
const MAX_ERROR_RETRIES = Number(process.env.MAX_ERROR_RETRIES || 5);
|
||||
|
||||
const processErrorController = async () => {
|
||||
logInfo('Iniciando verificação de tickets com erro...');
|
||||
logDebug('Iniciando verificação de tickets com erro...');
|
||||
try {
|
||||
const errorTickets = await TicketSyncModel.getTicketsInErrorState();
|
||||
|
||||
if (errorTickets.length === 0) {
|
||||
logInfo('Nenhum ticket com erro encontrado para reprocessamento.');
|
||||
logDebug('Nenhum ticket com erro encontrado para reprocessamento.');
|
||||
return;
|
||||
}
|
||||
|
||||
logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`);
|
||||
|
||||
for (const ticket of errorTickets) {
|
||||
const retryCount = await TicketSyncModel.bumpErrorRetryCount(ticket.sn_ticket_id);
|
||||
|
||||
if (retryCount > MAX_ERROR_RETRIES) {
|
||||
await TicketSyncModel.markPermanentError(ticket.sn_ticket_id);
|
||||
logWarning(`Ticket SN ID ${ticket.sn_ticket_id} excedeu ${MAX_ERROR_RETRIES} tentativas de reprocessamento. Marcado como 'error_permanent' (requer intervencao manual).`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let newSnStatus = ticket.sn_sync_status;
|
||||
let newGlpiStatus = ticket.glpi_sync_status;
|
||||
|
||||
@ -28,7 +38,7 @@ const processErrorController = async () => {
|
||||
if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close';
|
||||
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus);
|
||||
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`);
|
||||
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}, tentativa ${retryCount}/${MAX_ERROR_RETRIES}).`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@ -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
|
||||
};
|
||||
@ -2,7 +2,7 @@ const TicketSnModel = require('../models/ticketSnModel');
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const SyncControlModel = require('../models/syncControlModel');
|
||||
const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService');
|
||||
const { logInfo, logError, logSync } = require('../utils/logger');
|
||||
const { logInfo, logError, logSync, logDebug } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Processa e salva um lote de tickets (incidentes ou requisicoes).
|
||||
@ -22,7 +22,7 @@ const processAndSaveTickets = async (tickets, type) => {
|
||||
};
|
||||
|
||||
if (!tickets || tickets.length === 0) {
|
||||
logInfo(`Nenhum ticket do tipo '${type}' para processar.`);
|
||||
logDebug(`Nenhum ticket do tipo '${type}' para processar.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -54,6 +54,10 @@ const processAndSaveTickets = async (tickets, type) => {
|
||||
logInfo(`Variaveis da requisicao ${ticketNumber}: justificativa=${ticket.justificativa ? 'ok' : 'vazia'}, telefone=${ticket.telefone ? 'ok' : 'vazio'}.`);
|
||||
}
|
||||
|
||||
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
|
||||
const existingTicketSn = await TicketSnModel.findByTicketNumber(ticketNumber);
|
||||
const oldStatus = existingTicketSn?.status || null;
|
||||
|
||||
const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type);
|
||||
const ticketStatus = ticket.state;
|
||||
|
||||
@ -78,10 +82,14 @@ const processAndSaveTickets = async (tickets, type) => {
|
||||
if (!exists) {
|
||||
await TicketSyncModel.createSyncRecord(dataSync);
|
||||
} else if (wasInserted === false) {
|
||||
if (oldStatus !== ticketStatus) {
|
||||
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
|
||||
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
|
||||
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow (status '${oldStatus}' -> '${ticketStatus}'). 'source_last' definido como 'SNOW'.`);
|
||||
} else {
|
||||
logInfo(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
|
||||
logInfo(`Ticket ${ticket.number} recebido de novo pela margem de seguranca do watermark, sem mudanca de status ('${ticketStatus}'). Ignorando reset de 'source_last'.`);
|
||||
}
|
||||
} else {
|
||||
logDebug(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
|
||||
}
|
||||
|
||||
const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
|
||||
@ -106,12 +114,12 @@ const processAndSaveTickets = async (tickets, type) => {
|
||||
const processSyncController = async () => {
|
||||
try {
|
||||
const watermark = await SyncControlModel.getWatermark('servicenow');
|
||||
logInfo(`INFO: Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
|
||||
logDebug(`Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
|
||||
|
||||
const incidents = await fetchTicketsApi(watermark);
|
||||
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
|
||||
|
||||
logInfo('INFO: Buscando requisicoes do ServiceNow...');
|
||||
logDebug('Buscando requisicoes do ServiceNow...');
|
||||
const requests = await fetchRequestsApi(watermark);
|
||||
const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao');
|
||||
|
||||
@ -123,7 +131,7 @@ const processSyncController = async () => {
|
||||
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
|
||||
await SyncControlModel.setWatermark('servicenow', nextWatermark);
|
||||
} else {
|
||||
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
|
||||
logDebug("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
219
src/controllers/processTicketLifecycleController.js
Normal file
219
src/controllers/processTicketLifecycleController.js
Normal 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`.
|
||||
*/
|
||||
@ -1,10 +1,12 @@
|
||||
const { syncTicketsToGlpi } = require('../services/glpiTicketService');
|
||||
const { processSyncController } = require('./processSyncController');
|
||||
const { processPendingExternalTicketLinks } = require('../services/servicenowService');
|
||||
|
||||
|
||||
const processTicketsController = async () => {
|
||||
await processSyncController();
|
||||
await syncTicketsToGlpi();
|
||||
await processPendingExternalTicketLinks();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// src/data/database.js
|
||||
// Configuração da conexão com o banco de dados PostgreSQL
|
||||
const { Pool } = require('pg');
|
||||
const { logInfo, logError } = require('../utils/logger');
|
||||
const { logInfo, logError, logDebug } = require('../utils/logger');
|
||||
|
||||
const poolConfig = {
|
||||
host: process.env.SNGLPI_DB_HOST ,
|
||||
@ -15,13 +15,11 @@ const pool = new Pool(poolConfig);
|
||||
|
||||
// Log da configuração do pool para depuração (sem a senha)
|
||||
const sanitizedConfig = { ...poolConfig, password: '*****' };
|
||||
logInfo('--- Configuração do Pool PostgreSQL (Banco Intermediário) ---');
|
||||
logInfo(sanitizedConfig);
|
||||
logInfo('----------------------------------------------------------');
|
||||
logInfo('Configuração do Pool PostgreSQL (Banco Intermediário)', sanitizedConfig);
|
||||
|
||||
// testa a conexao
|
||||
pool.on('connect', () => {
|
||||
logInfo('Conectado ao PostgreSQL');
|
||||
logDebug('Conectado ao PostgreSQL');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// src/data/glpiDatabase.js
|
||||
// Configuração da conexão com o banco de dados mariaDB do GLPI
|
||||
const mysql = require('mysql2/promise');
|
||||
const { logInfo, logError, logWarning } = require('../utils/logger');
|
||||
const { logError, logDebug } = require('../utils/logger');
|
||||
|
||||
const glpiPool = mysql.createPool({
|
||||
host: process.env.GLPI_DB_HOST,
|
||||
@ -18,7 +18,7 @@ const glpiPool = mysql.createPool({
|
||||
|
||||
// Testar conexão
|
||||
glpiPool.on('connection', (connection) => {
|
||||
logInfo('Nova conexão GLPI estabelecida');
|
||||
logDebug('Nova conexão GLPI estabelecida');
|
||||
});
|
||||
|
||||
glpiPool.on('error', (err) => {
|
||||
|
||||
@ -39,6 +39,7 @@ class TicketGlpiModel {
|
||||
const statusMap = {
|
||||
1: 'Aguardando Atendimento', // Novo
|
||||
2: 'Em Atendimento', // Em atendimento (atribuído)
|
||||
3: 'Em Atendimento', // Em planejamento (tratado como variacao de "em andamento" pro SN)
|
||||
4: 'Em Espera', // Pendente
|
||||
5: 'Resolvido', // Solucionado
|
||||
6: 'Encerrado - Omitido' // Fechado
|
||||
@ -239,7 +240,8 @@ class TicketGlpiModel {
|
||||
itilcategories_id: TicketGlpiModel.mapCategory(ticketData.short_description),
|
||||
global_validation: 1,
|
||||
date_creation: ticketData.opened_at || new Date(),
|
||||
slas_id_ttr: slasIdTtr
|
||||
slas_id_ttr: slasIdTtr,
|
||||
externalid: ticketData.ticket_number
|
||||
};
|
||||
|
||||
const fields = Object.keys(glpiTicketData).join(', ');
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const pool = require('../data/database');
|
||||
const { logInfo, logError } = require('../utils/logger');
|
||||
const { logInfo, logError, logDebug } = require('../utils/logger');
|
||||
|
||||
class TicketSnModel {
|
||||
|
||||
@ -94,7 +94,10 @@ class TicketSnModel {
|
||||
action: 'insert'
|
||||
});
|
||||
} else {
|
||||
logInfo(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
|
||||
// Fica em debug: a margem de seguranca de 6h do watermark reenvia o mesmo ticket
|
||||
// toda vez que ele ainda esta "quente", mesmo sem mudanca real de campo. O sinal
|
||||
// de mudanca de status de verdade ja e logado em processSyncController.js.
|
||||
logDebug(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
|
||||
ticket_id: result.rows[0].id,
|
||||
ticket_number: values[0],
|
||||
action: 'update'
|
||||
@ -112,6 +115,7 @@ class TicketSnModel {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
tsync.id AS ticket_sync_id,
|
||||
ts.id,
|
||||
ts.ticket_number,
|
||||
ts.short_description,
|
||||
@ -136,7 +140,7 @@ class TicketSnModel {
|
||||
`;
|
||||
|
||||
const result = await pool.query(query);
|
||||
logInfo(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
|
||||
logDebug(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
|
||||
return result.rows;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@ -16,7 +16,9 @@ class TicketSyncModel {
|
||||
sn_sync_status,
|
||||
glpi_sync_status,
|
||||
source_last
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (sn_ticket_id) DO NOTHING
|
||||
RETURNING id`;
|
||||
|
||||
const values = [
|
||||
data.sn_ticket_id,
|
||||
@ -174,7 +176,8 @@ class TicketSyncModel {
|
||||
FROM ticket_sync tsync
|
||||
JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id
|
||||
WHERE (tsync.glpi_sync_status IN ('synced', 'solved') OR tsync.sn_sync_status IN ('synced', 'solved'))
|
||||
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`;
|
||||
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')
|
||||
AND (tsync.glpi_sync_status != 'error_permanent' AND tsync.sn_sync_status != 'error_permanent')`;
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
@ -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() {
|
||||
try {
|
||||
const query = `
|
||||
@ -208,9 +234,10 @@ class TicketSyncModel {
|
||||
static async getTicketsInErrorState() {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status
|
||||
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status, error_retry_count
|
||||
FROM ticket_sync
|
||||
WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%'
|
||||
WHERE (sn_sync_status LIKE '%error%' AND sn_sync_status != 'error_permanent')
|
||||
OR (glpi_sync_status LIKE '%error%' AND glpi_sync_status != 'error_permanent')
|
||||
`;
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
@ -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) {
|
||||
try {
|
||||
let fieldToUpdate;
|
||||
|
||||
@ -101,6 +101,23 @@ class TicketUpdateModel {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getPendingBySourcePrefix(sourcePrefix) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ticket_updates
|
||||
WHERE source_id LIKE $1
|
||||
AND destiny_id IS NULL
|
||||
ORDER BY created_at ASC NULLS FIRST, id ASC
|
||||
`;
|
||||
const { rows } = await pool.query(query, [`${sourcePrefix}%`]);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logError(error, `Erro ao buscar updates pendentes por prefixo: ${sourcePrefix}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TicketUpdateModel;
|
||||
|
||||
11
src/scripts/database/add_error_retry_count_ticket_sync.sql
Normal file
11
src/scripts/database/add_error_retry_count_ticket_sync.sql
Normal 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;
|
||||
22
src/scripts/database/add_unique_ticket_sync_sn_ticket_id.sql
Normal file
22
src/scripts/database/add_unique_ticket_sync_sn_ticket_id.sql
Normal 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);
|
||||
@ -1,7 +1,7 @@
|
||||
const TicketGlpiModel = require('../models/ticketGlpiModel');
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
||||
const { logInfo, logError } = require('../utils/logger');
|
||||
const { logInfo, logError, logDebug } = require('../utils/logger');
|
||||
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
|
||||
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
|
||||
|
||||
@ -28,7 +28,7 @@ const normalizeAuthorName = (rawAuthor) => {
|
||||
return toTitleCase(normalizedRaw);
|
||||
};
|
||||
|
||||
const formatSNUpdateForGlpi = (update) => {
|
||||
const formatSNUpdateForGlpi = (update, options = {}) => {
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@ -39,6 +39,9 @@ const formatSNUpdateForGlpi = (update) => {
|
||||
const content = escapeHtml(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 isSnReopenMessageByText = update.update_type === 'comment' && /reabert|reopen/i.test(String(update.content || ''));
|
||||
const isSnReopenMessageByContext = Boolean(options.isReopenContextComment);
|
||||
const isSnReopenMessage = isSnReopenMessageByText || isSnReopenMessageByContext;
|
||||
|
||||
if (isAutomaticTask) {
|
||||
return `
|
||||
@ -59,6 +62,14 @@ const formatSNUpdateForGlpi = (update) => {
|
||||
</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 `
|
||||
<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>
|
||||
@ -70,26 +81,34 @@ const formatSNUpdateForGlpi = (update) => {
|
||||
|
||||
const syncCommentsGlpitoSN = async (ticket) => {
|
||||
try {
|
||||
logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
|
||||
logDebug('Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
|
||||
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
|
||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
|
||||
if (!Array.isArray(comments) || comments.length === 0) {
|
||||
logInfo(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||
logDebug(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`INFO: ${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
|
||||
logDebug(`${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
|
||||
let hasError = false;
|
||||
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
const sanitized = sanitizeGLPIComment(comment);
|
||||
// Checa idempotencia local (barato, sem chamada externa) antes de sanitizar e consultar o SN.
|
||||
// A maioria dos comentarios de um ticket ja monitorado ha varios ciclos ja tem destiny_id
|
||||
// gravado, entao nem precisa sanitizar nem chamar a API do SN pra saber disso.
|
||||
const existingComment = await TicketUpdateModel.getBySourceId(comment.id);
|
||||
if (existingComment && existingComment.destiny_id) {
|
||||
logDebug(`Comentario ${comment.id} ja sincronizado.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeGLPIComment(comment);
|
||||
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized);
|
||||
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
|
||||
|
||||
if (existingComment && existsInSN) {
|
||||
logInfo(`INFO: Comentario ${comment.id} ja sincronizado.`);
|
||||
logDebug(`Comentario ${comment.id} ja sincronizado.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -146,32 +165,37 @@ const syncCommentsGlpitoSN = async (ticket) => {
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus);
|
||||
}
|
||||
|
||||
logInfo('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
|
||||
logDebug('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
|
||||
} catch (error) {
|
||||
logError(`Erro geral na sincronizacao: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const syncCommentsSNtoGlpi = async (comments, ticket) => {
|
||||
logInfo('INFO: Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
|
||||
const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
|
||||
logDebug('Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
|
||||
|
||||
const orderedComments = [...comments].sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
let reopenHighlightPending = Boolean(options.highlightReopenContext);
|
||||
|
||||
for (const comment of orderedComments) {
|
||||
try {
|
||||
const isReopenContextComment = reopenHighlightPending && comment.update_type === 'comment';
|
||||
const formattedPayload = {
|
||||
...comment,
|
||||
content: formatSNUpdateForGlpi(comment)
|
||||
content: formatSNUpdateForGlpi(comment, { isReopenContextComment })
|
||||
};
|
||||
const isTask = comment.update_type === 'task';
|
||||
const insertedId = isTask
|
||||
? await TicketGlpiModel.insertTask(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');
|
||||
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
|
||||
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
|
||||
|
||||
@ -2,19 +2,20 @@ const TicketSnModel = require('../models/ticketSnModel');
|
||||
const TicketGlpiModel = require('../models/ticketGlpiModel');
|
||||
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const { logInfo, logError } = require('../utils/logger');
|
||||
const { addWorkNoteToServiceNow } = require('./servicenowService');
|
||||
const { logInfo, logError, logDebug } = require('../utils/logger');
|
||||
const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry, reassignTicketInServiceNow } = require('./servicenowService');
|
||||
const { stripHTML } = require('../utils/commentSanitizer');
|
||||
|
||||
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
|
||||
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
|
||||
|
||||
const syncTicketsToGlpi = async () => {
|
||||
try {
|
||||
logInfo('🔄 Iniciando sincronização para GLPI...');
|
||||
logDebug('🔄 Iniciando sincronização para GLPI...');
|
||||
const pendingTickets = await TicketSnModel.getPendingTickets();
|
||||
|
||||
if (pendingTickets.length === 0) {
|
||||
logInfo('✅ Nenhum ticket pendente para sincronizar');
|
||||
logDebug('✅ Nenhum ticket pendente para sincronizar');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -40,6 +41,8 @@ const processSingleTicket = async (ticket) => {
|
||||
if (ticket.glpi_ticket_id) {
|
||||
logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`);
|
||||
await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced');
|
||||
const linkedOk = await updateExternalTicketInServiceNow(ticket.id, ticket.glpi_ticket_id);
|
||||
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, ticket.glpi_ticket_id, linkedOk);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -56,6 +59,8 @@ const processSingleTicket = async (ticket) => {
|
||||
return;
|
||||
}
|
||||
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
|
||||
const existingLinkOk = await updateExternalTicketInServiceNow(ticket.id, existingTicket);
|
||||
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, existingTicket, existingLinkOk);
|
||||
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
|
||||
return;
|
||||
}
|
||||
@ -80,6 +85,8 @@ const processSingleTicket = async (ticket) => {
|
||||
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
|
||||
await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
|
||||
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId);
|
||||
const externalLinkOk = await updateExternalTicketInServiceNow(ticket.id, glpiTicketId);
|
||||
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, glpiTicketId, externalLinkOk);
|
||||
|
||||
logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`);
|
||||
|
||||
@ -93,93 +100,68 @@ const processSingleTicket = async (ticket) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkGlpiStatusChanges = async () => {
|
||||
try {
|
||||
logInfo('📡 Verificando mudanças de status nos tickets do GLPI...');
|
||||
const isOutOfScopeSolution = (solution) => solution?.solutiontypes_id === outOfScopeSolutionTypeId;
|
||||
|
||||
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
|
||||
|
||||
if (ticketsToMonitor.length === 0) {
|
||||
logInfo('Nenhum ticket ativo para verificar status no GLPI.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ticket of ticketsToMonitor) {
|
||||
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
|
||||
|
||||
const currentDbStatus = ticket.glpi_sync_status;
|
||||
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_');
|
||||
|
||||
if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') {
|
||||
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`);
|
||||
|
||||
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
|
||||
|
||||
if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) {
|
||||
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`);
|
||||
/**
|
||||
* Regra "fora de escopo": nota no SN + transferencia para fila CAOA + remove grupo do GLPI + fecha
|
||||
* o ticket no GLPI. Antes existia uma copia quase identica em processGlpiClosureController.js e
|
||||
* outra em processStatusController.js (uma delas nao removia o grupo do GLPI) - consolidado aqui.
|
||||
*/
|
||||
const handleOutOfScopeResolution = async (ticket, solution) => {
|
||||
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
|
||||
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
|
||||
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
|
||||
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
|
||||
await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id);
|
||||
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
|
||||
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');
|
||||
}
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
|
||||
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fora do escopo: grupo removido, transferido para CAOA e fechado.`);
|
||||
};
|
||||
|
||||
const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => {
|
||||
const sourceId = `sn_closure_note:${ticket.glpi_ticket_id}:${finalStatus}`;
|
||||
try {
|
||||
const alreadySent = await TicketUpdateModel.getBySourceId(sourceId);
|
||||
if (alreadySent) {
|
||||
logInfo(`Nota de encerramento do ServiceNow ja registrada para o GLPI ${ticket.glpi_ticket_id} (status '${finalStatus}'). Ignorando reenvio.`);
|
||||
return { ok: true, isNew: false };
|
||||
}
|
||||
|
||||
const resolvedBy = collaboratorName && collaboratorName.trim()
|
||||
? collaboratorName.trim()
|
||||
: 'colaborador CAOA';
|
||||
const statusText = finalStatus === 'solucionado'
|
||||
? 'Chamado solucionado no Service Now pela CAOA.'
|
||||
: 'Chamado encerrado no Service Now pela CAOA.';
|
||||
? 'Chamado solucionado no Service Now e removido do monitoramento.'
|
||||
: 'Chamado encerrado no Service Now e removido do monitoramento.';
|
||||
|
||||
const styledMessage = `
|
||||
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
|
||||
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico no ServiceNow</div>
|
||||
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento realizado pelo Service Now</div>
|
||||
<div style="color:#742a2a;">
|
||||
${statusText}
|
||||
</div>
|
||||
<div style="margin-top:8px; color:#742a2a; font-size:12px;">
|
||||
Responsavel informado: ${resolvedBy}
|
||||
</div>
|
||||
<div style="margin-top:6px; color:#742a2a; font-size:12px;">
|
||||
Chamado resolvido no ServiceNow e removido do monitoramento automatico da integracao.
|
||||
Responsavel: ${resolvedBy}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
|
||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
|
||||
await TicketUpdateModel.insert({
|
||||
ticket_sync_id: ticket.id,
|
||||
update_type: 'close_notice',
|
||||
source_system: 'sn',
|
||||
content: styledMessage,
|
||||
author: 'integration',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
source_id: sourceId,
|
||||
destiny_id: 'done'
|
||||
});
|
||||
logInfo(`Nota de encerramento do ServiceNow adicionada ao GLPI ${ticket.glpi_ticket_id}.`);
|
||||
return true;
|
||||
return { ok: true, isNew: true };
|
||||
} catch (error) {
|
||||
logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`);
|
||||
return false;
|
||||
return { ok: false, isNew: false };
|
||||
}
|
||||
};
|
||||
|
||||
@ -202,9 +184,10 @@ const closeTicketInGlpi = async (ticket) => {
|
||||
|
||||
module.exports = {
|
||||
syncTicketsToGlpi,
|
||||
checkGlpiStatusChanges,
|
||||
closeTicketInGlpi,
|
||||
addServiceNowClosureNoteToGlpi
|
||||
addServiceNowClosureNoteToGlpi,
|
||||
isOutOfScopeSolution,
|
||||
handleOutOfScopeResolution
|
||||
};
|
||||
|
||||
/**
|
||||
@ -215,6 +198,6 @@ module.exports = {
|
||||
* Principais Funcionalidades:
|
||||
* - `syncTicketsToGlpi()`: Função principal que busca tickets pendentes no banco de dados local (previamente coletados do ServiceNow) e orquestra sua criação no GLPI.
|
||||
* - `processSingleTicket(ticket)`: Processa um único ticket. Verifica se ele já 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 já 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".
|
||||
*/
|
||||
|
||||
@ -3,22 +3,50 @@ const axios = require('axios');
|
||||
const apiConfig = require('../../config/apiConfig');
|
||||
const { stripHTML } = require('../utils/commentSanitizer');
|
||||
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 TicketSyncModel = require('../models/ticketSyncModel');
|
||||
|
||||
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
|
||||
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
|
||||
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
|
||||
const externalTicketField = process.env.SERVICENOW_EXTERNAL_TICKET_FIELD || 'u_external_tickets';
|
||||
const ignoredJournalUsers = (
|
||||
process.env.SERVICENOW_IGNORE_USERS_JOURNAL ||
|
||||
`${servicenowDefaultUser},admin.caoa`
|
||||
)
|
||||
.split(',')
|
||||
.map((u) => String(u || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalizeServiceNowDateTime = (value) => {
|
||||
if (!value) return null;
|
||||
const asString = String(value).trim();
|
||||
const m = asString.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
|
||||
if (m) return `${m[1]} ${m[2]}`;
|
||||
|
||||
// Formato ISO/local: YYYY-MM-DD HH:mm:ss
|
||||
const ymd = asString.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
||||
if (ymd) {
|
||||
return `${ymd[1]}-${ymd[2]}-${ymd[3]} ${ymd[4]}:${ymd[5]}:${ymd[6]}`;
|
||||
}
|
||||
|
||||
// Formato SN comum com barra: DD/MM/YYYY HH:mm:ss
|
||||
const dmy = asString.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
|
||||
if (dmy) {
|
||||
return `${dmy[3]}-${dmy[2]}-${dmy[1]} ${dmy[4]}:${dmy[5]}:${dmy[6]}`;
|
||||
}
|
||||
|
||||
const parsed = new Date(asString);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return parsed.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
// Fallback preservando timestamp local sem converter para UTC.
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
|
||||
};
|
||||
|
||||
const 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) => {
|
||||
@ -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) => {
|
||||
try {
|
||||
const ticket = await TicketSnModel.findById(ticketId);
|
||||
@ -178,6 +229,7 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,7 +261,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
|
||||
const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
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;
|
||||
|
||||
@ -229,7 +281,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
sysparm_limit: pageSize,
|
||||
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, {
|
||||
auth: apiConfig.servicenowAuthentication.auth,
|
||||
params
|
||||
@ -263,13 +315,18 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
// Em ambiente de teste, a regra pode ser desligada por env.
|
||||
const filteredComments = allComments.filter((entry) => {
|
||||
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') {
|
||||
return entry.sys_created_by !== servicenowDefaultUser;
|
||||
return !ignoredUser;
|
||||
}
|
||||
if (entry.element === 'work_notes') {
|
||||
return entry.sys_created_by !== servicenowDefaultUser;
|
||||
return !ignoredUser;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@ -281,11 +338,11 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
return aTime - bTime;
|
||||
});
|
||||
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 [];
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@ -294,24 +351,15 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
|
||||
|
||||
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;
|
||||
} 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 = {
|
||||
ticket_sync_id: ticket.id,
|
||||
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
|
||||
source_system: 'servicenow',
|
||||
content: normalizedContent,
|
||||
content: comment.value,
|
||||
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||
author: comment.sys_created_by || 'ServiceNow',
|
||||
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||
@ -389,17 +437,6 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
|
||||
};
|
||||
|
||||
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
|
||||
try {
|
||||
const ticketSn = await TicketSnModel.findById(snTicketId);
|
||||
@ -411,6 +448,19 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
|
||||
sysId = ticketSn.sys_id; // Atribuir valor
|
||||
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 payload = {};
|
||||
|
||||
@ -435,6 +485,14 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
|
||||
throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Validacao de leitura: o SN pode aceitar o PATCH (200) e ainda assim ignorar um codigo de
|
||||
// estado invalido silenciosamente (ja aconteceu com o codigo antigo de 'Em Espera').
|
||||
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
|
||||
if (liveStatus !== state) {
|
||||
logError(`Atualizacao de status no SN retornou 200 mas o status ao vivo ficou '${liveStatus}' em vez de '${state}'.`, { sysId, ticketType, state: stateCode });
|
||||
return null;
|
||||
}
|
||||
|
||||
logInfo(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
|
||||
return true;
|
||||
|
||||
@ -501,6 +559,15 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
|
||||
throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Validacao de leitura: o PATCH pode retornar 200 sem que o estado tenha realmente
|
||||
// transicionado (regra de negocio/ACL do SN pode bloquear silenciosamente).
|
||||
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
|
||||
const closedForReal = liveStatus === 'Resolvido' || liveStatus === 'Encerrado' || liveStatus === 'Encerrado - Omitido';
|
||||
if (!closedForReal) {
|
||||
logError(`Fechamento no SN retornou 200 mas o status ao vivo nao confirmou. Status atual: '${liveStatus}'.`, { sysId, ticketType });
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
|
||||
return true;
|
||||
|
||||
@ -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.
|
||||
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
|
||||
@ -591,6 +853,7 @@ module.exports = {
|
||||
fetchTicketsFromServiceNow,
|
||||
fetchRequestsFromServiceNow,
|
||||
fetchTicketLiveStatusFromServiceNow,
|
||||
fetchTicketCloserFromServiceNow,
|
||||
getTicketSysId,
|
||||
getTicketTypeById,
|
||||
createCommentInServiceNow,
|
||||
@ -602,7 +865,12 @@ module.exports = {
|
||||
closeTicketInServiceNow,
|
||||
updateStatusInServiceNow,
|
||||
addWorkNoteToServiceNow,
|
||||
fetchScItemOptionValue
|
||||
addCommentToServiceNow,
|
||||
fetchScItemOptionValue,
|
||||
updateExternalTicketInServiceNow,
|
||||
reassignTicketInServiceNow,
|
||||
queueExternalTicketLinkRetry,
|
||||
processPendingExternalTicketLinks
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const { logInfo, logWarning } = require('./logger');
|
||||
const { logDebug, logWarning } = require('./logger');
|
||||
|
||||
const stripHTML = (html) => {
|
||||
if (!html) return '';
|
||||
@ -76,7 +76,7 @@ const sanitizeGLPIComment = (commentObj) => {
|
||||
content = stripHTML(content);
|
||||
|
||||
if (content !== commentObj.content) {
|
||||
logInfo('🔧 Comentário sanitizado', {
|
||||
logDebug('🔧 Comentário sanitizado', {
|
||||
original: commentObj.content.substring(0, 100) + '...',
|
||||
cleaned: content.substring(0, 100) + '...'
|
||||
});
|
||||
|
||||
@ -30,7 +30,7 @@ const logger = winston.createLogger({
|
||||
format: winston.format.combine(
|
||||
winston.format.printf((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) {
|
||||
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
@ -49,7 +49,7 @@ const logger = winston.createLogger({
|
||||
format: winston.format.combine(
|
||||
winston.format.printf((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) {
|
||||
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
@ -67,7 +67,7 @@ if (logToConsole) {
|
||||
winston.format.colorize(),
|
||||
winston.format.printf((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) {
|
||||
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
@ -94,9 +94,15 @@ const logWarning = (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) => {
|
||||
logger.info(`SYNC: ${service} - ${count} ${type} sincronizados`, {
|
||||
logger.debug(`SYNC: ${service} - ${count} ${type} sincronizados`, {
|
||||
service,
|
||||
count,
|
||||
type
|
||||
@ -108,5 +114,6 @@ module.exports = {
|
||||
logError,
|
||||
logInfo,
|
||||
logWarning,
|
||||
logDebug,
|
||||
logSync,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user