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:
parent
eda8f558fd
commit
c21c00075c
@ -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_SYSID=b1312fda977a1250501634a6f053af98
|
||||
SERVICENOW_RESOLVED_BY_SYSID=b131d6ed1b2ba510c5e163923b4bcb77
|
||||
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=false
|
||||
|
||||
# GLPI (banco real)
|
||||
GLPI_DB_TYPE=mysql
|
||||
|
||||
19
README.md
19
README.md
@ -10,6 +10,9 @@ Middleware em Node.js para sincronizacao bidirecional de tickets, comentarios e
|
||||
- Sincroniza comentarios em duas direcoes:
|
||||
- ServiceNow -> GLPI
|
||||
- GLPI -> ServiceNow
|
||||
- No fluxo ServiceNow -> GLPI:
|
||||
- `comments` viram comentarios (followups) no GLPI.
|
||||
- `work_notes` viram tarefas no GLPI.
|
||||
- Sincroniza status em duas direcoes com controle de origem (`source_last`: `SNOW`/`GLPI`).
|
||||
- Trata regras de negocio especificas de fechamento/reabertura.
|
||||
- Reprocessa tickets em estado de erro no inicio de cada ciclo.
|
||||
@ -44,6 +47,11 @@ Executado em ordem pelo `main()`:
|
||||
## Regras de negocio importantes
|
||||
|
||||
- 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):
|
||||
- Nao resolve 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`.
|
||||
|
||||
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.
|
||||
|
||||
4. Comentarios GLPI -> SN
|
||||
@ -75,6 +86,9 @@ Executado em ordem pelo `main()`:
|
||||
|
||||
5. Comentarios SN -> GLPI
|
||||
- Comentarios novos detectados no SN sao persistidos no banco intermediario e enviados ao GLPI.
|
||||
- `work_notes` do SN sao persistidas como `update_type = task` e enviadas como tarefa no GLPI.
|
||||
- Campo `author` no banco intermediario usa `sys_created_by` do SN.
|
||||
- Conteudo enviado ao GLPI e formatado com card visual e autor no cabecalho.
|
||||
- IDs de origem/destino ficam registrados em `ticket_updates` para rastreabilidade.
|
||||
|
||||
6. Reprocessamento de erro
|
||||
@ -114,6 +128,8 @@ Crie `.env.development` e `.env.production` com base em `.env.example` e inclua
|
||||
- `SERVICENOW_TABLE_REQUEST_URL`
|
||||
- `SERVICENOW_TABLE_JOURNAL_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)
|
||||
|
||||
@ -191,7 +207,6 @@ Definicao base em `src/scripts/database/scriptBD.sql`:
|
||||
## Limitacoes conhecidas (estado atual)
|
||||
|
||||
- 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).
|
||||
- Existe logica sensivel de timezone/watermark que merece revisao para evitar reprocesso/perda de evento.
|
||||
|
||||
|
||||
@ -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) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
|
||||
@ -7,6 +7,24 @@ const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./serv
|
||||
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
|
||||
const { log } = require('winston');
|
||||
|
||||
const formatSNUpdateForGlpi = (update) => {
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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) {
|
||||
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');
|
||||
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
|
||||
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) {
|
||||
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 {
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error');
|
||||
|
||||
@ -9,6 +9,8 @@ const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const { log } = require('winston');
|
||||
|
||||
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) => {
|
||||
try {
|
||||
@ -175,7 +177,7 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
|
||||
const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
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;
|
||||
|
||||
@ -189,7 +191,7 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
while (true) {
|
||||
page++;
|
||||
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_limit: pageSize,
|
||||
sysparm_offset: offset
|
||||
@ -224,14 +226,26 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Traz apenas os tickets que nao sao do sys_created_by: "SOTHIS.CAOA"
|
||||
const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA');
|
||||
// Mantem comportamento legado para comments (evita loop da integracao).
|
||||
// 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) {
|
||||
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 [];
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@ -244,13 +258,22 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
continue;
|
||||
} 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 = {
|
||||
ticket_sync_id: ticket.id,
|
||||
update_type: 'comment',
|
||||
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
|
||||
source_system: 'servicenow',
|
||||
content: comment.value,
|
||||
content: normalizedContent,
|
||||
created_at: new Date(comment.sys_created_on),
|
||||
author: "CAOA",
|
||||
author: comment.sys_created_by || 'ServiceNow',
|
||||
updated_at: new Date(),
|
||||
source_id: comment.sys_id,
|
||||
destiny_id: null
|
||||
|
||||
Loading…
Reference in New Issue
Block a user