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:
GeorgeandClaude Sonnet 5 committed 2026-09-16 08:30:45 -05:00
commit e1c8ee9015
42 files changed
+4042

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import 'dotenv/config';
export interface ServiceLayerTarget {
baseUrl: string;
companyDb: string;
username: string;
password: string;
}
export const config = {
serviceLayer: {
baseUrl: requireEnv('SL_BASE_URL'),
companyDb: requireEnv('SL_COMPANY_DB'),
username: requireEnv('SL_USERNAME'),
password: requireEnv('SL_PASSWORD'),
} satisfies ServiceLayerTarget,
// El destino y la autenticación de cada envío salen de @OEVC (WebhookURL/
// AuthType/AuthCred), no de config — una suscripción puede apuntar a
// cualquier URL, no solo a una instancia fija de sap-webhook-api.
pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30),
retryLimit: Number(process.env.RETRY_LIMIT ?? 5),
};
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Falta la variable de entorno ${name}`);
return value;
}
+72
View File
@@ -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),
});
}
+44
View File
@@ -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 });
}
+18
View File
@@ -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 ?? '');
}
+144
View File
@@ -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');
}
}
+51
View File
@@ -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,
};
}
+27
View File
@@ -0,0 +1,27 @@
import cron from 'node-cron';
import { config } from './config.ts';
import { ServiceLayerClient } from './service-layer-client.ts';
import { processTick } from './dispatcher/process-tick.ts';
import { logger } from './lib/logger.ts';
const client = new ServiceLayerClient(config.serviceLayer);
async function runOnce(): Promise<void> {
try {
await processTick(client);
} catch (err) {
logger.error('[dispatcher] Error procesando el tick:', err);
}
}
const runOnceOnly = process.argv.includes('--once');
if (runOnceOnly) {
await runOnce();
process.exit(0);
} else {
const cronExpression = `*/${config.pollIntervalSeconds} * * * * *`;
logger.info(`[dispatcher] sap-legacy-webhook-emulator arrancó. Revisando @OEVN cada ${config.pollIntervalSeconds}s.`);
cron.schedule(cronExpression, runOnce);
await runOnce(); // primera corrida inmediata, sin esperar el primer tick
}
+33
View File
@@ -0,0 +1,33 @@
import { bizObjectFor } from '../mapping/object-types.ts';
import { operationFor } from '../mapping/trans-types.ts';
import type { OevnRow } from '../dispatcher/oevn-repository.ts';
// Mismo schema CloudEvents que ya implementa sap-webhook-api (ver
// webhooks.controller.ts) — el emulador debe producir un payload
// indistinguible del que manda el Webhook Messenger real.
export interface CloudEvent {
id: string;
specversion: '1.0';
source: string;
type: string;
subject: string;
time: string;
datacontenttype: 'application/json';
data: Record<string, string>;
}
export function buildCloudEvent(event: OevnRow): CloudEvent {
const bizObject = bizObjectFor(event.objectType);
const operation = operationFor(event.transType);
return {
id: event.code,
specversion: '1.0',
source: `/default/sap.b1/${event.sourceDb}`,
type: `sap.b1.${bizObject}.${operation}.v1`,
subject: event.fieldValue,
time: new Date().toISOString(),
datacontenttype: 'application/json',
data: { [event.fieldNames]: event.fieldValue },
};
}
+9
View File
@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
// Code sintético para filas de nuestros UserTable creadas desde Node
// (@EVC1, @EVN1 — ver docs/DISENO.md, decisión 5): nunca es el
// identificador de negocio real, solo una clave única. Mismo criterio que
// usa el SP en SQL (NEWID() en SQL Server, SYSUUID en HANA).
export function generateCode(): string {
return randomUUID();
}
+36
View File
@@ -0,0 +1,36 @@
import { appendFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// Log a archivo, uno por día (logs/dispatcher-YYYY-MM-DD.log), además de
// consola — para que un servicio Windows (NSSM/PM2/Task Scheduler, ver
// README) no dependa de que algo capture su stdout. Formato de línea
// simple (timestamp ISO + nivel entre corchetes) para que lo pueda leer
// cualquier visualizador de logs genérico (ej. lnav, o un `tail -f`).
const LOGS_DIR = fileURLToPath(new URL('../../logs/', import.meta.url));
mkdirSync(LOGS_DIR, { recursive: true });
function logFilePath(date: Date): string {
const day = date.toISOString().slice(0, 10);
return `${LOGS_DIR}dispatcher-${day}.log`;
}
function write(level: string, message: string): void {
const now = new Date();
appendFileSync(logFilePath(now), `${now.toISOString()} [${level}] ${message}\n`);
}
export const logger = {
info(message: string): void {
console.log(message);
write('INFO', message);
},
warn(message: string): void {
console.warn(message);
write('WARN', message);
},
error(message: string, err?: unknown): void {
const detail = err instanceof Error ? (err.stack ?? err.message) : err !== undefined ? String(err) : '';
console.error(message, err ?? '');
write('ERROR', detail ? `${message} ${detail}` : message);
},
};
+8
View File
@@ -0,0 +1,8 @@
// El EntitySet que expone Service Layer para leer/escribir FILAS de un
// UserTable es "U_<TableName>" (con el prefijo "U_") — distinto del nombre
// que usan los endpoints de metadata (UserTablesMD/UserFieldsMD, que usan
// el TableName sin prefijo). Comportamiento real confirmado contra Service
// Layer; no está documentado en ningún lado consultado durante el diseño.
export function userTableEntity(tableName: string): string {
return `U_${tableName}`;
}
+6
View File
@@ -0,0 +1,6 @@
// Formato entero HHMMSS usado por los campos U_LastSentTM (mismo criterio
// que los timestamps nativos de SAP, sin los milisegundos que sí tiene el
// OEVN real — ver docs/DISENO.md, diccionario de datos).
export function hhmmss(date: Date): number {
return date.getHours() * 10000 + date.getMinutes() * 100 + date.getSeconds();
}
+33
View File
@@ -0,0 +1,33 @@
// Mapeo ObjectType -> BizObject: nombre del member de BoObjectTypes (SDK de
// SAP Business One) sin el prefijo "o". Confirmado con el INSERT real
// capturado por trace (ObjectType='17' -> BizObject='Orders'). Subconjunto
// curado, no las ~150 entradas de BoObjectTypes — agregar acá a medida que
// se necesiten nuevos BusinessObject. Ver docs/DISENO.md.
export const OBJECT_TYPE_TO_BIZ_OBJECT: Record<string, string> = {
'1': 'ChartOfAccounts',
'2': 'BusinessPartners',
'4': 'Items',
'13': 'Invoices',
'14': 'CreditNotes',
'15': 'DeliveryNotes',
'16': 'Returns',
'17': 'Orders',
'18': 'PurchaseInvoices',
'19': 'PurchaseCreditNotes',
'20': 'PurchaseDeliveryNotes',
'21': 'PurchaseReturns',
'22': 'PurchaseOrders',
'23': 'Quotations',
'24': 'IncomingPayments',
'30': 'JournalEntries',
'46': 'VendorPayments',
'53': 'SalesPersons',
'67': 'StockTransfer',
'202': 'ProductionOrders',
};
export function bizObjectFor(objectType: string): string {
const bizObject = OBJECT_TYPE_TO_BIZ_OBJECT[objectType];
if (!bizObject) throw new Error(`ObjectType desconocido: '${objectType}' (agregarlo a object-types.ts)`);
return bizObject;
}
+14
View File
@@ -0,0 +1,14 @@
// Mapeo TransType -> Operation, confirmado contra la definición real de la
// tabla EVC1 de SAP. Ver docs/DISENO.md.
export const TRANS_TYPE_TO_OPERATION: Record<string, string> = {
A: 'Created',
U: 'Updated',
D: 'Deleted',
C: 'Canceled',
O: 'Reopened',
L: 'Closed',
};
export function operationFor(transType: string): string {
return TRANS_TYPE_TO_OPERATION[transType] ?? transType;
}
+54
View File
@@ -0,0 +1,54 @@
import type { FieldDefinition, TableDefinition } from './table-definitions.ts';
// Traducción de FieldDefinition/TableDefinition (nuestro esquema abstracto,
// ver table-definitions.ts) a los bodies reales de Service Layer
// (UserTablesMD/UserFieldsMD). Única fuente de verdad para esa forma —
// la usan tanto user-tables.ts (provisioning real) como
// scripts/export-table-json.ts (export a JSON para revisión o POST manual).
const FIELD_TYPE_TO_SAP: Record<FieldDefinition['type'], string> = {
Alpha: 'db_Alpha',
Numeric: 'db_Numeric',
Date: 'db_Date',
};
export interface UserTablePayload {
TableName: string;
TableDescription: string;
TableType: 'bott_NoObject';
}
export interface UserFieldPayload {
TableName: string;
Name: string;
Description: string;
Type: string;
Size?: number;
EditSize?: number;
}
export function toUserTablePayload(definition: TableDefinition): UserTablePayload {
return {
TableName: definition.tableName,
TableDescription: definition.description,
TableType: 'bott_NoObject',
};
}
export function toUserFieldPayload(tableName: string, field: FieldDefinition): UserFieldPayload {
return {
// Comportamiento real confirmado contra Service Layer: a diferencia de
// UserTablesMD.TableName (sin "@"), UserFieldsMD.TableName SÍ necesita
// el nombre físico con "@" — si no, Service Layer responde "Table not
// found" aunque la tabla exista.
TableName: `@${tableName}`,
Name: field.name,
Description: field.name,
Type: FIELD_TYPE_TO_SAP[field.type],
// Sin Size/EditSize explícito, un campo db_Numeric queda limitado en la
// práctica a 4 dígitos (aunque la metadata reporte Size=6 por default)
// — confirmado real: valores de 5+ dígitos se pierden en silencio en
// el PATCH (sin error, quedan null). Por eso Numeric también manda
// Size/EditSize, igual que Alpha — nunca confiar en el default.
...(field.type === 'Alpha' || field.type === 'Numeric' ? { Size: field.size, EditSize: field.size } : {}),
};
}
+11
View File
@@ -0,0 +1,11 @@
import { config } from '../config.ts';
import { ServiceLayerClient } from '../service-layer-client.ts';
import { TABLE_DEFINITIONS } from './table-definitions.ts';
import { ensureUserTable } from './user-tables.ts';
const client = new ServiceLayerClient(config.serviceLayer);
for (const definition of TABLE_DEFINITIONS) {
await ensureUserTable(client, definition);
console.log(`[provision] Tabla @${definition.tableName} verificada/creada.`);
}
+73
View File
@@ -0,0 +1,73 @@
// Definición declarativa de las 4 UserTablesMD del emulador. El esquema
// real de SAP y las decisiones que llevaron a este recorte de campos están
// documentadas en docs/DISENO.md ("Diccionario de datos").
export type FieldType = 'Alpha' | 'Numeric' | 'Date';
export interface FieldDefinition {
name: string;
type: FieldType;
size?: number; // requerido para 'Alpha' y 'Numeric' (ver payloads.ts: sin Size explícito, Numeric queda limitado a 4 dígitos)
}
export interface TableDefinition {
tableName: string; // sin el prefijo "@" — SAP lo antepone solo
description: string; // UserTablesMD.TableDescription: máx. 30 caracteres (confirmado contra Service Layer real)
fields: FieldDefinition[];
}
export const TABLE_DEFINITIONS: TableDefinition[] = [
{
tableName: 'OEVC',
description: 'Webhook Subscription',
fields: [
{ name: 'WebhookURL', type: 'Alpha', size: 254 },
{ name: 'AuthType', type: 'Alpha', size: 1 },
{ name: 'AuthCred', type: 'Alpha', size: 254 },
{ name: 'Handshake', type: 'Alpha', size: 1 },
{ name: 'VerifyCert', type: 'Alpha', size: 1 },
{ name: 'State', type: 'Alpha', size: 1 },
{ name: 'WorkMode', type: 'Alpha', size: 1 },
{ name: 'LastSentDT', type: 'Date' },
{ name: 'LastSentTM', type: 'Numeric', size: 9 },
{ name: 'LastErrMsg', type: 'Alpha', size: 254 },
],
},
{
tableName: 'EVC1',
description: 'Webhook Subscription Filter',
fields: [
{ name: 'WebhookID', type: 'Alpha', size: 40 },
{ name: 'BizObject', type: 'Alpha', size: 64 },
{ name: 'TransType', type: 'Alpha', size: 1 },
{ name: 'ObjectType', type: 'Alpha', size: 30 },
],
},
{
tableName: 'OEVN',
description: 'Webhook Event',
fields: [
{ name: 'SourceDB', type: 'Alpha', size: 100 },
{ name: 'Status', type: 'Alpha', size: 1 },
{ name: 'ObjectType', type: 'Alpha', size: 30 },
{ name: 'TransType', type: 'Alpha', size: 1 },
{ name: 'FieldsInKe', type: 'Numeric', size: 6 },
{ name: 'FieldNames', type: 'Alpha', size: 254 },
{ name: 'FieldValue', type: 'Alpha', size: 254 },
{ name: 'ReplayStat', type: 'Alpha', size: 1 },
{ name: 'UserID', type: 'Alpha', size: 25 },
],
},
{
tableName: 'EVN1',
description: 'Webhook Event Delivery',
fields: [
{ name: 'EventID', type: 'Alpha', size: 40 },
{ name: 'WebhookID', type: 'Alpha', size: 40 },
{ name: 'Status', type: 'Alpha', size: 1 },
{ name: 'Retry', type: 'Numeric', size: 6 },
{ name: 'ReturnCode', type: 'Numeric', size: 9 },
{ name: 'LastSentDT', type: 'Date' },
{ name: 'LastSentTM', type: 'Numeric', size: 9 },
],
},
];
+32
View File
@@ -0,0 +1,32 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import type { FieldDefinition, TableDefinition } from './table-definitions.ts';
import { toUserFieldPayload, toUserTablePayload } from './payloads.ts';
// Idempotente: crea la UserTable y sus UserFields solo si todavía no existen,
// para poder correr `npm run provision` tantas veces como haga falta.
export async function ensureUserTable(client: ServiceLayerClient, definition: TableDefinition): Promise<void> {
if (!(await tableExists(client, definition.tableName))) {
await client.post('/UserTablesMD', toUserTablePayload(definition));
}
for (const field of definition.fields) {
await ensureUserField(client, definition.tableName, field);
}
}
async function tableExists(client: ServiceLayerClient, tableName: string): Promise<boolean> {
const res = await client.get(`/UserTablesMD('${tableName}')`);
return res.status === 200;
}
async function ensureUserField(client: ServiceLayerClient, tableName: string, field: FieldDefinition): Promise<void> {
if (await fieldExists(client, tableName, field.name)) return;
await client.post('/UserFieldsMD', toUserFieldPayload(tableName, field));
}
async function fieldExists(client: ServiceLayerClient, tableName: string, fieldName: string): Promise<boolean> {
// El campo, una vez creado, queda registrado con TableName="@<tableName>"
// (ver la nota en toUserFieldPayload) — hay que filtrar igual.
const filter = encodeURIComponent(`TableName eq '@${tableName}' and Name eq '${fieldName}'`);
const res = await client.get(`/UserFieldsMD?$filter=${filter}`);
return res.status === 200 && Array.isArray(res.data?.value) && res.data.value.length > 0;
}
+90
View File
@@ -0,0 +1,90 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import https from 'node:https';
import type { ServiceLayerTarget } from './config.ts';
// Cliente mínimo del Service Layer: Login + GET/POST/PATCH/DELETE con la
// cookie de sesión. No cachea la sesión entre corridas (cada ciclo del
// dispatcher vuelve a loguearse) para no lidiar con expiración de
// B1SESSION (~30 min) — mismo criterio que sap-intercompany-worker.
export class ServiceLayerClient {
private readonly http: AxiosInstance;
private loggedIn = false;
constructor(private readonly target: ServiceLayerTarget) {
this.http = axios.create({
baseURL: target.baseUrl,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
validateStatus: () => true, // manejamos los status code nosotros, sin que axios tire excepción
});
}
async login(): Promise<void> {
const res = await this.http.post('/Login', {
CompanyDB: this.target.companyDb,
UserName: this.target.username,
Password: this.target.password,
});
if (res.status !== 200) {
throw new Error(`Login falló en ${this.target.baseUrl}: HTTP ${res.status} ${JSON.stringify(res.data)}`);
}
const setCookie = res.headers['set-cookie'];
if (setCookie) {
this.http.defaults.headers.common['Cookie'] = setCookie.map((c) => c.split(';')[0]).join('; ');
}
this.loggedIn = true;
}
private async ensureLoggedIn(): Promise<void> {
if (!this.loggedIn) await this.login();
}
// GET se usa a menudo para chequear existencia (404 es una respuesta
// válida, no un error) — se deja sin validar, cada llamador mira el
// status que necesita.
async get(path: string): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.http.get(path);
}
// Service Layer pagina las listas de a 20 registros por defecto
// (`odata.nextLink`) — confirmado real: con más de 20 eventos
// pendientes, un GET sin paginar devuelve solo los primeros 20 y
// esconde el resto en silencio. Usar SIEMPRE que se necesite la lista
// completa (no para chequeos de existencia de una sola fila).
async getAll(path: string): Promise<Record<string, unknown>[]> {
const results: Record<string, unknown>[] = [];
let next: string | undefined = path;
while (next) {
const res = await this.get(next);
results.push(...((res.data?.value as Record<string, unknown>[]) ?? []));
const nextLink = res.data?.['odata.nextLink'] as string | undefined;
next = nextLink ? `/${nextLink}` : undefined;
}
return results;
}
async post(path: string, body: unknown): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.post(path, body));
}
async patch(path: string, body: unknown): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.patch(path, body));
}
async delete(path: string): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.delete(path));
}
// POST/PATCH/DELETE son siempre una acción con una intención clara: si
// Service Layer responde error, tiene que ser ruidoso (excepción), nunca
// una fila que "parece" creada/actualizada pero no lo está.
private assertOk(res: AxiosResponse): AxiosResponse {
if (res.status < 200 || res.status >= 300) {
throw new Error(`Service Layer respondió HTTP ${res.status}: ${JSON.stringify(res.data)}`);
}
return res;
}
}
@@ -0,0 +1,56 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const OEVC = userTableEntity('OEVC');
const EVC1 = userTableEntity('EVC1');
export interface Subscription {
webhookId: string; // = Code
url: string;
authType: string;
authCred: string | null;
state: string;
}
export interface SubscriptionFilter {
webhookId: string;
bizObject: string;
transType: string;
}
function toSubscription(raw: Record<string, unknown>): Subscription {
return {
webhookId: raw.Code as string,
url: raw.U_WebhookURL as string,
authType: raw.U_AuthType as string,
authCred: (raw.U_AuthCred as string) ?? null,
state: raw.U_State as string,
};
}
function toFilter(raw: Record<string, unknown>): SubscriptionFilter {
return {
webhookId: raw.U_WebhookID as string,
bizObject: raw.U_BizObject as string,
transType: raw.U_TransType as string,
};
}
// Devuelve un mapa WebhookID -> Subscription, solo las activas (State='A').
export async function listActiveSubscriptions(client: ServiceLayerClient): Promise<Map<string, Subscription>> {
const rows = await client.getAll(`/${OEVC}?$filter=${encodeURIComponent("U_State eq 'A'")}`);
const subscriptions = rows.map(toSubscription);
return new Map(subscriptions.map((s) => [s.webhookId, s]));
}
// Todos los filtros de @EVC1 — se trae completo y se matchea en memoria
// contra BizObject/TransType, en vez de depender de operadores $filter más
// avanzados que Service Layer podría no soportar igual entre motores.
export async function listFilters(client: ServiceLayerClient): Promise<SubscriptionFilter[]> {
const rows = await client.getAll(`/${EVC1}`);
return rows.map(toFilter);
}
export async function markExceptional(client: ServiceLayerClient, webhookId: string): Promise<void> {
await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'X' });
}