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

+16
View File
@@ -0,0 +1,16 @@
# Service Layer del cliente que todavía no tiene el Webhook Messenger nativo
SL_BASE_URL="https://sap-servidor-cliente:50000/b1s/v1"
SL_COMPANY_DB="NOMBRE_COMPANIA"
SL_USERNAME="manager"
SL_PASSWORD=""
# Cada cuántos segundos el dispatcher revisa @OEVN en busca de eventos nuevos
POLL_INTERVAL_SECONDS=30
# Reintentos por suscripción antes de pasarla a State='X' (Exceptional)
RETRY_LIMIT=5
# El destino y la autenticación de cada envío NO se configuran acá: salen
# de @OEVC (WebhookURL/AuthType/AuthCred), administrado con
# `scripts/manage-subscriptions.ts` — una suscripción puede apuntar a
# cualquier URL (normalmente una instancia de sap-webhook-api).
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
logs/
tmp-test-events/
+178
View File
@@ -0,0 +1,178 @@
# sap-legacy-webhook-emulator
Reconstruye el mecanismo del **Webhook Messenger nativo** de SAP Business
One (disponible desde FP2605) para clientes que todavía no están en esa
versión, sin tocar nada de [`sap-webhook-api`](../sap-webhook-api): termina
mandando **el mismo payload CloudEvents contra el mismo endpoint**
(`POST /webhooks/sap`) que mandaría el Messenger real.
Ver [`docs/DISENO.md`](./docs/DISENO.md) para el diseño completo: por qué
`SBO_SP_PostTransactionNotice` y no `SBO_SP_TransactionNotification`, por
qué las 4 tablas son `UserTablesMD` en vez de SQL crudo, el diccionario de
datos completo (esquema real de SAP vs. el que implementamos), los mapeos
`ObjectType`↔`BizObject` / `TransType`↔`Operation`, y el diagrama de flujo
del dispatcher.
## Cómo funciona
1. **Captura**: la lógica vive en `SP_Webhook_CaptureEvent`
(`sql/transaction-notification/sp-webhook-capture-event-mssql.sql`) —
hace un único `INSERT` en la UserTable `@OEVN` si el evento no es un eco
de una escritura nuestra (ver `docs/DISENO.md`, hallazgo 5) y hay al
menos una suscripción activa interesada en ese `ObjectType`+`TransType`
(`EXISTS` contra `@EVC1`/`@OEVC` — lee la config real, no duplica
ninguna lista). El hook compartido de SAP, `SBO_SP_PostTransactionNotice`
(`sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql`),
queda reducido a un solo `EXEC` hacia ese procedimiento — así nuestra
lógica es identificable y no se mezcla con la de otros add-ons que
puedan tener código en ese mismo hook.
⚠️ El filtro por suscripción activa está escrito pero no reinstalado ni
reverificado contra un ambiente real todavía — ver `docs/DISENO.md`,
"Pendientes" ítem 0.
2. **Provisioning**: `npm run provision` crea (si no existen) las 4
UserTables — `@OEVC`/`@EVC1`/`@OEVN`/`@EVN1` — vía Service Layer
(`UserTablesMD`/`UserFieldsMD`), replicando el modelo de datos del
Webhook Messenger real.
3. **Dispatcher** (`src/dispatcher/`, corre con `npm start`): cada
`POLL_INTERVAL_SECONDS`, un tick hace dos pasadas:
- Registra eventos nuevos (`@OEVN` con `Status='N'`): resuelve
`BizObject`/`Operation` (`src/mapping/`), los matchea contra las
suscripciones activas en `@OEVC`+`@EVC1`, crea las filas de `@EVN1`
que falten, y descarta (borra) la fila de `@OEVN` si el `ObjectType`
no mapea a nada conocido o si no hay ninguna suscripción interesada.
- Despacha lo pendiente (`@EVN1` con `Status='F'`): agrupa por
suscripción, arma el batch de CloudEvents y hace un solo `POST` por
`WebhookURL` (firmando con HMAC si la suscripción es `AuthType='H'`),
actualiza `@EVN1`/`@OEVN`, e incrementa `Retry` en cada fallo. Al
superar `RETRY_LIMIT`, la suscripción pasa a `State='X'` (Exceptional)
y deja de reintentarse hasta que se reactive a mano.
## Quick start
```bash
npm install
cp .env.example .env # completar SL_* / POLL_INTERVAL_SECONDS / RETRY_LIMIT
npm run provision # crea las 4 UserTables en la company DB del cliente
```
Si preferís revisar o postear el body exacto a mano (Postman, curl,
`tools/sl.http`) en vez de correr `npm run provision`, están en
`json/user-tables/*.json` — uno por tabla, con `table` (body de
`POST /UserTablesMD`) y `fields` (bodies de `POST /UserFieldsMD`, en orden).
Se regeneran con `npx tsx scripts/export-table-json.ts` si cambia
`table-definitions.ts`.
Después, instalar en esa company DB, en este orden (SQL Server):
1. `sql/transaction-notification/sp-webhook-capture-event-mssql.sql`
(crea `SP_Webhook_CaptureEvent`).
2. `sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql` (reemplaza el cuerpo de
`SBO_SP_PostTransactionNotice`, que SAP crea vacío por cada compañía
nueva, con el `EXEC` hacia el procedimiento anterior).
En HANA es el mismo orden, con `sp-webhook-capture-event-hana.sql` y
`recreate_sbo_sp_posttransactionnotice_hana.sql` (que hace `DROP`+`CREATE`
en vez de `ALTER`, porque HANA no soporta modificar el cuerpo de un
procedimiento existente). ✅ Ambos ya se probaron e instalaron contra un
SAP Business One sobre HANA real (captura y entrega confirmadas de punta
a punta) — ⚠️ pero la versión *actual* del archivo (con el filtro por
suscripción activa, ver más arriba) todavía no se reinstaló ni reverificó
después de ese cambio.
Antes de arrancar el dispatcher, dar de alta al menos una suscripción (el
destino y la autenticación salen de acá, no de `.env`). Para probar sin
necesitar una instancia real de `sap-webhook-api`, usar
[`sap-test-legacy-webhook`](../sap-test-legacy-webhook) (receptor mínimo
de prueba, HTTP o HTTPS):
```bash
npx tsx scripts/manage-subscriptions.ts subscription:add SapWebhookApiTest https://192.168.1.166:3000/webhooks/sap
npx tsx scripts/manage-subscriptions.ts filter:add SapWebhookApiTest Orders A 17
```
Otros comandos disponibles: `subscription:list`, `subscription:update-url
<webhookId> <url>`, `subscription:pause`/`subscription:resume`,
`filter:list`, `filter:remove <webhookId> <bizObject> <transType>`.
```bash
npm run run-once # una sola pasada, útil para probar
npm run start # corre en loop
```
## Logs
Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día) además
de la consola — línea simple `<timestamp ISO> [NIVEL] mensaje`, legible
por cualquier visualizador genérico (`tail -f logs/dispatcher-*.log`,
`lnav`, etc.). Siempre deja al menos una línea de resumen por tick
(`Tick: N evento(s) nuevo(s), M pendiente(s) de entrega`), incluso sin
actividad — para poder confirmar que el proceso sigue vivo. No versionado
(`.gitignore`).
## Estructura
```
docs/DISENO.md # diseño completo, diccionario de datos, decisiones, diagrama de flujo
sql/transaction-notification/
sp-webhook-capture-event-mssql.sql # SP_Webhook_CaptureEvent — la lógica real (SQL Server)
alter_sbo_sp_posttransactionnotice.sql # SBO_SP_PostTransactionNotice — un EXEC al de arriba
sp-webhook-capture-event-hana.sql # ídem, HANA/SQLScript — no verificado, ver aviso en el archivo
recreate_sbo_sp_posttransactionnotice_hana.sql # ídem, HANA (DROP+CREATE, no ALTER)
scripts/
manage-subscriptions.ts # CLI: alta/listado de suscripciones y filtros (@OEVC/@EVC1)
export-table-json.ts # regenera json/user-tables/*.json a partir de table-definitions.ts
json/user-tables/ # body real de UserTablesMD/UserFieldsMD por tabla, para revisar o postear a mano
OEVC.json / EVC1.json / OEVN.json / EVN1.json
src/
config.ts # env: Service Layer, poll interval, retry limit
service-layer-client.ts # cliente mínimo del Service Layer (Login/GET/POST/PATCH/DELETE)
lib/
codes.ts # Code sintético (UUID) para filas creadas desde Node
timestamps.ts # formato HHMMSS para U_LastSentTM
cloud-event.ts # arma el payload CloudEvents a partir de una fila de @OEVN
logger.ts # log a consola + archivo (logs/dispatcher-YYYY-MM-DD.log)
mapping/
object-types.ts # ObjectType -> BizObject
trans-types.ts # TransType -> Operation
provisioning/
table-definitions.ts # esquema declarativo de las 4 UserTables (única fuente de verdad)
payloads.ts # traduce table-definitions.ts a los bodies reales de Service Layer
user-tables.ts # crea UserTablesMD/UserFieldsMD si no existen (idempotente)
run-provision.ts # CLI: npm run provision
subscriptions/
subscriptions-repository.ts # acceso a @OEVC/@EVC1 (suscripciones activas y filtros)
dispatcher/
oevn-repository.ts # acceso a @OEVN (leer/borrar/actualizar estado)
evn1-repository.ts # acceso a @EVN1 (crear, listar pendientes, marcar entrega/fallo)
send-webhook.ts # POST del batch de CloudEvents, firma HMAC opcional
own-table-guard.ts # detecta ecos de escrituras propias (ver docs/DISENO.md, hallazgo 5)
process-tick.ts # orquesta un tick completo
index.ts # cron loop / --once
```
## Stack
Mismo criterio que [`sap-intercompany-worker`](../sap-intercompany-worker):
script plano en TypeScript (ESM) con `tsx`, sin framework, sin base de
datos propia — todo el estado vive en las 4 UserTables dentro de SAP.
## Probar sin una instancia real de sap-webhook-api
[`sap-test-legacy-webhook`](../sap-test-legacy-webhook) es un receptor
mínimo (sin framework, sin autenticación por ahora, HTTP o HTTPS con
certificado autofirmado) pensado específicamente para probar este
proyecto de punta a punta sin depender de `sap-webhook-api`. Guarda cada
CloudEvent recibido en `data/events/<id>.json`. Usado para confirmar el
flujo completo (captura → dispatcher → entrega) y, por separado, que
HTTPS + certificado autofirmado funciona bien del lado del dispatcher.
## Simplificaciones deliberadas de esta v1
- Solo `AuthType` `N` (sin firma) y `H` (HMAC) — `Basic` y `OAuth` no están
implementados.
- Solo se maneja una clave simple en el evento (`FieldsInKe=1`, típicamente
`DocEntry`) — claves compuestas (ej. `CardCode`+`ItemCode`) no se arman
todavía en `data` del CloudEvent.
- No hay CLI para administrar `RETRY_LIMIT`/`State` de forma más fina que
pausar/reactivar toda la suscripción.
+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ñó.
@@ -0,0 +1,9 @@
{
"_comment": "POST /U_EVC1 — filtro para capturar Items (ObjectType=4) actualizados, para la suscripción TEST_LOCAL. Equivalente a: npx tsx scripts/manage-subscriptions.ts filter:add TEST_LOCAL Items U 4. El 'Code' es un UUID cualquiera (debe ser único, ver docs/DISENO.md decisión 5) — reemplazarlo si se postea manualmente.",
"Code": "<uuid-nuevo>",
"Name": "<mismo-uuid-que-Code>",
"U_WebhookID": "TEST_LOCAL",
"U_BizObject": "Items",
"U_TransType": "U",
"U_ObjectType": "4"
}
+10
View File
@@ -0,0 +1,10 @@
{
"_comment": "POST /U_OEVC — suscripción de prueba, apunta al listener HTTP local levantado para esta prueba (scripts/_tmp-fake-webhook-receiver.ts). Equivalente a: npx tsx scripts/manage-subscriptions.ts subscription:add TEST_LOCAL http://127.0.0.1:4100/webhooks/sap",
"Code": "TEST_LOCAL",
"Name": "TEST_LOCAL",
"U_WebhookURL": "http://127.0.0.1:4100/webhooks/sap",
"U_AuthType": "N",
"U_AuthCred": null,
"U_State": "A",
"U_WorkMode": "S"
}
+41
View File
@@ -0,0 +1,41 @@
{
"table": {
"TableName": "EVC1",
"TableDescription": "Webhook Subscription Filter",
"TableType": "bott_NoObject"
},
"fields": [
{
"TableName": "@EVC1",
"Name": "WebhookID",
"Description": "WebhookID",
"Type": "db_Alpha",
"Size": 40,
"EditSize": 40
},
{
"TableName": "@EVC1",
"Name": "BizObject",
"Description": "BizObject",
"Type": "db_Alpha",
"Size": 64,
"EditSize": 64
},
{
"TableName": "@EVC1",
"Name": "TransType",
"Description": "TransType",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@EVC1",
"Name": "ObjectType",
"Description": "ObjectType",
"Type": "db_Alpha",
"Size": 30,
"EditSize": 30
}
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"table": {
"TableName": "EVN1",
"TableDescription": "Webhook Event Delivery",
"TableType": "bott_NoObject"
},
"fields": [
{
"TableName": "@EVN1",
"Name": "EventID",
"Description": "EventID",
"Type": "db_Alpha",
"Size": 40,
"EditSize": 40
},
{
"TableName": "@EVN1",
"Name": "WebhookID",
"Description": "WebhookID",
"Type": "db_Alpha",
"Size": 40,
"EditSize": 40
},
{
"TableName": "@EVN1",
"Name": "Status",
"Description": "Status",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@EVN1",
"Name": "Retry",
"Description": "Retry",
"Type": "db_Numeric",
"Size": 6,
"EditSize": 6
},
{
"TableName": "@EVN1",
"Name": "ReturnCode",
"Description": "ReturnCode",
"Type": "db_Numeric",
"Size": 9,
"EditSize": 9
},
{
"TableName": "@EVN1",
"Name": "LastSentDT",
"Description": "LastSentDT",
"Type": "db_Date"
},
{
"TableName": "@EVN1",
"Name": "LastSentTM",
"Description": "LastSentTM",
"Type": "db_Numeric",
"Size": 9,
"EditSize": 9
}
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"table": {
"TableName": "OEVC",
"TableDescription": "Webhook Subscription",
"TableType": "bott_NoObject"
},
"fields": [
{
"TableName": "@OEVC",
"Name": "WebhookURL",
"Description": "WebhookURL",
"Type": "db_Alpha",
"Size": 254,
"EditSize": 254
},
{
"TableName": "@OEVC",
"Name": "AuthType",
"Description": "AuthType",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVC",
"Name": "AuthCred",
"Description": "AuthCred",
"Type": "db_Alpha",
"Size": 254,
"EditSize": 254
},
{
"TableName": "@OEVC",
"Name": "Handshake",
"Description": "Handshake",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVC",
"Name": "VerifyCert",
"Description": "VerifyCert",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVC",
"Name": "State",
"Description": "State",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVC",
"Name": "WorkMode",
"Description": "WorkMode",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVC",
"Name": "LastSentDT",
"Description": "LastSentDT",
"Type": "db_Date"
},
{
"TableName": "@OEVC",
"Name": "LastSentTM",
"Description": "LastSentTM",
"Type": "db_Numeric",
"Size": 9,
"EditSize": 9
},
{
"TableName": "@OEVC",
"Name": "LastErrMsg",
"Description": "LastErrMsg",
"Type": "db_Alpha",
"Size": 254,
"EditSize": 254
}
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"table": {
"TableName": "OEVN",
"TableDescription": "Webhook Event",
"TableType": "bott_NoObject"
},
"fields": [
{
"TableName": "@OEVN",
"Name": "SourceDB",
"Description": "SourceDB",
"Type": "db_Alpha",
"Size": 100,
"EditSize": 100
},
{
"TableName": "@OEVN",
"Name": "Status",
"Description": "Status",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVN",
"Name": "ObjectType",
"Description": "ObjectType",
"Type": "db_Alpha",
"Size": 30,
"EditSize": 30
},
{
"TableName": "@OEVN",
"Name": "TransType",
"Description": "TransType",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVN",
"Name": "FieldsInKe",
"Description": "FieldsInKe",
"Type": "db_Numeric",
"Size": 6,
"EditSize": 6
},
{
"TableName": "@OEVN",
"Name": "FieldNames",
"Description": "FieldNames",
"Type": "db_Alpha",
"Size": 254,
"EditSize": 254
},
{
"TableName": "@OEVN",
"Name": "FieldValue",
"Description": "FieldValue",
"Type": "db_Alpha",
"Size": 254,
"EditSize": 254
},
{
"TableName": "@OEVN",
"Name": "ReplayStat",
"Description": "ReplayStat",
"Type": "db_Alpha",
"Size": 1,
"EditSize": 1
},
{
"TableName": "@OEVN",
"Name": "UserID",
"Description": "UserID",
"Type": "db_Alpha",
"Size": 25,
"EditSize": 25
}
]
}
+1293
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "sap-legacy-webhook-emulator",
"version": "1.0.0",
"description": "Emula el Webhook Messenger nativo de SAP B1 (FP2605+) para clientes en versiones anteriores",
"type": "module",
"main": "src/index.ts",
"scripts": {
"provision": "tsx src/provisioning/run-provision.ts",
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"run-once": "tsx src/index.ts --once"
},
"keywords": [],
"author": "",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"axios": "^1.19.0",
"dotenv": "^17.4.2",
"node-cron": "^4.6.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/node-cron": "^3.0.11",
"tsx": "^4.23.12",
"typescript": "^7.0.2"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Listener HTTP mínimo, solo para esta prueba: simula sap-webhook-api pero
// en vez de guardar en Postgres, escribe cada POST recibido como JSON en
// tmp-test-events/ para poder inspeccionarlo. Se borra todo (este archivo
// y la carpeta) al terminar la prueba.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const outDir = fileURLToPath(new URL('../tmp-test-events/', import.meta.url));
const port = 4100;
const server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
console.log(`[fake-webhook] ${req.method} ${req.url}`);
if (req.method === 'POST') {
const fileName = `${Date.now()}.json`;
writeFileSync(`${outDir}${fileName}`, body);
console.log(`[fake-webhook] guardado: tmp-test-events/${fileName}`);
console.log(body);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
});
});
server.listen(port, () => console.log(`[fake-webhook] escuchando en http://127.0.0.1:${port}`));
+29
View File
@@ -0,0 +1,29 @@
// Exporta el body real de creación (Service Layer: UserTablesMD/UserFieldsMD)
// de cada una de las 4 UserTables a un archivo JSON en json/user-tables/ —
// útil para revisar el payload exacto, o para postearlo a mano (Postman,
// curl, tools/sl.http) sin correr `npm run provision`.
//
// Se genera a partir de src/provisioning/table-definitions.ts (única fuente
// de verdad) — si el esquema cambia ahí, correr de nuevo este script para
// regenerar los JSON.
//
// Uso: npx tsx scripts/export-table-json.ts
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { TABLE_DEFINITIONS } from '../src/provisioning/table-definitions.ts';
import { toUserFieldPayload, toUserTablePayload } from '../src/provisioning/payloads.ts';
const outDir = fileURLToPath(new URL('../json/user-tables/', import.meta.url));
mkdirSync(outDir, { recursive: true });
for (const definition of TABLE_DEFINITIONS) {
const payload = {
table: toUserTablePayload(definition),
fields: definition.fields.map((field) => toUserFieldPayload(definition.tableName, field)),
};
const filePath = `${outDir}${definition.tableName}.json`;
writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`Exportado: ${filePath}`);
}
+151
View File
@@ -0,0 +1,151 @@
// CLI de administración de suscripciones (@OEVC/@EVC1) contra el Service
// Layer configurado en .env. Requiere que las 4 UserTables ya existan
// (`npm run provision`).
//
// Uso:
// npx tsx scripts/manage-subscriptions.ts subscription:add <webhookId> <webhookUrl> [--hmac-key <secreto>]
// npx tsx scripts/manage-subscriptions.ts subscription:list
// npx tsx scripts/manage-subscriptions.ts subscription:update-url <webhookId> <webhookUrl>
// npx tsx scripts/manage-subscriptions.ts subscription:pause <webhookId>
// npx tsx scripts/manage-subscriptions.ts subscription:resume <webhookId>
//
// npx tsx scripts/manage-subscriptions.ts filter:add <webhookId> <bizObject> <transType> <objectType>
// npx tsx scripts/manage-subscriptions.ts filter:remove <webhookId> <bizObject> <transType>
// npx tsx scripts/manage-subscriptions.ts filter:list
import { config } from '../src/config.ts';
import { ServiceLayerClient } from '../src/service-layer-client.ts';
import { generateCode } from '../src/lib/codes.ts';
import { userTableEntity } from '../src/lib/service-layer-paths.ts';
const client = new ServiceLayerClient(config.serviceLayer);
const OEVC = userTableEntity('OEVC');
const EVC1 = userTableEntity('EVC1');
function fail(message: string): never {
console.error(`Error: ${message}`);
process.exit(1);
}
function flag(args: string[], name: string): string | undefined {
const index = args.indexOf(`--${name}`);
return index === -1 ? undefined : args[index + 1];
}
async function main() {
const [command, ...args] = process.argv.slice(2);
switch (command) {
case 'subscription:add': {
const [webhookId, webhookUrl] = args;
if (!webhookId || !webhookUrl) fail('subscription:add <webhookId> <webhookUrl> [--hmac-key <secreto>]');
const hmacKey = flag(args, 'hmac-key');
await client.post(`/${OEVC}`, {
Code: webhookId,
Name: webhookId,
U_WebhookURL: webhookUrl,
U_AuthType: hmacKey ? 'H' : 'N',
U_AuthCred: hmacKey ? JSON.stringify({ Key: hmacKey, Algorithm: 'hmacsha256' }) : null,
U_State: 'A',
U_WorkMode: 'S',
});
console.log(`Suscripción ${webhookId} creada (${webhookUrl}).`);
break;
}
case 'subscription:list': {
const rows = await client.getAll(`/${OEVC}`);
console.table(
rows.map((row) => ({
webhookId: row.Code,
url: row.U_WebhookURL,
authType: row.U_AuthType,
state: row.U_State,
})),
);
break;
}
case 'subscription:update-url': {
const [webhookId, webhookUrl] = args;
if (!webhookId || !webhookUrl) fail('subscription:update-url <webhookId> <webhookUrl>');
await client.patch(`/${OEVC}('${webhookId}')`, { U_WebhookURL: webhookUrl });
console.log(`Suscripción ${webhookId} actualizada -> ${webhookUrl}.`);
break;
}
case 'subscription:pause': {
const [webhookId] = args;
if (!webhookId) fail('subscription:pause <webhookId>');
await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'I' });
console.log(`Suscripción ${webhookId} pausada (State='I').`);
break;
}
case 'subscription:resume': {
const [webhookId] = args;
if (!webhookId) fail('subscription:resume <webhookId>');
await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'A' });
console.log(`Suscripción ${webhookId} reactivada (State='A').`);
break;
}
case 'filter:add': {
const [webhookId, bizObject, transType, objectType] = args;
if (!webhookId || !bizObject || !transType || !objectType) {
fail('filter:add <webhookId> <bizObject> <transType> <objectType>');
}
await client.post(`/${EVC1}`, {
Code: generateCode(),
Name: `${webhookId}/${bizObject}/${transType}`,
U_WebhookID: webhookId,
U_BizObject: bizObject,
U_TransType: transType,
U_ObjectType: objectType,
});
console.log(`Filtro agregado: ${webhookId} -> ${bizObject}/${transType} (ObjectType ${objectType}).`);
break;
}
case 'filter:remove': {
const [webhookId, bizObject, transType] = args;
if (!webhookId || !bizObject || !transType) {
fail('filter:remove <webhookId> <bizObject> <transType>');
}
const filter = encodeURIComponent(
`U_WebhookID eq '${webhookId}' and U_BizObject eq '${bizObject}' and U_TransType eq '${transType}'`,
);
const res = await client.get(`/${EVC1}?$filter=${filter}`);
const rows = (res.data?.value as { Code: string }[]) ?? [];
if (rows.length === 0) fail(`no existe el filtro ${webhookId} -> ${bizObject}/${transType}`);
await client.delete(`/${EVC1}('${rows[0].Code}')`);
console.log(`Filtro eliminado: ${webhookId} -> ${bizObject}/${transType}.`);
break;
}
case 'filter:list': {
const rows = await client.getAll(`/${EVC1}`);
console.table(
rows.map((row) => ({
webhookId: row.U_WebhookID,
bizObject: row.U_BizObject,
transType: row.U_TransType,
objectType: row.U_ObjectType,
})),
);
break;
}
default:
fail(
`comando desconocido: ${command ?? '(ninguno)'}\nComandos disponibles: subscription:add, subscription:list, ` +
'subscription:update-url, subscription:pause, subscription:resume, filter:add, filter:remove, filter:list',
);
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
@@ -0,0 +1,33 @@
-- SBO_SP_PostTransactionNotice — SQL Server / T-SQL
--
-- Corre DESPUÉS del commit de la transacción real de SAP (a diferencia de
-- SBO_SP_TransactionNotification, que es síncrono y puede hacer rollback).
-- 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) — por eso el cuerpo se deja
-- reducido a un solo llamado a SP_Webhook_CaptureEvent
-- (ver sp-webhook-capture-event-mssql.sql), donde vive toda la lógica.
--
-- Instalar, en este orden:
-- 1) sp-webhook-capture-event-mssql.sql
-- 2) este archivo (reemplaza el cuerpo del SBO_SP_PostTransactionNotice
-- existente en la company DB)
ALTER PROCEDURE [dbo].[SBO_SP_PostTransactionNotice]
@object_type NVARCHAR(20),
@transaction_type NCHAR(1),
@num_of_cols_in_key INT,
@list_of_key_cols_tab_del NVARCHAR(255),
@list_of_cols_val_tab_del NVARCHAR(255)
AS
BEGIN
DECLARE @error INT
DECLARE @error_message NVARCHAR(200)
EXEC [dbo].[SP_Webhook_CaptureEvent]
@object_type, @transaction_type, @num_of_cols_in_key,
@list_of_key_cols_tab_del, @list_of_cols_val_tab_del,
@error OUTPUT, @error_message OUTPUT
SELECT @error, @error_message
END
GO
@@ -0,0 +1,40 @@
-- SBO_SP_PostTransactionNotice — SAP HANA / SQLScript
--
-- Corre DESPUÉS del commit de la transacción real de SAP. 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) — por eso el cuerpo se deja
-- reducido a un solo CALL a SP_Webhook_CaptureEvent
-- (ver sp-webhook-capture-event-hana.sql), donde vive toda la lógica.
--
-- A diferencia de SQL Server (que soporta ALTER PROCEDURE), en HANA no se
-- puede modificar el cuerpo de un procedimiento existente — hay que
-- reemplazarlo con DROP + CREATE. El DROP asume que el procedimiento ya
-- existe (SAP lo crea automáticamente al crear la compañía).
--
-- ⚠️ NO VERIFICADO CONTRA UN HANA REAL — ver el aviso completo en
-- sp-webhook-capture-event-hana.sql.
--
-- Instalar, en este orden:
-- 1) sp-webhook-capture-event-hana.sql
-- 2) este archivo
DROP PROCEDURE "SBO_SP_PostTransactionNotice";
CREATE PROCEDURE "SBO_SP_PostTransactionNotice" (
IN object_type NVARCHAR(20),
IN transaction_type NVARCHAR(1),
IN num_of_cols_in_key INTEGER,
IN list_of_key_cols_tab_del NVARCHAR(255),
IN list_of_cols_val_tab_del NVARCHAR(255),
OUT error INTEGER,
OUT error_message NVARCHAR(200)
)
LANGUAGE SQLSCRIPT
AS
BEGIN
CALL "SP_Webhook_CaptureEvent" (
:object_type, :transaction_type, :num_of_cols_in_key,
:list_of_key_cols_tab_del, :list_of_cols_val_tab_del,
error, error_message
);
END;
@@ -0,0 +1,103 @@
-- SP_Webhook_CaptureEvent — SAP HANA / SQLScript
--
-- Equivalente HANA de sp-webhook-capture-event-mssql.sql: toda la lógica
-- de captura vive acá, no en el cuerpo de SBO_SP_PostTransactionNotice
-- (hook compartido de SAP, ver recreate_sbo_sp_posttransactionnotice_hana.sql).
--
-- Requiere que la UserTable "@OEVN" ya exista (creada por
-- `npm run provision`, ver src/provisioning/).
--
-- ✅ Verificado contra un HANA real (SAP Business One), instalado y
-- funcionando — incluida la sintaxis del EXIT HANDLER y el filtro de eco
-- (INSERT...SELECT...WHERE, más abajo): se confirmó que un DELETE vía
-- Service Layer sobre una fila de @OEVN ya no genera ningún eco nuevo.
-- ⚠️ El filtro por suscripción activa (EXISTS contra @EVC1/@OEVC, más
-- abajo) es nuevo y todavía NO se probó contra ese ambiente.
--
-- U_UserID: no se completa. CURRENT_USER da el usuario de la conexión a
-- HANA (una cuenta técnica), no el usuario real de SAP que hizo la
-- transacción — confirmado contra este mismo ambiente. Ninguno de los 5
-- parámetros que recibe el hook trae ese dato, así que por ahora el campo
-- queda sin poblar en las dos variantes (ver también mssql.sql).
--
-- Name: en una UserTable de SAP, "Name" también debe ser único (no solo
-- "Code") — confirmado contra Service Layer real (ODBC -2035 al repetir
-- un Name, con Code distinto). Por eso Name usa el mismo UUID que Code:
-- cualquier otro valor (como el propio ObjectType) se repite entre
-- eventos del mismo tipo y hace fallar el INSERT en el segundo evento
-- (en silencio, por el EXIT HANDLER).
--
-- Eco de nuestras propias escrituras: cualquier POST/PATCH/DELETE que
-- hace el dispatcher sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer
-- dispara este mismo hook de nuevo, con un ObjectType especial de SAP
-- para "operación genérica sobre una UserTable simple": un código
-- negativo + tab + nombre físico de la tabla (confirmado real:
-- "-3\t@OEVN" para un DELETE). Si insertáramos esa fila iba a generar
-- otro eco al borrarla, indefinidamente — por eso el INSERT usa
-- SELECT...FROM DUMMY WHERE en vez de VALUES, para poder filtrar el
-- ObjectType sin agregar una rama procedural nueva (ver docs/DISENO.md,
-- hallazgo 5).
--
-- Filtro por suscripción activa: además de lo anterior, solo se inserta
-- si existe al menos una fila en @EVC1 (con su @OEVC en State='A') para
-- este ObjectType+TransType exacto. No es "lógica de negocio propia" del
-- SP — consulta la misma tabla de configuración que ya es la fuente de
-- verdad para el dispatcher, no una copia/lista fija a mantener
-- sincronizada. Evita ensuciar @OEVN con todo lo que SAP genera y a
-- nadie le interesa (en el ambiente de prueba, cientos de eventos por
-- minuto sin ninguna suscripción real).
DROP PROCEDURE "SP_Webhook_CaptureEvent";
CREATE PROCEDURE "SP_Webhook_CaptureEvent" (
IN object_type NVARCHAR(20),
IN transaction_type NVARCHAR(1),
IN num_of_cols_in_key INTEGER,
IN list_of_key_cols_tab_del NVARCHAR(255),
IN list_of_cols_val_tab_del NVARCHAR(255),
OUT error INTEGER,
OUT error_message NVARCHAR(200)
)
LANGUAGE SQLSCRIPT
AS
BEGIN
DECLARE v_code NVARCHAR(36);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
error := ::SQL_ERROR_CODE;
error_message := SUBSTR(::SQL_ERROR_MESSAGE, 1, 200);
END;
error := 0;
error_message := N'Ok';
-- Sin condiciones de negocio: inserta siempre, salvo que sea un eco
-- de una escritura nuestra sobre estas mismas 4 tablas (arriba).
SELECT SYSUUID INTO v_code FROM DUMMY;
INSERT INTO "@OEVN" (
"Code", "Name",
"U_SourceDB", "U_Status", "U_ObjectType", "U_TransType",
"U_FieldsInKe", "U_FieldNames", "U_FieldValue", "U_ReplayStat"
)
SELECT
:v_code, :v_code,
CURRENT_SCHEMA, 'N', :object_type, :transaction_type,
:num_of_cols_in_key, :list_of_key_cols_tab_del, :list_of_cols_val_tab_del, 'N'
FROM DUMMY
WHERE NOT (
:object_type LIKE '-%' || CHAR(9) || '@OEVC'
OR :object_type LIKE '-%' || CHAR(9) || '@EVC1'
OR :object_type LIKE '-%' || CHAR(9) || '@OEVN'
OR :object_type LIKE '-%' || CHAR(9) || '@EVN1'
)
AND EXISTS (
SELECT 1
FROM "@EVC1" f
INNER JOIN "@OEVC" s ON s."Code" = f."U_WebhookID"
WHERE f."U_ObjectType" = :object_type
AND f."U_TransType" = :transaction_type
AND s."U_State" = 'A'
);
END;
@@ -0,0 +1,98 @@
-- SP_Webhook_CaptureEvent — SQL Server / T-SQL
--
-- Toda la lógica de captura del emulador vive acá, en un procedimiento
-- propio y con nombre descriptivo — no directamente en el cuerpo de
-- SBO_SP_PostTransactionNotice (ver mssql.sql), que es un hook compartido
-- de SAP donde puede convivir código de otros add-ons. Así el nuestro
-- queda identificable y fácil de mantener o remover por separado.
--
-- Requiere que la UserTable "@OEVN" ya exista (creada por
-- `npm run provision`, ver src/provisioning/).
--
-- U_UserID: no se completa. SUSER_SNAME() da el login de la conexión a SQL
-- Server (una cuenta técnica), no el usuario real de SAP que hizo la
-- transacción — confirmado equivalente en HANA (CURRENT_USER, ver
-- sp-webhook-capture-event-hana.sql). Ninguno de los 5 parámetros que
-- recibe el hook trae ese dato, así que por ahora el campo queda sin
-- poblar en las dos variantes.
--
-- Name: en una UserTable de SAP, "Name" también debe ser único (no solo
-- "Code") — confirmado contra Service Layer real (ODBC -2035 al repetir
-- un Name, con Code distinto). Por eso Name usa el mismo UUID que Code:
-- cualquier otro valor (como el propio ObjectType) se repite entre
-- eventos del mismo tipo y hace fallar el INSERT en el segundo evento
-- (en silencio, por el TRY/CATCH).
--
-- Eco de nuestras propias escrituras: cualquier POST/PATCH/DELETE que
-- hace el dispatcher sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer
-- dispara este mismo hook de nuevo, con un ObjectType especial de SAP
-- para "operación genérica sobre una UserTable simple": un código
-- negativo + tab + nombre físico de la tabla (confirmado real:
-- "-3\t@OEVN" para un DELETE). Si insertáramos esa fila iba a generar
-- otro eco al borrarla, indefinidamente — por eso NO se inserta nada
-- cuando el ObjectType matchea ese patrón para una de nuestras 4 tablas
-- (ver docs/DISENO.md, hallazgo 5).
--
-- Filtro por suscripción activa: además de lo anterior, solo se inserta
-- si existe al menos una fila en @EVC1 (con su @OEVC en State='A') para
-- este ObjectType+TransType exacto. No es "lógica de negocio propia" del
-- SP — consulta la misma tabla de configuración que ya es la fuente de
-- verdad para el dispatcher, no una copia/lista fija a mantener
-- sincronizada. Evita ensuciar @OEVN con todo lo que SAP genera y a
-- nadie le interesa (en el ambiente de prueba, cientos de eventos por
-- minuto sin ninguna suscripción real).
CREATE OR ALTER PROCEDURE [dbo].[SP_Webhook_CaptureEvent]
@object_type NVARCHAR(20),
@transaction_type NCHAR(1),
@num_of_cols_in_key INT,
@list_of_key_cols_tab_del NVARCHAR(255),
@list_of_cols_val_tab_del NVARCHAR(255),
@error INT OUTPUT,
@error_message NVARCHAR(200) OUTPUT
AS
BEGIN
SET @error = 0
SET @error_message = N'Ok'
DECLARE @code NVARCHAR(36) = CONVERT(NVARCHAR(36), NEWID())
DECLARE @isOwnTableEcho BIT = CASE
WHEN @object_type LIKE '-%' + CHAR(9) + '@OEVC' THEN 1
WHEN @object_type LIKE '-%' + CHAR(9) + '@EVC1' THEN 1
WHEN @object_type LIKE '-%' + CHAR(9) + '@OEVN' THEN 1
WHEN @object_type LIKE '-%' + CHAR(9) + '@EVN1' THEN 1
ELSE 0
END
DECLARE @hasActiveSubscription BIT = CASE WHEN EXISTS (
SELECT 1
FROM [dbo].[@EVC1] f
INNER JOIN [dbo].[@OEVC] s ON s.Code = f.U_WebhookID
WHERE f.U_ObjectType = @object_type
AND f.U_TransType = @transaction_type
AND s.U_State = 'A'
) THEN 1 ELSE 0 END
-- Solo inserta si no es un eco de nuestras propias tablas (arriba) y
-- si hay una suscripción activa interesada en este ObjectType+TransType.
IF @isOwnTableEcho = 0 AND @hasActiveSubscription = 1
BEGIN
BEGIN TRY
INSERT INTO [dbo].[@OEVN] (
Code, Name,
U_SourceDB, U_Status, U_ObjectType, U_TransType,
U_FieldsInKe, U_FieldNames, U_FieldValue, U_ReplayStat
) VALUES (
@code, @code,
DB_NAME(), 'N', @object_type, @transaction_type,
@num_of_cols_in_key, @list_of_key_cols_tab_del, @list_of_cols_val_tab_del, 'N'
)
END TRY
BEGIN CATCH
SET @error = ERROR_NUMBER()
SET @error_message = LEFT(ERROR_MESSAGE(), 200)
END CATCH
END
END
GO
+28
View File
@@ -0,0 +1,28 @@
import 'dotenv/config';
export interface ServiceLayerTarget {
baseUrl: string;
companyDb: string;
username: string;
password: string;
}
export const config = {
serviceLayer: {
baseUrl: requireEnv('SL_BASE_URL'),
companyDb: requireEnv('SL_COMPANY_DB'),
username: requireEnv('SL_USERNAME'),
password: requireEnv('SL_PASSWORD'),
} satisfies ServiceLayerTarget,
// El destino y la autenticación de cada envío salen de @OEVC (WebhookURL/
// AuthType/AuthCred), no de config — una suscripción puede apuntar a
// cualquier URL, no solo a una instancia fija de sap-webhook-api.
pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30),
retryLimit: Number(process.env.RETRY_LIMIT ?? 5),
};
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Falta la variable de entorno ${name}`);
return value;
}
+72
View File
@@ -0,0 +1,72 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { generateCode } from '../lib/codes.ts';
import { hhmmss } from '../lib/timestamps.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const ENTITY = userTableEntity('EVN1');
export interface Evn1Row {
code: string;
eventId: string;
webhookId: string;
status: string;
retry: number;
}
function toRow(raw: Record<string, unknown>): Evn1Row {
return {
code: raw.Code as string,
eventId: raw.U_EventID as string,
webhookId: raw.U_WebhookID as string,
status: raw.U_Status as string,
retry: (raw.U_Retry as number) ?? 0,
};
}
export async function findExisting(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<Evn1Row | null> {
const filter = encodeURIComponent(`U_EventID eq '${eventId}' and U_WebhookID eq '${webhookId}'`);
const res = await client.get(`/${ENTITY}?$filter=${filter}`);
const rows = (res.data?.value as Record<string, unknown>[]) ?? [];
return rows.length > 0 ? toRow(rows[0]) : null;
}
export async function create(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<void> {
await client.post(`/${ENTITY}`, {
Code: generateCode(),
Name: `${eventId}/${webhookId}`,
U_EventID: eventId,
U_WebhookID: webhookId,
U_Status: 'F',
U_Retry: 0,
});
}
export async function listPending(client: ServiceLayerClient): Promise<Evn1Row[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'F'")}`);
return rows.map(toRow);
}
export async function listForEvent(client: ServiceLayerClient, eventId: string): Promise<Evn1Row[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent(`U_EventID eq '${eventId}'`)}`);
return rows.map(toRow);
}
export async function markDelivered(client: ServiceLayerClient, code: string, returnCode: number): Promise<void> {
const now = new Date();
await client.patch(`/${ENTITY}('${code}')`, {
U_Status: 'D',
U_ReturnCode: returnCode,
U_LastSentDT: now.toISOString(),
U_LastSentTM: hhmmss(now),
});
}
export async function markFailedAttempt(client: ServiceLayerClient, row: Evn1Row, returnCode: number): Promise<void> {
const now = new Date();
await client.patch(`/${ENTITY}('${row.code}')`, {
U_Retry: row.retry + 1,
U_ReturnCode: returnCode,
U_LastSentDT: now.toISOString(),
U_LastSentTM: hhmmss(now),
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const ENTITY = userTableEntity('OEVN');
export interface OevnRow {
code: string;
sourceDb: string;
status: string;
objectType: string;
transType: string;
fieldNames: string;
fieldValue: string;
}
function toRow(raw: Record<string, unknown>): OevnRow {
return {
code: raw.Code as string,
sourceDb: raw.U_SourceDB as string,
status: raw.U_Status as string,
objectType: raw.U_ObjectType as string,
transType: raw.U_TransType as string,
fieldNames: raw.U_FieldNames as string,
fieldValue: raw.U_FieldValue as string,
};
}
export async function listNewEvents(client: ServiceLayerClient): Promise<OevnRow[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'N'")}`);
return rows.map(toRow);
}
export async function getEvent(client: ServiceLayerClient, code: string): Promise<OevnRow | null> {
const res = await client.get(`/${ENTITY}('${code}')`);
return res.status === 200 ? toRow(res.data) : null;
}
export async function deleteEvent(client: ServiceLayerClient, code: string): Promise<void> {
await client.delete(`/${ENTITY}('${code}')`);
}
export async function setEventStatus(client: ServiceLayerClient, code: string, status: string): Promise<void> {
await client.patch(`/${ENTITY}('${code}')`, { U_Status: status });
}
+18
View File
@@ -0,0 +1,18 @@
// SAP emite un `object_type` especial para operaciones sobre cualquier
// UserTable simple (no registrada como UDO completo): un código negativo
// seguido de un tab y el nombre físico de la tabla (con "@"), en vez de un
// código positivo de BoObjectTypes. Confirmado contra Service Layer real:
// un DELETE sobre "@OEVN" hecho por el propio dispatcher generó
// object_type = "-3\t@OEVN" — es decir, nuestras propias escrituras
// (DELETE/PATCH/POST sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer)
// producen un evento nuevo describiéndose a sí mismas. Si tratáramos ese
// evento como cualquier otro (sin match → borrar), el borrado generaría
// otro eco, y así indefinidamente. Por eso estos eventos nunca se tocan:
// ni se resuelven, ni se borran — quedan con Status='N' para siempre,
// cortando la cascada en el primer nivel. Ver docs/DISENO.md.
const OWN_TABLES = new Set(['@OEVC', '@EVC1', '@OEVN', '@EVN1']);
export function isEchoOfOwnTable(objectType: string): boolean {
const [code, tableName] = objectType.split('\t');
return code !== undefined && code.startsWith('-') && OWN_TABLES.has(tableName ?? '');
}
+144
View File
@@ -0,0 +1,144 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { config } from '../config.ts';
import { bizObjectFor } from '../mapping/object-types.ts';
import { buildCloudEvent } from '../lib/cloud-event.ts';
import * as oevn from './oevn-repository.ts';
import * as evn1 from './evn1-repository.ts';
import * as subscriptions from '../subscriptions/subscriptions-repository.ts';
import { sendBatch, type SendResult } from './send-webhook.ts';
import { isEchoOfOwnTable } from './own-table-guard.ts';
import { logger } from '../lib/logger.ts';
// Un tick del dispatcher: registrar eventos nuevos capturados por el SP
// (asignarles las suscripciones que les corresponden) y despachar todo lo
// que siga pendiente de entrega — nuevo o reintento. Ver docs/DISENO.md,
// sección "Dispatcher — lenguaje, arquitectura y flujo".
export async function processTick(client: ServiceLayerClient): Promise<void> {
const newCount = await registerNewEvents(client);
const pendingCount = await sendPendingDeliveries(client);
// Siempre deja un rastro, aunque no haya nada para hacer — así un
// visualizador de logs muestra que el proceso sigue vivo entre tanda y
// tanda de actividad real.
logger.info(`[dispatcher] Tick: ${newCount} evento(s) nuevo(s), ${pendingCount} pendiente(s) de entrega.`);
}
async function registerNewEvents(client: ServiceLayerClient): Promise<number> {
const newEvents = await oevn.listNewEvents(client);
if (newEvents.length === 0) return 0;
const filters = await subscriptions.listFilters(client);
for (const event of newEvents) {
// Eco de una escritura nuestra sobre @OEVC/@EVC1/@OEVN/@EVN1 (ver
// own-table-guard.ts) — nunca se toca, ni se resuelve ni se borra,
// para no perpetuar la cascada.
if (isEchoOfOwnTable(event.objectType)) continue;
let bizObject: string;
try {
bizObject = bizObjectFor(event.objectType);
} catch {
logger.warn(`[dispatcher] ObjectType desconocido '${event.objectType}' (evento ${event.code}) — se descarta.`);
await oevn.deleteEvent(client, event.code);
continue;
}
const matches = filters.filter((f) => f.bizObject === bizObject && f.transType === event.transType);
if (matches.length === 0) {
logger.info(`[dispatcher] Sin suscripciones para ${bizObject}/${event.transType} (evento ${event.code}) — se descarta.`);
await oevn.deleteEvent(client, event.code);
continue;
}
for (const match of matches) {
const existing = await evn1.findExisting(client, event.code, match.webhookId);
if (!existing) await evn1.create(client, event.code, match.webhookId);
}
await oevn.setEventStatus(client, event.code, 'P');
}
return newEvents.length;
}
async function sendPendingDeliveries(client: ServiceLayerClient): Promise<number> {
const pending = await evn1.listPending(client);
if (pending.length === 0) return 0;
const activeSubscriptions = await subscriptions.listActiveSubscriptions(client);
const byWebhookId = new Map<string, evn1.Evn1Row[]>();
for (const row of pending) {
if (row.retry >= config.retryLimit) {
if (activeSubscriptions.has(row.webhookId)) {
logger.warn(`[dispatcher] Suscripción ${row.webhookId} superó ${config.retryLimit} reintentos — pasa a State='X'.`);
await subscriptions.markExceptional(client, row.webhookId);
activeSubscriptions.delete(row.webhookId);
}
continue;
}
if (!activeSubscriptions.has(row.webhookId)) continue; // pausada / exceptional: no reintentar
const group = byWebhookId.get(row.webhookId) ?? [];
group.push(row);
byWebhookId.set(row.webhookId, group);
}
for (const [webhookId, rows] of byWebhookId) {
await sendGroup(client, activeSubscriptions.get(webhookId)!, rows);
}
await recomputeEventStatuses(client, [...new Set(pending.map((r) => r.eventId))]);
return pending.length;
}
async function sendGroup(
client: ServiceLayerClient,
subscription: subscriptions.Subscription,
rows: evn1.Evn1Row[],
): Promise<void> {
const events = [];
const deliverableRows: evn1.Evn1Row[] = [];
for (const row of rows) {
const event = await oevn.getEvent(client, row.eventId);
if (!event) continue; // evento borrado/inconsistente, se ignora
events.push(buildCloudEvent(event));
deliverableRows.push(row);
}
if (events.length === 0) return;
let result: SendResult;
try {
result = await sendBatch(subscription, events);
} catch (err) {
logger.warn(`[dispatcher] Error de red mandando a ${subscription.url}: ${err instanceof Error ? err.message : String(err)}`);
result = { ok: false, status: 0, body: '', finalUrl: subscription.url };
}
// "Entregado" aquí significa solo "el receptor contestó 2xx". Se registran
// status y respuesta porque un destino asíncrono puede aceptar con 202 y
// devolver un id de ejecución: sin ese dato no hay cómo distinguir una
// entrega procesada de una encolada que después se descartó.
const detail = `HTTP ${result.status}${result.body ? ` ${result.body}` : ''}`;
const redirect = result.finalUrl !== subscription.url ? ` [redirigido a ${result.finalUrl}]` : '';
if (result.ok) {
logger.info(
`[dispatcher] Entregados ${events.length} evento(s) a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`,
);
} else {
logger.warn(`[dispatcher] Falló la entrega a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`);
}
for (const row of deliverableRows) {
if (result.ok) await evn1.markDelivered(client, row.code, result.status);
else await evn1.markFailedAttempt(client, row, result.status);
}
}
async function recomputeEventStatuses(client: ServiceLayerClient, eventIds: string[]): Promise<void> {
for (const eventId of eventIds) {
const rows = await evn1.listForEvent(client, eventId);
if (rows.length === 0) continue;
const allDelivered = rows.every((r) => r.status === 'D');
await oevn.setEventStatus(client, eventId, allDelivered ? 'D' : 'P');
}
}
+51
View File
@@ -0,0 +1,51 @@
import axios from 'axios';
import https from 'node:https';
import { createHmac } from 'node:crypto';
import type { CloudEvent } from '../lib/cloud-event.ts';
import type { Subscription } from '../subscriptions/subscriptions-repository.ts';
const http = axios.create({
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
validateStatus: () => true,
});
export interface SendResult {
ok: boolean;
status: number;
// Respuesta cruda recortada. Un 2xx no prueba que el receptor haya
// procesado el evento: hay destinos que encolan y devuelven 202 con un id
// de ejecución, y sin este dato no hay forma de rastrear qué pasó después.
body: string;
// URL efectiva tras seguir redirects. Si difiere de subscription.url, el
// POST pudo haberse degradado a GET y perdido el body por el camino.
finalUrl: string;
}
const MAX_BODY_LOG = 300;
function summarizeBody(data: unknown): string {
const text = typeof data === 'string' ? data : JSON.stringify(data) ?? '';
return text.length > MAX_BODY_LOG ? `${text.slice(0, MAX_BODY_LOG)}…` : text;
}
// Mismo esquema de firma que ya implementa sap-webhook-api
// (X-B1-Webhook-Signature, HMAC-SHA256 en base64 sobre los bytes crudos del
// body) — el AuthCred de una suscripción HMAC es el JSON embebido que usa
// SAP internamente: {"Key": "<secreto>", "Algorithm": "hmacsha256"}.
export async function sendBatch(subscription: Subscription, events: CloudEvent[]): Promise<SendResult> {
const body = JSON.stringify(events);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (subscription.authType === 'H' && subscription.authCred) {
const { Key } = JSON.parse(subscription.authCred) as { Key: string };
headers['X-B1-Webhook-Signature'] = createHmac('sha256', Key).update(body).digest('base64');
}
const res = await http.post(subscription.url, body, { headers });
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
body: summarizeBody(res.data),
finalUrl: (res.request as { res?: { responseUrl?: string } })?.res?.responseUrl ?? subscription.url,
};
}
+27
View File
@@ -0,0 +1,27 @@
import cron from 'node-cron';
import { config } from './config.ts';
import { ServiceLayerClient } from './service-layer-client.ts';
import { processTick } from './dispatcher/process-tick.ts';
import { logger } from './lib/logger.ts';
const client = new ServiceLayerClient(config.serviceLayer);
async function runOnce(): Promise<void> {
try {
await processTick(client);
} catch (err) {
logger.error('[dispatcher] Error procesando el tick:', err);
}
}
const runOnceOnly = process.argv.includes('--once');
if (runOnceOnly) {
await runOnce();
process.exit(0);
} else {
const cronExpression = `*/${config.pollIntervalSeconds} * * * * *`;
logger.info(`[dispatcher] sap-legacy-webhook-emulator arrancó. Revisando @OEVN cada ${config.pollIntervalSeconds}s.`);
cron.schedule(cronExpression, runOnce);
await runOnce(); // primera corrida inmediata, sin esperar el primer tick
}
+33
View File
@@ -0,0 +1,33 @@
import { bizObjectFor } from '../mapping/object-types.ts';
import { operationFor } from '../mapping/trans-types.ts';
import type { OevnRow } from '../dispatcher/oevn-repository.ts';
// Mismo schema CloudEvents que ya implementa sap-webhook-api (ver
// webhooks.controller.ts) — el emulador debe producir un payload
// indistinguible del que manda el Webhook Messenger real.
export interface CloudEvent {
id: string;
specversion: '1.0';
source: string;
type: string;
subject: string;
time: string;
datacontenttype: 'application/json';
data: Record<string, string>;
}
export function buildCloudEvent(event: OevnRow): CloudEvent {
const bizObject = bizObjectFor(event.objectType);
const operation = operationFor(event.transType);
return {
id: event.code,
specversion: '1.0',
source: `/default/sap.b1/${event.sourceDb}`,
type: `sap.b1.${bizObject}.${operation}.v1`,
subject: event.fieldValue,
time: new Date().toISOString(),
datacontenttype: 'application/json',
data: { [event.fieldNames]: event.fieldValue },
};
}
+9
View File
@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
// Code sintético para filas de nuestros UserTable creadas desde Node
// (@EVC1, @EVN1 — ver docs/DISENO.md, decisión 5): nunca es el
// identificador de negocio real, solo una clave única. Mismo criterio que
// usa el SP en SQL (NEWID() en SQL Server, SYSUUID en HANA).
export function generateCode(): string {
return randomUUID();
}
+36
View File
@@ -0,0 +1,36 @@
import { appendFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// Log a archivo, uno por día (logs/dispatcher-YYYY-MM-DD.log), además de
// consola — para que un servicio Windows (NSSM/PM2/Task Scheduler, ver
// README) no dependa de que algo capture su stdout. Formato de línea
// simple (timestamp ISO + nivel entre corchetes) para que lo pueda leer
// cualquier visualizador de logs genérico (ej. lnav, o un `tail -f`).
const LOGS_DIR = fileURLToPath(new URL('../../logs/', import.meta.url));
mkdirSync(LOGS_DIR, { recursive: true });
function logFilePath(date: Date): string {
const day = date.toISOString().slice(0, 10);
return `${LOGS_DIR}dispatcher-${day}.log`;
}
function write(level: string, message: string): void {
const now = new Date();
appendFileSync(logFilePath(now), `${now.toISOString()} [${level}] ${message}\n`);
}
export const logger = {
info(message: string): void {
console.log(message);
write('INFO', message);
},
warn(message: string): void {
console.warn(message);
write('WARN', message);
},
error(message: string, err?: unknown): void {
const detail = err instanceof Error ? (err.stack ?? err.message) : err !== undefined ? String(err) : '';
console.error(message, err ?? '');
write('ERROR', detail ? `${message} ${detail}` : message);
},
};
+8
View File
@@ -0,0 +1,8 @@
// El EntitySet que expone Service Layer para leer/escribir FILAS de un
// UserTable es "U_<TableName>" (con el prefijo "U_") — distinto del nombre
// que usan los endpoints de metadata (UserTablesMD/UserFieldsMD, que usan
// el TableName sin prefijo). Comportamiento real confirmado contra Service
// Layer; no está documentado en ningún lado consultado durante el diseño.
export function userTableEntity(tableName: string): string {
return `U_${tableName}`;
}
+6
View File
@@ -0,0 +1,6 @@
// Formato entero HHMMSS usado por los campos U_LastSentTM (mismo criterio
// que los timestamps nativos de SAP, sin los milisegundos que sí tiene el
// OEVN real — ver docs/DISENO.md, diccionario de datos).
export function hhmmss(date: Date): number {
return date.getHours() * 10000 + date.getMinutes() * 100 + date.getSeconds();
}
+33
View File
@@ -0,0 +1,33 @@
// Mapeo ObjectType -> BizObject: nombre del member de BoObjectTypes (SDK de
// SAP Business One) sin el prefijo "o". Confirmado con el INSERT real
// capturado por trace (ObjectType='17' -> BizObject='Orders'). Subconjunto
// curado, no las ~150 entradas de BoObjectTypes — agregar acá a medida que
// se necesiten nuevos BusinessObject. Ver docs/DISENO.md.
export const OBJECT_TYPE_TO_BIZ_OBJECT: Record<string, string> = {
'1': 'ChartOfAccounts',
'2': 'BusinessPartners',
'4': 'Items',
'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',
};
export function bizObjectFor(objectType: string): string {
const bizObject = OBJECT_TYPE_TO_BIZ_OBJECT[objectType];
if (!bizObject) throw new Error(`ObjectType desconocido: '${objectType}' (agregarlo a object-types.ts)`);
return bizObject;
}
+14
View File
@@ -0,0 +1,14 @@
// Mapeo TransType -> Operation, confirmado contra la definición real de la
// tabla EVC1 de SAP. Ver docs/DISENO.md.
export const TRANS_TYPE_TO_OPERATION: Record<string, string> = {
A: 'Created',
U: 'Updated',
D: 'Deleted',
C: 'Canceled',
O: 'Reopened',
L: 'Closed',
};
export function operationFor(transType: string): string {
return TRANS_TYPE_TO_OPERATION[transType] ?? transType;
}
+54
View File
@@ -0,0 +1,54 @@
import type { FieldDefinition, TableDefinition } from './table-definitions.ts';
// Traducción de FieldDefinition/TableDefinition (nuestro esquema abstracto,
// ver table-definitions.ts) a los bodies reales de Service Layer
// (UserTablesMD/UserFieldsMD). Única fuente de verdad para esa forma —
// la usan tanto user-tables.ts (provisioning real) como
// scripts/export-table-json.ts (export a JSON para revisión o POST manual).
const FIELD_TYPE_TO_SAP: Record<FieldDefinition['type'], string> = {
Alpha: 'db_Alpha',
Numeric: 'db_Numeric',
Date: 'db_Date',
};
export interface UserTablePayload {
TableName: string;
TableDescription: string;
TableType: 'bott_NoObject';
}
export interface UserFieldPayload {
TableName: string;
Name: string;
Description: string;
Type: string;
Size?: number;
EditSize?: number;
}
export function toUserTablePayload(definition: TableDefinition): UserTablePayload {
return {
TableName: definition.tableName,
TableDescription: definition.description,
TableType: 'bott_NoObject',
};
}
export function toUserFieldPayload(tableName: string, field: FieldDefinition): UserFieldPayload {
return {
// Comportamiento real confirmado contra Service Layer: a diferencia de
// UserTablesMD.TableName (sin "@"), UserFieldsMD.TableName SÍ necesita
// el nombre físico con "@" — si no, Service Layer responde "Table not
// found" aunque la tabla exista.
TableName: `@${tableName}`,
Name: field.name,
Description: field.name,
Type: FIELD_TYPE_TO_SAP[field.type],
// Sin Size/EditSize explícito, un campo db_Numeric queda limitado en la
// práctica a 4 dígitos (aunque la metadata reporte Size=6 por default)
// — confirmado real: valores de 5+ dígitos se pierden en silencio en
// el PATCH (sin error, quedan null). Por eso Numeric también manda
// Size/EditSize, igual que Alpha — nunca confiar en el default.
...(field.type === 'Alpha' || field.type === 'Numeric' ? { Size: field.size, EditSize: field.size } : {}),
};
}
+11
View File
@@ -0,0 +1,11 @@
import { config } from '../config.ts';
import { ServiceLayerClient } from '../service-layer-client.ts';
import { TABLE_DEFINITIONS } from './table-definitions.ts';
import { ensureUserTable } from './user-tables.ts';
const client = new ServiceLayerClient(config.serviceLayer);
for (const definition of TABLE_DEFINITIONS) {
await ensureUserTable(client, definition);
console.log(`[provision] Tabla @${definition.tableName} verificada/creada.`);
}
+73
View File
@@ -0,0 +1,73 @@
// Definición declarativa de las 4 UserTablesMD del emulador. El esquema
// real de SAP y las decisiones que llevaron a este recorte de campos están
// documentadas en docs/DISENO.md ("Diccionario de datos").
export type FieldType = 'Alpha' | 'Numeric' | 'Date';
export interface FieldDefinition {
name: string;
type: FieldType;
size?: number; // requerido para 'Alpha' y 'Numeric' (ver payloads.ts: sin Size explícito, Numeric queda limitado a 4 dígitos)
}
export interface TableDefinition {
tableName: string; // sin el prefijo "@" — SAP lo antepone solo
description: string; // UserTablesMD.TableDescription: máx. 30 caracteres (confirmado contra Service Layer real)
fields: FieldDefinition[];
}
export const TABLE_DEFINITIONS: TableDefinition[] = [
{
tableName: 'OEVC',
description: 'Webhook Subscription',
fields: [
{ name: 'WebhookURL', type: 'Alpha', size: 254 },
{ name: 'AuthType', type: 'Alpha', size: 1 },
{ name: 'AuthCred', type: 'Alpha', size: 254 },
{ name: 'Handshake', type: 'Alpha', size: 1 },
{ name: 'VerifyCert', type: 'Alpha', size: 1 },
{ name: 'State', type: 'Alpha', size: 1 },
{ name: 'WorkMode', type: 'Alpha', size: 1 },
{ name: 'LastSentDT', type: 'Date' },
{ name: 'LastSentTM', type: 'Numeric', size: 9 },
{ name: 'LastErrMsg', type: 'Alpha', size: 254 },
],
},
{
tableName: 'EVC1',
description: 'Webhook Subscription Filter',
fields: [
{ name: 'WebhookID', type: 'Alpha', size: 40 },
{ name: 'BizObject', type: 'Alpha', size: 64 },
{ name: 'TransType', type: 'Alpha', size: 1 },
{ name: 'ObjectType', type: 'Alpha', size: 30 },
],
},
{
tableName: 'OEVN',
description: 'Webhook Event',
fields: [
{ name: 'SourceDB', type: 'Alpha', size: 100 },
{ name: 'Status', type: 'Alpha', size: 1 },
{ name: 'ObjectType', type: 'Alpha', size: 30 },
{ name: 'TransType', type: 'Alpha', size: 1 },
{ name: 'FieldsInKe', type: 'Numeric', size: 6 },
{ name: 'FieldNames', type: 'Alpha', size: 254 },
{ name: 'FieldValue', type: 'Alpha', size: 254 },
{ name: 'ReplayStat', type: 'Alpha', size: 1 },
{ name: 'UserID', type: 'Alpha', size: 25 },
],
},
{
tableName: 'EVN1',
description: 'Webhook Event Delivery',
fields: [
{ name: 'EventID', type: 'Alpha', size: 40 },
{ name: 'WebhookID', type: 'Alpha', size: 40 },
{ name: 'Status', type: 'Alpha', size: 1 },
{ name: 'Retry', type: 'Numeric', size: 6 },
{ name: 'ReturnCode', type: 'Numeric', size: 9 },
{ name: 'LastSentDT', type: 'Date' },
{ name: 'LastSentTM', type: 'Numeric', size: 9 },
],
},
];
+32
View File
@@ -0,0 +1,32 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import type { FieldDefinition, TableDefinition } from './table-definitions.ts';
import { toUserFieldPayload, toUserTablePayload } from './payloads.ts';
// Idempotente: crea la UserTable y sus UserFields solo si todavía no existen,
// para poder correr `npm run provision` tantas veces como haga falta.
export async function ensureUserTable(client: ServiceLayerClient, definition: TableDefinition): Promise<void> {
if (!(await tableExists(client, definition.tableName))) {
await client.post('/UserTablesMD', toUserTablePayload(definition));
}
for (const field of definition.fields) {
await ensureUserField(client, definition.tableName, field);
}
}
async function tableExists(client: ServiceLayerClient, tableName: string): Promise<boolean> {
const res = await client.get(`/UserTablesMD('${tableName}')`);
return res.status === 200;
}
async function ensureUserField(client: ServiceLayerClient, tableName: string, field: FieldDefinition): Promise<void> {
if (await fieldExists(client, tableName, field.name)) return;
await client.post('/UserFieldsMD', toUserFieldPayload(tableName, field));
}
async function fieldExists(client: ServiceLayerClient, tableName: string, fieldName: string): Promise<boolean> {
// El campo, una vez creado, queda registrado con TableName="@<tableName>"
// (ver la nota en toUserFieldPayload) — hay que filtrar igual.
const filter = encodeURIComponent(`TableName eq '@${tableName}' and Name eq '${fieldName}'`);
const res = await client.get(`/UserFieldsMD?$filter=${filter}`);
return res.status === 200 && Array.isArray(res.data?.value) && res.data.value.length > 0;
}
+90
View File
@@ -0,0 +1,90 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import https from 'node:https';
import type { ServiceLayerTarget } from './config.ts';
// Cliente mínimo del Service Layer: Login + GET/POST/PATCH/DELETE con la
// cookie de sesión. No cachea la sesión entre corridas (cada ciclo del
// dispatcher vuelve a loguearse) para no lidiar con expiración de
// B1SESSION (~30 min) — mismo criterio que sap-intercompany-worker.
export class ServiceLayerClient {
private readonly http: AxiosInstance;
private loggedIn = false;
constructor(private readonly target: ServiceLayerTarget) {
this.http = axios.create({
baseURL: target.baseUrl,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
validateStatus: () => true, // manejamos los status code nosotros, sin que axios tire excepción
});
}
async login(): Promise<void> {
const res = await this.http.post('/Login', {
CompanyDB: this.target.companyDb,
UserName: this.target.username,
Password: this.target.password,
});
if (res.status !== 200) {
throw new Error(`Login falló en ${this.target.baseUrl}: HTTP ${res.status} ${JSON.stringify(res.data)}`);
}
const setCookie = res.headers['set-cookie'];
if (setCookie) {
this.http.defaults.headers.common['Cookie'] = setCookie.map((c) => c.split(';')[0]).join('; ');
}
this.loggedIn = true;
}
private async ensureLoggedIn(): Promise<void> {
if (!this.loggedIn) await this.login();
}
// GET se usa a menudo para chequear existencia (404 es una respuesta
// válida, no un error) — se deja sin validar, cada llamador mira el
// status que necesita.
async get(path: string): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.http.get(path);
}
// Service Layer pagina las listas de a 20 registros por defecto
// (`odata.nextLink`) — confirmado real: con más de 20 eventos
// pendientes, un GET sin paginar devuelve solo los primeros 20 y
// esconde el resto en silencio. Usar SIEMPRE que se necesite la lista
// completa (no para chequeos de existencia de una sola fila).
async getAll(path: string): Promise<Record<string, unknown>[]> {
const results: Record<string, unknown>[] = [];
let next: string | undefined = path;
while (next) {
const res = await this.get(next);
results.push(...((res.data?.value as Record<string, unknown>[]) ?? []));
const nextLink = res.data?.['odata.nextLink'] as string | undefined;
next = nextLink ? `/${nextLink}` : undefined;
}
return results;
}
async post(path: string, body: unknown): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.post(path, body));
}
async patch(path: string, body: unknown): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.patch(path, body));
}
async delete(path: string): Promise<AxiosResponse> {
await this.ensureLoggedIn();
return this.assertOk(await this.http.delete(path));
}
// POST/PATCH/DELETE son siempre una acción con una intención clara: si
// Service Layer responde error, tiene que ser ruidoso (excepción), nunca
// una fila que "parece" creada/actualizada pero no lo está.
private assertOk(res: AxiosResponse): AxiosResponse {
if (res.status < 200 || res.status >= 300) {
throw new Error(`Service Layer respondió HTTP ${res.status}: ${JSON.stringify(res.data)}`);
}
return res;
}
}
@@ -0,0 +1,56 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const OEVC = userTableEntity('OEVC');
const EVC1 = userTableEntity('EVC1');
export interface Subscription {
webhookId: string; // = Code
url: string;
authType: string;
authCred: string | null;
state: string;
}
export interface SubscriptionFilter {
webhookId: string;
bizObject: string;
transType: string;
}
function toSubscription(raw: Record<string, unknown>): Subscription {
return {
webhookId: raw.Code as string,
url: raw.U_WebhookURL as string,
authType: raw.U_AuthType as string,
authCred: (raw.U_AuthCred as string) ?? null,
state: raw.U_State as string,
};
}
function toFilter(raw: Record<string, unknown>): SubscriptionFilter {
return {
webhookId: raw.U_WebhookID as string,
bizObject: raw.U_BizObject as string,
transType: raw.U_TransType as string,
};
}
// Devuelve un mapa WebhookID -> Subscription, solo las activas (State='A').
export async function listActiveSubscriptions(client: ServiceLayerClient): Promise<Map<string, Subscription>> {
const rows = await client.getAll(`/${OEVC}?$filter=${encodeURIComponent("U_State eq 'A'")}`);
const subscriptions = rows.map(toSubscription);
return new Map(subscriptions.map((s) => [s.webhookId, s]));
}
// Todos los filtros de @EVC1 — se trae completo y se matchea en memoria
// contra BizObject/TransType, en vez de depender de operadores $filter más
// avanzados que Service Layer podría no soportar igual entre motores.
export async function listFilters(client: ServiceLayerClient): Promise<SubscriptionFilter[]> {
const rows = await client.getAll(`/${EVC1}`);
return rows.map(toFilter);
}
export async function markExceptional(client: ServiceLayerClient, webhookId: string): Promise<void> {
await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'X' });
}
+51
View File
@@ -0,0 +1,51 @@
@url=https://hw-cloud-su2.dot-solutions.cloud:50000/b1s/v1
###
# @no-reject-unauthorized
POST {{url}}/Login
Content-Type: application/json
{
"CompanyDB": "SBOTSTLIBROS",
"UserName": "dot-solutions\\dot.zmarcano",
"Password": "<SL_PASSWORD>"
}
###
# @no-reject-unauthorized
GET {{url}}/PurchaseDeliveryNotes?$select=DocEntry,DocNum,DocCurrency,DocDate,CardCode,CardName,&$filter=CardCode eq '93250000-0P'and DocumentStatus eq 'bost_Open' and DocType eq 'dDocument_Items'
###
# @no-reject-unauthorized
GET {{url}}/PurchaseDeliveryNotes(4020)
###
# @no-reject-unauthorized
POST {{url}}/PurchaseReturns
Content-Type: application/json
{
"DocType": "dDocument_Items",
"CardCode": "93250000-0P",
"DocDate": "2026-09-09T00:00:00Z",
"DocCurrency": "CLP",
"DocumentLines":[
{
"BaseType":20,
"BaseEntry":477,
"BaseLine":0,
"Quantity":1
},
{
"BaseType":20,
"BaseEntry":4020,
"BaseLine":0,
"Quantity":2
}
]
}
+51
View File
@@ -0,0 +1,51 @@
@url=https://hw-cloud-su2.dot-solutions.cloud:50000/b1s/v1
###
# @no-reject-unauthorized
POST {{url}}/Login
Content-Type: application/json
{
"CompanyDB": "SBOTSTLIBROS",
"UserName": "dot-solutions\\dot.zmarcano",
"Password": "<SL_PASSWORD>"
}
###
# @no-reject-unauthorized
GET {{url}}/Items('8435174921233F')
###
# @no-reject-unauthorized
PATCH {{url}}/Items('8435174921233F')
Content-Type: application/json
{
"ForeignName":"MONSTRUO DE COLORES - SL6",
"BarCode": "8435174921233"
}
###
# @no-reject-unauthorized
GET {{url}}/BusinessPartners('76039758-KP')
###
# @no-reject-unauthorized
PATCH {{url}}/BusinessPartners('76039758-KP')
Content-Type: application/json
{
"Notes":"ACTUALIZADO POR SL",
"MailAddress": "correo@corre.cl"
}
###
# @no-reject-unauthorized
GET {{url}}/Orders(8)
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}