Motor de escenarios de replicacion entre bases SAP Business One (Service Layer). Poll periodico sobre webhook_events, escenarios configurables via Prisma (filter + transform por escenario). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
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.
Escenario (scenarios)
La unidad central de configuración. Cada escenario define:
- a qué
businessObjectaplica (BusinessPartners,SalesPersons, ...) — también determina con qué se leen los registros del origen, - opcionalmente, un
destinationBusinessObjectdistinto — 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. origenPurchaseOrders→ destinoOrders, 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 — onumber— 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 enscenario_destinations, para datos maestros) opayload(el destino se resuelve leyendo unroutingFielddel propio mensaje, para transacciones) — los dos modos están implementados, ver el ejemplo real deJOURNALENTRIESenESCENARIOS.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 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 dekeyFieldsignifica lo mismo en origen y destino (CardCode,CenterCode— códigos de negocio asignados a mano). El motor haceGETdirecto 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 unmatchField: 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 comoresultKeydel intento, para trazabilidad.
- si encuentra un registro, usa SU clave propia (la del destino) para el
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:
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 tablascenario_value_mappings(verTABLAS.md) para ESTE escenario y ESTE destino. Devuelveundefinedsi no hay mapeo cargado — el archivo decide qué hacer (tirar error para marcar el intento comoSKIPPED, 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 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.
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 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.