Initial commit: sap-legacy-webhook-emulator
Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para clientes que aun no estan en esa version: captura eventos via SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1 y despacha CloudEvents al mismo endpoint que el messenger real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
e1c8ee9015
42 files changed
+4042
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import type { ServiceLayerClient } from '../service-layer-client.ts';
|
||||
import { generateCode } from '../lib/codes.ts';
|
||||
import { hhmmss } from '../lib/timestamps.ts';
|
||||
import { userTableEntity } from '../lib/service-layer-paths.ts';
|
||||
|
||||
const ENTITY = userTableEntity('EVN1');
|
||||
|
||||
export interface Evn1Row {
|
||||
code: string;
|
||||
eventId: string;
|
||||
webhookId: string;
|
||||
status: string;
|
||||
retry: number;
|
||||
}
|
||||
|
||||
function toRow(raw: Record<string, unknown>): Evn1Row {
|
||||
return {
|
||||
code: raw.Code as string,
|
||||
eventId: raw.U_EventID as string,
|
||||
webhookId: raw.U_WebhookID as string,
|
||||
status: raw.U_Status as string,
|
||||
retry: (raw.U_Retry as number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findExisting(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<Evn1Row | null> {
|
||||
const filter = encodeURIComponent(`U_EventID eq '${eventId}' and U_WebhookID eq '${webhookId}'`);
|
||||
const res = await client.get(`/${ENTITY}?$filter=${filter}`);
|
||||
const rows = (res.data?.value as Record<string, unknown>[]) ?? [];
|
||||
return rows.length > 0 ? toRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function create(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<void> {
|
||||
await client.post(`/${ENTITY}`, {
|
||||
Code: generateCode(),
|
||||
Name: `${eventId}/${webhookId}`,
|
||||
U_EventID: eventId,
|
||||
U_WebhookID: webhookId,
|
||||
U_Status: 'F',
|
||||
U_Retry: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPending(client: ServiceLayerClient): Promise<Evn1Row[]> {
|
||||
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'F'")}`);
|
||||
return rows.map(toRow);
|
||||
}
|
||||
|
||||
export async function listForEvent(client: ServiceLayerClient, eventId: string): Promise<Evn1Row[]> {
|
||||
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent(`U_EventID eq '${eventId}'`)}`);
|
||||
return rows.map(toRow);
|
||||
}
|
||||
|
||||
export async function markDelivered(client: ServiceLayerClient, code: string, returnCode: number): Promise<void> {
|
||||
const now = new Date();
|
||||
await client.patch(`/${ENTITY}('${code}')`, {
|
||||
U_Status: 'D',
|
||||
U_ReturnCode: returnCode,
|
||||
U_LastSentDT: now.toISOString(),
|
||||
U_LastSentTM: hhmmss(now),
|
||||
});
|
||||
}
|
||||
|
||||
export async function markFailedAttempt(client: ServiceLayerClient, row: Evn1Row, returnCode: number): Promise<void> {
|
||||
const now = new Date();
|
||||
await client.patch(`/${ENTITY}('${row.code}')`, {
|
||||
U_Retry: row.retry + 1,
|
||||
U_ReturnCode: returnCode,
|
||||
U_LastSentDT: now.toISOString(),
|
||||
U_LastSentTM: hhmmss(now),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ServiceLayerClient } from '../service-layer-client.ts';
|
||||
import { userTableEntity } from '../lib/service-layer-paths.ts';
|
||||
|
||||
const ENTITY = userTableEntity('OEVN');
|
||||
|
||||
export interface OevnRow {
|
||||
code: string;
|
||||
sourceDb: string;
|
||||
status: string;
|
||||
objectType: string;
|
||||
transType: string;
|
||||
fieldNames: string;
|
||||
fieldValue: string;
|
||||
}
|
||||
|
||||
function toRow(raw: Record<string, unknown>): OevnRow {
|
||||
return {
|
||||
code: raw.Code as string,
|
||||
sourceDb: raw.U_SourceDB as string,
|
||||
status: raw.U_Status as string,
|
||||
objectType: raw.U_ObjectType as string,
|
||||
transType: raw.U_TransType as string,
|
||||
fieldNames: raw.U_FieldNames as string,
|
||||
fieldValue: raw.U_FieldValue as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNewEvents(client: ServiceLayerClient): Promise<OevnRow[]> {
|
||||
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'N'")}`);
|
||||
return rows.map(toRow);
|
||||
}
|
||||
|
||||
export async function getEvent(client: ServiceLayerClient, code: string): Promise<OevnRow | null> {
|
||||
const res = await client.get(`/${ENTITY}('${code}')`);
|
||||
return res.status === 200 ? toRow(res.data) : null;
|
||||
}
|
||||
|
||||
export async function deleteEvent(client: ServiceLayerClient, code: string): Promise<void> {
|
||||
await client.delete(`/${ENTITY}('${code}')`);
|
||||
}
|
||||
|
||||
export async function setEventStatus(client: ServiceLayerClient, code: string, status: string): Promise<void> {
|
||||
await client.patch(`/${ENTITY}('${code}')`, { U_Status: status });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SAP emite un `object_type` especial para operaciones sobre cualquier
|
||||
// UserTable simple (no registrada como UDO completo): un código negativo
|
||||
// seguido de un tab y el nombre físico de la tabla (con "@"), en vez de un
|
||||
// código positivo de BoObjectTypes. Confirmado contra Service Layer real:
|
||||
// un DELETE sobre "@OEVN" hecho por el propio dispatcher generó
|
||||
// object_type = "-3\t@OEVN" — es decir, nuestras propias escrituras
|
||||
// (DELETE/PATCH/POST sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer)
|
||||
// producen un evento nuevo describiéndose a sí mismas. Si tratáramos ese
|
||||
// evento como cualquier otro (sin match → borrar), el borrado generaría
|
||||
// otro eco, y así indefinidamente. Por eso estos eventos nunca se tocan:
|
||||
// ni se resuelven, ni se borran — quedan con Status='N' para siempre,
|
||||
// cortando la cascada en el primer nivel. Ver docs/DISENO.md.
|
||||
const OWN_TABLES = new Set(['@OEVC', '@EVC1', '@OEVN', '@EVN1']);
|
||||
|
||||
export function isEchoOfOwnTable(objectType: string): boolean {
|
||||
const [code, tableName] = objectType.split('\t');
|
||||
return code !== undefined && code.startsWith('-') && OWN_TABLES.has(tableName ?? '');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ServiceLayerClient } from '../service-layer-client.ts';
|
||||
import { config } from '../config.ts';
|
||||
import { bizObjectFor } from '../mapping/object-types.ts';
|
||||
import { buildCloudEvent } from '../lib/cloud-event.ts';
|
||||
import * as oevn from './oevn-repository.ts';
|
||||
import * as evn1 from './evn1-repository.ts';
|
||||
import * as subscriptions from '../subscriptions/subscriptions-repository.ts';
|
||||
import { sendBatch, type SendResult } from './send-webhook.ts';
|
||||
import { isEchoOfOwnTable } from './own-table-guard.ts';
|
||||
import { logger } from '../lib/logger.ts';
|
||||
|
||||
// Un tick del dispatcher: registrar eventos nuevos capturados por el SP
|
||||
// (asignarles las suscripciones que les corresponden) y despachar todo lo
|
||||
// que siga pendiente de entrega — nuevo o reintento. Ver docs/DISENO.md,
|
||||
// sección "Dispatcher — lenguaje, arquitectura y flujo".
|
||||
export async function processTick(client: ServiceLayerClient): Promise<void> {
|
||||
const newCount = await registerNewEvents(client);
|
||||
const pendingCount = await sendPendingDeliveries(client);
|
||||
// Siempre deja un rastro, aunque no haya nada para hacer — así un
|
||||
// visualizador de logs muestra que el proceso sigue vivo entre tanda y
|
||||
// tanda de actividad real.
|
||||
logger.info(`[dispatcher] Tick: ${newCount} evento(s) nuevo(s), ${pendingCount} pendiente(s) de entrega.`);
|
||||
}
|
||||
|
||||
async function registerNewEvents(client: ServiceLayerClient): Promise<number> {
|
||||
const newEvents = await oevn.listNewEvents(client);
|
||||
if (newEvents.length === 0) return 0;
|
||||
|
||||
const filters = await subscriptions.listFilters(client);
|
||||
|
||||
for (const event of newEvents) {
|
||||
// Eco de una escritura nuestra sobre @OEVC/@EVC1/@OEVN/@EVN1 (ver
|
||||
// own-table-guard.ts) — nunca se toca, ni se resuelve ni se borra,
|
||||
// para no perpetuar la cascada.
|
||||
if (isEchoOfOwnTable(event.objectType)) continue;
|
||||
|
||||
let bizObject: string;
|
||||
try {
|
||||
bizObject = bizObjectFor(event.objectType);
|
||||
} catch {
|
||||
logger.warn(`[dispatcher] ObjectType desconocido '${event.objectType}' (evento ${event.code}) — se descarta.`);
|
||||
await oevn.deleteEvent(client, event.code);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = filters.filter((f) => f.bizObject === bizObject && f.transType === event.transType);
|
||||
if (matches.length === 0) {
|
||||
logger.info(`[dispatcher] Sin suscripciones para ${bizObject}/${event.transType} (evento ${event.code}) — se descarta.`);
|
||||
await oevn.deleteEvent(client, event.code);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const existing = await evn1.findExisting(client, event.code, match.webhookId);
|
||||
if (!existing) await evn1.create(client, event.code, match.webhookId);
|
||||
}
|
||||
await oevn.setEventStatus(client, event.code, 'P');
|
||||
}
|
||||
return newEvents.length;
|
||||
}
|
||||
|
||||
async function sendPendingDeliveries(client: ServiceLayerClient): Promise<number> {
|
||||
const pending = await evn1.listPending(client);
|
||||
if (pending.length === 0) return 0;
|
||||
|
||||
const activeSubscriptions = await subscriptions.listActiveSubscriptions(client);
|
||||
const byWebhookId = new Map<string, evn1.Evn1Row[]>();
|
||||
|
||||
for (const row of pending) {
|
||||
if (row.retry >= config.retryLimit) {
|
||||
if (activeSubscriptions.has(row.webhookId)) {
|
||||
logger.warn(`[dispatcher] Suscripción ${row.webhookId} superó ${config.retryLimit} reintentos — pasa a State='X'.`);
|
||||
await subscriptions.markExceptional(client, row.webhookId);
|
||||
activeSubscriptions.delete(row.webhookId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!activeSubscriptions.has(row.webhookId)) continue; // pausada / exceptional: no reintentar
|
||||
|
||||
const group = byWebhookId.get(row.webhookId) ?? [];
|
||||
group.push(row);
|
||||
byWebhookId.set(row.webhookId, group);
|
||||
}
|
||||
|
||||
for (const [webhookId, rows] of byWebhookId) {
|
||||
await sendGroup(client, activeSubscriptions.get(webhookId)!, rows);
|
||||
}
|
||||
|
||||
await recomputeEventStatuses(client, [...new Set(pending.map((r) => r.eventId))]);
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
async function sendGroup(
|
||||
client: ServiceLayerClient,
|
||||
subscription: subscriptions.Subscription,
|
||||
rows: evn1.Evn1Row[],
|
||||
): Promise<void> {
|
||||
const events = [];
|
||||
const deliverableRows: evn1.Evn1Row[] = [];
|
||||
for (const row of rows) {
|
||||
const event = await oevn.getEvent(client, row.eventId);
|
||||
if (!event) continue; // evento borrado/inconsistente, se ignora
|
||||
events.push(buildCloudEvent(event));
|
||||
deliverableRows.push(row);
|
||||
}
|
||||
if (events.length === 0) return;
|
||||
|
||||
let result: SendResult;
|
||||
try {
|
||||
result = await sendBatch(subscription, events);
|
||||
} catch (err) {
|
||||
logger.warn(`[dispatcher] Error de red mandando a ${subscription.url}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
result = { ok: false, status: 0, body: '', finalUrl: subscription.url };
|
||||
}
|
||||
|
||||
// "Entregado" aquí significa solo "el receptor contestó 2xx". Se registran
|
||||
// status y respuesta porque un destino asíncrono puede aceptar con 202 y
|
||||
// devolver un id de ejecución: sin ese dato no hay cómo distinguir una
|
||||
// entrega procesada de una encolada que después se descartó.
|
||||
const detail = `HTTP ${result.status}${result.body ? ` ${result.body}` : ''}`;
|
||||
const redirect = result.finalUrl !== subscription.url ? ` [redirigido a ${result.finalUrl}]` : '';
|
||||
|
||||
if (result.ok) {
|
||||
logger.info(
|
||||
`[dispatcher] Entregados ${events.length} evento(s) a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(`[dispatcher] Falló la entrega a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`);
|
||||
}
|
||||
|
||||
for (const row of deliverableRows) {
|
||||
if (result.ok) await evn1.markDelivered(client, row.code, result.status);
|
||||
else await evn1.markFailedAttempt(client, row, result.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function recomputeEventStatuses(client: ServiceLayerClient, eventIds: string[]): Promise<void> {
|
||||
for (const eventId of eventIds) {
|
||||
const rows = await evn1.listForEvent(client, eventId);
|
||||
if (rows.length === 0) continue;
|
||||
const allDelivered = rows.every((r) => r.status === 'D');
|
||||
await oevn.setEventStatus(client, eventId, allDelivered ? 'D' : 'P');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import https from 'node:https';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import type { CloudEvent } from '../lib/cloud-event.ts';
|
||||
import type { Subscription } from '../subscriptions/subscriptions-repository.ts';
|
||||
|
||||
const http = axios.create({
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
// Respuesta cruda recortada. Un 2xx no prueba que el receptor haya
|
||||
// procesado el evento: hay destinos que encolan y devuelven 202 con un id
|
||||
// de ejecución, y sin este dato no hay forma de rastrear qué pasó después.
|
||||
body: string;
|
||||
// URL efectiva tras seguir redirects. Si difiere de subscription.url, el
|
||||
// POST pudo haberse degradado a GET y perdido el body por el camino.
|
||||
finalUrl: string;
|
||||
}
|
||||
|
||||
const MAX_BODY_LOG = 300;
|
||||
|
||||
function summarizeBody(data: unknown): string {
|
||||
const text = typeof data === 'string' ? data : JSON.stringify(data) ?? '';
|
||||
return text.length > MAX_BODY_LOG ? `${text.slice(0, MAX_BODY_LOG)}…` : text;
|
||||
}
|
||||
|
||||
// Mismo esquema de firma que ya implementa sap-webhook-api
|
||||
// (X-B1-Webhook-Signature, HMAC-SHA256 en base64 sobre los bytes crudos del
|
||||
// body) — el AuthCred de una suscripción HMAC es el JSON embebido que usa
|
||||
// SAP internamente: {"Key": "<secreto>", "Algorithm": "hmacsha256"}.
|
||||
export async function sendBatch(subscription: Subscription, events: CloudEvent[]): Promise<SendResult> {
|
||||
const body = JSON.stringify(events);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (subscription.authType === 'H' && subscription.authCred) {
|
||||
const { Key } = JSON.parse(subscription.authCred) as { Key: string };
|
||||
headers['X-B1-Webhook-Signature'] = createHmac('sha256', Key).update(body).digest('base64');
|
||||
}
|
||||
|
||||
const res = await http.post(subscription.url, body, { headers });
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
body: summarizeBody(res.data),
|
||||
finalUrl: (res.request as { res?: { responseUrl?: string } })?.res?.responseUrl ?? subscription.url,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user