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,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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
Reference in new issue
Block a user