Initial commit: sap-legacy-webhook-emulator
Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para clientes que aun no estan en esa version: captura eventos via SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1 y despacha CloudEvents al mismo endpoint que el messenger real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
e1c8ee9015
42 files changed
+4042
No files matched your search
+792
@@ -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ñó.
|
||||
Reference in new issue
Block a user