diff --git a/.env.development b/.env.development deleted file mode 100644 index 4fa1039..0000000 --- a/.env.development +++ /dev/null @@ -1,59 +0,0 @@ -# ========================================= -# DEVELOPMENT CONFIGURATION -# ========================================= -NODE_ENV=development -PORT=3000 - -# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos) -CRON_SCHEDULE='* * * * *' - -# 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 - -# Usuario padrao pra criacao de chamados (ServiceNow) -SERVICENOW_DEFAULT_USER=SOTHIS.CAOA -SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98 -SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77 -SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=false - -# GLPI (banco real) -GLPI_DB_TYPE=mysql -GLPI_DB_HOST=177.73.177.44 -GLPI_DB_PORT=3306 -GLPI_DB_USER=desenvolvimento -GLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W -GLPI_DB_NAME=glpi_data -GLPI_DB_CHARSET=utf8mb4 - -# ID do usuario padrao do GLPI para criacao de tickets e comentarios -GLPI_DEFAULT_USER_ID=11111 -GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa -GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao - -# Banco intermediario (PostgreSQL) -SNGLPI_DB_HOST=10.0.120.75 -SNGLPI_DB_PORT=5432 -SNGLPI_DB_NAME=snglpi-development -SNGLPI_DB_USER=desenvolvimento -SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W - -# CSV de mapeamento -LOCATION_MAPPING_CSV_PATH=\\10.0.121.40\Tecnica\Controle CAOA\ENTIDADE SERVICE NOW\LOCATION_MAPPING.csv - -# Logging -LOG_LEVEL=debug -LOG_RETENTION_DAYS=10 -LOG_DIR=logs diff --git a/.env.example b/.env.example index 10f7f06..1791e5c 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ GLPI_DB_CHARSET=utf8mb4 # ID do usuario padrao do GLPI para criacao de tickets e comentarios GLPI_DEFAULT_USER_ID=1118 +GLPI_DEFAULT_GROUP_ID=30 +GLPI_DEFAULT_ENTITY_ID=127 +GLPI_OOS_SOLUTION_TYPE_ID=27 GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao diff --git a/.gitignore b/.gitignore index 1661cd7..c4c84b1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ config/*.json servicenow_requests.json servicenow_tickets.json location_mapping.log -.env.production +.env* +!.env.example diff --git a/README.md b/README.md index e541fb6..0700047 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Executado em ordem pelo `main()`: - Nao fecha ticket no GLPI automaticamente. - Insere nota formatada no GLPI. - Marca sync como `closed/closed` para remover do monitoramento. -- Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo): +- Ticket resolvido no GLPI com `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID` (fora do escopo): - Nao resolve no SN. - Adiciona `work_note` no SN. - Marca sincronizacao como encerrada/ignorada. @@ -71,7 +71,7 @@ Executado em ordem pelo `main()`: 2. Status GLPI -> ServiceNow - Solucao no GLPI dispara tentativa de resolucao no SN. - Se SN estiver em status bloqueante (`Em Espera` ou `Aguardando Atendimento`), a integracao seta `Em Atendimento` antes da resolucao. -- Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`. +- Regra fora do escopo (`solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`) fecha fluxo local sem resolver no SN e registra `work_note`. 3. Status ServiceNow -> GLPI - Mudancas de estado no SN sao refletidas no GLPI para tickets ativos. @@ -129,6 +129,7 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua - `SERVICENOW_TABLE_JOURNAL_URL` - `SERVICENOW_SC_ITEM_OPTION_URL` - `SERVICENOW_DEFAULT_USER` +- `SERVICENOW_RESOLVED_BY_SYSID` - `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais) ### GLPI (MySQL/MariaDB) @@ -140,6 +141,9 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua - `GLPI_DB_NAME` - `GLPI_DB_CHARSET` - `GLPI_DEFAULT_USER_ID` +- `GLPI_DEFAULT_GROUP_ID` (padrao `30`) +- `GLPI_DEFAULT_ENTITY_ID` (padrao `127`) +- `GLPI_OOS_SOLUTION_TYPE_ID` (padrao `27`) ### Banco intermediario (PostgreSQL) @@ -194,6 +198,16 @@ Arquivo: `src/scripts/python/update_location_mapping.py` - Recria os dados da tabela `location_mapping` com base no CSV e IDs validos em ambos os bancos. - Roda em loop continuo (ideal via PM2 como processo separado). +## Como os dados chegam do ServiceNow + +- Incidentes: consulta na tabela definida por `SERVICENOW_TABLE_INCIDENT_URL`, filtrando por `assignment_group` e `sys_updated_on >= watermark`. +- Requisicoes: consulta na tabela definida por `SERVICENOW_TABLE_REQUEST_URL` com o mesmo filtro. +- Comentarios: consulta na tabela definida por `SERVICENOW_TABLE_JOURNAL_URL` por `element_id` e `element IN (comments, work_notes)`, com paginacao (`limit/offset`). +- Enriquecimento de requisicoes: consulta em `SERVICENOW_SC_ITEM_OPTION_URL` para capturar variaveis como justificativa e telefone. +- Campos principais persistidos localmente: + - Ticket: `number`, `sys_id`, `short_description`, `state`, `description`, `caller/opened_by`, `location`, `opened_at`, `sys_updated_on`. + - Atualizacoes: `sys_id`, `element`, `value`, `sys_created_on`, `sys_created_by`. + ## Estrutura do banco intermediario Definicao base em `src/scripts/database/scriptBD.sql`: @@ -204,10 +218,14 @@ Definicao base em `src/scripts/database/scriptBD.sql`: - `location_mapping` - `sync_control` +Arquivo auxiliar de manutencao/reset (com comandos destrutivos): + +- `src/scripts/database/maintenance_reset.sql` + ## Limitacoes conhecidas (estado atual) - Nao ha suite de testes automatizados (`npm test` e placeholder). -- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). +- Parte do fluxo depende de acesso direto ao banco do GLPI (acoplamento operacional alto). (Utilizar API em nova versão) - Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento. ## Proximas melhorias recomendadas @@ -217,6 +235,7 @@ Definicao base em `src/scripts/database/scriptBD.sql`: - Revisar estrategia de watermark/timezone e idempotencia. - Criar healthcheck/observabilidade (metricas de ciclo, falhas por etapa, tickets processados). - Adicionar validacoes de configuracao na inicializacao (falha rapida quando faltar env obrigatoria). +- Alterar uso de banco do GLPI para a API do GLPI. ## Arquivos principais diff --git a/config/apiConfig.js b/config/apiConfig.js index f47444d..460fc52 100644 --- a/config/apiConfig.js +++ b/config/apiConfig.js @@ -24,11 +24,6 @@ const snIncidentConfig = { baseUrl: process.env.SERVICENOW_INCIDENT_URL, } -const snRequestConfig = { - // A URL base deve ser limpa, sem parâmetros. Os filtros serão adicionados no serviço. - baseUrl: process.env.SERVICENOW_REQUEST_URL, -} - const snTableIncidentConfig = { baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL, } @@ -53,11 +48,10 @@ module.exports = { // configuração do ServiceNow e outras já existentes podem permanecer glpiDbConfig, snIncidentConfig, - snRequestConfig, snTableIncidentConfig, snTableJournalConfig, servicenowAuthentication, snTableRequestConfig, snScItemOptionConfig, assignmentGroup -}; \ No newline at end of file +}; diff --git a/src/controllers/processStatusController.js b/src/controllers/processStatusController.js index a1c3804..5ad2399 100644 --- a/src/controllers/processStatusController.js +++ b/src/controllers/processStatusController.js @@ -3,9 +3,11 @@ const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const { logInfo, logError, logWarning } = require('../utils/logger'); const { stripHTML } = require('../utils/commentSanitizer'); -const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService'); +const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, fetchTicketLiveStatusFromServiceNow } = require('../services/servicenowService'); const { addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService'); +const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27); + const processStatusAndClosureController = async () => { logInfo('Iniciando processamento de status e fechamento de tickets...'); @@ -29,25 +31,50 @@ const processStatusAndClosureController = async () => { logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`); continue; } - const snCurrentStatus = ticketSnDetails.status; + let snCurrentStatus = ticketSnDetails.status; + const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSnDetails.sys_id, ticketSnDetails.tipo); + if (liveSnStatus && liveSnStatus !== snCurrentStatus) { + logInfo(`Status ao vivo do SN para ${ticketSnDetails.ticket_number}: '${snCurrentStatus}' -> '${liveSnStatus}'.`); + snCurrentStatus = liveSnStatus; + await TicketSnModel.updateTicket(ticket.sn_ticket_id, { status: liveSnStatus }); + } + + const snIsFinalState = snCurrentStatus === 'Resolvido' || snCurrentStatus === 'Encerrado' || snCurrentStatus === 'Encerrado - Omitido'; + let effectiveSourceLast = ticket.source_last; + if (snIsFinalState) { + effectiveSourceLast = 'SNOW'; + } // ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI ================= // Primeiro, verificamos se o GLPI mudou de estado por conta própria. const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id); + const glpiIsFinalState = glpiLiveStatus !== null && glpiLiveStatus >= 5; + const glpiWasFinalLocally = ticket.glpi_sync_status === 'solved' || ticket.glpi_sync_status === 'closed'; // Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento') const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus); - // Compara o status real do GLPI com o status que temos do ServiceNow + // Compara o status real do GLPI com o status do SN apenas para observabilidade. + // A direcao da sincronizacao deve respeitar o source_last persistido. if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) { - logInfo(`Mudanca de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. GLPI esta como '${glpiLiveStatusAsString}', SN esta como '${snCurrentStatus}'.`); - // Hotfix: quando houver divergencia real de status, prioriza GLPI para restaurar fluxo GLPI -> SN. - ticket.source_last = 'GLPI'; + logInfo(`Mudanca de status detectada para o ticket ${ticket.glpi_ticket_id}. GLPI='${glpiLiveStatusAsString}', SN='${snCurrentStatus}', source_last='${effectiveSourceLast}'.`); + } + + // Regra de precedencia de finalizacao: + // quando GLPI ja esta finalizado e SN ainda nao, prioriza GLPI->SN + // apenas se o bastao ainda estiver com GLPI. + // Se o bastao estiver com SNOW, permitimos reabertura SN->GLPI. + if ( + glpiIsFinalState && + !snIsFinalState && + (!glpiWasFinalLocally || ticket.source_last === 'GLPI') + ) { + effectiveSourceLast = 'GLPI'; } // ================= LÓGICA SNOW -> GLPI ================= // Se a última atualização veio do ServiceNow, atualiza o GLPI - if (ticket.source_last === 'SNOW') { + if (effectiveSourceLast === 'SNOW') { logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`); // **LÓGICA DE REABERTURA REFINADA** @@ -81,7 +108,7 @@ const processStatusAndClosureController = async () => { } // ================= LÓGICA GLPI -> SNOW ================= // Se a última atualização veio do GLPI, atualiza o ServiceNow - else if (ticket.source_last === 'GLPI') { + else if (effectiveSourceLast === 'GLPI') { logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`); const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos if (glpiCurrentStatus !== null) { @@ -92,9 +119,9 @@ const processStatusAndClosureController = async () => { const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id); // REGRA "FORA DO ESCOPO" - if (solution?.solutiontypes_id === 27) { - logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`); - const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.'; + if (solution?.solutiontypes_id === outOfScopeSolutionTypeId) { + logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: ${outOfScopeSolutionTypeId}).`); + const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.'; await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket não será mais exibido para equipe Sothis, com a justificativa: ${justification}`); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored'); await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id); diff --git a/src/models/ticketGlpiModel.js b/src/models/ticketGlpiModel.js index 3c13625..652b8e4 100644 --- a/src/models/ticketGlpiModel.js +++ b/src/models/ticketGlpiModel.js @@ -4,9 +4,23 @@ const LocationMappingModel = require('./locationMappingModel'); // Carrega o ID do usuário padrão do GLPI a partir das variáveis de ambiente const GLPI_DEFAULT_USER_ID = parseInt(process.env.GLPI_DEFAULT_USER_ID, 10) || 1118; +const GLPI_DEFAULT_GROUP_ID = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30; +const GLPI_DEFAULT_ENTITY_ID = parseInt(process.env.GLPI_DEFAULT_ENTITY_ID, 10) || 127; const GLPI_REQ_DEFAULT_JUSTIFICATIVA = process.env.GLPI_REQ_DEFAULT_JUSTIFICATIVA || 'Chamado aberto por colaborador CAOA sem Justificativa'; const GLPI_REQ_DEFAULT_DESCRIPTION = process.env.GLPI_REQ_DEFAULT_DESCRIPTION || 'Chamado aberto por colaborador CAOA sem Descricao'; +const toMySqlDateTime = (value) => { + if (!value) return new Date(); + if (typeof value === 'string') { + const m = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/); + if (m) return `${m[1]} ${m[2]}`; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +}; + class TicketGlpiModel { static mapStatus(snState) { @@ -91,14 +105,14 @@ class TicketGlpiModel { return { title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado - entityId: entityData?.id || 127 + entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID }; } catch (error) { logError(error, 'Erro ao formatar título do ticket'); const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE'; return { title: `${tipo} - ${ticketData.short_description || 'Sem título'}`, - entityId: 127 + entityId: GLPI_DEFAULT_ENTITY_ID }; } } @@ -188,7 +202,7 @@ class TicketGlpiModel { try { await connection.beginTransaction(); - let entityId = 127; + let entityId = GLPI_DEFAULT_ENTITY_ID; if (ticketData.location_id) { entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId; } else if (ticketData.location_name) { @@ -247,7 +261,7 @@ class TicketGlpiModel { `INSERT INTO glpi_groups_tickets (tickets_id, groups_id, type) VALUES (?, ?, 2)`, - [ticketId, 30] + [ticketId, GLPI_DEFAULT_GROUP_ID] ); await connection.commit(); @@ -301,8 +315,14 @@ class TicketGlpiModel { static async insertComment(comment, items_id) { const connection = await glpiPool.getConnection(); try { - const query = 'INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) VALUES(?, ?, NOW(), ?, ?, NOW(), NOW(), ?)'; - const values = ['Ticket', items_id, GLPI_DEFAULT_USER_ID, comment.content , 1]; + const createdAt = toMySqlDateTime(comment.created_at); + const updatedAt = toMySqlDateTime(comment.updated_at || comment.created_at); + const query = ` + INSERT INTO glpi_itilfollowups + (itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) + VALUES(?, ?, ?, ?, ?, ?, ?, ?) + `; + const values = ['Ticket', items_id, createdAt, GLPI_DEFAULT_USER_ID, comment.content, createdAt, updatedAt, 1]; const [result] = await connection.execute(query, values); return [{ id: result.insertId }]; @@ -316,17 +336,21 @@ class TicketGlpiModel { static async insertTask(task, items_id) { const connection = await glpiPool.getConnection(); try { + const createdAt = toMySqlDateTime(task.created_at); + const updatedAt = toMySqlDateTime(task.updated_at || task.created_at); // Registra work_notes do ServiceNow como tarefa no GLPI. const query = ` INSERT INTO glpi_tickettasks (tickets_id, users_id, state, content, date, date_mod, actiontime) - VALUES (?, ?, ?, ?, NOW(), NOW(), ?) + VALUES (?, ?, ?, ?, ?, ?, ?) `; const values = [ items_id, GLPI_DEFAULT_USER_ID, 2, task.content, + createdAt, + updatedAt, 0 ]; @@ -420,11 +444,10 @@ class TicketGlpiModel { const connection = await glpiPool.getConnection(); try { await connection.beginTransaction(); - - status = this.mapStatus(status); + const mappedStatus = this.mapStatus(status); const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`; - await connection.execute(updateQuery, [status, ticketId]); + await connection.execute(updateQuery, [mappedStatus, ticketId]); await connection.commit(); } catch (error) { diff --git a/src/models/ticketSyncModel.js b/src/models/ticketSyncModel.js index 5f113ff..39b7a60 100644 --- a/src/models/ticketSyncModel.js +++ b/src/models/ticketSyncModel.js @@ -1,4 +1,3 @@ -const { query } = require('winston'); const pool = require('../data/database'); const { logError, logInfo } = require('../utils/logger'); @@ -265,4 +264,4 @@ class TicketSyncModel { */ -module.exports = TicketSyncModel; \ No newline at end of file +module.exports = TicketSyncModel; diff --git a/src/scripts/database/maintenance_reset.sql b/src/scripts/database/maintenance_reset.sql new file mode 100644 index 0000000..1c91e8b --- /dev/null +++ b/src/scripts/database/maintenance_reset.sql @@ -0,0 +1,25 @@ +-- ============================================= +-- MANUTENCAO / RESET CONTROLADO - SN <-> GLPI +-- ============================================= +-- Use este arquivo apenas para manutencao operacional. +-- Nao execute em producao sem backup e janela aprovada. + +BEGIN; + +-- Diagnostico rapido +SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored'; +SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; +SELECT * FROM public.location_mapping; + +-- Limpeza de tickets abertos (mantem chamados encerrados) +DELETE FROM tickets_sn +WHERE status != 'Encerrado' + AND status != 'Encerrado - Omitido'; + +-- Reinicia watermarks para reprocessar coleta completa +UPDATE sync_control +SET last_watermark = '1970-01-01 00:00:00+00', + last_run_status = 'manual_reset', + last_run_at = NOW(); + +COMMIT; diff --git a/src/scripts/database/scriptBD.sql b/src/scripts/database/scriptBD.sql index ad4ad17..0bcb6e8 100644 --- a/src/scripts/database/scriptBD.sql +++ b/src/scripts/database/scriptBD.sql @@ -287,16 +287,6 @@ COMMENT ON COLUMN sync_control.last_run_at IS 'Timestamp da última vez que o jo -- Registro inicial para a marca d'água do GLPI. INSERT INTO sync_control (system_name, last_watermark) VALUES ('glpi', '1970-01-01 00:00:00+00'); -SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored'; -SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; -SELECT * FROM public.location_mapping - -DELETE FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido'; - -UPDATE sync_control -SET last_watermark = '1970-01-01 00:00:00+00'; - -- Migracao para bases existentes: ALTER TABLE tickets_sn ADD COLUMN IF NOT EXISTS justificativa TEXT; - diff --git a/src/services/glpiCommentService.js b/src/services/glpiCommentService.js index 79400b7..aac446e 100644 --- a/src/services/glpiCommentService.js +++ b/src/services/glpiCommentService.js @@ -1,11 +1,32 @@ -const TicketSnModel = require('../models/ticketSnModel'); const TicketGlpiModel = require('../models/ticketGlpiModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const TicketUpdateModel = require('../models/ticketUpdateModel'); const { logInfo, logError } = require('../utils/logger'); const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); -const { log } = require('winston'); + +const toTitleCase = (text) => text + .split(' ') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + +const normalizeAuthorName = (rawAuthor) => { + const source = String(rawAuthor || 'ServiceNow').trim(); + const localPart = source.includes('@') ? source.split('@')[0] : source; + const normalizedRaw = localPart.replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim(); + + if (!normalizedRaw) { + return 'ServiceNow'; + } + + const parts = normalizedRaw.split(' '); + if (parts[0].toLowerCase() === 't' && parts.length > 1) { + return `Tecnico ${toTitleCase(parts.slice(1).join(' '))}`; + } + + return toTitleCase(normalizedRaw); +}; const formatSNUpdateForGlpi = (update) => { const escapeHtml = (value) => String(value || '') @@ -13,8 +34,30 @@ const formatSNUpdateForGlpi = (update) => { .replace(//g, '>'); - const author = escapeHtml(update.author || 'ServiceNow'); + const normalizedAuthor = normalizeAuthorName(update.author); + const author = escapeHtml(normalizedAuthor); const content = escapeHtml(update.content || ''); + const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || '')); + const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || '')); + + if (isAutomaticTask) { + return ` +