From bfa0e8acc8238f27296693f273c303ae4a3ab545 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Wed, 29 Oct 2025 14:20:21 -0300 Subject: [PATCH] =?UTF-8?q?CLEANUP:=20Comentarios=20atualizados=20e=20Read?= =?UTF-8?q?me.md=20alimentado=20com=20V1.0=20da=20documenta=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 43 ----- .env.development | 49 +++++ .gitignore | 3 +- README.md | 170 +++++++++++------- config/apiConfig.js | 9 +- config/databaseConfig.js | 1 - src/app.js | 38 +++- src/controllers/processCommentsController.js | 14 +- src/controllers/processErrorController.js | 14 +- src/controllers/processStatusController.js | 8 +- src/controllers/processSyncController.js | 54 +++--- src/controllers/processTicketsController.js | 15 +- src/models/locationMappingModel.js | 27 ++- src/models/syncControlModel.js | 14 +- src/models/ticketGlpiModel.js | 71 +++----- src/models/ticketSnModel.js | 36 +--- src/models/ticketSyncModel.js | 41 ++--- src/models/ticketUpdateModel.js | 18 +- src/scripts/python/update_location_mapping.py | 11 +- src/services/glpiCommentService.js | 37 ++-- src/services/glpiTicketService.js | 64 +++---- src/services/hubsoftService.js | 0 src/services/servicenowService.js | 40 ++--- src/utils/commentSanitizer.js | 46 ++--- 24 files changed, 398 insertions(+), 425 deletions(-) delete mode 100644 .env create mode 100644 .env.development delete mode 100644 src/services/hubsoftService.js diff --git a/.env b/.env deleted file mode 100644 index a531a7a..0000000 --- a/.env +++ /dev/null @@ -1,43 +0,0 @@ -# Contents of the file: /servicenow-glpi-sync/servicenow-glpi-sync/.env.example - -# This is an example of the environment variables needed for the application. - -# ServiceNow API Configuration -SERVICENOW_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20&sysparm_query=ORDERBYDESCsys_created_on&sysparm_fields=number%2state%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cvariables.caller_id%2Cvariables.location%2Cvariables.telephone_favorecido_rh%2Cvariables.numero_ramal%2Copened_by.email%2Csys_id%5Eassignment_group%3D1132c9e01bfad410e77eb9121b4bcbab" -SERVICENOW_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_fields=number%2Cstate%2Crequest%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cassignment_group%2Csys_id&sysparm_display_value=all&sysparm_query=assignment_group%3D1132c9e01bfad410e77eb9121b4bcbab" -SERVICENOW_TABLE_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident" -SERVICENOW_TABLE_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item" -SERVICENOW_TABLE_JOURNAL_URL="https://caoadev.service-now.com/api/now/table/sys_journal_field" -SERVICENOW_SC_ITEM_OPTION_URL="https://caoadev.service-now.com/api/now/table/sc_item_option_mtom" -SERVICENOW_USERNAME=SOTHIS.CAOA -SERVICENOW_PASSWORD=#Sothis20 - -# GLPI access via DB (use DB connection instead of API) -GLPI_DB_TYPE=mysql # ou postgresql -GLPI_DB_HOST=177.73.177.32 -GLPI_DB_PORT=3306 -GLPI_DB_USER=snglpi -GLPI_DB_PASSWORD=j2633669 -GLPI_DB_NAME=glpi_data -GLPI_DB_CHARSET=utf8mb4 - - -# Banco SNGLPI (PostgreSQL) -SNGLPI_DB_HOST=10.0.120.75 -SNGLPI_DB_PORT=5432 -SNGLPI_DB_NAME=snglpi -SNGLPI_DB_USER=desenvolvimento -SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W - -# Caminho do CSV -LOCATION_MAPPING_CSV_PATH=\\10.0.121.40\Tecnica\Controle CAOA\ENTIDADE SERVICE NOW\LOCATION_MAPPING.csv - - -# Logging Configuration -LOG_LEVEL=info -LOG_FILE_PATH=logs/app.log -ERROR_LOG_FILE_PATH=logs/error.log - -# Other Configuration -PORT=3000 -NODE_ENV=development \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..2af03e7 --- /dev/null +++ b/.env.development @@ -0,0 +1,49 @@ +# ========================================= +# DEVELOPMENT CONFIGURATION +# ========================================= +NODE_ENV=development +PORT=3000 + +# ServiceNow API (Dev) +SERVICENOW_INSTANCE=https://caoadev.service-now.com +SERVICENOW_USERNAME=SOTHIS.CAOA +SERVICENOW_PASSWORD=#Sothis20 + +# GRUPO SOTHIS PARA BUSCA +SERVICENOW_ASSIGNMENT_GROUP="1132c9e01bfad410e77eb9121b4bcbab" + +# Endpoints +SERVICENOW_INCIDENT_URL=https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20 +SERVICENOW_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_display_value=all&sysparm_limit=20 +SERVICENOW_TABLE_INCIDENT_URL=https://caoadev.service-now.com/api/now/table/incident +SERVICENOW_TABLE_REQUEST_URL=https://caoadev.service-now.com/api/now/table/sc_req_item +SERVICENOW_TABLE_JOURNAL_URL=https://caoadev.service-now.com/api/now/table/sys_journal_field +SERVICENOW_SC_ITEM_OPTION_URL=https://caoadev.service-now.com/api/now/table/sc_item_option_mtom + +# Usuário padrão pra criação de chamados (ServiceNow) +SERVICENOW_DEFAULT_USER=SOTHIS.CAOA +SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98 + +# GLPI (banco real) +GLPI_DB_TYPE=mysql +GLPI_DB_HOST=177.73.177.32 +GLPI_DB_PORT=3306 +GLPI_DB_USER=snglpi +GLPI_DB_PASSWORD=j2633669 +GLPI_DB_NAME=glpi_data +GLPI_DB_CHARSET=utf8mb4 + +# Banco intermediário (PostgreSQL) +SNGLPI_DB_HOST=localhost +SNGLPI_DB_PORT=5432 +SNGLPI_DB_NAME=snglpi +SNGLPI_DB_USER=desenvolvimento +SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W + +# CSV de mapeamento +LOCATION_MAPPING_CSV_PATH=/mnt/csv_share/OCATION_MAPPING.csv + +# Logging +LOG_LEVEL=debug +LOG_RETENTION_DAYS=10 +LOG_DIR=logs diff --git a/.gitignore b/.gitignore index dcd0dce..c23f7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ config/*.json *.tmp servicenow_requests.json servicenow_tickets.json -location_mapping.log \ No newline at end of file +location_mapping.log +.env.production diff --git a/README.md b/README.md index 8be3499..96ae7ae 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,128 @@ -# servicenow-glpi-sync/README.md +# Sistema de Sincronização ServiceNow <> GLPI -# ServiceNow and GLPI Sync +## Visão Geral -This project is designed to facilitate the integration between ServiceNow and GLPI, allowing for the synchronization of tickets between the two platforms. It utilizes Node.js and follows the MVC architecture pattern. +Esta aplicação Node.js é um middleware projetado para realizar a sincronização bidirecional de tickets entre uma instância do **ServiceNow (SNOW)** e uma do **GLPI**. O objetivo é manter os tickets, seus status e comentários consistentes entre as duas plataformas, automatizando o fluxo de trabalho para as equipes de suporte. -## Table of Contents +A aplicação funciona como um job agendado (por exemplo, via cron) que executa um ciclo completo de sincronização. -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Running the Application](#running-the-application) -- [Endpoints](#endpoints) -- [Logging](#logging) -- [Contributing](#contributing) -- [License](#license) +--- -## Prerequisites +## Fluxo de Execução -- Node.js (version 14 or higher) -- npm (Node Package Manager) -- PostgreSQL or MySQL database -- Access to ServiceNow and GLPI APIs +A aplicação segue uma ordem de execução estrita para garantir a consistência dos dados. O ponto de entrada é o arquivo `src/app.js`, que orquestra a chamada dos seguintes controladores: -## Installation +1. **`processErrorController` - Limpeza de Erros** + - **Objetivo**: Aumentar a resiliência do sistema. + - **Ação**: Busca por tickets que falharam em execuções anteriores (marcados com status de `error`). Ele reseta o status desses tickets para um estado anterior válido (ex: `pending_check` ou `synced`), permitindo que eles sejam reprocessados no ciclo atual. -1. Clone the repository: +2. **`processTicketsController` - Criação de Tickets** + - **Objetivo**: Trazer novos tickets do ServiceNow para o GLPI. + - **Ação**: + - **Busca no SNOW**: Invoca o `processSyncController` para buscar incidentes e requisições novas ou atualizadas no ServiceNow, utilizando uma "marca d'água" (timestamp) para otimização. + - **Criação no GLPI**: Para cada novo ticket coletado, o `glpiTicketService` é chamado para: + - Verificar se o ticket já existe no GLPI para evitar duplicatas. + - Formatar os dados (título, descrição, categoria, etc.). + - Criar o ticket correspondente no banco de dados do GLPI. - ``` - git clone https://github.com/yourusername/servicenow-glpi-sync.git - cd servicenow-glpi-sync - ``` +3. **`processCommentsController` - Sincronização de Comentários** + - **Objetivo**: Manter as conversas dos tickets sincronizadas. + - **Ação**: Para cada ticket ativo, realiza uma sincronização bidirecional de comentários (notas/follow-ups): + - **SNOW -> GLPI**: Busca novos comentários no ServiceNow, os salva no banco intermediário e os insere como follow-ups no GLPI. + - **GLPI -> SNOW**: Busca novos follow-ups no GLPI, os sanitiza (remove HTML, trata imagens) e os envia como `work_notes` para o ServiceNow. -2. Install the dependencies: +4. **`processStatusAndClosureController` - Sincronização de Status e Fechamento** + - **Objetivo**: Manter os status dos tickets alinhados e gerenciar o ciclo de vida (resolução e fechamento). + - **Ação**: Realiza uma sincronização bidirecional de status, tratando reaberturas, pausas, resoluções e fechamentos em ambas as direções. - ``` - npm install - ``` +--- -3. Copy the `.env.example` file to `.env` and fill in the required environment variables: +## Regras de Negócio Principais - ``` - cp .env.example .env - ``` +A aplicação implementa várias regras de negócio para gerenciar a complexidade da sincronização: -## Configuration +### Controle de Fluxo com "Bastão" (`source_last`) +- Para evitar conflitos de atualização (race conditions), o sistema utiliza um campo `source_last` na tabela `ticket_sync`. +- Este campo funciona como um "bastão da palavra": o sistema que realiza a última atualização (SNOW ou GLPI) "segura o bastão". +- O sistema oposto só pode atualizar o ticket se ele estiver com o bastão. Isso garante que as atualizações de status e comentários ocorram de forma ordenada. -- Update the `.env` file with your ServiceNow and GLPI API credentials, database connection details, and any other necessary configurations. +### Criação e Enriquecimento de Tickets +- **Mapeamento**: Tickets do ServiceNow são mapeados para o GLPI com base em regras de categoria, prioridade e SLA definidas no `ticketGlpiModel.js`. +- **Título**: O título do ticket no GLPI é padronizado como `[TIPO] - [ENTIDADE] - [TÍTULO ORIGINAL]`. +- **Enriquecimento de Descrição**: Se uma requisição do ServiceNow chega com a descrição vazia, o sistema busca automaticamente o conteúdo de variáveis de catálogo (como `justificativa` e `telefone`) e as utiliza para preencher a descrição e os dados do ticket no GLPI. -## Running the Application +### Sincronização de Comentários +- **Lógica de 4 Casos (GLPI -> SNOW)**: Para evitar duplicatas, a sincronização de comentários do GLPI para o SNOW verifica 4 cenários: + 1. **Já Sincronizado**: O comentário existe em ambos os locais. Nenhuma ação. + 2. **Existe no SN, Falta no BD Local**: O registro local é criado para corrigir o estado. + 3. **Existe no BD Local, Falta no SN**: O comentário é reenviado para o ServiceNow. + 4. **Novo Comentário**: O comentário é enviado para o SNOW e registrado localmente. +- **Sanitização**: Comentários do GLPI passam por um processo de limpeza (`commentSanitizer.js`) que remove tags HTML e substitui imagens por um texto placeholder, garantindo que o conteúdo seja legível no ServiceNow. -To start the application, run: +### Sincronização de Status +- **Fechamento de Ticket em Espera**: Se um ticket é resolvido no GLPI, mas seu correspondente no ServiceNow está com o status "Em Espera" ou "Aguardando Atendimento", o sistema primeiro altera o status no ServiceNow para "Em Atendimento" e só então prossegue com a resolução. Isso automatiza o fluxo e evita falhas. +- **Regra "Fora do Escopo"**: Se um ticket no GLPI é solucionado com o tipo de solução "Fora do Escopo", o sistema não o resolve no ServiceNow. Em vez disso, ele adiciona uma nota de trabalho (`work_note`) explicando o motivo e fecha o registro de sincronização, tratando-o como um caso especial. +- **Reabertura**: O sistema permite a reabertura de tickets em ambas as direções, desde que o ticket não esteja permanentemente fechado. +### Ambientes (`.env`) +- A aplicação suporta múltiplos ambientes através de arquivos `.env`. +- Se a variável de ambiente `NODE_ENV` for definida como `production`, o arquivo `.env.production` será carregado. +- Caso contrário (ou se `NODE_ENV` for `development`), o arquivo `.env.development` será utilizado. + +### Atualização do Mapeamento de Localidades (`update_location.py`) +- A aplicação conta com um script Python (`src/scripts/python/update_location.py`) para manter o mapeamento entre as localidades do ServiceNow e as entidades do GLPI. +- **Fonte da Verdade**: Um arquivo `.csv` localizado em um servidor de arquivos (`LOCATION_MAPPING_CSV_PATH` no `.env`) é a fonte da verdade para este mapeamento. +- **Processo**: + 1. Um usuário (`sothis`) atualiza o arquivo `.csv` com as correspondências corretas entre os nomes das localidades (SNOW) e os nomes das entidades (GLPI). + 2. Um `cron job` no servidor executa o script `update_location.py` periodicamente. + 3. O script lê o `.csv`, busca os IDs correspondentes nos bancos de dados de ambos os sistemas e recria a tabela `location_mapping` no banco de dados intermediário (PostgreSQL). +- **Resultado**: Garante que os tickets criados no GLPI sejam sempre associados à entidade correta, com base em um mapeamento centralizado e de fácil manutenção. +--- + +## Estrutura do Banco de Dados Intermediário (PostgreSQL) + +- **`tickets_sn`**: Armazena uma cópia local dos dados dos tickets do ServiceNow para otimizar consultas. +- **`ticket_sync`**: Tabela central que controla o estado da sincronização de cada ticket, incluindo os IDs de ambos os sistemas e o campo `source_last`. +- **`ticket_updates`**: Log de todas as atualizações (comentários, etc.), usado para evitar duplicidade e rastrear o histórico. +- **`location_mapping`**: Tabela de mapeamento entre as localizações do ServiceNow e as entidades do GLPI. +- **`sync_control`**: Armazena metadados do processo, como a marca d'água (timestamp) da última sincronização bem-sucedida. + +--- + +## Diagrama de Fluxo (Exemplo) + +### Sincronização de Resolução: GLPI -> ServiceNow + +O diagrama abaixo ilustra como o sistema lida com um ticket que foi marcado como "Solucionado" no GLPI. + +```mermaid +sequenceDiagram + participant App as app.js + participant StatusCtrl as processStatusAndClosureController + participant GlpiModel as TicketGlpiModel + participant SyncModel as TicketSyncModel + participant SnowService as servicenowService + participant ServiceNow as ServiceNow API + + App->>StatusCtrl: Iniciar ciclo de status + StatusCtrl->>SyncModel: getTicketsToMonitor() + SyncModel-->>StatusCtrl: Lista de tickets ativos + + loop Para cada ticket monitorado + StatusCtrl->>GlpiModel: getTicketStatus(glpi_id) + GlpiModel-->>StatusCtrl: Status 'Solucionado' (5) + + alt Status mudou para 'Solucionado' + StatusCtrl->>GlpiModel: getTicketSolution(glpi_id) + GlpiModel-->>StatusCtrl: Detalhes da solução + + StatusCtrl->>SnowService: closeTicketInServiceNow(sn_id, solucao) + SnowService->>ServiceNow: PATCH /api/now/table/incident/{sys_id} (state: 'Resolved') + ServiceNow-->>SnowService: 200 OK + SnowService-->>StatusCtrl: Sucesso + + StatusCtrl->>SyncModel: updateStatus(sn_id, 'solved', 'solved') + SyncModel-->>StatusCtrl: OK + end + end ``` -node server.js -``` - -The application will listen on the port specified in the `.env` file. - -## Endpoints - -- `POST /tickets/sync` - Synchronize tickets from ServiceNow to GLPI. -- `GET /tickets/:id` - Retrieve details of a specific ticket. -- `POST /tickets` - Create a new ticket in GLPI based on ServiceNow data. -- `PUT /tickets/:id` - Update an existing ticket in GLPI. - -## Logging - -The application uses Winston for logging. Logs are stored in the `logs` directory: - -- `app.log` - Logs application events. -- `error.log` - Logs errors that occur during execution. - -## Contributing - -Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. - -## License - -This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file diff --git a/config/apiConfig.js b/config/apiConfig.js index 8b59551..f47444d 100644 --- a/config/apiConfig.js +++ b/config/apiConfig.js @@ -1,4 +1,4 @@ -require('dotenv').config(); +//config/apiConfig.js const glpiDbConfig = { client: process.env.GLPI_DB_TYPE || 'mysql', @@ -45,6 +45,10 @@ const snScItemOptionConfig = { baseUrl: process.env.SERVICENOW_SC_ITEM_OPTION_URL, } +const assignmentGroup = { + username: process.env.SERVICENOW_ASSIGNMENT_GROUP, +} + module.exports = { // configuração do ServiceNow e outras já existentes podem permanecer glpiDbConfig, @@ -54,5 +58,6 @@ module.exports = { snTableJournalConfig, servicenowAuthentication, snTableRequestConfig, - snScItemOptionConfig + snScItemOptionConfig, + assignmentGroup }; \ No newline at end of file diff --git a/config/databaseConfig.js b/config/databaseConfig.js index 7ce29f4..a6bbafe 100644 --- a/config/databaseConfig.js +++ b/config/databaseConfig.js @@ -1,5 +1,4 @@ const { Sequelize } = require('sequelize'); -require('dotenv').config(); // Database connection configuration const dbConfig = { diff --git a/src/app.js b/src/app.js index 8580389..62f6047 100644 --- a/src/app.js +++ b/src/app.js @@ -1,10 +1,17 @@ -// src/app.js -require('dotenv').config(); +const path = require('path'); +const dotenv = require('dotenv'); +const nodeEnv = process.env.NODE_ENV; +let envFile = '.env.devlopment'; +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 } = require('./utils/logger'); +const { logInfo, logger } = require('./utils/logger'); + +logInfo(`🌎 Ambiente carregado: ${process.env.NODE_ENV || 'development'}`); logInfo('Aplicação iniciada', { timestamp: new Date().toISOString(), @@ -13,15 +20,28 @@ logInfo('Aplicação iniciada', { async function main() { try { - await processErrorController(); // Reseta os erros - await processTicketsController(); // Coleta tickets do SN e cria no GLPI - await processCommentsController(); // Sincroniza chamados Bidirecionalmente - await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente + await processErrorController(); + await processTicketsController(); + await processCommentsController(); + await processStatusAndClosureController(); logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉'); } catch (error) { logInfo('Erro na aplicação:', error); } - } -main(); \ No newline at end of file +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 6839f39..9e54c9d 100644 --- a/src/controllers/processCommentsController.js +++ b/src/controllers/processCommentsController.js @@ -4,17 +4,21 @@ const TicketSyncModel = require('../models/ticketSyncModel'); const processCommentsController = async () => { - - // Usa getTicketsToMonitor para incluir tickets 'synced' e 'solved'. const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); for (const ticket of ticketsToMonitor){ const comments = await fetchCommentsFromServiceNow(ticket); if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);} await syncCommentsGlpitoSN(ticket); - } - + } } + module.exports = { processCommentsController -} \ No newline at end of file +} + +/** + * @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 diff --git a/src/controllers/processErrorController.js b/src/controllers/processErrorController.js index 3a0f325..c1cbcda 100644 --- a/src/controllers/processErrorController.js +++ b/src/controllers/processErrorController.js @@ -1,10 +1,8 @@ -// src/controllers/processErrorController.js const TicketSyncModel = require('../models/ticketSyncModel'); const { logInfo, logError, logWarning } = require('../utils/logger'); const processErrorController = async () => { logInfo('Iniciando verificação de tickets com erro...'); - try { const errorTickets = await TicketSyncModel.getTicketsInErrorState(); @@ -19,20 +17,16 @@ const processErrorController = async () => { let newSnStatus = ticket.sn_sync_status; let newGlpiStatus = ticket.glpi_sync_status; - // Reset 'error' na criação do GLPI para tentar de novo if (ticket.glpi_sync_status === 'error') { newGlpiStatus = 'pending_check'; } - // Reset 'error_sync' para tentar sincronizar status/comentários de novo if (ticket.sn_sync_status === 'error_sync') newSnStatus = 'synced'; if (ticket.glpi_sync_status === 'error_sync') newGlpiStatus = 'synced'; - // Reset 'error_closing' para tentar fechar de novo if (ticket.sn_sync_status === 'error_closing') newSnStatus = 'pending_close'; if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close'; - // Atualiza o status para que o ticket reentre no fluxo normal await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus); logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`); } @@ -45,3 +39,11 @@ const processErrorController = async () => { module.exports = { processErrorController }; + +/** + * @file processErrorController.js + * @description Este controlador é responsável por gerenciar tickets que entraram em estado de erro durante o processo de sincronização. + * Ele busca todos os tickets marcados com status de erro (ex: 'error', 'error_sync') e os reseta para um estado anterior válido + * (ex: 'pending_check', 'synced'). Isso permite que os tickets sejam automaticamente reprocessados na próxima execução do ciclo de sincronização, + * aumentando a resiliência do sistema. + */ diff --git a/src/controllers/processStatusController.js b/src/controllers/processStatusController.js index 1b0a7cd..8ebf998 100644 --- a/src/controllers/processStatusController.js +++ b/src/controllers/processStatusController.js @@ -1,7 +1,6 @@ const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); -const TicketUpdateModel = require('../models/ticketUpdateModel'); const { logInfo, logError, logWarning } = require('../utils/logger'); const { stripHTML } = require('../utils/commentSanitizer'); const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService'); @@ -119,8 +118,15 @@ const processStatusAndClosureController = async () => { // 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 diff --git a/src/controllers/processSyncController.js b/src/controllers/processSyncController.js index 638ee48..dffad79 100644 --- a/src/controllers/processSyncController.js +++ b/src/controllers/processSyncController.js @@ -11,10 +11,8 @@ const e = require('express'); * @param {('incidente'|'requisicao')} type - O tipo de ticket. */ const processAndSaveTickets = async (tickets, type) => { - // Helper para converter a data do formato 'DD/MM/YYYY HH:mm:ss' para um objeto Date. const parseServiceNowDate = (dateString) => { if (!dateString) return null; - // Verifica se já está em um formato reconhecível (como ISO 8601) if (!dateString.includes('/')) { return new Date(dateString); } @@ -26,18 +24,15 @@ const processAndSaveTickets = async (tickets, type) => { if (!tickets || tickets.length === 0) { logInfo(`Nenhum ticket do tipo '${type}' para processar.`); - return ''; // Retorna string vazia se não houver tickets + return ''; } logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`); - let latestUpdateTimestamp = new Date(0); // Inicia com a data mínima (epoch) + let latestUpdateTimestamp = new Date(0); for (const ticket of tickets) { try { - // --- LÓGICA DE ENRIQUECIMENTO DA DESCRIÇÃO --- - // Se for uma requisição e a descrição estiver vazia, busca na sc_item_option. if (type === 'requisicao' && (!ticket.description || ticket.description.trim() === '')) { logInfo(`Descrição vazia para requisição ${ticket.number}. Buscando em sc_item_option...`); - // O nome 'justificativa' é um exemplo, use o nome real da sua variável. const extraData = await fetchScItemOptionValue(ticket.sys_id, 'justificativa'); const vars = {}; (extraData || []).forEach(item => { @@ -51,7 +46,7 @@ const processAndSaveTickets = async (tickets, type) => { if (vars.justification) { - ticket.description = vars.justification; // Atualiza o objeto do ticket em memória + ticket.description = vars.justification; logInfo(`Descrição da requisição ${ticket.number} atualizada com valor de sc_item_option.`); } if (vars.telephone){ @@ -59,13 +54,8 @@ const processAndSaveTickets = async (tickets, type) => { logInfo(`Telefone da requisição ${ticket.number} atualizado com valor de sc_item_option.`); } } - // --- FIM DA LÓGICA DE ENRIQUECIMENTO --- - // 1. Salva o ticket na tabela 'tickets_sn' const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type); - - // 2. Prepara os dados para a tabela de sincronização 'ticket_sync' - // Para requisições, o status está diretamente em ticket.state const ticketStatus = ticket.state; const dataSync = { @@ -76,31 +66,25 @@ const processAndSaveTickets = async (tickets, type) => { updated_at: new Date() }; - // 3. Define o status inicial de sincronização if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') { dataSync.sn_sync_status = 'closed'; - dataSync.glpi_sync_status = 'ignored'; // Ignora no GLPI se já veio fechado do SN + dataSync.glpi_sync_status = 'ignored'; } else { - dataSync.sn_sync_status = 'collected'; // Status que indica que foi coletado do SN - dataSync.glpi_sync_status = 'pending_check'; // Status que indica que precisa ser checkado no GLPI + dataSync.sn_sync_status = 'collected'; + dataSync.glpi_sync_status = 'pending_check'; } - dataSync.source_last = 'SNOW'; // A última atualização veio do ServiceNow + dataSync.source_last = 'SNOW'; - // 4. Cria o registro de sincronização se ele não existir const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn); if (!exists) { await TicketSyncModel.createSyncRecord(dataSync); } else if (wasInserted === false) { - // Se o ticket foi atualizado (não inserido), significa que uma mudança veio do SN. - // Então, definimos o SN como a última fonte da verdade. await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW'); logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); } else { logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`); } - // Se já existe, não faz nada, pois a lógica de atualização de status será em outro processo. - // Guarda o timestamp mais recente do lote para usar como a próxima marca d'água const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on); if (currentUpdateDate && currentUpdateDate.getTime() > latestUpdateTimestamp.getTime()) { latestUpdateTimestamp = currentUpdateDate; @@ -112,7 +96,6 @@ const processAndSaveTickets = async (tickets, type) => { } logSync('ServiceNow', tickets.length, `${type}s`); - // Se encontramos uma data válida, a formata para 'YYYY-MM-DD HH:MM:SS'. Senão, retorna string vazia. return latestUpdateTimestamp.getTime() > 0 ? latestUpdateTimestamp.toISOString().slice(0, 19).replace('T', ' ') : ''; @@ -123,24 +106,18 @@ const processAndSaveTickets = async (tickets, type) => { */ const processSyncController = async () => { try { - // 1. Pega a marca d'água da última execução bem-sucedida const watermark = await SyncControlModel.getWatermark('servicenow'); logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`); - // Busca incidentes - const incidents = await fetchTicketsApi(watermark); const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente'); - // Busca requisições logInfo('📡 Buscando requisições do ServiceNow...'); const requests = await fetchRequestsApi(watermark); const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao'); - // 2. Determina a nova marca d'água (o timestamp mais recente entre todos os tickets processados) const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate; - // 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução if (newWatermark) { // Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais const sixHoursInMillis = 6 * 60 * 60 * 1000; @@ -157,4 +134,19 @@ const processSyncController = async () => { module.exports = { processSyncController -}; \ No newline at end of file +}; + +/** + * @file processSyncController.js + * @description + * Este controlador é responsável por buscar novos tickets e atualizações do ServiceNow. + * Ele utiliza uma "marca d'água" (watermark) para buscar apenas os registros modificados desde a última execução, + * otimizando o processo. + * + * Funcionalidades: + * - Busca incidentes e requisições do ServiceNow com base na data da última atualização. + * - Para cada ticket, salva ou atualiza o registro no banco de dados intermediário (`tickets_sn`). + * - Cria ou atualiza o registro de controle na tabela `ticket_sync`, definindo o status inicial e a origem da atualização (`source_last`). + * - Implementa uma lógica de enriquecimento de dados para requisições com descrição vazia, buscando informações de variáveis de catálogo (`sc_item_option`). + * - Ao final, atualiza a marca d'água para a próxima execução, apenas se houverem tickets atualizados na execução vigente. + */ \ No newline at end of file diff --git a/src/controllers/processTicketsController.js b/src/controllers/processTicketsController.js index 443ce64..e1d3b3d 100644 --- a/src/controllers/processTicketsController.js +++ b/src/controllers/processTicketsController.js @@ -1,19 +1,20 @@ -// src/controllers/glpiController.js const { syncTicketsToGlpi } = require('../services/glpiTicketService'); const { processSyncController } = require('./processSyncController'); const processTicketsController = async () => { - - // 1. Busca Incidentes e Requisições do Service Now e salva no banco intermediário - // Esta etapa prepara os dados para a sincronização com o GLPI. await processSyncController(); - - //Sincroniza os tickets com o GLPI await syncTicketsToGlpi(); - } module.exports = { processTicketsController } + +/** + * @file processTicketsController.js + * @description Este controlador gerencia o fluxo inicial de criação de tickets. + * Ele primeiro invoca o `processSyncController` para buscar e salvar localmente os tickets do ServiceNow. + * Em seguida, chama o serviço `syncTicketsToGlpi` para processar esses tickets locais e criá-los no GLPI, + * estabelecendo a ligação inicial entre os sistemas. + */ diff --git a/src/models/locationMappingModel.js b/src/models/locationMappingModel.js index bde7410..9907b99 100644 --- a/src/models/locationMappingModel.js +++ b/src/models/locationMappingModel.js @@ -1,10 +1,7 @@ -// src/models/locationMappingModel.js const pool = require('../data/database'); const { logInfo, logError } = require('../utils/logger'); class LocationMappingModel { - - // Buscar entity_id do GLPI baseado no location_id do ServiceNow static async getGlpiEntityId(snLocationId) { try { const query = ` @@ -25,8 +22,7 @@ class LocationMappingModel { return null; } } - - // Buscar entity_id do GLPI baseado no location_name do ServiceNow + static async getGlpiEntityIdByName(snLocationName) { try { const query = ` @@ -47,10 +43,7 @@ class LocationMappingModel { return null; } } - - - // Buscar entity (id e name) do GLPI baseado no location_id do ServiceNow static async getGlpiEntity(snLocationId) { try { const query = ` @@ -74,12 +67,8 @@ class LocationMappingModel { return null; } - - } - - // Buscar entity (id e name) do GLPI baseado no location_name do ServiceNow static async getGlpiEntityByName(snLocationName) { try { const query = ` @@ -105,8 +94,14 @@ class LocationMappingModel { } } +module.exports = LocationMappingModel; - - - -module.exports = LocationMappingModel; \ No newline at end of file +/** + * @module LocationMappingModel + * @description Este módulo gerencia a interação com a tabela `location_mapping`, que é crucial para a correta atribuição de tickets no GLPI. + * + * Funcionalidades: + * - `getGlpiEntityId` / `getGlpiEntityIdByName`: Buscam o ID da entidade GLPI correspondente a uma localização do ServiceNow, usando o ID ou o nome da localização. + * - `getGlpiEntity` / `getGlpiEntityByName`: Buscam o objeto completo da entidade GLPI (ID e nome), que é usado para formatar o título do ticket no GLPI. + * Este mapeamento permite que tickets abertos para uma determinada localidade no ServiceNow sejam automaticamente associados à entidade correta no GLPI. + */ \ No newline at end of file diff --git a/src/models/syncControlModel.js b/src/models/syncControlModel.js index 2462858..dc3ad55 100644 --- a/src/models/syncControlModel.js +++ b/src/models/syncControlModel.js @@ -1,4 +1,3 @@ -// src/models/syncControlModel.js const pool = require('../data/database'); const { logError, logInfo } = require('../utils/logger'); @@ -10,7 +9,6 @@ class SyncControlModel { if (rows.length === 0) { throw new Error(`Watermark para o sistema '${systemName}' não encontrada no banco de dados.`); } - // Retorna no formato que o ServiceNow espera: 'YYYY-MM-DD HH:MM:SS' return rows[0].last_watermark.toISOString().replace('T', ' ').substring(0, 19); } catch (error) { logError(error, `Erro ao buscar watermark para ${systemName}`); @@ -35,3 +33,15 @@ class SyncControlModel { } module.exports = SyncControlModel; + +/** + * @module SyncControlModel + * @description Este módulo gerencia a tabela `sync_control`, que armazena metadados essenciais para o processo de sincronização, + * como as "marcas d'água" (watermarks). + * + * Funcionalidades: + * - `getWatermark(systemName)`: Obtém o timestamp do último registro processado com sucesso para um determinado sistema (ex: 'servicenow'). Isso garante que, na próxima execução, apenas os dados novos ou atualizados sejam buscados. + * - `setWatermark(systemName, watermark)`: Atualiza a marca d'água para um sistema após um ciclo de sincronização bem-sucedido. + * + * O uso de watermarks é fundamental para a eficiência do processo, evitando a re-leitura de todos os tickets a cada execução. + */ diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 429d2fe..361df2e 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -1,11 +1,9 @@ -// src/models/ticketGlpiModel.js const glpiPool = require('../data/glpiDataBase'); const { logInfo, logError } = require('../utils/logger'); const LocationMappingModel = require('./locationMappingModel'); class TicketGlpiModel { - // Mapeamento de status do ServiceNow para o GLPI static mapStatus(snState) { const statusMap = { 'Aguardando Atendimento': 1, @@ -13,12 +11,11 @@ class TicketGlpiModel { 'Em Espera': 4, 'Resolvido': 5, 'Encerrado - Omitido': 6, - 'Encerrado': 6 // Mapeia para Fechado no GLPI + 'Encerrado': 6 }; return statusMap[snState] || 1; } - // Mapeamento de status do GLPI (numérico) para ServiceNow (string) static mapGlpiStatusToString(glpiStatus) { const statusMap = { 1: 'Aguardando Atendimento', // Novo @@ -30,7 +27,6 @@ class TicketGlpiModel { return statusMap[glpiStatus] || 'Desconhecido'; } - // Mapeamento de prioridade static mapPriority(snPriority) { const priorityMap = { '1': 5, @@ -42,7 +38,6 @@ class TicketGlpiModel { return priorityMap[snPriority] || 3; } - // Mapeamento de categoria static mapCategory(shortDescription) { const categoryMap = { 'Problemas com Telefonia Fixa': 5717, @@ -53,7 +48,6 @@ class TicketGlpiModel { return categoryMap[shortDescription] || 5717; } - // Mapeamento de SLA baseado no tipo e na descrição static mapSlasIdTtr(ticketData) { const isRequisicao = ticketData.type === 'requisicao'; const shortDesc = ticketData.short_description; @@ -73,10 +67,9 @@ class TicketGlpiModel { if (!isRequisicao && isTelefonia) { return 34; } - return 0; // Default se nenhum critério for atendido + return 0; } - // Formata o título do ticket static async formatTitle(ticketData) { try { let entityData = null; @@ -92,8 +85,8 @@ class TicketGlpiModel { const tituloSn = ticketData.short_description || 'Sem título'; return { - title: `${tipo} - ${entityName} - ${tituloSn}`, - entityId: entityData?.id || 127 // fallback para entity_id default + title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado + entityId: entityData?.id || 127 }; } catch (error) { logError(error, 'Erro ao formatar título do ticket'); @@ -107,7 +100,6 @@ class TicketGlpiModel { static formatDescription(ticketData) { - // Função auxiliar para extrair informações usando regex const extractInfo = (regex, description) => { if (!description) return null; const match = description.match(regex); @@ -123,12 +115,10 @@ class TicketGlpiModel { return extractInfo(/(\d{3,4})/i, description); }; - // Obtém email, telefone e ramal, usando os valores do ticketData ou extraindo da descrição, se necessário. const email = ticketData.caller_email || extractEmailFromDescription(ticketData.description) || 'N/A'; const phone = ticketData.caller_phone || extractPhoneFromDescription(ticketData.description) || 'N/A'; const ramal = ticketData.caller_ramal || extractRamalFromDescription(ticketData.description) || 'N/A'; - // Formata a descrição em HTML let htmlDescription = ` @@ -164,23 +154,19 @@ class TicketGlpiModel { return htmlDescription; } - // Cria um ticket no GLPI static async createTicket(ticketData) { const connection = await glpiPool.getConnection(); try { await connection.beginTransaction(); - // Buscar entity_id com base no location_id ou location_name - let entityId = 127; // Valor padrão (fallback) - + let entityId = 127; if (ticketData.location_id) { entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId; } else if (ticketData.location_name) { entityId = await LocationMappingModel.getGlpiEntityIdByName(ticketData.location_name) || entityId; } - // Extrai o email da descrição, se existir e não houver email no ticketData let email = ticketData.caller_email; if (!email && ticketData.description) { const emailRegex = /[\w-\.]+@([\w-]+\.)+[\w-]{2,4}/g; @@ -190,18 +176,13 @@ class TicketGlpiModel { } } - // Formata o título do ticket const { title: formattedTitle } = await TicketGlpiModel.formatTitle(ticketData); - // Formata a descrição para HTML const formattedDescription = TicketGlpiModel.formatDescription(ticketData); - // Mapeia o tipo do ticket const ticketType = ticketData.type === 'requisicao' ? 2 : 1; - // Determina o valor de slas_id_ttr const slasIdTtr = TicketGlpiModel.mapSlasIdTtr(ticketData); - // Prepara os dados para o GLPI const glpiTicketData = { - entities_id: entityId, // Usar o entity_id mapeado + entities_id: entityId, name: formattedTitle, date: ticketData.opened_at || new Date(), date_mod: ticketData.updated_at || new Date(), @@ -219,7 +200,6 @@ class TicketGlpiModel { slas_id_ttr: slasIdTtr }; - // Constrói a query dinamicamente e insere o ticket principal const fields = Object.keys(glpiTicketData).join(', '); const placeholders = Object.keys(glpiTicketData).map(() => '?').join(', '); const values = Object.values(glpiTicketData); @@ -228,7 +208,6 @@ class TicketGlpiModel { const [ticketResult] = await connection.execute(ticketQuery, values); const ticketId = ticketResult.insertId; - // Adiciona o requerente await connection.execute( `INSERT INTO glpi_tickets_users (tickets_id, users_id, type, use_notification, alternative_email) @@ -236,7 +215,6 @@ class TicketGlpiModel { [ticketId, 917] ); - // Adiciona o grupo await connection.execute( `INSERT INTO glpi_groups_tickets (tickets_id, groups_id, type) @@ -258,7 +236,6 @@ class TicketGlpiModel { } } - // Verifica se um ticket já existe no GLPI static async ticketExists(snTicketNumber) { try { const query = ` @@ -279,7 +256,7 @@ class TicketGlpiModel { return null; } } - // Buscar followups (notas) de um ticket no GLPI + static async getFollowupsByItemId(itemId) { const connection = await glpiPool.getConnection(); try { @@ -299,7 +276,7 @@ class TicketGlpiModel { const values = ['Ticket', items_id, 971, comment.content , 1]; const [result] = await connection.execute(query, values); - return [{ id: result.insertId }]; // Retorna o ID inserido + return [{ id: result.insertId }]; }catch(error){ logError(`Erro ao inserir o comentário no GLPI: ${error}`); } finally { @@ -307,11 +284,9 @@ class TicketGlpiModel { } } - // Busca tickets que foram fechados ou solucionados no GLPI static async getClosedTickets(watermark, limit = 100) { const connection = await glpiPool.getConnection(); try { - // Status 5 = Solucionado, Status 6 = Fechado const query = ` SELECT t.id, @@ -332,17 +307,14 @@ class TicketGlpiModel { } } - // Fecha um ticket no GLPI, adicionando uma solução static async closeTicket(ticketId, solutionContent) { const connection = await glpiPool.getConnection(); try { await connection.beginTransaction(); - // Adiciona a solução const solutionQuery = `INSERT INTO glpi_itilsolutions (itemtype, items_id, content, users_id_editor, date_creation, date_mod) VALUES ('Ticket', ?, ?, ?, NOW(), NOW())`; - await connection.execute(solutionQuery, [ticketId, solutionContent, 971]); // 971 = ID do usuário da integração + await connection.execute(solutionQuery, [ticketId, solutionContent, 971]); - // Atualiza o status do ticket para "Solucionado" (status 5) const updateTicketQuery = `UPDATE glpi_tickets SET status = 5, solvedate = NOW() WHERE id = ?`; await connection.execute(updateTicketQuery, [ticketId]); @@ -356,7 +328,6 @@ class TicketGlpiModel { } } - // Busca a solução de um ticket no GLPI static async getTicketSolution(glpiTicketId) { const connection = await glpiPool.getConnection(); try { @@ -375,17 +346,14 @@ class TicketGlpiModel { } } - // Força o fechamento de um ticket (muda status para 6) static async forceCloseTicket(ticketId) { const connection = await glpiPool.getConnection(); try { - // Atualiza o status do ticket para "Fechado" (status 6) const updateTicketQuery = `UPDATE glpi_tickets SET status = 6, closedate = NOW() WHERE id = ?`; await connection.execute(updateTicketQuery, [ticketId]); logInfo(`✅ Ticket GLPI ${ticketId} forçado para o estado Fechado (6).`); } catch (error) { logError(error, `❌ Erro ao forçar fechamento do ticket ${ticketId} no GLPI`); - // Não lança o erro para não parar o fluxo principal por uma ação secundária } finally { if (connection) connection.release(); } @@ -412,7 +380,6 @@ class TicketGlpiModel { } } - // Busca o status atual de um ticket no GLPI static async getTicketStatus(glpiTicketId) { const connection = await glpiPool.getConnection(); try { @@ -431,18 +398,20 @@ class TicketGlpiModel { } } + /** * @module TicketGlpiModel - * @description Este módulo fornece métodos para gerenciar tickets no GLPI. - * Inclui funcionalidades para mapear status, prioridade e categoria, - * formatar a descrição do ticket, determinar o SLA, criar tickets e - * verificar se um ticket já existe no GLPI. + * @description Este módulo é a camada de acesso a dados para o banco de dados do GLPI. + * Ele encapsula todas as interações diretas com o banco de dados MySQL do GLPI, + * oferecendo uma interface clara para criar, atualizar e consultar tickets e seus componentes. * - * O módulo usa expressões regulares para extrair informações da descrição do ticket, - * como email, número de telefone e ramal, quando essas informações não são - * fornecidas diretamente. Também formata a descrição em HTML para melhor - * apresentação no GLPI. + * Principais Funcionalidades: + * - **Mapeamento de Dados**: Contém funções (`mapStatus`, `mapPriority`, `mapCategory`, etc.) para traduzir os dados do formato do ServiceNow para o formato esperado pelo GLPI. + * - **Formatação de Conteúdo**: `formatTitle` e `formatDescription` preparam o título e a descrição do ticket, enriquecendo-os com informações da entidade e formatando a descrição em uma tabela HTML para melhor legibilidade. + * - **Criação de Tickets (`createTicket`)**: Orquestra a criação de um novo ticket no GLPI, incluindo a inserção na tabela principal (`glpi_tickets`), a associação de usuários e grupos. + * - **Consulta de Dados**: Oferece métodos para verificar a existência de um ticket (`ticketExists`), buscar seu status (`getTicketStatus`), soluções (`getTicketSolution`) e comentários/follow-ups (`getFollowupsByItemId`). + * - **Atualização de Tickets**: Inclui métodos para atualizar o status (`updateStatus`), adicionar comentários (`insertComment`), e fechar tickets, seja de forma padrão com uma solução (`closeTicket`) ou forçando o estado para "Fechado" (`forceCloseTicket`). * - * O módulo interage com o banco de dados GLPI através da conexão glpiPool. + * Este modelo é fundamental para abstrair a complexidade do esquema de banco de dados do GLPI e garantir que as operações sejam realizadas de forma transacional e segura. */ module.exports = TicketGlpiModel; \ No newline at end of file diff --git a/src/models/ticketSnModel.js b/src/models/ticketSnModel.js index b0a7365..30e204f 100644 --- a/src/models/ticketSnModel.js +++ b/src/models/ticketSnModel.js @@ -1,10 +1,8 @@ -// src/models/ticketSnModel.js const pool = require('../data/database'); const { logInfo, logError, logWarning } = require('../utils/logger'); class TicketSnModel { - // Salva ou atualiza um ticket no banco de dados static async saveTicket(ticketData, typeTicket) { const query = ` INSERT INTO tickets_sn @@ -25,19 +23,14 @@ class TicketSnModel { telefone = EXCLUDED.telefone, opened_at = EXCLUDED.opened_at, tipo = EXCLUDED.tipo - RETURNING id, (xmax = 0) AS "wasInserted"`; // xmax=0 é um truque do PostgreSQL para saber se foi INSERT + RETURNING id, (xmax = 0) AS "wasInserted"`; - // Trata a diferença na estrutura do campo 'number' entre Incidentes e Requisições const ticketNumber = typeof ticketData.number === 'object' ? ticketData.number?.value : ticketData.number; - - // Mapeamento inteligente de campos baseado no tipo de ticket let callerName, callerEmail, ramal, telefone, sysId, shortDescription, state, description, locationId, locationName, openedAt; - if (typeTicket === 'incidente') { callerName = ticketData.caller_id?.display_value; - // O campo de email para incidentes vem como um objeto, precisamos extrair o valor. const emailObject = ticketData['caller_id.email'] || {}; callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null; ramal = null; // Incidentes não têm essa variável por padrão @@ -59,7 +52,6 @@ class TicketSnModel { shortDescription = ticketData.short_description || null; state = ticketData.state || null; description = ticketData.description || null; - // Para requisições, o sys_id da localização vem no final da URL do link const locationLink = ticketData.location?.link; locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null; locationName = ticketData.location?.display_value || null; @@ -89,7 +81,7 @@ class TicketSnModel { try { const result = await pool.query(query, values); - const wasInserted = result.rows[0].wasInserted; // Corrigido para corresponder ao alias "wasInserted" + const wasInserted = result.rows[0].wasInserted; if (wasInserted) { logInfo(`✅ NOVO ticket salvo! ID: ${result.rows[0].id}`, { @@ -112,7 +104,6 @@ class TicketSnModel { } } - // Busca tickets que ainda não foram sincronizados com o GLPI static async getPendingTickets() { try { const query = ` @@ -149,7 +140,6 @@ class TicketSnModel { } } - // Busca um ticket pelo número static async findByTicketNumber(ticketNumber) { try { const query = ` @@ -166,8 +156,6 @@ class TicketSnModel { } } - - // Busca o status de um ticket pelo ID static async getStatus(ticketId) { try { const query = ` @@ -181,9 +169,6 @@ class TicketSnModel { } } - - - // Busca um ticket pelo ID static async findById(ticketId) { try { const query = ` @@ -200,24 +185,20 @@ class TicketSnModel { } } - // Atualiza os dados de um ticket static async updateTicket(ticketId, updateData) { try { const fields = []; const values = []; let paramCount = 1; - // Construir dinamicamente a query baseada nos campos fornecidos for (const [key, value] of Object.entries(updateData)) { fields.push(`${key} = $${paramCount}`); values.push(value); paramCount++; } - // Adicionar updated_at fields.push(`updated_at = NOW()`); - // Adicionar o ID no final dos valores values.push(ticketId); const query = ` @@ -247,12 +228,13 @@ class TicketSnModel { /** * @module TicketSnModel - * @description Este módulo fornece métodos para interagir com a tabela de tickets do ServiceNow (tickets_sn) no banco de dados. - * Inclui funcionalidades para salvar um ticket, buscar tickets pendentes para sincronização com o GLPI, - * e buscar/atualizar tickets específicos. + * @description Este módulo serve como a camada de acesso a dados para a tabela `tickets_sn`, que armazena uma cópia local dos dados dos tickets do ServiceNow. + * Manter uma cópia local otimiza as consultas e reduz a dependência de chamadas de API repetitivas ao ServiceNow. * - * A função saveTicket insere ou atualiza um ticket na tabela tickets_sn. - * A função getPendingTickets busca tickets que ainda não foram sincronizados com o GLPI (não possuem registro em ticket_sync) - * e ignora os tickets com status 'Encerrado' ou 'Encerrado - Omitido'. + * Principais Funcionalidades: + * - `saveTicket(ticketData, typeTicket)`: Insere um novo ticket ou atualiza um existente (UPSERT) na tabela `tickets_sn`. Ele mapeia de forma inteligente os campos de incidentes e requisições, que possuem estruturas diferentes na API do ServiceNow. Retorna se o registro foi inserido ou atualizado. + * - `getPendingTickets()`: Busca todos os tickets que estão no estado 'pending_check', indicando que foram coletados do ServiceNow mas ainda não foram processados para criação no GLPI. + * - Funções de Busca (`findByTicketNumber`, `findById`, `getStatus`): Métodos para recuperar registros de tickets ou seus status com base em diferentes identificadores. + * - `updateTicket(ticketId, updateData)`: Atualiza dinamicamente os campos de um registro de ticket existente. */ module.exports = TicketSnModel; \ No newline at end of file diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index 818015f..f448612 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -1,15 +1,12 @@ -// src/models/ticketSyncModel.js const { query } = require('winston'); const pool = require('../data/database'); const { logError } = require('../utils/logger'); class TicketSyncModel { - // Cria um registro de sincronização para um ticket static async createSyncRecord(data) { try { - const query = `INSERT INTO ticket_sync ( sn_ticket_id, glpi_ticket_id, @@ -43,8 +40,6 @@ class TicketSyncModel { } } - - // Buscar tickets que foram criados com sucesso para sincronizar comentários static async getTicketsToSync(glpi_sync_status, sn_sync_status) { try { const query = 'SELECT id, glpi_ticket_id, sn_ticket_id FROM ticket_sync WHERE glpi_sync_status = $1 AND sn_sync_status = $2'; @@ -57,7 +52,6 @@ class TicketSyncModel { } } - // Atualiza apenas o source_last de um ticket static async updateSourceLast(snTicketId, source) { try { const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; @@ -68,7 +62,6 @@ class TicketSyncModel { } } - // Atualiza o status de sincronização de um ticket static async updateStatus(ticketSyncId, glpiStatus, snStatus) { try { const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2 WHERE sn_ticket_id = $3'; @@ -78,7 +71,7 @@ class TicketSyncModel { throw error; } } - // Atualiza o status de sincronização de um ticket e o source last + static async updateStatusAndSourceLast(ticketSyncId, glpiStatus, snStatus, source_last) { try { @@ -90,7 +83,6 @@ class TicketSyncModel { } } - // Atualiza apenas o source_last de um ticket static async updateSourceLast(snTicketId, source) { try { const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; @@ -101,8 +93,6 @@ class TicketSyncModel { } } - - static async updateStatusAndGlpiTicket(ticketSyncId, glpiStatus, snStatus, glpiTicketId) { try { const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2, glpi_ticket_id = $4 WHERE sn_ticket_id = $3'; @@ -113,7 +103,6 @@ class TicketSyncModel { } } - // Buscar o ID de sincronização baseado no glpi_ticket_id static async getIdSyncByGlpiId(glpiTicketId) { try { const query = 'SELECT id FROM ticket_sync WHERE glpi_ticket_id = $1'; @@ -125,7 +114,6 @@ class TicketSyncModel { } } - // Buscar o ID de sincronização baseado no sys_id do SN static async getBySysId(sysId) { try { const query = ` @@ -140,7 +128,6 @@ class TicketSyncModel { } } - // Buscar o ID de sincronização baseado no sn_ticket_id static async getIdSyncBySnId(snTicketId) { try { const query = 'SELECT id FROM ticket_sync WHERE sn_ticket_id = $1'; @@ -151,6 +138,7 @@ class TicketSyncModel { throw error; } } + static async getByTicketNumber(ticketNumber) { try { const query = 'SELECT * FROM ticket_sync WHERE sn_ticket_id = $1'; @@ -163,7 +151,6 @@ class TicketSyncModel { } - // Buscar tickets que estão sincronizados static async getTicketsSynced() { try { const query = ` @@ -184,7 +171,6 @@ class TicketSyncModel { } } - // Buscar tickets que estão ativos para monitoramento (synced ou solved) static async getTicketsToMonitor() { try { const query = ` @@ -208,7 +194,6 @@ class TicketSyncModel { } } - // Buscar tickets que precisam ter o fechamento sincronizado static async getTicketsPendingSolve() { try { const query = ` @@ -231,7 +216,6 @@ class TicketSyncModel { } } - // Busca todos os tickets que estão em algum estado de erro static async getTicketsInErrorState() { try { const query = ` @@ -247,7 +231,6 @@ class TicketSyncModel { } } - // Atualiza os timestamps de última sincronização static async updateLastSync(snTicketId, system) { try { let fieldToUpdate; @@ -256,17 +239,15 @@ class TicketSyncModel { } else if (system === 'glpi') { fieldToUpdate = 'last_glpi_sync'; } else { - return; // Não faz nada se o sistema for inválido + return; } const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`; await pool.query(query, [snTicketId]); } catch (error) { logError(`${error} ❌ Erro ao atualizar last_sync para o ID: ${snTicketId}`); - // Não lançar erro para não parar o fluxo principal } } - // Atualiza o status de sincronização de um ticket usando o GLPI ID static async updateStatusByGlpiId(glpiTicketId, snStatus, glpiStatus) { try { const query = 'UPDATE ticket_sync SET sn_sync_status = $1, glpi_sync_status = $2 WHERE glpi_ticket_id = $3'; @@ -281,14 +262,16 @@ class TicketSyncModel { } /** * @module TicketSyncModel - * @description Este módulo fornece métodos para interagir com a tabela de sincronização de tickets (ticket_sync) no banco de dados. - * Inclui funcionalidades para buscar tickets para sincronização, atualizar o status de sincronização - * e buscar o ID de sincronização baseado no glpi_ticket_id. + * @description Este módulo é a camada de acesso à tabela `ticket_sync`, que atua como o cérebro do processo de sincronização. + * Ele armazena o estado de cada ticket em ambos os sistemas (GLPI e ServiceNow) e controla o fluxo de atualizações. * - * A função getTicketsToSync busca todos os tickets que possuem IDs tanto no GLPI quanto no ServiceNow. - * A função updateStatus atualiza os status de sincronização do GLPI e do ServiceNow para um ticket específico. - * A função getIdSyncByGlpiId retorna o ID de sincronização baseado no glpi_ticket_id. - * Essas funcionalidades são essenciais para manter a consistência e o rastreamento dos tickets entre os dois sistemas. + * Principais Funcionalidades: + * - **Gerenciamento de Estado**: Métodos como `updateStatus`, `updateStatusAndSourceLast` e `updateStatusAndGlpiTicket` são usados para modificar os status de sincronização (ex: 'pending_check', 'synced', 'solved', 'closed', 'error'). + * - **Controle de Fluxo (`source_last`)**: A coluna `source_last` é gerenciada por este modelo para indicar qual sistema realizou a última modificação, prevenindo atualizações conflitantes (race conditions). + * - **Seleção de Tickets para Processamento**: Métodos como `getTicketsToMonitor`, `getTicketsInErrorState`, e `getTicketsSynced` fornecem listas de tickets para os diferentes controladores processarem, com base em seus status atuais. + * - **Criação e Consulta**: `createSyncRecord` cria o registro inicial de um ticket, e várias funções de busca (`getBySysId`, `getIdSyncByGlpiId`, etc.) permitem localizar registros de sincronização. + * + * A tabela `ticket_sync` é a fonte da verdade para o estado da integração de cada ticket individualmente. */ diff --git a/src/models/ticketUpdateModel.js b/src/models/ticketUpdateModel.js index de0717b..fd28535 100644 --- a/src/models/ticketUpdateModel.js +++ b/src/models/ticketUpdateModel.js @@ -1,4 +1,3 @@ -// src/models/ticketUpdateModel.js const ticketSyncModel = require('./ticketSyncModel'); const pool = require('../data/database'); const { logError } = require('../utils/logger'); @@ -23,7 +22,7 @@ class TicketUpdateModel { `; const values = [ updateData.ticket_sync_id, - updateData.update_type, // Corrigido para usar o tipo de atualização dinâmico + updateData.update_type, updateData.source_system, updateData.content, updateData.author, @@ -103,4 +102,17 @@ class TicketUpdateModel { } } } -module.exports = TicketUpdateModel; \ No newline at end of file + +module.exports = TicketUpdateModel; + +/** + * @module TicketUpdateModel + * @description Este módulo gerencia a tabela `ticket_updates`, que serve como um log persistente de todas as atualizações (comentários, mudanças de status, etc.) + * que ocorrem em ambos os sistemas. Ele é fundamental para a sincronização de comentários e para evitar a duplicação de dados. + * + * Funcionalidades: + * - `insert(updateData)`: Insere ou atualiza (UPSERT) um registro de atualização. A cláusula `ON CONFLICT` é usada para evitar duplicatas com base no `source_id`. + * - `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/scripts/python/update_location_mapping.py b/src/scripts/python/update_location_mapping.py index 11e8170..8e77156 100644 --- a/src/scripts/python/update_location_mapping.py +++ b/src/scripts/python/update_location_mapping.py @@ -11,8 +11,17 @@ import pandas as pd from datetime import timedelta # Carrega as variáveis do .env -load_dotenv() +from pathlib import Path +# Detecta ambiente +env = os.getenv('NODE_ENV', 'development') +env_file = f".env.{env}" + +# Caminho absoluto (mesma pasta do script ou raiz do projeto) +env_path = Path(__file__).resolve().parent.parent / env_file +load_dotenv(dotenv_path=env_path) + +print(f"🌎 Ambiente carregado: {env}") # Configuração de logging logging.basicConfig( level=logging.INFO, diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index d8021b3..17ee453 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -1,4 +1,3 @@ -// src/services/glpiCommentService.js const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketSyncModel = require('../models/ticketSyncModel'); @@ -8,11 +7,9 @@ const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./serv const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); const { log } = require('winston'); -// Sincroniza comentários do GLPI para o Banco de Dados local const syncCommentsGlpitoSN = async (ticket) => { try { logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...'); - const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp if (comments.length === 0) { @@ -30,15 +27,11 @@ const syncCommentsGlpitoSN = async (ticket) => { const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized); const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); - // Caso 1: já sincronizado if (existingComment && existsInSN) { logInfo(`🔁 Comentário ${comment.id} já sincronizado.`); - // Não muda o source_last aqui, pois não houve nova sincronização continue; } - - // Caso 2: está no SN mas não no banco if (!existingComment && existsInSN) { await TicketUpdateModel.insert({ ticket_sync_id: syncId, @@ -50,11 +43,9 @@ const syncCommentsGlpitoSN = async (ticket) => { source_id: comment.id, destiny_id: existsInSN.destiny_id }); - // Não muda o source_last aqui continue; } - // Caso 3: existe no banco mas falta no SN if (existingComment && !existsInSN) { await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_sync'); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); @@ -63,7 +54,6 @@ const syncCommentsGlpitoSN = async (ticket) => { continue; } - // Caso 4: não existe em nenhum lugar → sincroniza ambos await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync'); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); await TicketUpdateModel.insert({ @@ -76,8 +66,6 @@ const syncCommentsGlpitoSN = async (ticket) => { source_id: comment.id, destiny_id: snCommentId }); - // Apenas aqui, quando um comentário é efetivamente sincronizado, passamos o bastão, - // mas SOMENTE se o ticket não estiver já resolvido ou fechado. if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); } @@ -101,8 +89,7 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => { for (const comment of comments) { try { const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id); - await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp - // Passa o bastão para o SN, mas SOMENTE se o ticket não estiver já resolvido ou fechado. + 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'); } @@ -122,18 +109,20 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => { } }; - - - - /** * @module glpiCommentService - * @description Este módulo fornece serviços para sincronizar comentários do GLPI para o banco de dados local. - * A função syncCommentsGlpi busca tickets que foram criados com sucesso e sincroniza seus comentários, - * garantindo que apenas novos comentários sejam inseridos no banco de dados.synced - * Utiliza os modelos TicketSnModel, TicketGlpiModel, TicketSyncModel e TicketUpdateModel para interagir com o banco de dados. - * Atualiza o status de sincronização após cada comentário processado. -* Inclui logging detalhado para monitorar o progresso e identificar erros durante o processo de sincronização. + * @description Este serviço orquestra a sincronização de comentários (follow-ups/notas) entre GLPI e ServiceNow. + * + * Principais Funcionalidades: + * - `syncCommentsGlpitoSN(ticket)`: Sincroniza comentários do GLPI para o ServiceNow. Esta é a função mais complexa, pois implementa uma lógica de 4 casos para garantir a consistência e evitar duplicatas: + * 1. **Já Sincronizado**: O comentário existe no banco local e no ServiceNow. Nenhuma ação é necessária. + * 2. **Existe no SN, Falta no Local**: O comentário foi sincronizado em uma execução anterior que falhou. O registro local é criado para refletir a realidade. + * 3. **Existe no Local, Falta no SN**: O comentário foi registrado localmente, mas a sincronização com o SN falhou. A função reenvia o comentário para o ServiceNow. + * 4. **Não Existe em Nenhum**: Um novo comentário do GLPI. Ele é enviado para o ServiceNow e um novo registro é criado no banco local. + * + * - `syncCommentsSNtoGlpi(comments, ticket)`: Sincroniza comentários do ServiceNow para o GLPI. Este fluxo é mais simples: para cada novo comentário do SN (já filtrado e salvo localmente pelo `servicenowService`), ele o insere no GLPI. + * + * Em ambos os fluxos, o "bastão" (`source_last`) é atualizado para indicar qual sistema originou a última atualização, mantendo o controle do fluxo de status. */ module.exports = { diff --git a/src/services/glpiTicketService.js b/src/services/glpiTicketService.js index b2fa7b0..d15ab0a 100644 --- a/src/services/glpiTicketService.js +++ b/src/services/glpiTicketService.js @@ -1,4 +1,3 @@ -// src/services/glpiService.js const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketUpdateModel = require('../models/ticketUpdateModel'); @@ -6,12 +5,9 @@ const TicketSyncModel = require('../models/ticketSyncModel'); const { logInfo, logError, logWarning } = require('../utils/logger'); const { addWorkNoteToServiceNow } = require('./servicenowService'); - -// Método principal que o controller vai chamar const syncTicketsToGlpi = async () => { try { logInfo('🔄 Iniciando sincronização para GLPI...'); - // 1. Buscar tickets pendentes const pendingTickets = await TicketSnModel.getPendingTickets(); if (pendingTickets.length === 0) { @@ -21,7 +17,6 @@ const syncTicketsToGlpi = async () => { logInfo(`📋 Encontrados ${pendingTickets.length} tickets para sincronizar`); - // 2. Processar cada ticket for (const ticket of pendingTickets) { await processSingleTicket(ticket); } @@ -34,42 +29,34 @@ const syncTicketsToGlpi = async () => { } }; -// Processar um ticket individual const processSingleTicket = async (ticket) => { try { logInfo(`🔍 Processando ticket: ${ticket.ticket_number}`); - // 1. Verifica se o ticket já possui um ID do GLPI no nosso banco de sincronização. 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'); return; } - // 2. Se não tem ID local, verifica se o ticket existe no GLPI (buscando pelo número). const existingTicket = await TicketGlpiModel.ticketExists(ticket.ticket_number); - await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp + await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); if (existingTicket) { logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${existingTicket}`); - // --- NOVA VALIDAÇÃO: Verificar status do ticket existente no GLPI --- const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(existingTicket); - if (glpiLiveStatus === 6) { // Status 6 = Fechado no GLPI + if (glpiLiveStatus === 6) { logInfo(`Ticket GLPI ${existingTicket} já está Fechado. Marcando ticket SN ${ticket.ticket_number} como 'closed'/'ignored' e não monitorado.`); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'ignored', 'closed', existingTicket); - return; // Não processa mais este ticket + return; } - // --- FIM DA NOVA VALIDAÇÃO --- - - // Atualiza nosso registro de sincronização com o ID do GLPI encontrado. await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket); logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`); - return; // Adiciona este return para evitar a criação de um novo ticket + return; } - // 3. Converter dados para formato GLPI const glpiData = { ticket_number: ticket.ticket_number, short_description: ticket.short_description, @@ -87,12 +74,8 @@ const processSingleTicket = async (ticket) => { updated_at: ticket.updated_at }; - // Lógica para buscar descrição de sc_item_option se a descrição atual estiver vazia - // e for um ticket de requisição (apenas para criação de novos tickets) if (glpiData.type === 'requisicao' && (!glpiData.description || glpiData.description.trim() === '')) { logInfo(`Descrição vazia para requisição ${ticket.ticket_number}. Tentando buscar de sc_item_option.`); - // Você precisará saber o nome interno da variável de catálogo que contém a descrição. - // Por exemplo, se for 'justificativa', use 'justificativa'. const scItemOptionDescription = await require('./servicenowService').fetchScItemOptionValue(ticket.sys_id, 'justificativa'); if (scItemOptionDescription) { glpiData.description = scItemOptionDescription; @@ -102,19 +85,14 @@ const processSingleTicket = async (ticket) => { } } - // 3. Inserir no GLPI const glpiTicketId = await TicketGlpiModel.createTicket(glpiData); - await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp - - // 4. Atualizar registro de sincronização + await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId); logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`); } catch (error) { logError(error, `❌ Erro no ticket ${ticket.ticket_number}`); - - // Se temos um registro de sync, atualizar o status para error try { await TicketSyncModel.updateStatus(ticket.id, 'error', 'collected'); } catch (innerError) { @@ -127,7 +105,6 @@ const checkGlpiStatusChanges = async () => { try { logInfo('📡 Verificando mudanças de status nos tickets do GLPI...'); - // 1. Busca todos os tickets que estão sendo monitorados const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); if (ticketsToMonitor.length === 0) { @@ -136,30 +113,25 @@ const checkGlpiStatusChanges = async () => { } for (const ticket of ticketsToMonitor) { - // 2. Para cada ticket, busca seu status real no GLPI const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); - // 3. Compara o status real com o que temos no nosso banco const currentDbStatus = ticket.glpi_sync_status; - const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); // ex: 'em_atendimento' + const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); - // Se o status no GLPI mudou para Solucionado (5) e nosso banco ainda não reflete isso 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); - // REGRA "FORA DO ESCOPO" if (solution?.solutiontypes_id === 27) { logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`); const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.'; await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); - continue; // Pula para o próximo ticket + continue; } - // FLUXO NORMAL DE RESOLUÇÃO const sanitizedContent = solution?.content ? stripHTML(solution.content) : 'Chamado solucionado sem notas.'; await TicketUpdateModel.insert({ ticket_sync_id: ticket.id, @@ -170,7 +142,6 @@ const checkGlpiStatusChanges = async () => { created_at: solution?.date_mod || new Date(), source_id: `solution-${ticket.glpi_ticket_id}`, }); - // Marca o ticket para ser resolvido no SN await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'pending_solve', 'GLPI'); } else if (glpiStatus === 6 && currentDbStatus !== 'closed') { @@ -178,7 +149,6 @@ const checkGlpiStatusChanges = async () => { await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status); } else if (glpiStatus < 5 && glpiStatusAsString !== currentDbStatus && ticket.source_last !== 'GLPI') { - // Para outros status (reabertura, etc.), apenas passa o bastão. 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'); } @@ -192,18 +162,16 @@ const closeTicketInGlpi = async (ticket) => { try { logInfo(`🔒 Iniciando fechamento do chamado GLPI ${ticket.glpi_ticket_id}...`); - // Busca a mensagem de fechamento vinda do ServiceNow const closeMessageRecord = await TicketUpdateModel.getLatestUpdate(ticket.id, 'servicenow'); const solutionContent = closeMessageRecord?.content || 'Chamado encerrado via integração com o ServiceNow.'; - // Chama o model para fechar o ticket no GLPI await TicketGlpiModel.closeTicket(ticket.glpi_ticket_id, solutionContent); - await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp + await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); logInfo(`✅ Chamado ${ticket.glpi_ticket_id} solucionado com sucesso no GLPI.`); } catch (error) { logError(error, `🚨Falha ao fechar chamado no GLPI: ${ticket.glpi_ticket_id}`); - throw error; // Propaga o erro para o controller tratar + throw error; } }; @@ -211,4 +179,16 @@ module.exports = { syncTicketsToGlpi, checkGlpiStatusChanges, closeTicketInGlpi -}; \ No newline at end of file +}; + +/** + * @module glpiTicketService + * @description Este serviço contém a lógica de negócio para a criação e atualização de tickets no GLPI. + * Ele atua como um intermediário entre os controladores e os modelos de dados. + * + * 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. + * - `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". + */ \ No newline at end of file diff --git a/src/services/hubsoftService.js b/src/services/hubsoftService.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/services/servicenowService.js b/src/services/servicenowService.js index a8dd71a..0b992f9 100644 --- a/src/services/servicenowService.js +++ b/src/services/servicenowService.js @@ -1,54 +1,47 @@ // src/services/servicenowService.js 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 TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const { log } = require('winston'); +// Função para buscar incidentes do ServiceNow const fetchTicketsFromServiceNow = async (watermark) => { try { - // A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date. const watermarkDate = new Date(watermark); - // Ajusta para o fuso horário correto se necessário e formata para a query do SN. - const watermarkBRT = new Date(watermarkDate.getTime()); // Se precisar de ajuste de fuso, faça aqui. + const watermarkBRT = new Date(watermarkDate.getTime()); const formattedWatermark = watermarkBRT.toISOString().replace('T', ' ').substring(0, 19); const fields = 'number,sys_id,short_description,state,description,caller_id,caller_id.email,caller_id.phone,caller_id.mobile_phone,location,opened_at,sys_updated_on'; - const query = `assignment_group=1132c9e01bfad410e77eb9121b4bcbab^sys_updated_on>=${formattedWatermark}`; - const url = `${apiConfig.snTableIncidentConfig.baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; + const baseUrl = apiConfig.snTableIncidentConfig.baseUrl.replace(/\/$/, ""); + const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${formattedWatermark}`; + const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; const ticketsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); return ticketsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Tickets'); - throw error; // Lança o erro para que o controller não atualize a watermark + throw error; } } - - +//Função para buscar requsições do ServiceNow const fetchRequestsFromServiceNow = async (watermark) => { try { - // A tabela de requisições (sc_req_item) não possui o campo 'active'. A query deve ser apenas pela data. - // Construímos a query completa aqui, combinando o grupo de atribuição com a watermark. - const assignmentGroupSysId = '1132c9e01bfad410e77eb9121b4bcbab'; // Sys ID do grupo 'Sothis' - const query = `assignment_group=${assignmentGroupSysId}^sys_updated_on>=${watermark}`; - const fields = 'number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables'; - // Garante que a URL base não tenha uma barra no final para evitar URL malformada. + const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${watermark}`; + const fields ='number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables'; const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, ""); - //const url = `${baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`; const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; - - const requestsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); return requestsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Requests'); - throw error; // Lança o erro + throw error; } } @@ -186,7 +179,6 @@ const existingCommentInServiceNow = async (ticketId, comment) => { // Função para buscar todos os comentários de tickets no ServiceNow a partir do ticket id no banco local // Deve-se atentar para a paginação da API do ServiceNow -// para fins de teste, gerar um arquivo JSON com os comentários de um ticket específico const fetchCommentsFromServiceNow = async (ticket) => { try { @@ -291,8 +283,8 @@ const fetchCommentsFromServiceNow = async (ticket) => { return []; } }; + // Adiciona no final do arquivo src/services/servicenowService.js -const { stripHTML } = require('../utils/commentSanitizer'); const fetchAndProcessClosedTicketsFromSN = async () => { try { logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...'); @@ -341,9 +333,7 @@ const fetchAndProcessClosedTicketsFromSN = async () => { } }; -/** - * Atualiza o status (state) de um ticket no ServiceNow (incidente ou requisição). - */ +//Atualiza o status (state) de um ticket no ServiceNow (incidente ou requisição). const updateStatusInServiceNow = async (snTicketId, state) => { // Mapeamento de status de string para código numérico do ServiceNow const stateMap = { @@ -446,10 +436,6 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => { work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`, }; - - console.log(url); - console.log(payload); - } else { logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); diff --git a/src/utils/commentSanitizer.js b/src/utils/commentSanitizer.js index e51fa0f..0ec51c1 100644 --- a/src/utils/commentSanitizer.js +++ b/src/utils/commentSanitizer.js @@ -1,13 +1,8 @@ -// src/utils/commentSanitizer.js const { logInfo, logWarning } = require('./logger'); -/** - * Remove HTML tags e converte entidades HTML - CORRIGIDO - */ const stripHTML = (html) => { if (!html) return ''; - // PRIMEIRO: converte entidades HTML para caracteres normais let cleaned = html .replace(/</g, '<') // < para < .replace(/>/g, '>') // > para > @@ -18,7 +13,6 @@ const stripHTML = (html) => { .replace(/&/g, '&') // & para & .replace(/"/g, '"'); // " para " - // DEPOIS: remove tags HTML cleaned = cleaned .replace(//gi, '\n') //
para quebra de linha .replace(/

/gi, '') // Remove

@@ -39,33 +33,24 @@ const stripHTML = (html) => { return cleaned; }; -/** - * Detecta e trata imagens do GLPI - */ const handleImages = (content) => { const imgRegex = /]+src="([^"]+)"[^>]*>/gi; const hasImages = imgRegex.test(content); if (hasImages) { logWarning('📷 Imagem detectada no comentário GLPI'); - // Remove toda a tag de imagem mas mantém o texto around return content.replace(/]*>.*?]*>.*?<\/a>/gi, '[IMAGEM ANEXA NO GLPI]'); } return content; }; -/** - * Limpa metadados JSON do GLPI - */ const cleanJSONMetadata = (content) => { if (typeof content === 'string' && content.includes('{id=') && content.includes('content=')) { try { - // Extrai o conteúdo do campo content= do objeto const contentMatch = content.match(/content=([^,]+),/); if (contentMatch && contentMatch[1]) { let extracted = contentMatch[1].trim(); - // Remove aspas se existirem extracted = extracted.replace(/^['"]|['"]$/g, ''); return extracted; } @@ -76,27 +61,20 @@ const cleanJSONMetadata = (content) => { return content; }; -/** - * Função principal de sanitização - */ const sanitizeGLPIComment = (commentObj) => { if (!commentObj || typeof commentObj !== 'object') { logWarning('Comentário inválido recebido', { comment: commentObj }); return ''; } - // Pega o conteúdo do objeto comment let content = commentObj.content || ''; - // Se for string vazia, retorna vazio if (!content) return ''; - // Aplica as limpezas em sequência content = cleanJSONMetadata(content.toString()); content = handleImages(content); content = stripHTML(content); - // Log para debugging if (content !== commentObj.content) { logInfo('🔧 Comentário sanitizado', { original: commentObj.content.substring(0, 100) + '...', @@ -108,17 +86,16 @@ const sanitizeGLPIComment = (commentObj) => { }; /** - * Esse módulo fornece funções para sanitizar comentários importados do GLPI. - * Ele remove tags HTML, trata imagens e limpa metadados JSON específicos do GLPI. - * O objetivo é garantir que os comentários sejam legíveis e seguros para exibição. - * - * Funções principais: - * - sanitizeGLPIComment: Função principal que aplica todas as sanitizações. - * - stripHTML: Remove tags HTML e converte algumas entidades para texto simples. - * - handleImages: Detecta imagens e substitui por um marcador de imagem. - * - cleanJSONMetadata: Remove metadados JSON do GLPI, extraindo apenas o conteúdo relevante. - * - * Logs são gerados para ajudar no debugging e monitoramento do processo de sanitização. + * @module commentSanitizer + * @description Este módulo utilitário é especializado em limpar e formatar o conteúdo dos comentários (follow-ups) vindos do GLPI. + * O GLPI armazena comentários em formato HTML, muitas vezes com metadados e formatações que não são desejáveis no ServiceNow. + * O objetivo é converter esse conteúdo em texto puro e legível. + * + * Funções: + * - `sanitizeGLPIComment(commentObj)`: A função principal que orquestra o processo de limpeza. + * - `stripHTML(html)`: Remove a maioria das tags HTML, convertendo algumas (como `
`, `

`, ``) em marcações de texto simples (quebras de linha, asteriscos). + * - `handleImages(content)`: Detecta a presença de imagens nos comentários do GLPI e as substitui por um texto placeholder, como "[IMAGEM ANEXA NO GLPI]", já que as imagens não podem ser transferidas diretamente. + * - `cleanJSONMetadata(content)`: Remove metadados em formato de string JSON que o GLPI às vezes insere no campo de conteúdo, extraindo apenas o texto real do comentário. */ module.exports = { @@ -127,6 +104,3 @@ module.exports = { handleImages, cleanJSONMetadata }; - - -