CLEANUP: Comentarios atualizados e Readme.md alimentado com V1.0 da documentação

This commit is contained in:
Rafael Alves Lopes 2025-10-29 14:20:21 -03:00
parent 0c4538355a
commit bfa0e8acc8
24 changed files with 398 additions and 425 deletions

43
.env
View File

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

49
.env.development Normal file
View File

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

3
.gitignore vendored
View File

@ -5,4 +5,5 @@ config/*.json
*.tmp *.tmp
servicenow_requests.json servicenow_requests.json
servicenow_tickets.json servicenow_tickets.json
location_mapping.log location_mapping.log
.env.production

170
README.md
View File

@ -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) 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:
- npm (Node Package Manager)
- PostgreSQL or MySQL database
- Access to ServiceNow and GLPI APIs
## 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.
``` 3. **`processCommentsController` - Sincronização de Comentários**
git clone https://github.com/yourusername/servicenow-glpi-sync.git - **Objetivo**: Manter as conversas dos tickets sincronizadas.
cd servicenow-glpi-sync - **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
``` A aplicação implementa várias regras de negócio para gerenciar a complexidade da sincronização:
cp .env.example .env
```
## 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.

View File

@ -1,4 +1,4 @@
require('dotenv').config(); //config/apiConfig.js
const glpiDbConfig = { const glpiDbConfig = {
client: process.env.GLPI_DB_TYPE || 'mysql', client: process.env.GLPI_DB_TYPE || 'mysql',
@ -45,6 +45,10 @@ const snScItemOptionConfig = {
baseUrl: process.env.SERVICENOW_SC_ITEM_OPTION_URL, baseUrl: process.env.SERVICENOW_SC_ITEM_OPTION_URL,
} }
const assignmentGroup = {
username: process.env.SERVICENOW_ASSIGNMENT_GROUP,
}
module.exports = { module.exports = {
// configuração do ServiceNow e outras já existentes podem permanecer // configuração do ServiceNow e outras já existentes podem permanecer
glpiDbConfig, glpiDbConfig,
@ -54,5 +58,6 @@ module.exports = {
snTableJournalConfig, snTableJournalConfig,
servicenowAuthentication, servicenowAuthentication,
snTableRequestConfig, snTableRequestConfig,
snScItemOptionConfig snScItemOptionConfig,
assignmentGroup
}; };

View File

@ -1,5 +1,4 @@
const { Sequelize } = require('sequelize'); const { Sequelize } = require('sequelize');
require('dotenv').config();
// Database connection configuration // Database connection configuration
const dbConfig = { const dbConfig = {

View File

@ -1,10 +1,17 @@
// src/app.js const path = require('path');
require('dotenv').config(); 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 { processTicketsController } = require('./controllers/processTicketsController');
const { processCommentsController } = require('./controllers/processCommentsController'); const { processCommentsController } = require('./controllers/processCommentsController');
const { processErrorController } = require('./controllers/processErrorController'); const { processErrorController } = require('./controllers/processErrorController');
const { processStatusAndClosureController } = require('./controllers/processStatusController'); const { 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', { logInfo('Aplicação iniciada', {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -13,15 +20,28 @@ logInfo('Aplicação iniciada', {
async function main() { async function main() {
try { try {
await processErrorController(); // Reseta os erros await processErrorController();
await processTicketsController(); // Coleta tickets do SN e cria no GLPI await processTicketsController();
await processCommentsController(); // Sincroniza chamados Bidirecionalmente await processCommentsController();
await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente await processStatusAndClosureController();
logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉'); logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉');
} catch (error) { } catch (error) {
logInfo('Erro na aplicação:', error); logInfo('Erro na aplicação:', error);
} }
} }
main(); main();
/**
* @file app.js
* @description
* Ponto de entrada principal da aplicação de sincronização. Este arquivo é responsável por:
* 1. Carregar as variáveis de ambiente corretas (`.env.development` ou `.env.production`) com base na variável `NODE_ENV`.
* 2. Orquestrar a execução sequencial dos controladores que compõem o ciclo de sincronização.
*
* O fluxo de execução é o seguinte:
* 1. `processErrorController`: Verifica e reseta tickets que falharam em execuções anteriores, permitindo que sejam reprocessados.
* 2. `processTicketsController`: Busca novos tickets (incidentes e requisições) do ServiceNow, os salva no banco de dados intermediário e cria os tickets correspondentes no GLPI.
* 3. `processCommentsController`: Realiza a sincronização bidirecional de comentários (notas/follow-ups) para os tickets que estão sincronizados.
* 4. `processStatusAndClosureController`: Executa a sincronização bidirecional de status (aberto, em atendimento, resolvido, fechado) e gerencia o fechamento de tickets em ambas as plataformas.
*/

View File

@ -4,17 +4,21 @@ const TicketSyncModel = require('../models/ticketSyncModel');
const processCommentsController = async () => { const processCommentsController = async () => {
// Usa getTicketsToMonitor para incluir tickets 'synced' e 'solved'.
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor(); const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
for (const ticket of ticketsToMonitor){ for (const ticket of ticketsToMonitor){
const comments = await fetchCommentsFromServiceNow(ticket); const comments = await fetchCommentsFromServiceNow(ticket);
if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);} if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);}
await syncCommentsGlpitoSN(ticket); await syncCommentsGlpitoSN(ticket);
} }
} }
module.exports = { module.exports = {
processCommentsController processCommentsController
} }
/**
* @file processCommentsController.js
* @description Este controlador orquestra a sincronização bidirecional de comentários para todos os tickets ativos.
* Ele busca tickets que estão sendo monitorados e, para cada um, executa a sincronização de comentários do ServiceNow para o GLPI e vice-versa.
*/

View File

@ -1,10 +1,8 @@
// src/controllers/processErrorController.js
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
const processErrorController = async () => { const processErrorController = async () => {
logInfo('Iniciando verificação de tickets com erro...'); logInfo('Iniciando verificação de tickets com erro...');
try { try {
const errorTickets = await TicketSyncModel.getTicketsInErrorState(); const errorTickets = await TicketSyncModel.getTicketsInErrorState();
@ -19,20 +17,16 @@ const processErrorController = async () => {
let newSnStatus = ticket.sn_sync_status; let newSnStatus = ticket.sn_sync_status;
let newGlpiStatus = ticket.glpi_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') { if (ticket.glpi_sync_status === 'error') {
newGlpiStatus = 'pending_check'; 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.sn_sync_status === 'error_sync') newSnStatus = 'synced';
if (ticket.glpi_sync_status === 'error_sync') newGlpiStatus = '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.sn_sync_status === 'error_closing') newSnStatus = 'pending_close';
if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = '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); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus);
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`); logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`);
} }
@ -45,3 +39,11 @@ const processErrorController = async () => {
module.exports = { module.exports = {
processErrorController 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.
*/

View File

@ -1,7 +1,6 @@
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
const { stripHTML } = require('../utils/commentSanitizer'); const { stripHTML } = require('../utils/commentSanitizer');
const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService'); 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. // Se o status for de um ticket aberto (reabertura, etc.), propaga a mudança.
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiCurrentStatus); const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiCurrentStatus);
if (glpiStatusAsString !== snCurrentStatus) { 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...`); logInfo(`Diferença de status detectada (GLPI: ${glpiStatusAsString}, SN: ${snCurrentStatus}). Sincronizando...`);
await updateStatusInServiceNow(ticket.sn_ticket_id, glpiStatusAsString); 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 // Passa o bastão de volta para o SN se a ação não for de finalização

View File

@ -11,10 +11,8 @@ const e = require('express');
* @param {('incidente'|'requisicao')} type - O tipo de ticket. * @param {('incidente'|'requisicao')} type - O tipo de ticket.
*/ */
const processAndSaveTickets = async (tickets, type) => { 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) => { const parseServiceNowDate = (dateString) => {
if (!dateString) return null; if (!dateString) return null;
// Verifica se já está em um formato reconhecível (como ISO 8601)
if (!dateString.includes('/')) { if (!dateString.includes('/')) {
return new Date(dateString); return new Date(dateString);
} }
@ -26,18 +24,15 @@ const processAndSaveTickets = async (tickets, type) => {
if (!tickets || tickets.length === 0) { if (!tickets || tickets.length === 0) {
logInfo(`Nenhum ticket do tipo '${type}' para processar.`); 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...`); 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) { for (const ticket of tickets) {
try { 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() === '')) { if (type === 'requisicao' && (!ticket.description || ticket.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.number}. Buscando em sc_item_option...`); 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 extraData = await fetchScItemOptionValue(ticket.sys_id, 'justificativa');
const vars = {}; const vars = {};
(extraData || []).forEach(item => { (extraData || []).forEach(item => {
@ -51,7 +46,7 @@ const processAndSaveTickets = async (tickets, type) => {
if (vars.justification) { 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.`); logInfo(`Descrição da requisição ${ticket.number} atualizada com valor de sc_item_option.`);
} }
if (vars.telephone){ 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.`); 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); 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 ticketStatus = ticket.state;
const dataSync = { const dataSync = {
@ -76,31 +66,25 @@ const processAndSaveTickets = async (tickets, type) => {
updated_at: new Date() updated_at: new Date()
}; };
// 3. Define o status inicial de sincronização
if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') { if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') {
dataSync.sn_sync_status = 'closed'; 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 { } else {
dataSync.sn_sync_status = 'collected'; // Status que indica que foi coletado do SN dataSync.sn_sync_status = 'collected';
dataSync.glpi_sync_status = 'pending_check'; // Status que indica que precisa ser checkado no GLPI 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); const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn);
if (!exists) { if (!exists) {
await TicketSyncModel.createSyncRecord(dataSync); await TicketSyncModel.createSyncRecord(dataSync);
} else if (wasInserted === false) { } 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'); await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`); logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
} else { } else {
logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`); 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); const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
if (currentUpdateDate && currentUpdateDate.getTime() > latestUpdateTimestamp.getTime()) { if (currentUpdateDate && currentUpdateDate.getTime() > latestUpdateTimestamp.getTime()) {
latestUpdateTimestamp = currentUpdateDate; latestUpdateTimestamp = currentUpdateDate;
@ -112,7 +96,6 @@ const processAndSaveTickets = async (tickets, type) => {
} }
logSync('ServiceNow', tickets.length, `${type}s`); 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 return latestUpdateTimestamp.getTime() > 0
? latestUpdateTimestamp.toISOString().slice(0, 19).replace('T', ' ') ? latestUpdateTimestamp.toISOString().slice(0, 19).replace('T', ' ')
: ''; : '';
@ -123,24 +106,18 @@ const processAndSaveTickets = async (tickets, type) => {
*/ */
const processSyncController = async () => { const processSyncController = async () => {
try { try {
// 1. Pega a marca d'água da última execução bem-sucedida
const watermark = await SyncControlModel.getWatermark('servicenow'); const watermark = await SyncControlModel.getWatermark('servicenow');
logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`); logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
// Busca incidentes
const incidents = await fetchTicketsApi(watermark); const incidents = await fetchTicketsApi(watermark);
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente'); const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
// Busca requisições
logInfo('📡 Buscando requisições do ServiceNow...'); logInfo('📡 Buscando requisições do ServiceNow...');
const requests = await fetchRequestsApi(watermark); const requests = await fetchRequestsApi(watermark);
const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao'); 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; const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate;
// 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução
if (newWatermark) { if (newWatermark) {
// Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais // Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais
const sixHoursInMillis = 6 * 60 * 60 * 1000; const sixHoursInMillis = 6 * 60 * 60 * 1000;
@ -157,4 +134,19 @@ const processSyncController = async () => {
module.exports = { module.exports = {
processSyncController processSyncController
}; };
/**
* @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.
*/

View File

@ -1,19 +1,20 @@
// src/controllers/glpiController.js
const { syncTicketsToGlpi } = require('../services/glpiTicketService'); const { syncTicketsToGlpi } = require('../services/glpiTicketService');
const { processSyncController } = require('./processSyncController'); const { processSyncController } = require('./processSyncController');
const processTicketsController = async () => { 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(); await processSyncController();
//Sincroniza os tickets com o GLPI
await syncTicketsToGlpi(); await syncTicketsToGlpi();
} }
module.exports = { module.exports = {
processTicketsController 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.
*/

View File

@ -1,10 +1,7 @@
// src/models/locationMappingModel.js
const pool = require('../data/database'); const pool = require('../data/database');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError } = require('../utils/logger');
class LocationMappingModel { class LocationMappingModel {
// Buscar entity_id do GLPI baseado no location_id do ServiceNow
static async getGlpiEntityId(snLocationId) { static async getGlpiEntityId(snLocationId) {
try { try {
const query = ` const query = `
@ -25,8 +22,7 @@ class LocationMappingModel {
return null; return null;
} }
} }
// Buscar entity_id do GLPI baseado no location_name do ServiceNow
static async getGlpiEntityIdByName(snLocationName) { static async getGlpiEntityIdByName(snLocationName) {
try { try {
const query = ` const query = `
@ -47,10 +43,7 @@ class LocationMappingModel {
return null; return null;
} }
} }
// Buscar entity (id e name) do GLPI baseado no location_id do ServiceNow
static async getGlpiEntity(snLocationId) { static async getGlpiEntity(snLocationId) {
try { try {
const query = ` const query = `
@ -74,12 +67,8 @@ class LocationMappingModel {
return null; return null;
} }
} }
// Buscar entity (id e name) do GLPI baseado no location_name do ServiceNow
static async getGlpiEntityByName(snLocationName) { static async getGlpiEntityByName(snLocationName) {
try { try {
const query = ` const query = `
@ -105,8 +94,14 @@ class LocationMappingModel {
} }
} }
module.exports = LocationMappingModel;
/**
* @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.
module.exports = LocationMappingModel; *
* 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.
*/

View File

@ -1,4 +1,3 @@
// src/models/syncControlModel.js
const pool = require('../data/database'); const pool = require('../data/database');
const { logError, logInfo } = require('../utils/logger'); const { logError, logInfo } = require('../utils/logger');
@ -10,7 +9,6 @@ class SyncControlModel {
if (rows.length === 0) { if (rows.length === 0) {
throw new Error(`Watermark para o sistema '${systemName}' não encontrada no banco de dados.`); 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); return rows[0].last_watermark.toISOString().replace('T', ' ').substring(0, 19);
} catch (error) { } catch (error) {
logError(error, `Erro ao buscar watermark para ${systemName}`); logError(error, `Erro ao buscar watermark para ${systemName}`);
@ -35,3 +33,15 @@ class SyncControlModel {
} }
module.exports = 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.
*/

View File

@ -1,11 +1,9 @@
// src/models/ticketGlpiModel.js
const glpiPool = require('../data/glpiDataBase'); const glpiPool = require('../data/glpiDataBase');
const { logInfo, logError } = require('../utils/logger'); const { logInfo, logError } = require('../utils/logger');
const LocationMappingModel = require('./locationMappingModel'); const LocationMappingModel = require('./locationMappingModel');
class TicketGlpiModel { class TicketGlpiModel {
// Mapeamento de status do ServiceNow para o GLPI
static mapStatus(snState) { static mapStatus(snState) {
const statusMap = { const statusMap = {
'Aguardando Atendimento': 1, 'Aguardando Atendimento': 1,
@ -13,12 +11,11 @@ class TicketGlpiModel {
'Em Espera': 4, 'Em Espera': 4,
'Resolvido': 5, 'Resolvido': 5,
'Encerrado - Omitido': 6, 'Encerrado - Omitido': 6,
'Encerrado': 6 // Mapeia para Fechado no GLPI 'Encerrado': 6
}; };
return statusMap[snState] || 1; return statusMap[snState] || 1;
} }
// Mapeamento de status do GLPI (numérico) para ServiceNow (string)
static mapGlpiStatusToString(glpiStatus) { static mapGlpiStatusToString(glpiStatus) {
const statusMap = { const statusMap = {
1: 'Aguardando Atendimento', // Novo 1: 'Aguardando Atendimento', // Novo
@ -30,7 +27,6 @@ class TicketGlpiModel {
return statusMap[glpiStatus] || 'Desconhecido'; return statusMap[glpiStatus] || 'Desconhecido';
} }
// Mapeamento de prioridade
static mapPriority(snPriority) { static mapPriority(snPriority) {
const priorityMap = { const priorityMap = {
'1': 5, '1': 5,
@ -42,7 +38,6 @@ class TicketGlpiModel {
return priorityMap[snPriority] || 3; return priorityMap[snPriority] || 3;
} }
// Mapeamento de categoria
static mapCategory(shortDescription) { static mapCategory(shortDescription) {
const categoryMap = { const categoryMap = {
'Problemas com Telefonia Fixa': 5717, 'Problemas com Telefonia Fixa': 5717,
@ -53,7 +48,6 @@ class TicketGlpiModel {
return categoryMap[shortDescription] || 5717; return categoryMap[shortDescription] || 5717;
} }
// Mapeamento de SLA baseado no tipo e na descrição
static mapSlasIdTtr(ticketData) { static mapSlasIdTtr(ticketData) {
const isRequisicao = ticketData.type === 'requisicao'; const isRequisicao = ticketData.type === 'requisicao';
const shortDesc = ticketData.short_description; const shortDesc = ticketData.short_description;
@ -73,10 +67,9 @@ class TicketGlpiModel {
if (!isRequisicao && isTelefonia) { if (!isRequisicao && isTelefonia) {
return 34; return 34;
} }
return 0; // Default se nenhum critério for atendido return 0;
} }
// Formata o título do ticket
static async formatTitle(ticketData) { static async formatTitle(ticketData) {
try { try {
let entityData = null; let entityData = null;
@ -92,8 +85,8 @@ class TicketGlpiModel {
const tituloSn = ticketData.short_description || 'Sem título'; const tituloSn = ticketData.short_description || 'Sem título';
return { return {
title: `${tipo} - ${entityName} - ${tituloSn}`, title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
entityId: entityData?.id || 127 // fallback para entity_id default entityId: entityData?.id || 127
}; };
} catch (error) { } catch (error) {
logError(error, 'Erro ao formatar título do ticket'); logError(error, 'Erro ao formatar título do ticket');
@ -107,7 +100,6 @@ class TicketGlpiModel {
static formatDescription(ticketData) { static formatDescription(ticketData) {
// Função auxiliar para extrair informações usando regex
const extractInfo = (regex, description) => { const extractInfo = (regex, description) => {
if (!description) return null; if (!description) return null;
const match = description.match(regex); const match = description.match(regex);
@ -123,12 +115,10 @@ class TicketGlpiModel {
return extractInfo(/(\d{3,4})/i, description); 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 email = ticketData.caller_email || extractEmailFromDescription(ticketData.description) || 'N/A';
const phone = ticketData.caller_phone || extractPhoneFromDescription(ticketData.description) || 'N/A'; const phone = ticketData.caller_phone || extractPhoneFromDescription(ticketData.description) || 'N/A';
const ramal = ticketData.caller_ramal || extractRamalFromDescription(ticketData.description) || 'N/A'; const ramal = ticketData.caller_ramal || extractRamalFromDescription(ticketData.description) || 'N/A';
// Formata a descrição em HTML
let htmlDescription = ` let htmlDescription = `
<table style="width:100%; border-collapse: collapse;"> <table style="width:100%; border-collapse: collapse;">
<tr style="background-color:#f2f2f2;"> <tr style="background-color:#f2f2f2;">
@ -164,23 +154,19 @@ class TicketGlpiModel {
return htmlDescription; return htmlDescription;
} }
// Cria um ticket no GLPI
static async createTicket(ticketData) { static async createTicket(ticketData) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
// Buscar entity_id com base no location_id ou location_name let entityId = 127;
let entityId = 127; // Valor padrão (fallback)
if (ticketData.location_id) { if (ticketData.location_id) {
entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId; entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId;
} else if (ticketData.location_name) { } else if (ticketData.location_name) {
entityId = await LocationMappingModel.getGlpiEntityIdByName(ticketData.location_name) || entityId; 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; let email = ticketData.caller_email;
if (!email && ticketData.description) { if (!email && ticketData.description) {
const emailRegex = /[\w-\.]+@([\w-]+\.)+[\w-]{2,4}/g; 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); const { title: formattedTitle } = await TicketGlpiModel.formatTitle(ticketData);
// Formata a descrição para HTML
const formattedDescription = TicketGlpiModel.formatDescription(ticketData); const formattedDescription = TicketGlpiModel.formatDescription(ticketData);
// Mapeia o tipo do ticket
const ticketType = ticketData.type === 'requisicao' ? 2 : 1; const ticketType = ticketData.type === 'requisicao' ? 2 : 1;
// Determina o valor de slas_id_ttr
const slasIdTtr = TicketGlpiModel.mapSlasIdTtr(ticketData); const slasIdTtr = TicketGlpiModel.mapSlasIdTtr(ticketData);
// Prepara os dados para o GLPI
const glpiTicketData = { const glpiTicketData = {
entities_id: entityId, // Usar o entity_id mapeado entities_id: entityId,
name: formattedTitle, name: formattedTitle,
date: ticketData.opened_at || new Date(), date: ticketData.opened_at || new Date(),
date_mod: ticketData.updated_at || new Date(), date_mod: ticketData.updated_at || new Date(),
@ -219,7 +200,6 @@ class TicketGlpiModel {
slas_id_ttr: slasIdTtr slas_id_ttr: slasIdTtr
}; };
// Constrói a query dinamicamente e insere o ticket principal
const fields = Object.keys(glpiTicketData).join(', '); const fields = Object.keys(glpiTicketData).join(', ');
const placeholders = Object.keys(glpiTicketData).map(() => '?').join(', '); const placeholders = Object.keys(glpiTicketData).map(() => '?').join(', ');
const values = Object.values(glpiTicketData); const values = Object.values(glpiTicketData);
@ -228,7 +208,6 @@ class TicketGlpiModel {
const [ticketResult] = await connection.execute(ticketQuery, values); const [ticketResult] = await connection.execute(ticketQuery, values);
const ticketId = ticketResult.insertId; const ticketId = ticketResult.insertId;
// Adiciona o requerente
await connection.execute( await connection.execute(
`INSERT INTO glpi_tickets_users `INSERT INTO glpi_tickets_users
(tickets_id, users_id, type, use_notification, alternative_email) (tickets_id, users_id, type, use_notification, alternative_email)
@ -236,7 +215,6 @@ class TicketGlpiModel {
[ticketId, 917] [ticketId, 917]
); );
// Adiciona o grupo
await connection.execute( await connection.execute(
`INSERT INTO glpi_groups_tickets `INSERT INTO glpi_groups_tickets
(tickets_id, groups_id, type) (tickets_id, groups_id, type)
@ -258,7 +236,6 @@ class TicketGlpiModel {
} }
} }
// Verifica se um ticket já existe no GLPI
static async ticketExists(snTicketNumber) { static async ticketExists(snTicketNumber) {
try { try {
const query = ` const query = `
@ -279,7 +256,7 @@ class TicketGlpiModel {
return null; return null;
} }
} }
// Buscar followups (notas) de um ticket no GLPI
static async getFollowupsByItemId(itemId) { static async getFollowupsByItemId(itemId) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
@ -299,7 +276,7 @@ class TicketGlpiModel {
const values = ['Ticket', items_id, 971, comment.content , 1]; const values = ['Ticket', items_id, 971, comment.content , 1];
const [result] = await connection.execute(query, values); const [result] = await connection.execute(query, values);
return [{ id: result.insertId }]; // Retorna o ID inserido return [{ id: result.insertId }];
}catch(error){ }catch(error){
logError(`Erro ao inserir o comentário no GLPI: ${error}`); logError(`Erro ao inserir o comentário no GLPI: ${error}`);
} finally { } finally {
@ -307,11 +284,9 @@ class TicketGlpiModel {
} }
} }
// Busca tickets que foram fechados ou solucionados no GLPI
static async getClosedTickets(watermark, limit = 100) { static async getClosedTickets(watermark, limit = 100) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
// Status 5 = Solucionado, Status 6 = Fechado
const query = ` const query = `
SELECT SELECT
t.id, t.id,
@ -332,17 +307,14 @@ class TicketGlpiModel {
} }
} }
// Fecha um ticket no GLPI, adicionando uma solução
static async closeTicket(ticketId, solutionContent) { static async closeTicket(ticketId, solutionContent) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
await connection.beginTransaction(); 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())`; 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 = ?`; const updateTicketQuery = `UPDATE glpi_tickets SET status = 5, solvedate = NOW() WHERE id = ?`;
await connection.execute(updateTicketQuery, [ticketId]); await connection.execute(updateTicketQuery, [ticketId]);
@ -356,7 +328,6 @@ class TicketGlpiModel {
} }
} }
// Busca a solução de um ticket no GLPI
static async getTicketSolution(glpiTicketId) { static async getTicketSolution(glpiTicketId) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
@ -375,17 +346,14 @@ class TicketGlpiModel {
} }
} }
// Força o fechamento de um ticket (muda status para 6)
static async forceCloseTicket(ticketId) { static async forceCloseTicket(ticketId) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
// Atualiza o status do ticket para "Fechado" (status 6)
const updateTicketQuery = `UPDATE glpi_tickets SET status = 6, closedate = NOW() WHERE id = ?`; const updateTicketQuery = `UPDATE glpi_tickets SET status = 6, closedate = NOW() WHERE id = ?`;
await connection.execute(updateTicketQuery, [ticketId]); await connection.execute(updateTicketQuery, [ticketId]);
logInfo(`✅ Ticket GLPI ${ticketId} forçado para o estado Fechado (6).`); logInfo(`✅ Ticket GLPI ${ticketId} forçado para o estado Fechado (6).`);
} catch (error) { } catch (error) {
logError(error, `❌ Erro ao forçar fechamento do ticket ${ticketId} no GLPI`); 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 { } finally {
if (connection) connection.release(); if (connection) connection.release();
} }
@ -412,7 +380,6 @@ class TicketGlpiModel {
} }
} }
// Busca o status atual de um ticket no GLPI
static async getTicketStatus(glpiTicketId) { static async getTicketStatus(glpiTicketId) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {
@ -431,18 +398,20 @@ class TicketGlpiModel {
} }
} }
/** /**
* @module TicketGlpiModel * @module TicketGlpiModel
* @description Este módulo fornece métodos para gerenciar tickets no GLPI. * @description Este módulo é a camada de acesso a dados para o banco de dados do GLPI.
* Inclui funcionalidades para mapear status, prioridade e categoria, * Ele encapsula todas as interações diretas com o banco de dados MySQL do GLPI,
* formatar a descrição do ticket, determinar o SLA, criar tickets e * oferecendo uma interface clara para criar, atualizar e consultar tickets e seus componentes.
* verificar se um ticket existe no GLPI.
* *
* O módulo usa expressões regulares para extrair informações da descrição do ticket, * Principais Funcionalidades:
* como email, número de telefone e ramal, quando essas informações não são * - **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.
* fornecidas diretamente. Também formata a descrição em HTML para melhor * - **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.
* apresentação no GLPI. * - **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; module.exports = TicketGlpiModel;

View File

@ -1,10 +1,8 @@
// src/models/ticketSnModel.js
const pool = require('../data/database'); const pool = require('../data/database');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
class TicketSnModel { class TicketSnModel {
// Salva ou atualiza um ticket no banco de dados
static async saveTicket(ticketData, typeTicket) { static async saveTicket(ticketData, typeTicket) {
const query = ` const query = `
INSERT INTO tickets_sn INSERT INTO tickets_sn
@ -25,19 +23,14 @@ class TicketSnModel {
telefone = EXCLUDED.telefone, telefone = EXCLUDED.telefone,
opened_at = EXCLUDED.opened_at, opened_at = EXCLUDED.opened_at,
tipo = EXCLUDED.tipo 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; 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; let callerName, callerEmail, ramal, telefone, sysId, shortDescription, state, description, locationId, locationName, openedAt;
if (typeTicket === 'incidente') { if (typeTicket === 'incidente') {
callerName = ticketData.caller_id?.display_value; 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'] || {}; const emailObject = ticketData['caller_id.email'] || {};
callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null; callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null;
ramal = null; // Incidentes não têm essa variável por padrão ramal = null; // Incidentes não têm essa variável por padrão
@ -59,7 +52,6 @@ class TicketSnModel {
shortDescription = ticketData.short_description || null; shortDescription = ticketData.short_description || null;
state = ticketData.state || null; state = ticketData.state || null;
description = ticketData.description || 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; const locationLink = ticketData.location?.link;
locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null; locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null;
locationName = ticketData.location?.display_value || null; locationName = ticketData.location?.display_value || null;
@ -89,7 +81,7 @@ class TicketSnModel {
try { try {
const result = await pool.query(query, values); 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) { if (wasInserted) {
logInfo(`✅ NOVO ticket salvo! ID: ${result.rows[0].id}`, { 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() { static async getPendingTickets() {
try { try {
const query = ` const query = `
@ -149,7 +140,6 @@ class TicketSnModel {
} }
} }
// Busca um ticket pelo número
static async findByTicketNumber(ticketNumber) { static async findByTicketNumber(ticketNumber) {
try { try {
const query = ` const query = `
@ -166,8 +156,6 @@ class TicketSnModel {
} }
} }
// Busca o status de um ticket pelo ID
static async getStatus(ticketId) { static async getStatus(ticketId) {
try { try {
const query = ` const query = `
@ -181,9 +169,6 @@ class TicketSnModel {
} }
} }
// Busca um ticket pelo ID
static async findById(ticketId) { static async findById(ticketId) {
try { try {
const query = ` const query = `
@ -200,24 +185,20 @@ class TicketSnModel {
} }
} }
// Atualiza os dados de um ticket
static async updateTicket(ticketId, updateData) { static async updateTicket(ticketId, updateData) {
try { try {
const fields = []; const fields = [];
const values = []; const values = [];
let paramCount = 1; let paramCount = 1;
// Construir dinamicamente a query baseada nos campos fornecidos
for (const [key, value] of Object.entries(updateData)) { for (const [key, value] of Object.entries(updateData)) {
fields.push(`${key} = $${paramCount}`); fields.push(`${key} = $${paramCount}`);
values.push(value); values.push(value);
paramCount++; paramCount++;
} }
// Adicionar updated_at
fields.push(`updated_at = NOW()`); fields.push(`updated_at = NOW()`);
// Adicionar o ID no final dos valores
values.push(ticketId); values.push(ticketId);
const query = ` const query = `
@ -247,12 +228,13 @@ class TicketSnModel {
/** /**
* @module 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. * @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.
* Inclui funcionalidades para salvar um ticket, buscar tickets pendentes para sincronização com o GLPI, * Manter uma cópia local otimiza as consultas e reduz a dependência de chamadas de API repetitivas ao ServiceNow.
* e buscar/atualizar tickets específicos.
* *
* A função saveTicket insere ou atualiza um ticket na tabela tickets_sn. * Principais Funcionalidades:
* A função getPendingTickets busca tickets que ainda não foram sincronizados com o GLPI (não possuem registro em ticket_sync) * - `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.
* e ignora os tickets com status 'Encerrado' ou 'Encerrado - Omitido'. * - `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; module.exports = TicketSnModel;

View File

@ -1,15 +1,12 @@
// src/models/ticketSyncModel.js
const { query } = require('winston'); const { query } = require('winston');
const pool = require('../data/database'); const pool = require('../data/database');
const { logError } = require('../utils/logger'); const { logError } = require('../utils/logger');
class TicketSyncModel { class TicketSyncModel {
// Cria um registro de sincronização para um ticket
static async createSyncRecord(data) { static async createSyncRecord(data) {
try { try {
const query = `INSERT INTO ticket_sync ( const query = `INSERT INTO ticket_sync (
sn_ticket_id, sn_ticket_id,
glpi_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) { static async getTicketsToSync(glpi_sync_status, sn_sync_status) {
try { try {
const query = 'SELECT id, glpi_ticket_id, sn_ticket_id FROM ticket_sync WHERE glpi_sync_status = $1 AND sn_sync_status = $2'; 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) { static async updateSourceLast(snTicketId, source) {
try { try {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; 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) { static async updateStatus(ticketSyncId, glpiStatus, snStatus) {
try { try {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2 WHERE sn_ticket_id = $3'; 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; throw error;
} }
} }
// Atualiza o status de sincronização de um ticket e o source last
static async updateStatusAndSourceLast(ticketSyncId, glpiStatus, snStatus, source_last) { static async updateStatusAndSourceLast(ticketSyncId, glpiStatus, snStatus, source_last) {
try { try {
@ -90,7 +83,6 @@ class TicketSyncModel {
} }
} }
// Atualiza apenas o source_last de um ticket
static async updateSourceLast(snTicketId, source) { static async updateSourceLast(snTicketId, source) {
try { try {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2'; 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) { static async updateStatusAndGlpiTicket(ticketSyncId, glpiStatus, snStatus, glpiTicketId) {
try { try {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2, glpi_ticket_id = $4 WHERE sn_ticket_id = $3'; 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) { static async getIdSyncByGlpiId(glpiTicketId) {
try { try {
const query = 'SELECT id FROM ticket_sync WHERE glpi_ticket_id = $1'; 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) { static async getBySysId(sysId) {
try { try {
const query = ` const query = `
@ -140,7 +128,6 @@ class TicketSyncModel {
} }
} }
// Buscar o ID de sincronização baseado no sn_ticket_id
static async getIdSyncBySnId(snTicketId) { static async getIdSyncBySnId(snTicketId) {
try { try {
const query = 'SELECT id FROM ticket_sync WHERE sn_ticket_id = $1'; const query = 'SELECT id FROM ticket_sync WHERE sn_ticket_id = $1';
@ -151,6 +138,7 @@ class TicketSyncModel {
throw error; throw error;
} }
} }
static async getByTicketNumber(ticketNumber) { static async getByTicketNumber(ticketNumber) {
try { try {
const query = 'SELECT * FROM ticket_sync WHERE sn_ticket_id = $1'; 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() { static async getTicketsSynced() {
try { try {
const query = ` const query = `
@ -184,7 +171,6 @@ class TicketSyncModel {
} }
} }
// Buscar tickets que estão ativos para monitoramento (synced ou solved)
static async getTicketsToMonitor() { static async getTicketsToMonitor() {
try { try {
const query = ` const query = `
@ -208,7 +194,6 @@ class TicketSyncModel {
} }
} }
// Buscar tickets que precisam ter o fechamento sincronizado
static async getTicketsPendingSolve() { static async getTicketsPendingSolve() {
try { try {
const query = ` const query = `
@ -231,7 +216,6 @@ class TicketSyncModel {
} }
} }
// Busca todos os tickets que estão em algum estado de erro
static async getTicketsInErrorState() { static async getTicketsInErrorState() {
try { try {
const query = ` const query = `
@ -247,7 +231,6 @@ class TicketSyncModel {
} }
} }
// Atualiza os timestamps de última sincronização
static async updateLastSync(snTicketId, system) { static async updateLastSync(snTicketId, system) {
try { try {
let fieldToUpdate; let fieldToUpdate;
@ -256,17 +239,15 @@ class TicketSyncModel {
} else if (system === 'glpi') { } else if (system === 'glpi') {
fieldToUpdate = 'last_glpi_sync'; fieldToUpdate = 'last_glpi_sync';
} else { } 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`; const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`;
await pool.query(query, [snTicketId]); await pool.query(query, [snTicketId]);
} catch (error) { } catch (error) {
logError(`${error} ❌ Erro ao atualizar last_sync para o ID: ${snTicketId}`); 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) { static async updateStatusByGlpiId(glpiTicketId, snStatus, glpiStatus) {
try { try {
const query = 'UPDATE ticket_sync SET sn_sync_status = $1, glpi_sync_status = $2 WHERE glpi_ticket_id = $3'; 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 * @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. * @description Este módulo é a camada de acesso à tabela `ticket_sync`, que atua como o cérebro do processo de sincronização.
* Inclui funcionalidades para buscar tickets para sincronização, atualizar o status de sincronização * Ele armazena o estado de cada ticket em ambos os sistemas (GLPI e ServiceNow) e controla o fluxo de atualizações.
* e buscar o ID de sincronização baseado no glpi_ticket_id.
* *
* A função getTicketsToSync busca todos os tickets que possuem IDs tanto no GLPI quanto no ServiceNow. * Principais Funcionalidades:
* A função updateStatus atualiza os status de sincronização do GLPI e do ServiceNow para um ticket específico. * - **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').
* A função getIdSyncByGlpiId retorna o ID de sincronização baseado no glpi_ticket_id. * - **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).
* Essas funcionalidades são essenciais para manter a consistência e o rastreamento dos tickets entre os dois sistemas. * - **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.
*/ */

View File

@ -1,4 +1,3 @@
// src/models/ticketUpdateModel.js
const ticketSyncModel = require('./ticketSyncModel'); const ticketSyncModel = require('./ticketSyncModel');
const pool = require('../data/database'); const pool = require('../data/database');
const { logError } = require('../utils/logger'); const { logError } = require('../utils/logger');
@ -23,7 +22,7 @@ class TicketUpdateModel {
`; `;
const values = [ const values = [
updateData.ticket_sync_id, updateData.ticket_sync_id,
updateData.update_type, // Corrigido para usar o tipo de atualização dinâmico updateData.update_type,
updateData.source_system, updateData.source_system,
updateData.content, updateData.content,
updateData.author, updateData.author,
@ -103,4 +102,17 @@ class TicketUpdateModel {
} }
} }
} }
module.exports = TicketUpdateModel;
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) 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.
*/

View File

@ -11,8 +11,17 @@ import pandas as pd
from datetime import timedelta from datetime import timedelta
# Carrega as variáveis do .env # 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 # Configuração de logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,

View File

@ -1,4 +1,3 @@
// src/services/glpiCommentService.js
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
@ -8,11 +7,9 @@ const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./serv
const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
const { log } = require('winston'); const { log } = require('winston');
// Sincroniza comentários do GLPI para o Banco de Dados local
const syncCommentsGlpitoSN = async (ticket) => { const syncCommentsGlpitoSN = async (ticket) => {
try { try {
logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...'); logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...');
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id); const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
if (comments.length === 0) { if (comments.length === 0) {
@ -30,15 +27,11 @@ const syncCommentsGlpitoSN = async (ticket) => {
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized); const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized);
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id); const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
// Caso 1: já sincronizado
if (existingComment && existsInSN) { if (existingComment && existsInSN) {
logInfo(`🔁 Comentário ${comment.id} já sincronizado.`); logInfo(`🔁 Comentário ${comment.id} já sincronizado.`);
// Não muda o source_last aqui, pois não houve nova sincronização
continue; continue;
} }
// Caso 2: está no SN mas não no banco
if (!existingComment && existsInSN) { if (!existingComment && existsInSN) {
await TicketUpdateModel.insert({ await TicketUpdateModel.insert({
ticket_sync_id: syncId, ticket_sync_id: syncId,
@ -50,11 +43,9 @@ const syncCommentsGlpitoSN = async (ticket) => {
source_id: comment.id, source_id: comment.id,
destiny_id: existsInSN.destiny_id destiny_id: existsInSN.destiny_id
}); });
// Não muda o source_last aqui
continue; continue;
} }
// Caso 3: existe no banco mas falta no SN
if (existingComment && !existsInSN) { if (existingComment && !existsInSN) {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_sync'); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_sync');
const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized);
@ -63,7 +54,6 @@ const syncCommentsGlpitoSN = async (ticket) => {
continue; continue;
} }
// Caso 4: não existe em nenhum lugar → sincroniza ambos
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync'); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync');
const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized); const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized);
await TicketUpdateModel.insert({ await TicketUpdateModel.insert({
@ -76,8 +66,6 @@ const syncCommentsGlpitoSN = async (ticket) => {
source_id: comment.id, source_id: comment.id,
destiny_id: snCommentId 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') { if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI');
} }
@ -101,8 +89,7 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
for (const comment of comments) { for (const comment of comments) {
try { try {
const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id); const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
// Passa o bastão para o SN, mas SOMENTE se o ticket não estiver já resolvido ou fechado.
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') { if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
} }
@ -122,18 +109,20 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
} }
}; };
/** /**
* @module glpiCommentService * @module glpiCommentService
* @description Este módulo fornece serviços para sincronizar comentários do GLPI para o banco de dados local. * @description Este serviço orquestra a sincronização de comentários (follow-ups/notas) entre GLPI e ServiceNow.
* 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 * Principais Funcionalidades:
* Utiliza os modelos TicketSnModel, TicketGlpiModel, TicketSyncModel e TicketUpdateModel para interagir com o banco de dados. * - `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:
* Atualiza o status de sincronização após cada comentário processado. * 1. ** Sincronizado**: O comentário existe no banco local e no ServiceNow. Nenhuma ação é necessária.
* Inclui logging detalhado para monitorar o progresso e identificar erros durante o processo de sincronização. * 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 ( 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 = { module.exports = {

View File

@ -1,4 +1,3 @@
// src/services/glpiService.js
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
@ -6,12 +5,9 @@ const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
const { addWorkNoteToServiceNow } = require('./servicenowService'); const { addWorkNoteToServiceNow } = require('./servicenowService');
// Método principal que o controller vai chamar
const syncTicketsToGlpi = async () => { const syncTicketsToGlpi = async () => {
try { try {
logInfo('🔄 Iniciando sincronização para GLPI...'); logInfo('🔄 Iniciando sincronização para GLPI...');
// 1. Buscar tickets pendentes
const pendingTickets = await TicketSnModel.getPendingTickets(); const pendingTickets = await TicketSnModel.getPendingTickets();
if (pendingTickets.length === 0) { if (pendingTickets.length === 0) {
@ -21,7 +17,6 @@ const syncTicketsToGlpi = async () => {
logInfo(`📋 Encontrados ${pendingTickets.length} tickets para sincronizar`); logInfo(`📋 Encontrados ${pendingTickets.length} tickets para sincronizar`);
// 2. Processar cada ticket
for (const ticket of pendingTickets) { for (const ticket of pendingTickets) {
await processSingleTicket(ticket); await processSingleTicket(ticket);
} }
@ -34,42 +29,34 @@ const syncTicketsToGlpi = async () => {
} }
}; };
// Processar um ticket individual
const processSingleTicket = async (ticket) => { const processSingleTicket = async (ticket) => {
try { try {
logInfo(`🔍 Processando ticket: ${ticket.ticket_number}`); 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) { if (ticket.glpi_ticket_id) {
logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`); logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`);
await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced'); await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced');
return; 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); 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) { if (existingTicket) {
logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${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); 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.`); 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); 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); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`); logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
return; // Adiciona este return para evitar a criação de um novo ticket return;
} }
// 3. Converter dados para formato GLPI
const glpiData = { const glpiData = {
ticket_number: ticket.ticket_number, ticket_number: ticket.ticket_number,
short_description: ticket.short_description, short_description: ticket.short_description,
@ -87,12 +74,8 @@ const processSingleTicket = async (ticket) => {
updated_at: ticket.updated_at 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() === '')) { 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.`); 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'); const scItemOptionDescription = await require('./servicenowService').fetchScItemOptionValue(ticket.sys_id, 'justificativa');
if (scItemOptionDescription) { if (scItemOptionDescription) {
glpiData.description = scItemOptionDescription; glpiData.description = scItemOptionDescription;
@ -102,19 +85,14 @@ const processSingleTicket = async (ticket) => {
} }
} }
// 3. Inserir no GLPI
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData); const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
// 4. Atualizar registro de sincronização
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId); await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId);
logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`); logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`);
} catch (error) { } catch (error) {
logError(error, `❌ Erro no ticket ${ticket.ticket_number}`); logError(error, `❌ Erro no ticket ${ticket.ticket_number}`);
// Se temos um registro de sync, atualizar o status para error
try { try {
await TicketSyncModel.updateStatus(ticket.id, 'error', 'collected'); await TicketSyncModel.updateStatus(ticket.id, 'error', 'collected');
} catch (innerError) { } catch (innerError) {
@ -127,7 +105,6 @@ const checkGlpiStatusChanges = async () => {
try { try {
logInfo('📡 Verificando mudanças de status nos tickets do GLPI...'); 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(); const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
if (ticketsToMonitor.length === 0) { if (ticketsToMonitor.length === 0) {
@ -136,30 +113,25 @@ const checkGlpiStatusChanges = async () => {
} }
for (const ticket of ticketsToMonitor) { for (const ticket of ticketsToMonitor) {
// 2. Para cada ticket, busca seu status real no GLPI
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); 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 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') { if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`); logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`);
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
// REGRA "FORA DO ESCOPO"
if (solution?.solutiontypes_id === 27) { if (solution?.solutiontypes_id === 27) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType 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.'; 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 addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); 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.'; const sanitizedContent = solution?.content ? stripHTML(solution.content) : 'Chamado solucionado sem notas.';
await TicketUpdateModel.insert({ await TicketUpdateModel.insert({
ticket_sync_id: ticket.id, ticket_sync_id: ticket.id,
@ -170,7 +142,6 @@ const checkGlpiStatusChanges = async () => {
created_at: solution?.date_mod || new Date(), created_at: solution?.date_mod || new Date(),
source_id: `solution-${ticket.glpi_ticket_id}`, 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'); await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'pending_solve', 'GLPI');
} else if (glpiStatus === 6 && currentDbStatus !== 'closed') { } 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); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status);
} else if (glpiStatus < 5 && glpiStatusAsString !== currentDbStatus && ticket.source_last !== 'GLPI') { } 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}'.`); 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'); await TicketSyncModel.updateSourceLast(ticket.sn_ticket_id, 'GLPI');
} }
@ -192,18 +162,16 @@ const closeTicketInGlpi = async (ticket) => {
try { try {
logInfo(`🔒 Iniciando fechamento do chamado GLPI ${ticket.glpi_ticket_id}...`); 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 closeMessageRecord = await TicketUpdateModel.getLatestUpdate(ticket.id, 'servicenow');
const solutionContent = closeMessageRecord?.content || 'Chamado encerrado via integração com o 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 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.`); logInfo(`✅ Chamado ${ticket.glpi_ticket_id} solucionado com sucesso no GLPI.`);
} catch (error) { } catch (error) {
logError(error, `🚨Falha ao fechar chamado no GLPI: ${ticket.glpi_ticket_id}`); 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, syncTicketsToGlpi,
checkGlpiStatusChanges, checkGlpiStatusChanges,
closeTicketInGlpi closeTicketInGlpi
}; };
/**
* @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 existe no GLPI para evitar duplicatas. Se não existir, formata os dados (incluindo o enriquecimento da descrição para requisições) e chama o `TicketGlpiModel` para criar o ticket. Se existir, apenas atualiza o registro de sincronização.
* - `checkGlpiStatusChanges()`: (Atualmente não utilizado no fluxo principal, sua lógica foi integrada ao `processStatusController`) Função projetada para detectar proativamente mudanças de status no GLPI.
* - `closeTicketInGlpi(ticket)`: Orquestra o processo de fechamento de um ticket no GLPI. Ele busca a nota de resolução vinda do ServiceNow e a utiliza para adicionar uma solução no GLPI antes de mudar o status para "Solucionado".
*/

View File

@ -1,54 +1,47 @@
// src/services/servicenowService.js // src/services/servicenowService.js
const axios = require('axios'); const axios = require('axios');
const apiConfig = require('../../config/apiConfig'); const apiConfig = require('../../config/apiConfig');
const { stripHTML } = require('../utils/commentSanitizer');
const TicketSnModel = require('../models/ticketSnModel'); const TicketSnModel = require('../models/ticketSnModel');
const { logInfo, logError, logSync } = require('../utils/logger'); const { logInfo, logError, logSync } = require('../utils/logger');
const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel'); const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston'); const { log } = require('winston');
// Função para buscar incidentes do ServiceNow
const fetchTicketsFromServiceNow = async (watermark) => { const fetchTicketsFromServiceNow = async (watermark) => {
try { try {
// A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date.
const watermarkDate = new Date(watermark); 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());
const watermarkBRT = new Date(watermarkDate.getTime()); // Se precisar de ajuste de fuso, faça aqui.
const formattedWatermark = watermarkBRT.toISOString().replace('T', ' ').substring(0, 19); 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 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 baseUrl = apiConfig.snTableIncidentConfig.baseUrl.replace(/\/$/, "");
const url = `${apiConfig.snTableIncidentConfig.baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; 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, { const ticketsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth auth: apiConfig.servicenowAuthentication.auth
}); });
return ticketsResponse.data.result || []; return ticketsResponse.data.result || [];
} catch (error) { } catch (error) {
logError(error, 'SERVICE NOW Tickets'); 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) => { const fetchRequestsFromServiceNow = async (watermark) => {
try { try {
// A tabela de requisições (sc_req_item) não possui o campo 'active'. A query deve ser apenas pela data. const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${watermark}`;
// Construímos a query completa aqui, combinando o grupo de atribuição com a 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 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 baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, ""); 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 url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const requestsResponse = await axios.get(url, { const requestsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth auth: apiConfig.servicenowAuthentication.auth
}); });
return requestsResponse.data.result || []; return requestsResponse.data.result || [];
} catch (error) { } catch (error) {
logError(error, 'SERVICE NOW Requests'); 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 // 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 // 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) => { const fetchCommentsFromServiceNow = async (ticket) => {
try { try {
@ -291,8 +283,8 @@ const fetchCommentsFromServiceNow = async (ticket) => {
return []; return [];
} }
}; };
// Adiciona no final do arquivo src/services/servicenowService.js // Adiciona no final do arquivo src/services/servicenowService.js
const { stripHTML } = require('../utils/commentSanitizer');
const fetchAndProcessClosedTicketsFromSN = async () => { const fetchAndProcessClosedTicketsFromSN = async () => {
try { try {
logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...'); 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) => { const updateStatusInServiceNow = async (snTicketId, state) => {
// Mapeamento de status de string para código numérico do ServiceNow // Mapeamento de status de string para código numérico do ServiceNow
const stateMap = { const stateMap = {
@ -446,10 +436,6 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`, work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`,
}; };
console.log(url);
console.log(payload);
} else { } else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);

View File

@ -1,13 +1,8 @@
// src/utils/commentSanitizer.js
const { logInfo, logWarning } = require('./logger'); const { logInfo, logWarning } = require('./logger');
/**
* Remove HTML tags e converte entidades HTML - CORRIGIDO
*/
const stripHTML = (html) => { const stripHTML = (html) => {
if (!html) return ''; if (!html) return '';
// PRIMEIRO: converte entidades HTML para caracteres normais
let cleaned = html let cleaned = html
.replace(/&#60;/g, '<') // &#60; para < .replace(/&#60;/g, '<') // &#60; para <
.replace(/&#62;/g, '>') // &#62; para > .replace(/&#62;/g, '>') // &#62; para >
@ -18,7 +13,6 @@ const stripHTML = (html) => {
.replace(/&amp;/g, '&') // &amp; para & .replace(/&amp;/g, '&') // &amp; para &
.replace(/&quot;/g, '"'); // &quot; para " .replace(/&quot;/g, '"'); // &quot; para "
// DEPOIS: remove tags HTML
cleaned = cleaned cleaned = cleaned
.replace(/<br\s*\/?>/gi, '\n') // <br> para quebra de linha .replace(/<br\s*\/?>/gi, '\n') // <br> para quebra de linha
.replace(/<p>/gi, '') // Remove <p> .replace(/<p>/gi, '') // Remove <p>
@ -39,33 +33,24 @@ const stripHTML = (html) => {
return cleaned; return cleaned;
}; };
/**
* Detecta e trata imagens do GLPI
*/
const handleImages = (content) => { const handleImages = (content) => {
const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi; const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi;
const hasImages = imgRegex.test(content); const hasImages = imgRegex.test(content);
if (hasImages) { if (hasImages) {
logWarning('📷 Imagem detectada no comentário GLPI'); logWarning('📷 Imagem detectada no comentário GLPI');
// Remove toda a tag de imagem mas mantém o texto around
return content.replace(/<a[^>]*>.*?<img[^>]*>.*?<\/a>/gi, '[IMAGEM ANEXA NO GLPI]'); return content.replace(/<a[^>]*>.*?<img[^>]*>.*?<\/a>/gi, '[IMAGEM ANEXA NO GLPI]');
} }
return content; return content;
}; };
/**
* Limpa metadados JSON do GLPI
*/
const cleanJSONMetadata = (content) => { const cleanJSONMetadata = (content) => {
if (typeof content === 'string' && content.includes('{id=') && content.includes('content=')) { if (typeof content === 'string' && content.includes('{id=') && content.includes('content=')) {
try { try {
// Extrai o conteúdo do campo content= do objeto
const contentMatch = content.match(/content=([^,]+),/); const contentMatch = content.match(/content=([^,]+),/);
if (contentMatch && contentMatch[1]) { if (contentMatch && contentMatch[1]) {
let extracted = contentMatch[1].trim(); let extracted = contentMatch[1].trim();
// Remove aspas se existirem
extracted = extracted.replace(/^['"]|['"]$/g, ''); extracted = extracted.replace(/^['"]|['"]$/g, '');
return extracted; return extracted;
} }
@ -76,27 +61,20 @@ const cleanJSONMetadata = (content) => {
return content; return content;
}; };
/**
* Função principal de sanitização
*/
const sanitizeGLPIComment = (commentObj) => { const sanitizeGLPIComment = (commentObj) => {
if (!commentObj || typeof commentObj !== 'object') { if (!commentObj || typeof commentObj !== 'object') {
logWarning('Comentário inválido recebido', { comment: commentObj }); logWarning('Comentário inválido recebido', { comment: commentObj });
return ''; return '';
} }
// Pega o conteúdo do objeto comment
let content = commentObj.content || ''; let content = commentObj.content || '';
// Se for string vazia, retorna vazio
if (!content) return ''; if (!content) return '';
// Aplica as limpezas em sequência
content = cleanJSONMetadata(content.toString()); content = cleanJSONMetadata(content.toString());
content = handleImages(content); content = handleImages(content);
content = stripHTML(content); content = stripHTML(content);
// Log para debugging
if (content !== commentObj.content) { if (content !== commentObj.content) {
logInfo('🔧 Comentário sanitizado', { logInfo('🔧 Comentário sanitizado', {
original: commentObj.content.substring(0, 100) + '...', 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. * @module commentSanitizer
* Ele remove tags HTML, trata imagens e limpa metadados JSON específicos do GLPI. * @description Este módulo utilitário é especializado em limpar e formatar o conteúdo dos comentários (follow-ups) vindos do GLPI.
* O objetivo é garantir que os comentários sejam legíveis e seguros para exibição. * 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 principais: *
* - sanitizeGLPIComment: Função principal que aplica todas as sanitizações. * Funções:
* - stripHTML: Remove tags HTML e converte algumas entidades para texto simples. * - `sanitizeGLPIComment(commentObj)`: A função principal que orquestra o processo de limpeza.
* - handleImages: Detecta imagens e substitui por um marcador de imagem. * - `stripHTML(html)`: Remove a maioria das tags HTML, convertendo algumas (como `<br>`, `<p>`, `<strong>`) em marcações de texto simples (quebras de linha, asteriscos).
* - cleanJSONMetadata: Remove metadados JSON do GLPI, extraindo apenas o conteúdo relevante. * - `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]", 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.
* Logs são gerados para ajudar no debugging e monitoramento do processo de sanitização.
*/ */
module.exports = { module.exports = {
@ -127,6 +104,3 @@ module.exports = {
handleImages, handleImages,
cleanJSONMetadata cleanJSONMetadata
}; };