Files
sap-intercompany-worker/docs/ARQUITECTURA.md
T
GeorgeandClaude Sonnet 5 ca57403663 Initial commit: sap-intercompany-worker
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>
2026-09-16 07:45:57 -05:00

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é 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.

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 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:

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) 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 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.