feat(sync): mapear work_notes SN para tarefas GLPI e ajustar regras/README

- coleta comments e work_notes do SN\n- envia comments como followup e work_notes como tarefa no GLPI\n- usa sys_created_by como author no banco intermediario\n- formata mensagem SN->GLPI com autor no cabecalho\n- aplica regras de resolucao/encerramento SN sem fechar GLPI\n- documenta regras de negocio no README
This commit is contained in:
Rafael Alves Lopes 2026-02-23 16:45:46 -03:00
parent eda8f558fd
commit c21c00075c
5 changed files with 106 additions and 15 deletions

View File

@ -27,6 +27,7 @@ SERVICENOW_SC_ITEM_OPTION_URL=https://caoadev.service-now.com/api/now/table/sc_i
SERVICENOW_DEFAULT_USER=SOTHIS.CAOA SERVICENOW_DEFAULT_USER=SOTHIS.CAOA
SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98 SERVICENOW_DEFAULT_USER_SYSID=b1312fda977a1250501634a6f053af98
SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77 SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=false
# GLPI (banco real) # GLPI (banco real)
GLPI_DB_TYPE=mysql GLPI_DB_TYPE=mysql

View File

@ -10,6 +10,9 @@ Middleware em Node.js para sincronizacao bidirecional de tickets, comentarios e
- Sincroniza comentarios em duas direcoes: - Sincroniza comentarios em duas direcoes:
- ServiceNow -> GLPI - ServiceNow -> GLPI
- GLPI -> ServiceNow - GLPI -> ServiceNow
- No fluxo ServiceNow -> GLPI:
- `comments` viram comentarios (followups) no GLPI.
- `work_notes` viram tarefas no GLPI.
- Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`). - Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`).
- Trata regras de negocio especificas de fechamento/reabertura. - Trata regras de negocio especificas de fechamento/reabertura.
- Reprocessa tickets em estado de erro no inicio de cada ciclo. - Reprocessa tickets em estado de erro no inicio de cada ciclo.
@ -44,6 +47,11 @@ Executado em ordem pelo `main()`:
## Regras de negocio importantes ## Regras de negocio importantes
- Controle de precedencia por `source_last` para reduzir conflito de atualizacao. - Controle de precedencia por `source_last` para reduzir conflito de atualizacao.
- Ao detectar divergencia de status, o bastao nao e invertido para `GLPI` quando a ultima origem valida ja e `SNOW`.
- Encerramento/Resolucao vindo do ServiceNow:
- Nao fecha ticket no GLPI automaticamente.
- Insere nota formatada no GLPI.
- Marca sync como `closed/closed` para remover do monitoramento.
- Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo): - Ticket resolvido no GLPI com `solutiontypes_id = 27` (fora do escopo):
- Nao resolve no SN. - Nao resolve no SN.
- Adiciona `work_note` no SN. - Adiciona `work_note` no SN.
@ -66,7 +74,10 @@ Executado em ordem pelo `main()`:
- Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`. - Regra fora do escopo (`solutiontypes_id = 27`) fecha fluxo local sem resolver no SN e registra `work_note`.
3. Status ServiceNow -> GLPI 3. Status ServiceNow -> GLPI
- Mudancas de estado no SN sao refletidas no GLPI respeitando o estado final de fechado. - Mudancas de estado no SN sao refletidas no GLPI para tickets ativos.
- Para `Resolvido`, `Encerrado` e `Encerrado - Omitido` no SN:
- GLPI recebe nota de encerramento/resolucao.
- Integracao remove ticket do monitoramento (`closed/closed`), sem fechar ticket no GLPI.
- Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao. - Fechamento permanente no GLPI bloqueia reabertura automatica por sincronizacao.
4. Comentarios GLPI -> SN 4. Comentarios GLPI -> SN
@ -75,6 +86,9 @@ Executado em ordem pelo `main()`:
5. Comentarios SN -> GLPI 5. Comentarios SN -> GLPI
- Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI. - Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI.
- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI.
- Campo `author` no banco intermediario usa `sys_created_by` do SN.
- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho.
- IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade. - IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade.
6. Reprocessamento de erro 6. Reprocessamento de erro
@ -114,6 +128,8 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua
- `SERVICENOW_TABLE_REQUEST_URL` - `SERVICENOW_TABLE_REQUEST_URL`
- `SERVICENOW_TABLE_JOURNAL_URL` - `SERVICENOW_TABLE_JOURNAL_URL`
- `SERVICENOW_SC_ITEM_OPTION_URL` - `SERVICENOW_SC_ITEM_OPTION_URL`
- `SERVICENOW_DEFAULT_USER`
- `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL` (`true` por padrao; use `false` para testes locais)
### GLPI (MySQL/MariaDB) ### GLPI (MySQL/MariaDB)
@ -191,7 +207,6 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
## Limitacoes conhecidas (estado atual) ## Limitacoes conhecidas (estado atual)
- Nao ha suite de testes automatizados (`npm test` e placeholder). - Nao ha suite de testes automatizados (`npm test` e placeholder).
- Ha regras hardcoded que deveriam vir de configuracao (ex.: `resolved_by` em fechamento no SN).
- 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).
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento. - Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.

View File

@ -313,6 +313,33 @@ class TicketGlpiModel {
} }
} }
static async insertTask(task, items_id) {
const connection = await glpiPool.getConnection();
try {
// 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(), ?)
`;
const values = [
items_id,
GLPI_DEFAULT_USER_ID,
2,
task.content,
0
];
const [result] = await connection.execute(query, values);
return [{ id: result.insertId }];
} catch(error){
logError(`Erro ao inserir tarefa no GLPI: ${error}`);
throw error;
} finally {
if (connection) connection.release();
}
}
static async getClosedTickets(watermark, limit = 100) { static async getClosedTickets(watermark, limit = 100) {
const connection = await glpiPool.getConnection(); const connection = await glpiPool.getConnection();
try { try {

View File

@ -7,6 +7,24 @@ const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./serv
const { sanitizeGLPIComment } = require('../utils/commentSanitizer'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
const { log } = require('winston'); const { log } = require('winston');
const formatSNUpdateForGlpi = (update) => {
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const author = escapeHtml(update.author || 'ServiceNow');
const content = escapeHtml(update.content || '');
return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">${author}</div>
<div style="color:#4a5568; font-size:12px; white-space: pre-wrap;">
${content}
</div>
</div>`;
};
const syncCommentsGlpitoSN = async (ticket) => { const syncCommentsGlpitoSN = async (ticket) => {
try { try {
logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...'); logInfo('INFO: Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
@ -88,17 +106,24 @@ 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 formattedPayload = {
...comment,
content: formatSNUpdateForGlpi(comment)
};
const isTask = comment.update_type === 'task';
const insertedId = isTask
? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id)
: await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
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');
} }
await TicketUpdateModel.updateDestinyId(comment.source_id, commentId[0].id); await TicketUpdateModel.updateDestinyId(comment.source_id, insertedId[0].id);
logInfo(`OK: Comentario inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`); logInfo(`OK: ${isTask ? 'Tarefa' : 'Comentario'} inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
} catch (error) { } catch (error) {
logError(`ERRO: Erro ao processar comentario do ticket ${ticket.sn_ticket_id}: ${error}`); logError(`ERRO: Erro ao processar atualizacao do ticket ${ticket.sn_ticket_id}: ${error}`);
try { try {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error'); await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error');

View File

@ -9,6 +9,8 @@ const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston'); const { log } = require('winston');
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77'; const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
const fetchTicketsFromServiceNow = async (watermark) => { const fetchTicketsFromServiceNow = async (watermark) => {
try { try {
@ -175,7 +177,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
const fetchCommentsFromServiceNow = async (ticket) => { const fetchCommentsFromServiceNow = async (ticket) => {
try { try {
logInfo(`Iniciando busca de comentarios do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); logInfo(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const sys_id = ticket.sys_id; const sys_id = ticket.sys_id;
@ -189,7 +191,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
while (true) { while (true) {
page++; page++;
const params = { const params = {
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^element=comments`, sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`,
sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id', sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id',
sysparm_limit: pageSize, sysparm_limit: pageSize,
sysparm_offset: offset sysparm_offset: offset
@ -224,14 +226,26 @@ const fetchCommentsFromServiceNow = async (ticket) => {
} }
} }
// Traz apenas os tickets que nao sao do sys_created_by: "SOTHIS.CAOA" // Mantem comportamento legado para comments (evita loop da integracao).
const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA'); // Em ambiente de teste, a regra pode ser desligada por env.
const filteredComments = allComments.filter((entry) => {
if (!shouldIgnoreDefaultUserJournal) {
return true;
}
if (entry.element === 'comments') {
return entry.sys_created_by !== servicenowDefaultUser;
}
if (entry.element === 'work_notes') {
return entry.sys_created_by !== servicenowDefaultUser;
}
return true;
});
if (filteredComments.length === 0) { if (filteredComments.length === 0) {
logInfo(`Nenhum comentario encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); logInfo(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return []; return [];
} }
logInfo(`Encontrado ${filteredComments.length} comentarios para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) logInfo(`Encontrado ${filteredComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
const commentsInserted = []; const commentsInserted = [];
@ -244,13 +258,22 @@ const fetchCommentsFromServiceNow = async (ticket) => {
continue; continue;
} else { } else {
let normalizedContent = comment.value;
if (
comment.element === 'work_notes' &&
typeof normalizedContent === 'string' &&
normalizedContent.includes('Registro alterado por:')
) {
normalizedContent = `Anotacao de trabalho adicionada pelo Service Now (registro automatico).\n\nMensagem original:\n${normalizedContent}`;
}
const commentData = { const commentData = {
ticket_sync_id: ticket.id, ticket_sync_id: ticket.id,
update_type: 'comment', update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow', source_system: 'servicenow',
content: comment.value, content: normalizedContent,
created_at: new Date(comment.sys_created_on), created_at: new Date(comment.sys_created_on),
author: "CAOA", author: comment.sys_created_by || 'ServiceNow',
updated_at: new Date(), updated_at: new Date(),
source_id: comment.sys_id, source_id: comment.sys_id,
destiny_id: null destiny_id: null