# 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/.ts` Cada escenario tiene **un solo archivo**, con **siempre las mismas dos partes**: ```ts export function filter(record: Record, ctx: ScenarioFilterContext): boolean | Promise export function transform(record: Record, ctx: ScenarioTransformContext): Record ``` **`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
del mismo businessObject} C --> S{source del evento coincide
con la base origen del escenario?} S -- no --> C S -- si --> D[Leer registro completo
desde la base origen del escenario] D --> E{filter-record- del
archivo del escenario} E -- false --> C E -- true --> F[transform-record, ctx-
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
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/"` — 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.