218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# Arquitectura: motor de escenarios
|
|||
|
|
|
||
|
|
## Por qué existe
|
||
|
|
|
||
|
|
La primera versión de este worker tenía una regla fija por cada `businessObject`
|
||
|
|
recibido del webhook (una sola por objeto, aplicada siempre, hacia todas las
|
||
|
|
subsidiarias activas). Eso alcanzaba mientras solo existía **un** caso de uso
|
||
|
|
por objeto. En cuanto aparece más de una forma de tratar el mismo objeto —
|
||
|
|
por ejemplo, replicar los **clientes** de `BusinessPartners` con reglas y
|
||
|
|
destinos distintos que los **proveedores** — la regla 1:1 no alcanza.
|
||
|
|
|
||
|
|
El motor actual reemplaza esa regla única por **escenarios**: cada uno es una
|
||
|
|
forma completa e independiente de procesar un `businessObject`, con su
|
||
|
|
propio filtro, base origen, base(s) destino y transformación de payload.
|
||
|
|
Puede haber varios escenarios activos para el mismo objeto a la vez, y un
|
||
|
|
mismo evento puede ser procesado por **más de uno** en paralelo.
|
||
|
|
|
||
|
|
## Conceptos
|
||
|
|
|
||
|
|
### Base de datos (`databases`)
|
||
|
|
|
||
|
|
Registro único de toda compañía SAP alcanzable por Service Layer: URL,
|
||
|
|
`CompanyDB` y credenciales. **No existe distinción entre "casa matriz" y
|
||
|
|
"subsidiaria"** — cualquier fila puede ser origen en un escenario y destino
|
||
|
|
en otro al mismo tiempo. Ver [`TABLAS.md`](./TABLAS.md#databases).
|
||
|
|
|
||
|
|
### Escenario (`scenarios`)
|
||
|
|
|
||
|
|
La unidad central de configuración. Cada escenario define:
|
||
|
|
|
||
|
|
- a qué `businessObject` aplica (`BusinessPartners`, `SalesPersons`, ...) —
|
||
|
|
también determina con qué se leen los registros del origen,
|
||
|
|
- opcionalmente, un `destinationBusinessObject` distinto — si no se
|
||
|
|
especifica, el destino usa el mismo objeto que el origen (el caso
|
||
|
|
común); se especifica cuando el objeto cambia de nombre entre bases
|
||
|
|
(ej. origen `PurchaseOrders` → destino `Orders`, comercio intercompañía),
|
||
|
|
- de qué base lee el registro completo (`originDatabaseCode`),
|
||
|
|
- qué campo identifica al registro (`keyField`, ej. `CardCode`) y de qué
|
||
|
|
tipo es (`keyType`: `string` — va entre comillas en la URL OData — o
|
||
|
|
`number` — va sin comillas; mandar comillas en un campo numérico da
|
||
|
|
HTTP 400),
|
||
|
|
- cómo se resuelve si el registro ya existe en el destino (`keyStrategy`,
|
||
|
|
ver más abajo),
|
||
|
|
- el modo de resolver destino: `fixed` (lista fija de bases en
|
||
|
|
`scenario_destinations`, para datos maestros) o `payload` (el destino se
|
||
|
|
resuelve leyendo un `routingField` del propio mensaje, para
|
||
|
|
transacciones) — **los dos modos están implementados**, ver el ejemplo
|
||
|
|
real de `JOURNALENTRIES` en [`ESCENARIOS.md`](./ESCENARIOS.md).
|
||
|
|
|
||
|
|
El filtro (si el escenario aplica a un registro puntual) y la
|
||
|
|
transformación del payload NO son columnas de esta tabla — son las dos
|
||
|
|
partes fijas del archivo de código de cada escenario, ver más abajo.
|
||
|
|
|
||
|
|
Ver [`ESCENARIOS.md`](./ESCENARIOS.md) para la guía completa de cómo armar
|
||
|
|
uno paso a paso, con ejemplos reales.
|
||
|
|
|
||
|
|
### `keyStrategy`: cómo se identifica el mismo registro entre bases
|
||
|
|
|
||
|
|
- **`portable`**: el valor de `keyField` significa lo mismo en origen y
|
||
|
|
destino (`CardCode`, `CenterCode` — códigos de negocio asignados a mano).
|
||
|
|
El motor hace `GET` directo por esa clave en el destino.
|
||
|
|
- **`udfLink`**: SAP asigna la clave por base (ej. `SalesEmployeeCode`, que
|
||
|
|
autonumera cada compañía) — el mismo vendedor tiene un código distinto en
|
||
|
|
cada subsidiaria. El escenario declara un `matchField`: un UDF creado a
|
||
|
|
mano en el destino (ej. `U_HQ_SlpCode`) que guarda el valor de la clave de
|
||
|
|
origen. El motor busca por ese UDF (`GET .../SalesPersons?$filter=U_HQ_SlpCode eq '5'`)
|
||
|
|
en vez de por la clave directa:
|
||
|
|
- si encuentra un registro, usa SU clave propia (la del destino) para el
|
||
|
|
`PATCH`,
|
||
|
|
- si no encuentra nada, hace `POST` (con el UDF seteado en el payload
|
||
|
|
para poder encontrarlo la próxima vez) y guarda la clave que SAP le
|
||
|
|
asignó en el destino como `resultKey` del intento, para trazabilidad.
|
||
|
|
|
||
|
|
### Destinos de un escenario (`scenario_destinations`)
|
||
|
|
|
||
|
|
Solo aplica a escenarios `destinationMode = 'fixed'`. Es la lista de bases
|
||
|
|
hacia las que ese escenario replica. Un escenario puede tener una o varias.
|
||
|
|
|
||
|
|
### El archivo del escenario: `src/scenarios/<code>.ts`
|
||
|
|
|
||
|
|
Cada escenario tiene **un solo archivo**, con **siempre las mismas dos
|
||
|
|
partes**:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): boolean | Promise<boolean>
|
||
|
|
export function transform(record: Record<string, unknown>, ctx: ScenarioTransformContext): Record<string, unknown>
|
||
|
|
```
|
||
|
|
|
||
|
|
**`filter`** decide si el escenario aplica a ESE registro puntual (`true` =
|
||
|
|
aplica). Vive en código — no en columnas de la tabla `scenarios` — porque
|
||
|
|
una condición simple (campo/operador/valor) no alcanza para casos reales
|
||
|
|
(condiciones compuestas, varios campos a la vez). Se evalúa una sola vez
|
||
|
|
por evento, contra el registro leído de la base origen, y puede ser
|
||
|
|
`async`: recibe `ctx.get(path)`, un `GET` arbitrario contra la base ORIGEN
|
||
|
|
del escenario (reusa la sesión ya autenticada, no abre un login nuevo).
|
||
|
|
Esto cubre el caso típico de transacciones: una Factura trae el `CardCode`
|
||
|
|
del cliente en su payload, pero el filtro depende de un campo del
|
||
|
|
`BusinessPartner` (ej. su `GroupCode`) — el archivo hace
|
||
|
|
`await ctx.get("/BusinessPartners('${record.CardCode}')")` y decide con
|
||
|
|
ese dato, sin que el motor tenga que saber nada de esa relación.
|
||
|
|
|
||
|
|
**`transform`** arma el payload a enviar a un destino puntual — se llama
|
||
|
|
**una vez por destino**, recibe el registro ya leído (limpio de metadata
|
||
|
|
OData) y un `ctx` con:
|
||
|
|
|
||
|
|
- `ctx.destinationCode`: a qué base le toca este payload (permite
|
||
|
|
transformar distinto según el destino).
|
||
|
|
- `ctx.mapValue(fieldName, sourceValue)`: consulta la tabla
|
||
|
|
`scenario_value_mappings` (ver [`TABLAS.md`](./TABLAS.md)) para ESTE
|
||
|
|
escenario y ESTE destino. Devuelve `undefined` si no hay mapeo cargado —
|
||
|
|
el archivo decide qué hacer (tirar error para marcar el intento como
|
||
|
|
`SKIPPED`, dejar el campo afuera, poner un default, etc.). Esta es la
|
||
|
|
parte de **mapeo** dentro de la transformación.
|
||
|
|
|
||
|
|
Filtro y transformación viven en código porque necesitan lógica arbitraria
|
||
|
|
(condicionales, elegir campos) que no es expresable como una tabla de
|
||
|
|
equivalencias simple — pero los VALORES de los mapeos de campo (ej.
|
||
|
|
`GroupCode` de HQ → `GroupCode` de cada subsidiaria) sí son datos puros, y
|
||
|
|
esos viven en Postgres para poder cargarlos con el CLI sin tocar código (y
|
||
|
|
a futuro, desde una UI). El archivo sigue siendo quien decide QUÉ campo
|
||
|
|
mapear y QUÉ hacer si falta el valor — la tabla solo guarda el dato. Ver
|
||
|
|
[`ESCENARIOS.md`](./ESCENARIOS.md) para los 3 ejemplos completos ya
|
||
|
|
configurados.
|
||
|
|
|
||
|
|
### Intento de replicación (`replication_attempts`)
|
||
|
|
|
||
|
|
Un registro por cada intento de un escenario hacia una base destino
|
||
|
|
puntual: verbo usado, HTTP status, mensaje de error y el **payload ya
|
||
|
|
transformado que se envió** (siempre, en éxito y en falla — pensado para
|
||
|
|
una futura UI de consulta, ver "Trabajo futuro" más abajo). Es un log de
|
||
|
|
auditoría inmutable: guarda los códigos de base/escenario como texto en el
|
||
|
|
momento del intento, no relaciones vivas, para no perder trazabilidad si la
|
||
|
|
configuración cambia o se borra después.
|
||
|
|
|
||
|
|
## Flujo de un evento
|
||
|
|
|
||
|
|
Diagrama de decisión (qué pasa y por qué). Para el diagrama de secuencia
|
||
|
|
archivo-por-archivo, función por función — incluyendo `sap-webhook-api` —
|
||
|
|
ver [`FLUJO.md`](./FLUJO.md).
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart TD
|
||
|
|
A[webhook_events: status=pending] --> B[Tomar evento]
|
||
|
|
B --> C{Por cada escenario activo<br/>del mismo businessObject}
|
||
|
|
C --> S{source del evento coincide<br/>con la base origen del escenario?}
|
||
|
|
S -- no --> C
|
||
|
|
S -- si --> D[Leer registro completo<br/>desde la base origen del escenario]
|
||
|
|
D --> E{filter-record- del<br/>archivo del escenario}
|
||
|
|
E -- false --> C
|
||
|
|
E -- true --> F[transform-record, ctx-<br/>por cada base destino]
|
||
|
|
F --> K{keyStrategy}
|
||
|
|
K -- portable --> G1[GET directo por keyField]
|
||
|
|
K -- udfLink --> G2[GET por filtro sobre matchField]
|
||
|
|
G1 --> G[Existe en el destino?]
|
||
|
|
G2 --> G
|
||
|
|
G -- si --> H[PATCH]
|
||
|
|
G -- no --> I[POST + capturar clave asignada]
|
||
|
|
H --> J[Registrar intento<br/>verbo, status, error, payload]
|
||
|
|
I --> J
|
||
|
|
J --> C
|
||
|
|
C -- todos los escenarios procesados --> L[Calcular status final del evento]
|
||
|
|
```
|
||
|
|
|
||
|
|
### Protección contra loops: `source` del evento vs. base origen
|
||
|
|
|
||
|
|
El CloudEvent que manda SAP incluye `"source": "/default/sap.b1/<CompanyDB>"`
|
||
|
|
— de qué base salió la notificación. Antes de procesar un evento con un
|
||
|
|
escenario, el motor compara ese `CompanyDB` contra el de la base origen
|
||
|
|
configurada (`originDatabase.companyDb`). Si no coincide, el escenario
|
||
|
|
simplemente no aplica (no genera ningún intento).
|
||
|
|
|
||
|
|
Esto no es cosmético: se descubrió en producción-de-prueba que si una base
|
||
|
|
DESTINO tiene su propia suscripción de webhook activa (ej. porque se creó
|
||
|
|
clonando/restaurando la base origen y heredó su configuración), un `PATCH`
|
||
|
|
nuestro ahí dispara una notificación que, sin este chequeo, el motor
|
||
|
|
reprocesaría como si fuera un cambio nuevo en el origen — generando un
|
||
|
|
`PATCH` de nuevo, que dispara otra notificación, indefinidamente. El fix de
|
||
|
|
fondo es desactivar `EnableWebhook` en las bases que son solo destino, pero
|
||
|
|
este chequeo queda como segunda capa de protección barata (una comparación
|
||
|
|
de string) para cualquier otro caso de mala configuración futura.
|
||
|
|
|
||
|
|
### Estado final del evento (`webhook_events.status`)
|
||
|
|
|
||
|
|
Como un evento puede ser tomado por varios escenarios a la vez, el estado
|
||
|
|
final se calcula sobre **todos** los intentos de **todos** los escenarios
|
||
|
|
que aplicaron:
|
||
|
|
|
||
|
|
| Estado | Condición |
|
||
|
|
|---|---|
|
||
|
|
| `filtered` | Ningún escenario activo para ese `businessObject` aceptó el mensaje (ni siquiera vino de la base origen esperada de ninguno, o todos los filtros lo rechazaron). |
|
||
|
|
| `done` | Al menos un escenario aplicó, y **todos** los intentos (de todos los escenarios, hacia todos sus destinos) tuvieron éxito. |
|
||
|
|
| `partial` | Al menos un escenario aplicó, y hay una mezcla de intentos exitosos y fallidos. |
|
||
|
|
| `failed` | Al menos un escenario aplicó, pero **ningún** intento tuvo éxito (incluye el caso de un escenario sin destinos configurados). |
|
||
|
|
|
||
|
|
## `destinationMode = 'payload'` (transacciones) — implementado
|
||
|
|
|
||
|
|
`processPendingEvents` (en `src/replicateEvents.ts`) procesa ambos modos. En `payload`, no hay filas
|
||
|
|
en `scenario_destinations` para ese escenario — el destino se resuelve en
|
||
|
|
runtime leyendo `sourceRecord[scenario.routingField]` (un UDF en el
|
||
|
|
**origen** cuyo valor es directamente el `code` de la base destino, ej.
|
||
|
|
`SUB_01` — no el nombre de la compañía SAP). Si el campo viene vacío o no
|
||
|
|
matchea ninguna base registrada, se registra un intento de error (destino
|
||
|
|
`'N/A'`) en vez de fallar en silencio. Ejemplo real funcionando:
|
||
|
|
`JOURNALENTRIES` (ver [`ESCENARIOS.md`](./ESCENARIOS.md) sección 5).
|
||
|
|
|
||
|
|
`keyStrategy=udfLink` es especialmente relevante en modo `payload`: como
|
||
|
|
cada mensaje puede terminar en una base distinta, sin un UDF de
|
||
|
|
correspondencia (`matchField`) reprocesar el mismo mensaje generaría un
|
||
|
|
documento duplicado en el destino cada vez.
|
||
|
|
|
||
|
|
## Trabajo futuro: `sap-intercompany-viewer`
|
||
|
|
|
||
|
|
Proyecto aparte (a construir después de terminar este) para la parte visual
|
||
|
|
de toda esta configuración: visor de eventos y reenvíos, alta de bases de
|
||
|
|
datos con prueba de conexión, alta/edición de escenarios, y administración
|
||
|
|
de sus mapeos de valor. `replication_attempts` ya guarda siempre el payload
|
||
|
|
enviado (éxito y falla) e incluye índices sobre `event_id`, `scenario_id` y
|
||
|
|
`attempted_at` pensando en ese consumo futuro.
|