Files
sap-intercompany-worker/docs/ARQUITECTURA.md
T

218 lines
11 KiB
Markdown
Raw Normal View History

2026-09-16 07:45:57 -05:00
# 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.