# 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](https://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...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, `-` (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 | `/`, 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 | `//`, 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 ```mermaid 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
y matchear @OEVC + @EVC1"] Track["3. Crear/actualizar filas en @EVN1"] Send["4. Armar CloudEvents,
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
POST /webhooks/sap"] ``` ### Secuencia por evento (un tick del dispatcher) ```mermaid 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 0. ⚠️ **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). 1. **`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. 2. ✅ **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. 3. ✅ **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). 4. ✅ **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`](../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_`, no ``.** 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. 4. **`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. 5. **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 = ""`. 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 `-\t` — 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. 6. **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.) 7. **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`](../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/.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ñó.