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=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
|
||||||
|
|||||||
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:
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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, '<')
|
||||||
|
.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) => {
|
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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user