diff --git a/.env.example b/.env.example index bf1b8dd..9533fd2 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ PORT=3000 # Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos) CRON_SCHEDULE='* * * * *' +ENABLE_STATUS_SYNC=false +ENABLE_GLPI_WEBHOOK=true +ENABLE_GLPI_CLOSE_CRON=true # --- Configuracao da API do ServiceNow --- # URL base da sua instancia do ServiceNow @@ -26,12 +29,14 @@ SERVICENOW_ASSIGNMENT_GROUP= SERVICENOW_DEFAULT_USER_SYSID= SERVICENOW_DEFAULT_USER= SERVICENOW_RESOLVED_BY_SYSID= +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_ticket # --- Configuracao do Banco de Dados do GLPI (MySQL) --- GLPI_DB_TYPE=mysql diff --git a/README.md b/README.md index 0700047..26df918 100644 --- a/README.md +++ b/README.md @@ -1,251 +1,84 @@ -# 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. +3. Sincronizacao de comentarios e tasks em duas direcoes. +4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI. +5. Monitor GLPI para `status=5` (resolver SN) e `status=6` (encerrar definitivamente no SN). +6. Flags para desligar sincronizacao legada de status. ## Fluxo do ciclo -Executado em ordem pelo `main()`: +Executado pelo `main()`: 1. `processErrorController` 2. `processTicketsController` 3. `processCommentsController` -4. `processStatusAndClosureController` +4. `processGlpiClosureController` (quando `ENABLE_GLPI_CLOSE_CRON=true`) +5. `processStatusAndClosureController` apenas quando `ENABLE_STATUS_SYNC=true` -## 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. 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_STATUS_SYNC` + - `true`: liga fluxo legado de status bidirecional + - `false`: desliga fluxo legado de status +2. `ENABLE_GLPI_CLOSE_CRON` + - `true`: liga monitor de fechamento definitivo GLPI=6 + - `false`: desliga monitor de fechamento definitivo ### 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 +1. PM2: ```bash pm2 start ecosystem.config.js --env production ``` -Comandos uteis: +## Observacoes -```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 - -Arquivo: `src/scripts/python/update_location_mapping.py` - -- 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). - -## Como os dados chegam do ServiceNow - -- 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`. - -## Estrutura do banco intermediario - -Definicao base em `src/scripts/database/scriptBD.sql`: - -- `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. diff --git a/docs/casosdeuso.md b/docs/casosdeuso.md new file mode 100644 index 0000000..7c688eb --- /dev/null +++ b/docs/casosdeuso.md @@ -0,0 +1,153 @@ +# Casos de Uso (Formato RPG) + +## 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`. diff --git a/docs/fluxo.md b/docs/fluxo.md new file mode 100644 index 0000000..213fe1c --- /dev/null +++ b/docs/fluxo.md @@ -0,0 +1,56 @@ +# Fluxo Operacional + +## 1. Fluxo do ciclo (`main`) + +1. `processErrorController` +2. `processTicketsController` +3. `processCommentsController` +4. `processGlpiClosureController` (se `ENABLE_GLPI_CLOSE_CRON=true`) +5. `processStatusAndClosureController` apenas se `ENABLE_STATUS_SYNC=true` + +## 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. Resolucao e fechamento GLPI -> SN (monitor cron) + +1. Monitor consulta tickets ativos no `ticket_sync`. +2. Busca status real no GLPI. +3. Se status GLPI = `5`: + - busca nota de solucao + - se SN estiver em espera, move para em atendimento + - resolve SN + - marca `ticket_sync` como `solved/solved` +4. Se status GLPI = `6`: + - adiciona comentario no SN: + - "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." + - nao altera status do chamado no SN + - marca `ticket_sync` como `closed/closed` para retirar do monitoramento + +## 5. Reabertura + +1. Reabertura automatica permitida apenas para tickets em GLPI `5`. +2. Se GLPI `6`, reabertura automatica bloqueada. diff --git a/docs/regrasdenegocio.md b/docs/regrasdenegocio.md new file mode 100644 index 0000000..42df3ee --- /dev/null +++ b/docs/regrasdenegocio.md @@ -0,0 +1,211 @@ +# 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` + - `processCommentsController` + - `processGlpiClosureController` (se habilitado) + - `processStatusAndClosureController` (somente legado, se habilitado) +3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo. + +## 4. Feature flags e comportamento + +1. `ENABLE_STATUS_SYNC` + - `true`: executa sincronizacao legada de status bidirecional. + - `false`: desliga fluxo legado de status. +2. `ENABLE_GLPI_CLOSE_CRON` + - `true`: executa monitor de fechamento definitivo GLPI=6. + - `false`: desliga monitor. +3. `ENABLE_GLPI_WEBHOOK` + - 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 legado, mas deve ser deprecado para decisao de status no modelo novo. + +## 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 + +### 14.1 Fluxo novo (prioritario) + +1. Fechamento definitivo GLPI `status=6` e refletido no SN via monitor cron. +2. Ao detectar GLPI=6: + - enviar comentario no SN: + - "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado." + - nao alterar status do chamado no SN + - marcar `ticket_sync` como `closed/closed` para retirar do monitoramento +3. Aviso de encerramento definitivo no SN e idempotente por `source_id` em `ticket_updates`. + +### 14.2 Fluxo legado de status (quando habilitado) + +1. Pode propagar estado entre SN e GLPI. +2. Usa regras de precedencia historicas com `source_last`. +3. Deve ser considerado transitorio e desligavel por flag. + +## 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` + +Regra geral: `closed` e `ignored` 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. diff --git a/src/app.js b/src/app.js index eb7b266..85a8509 100644 --- a/src/app.js +++ b/src/app.js @@ -1,51 +1,54 @@ 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';} +let 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 { processGlpiClosureController } = require('./controllers/processGlpiClosureController'); +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 statusSyncEnabled = process.env.ENABLE_STATUS_SYNC === 'true'; + const glpiCloseCronEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false'; + await processErrorController(); await processTicketsController(); await processCommentsController(); - await processStatusAndClosureController(); - logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉'); + + if (statusSyncEnabled) { + await processStatusAndClosureController(); + } else { + logInfo('Sincronizacao legada de status desabilitada (ENABLE_STATUS_SYNC=false).'); + } + + if (glpiCloseCronEnabled) { + await processGlpiClosureController(); + } else { + logInfo('Monitor de encerramento definitivo GLPI desabilitado (ENABLE_GLPI_CLOSE_CRON=false).'); + } + + 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. - */ \ No newline at end of file diff --git a/src/controllers/processCommentsController.js b/src/controllers/processCommentsController.js index 5183a77..ab780f4 100644 --- a/src/controllers/processCommentsController.js +++ b/src/controllers/processCommentsController.js @@ -1,30 +1,50 @@ -const {fetchCommentsFromServiceNow } = require('../services/servicenowService'); +const { fetchCommentsFromServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService'); const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService'); const TicketSyncModel = require('../models/ticketSyncModel'); +const TicketSnModel = require('../models/ticketSnModel'); +const TicketGlpiModel = require('../models/ticketGlpiModel'); const { logInfo } = require('../utils/logger'); +const ensureReopenFromSNWhenGlpiSolved = async (ticket) => { + if (ticket.glpi_sync_status !== 'solved') { + return; + } + + const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id); + if (!ticketSn) { + return; + } + + const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo); + const snIsFinal = liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido'; + if (snIsFinal) { + return; + } + + const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + if (glpiStatus === 5) { + await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento'); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'synced'); + logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`); + } +}; const processCommentsController = async () => { const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); - logInfo(`📋Tickets em monitoramento: ${ticketsToMonitor.length}`); + logInfo(`Tickets em monitoramento: ${ticketsToMonitor.length}`); + for (const ticket of ticketsToMonitor) { + await ensureReopenFromSNWhenGlpiSolved(ticket); - 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. - */ \ No newline at end of file +}; \ No newline at end of file diff --git a/src/controllers/processGlpiClosureController.js b/src/controllers/processGlpiClosureController.js new file mode 100644 index 0000000..0564399 --- /dev/null +++ b/src/controllers/processGlpiClosureController.js @@ -0,0 +1,93 @@ +const TicketSyncModel = require('../models/ticketSyncModel'); +const TicketGlpiModel = require('../models/ticketGlpiModel'); +const TicketUpdateModel = require('../models/ticketUpdateModel'); +const TicketSnModel = require('../models/ticketSnModel'); +const { addCommentToServiceNow, addWorkNoteToServiceNow, updateStatusInServiceNow, fetchTicketLiveStatusFromServiceNow, closeTicketInServiceNow } = require('../services/servicenowService'); +const { stripHTML } = require('../utils/commentSanitizer'); +const { logInfo, logError } = require('../utils/logger'); + +const DEFINITIVE_CLOSE_NOTICE = 'Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado.'; +const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); + +const processGlpiClosureController = async () => { + try { + const tickets = await TicketSyncModel.getTicketsForClosureMonitor(); + if (!tickets.length) { + logInfo('Nenhum ticket ativo para monitoramento de fechamento definitivo no GLPI.'); + return; + } + + for (const ticket of tickets) { + try { + const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + if (glpiStatus !== 5 && glpiStatus !== 6) { + continue; + } + + const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id); + if (!ticketSn) { + continue; + } + + const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo); + if (glpiStatus === 5) { + if (ticket.sn_sync_status === 'solved') { + continue; + } + + const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); + if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) { + const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como fora do escopo.'; + await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket nao sera mais exibido para equipe Sothis, com a justificativa: ${justification}`); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); + await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); + continue; + } + + if (liveSnStatus === 'Em Espera' || liveSnStatus === 'Aguardando Atendimento') { + await updateStatusInServiceNow(ticket.sn_ticket_id, 'Em Atendimento'); + } + + const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integracao GLPI.'; + const resolvedAt = solution?.date_mod || new Date(); + await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt); + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved'); + logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} solucionado e refletido no SN.`); + continue; + } + + const sourceId = `glpi_closed_notice:${ticket.glpi_ticket_id}`; + const alreadyNotified = await TicketUpdateModel.getBySourceId(sourceId); + if (!alreadyNotified) { + const commentSent = await addCommentToServiceNow(ticket.sn_ticket_id, DEFINITIVE_CLOSE_NOTICE); + if (commentSent) { + await TicketUpdateModel.insert({ + ticket_sync_id: ticket.id, + update_type: 'close_notice', + source_system: 'glpi', + content: DEFINITIVE_CLOSE_NOTICE, + author: 'integration', + created_at: new Date(), + updated_at: new Date(), + source_id: sourceId, + destiny_id: 'done' + }); + } + } + + // Regra nova: nao altera status no SN ao fechar definitivamente no GLPI. + // Apenas registra comentario e remove do monitoramento local. + await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'closed'); + logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} encerrado definitivamente e refletido no SN.`); + } catch (error) { + logError(error, `Falha ao processar fechamento definitivo GLPI para ticket ${ticket.glpi_ticket_id}`); + } + } + } catch (error) { + logError(error, 'Erro geral no monitoramento de fechamento definitivo GLPI'); + } +}; + +module.exports = { + processGlpiClosureController +}; diff --git a/src/controllers/processTicketsController.js b/src/controllers/processTicketsController.js index e1d3b3d..242d1d7 100644 --- a/src/controllers/processTicketsController.js +++ b/src/controllers/processTicketsController.js @@ -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 = { diff --git a/src/models/ticketSnModel.js b/src/models/ticketSnModel.js index fe5ebdc..fb996f5 100644 --- a/src/models/ticketSnModel.js +++ b/src/models/ticketSnModel.js @@ -112,6 +112,7 @@ class TicketSnModel { try { const query = ` SELECT + tsync.id AS ticket_sync_id, ts.id, ts.ticket_number, ts.short_description, diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index 39b7a60..0978224 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -183,6 +183,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') + AND tsync.sn_sync_status NOT IN ('closed', 'ignored') + `; + 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 = ` diff --git a/src/models/ticketUpdateModel.js b/src/models/ticketUpdateModel.js index ae33bef..27c6d6c 100644 --- a/src/models/ticketUpdateModel.js +++ b/src/models/ticketUpdateModel.js @@ -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; @@ -115,4 +132,4 @@ module.exports = TicketUpdateModel; * - `getBySourceId(sourceId)`: Verifica se uma atualização específica (ex: um comentário com um determinado ID) já foi registrada no banco de dados. * - `updateDestinyId(sourceId, destinyId)`: Após uma atualização ser sincronizada com o sistema de destino, este método armazena o ID gerado no destino, completando o ciclo de mapeamento. * - `getLatestUpdate(...)`: Busca a atualização mais recente para um determinado ticket, útil para obter notas de fechamento, por exemplo. - */ \ No newline at end of file + */ diff --git a/src/services/glpiTicketService.js b/src/services/glpiTicketService.js index c56b647..a231928 100644 --- a/src/services/glpiTicketService.js +++ b/src/services/glpiTicketService.js @@ -3,7 +3,7 @@ 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 { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry } = require('./servicenowService'); const { stripHTML } = require('../utils/commentSanitizer'); const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); @@ -40,6 +40,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 +58,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 +84,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}`); diff --git a/src/services/servicenowService.js b/src/services/servicenowService.js index aefe296..74c10f6 100644 --- a/src/services/servicenowService.js +++ b/src/services/servicenowService.js @@ -10,15 +10,37 @@ 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 fetchTicketsFromServiceNow = async (watermark) => { @@ -265,11 +287,13 @@ const fetchCommentsFromServiceNow = async (ticket) => { if (!shouldIgnoreDefaultUserJournal) { return true; } + const createdBy = String(entry.sys_created_by || '').trim().toLowerCase(); + const ignoredUser = ignoredJournalUsers.includes(createdBy); 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; }); @@ -548,6 +572,152 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => { } }; +const addCommentToServiceNow = async (snTicketId, comment) => { + let sysId = null; + 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; + 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 = { 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) { + 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 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). @@ -602,7 +772,11 @@ module.exports = { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, - fetchScItemOptionValue + addCommentToServiceNow, + fetchScItemOptionValue, + updateExternalTicketInServiceNow, + queueExternalTicketLinkRetry, + processPendingExternalTicketLinks }; /**