Initial commit: sap-legacy-webhook-emulator

Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para
clientes que aun no estan en esa version: captura eventos via
SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1
y despacha CloudEvents al mismo endpoint que el messenger real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-16 08:30:45 -05:00
commit e1c8ee9015
42 files changed
+4042

No files matched your search

+792
View File
@@ -0,0 +1,792 @@
# 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.<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
```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<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)
```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_<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.
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 = "<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.
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/<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ñó.