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>
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 losDelete. - 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 TABLEdirecto) — 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. UserTablesMDví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
UserTablesMDobliga una clave primariaCode+ columnaName, más columnas de auditoría que SAP llena solo (CreateDate,CreateTime,UpdateDate,UpdateTime— pero sin milisegundos, a diferencia delCreateTimede 9 dígitos delOEVNnativo real). Cualquier columna propia (BizObject,TransType,Status, etc.) se registra víaUserFieldsMDy SAP le anteponeU_automáticamente (U_BizObject, noBizObject). - Un
UserTableno soporta clave primaria compuesta — soloCodesimple.@EVC1(PK real:WebhookID+BizObject+TransType) y@EVN1(PK real:EventID+WebhookID) necesitan una estrategia deCodesintético (ver "Pendientes" más abajo). TableTypepropuesto 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 comoUserTablesMD(@OEVC/@EVC1/@OEVN/@EVN1), aplicando las decisiones (2), (4) y (5) de arriba:Code/Nameobligatorios y generados por nosotros (no son el identificador real de negocio, salvo en@OEVC), prefijoU_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
- Batching por
WebhookURL: si dos eventos distintos matchean la misma suscripción en el mismo tick, se agrupan en un soloPOST(array de CloudEvents) — replica el comportamiento real que ya vimos (5 eventos atascados llegaron juntos en un soloPOSTcuando se destrabó el bug deDATEFORMAT). - Descarte silencioso: si un
ObjectTypeno mapea a ningúnBizObjectconocido, o mapea pero no hay ninguna suscripción activa para eseBizObject+TransType, la fila de@OEVNse borra (no se archiva) — mantiene la tabla liviana. Se puede loguear a consola para diagnóstico, pero no queda rastro persistente en SAP. - Reintentos:
@EVN1.U_Retryse 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@OEVCaState='X'(Exceptional) — mismo comportamiento que documentamos para el Messenger real. - Idempotencia: antes de crear una fila en
@EVN1, hay que chequear que no exista ya para eseEventID+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
- ⚠️ Filtro por suscripción activa (decisión 3, revisión) escrito pero
NO reinstalado/verificado todavía — el
EXISTScontra@EVC1/@OEVCestá 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 deItems/ObjectType=4, ver "Plan de prueba") corrieron contra la versión anterior del SP en HANA (solo con el filtro de eco, sin elEXISTS) — nunca se reinstaló la versión con el filtro de suscripción en el ambiente real. Falta: reinstalarsp-webhook-capture-event-hana.sql(versión actual) y confirmar que sigue capturando bien, y que ahora no inserta nada para unObjectType/TransTypesin ninguna suscripción activa (en vez de insertarlo y que el dispatcher lo borre después). IncomingPaymentsvsVendorPayments: la enumeraciónBoObjectTypesdescribe ambos (24y46) como "Payments object" — falta confirmar si elBizObjectreal difiere entre los dos como se asume en la tabla de arriba, cuando probemos ese caso puntual.- ✅ 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. - ✅ Los dos SP de HANA verificados —
sp-webhook-capture-event-hana.sqlyrecreate_sbo_sp_posttransactionnotice_hana.sql(el hook) están instalados y funcionando contra un SAP Business One sobre HANA real, incluida la sintaxis delEXIT HANDLER/::SQL_ERROR_CODEque estaba marcada como no confirmada. Falta todavía confirmar con una transacción real en SAP que@OEVNse llena sola (el hook está instalado, pero no se disparó todavía con un documento real). - ✅ Confirmado (no solo teórico):
CURRENT_USERen 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 aSUSER_SNAME()en SQL Server, por la misma razón. Decisión:U_UserIDse 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 elINSERTde 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 aSBO_SP_TransactionNotification/SBO_SP_PostTransactionNoticeen ninguna versión; lo que sí aparece documentado es la forma histórica indirecta (join contra tablas de log comoACRD/ADOCusandoUserSign, 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 quesap-intercompany-worker(ESM +tsx, sin framework). - ✅ Mapeos
ObjectType→BizObjectyTransType→Operation(src/mapping/). - ✅ Provisioning de las 4
UserTablesMDvía Service Layer (src/provisioning/,npm run provision), con los bodies reales también exportados ajson/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 hookSBO_SP_PostTransactionNotice(sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql, reducido a unEXEC). - ✅ 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 elPOSTbatch (con HMAC opcional), actualiza estados, reintenta con límite (RETRY_LIMIT) y escala la suscripción aState='X'al agotarlo. Simplificaciones de esta v1 documentadas en elREADME.md(sinBasic/OAuth, sin claves compuestas en el evento). - ✅ Los dos SP de captura para HANA
(
sql/transaction-notification/sp-webhook-capture-event-hana.sqlyrecreate_sbo_sp_posttransactionnotice_hana.sql) — instalados y verificados contra un SAP Business One sobre HANA real. - ✅ Captura confirmada con una transacción real (
PATCHa 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 (
EXISTScontra@EVC1/@OEVCen 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@OEVNque 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 camposNumeric). - ✅
ObjectType='4'(Items) confirmado real — capturado, matcheado contra una suscripción real, entregado porPOST(sap.b1.Items.Updated.v1), y marcadoStatus='D'en@OEVN/@EVN1conU_LastSentDT/U_LastSentTMcompletos.ObjectType='31'(que se había usado provisoriamente comoItemWarehouseInfopara la primera prueba) se sacó del mapeo — no se confirmó a quéBizObjectreal 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 desap-webhook-api) en modohttps(npm run start:https, certificado autofirmado encerts/). Confirma que el401reportado 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.tsya teníarejectUnauthorized: falsedesde el diseño original, sin cambios. - ✅ Logging a archivo (
src/lib/logger.ts) — cada tick escribe alogs/dispatcher-YYYY-MM-DD.logademá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) yfilter:removeagregados ascripts/manage-subscriptions.ts. - ⬜ Camino de falla (reintentos,
State='X'al agotarRETRY_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:
UserTablesMD.TableDescriptiontiene un máximo de 30 caracteres. Confirmado por binary search contra el servidor real (30 pasa, 31 falla conHTTP 400/CServiceData::SetPropertyValueString failed; Value too long). Las descripciones de las 4 tablas entable-definitions.tsse acortaron para entrar cómodas (13 a 27 caracteres).UserFieldsMD.TableNamenecesita el nombre físico con@, peroUserTablesMD.TableNamelo necesita SIN@. Es decir, para crear la tabla se mandaTableName: "OEVN", pero para crear un campo sobre esa misma tabla hay que mandarTableName: "@OEVN"— si no, Service Layer respondeTable not found (ODBC -2004)aunque la tabla exista. No es una inconsistencia nuestra, es así como lo expone la API. Corregido entoUserFieldPayload(src/provisioning/payloads.ts).- 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//UserFieldsMDcon el nombre pelado (OEVN), pero para leer o escribir datos hay que pegarle a/U_OEVN,/U_EVC1,/U_OEVC,/U_EVN1—GET /OEVNresponde400 Unrecognized resource path, aunque la tabla exista y tenga datos (confirmado comparando contra unSELECT * 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 prefijoU_como string suelto en cada repositorio — la usanoevn-repository.ts,evn1-repository.ts,subscriptions-repository.tsymanage-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.
-
Nametambién debe ser único en una UserTable de SAP, no soloCode. Confirmado insertando dos filas conCodedistinto pero el mismoNamecontra Service Layer real: la segunda falla conODBC -2035: This entry already exists. El SP original poníaName := object_type(ej.'31') — el primer evento de unObjectTypese capturaba bien, pero el segundo evento del mismo tipo fallaba en elINSERT(tragado en silencio por elEXIT 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:Nameahora usa el mismo UUID queCode(una sola variable, reusada en ambos campos), en vez delObjectTypeu otro valor que se repite entre eventos. -
Escribir en
@OEVC/@EVC1/@OEVN/@EVN1vía Service Layer genera un evento nuevo describiendo esa misma escritura ("eco"). Confirmado provocando unDELETEreal sobre@OEVN: generó una fila conU_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 deBoObjectTypes"),U_TransType = "D",U_FieldNames = "Code",U_FieldValue = "<Code de la fila borrada>". El usuario confirmó además que el mismo patrón aparece paraPOST/PATCH/DELETEpor igual (no soloDELETE) — elObjectTypeespecial no depende delTransType.Fix de dos capas:
- En el origen (los dos SP): antes de insertar, se chequea si
object_typematchea el patrón-<código>\t<una de las 4 tablas>— si matchea, directamente no se inserta nada (en HANA, elINSERTpasó deVALUESaSELECT ... FROM DUMMY WHERE NOT (...)para filtrar sin agregar una rama procedural nueva sin probar; en SQL Server, unIFsimple conLIKE). Así el eco nunca llega a@OEVN— no hay nada que acumular ni que limpiar. - Red de seguridad en el dispatcher:
isEchoOfOwnTable()(src/dispatcher/own-table-guard.ts), chequeada primero enregisterNewEvents()(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@OEVNque 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.sqlcon el filtro, se hizo unDELETEreal vía Service Layer sobre una fila de@OEVN, y no apareció ningún eco nuevo. - En el origen (los dos SP): antes de insertar, se chequea si
-
Service Layer pagina las listas de a 20 registros por defecto (
odata.nextLinken la respuesta cuando hay más). Confirmado real: con más de 20 eventos conStatus='N'en@OEVN(el ambiente de prueba los genera solo, de forma continua), unGETsin 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 loslistdemanage-subscriptions.ts. Fix:ServiceLayerClient.getAll()(src/service-layer-client.ts) sigueodata.nextLinkhasta agotarlo y junta todas las páginas — reemplaza aclient.get()en todos los lugares donde hacía falta la lista completa (no en los que buscan una fila puntual por clave, comogetEvent/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-onceprocesó correctamente eventos reales (ObjectType='31', mapeado provisoriamente aItemWarehouseInfo), armó el CloudEvent (sap.b1.ItemWarehouseInfo.Updated.v1) y lo entregó porPOST. 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.) -
Un campo
db_NumericsinSize/EditSizeexplícito al crearlo queda limitado a 4 dígitos en la práctica (aunque la metadata reporteSize=6por default) — valores de 5+ dígitos se pierden en silencio en elPATCH(sin error, HTTP 200, pero el campo quedanullal releerlo). Confirmado real: probado de forma aislada contraU_Retry,U_ReturnCodeyU_LastSentTMde@EVN1— los tres fallan igual arriba de 4 dígitos, y vuelven a funcionar si se recrea el campo conSizeexplícito (probado conSize=9, funciona con valores de hasta 6 dígitos como235959). Esto es justo el caso deU_LastSentTM(HHMMSS, hasta 6 dígitos) yU_ReturnCode(puede llevar un código de error de varios dígitos).Fix:
toUserFieldPayload(src/provisioning/payloads.ts) ahora mandaSize/EditSizetambién paraNumeric(antes solo paraAlpha). Los campos numéricos entable-definitions.tspasaron a declararsizeexplícito:Retry/FieldsInKeen 6,ReturnCode/LastSentTMen 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 (UserFieldsMDno permite ampliar unSizeexistente con un simplePATCH, 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_LastSentTMahora 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):
- ✅ Login de Service Layer:
.env(SL_*) conecta contra la company DB de esa instalación. - ✅ Provisioning:
npm run provisioncorrido 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 unCodede 36 caracteres (UUID) entra sin truncarse en una fila real — el provisioning solo crea metadata, no probó insertar datos. - ✅ Instalar los dos SP (
sp-webhook-capture-event-hana.sql, despuésrecreate_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 elEXIT HANDLER). - ✅ Transacción real: se probó con un
PATCHa un Item — la fila apareció en@OEVNconU_ObjectType='31',U_TransType='U',U_FieldNames/U_FieldValuecorrectos (dos claves, tab-delimited). De paso se encontró y corrigió el hallazgo 3 (/U_OEVN, no/OEVN, para leer datos vía Service Layer). - ✅ 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ó suBizObject); se probó en cambio conObjectType='4'(Items, confirmado real) —subscription:add+filter:add Items U 4. - ✅ Dispatcher:
npm run run-onceprocesó el evento real deItems,sap-test-legacy-webhooklo recibió y guardó (data/events/<id>.json), y@EVN1/@OEVNquedaron enStatus='D'conU_LastSentDT/U_LastSentTMcompletos (tras corregir el hallazgo 7). Repetido además contrasap-test-legacy-webhooken modo HTTPS con certificado autofirmado (npm run start:https) — entrega exitosa, confirma que HTTPS/certificado autofirmado nunca fue un problema del dispatcher. - ⬜ Camino de falla: apuntar la suscripción a una URL inalcanzable,
confirmar que
U_Retrysube en cada tick y que al superarRETRY_LIMITla suscripción pasa aState='X'sola. Todavía no probado. - ✅ Descarte silencioso: observado real durante las pruebas —
eventos con
ObjectTypesin 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ñó.