Initial commit: sap-legacy-webhook-emulator
Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para clientes que aun no estan en esa version: captura eventos via SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1 y despacha CloudEvents al mismo endpoint que el messenger real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
e1c8ee9015
42 files changed
+4042
No files matched your search
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
logs/
|
||||
tmp-test-events/
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1293
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user