Files
sap-legacy-webhook-emulator/docs/DISENO.md
T
GeorgeandClaude Sonnet 5 e1c8ee9015 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>
2026-09-16 08:30:45 -05:00

44 KiB

sap-legacy-webhook-emulator — Diseño, acuerdos y diccionario de datos

Estado: flujo completo probado de punta a punta contra un SAP Business One sobre HANA real — provisioning, captura (ObjectType=4/Items), dispatcher, entrega por HTTP y por HTTPS con certificado autofirmado (ver "Estado de la implementación" y "Plan de prueba" al final). Falta: reinstalar y verificar el filtro por suscripción activa del SP más reciente (ver "Pendientes", ítem 0), y probar el camino de reintento/falla. Este documento recoge lo acordado para poder retomar la conversación de diseño o la implementación sin perder contexto.

Objetivo

sap-webhook-api recibe los eventos del Webhook Messenger nativo de SAP Business One, disponible desde FP2605. Hay clientes que todavía no están en esa versión y no la van a tener en el corto plazo, pero sí necesitan que los eventos de negocio (creación de una orden, un asiento, etc.) lleguen al mismo lugar.

sap-legacy-webhook-emulator es un componente nuevo que reconstruye ese mecanismo por fuera de SAP, usando datos y estructuras equivalentes a las que usa la feature nativa, y termina emitiendo el mismo payload CloudEvents, contra el mismo endpoint (POST /webhooks/sap) que ya implementa sap-webhook-api (sap-webhook-api/src/webhooks/webhooks.controller.ts:84-108). Por diseño, sap-webhook-api no necesita ningún cambio — para él, este componente es indistinguible del Webhook Messenger real.

Por qué se descartaron otras alternativas

  • Polling directo a Service Layer (sin tocar SQL) quedó como alternativa de reserva, no como mecanismo principal: más latencia, hay que mantener un watermark por BusinessObject+compañía, y no detecta bien los Delete.
  • Se prefirió capturar el evento en el momento real de la transacción usando un mecanismo del lado de la base de datos de SAP.

Decisiones de arquitectura acordadas

1. SBO_SP_PostTransactionNotice, no SBO_SP_TransactionNotification

SAP ofrece dos stored procedures "hook":

  • SBO_SP_TransactionNotification — síncrono, corre antes del commit, puede devolver un error y SAP hace rollback de la transacción real del usuario si tarda o falla.
  • SBO_SP_PostTransactionNotice — corre después del commit, de forma asíncrona respecto a la transacción del usuario. No puede bloquear ni revertir nada.

Se eligió la variante Post para no arriesgar nunca una transacción real de un cliente por un problema de nuestro lado.

2. Las 4 tablas se crean como UserTablesMD (DI API / Service Layer), no como tablas SQL crudas

Se evaluaron dos caminos para crear las tablas @OEVC/@EVC1/@OEVN/@EVN1:

  • Tablas SQL propias (CREATE TABLE directo) — permitía replicar el esquema real de SAP campo por campo, pero no queda registrado en el catálogo de SAP (OUTB) y es una customización por fuera del framework soportado.
  • UserTablesMD vía DI API/Service Layer (elegido) — es el mecanismo oficialmente soportado por SAP para extender el modelo de datos de la company DB, evita problemas de soporte/upgrade a futuro.

Esto tiene dos implicancias de diseño que hay que respetar:

  • Toda UserTablesMD obliga una clave primaria Code + columna Name, más columnas de auditoría que SAP llena solo (CreateDate, CreateTime, UpdateDate, UpdateTime — pero sin milisegundos, a diferencia del CreateTime de 9 dígitos del OEVN nativo real). Cualquier columna propia (BizObject, TransType, Status, etc.) se registra vía UserFieldsMD y SAP le antepone U_ automáticamente (U_BizObject, no BizObject).
  • Un UserTable no soporta clave primaria compuesta — solo Code simple. @EVC1 (PK real: WebhookID+BizObject+TransType) y @EVN1 (PK real: EventID+WebhookID) necesitan una estrategia de Code sintético (ver "Pendientes" más abajo).
  • TableType propuesto para las 4 tablas: bott_NoObject (sin header/líneas — no estamos modelando documentos).

3. El SP inserta en @OEVN solo si hay una suscripción activa esperando ese evento

Revisión de la decisión original (que decía "el SP inserta siempre, sin condiciones, sin joins"): con el volumen real que generó el primer ambiente de prueba (cientos de eventos por minuto, la enorme mayoría sin ninguna suscripción real interesada), quedó claro que insertar todo y descartar después ensucia @OEVN innecesariamente. El SP ahora hace un EXISTS contra @EVC1+@OEVC (ObjectType+TransType exactos, con State='A') antes de insertar. Esto no reintroduce el problema que la decisión original evitaba (duplicar una lista de negocio en dos lenguajes): el EXISTS lee la misma tabla de configuración que ya es la fuente de verdad para el dispatcher — no hay una segunda copia que mantener sincronizada, es la config real, en vivo. Sigue siendo cierto que el SP no resuelve BizObject/Operation (eso lo sigue haciendo el dispatcher al leer, ver decisión 4) y no crea filas en @EVN1 — solo decide si vale la pena insertar el evento crudo o no.

Además, el SP sigue chequeando el patrón de eco de escrituras propias (ver más abajo) antes que nada — un eco nunca va a matchear ningún EVC1 real (su ObjectType es un string compuesto tipo -3\t@OEVN, no un código positivo real), así que en la práctica el chequeo de suscripción ya lo filtraría solo, pero se mantiene el chequeo explícito por claridad y para no depender de esa coincidencia.

Toda la demás lógica de negocio (crear las filas en @EVN1, armar y mandar el POST con el CloudEvent, actualizar los estados) vive en un proceso Node/TypeScript aparte, hablando siempre por Service Layer (nunca SQL directo) — así el mismo código sirve para clientes sobre SQL Server y sobre HANA sin diferenciar motor. Los únicos puntos que sí son SQL crudo (uno por motor) son los dos SP, porque corren dentro del motor de base y no pueden llamar a Service Layer desde ahí.

El dispatcher igual conserva su lógica de descarte (ObjectType sin mapear a BizObject, o sin suscripción activa) como red de seguridad ante condiciones de carrera — ej. si se borra un filtro justo entre que el SP lo insertó y el dispatcher lo procesa — aunque en el camino normal ya no debería activarse casi nunca.

Además, la lógica de captura no vive directamente en el cuerpo de SBO_SP_PostTransactionNotice — ese es un hook compartido de SAP (SAP lo crea vacío por cada compañía nueva, y ahí puede convivir código de otros add-ons instalados en el mismo cliente). Para que la nuestra sea fácil de identificar, mantener o remover sin tocar el de otros, vive en un procedimiento propio con nombre descriptivo — SP_Webhook_CaptureEvent (sql/transaction-notification/sp-webhook-capture-event-mssql.sql) — y el hook (sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql) queda reducido a un único EXEC hacia ese procedimiento.

Ese proceso hace polling de @OEVN con Status='N' (mismo patrón que ya usa sap-intercompany-worker con node-cron sobre webhook_events).

4. @OEVN no persiste BizObject ni Operation — se derivan al leer

El SP recibe object_type y transaction_type tal cual se los da SAP y los guarda sin traducir. BizObject (mapeo de ObjectType) y Operation (mapeo de TransType) son valores derivados que el dispatcher calcula en el momento de armar el CloudEvent, usando las tablas de mapeo de este mismo documento — no hace falta persistirlos ni duplicar el mapeo en SQL. Menos columnas en el UDT, y el SP se mantiene con un único INSERT sin lógica.

5. Estrategia de Code definida: siempre sintético, generado con el UUID nativo de cada motor

La regla es uniforme para las 4 tablas: Code nunca es el identificador de negocio real, siempre es un UUID generado con la función nativa del motor — NEWID() en SQL Server, SELECT SYSUUID FROM DUMMY en HANA, y crypto.randomUUID() en Node para las filas que crea el dispatcher (@EVC1/@EVN1) — salvo @OEVC, donde Code = WebhookID directamente (es un id corto elegido por un administrador al crear la suscripción, no hay razón para generarle un UUID). Los identificadores de negocio reales (EventID de un evento, WebhookID de una suscripción, BizObject+ TransType de un filtro) viven siempre como columnas U_ propias, nunca codificados dentro de Code. El id del CloudEvent que se despacha es directamente el Code de la fila de @OEVN — no hace falta un campo EventID aparte en esa tabla. Los campos U_ que referencian el Code de otra tabla (EVN1.U_EventID, EVN1.U_WebhookID, EVC1.U_WebhookID) se dimensionan en 40 caracteres para dar margen sobre el largo de un UUID (36 caracteres con guiones).

Corolario confirmado contra Service Layer real (ver "Hallazgos confirmados...", hallazgo 4): Name también debe ser único en una UserTable, igual que Code — por eso Name siempre usa el mismo UUID que Code (nunca un valor que se repita entre filas, como el ObjectType).

Nomenclatura

Término Significado
ObjectType Código numérico interno de SAP para un tipo de objeto de negocio (ej. 17). Viene tal cual del parámetro object_type que recibe el SP. Enumeración oficial: BoObjectTypes del SDK de SAP B1.
BizObject Nombre del objeto de negocio, el mismo que usa Service Layer/DI API para referirse a esa entidad (ej. Orders, BusinessPartners). Es la contraparte legible de ObjectType. Regla de mapeo confirmada: el nombre del member de BoObjectTypes sin el prefijo o (oOrders → Orders).
TransType Código de una letra para el tipo de transacción sobre el documento (A=Created, U=Updated, D=Deleted, C=Canceled, O=Reopened, L=Closed).
Operation Nombre completo de la operación (Created, Updated, etc.), sale de mapear TransType. Se usa para construir el type del CloudEvent.
EventID Identificador único de un evento capturado (PK de OEVN nativo). En nuestra implementación, sujeto a la longitud real de Code en UserTablesMD (ver "Pendientes").
WebhookID Identificador de una suscripción (PK de OEVC nativo).
CloudEvent Estándar (cloudevents.io) que usa el payload real del Webhook Messenger de SAP, y el que ya implementa sap-webhook-api. type tiene la forma sap.b1.<BizObject>.<Operation>.v1 (ej. sap.b1.Orders.Created.v1).

Diccionario de datos

La primera tabla de cada sección es el esquema real de SAP, documentado por el usuario a partir de la definición nativa (OEVC/EVC1/OEVN/EVN1). Debajo, el esquema que efectivamente implementamos como UserTablesMD (@OEVC/@EVC1/@OEVN/@EVN1), aplicando las decisiones (2), (4) y (5) de arriba: Code/Name obligatorios y generados por nosotros (no son el identificador real de negocio, salvo en @OEVC), prefijo U_ en cada campo propio, sin columnas derivadas ni irrelevantes para el emulador.

OEVN — Event Notification (evento capturado)

Esquema real de SAP:

Campo Tipo Tamaño Valores / notas
EventID nVarChar 50 PK única.
SourceDB nVarChar 100
Status VarChar 1 N=New (default), F=Failed, P=Partially Delivered, D=Delivered, A=Archived. Estado agregado del evento sobre todas sus filas en EVN1.
ObjectType nVarChar 30 Código numérico como texto (ej. '17').
BizObject nVarChar 64 Ej. 'Orders'.
TransType VarChar 1 Ver tabla de TransType más abajo.
Operation nVarChar 64 Ej. 'Created'.
FieldsInKe Int 11 Cantidad de campos clave (en nuestro caso: 1, DocEntry).
FieldNames nVarChar 254 Nombre del/los campo(s) clave, ej. 'DocEntry'.
FieldValue nVarChar 254 Valor del/los campo(s) clave, ej. '295'.
ReplayStat VarChar 1 N=None (default), R=Replaying, C=Completed.
UserID nVarChar 25 Usuario SAP que disparó la transacción.
SAPPassprt Text 16 Extended SAP Passport.
CreateDate Date 8
CreateTime Int 11 Formato HHMMSSmmm (9 dígitos, con milisegundos) en el OEVN nativo real — confirmado con un INSERT capturado por trace de SQL Server (125318067 = 12:53:18.067).
UpdateDate Date 8
UpdateTime Int 11

Esquema de @OEVN (UserTablesMD, TableType=bott_NoObject):

Columna física Origen Notas
Code generado por nosotros 20 caracteres hex al azar. Es el id que se manda en el CloudEvent — no hace falta un campo EventID aparte.
Name generado por nosotros Label legible, <ObjectType>-<TransType> (ej. 17-A), solo para lectura humana en el cliente SAP.
U_SourceDB Alpha(100)
U_Status Alpha(1), default N mismos valores que el real.
U_ObjectType Alpha(30)
U_TransType Alpha(1)
U_FieldsInKe Numeric
U_FieldNames Alpha(254)
U_FieldValue Alpha(254)
U_ReplayStat Alpha(1), default N
U_UserID Alpha(25) Definido pero sin poblar — ver decisión/pendiente sobre U_UserID más abajo.

No se incluyen BizObject/Operation (ver decisión 4) ni SAPPassprt (específico de SAP, sin uso para el emulador). CreateDate/CreateTime/ UpdateDate/UpdateTime los gestiona SAP automáticamente en todo UserTable (sin milisegundos — no hace falta replicarlo a mano).

EVN1 — Event Notification Details (estado de entrega por suscripción)

Esquema real de SAP:

Campo Tipo Tamaño Valores / notas
EventID nVarChar 50 PK compuesta con WebhookID.
WebhookID nVarChar 40 PK compuesta con EventID.
Status VarChar 1 F=Failed, D=Delivered (default). Solo estos dos valores — es el estado puntual de esta suscripción para este evento (a diferencia del Status agregado de OEVN, que tiene 5 valores).
Retry Int 11 Cantidad de reintentos.
ReturnCode Int 11 Código de error del último intento.
LastSentDT Date 8
LastSentTM Int 11

Esquema de @EVN1:

Columna física Origen Notas
Code generado por nosotros sintético (ej. hash corto de EventID+WebhookID) — la PK compuesta real no es soportada por un UserTable.
Name generado por nosotros <EventID>/<WebhookID>, legible.
U_EventID Alpha(20) referencia al Code de la fila en @OEVN.
U_WebhookID Alpha(40) referencia al Code de la fila en @OEVC (= el WebhookID real).
U_Status Alpha(1), default F
U_Retry Numeric
U_ReturnCode Numeric
U_LastSentDT Date
U_LastSentTM Numeric

OEVC — Event Subscription (suscripción a webhook)

Esquema real de SAP:

Campo Tipo Tamaño Valores / notas
WebhookID nVarChar 40 PK única.
WebhookURL nVarChar 254 UNIQUE. No puede haber dos suscripciones activas apuntando a la misma URL.
AuthType VarChar 1 N=None, B=Basic, O=OAuth, H=HMAC (default).
AuthCred Text 16 Credenciales de autenticación.
Handshake VarChar 1 Y=Yes (default), N=No.
VerifyCert VarChar 1 Y=Yes (default), N=No.
State VarChar 1 A=Active (default), I=Inactive, X=Exceptional (estado al que cae la suscripción tras fallos/timeouts repetidos).
WorkMode VarChar 1 S=Standard (default), R=Replay — se conecta con ReplayStat de OEVN: en modo Replay se reprocesan eventos viejos para esta suscripción.
CreateDate/CreateTime Date/Int 8/11
UpdateDate/UpdateTime Date/Int 8/11
LastSentDT/LastSentTM Date/Int 8/11
LastErrMsg nVarChar 254
UserSign Int 6
UserSign2 Int 6 Usuario que actualizó.
DataVers Int 11 Default 1.
LogInstanc Int 11 Default 0.

Esquema de @OEVC:

Columna física Origen Notas
Code = WebhookID única excepción a "Code siempre sintético": es un id corto elegido por un administrador, no hace falta ofuscarlo.
Name = WebhookID
U_WebhookURL Alpha(254)
U_AuthType Alpha(1), default H
U_AuthCred Alpha(254)
U_Handshake Alpha(1), default Y
U_VerifyCert Alpha(1), default Y
U_State Alpha(1), default A
U_WorkMode Alpha(1), default S
U_LastSentDT Date
U_LastSentTM Numeric
U_LastErrMsg Alpha(254)

UserSign/UserSign2/DataVers/LogInstanc los gestiona SAP automáticamente en todo UserTable — no se declaran a mano.

EVC1 — Event Subscription Details (filtro de objeto/transacción por suscripción)

Esquema real de SAP:

Campo Tipo Tamaño Valores / notas
WebhookID nVarChar 40 PK compuesta con BizObject+TransType.
BizObject nVarChar 64 PK compuesta.
TransType VarChar 1 PK compuesta. Ver tabla de valores abajo.
ObjectType nVarChar 30
LogInstanc Int 11 Default 0.

Esquema de @EVC1:

Columna física Origen Notas
Code generado por nosotros sintético (hash corto de WebhookID+BizObject+TransType).
Name generado por nosotros <WebhookID>/<BizObject>/<TransType>, legible.
U_WebhookID Alpha(40) referencia al Code de la fila en @OEVC.
U_BizObject Alpha(64)
U_TransType Alpha(1)
U_ObjectType Alpha(30)

Valores de TransType / Operation

TransType Operation
A Created
U Updated
D Deleted
C Canceled
O Reopened
L Closed

Mapeo ObjectType → BizObject (subconjunto relevante)

Regla general: nombre del member de BoObjectTypes sin el prefijo o. Confirmado con el INSERT real capturado por trace (ObjectType='17' → BizObject='Orders'). El feature nativo de Webhooks de SAP solo soporta un subconjunto curado de BusinessObject, no los ~150 ObjectType que existen en total — se arranca con los que ya usa sap-intercompany-worker más los de documentos de venta/compra más comunes.

ObjectType BizObject
1 ChartOfAccounts
2 BusinessPartners
4 Items — ✅ confirmado con captura y entrega real (PATCH a un Item, sap.b1.Items.Updated.v1 entregado de punta a punta)
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

Fuente completa (todos los ObjectType de BoObjectTypes, no solo los relevantes acá): documentación oficial del SDK de SAP Business One (help.sap.com, enumeración BoObjectTypes), contrastada con el listado provisto por el usuario — coinciden.

Dispatcher — lenguaje, arquitectura y flujo

Lenguaje y arquitectura

Mismo criterio que el resto del proyecto y que sap-intercompany-worker: TypeScript (ESM) ejecutado con tsx, sin framework, sin servidor HTTP propio. node-cron para el loop periódico (POLL_INTERVAL_SECONDS, mismo patrón que ya está en src/config.ts). Toda la comunicación con SAP es por Service Layer, con el ServiceLayerClient ya escrito (src/service-layer-client.ts) — no hay acceso a SQL desde el dispatcher (decisión 3), y no hay base de datos propia: todo el estado (eventos pendientes, suscripciones, estado de entrega) vive en las 4 UserTables dentro de SAP. Esto es más simple que sap-intercompany-worker (que sí tiene Postgres/Prisma) porque acá no hace falta persistir nada del lado nuestro — el "outbox" ya es SAP mismo.

Diagrama de componentes

flowchart LR
    SAP["Transacción real en SAP B1"] -->|commit| Hook["SBO_SP_PostTransactionNotice"]
    Hook --> Capture["SP_Webhook_CaptureEvent"]
    Capture -->|INSERT| OEVN[("@OEVN")]

    subgraph Dispatcher["dispatcher (Node + node-cron, cada POLL_INTERVAL_SECONDS)"]
        Poll["1. Leer @OEVN Status=N"]
        Match["2. Resolver BizObject/Operation<br/>y matchear @OEVC + @EVC1"]
        Track["3. Crear/actualizar filas en @EVN1"]
        Send["4. Armar CloudEvents,<br/>agrupar por WebhookURL, POST"]
        Update["5. Actualizar @EVN1 y @OEVN"]
        Poll --> Match --> Track --> Send --> Update
    end

    OEVN -. Service Layer .-> Poll
    OEVC[("@OEVC")] -. Service Layer .-> Match
    EVC1[("@EVC1")] -. Service Layer .-> Match
    Track -. Service Layer .-> EVN1[("@EVN1")]
    Update -. Service Layer .-> EVN1
    Update -. Service Layer .-> OEVN
    Send --> WebhookAPI["sap-webhook-api<br/>POST /webhooks/sap"]

Secuencia por evento (un tick del dispatcher)

sequenceDiagram
    participant O as "@OEVN (Status=N)"
    participant D as Dispatcher
    participant C as "@OEVC / @EVC1"
    participant N as "@EVN1"
    participant W as sap-webhook-api

    D->>O: GET ?$filter=U_Status eq 'N'
    O-->>D: filas pendientes
    loop por cada fila
        D->>D: ObjectType -> BizObject (mapping)
        alt ObjectType desconocido
            D->>O: DELETE la fila (no nos interesa)
        else
            D->>C: matchear BizObject+TransType contra suscripciones activas
            alt sin suscripción activa
                D->>O: DELETE la fila (nadie la espera)
            else hay 1+ suscripciones
                D->>N: crear fila (EventID, WebhookID, Status='F') si no existe
                D->>D: armar CloudEvent, agrupar por WebhookURL
                D->>W: POST (batch por WebhookURL)
                W-->>D: 200 / error
                D->>N: actualizar Status/Retry/ReturnCode/LastSent*
                D->>O: recalcular Status agregado (D/P/F)
            end
        end
    end

Decisiones a confirmar antes de implementar

  1. Batching por WebhookURL: si dos eventos distintos matchean la misma suscripción en el mismo tick, se agrupan en un solo POST (array de CloudEvents) — replica el comportamiento real que ya vimos (5 eventos atascados llegaron juntos en un solo POST cuando se destrabó el bug de DATEFORMAT).
  2. Descarte silencioso: si un ObjectType no mapea a ningún BizObject conocido, o mapea pero no hay ninguna suscripción activa para ese BizObject+TransType, la fila de @OEVN se borra (no se archiva) — mantiene la tabla liviana. Se puede loguear a consola para diagnóstico, pero no queda rastro persistente en SAP.
  3. Reintentos: @EVN1.U_Retry se incrementa en cada intento fallido. Falta definir un límite (¿cuántos reintentos antes de dar por perdido un evento para esa suscripción?) y si al agotarlo hay que pasar @OEVC a State='X' (Exceptional) — mismo comportamiento que documentamos para el Messenger real.
  4. Idempotencia: antes de crear una fila en @EVN1, hay que chequear que no exista ya para ese EventID+WebhookID (por si el dispatcher se interrumpió a mitad de un tick anterior) — evita mandar el mismo evento dos veces a la misma suscripción.

Pendientes / a verificar antes o durante la implementación

  1. ⚠️ Filtro por suscripción activa (decisión 3, revisión) escrito pero NO reinstalado/verificado todavía — el EXISTS contra @EVC1/@OEVC está en los dos SP (sp-webhook-capture-event-mssql.sql, sp-webhook-capture-event-hana.sql), pero las pruebas end-to-end que sí se hicieron (captura y entrega de Items/ObjectType=4, ver "Plan de prueba") corrieron contra la versión anterior del SP en HANA (solo con el filtro de eco, sin el EXISTS) — nunca se reinstaló la versión con el filtro de suscripción en el ambiente real. Falta: reinstalar sp-webhook-capture-event-hana.sql (versión actual) y confirmar que sigue capturando bien, y que ahora no inserta nada para un ObjectType/TransType sin ninguna suscripción activa (en vez de insertarlo y que el dispatcher lo borre después).
  2. IncomingPayments vs VendorPayments: la enumeración BoObjectTypes describe ambos (24 y 46) como "Payments object" — falta confirmar si el BizObject real difiere entre los dos como se asume en la tabla de arriba, cuando probemos ese caso puntual.
  3. ✅ Provisioning probado contra un Service Layer real (SAP Business One sobre HANA) — ver "Hallazgos confirmados contra Service Layer real" más abajo. Falta todavía probar la inserción de una fila real con Code=UUID (36 caracteres) — el provisioning solo crea metadata de tabla/campo, no probó longitud de dato en una fila.
  4. ✅ Los dos SP de HANA verificados — sp-webhook-capture-event-hana.sql y recreate_sbo_sp_posttransactionnotice_hana.sql (el hook) están instalados y funcionando contra un SAP Business One sobre HANA real, incluida la sintaxis del EXIT HANDLER/::SQL_ERROR_CODE que estaba marcada como no confirmada. Falta todavía confirmar con una transacción real en SAP que @OEVN se llena sola (el hook está instalado, pero no se disparó todavía con un documento real).
  5. ✅ Confirmado (no solo teórico): CURRENT_USER en HANA da el usuario de la conexión (una cuenta técnica), no el usuario real de SAP que hizo la transacción — el hook no tiene ese dato entre sus 5 parámetros. Mismo límite aplica a SUSER_SNAME() en SQL Server, por la misma razón. Decisión: U_UserID se deja definido en las 4 tablas (no se saca del esquema, puede servir a futuro si aparece otra forma de obtenerlo), pero no se completa en el INSERT de ninguno de los dos SP, y el dispatcher (Node) no lo lee en ningún lado. A investigar más adelante (no bloquea nada hoy): el usuario planteó que versiones más nuevas de SAP podrían pasar el usuario SAP real como parámetro del hook — una búsqueda rápida no encontró confirmación de un parámetro nuevo agregado a SBO_SP_TransactionNotification/ SBO_SP_PostTransactionNotice en ninguna versión; lo que sí aparece documentado es la forma histórica indirecta (join contra tablas de log como ACRD/ADOC usando UserSign, no un parámetro del hook). Falta confirmar contra la versión de SAP específica del cliente cuando se retome este punto.

Estado de la implementación

  • ✅ docs/DISENO.md (este documento).
  • ✅ Scaffold del proyecto (package.json, tsconfig.json, config, cliente de Service Layer) — sigue el mismo patrón que sap-intercompany-worker (ESM + tsx, sin framework).
  • ✅ Mapeos ObjectType→BizObject y TransType→Operation (src/mapping/).
  • ✅ Provisioning de las 4 UserTablesMD vía Service Layer (src/provisioning/, npm run provision), con los bodies reales también exportados a json/user-tables/*.json (scripts/export-table-json.ts) para revisar o postear a mano.
  • ✅ Lógica de captura para SQL Server, separada en dos archivos: SP_Webhook_CaptureEvent (sql/transaction-notification/sp-webhook-capture-event-mssql.sql, el único que inserta en @OEVN) y el hook SBO_SP_PostTransactionNotice (sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql, reducido a un EXEC).
  • ✅ CLI de suscripciones (scripts/manage-subscriptions.ts) — alta/listado de @OEVC (suscripciones) y @EVC1 (filtros), pausar/reactivar.
  • ✅ Dispatcher (src/dispatcher/, src/subscriptions/, src/lib/cloud-event.ts, src/lib/timestamps.ts, src/index.ts) — implementa el flujo completo del diagrama: registra eventos nuevos, crea @EVN1, agrupa por suscripción, hace el POST batch (con HMAC opcional), actualiza estados, reintenta con límite (RETRY_LIMIT) y escala la suscripción a State='X' al agotarlo. Simplificaciones de esta v1 documentadas en el README.md (sin Basic/OAuth, sin claves compuestas en el evento).
  • ✅ Los dos SP de captura para HANA (sql/transaction-notification/sp-webhook-capture-event-hana.sql y recreate_sbo_sp_posttransactionnotice_hana.sql) — instalados y verificados contra un SAP Business One sobre HANA real.
  • ✅ Captura confirmada con una transacción real (PATCH a un Item) — ver "Hallazgos confirmados contra Service Layer real", hallazgo 3.
  • ✅ Filtro anti-eco (loop de escrituras propias) implementado en los dos SP y en el dispatcher (own-table-guard.ts), verificado contra HANA real — ver hallazgo 5.
  • ⚠️ Filtro por suscripción activa (EXISTS contra @EVC1/@OEVC en el SP, decisión 3 revisada) — escrito en los dos SP, pero no reinstalado ni verificado contra el ambiente real todavía. Ver "Pendientes", ítem 0.
  • ⬜ Limpiar por SQL directo las filas -3\t@OEVN que quedaron grabadas antes de este fix (no vía Service Layer, para no generar un eco nuevo).
  • ✅ Dispatcher probado end-to-end contra ese ambiente real (suscripción + filtro reales, npm run run-once) — encontró y corrigió el hallazgo 6 (paginación de Service Layer) y el hallazgo 7 (tamaño de campos Numeric).
  • ✅ ObjectType='4' (Items) confirmado real — capturado, matcheado contra una suscripción real, entregado por POST (sap.b1.Items.Updated.v1), y marcado Status='D' en @OEVN/@EVN1 con U_LastSentDT/U_LastSentTM completos. ObjectType='31' (que se había usado provisoriamente como ItemWarehouseInfo para la primera prueba) se sacó del mapeo — no se confirmó a qué BizObject real corresponde, no se sigue usando.
  • ✅ Entrega confirmada por HTTPS con certificado autofirmado, sin autenticación — usando sap-test-legacy-webhook (proyecto nuevo, aparte, pensado como receptor de prueba para no depender de una instancia real de sap-webhook-api) en modo https (npm run start:https, certificado autofirmado en certs/). Confirma que el 401 reportado en otra prueba (contra un destino real distinto, no identificado) era autenticación faltante del lado de ese destino, no un problema de HTTPS/certificado de nuestro lado — send-webhook.ts ya tenía rejectUnauthorized: false desde el diseño original, sin cambios.
  • ✅ Logging a archivo (src/lib/logger.ts) — cada tick escribe a logs/dispatcher-YYYY-MM-DD.log además de consola, con un resumen siempre presente (Tick: N evento(s) nuevo(s), M pendiente(s) de entrega) para poder confirmar actividad aunque no haya nada que hacer.
  • ✅ CLI de suscripciones ampliado: subscription:update-url (cambiar la URL sin recrear la suscripción) y filter:remove agregados a scripts/manage-subscriptions.ts.
  • ⬜ Camino de falla (reintentos, State='X' al agotar RETRY_LIMIT) — todavía no probado contra un destino real inalcanzable.

Hallazgos confirmados contra Service Layer real

Al correr npm run provision contra el primer ambiente real (SAP Business One sobre HANA, 192.168.1.175) aparecieron dos comportamientos de Service Layer que no estaban documentados en ningún lado consultado, y que el código original asumía mal. Los dos ya están corregidos:

  1. UserTablesMD.TableDescription tiene un máximo de 30 caracteres. Confirmado por binary search contra el servidor real (30 pasa, 31 falla con HTTP 400 / CServiceData::SetPropertyValueString failed; Value too long). Las descripciones de las 4 tablas en table-definitions.ts se acortaron para entrar cómodas (13 a 27 caracteres).
  2. UserFieldsMD.TableName necesita el nombre físico con @, pero UserTablesMD.TableName lo necesita SIN @. Es decir, para crear la tabla se manda TableName: "OEVN", pero para crear un campo sobre esa misma tabla hay que mandar TableName: "@OEVN" — si no, Service Layer responde Table not found (ODBC -2004) aunque la tabla exista. No es una inconsistencia nuestra, es así como lo expone la API. Corregido en toUserFieldPayload (src/provisioning/payloads.ts).
  3. El EntitySet para leer/escribir FILAS de un UserTable es U_<TableName>, no <TableName>. Es decir: la metadata de la tabla se administra en /UserTablesMD//UserFieldsMD con el nombre pelado (OEVN), pero para leer o escribir datos hay que pegarle a /U_OEVN, /U_EVC1, /U_OEVC, /U_EVN1 — GET /OEVN responde 400 Unrecognized resource path, aunque la tabla exista y tenga datos (confirmado comparando contra un SELECT * FROM "@OEVN" directo en HANA, que sí mostraba la fila). Se centralizó en una sola función (userTableEntity, src/lib/service-layer-paths.ts) para no repetir el prefijo U_ como string suelto en cada repositorio — la usan oevn-repository.ts, evn1-repository.ts, subscriptions-repository.ts y manage-subscriptions.ts.

Por qué no se detectó antes de correr contra un servidor real: el ServiceLayerClient no validaba el status code de POST/PATCH/DELETE — una creación fallida se veía igual que una exitosa en los logs ("Tabla verificada/creada"). Se corrigió de raíz: post/patch/delete ahora tiran una excepción si Service Layer responde algo fuera de 2xx (ServiceLayerClient.assertOk, src/service-layer-client.ts) — get se deja permisivo a propósito, porque varios call sites usan un 404 como respuesta válida (chequeos de existencia). Este bug de validación afectaba potencialmente a todo el proyecto, no solo al provisioning (el dispatcher también usa post/patch/delete para crear/actualizar @EVN1, borrar @OEVN, etc.) — con el fix, cualquier error real de Service Layer en esas rutas ahora se va a ver como una excepción en los logs del dispatcher, en vez de pasar desapercibido.

Verificado tras el fix: las 4 tablas (@OEVC=10, @EVC1=4, @OEVN=9, @EVN1=7 campos) quedaron creadas completas, con los tipos/tamaños esperados. Y con el hallazgo 3 corregido, se confirmó captura real end-to-end: un PATCH a un Item disparó SBO_SP_PostTransactionNotice → SP_Webhook_CaptureEvent → INSERT en @OEVN, y listNewEvents() (código real del dispatcher, no una prueba suelta) lo encontró vía Service Layer con todos los campos correctos (U_ObjectType='31', U_TransType='U', U_FieldNames='ItemCode\tWhsCode', U_FieldValue='90201011507120002\t100' — dos campos clave, tab-delimited, tal como documenta el mecanismo). Nota: 31 no es el ObjectType de Items (4) — el PATCH a un Item aparentemente disparó el hook para un objeto relacionado a nivel ítem-almacén, no para Items en sí. Falta identificar a qué BizObject corresponde 31 cuando se quiera armar un filtro real para este caso.

  1. Name también debe ser único en una UserTable de SAP, no solo Code. Confirmado insertando dos filas con Code distinto pero el mismo Name contra Service Layer real: la segunda falla con ODBC -2035: This entry already exists. El SP original ponía Name := object_type (ej. '31') — el primer evento de un ObjectType se capturaba bien, pero el segundo evento del mismo tipo fallaba en el INSERT (tragado en silencio por el EXIT HANDLER/TRY-CATCH, así que no se veía ningún error). El usuario ya conocía este comportamiento de implementaciones previas propias. Corregido en los dos motores: Name ahora usa el mismo UUID que Code (una sola variable, reusada en ambos campos), en vez del ObjectType u otro valor que se repite entre eventos.

  2. Escribir en @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer genera un evento nuevo describiendo esa misma escritura ("eco"). Confirmado provocando un DELETE real sobre @OEVN: generó una fila con U_ObjectType = "-3\t@OEVN" (código negativo + tab + nombre físico de la tabla — el mecanismo que usa SAP para "operación genérica sobre una UserTable simple, no un objeto de negocio con código positivo de BoObjectTypes"), U_TransType = "D", U_FieldNames = "Code", U_FieldValue = "<Code de la fila borrada>". El usuario confirmó además que el mismo patrón aparece para POST/PATCH/DELETE por igual (no solo DELETE) — el ObjectType especial no depende del TransType.

    Fix de dos capas:

    1. En el origen (los dos SP): antes de insertar, se chequea si object_type matchea el patrón -<código>\t<una de las 4 tablas> — si matchea, directamente no se inserta nada (en HANA, el INSERT pasó de VALUES a SELECT ... FROM DUMMY WHERE NOT (...) para filtrar sin agregar una rama procedural nueva sin probar; en SQL Server, un IF simple con LIKE). Así el eco nunca llega a @OEVN — no hay nada que acumular ni que limpiar.
    2. Red de seguridad en el dispatcher: isEchoOfOwnTable() (src/dispatcher/own-table-guard.ts), chequeada primero en registerNewEvents() (process-tick.ts) — nunca toca (ni resuelve ni borra) una fila que matchee el patrón. Se mantiene aunque el fix 1 ya lo previene, porque las filas -3\t@OEVN que quedaron grabadas antes de este fix siguen en la tabla — sin este guard, el dispatcher las trataría como "sin match" y las borraría, generando un eco nuevo de nuevo (esas filas viejas hay que limpiarlas por SQL directo, no vía Service Layer, para no perpetuar la cascada).

    ✅ Verificado end-to-end en HANA real: se reinstaló sp-webhook-capture-event-hana.sql con el filtro, se hizo un DELETE real vía Service Layer sobre una fila de @OEVN, y no apareció ningún eco nuevo.

  3. Service Layer pagina las listas de a 20 registros por defecto (odata.nextLink en la respuesta cuando hay más). Confirmado real: con más de 20 eventos con Status='N' en @OEVN (el ambiente de prueba los genera solo, de forma continua), un GET sin paginar devolvía exactamente 20 y escondía el resto en silencio — ningún código tiraba error, simplemente el dispatcher no veía más allá de la primera página. Afecta a cualquier lista que pueda superar 20 filas: listNewEvents, listPending (@EVN1), listActiveSubscriptions (@OEVC), listFilters (@EVC1), y los list de manage-subscriptions.ts. Fix: ServiceLayerClient.getAll() (src/service-layer-client.ts) sigue odata.nextLink hasta agotarlo y junta todas las páginas — reemplaza a client.get() en todos los lugares donde hacía falta la lista completa (no en los que buscan una fila puntual por clave, como getEvent/findExisting, no afectados por paginación).

    Verificado tras el fix con el primer test end-to-end completo del dispatcher: se levantó un listener HTTP local de prueba, se dio de alta una suscripción real apuntándole, y npm run run-once procesó correctamente eventos reales (ObjectType='31', mapeado provisoriamente a ItemWarehouseInfo), armó el CloudEvent (sap.b1.ItemWarehouseInfo.Updated.v1) y lo entregó por POST. Un error transitorio de Service Layer (Property 'U_LastSentTM' of 'U_EVN1' is invalid) causó una entrega duplicada en el primer intento — comportamiento esperado del diseño "al menos una entrega" ante una falla de tracking, no un bug nuevo. (Ese error puntual con "is invalid" no se pudo reproducir después y puede ser un síntoma distinto del hallazgo 7 más abajo, o algo transitorio — no bloqueó nada.)

  4. Un campo db_Numeric sin Size/EditSize explícito al crearlo queda limitado a 4 dígitos en la práctica (aunque la metadata reporte Size=6 por default) — valores de 5+ dígitos se pierden en silencio en el PATCH (sin error, HTTP 200, pero el campo queda null al releerlo). Confirmado real: probado de forma aislada contra U_Retry, U_ReturnCode y U_LastSentTM de @EVN1 — los tres fallan igual arriba de 4 dígitos, y vuelven a funcionar si se recrea el campo con Size explícito (probado con Size=9, funciona con valores de hasta 6 dígitos como 235959). Esto es justo el caso de U_LastSentTM (HHMMSS, hasta 6 dígitos) y U_ReturnCode (puede llevar un código de error de varios dígitos).

    Fix: toUserFieldPayload (src/provisioning/payloads.ts) ahora manda Size/EditSize también para Numeric (antes solo para Alpha). Los campos numéricos en table-definitions.ts pasaron a declarar size explícito: Retry/FieldsInKe en 6, ReturnCode/ LastSentTM en 9. Como las tablas ya existían con los campos viejos (limitados), hubo que además borrar y recrear esos 5 campos puntuales en el ambiente real (UserFieldsMD no permite ampliar un Size existente con un simple PATCH, o al menos no se probó esa vía — se optó por recrear, que es lo que ya sabíamos que funciona). Verificado con el código real (markDelivered()): U_LastSentTM ahora persiste un valor de 6 dígitos correctamente.

Plan de prueba contra un SAP Business One sobre HANA real

El usuario armó este ambiente (192.168.1.175, company SBO_PRUEBA_TAGUM_22). Checklist para seguir avanzando (cada paso valida algo que hasta ahora era solo un supuesto de diseño):

  1. ✅ Login de Service Layer: .env (SL_*) conecta contra la company DB de esa instalación.
  2. ✅ Provisioning: npm run provision corrido contra ese ambiente real — encontró y corrigió 2 bugs reales (ver "Hallazgos confirmados contra Service Layer real" arriba). Las 4 tablas quedaron creadas con todos sus campos. Falta todavía confirmar que un Code de 36 caracteres (UUID) entra sin truncarse en una fila real — el provisioning solo crea metadata, no probó insertar datos.
  3. ✅ Instalar los dos SP (sp-webhook-capture-event-hana.sql, después recreate_sbo_sp_posttransactionnotice_hana.sql) — instalados y compilan sin errores en esa base. De paso resolvió el punto de "comparar contra el stub real" que estaba planificado como paso previo: al instalarlos directamente no hubo errores de sintaxis, así que la traducción a SQLScript quedó confirmada válida (incluido el EXIT HANDLER).
  4. ✅ Transacción real: se probó con un PATCH a un Item — la fila apareció en @OEVN con U_ObjectType='31', U_TransType='U', U_FieldNames/U_FieldValue correctos (dos claves, tab-delimited). De paso se encontró y corrigió el hallazgo 3 (/U_OEVN, no /OEVN, para leer datos vía Service Layer).
  5. ✅ Suscripción de prueba: en vez de sap-webhook-api, se armó sap-test-legacy-webhook — un receptor mínimo aparte, mismo comportamiento núcleo, para no depender de una instancia real. ObjectType='31' se descartó (no se confirmó su BizObject); se probó en cambio con ObjectType='4' (Items, confirmado real) — subscription:add + filter:add Items U 4.
  6. ✅ Dispatcher: npm run run-once procesó el evento real de Items, sap-test-legacy-webhook lo recibió y guardó (data/events/<id>.json), y @EVN1/@OEVN quedaron en Status='D' con U_LastSentDT/U_LastSentTM completos (tras corregir el hallazgo 7). Repetido además contra sap-test-legacy-webhook en modo HTTPS con certificado autofirmado (npm run start:https) — entrega exitosa, confirma que HTTPS/certificado autofirmado nunca fue un problema del dispatcher.
  7. ⬜ Camino de falla: apuntar la suscripción a una URL inalcanzable, confirmar que U_Retry sube en cada tick y que al superar RETRY_LIMIT la suscripción pasa a State='X' sola. Todavía no probado.
  8. ✅ Descarte silencioso: observado real durante las pruebas — eventos con ObjectType sin mapear ('1470000062', y '4' en el momento en que todavía no estaba en el mapeo) se descartaron (DELETE) sin errores, tal como se diseñó.