Files
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

Guía: cómo armar un escenario

Toda la configuración se administra con scripts/manage-config.ts (vía tsx), salvo el filtro y la transformación de cada escenario, que son código. Los cambios tienen efecto en el próximo tick del worker — no hace falta reiniciar el proceso, tampoco después de editar el archivo del escenario: el motor lo importa dinámicamente en cada intento.

Nombre del archivo: el code de un escenario va en mayúsculas (es el identificador que ves en scenario:list, en los logs y en --filter/etc.) pero su archivo en src/scenarios/ va en minúsculas — ej. código BP_PROVEEDORES → archivo bp_proveedores.ts. scenario:add ya genera el archivo con el nombre correcto; si necesitás renombrar un escenario después, usá scenario:rename (mueve el archivo automáticamente, no lo hagas a mano).

1. Registrar las bases de datos

Antes de crear un escenario, las bases que va a usar (origen y destinos) tienen que existir en databases:

npx tsx scripts/manage-config.ts database:add HQ01 "HQ DemoCL" https://sap10:50000/b1s/v2 SBODemoCL manager mngr
npx tsx scripts/manage-config.ts database:add SUB_01 "SUB 01 DemoCL" https://sap10:50000/b1s/v2 SUBDemoCL_01 manager mngr
npx tsx scripts/manage-config.ts database:add SUB_02 "SUB 02 DemoCL" https://sap10:50000/b1s/v2 SUBDemoCL_02 manager mngr

npx tsx scripts/manage-config.ts database:list

Importante: si una base destino se creó clonando/restaurando la base origen (común en ambientes de demo), puede haber heredado su propia suscripción de webhook activa. Si el mismo businessObject que estás por replicar dispara un webhook en el destino, hay que desactivarlo (EnableWebhook: tNO en esa compañía) — si no, cada PATCH/POST nuestro genera una notificación que, aunque el motor la descarta (ver ARQUITECTURA.md § protección contra loops), sigue entrando como ruido.

2. Ejemplo 1: clave portable + mapeo de valor (BP_PROVEEDORES)

Caso real: BusinessPartners con CardType=cSupplier, replicando solo algunos campos, con GroupCode traducido por destino.

2.1 Crear el escenario

npx tsx scripts/manage-config.ts scenario:add BP_PROVEEDORES "BP - Proveedores" BusinessPartners HQ01 CardCode

CardCode es texto de negocio (igual en HQ01 y en cada subsidiaria), así que no hace falta --key-strategy ni --key-type — quedan en sus defaults (portable y string).

Este comando genera automáticamente src/scenarios/bp_proveedores.ts (si no existía) con un filter/transform de partida (filter devuelve true siempre, transform devuelve el registro tal cual) — hay que editarlo con la condición real.

2.2 Definir los destinos

npx tsx scripts/manage-config.ts scenario:add-destination BP_PROVEEDORES SUB_01
npx tsx scripts/manage-config.ts scenario:add-destination BP_PROVEEDORES SUB_02

2.3 Cargar los mapeos de valor (GroupCode)

GroupCode en SAP significa "grupo de clientes" o "grupo de proveedores" según el CardType del registro — son dominios de valores distintos, aunque el nombre del campo sea el mismo. Por eso el mapeo se carga por escenario, no global:

npx tsx scripts/manage-config.ts scenario:mapping:add BP_PROVEEDORES GroupCode SUB_01 101 101
npx tsx scripts/manage-config.ts scenario:mapping:add BP_PROVEEDORES GroupCode SUB_02 101 101
# ...un comando por cada (destino, valor de origen) — ver docs/TABLAS.md para la lista completa

2.4 Escribir el filtro y la transformación

src/scenarios/bp_proveedores.ts — el archivo siempre tiene las mismas dos partes. El filtro es la condición CardType=cSupplier (antes vivía como columnas en la tabla scenarios, ahora es código); la transformación selecciona solo los campos necesarios y resuelve GroupCode con ctx.mapValue, no con el valor crudo de HQ01:

import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts';

// --- Filtro: decide si este escenario aplica al registro ---
// No necesita ctx acá: CardType ya viene en el payload de BusinessPartners.
export function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): boolean {
  return record.CardType === 'cSupplier';
}

// --- Transformación: arma el payload para un destino puntual ---
export function transform(record: Record<string, unknown>, ctx: ScenarioTransformContext): Record<string, unknown> {
  // --- Mapeo: GroupCode vive en Postgres (scenario_value_mappings), no acá ---
  const groupCode = record.GroupCode === undefined || record.GroupCode === null ? undefined : String(record.GroupCode);
  const mappedGroupCode = groupCode === undefined ? undefined : ctx.mapValue('GroupCode', groupCode);

  if (groupCode !== undefined && mappedGroupCode === undefined) {
    throw new Error(`GroupCode '${groupCode}' no tiene mapeo cargado para el destino '${ctx.destinationCode}'`);
  }

  return {
    CardCode: record.CardCode,
    CardName: record.CardName,
    CardType: record.CardType,
    GroupCode: mappedGroupCode,
    Phone1: record.Phone1,
    Phone2: record.Phone2,
    Notes: record.Notes,
    FederalTaxID: record.FederalTaxID,
    Currency: record.Currency,
    DebitorAccount: record.DebitorAccount,
    Valid: record.Valid,
    Frozen: record.Frozen,
    Properties1: record.Properties1,
  };
}

Si filter devuelve false, el escenario no aplica a ese registro (no genera ningún intento). Si GroupCode viene con un valor sin mapeo cargado dentro de transform, el throw marca el intento como SKIPPED (no manda nada sin traducir) y el motivo queda en error_message de replication_attempts.

SalesPersons usa SalesEmployeeCode, que SAP autonumera por compañía — el mismo vendedor tiene un código distinto en cada subsidiaria. No se puede usar portable.

3.1 Crear el UDF en SAP (paso manual, fuera de este repo)

En cada subsidiaria destino, crear un campo de usuario en la tabla OSLP (Sales Employees) — ej. U_HQ_SlpCode, tipo Alfanumérico — desde Herramientas de Personalización / UDF Manager de SAP B1. Sin esto, el escenario no tiene dónde guardar la correspondencia.

3.2 Crear el escenario

npx tsx scripts/manage-config.ts scenario:add SALESPERSONS "SalesPersons" SalesPersons HQ01 SalesEmployeeCode \
  --key-strategy udfLink --match-field U_HQ_SlpCode --key-type number

npx tsx scripts/manage-config.ts scenario:add-destination SALESPERSONS SUB_01
npx tsx scripts/manage-config.ts scenario:add-destination SALESPERSONS SUB_03

--key-type number es necesario porque SalesEmployeeCode es numérico en el Service Layer — mandar la URL con comillas (SalesPersons('5')) da HTTP 400; sin comillas (SalesPersons(5)) funciona.

3.3 Cómo lo resuelve el motor (sin que tengas que hacer nada más)

  • Si ya existe un SalesPersons en el destino con U_HQ_SlpCode igual al SalesEmployeeCode de origen → lo encuentra, usa la clave propia del destino para el PATCH.
  • Si no existe ninguno → POST (con U_HQ_SlpCode seteado en el payload automáticamente por el motor — no hace falta ponerlo en el transform), y guarda la clave que SAP le asignó en replication_attempts.result_key para la próxima actualización.

Sin lógica de negocio adicional pedida para este objeto, el filter (return true) y el transform (identity, return { ...record }) generados por scenario:add alcanzan tal cual.

4. Ejemplo 3: sin filtro, a 3 destinos (PROFITCENTERS)

ProfitCenters usa CenterCode, un código de negocio asignado a mano (CC1, Stelle_2) — portable, igual que BusinessPartners:

npx tsx scripts/manage-config.ts scenario:add PROFITCENTERS "ProfitCenters" ProfitCenters HQ01 CenterCode

npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_01
npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_02
npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_03

Sin condición pedida: filter queda como lo genera el scaffold (return true), aplica a todo ProfitCenters.

5. Filtro que necesita un dato que no viene en el payload

Caso típico de transacciones: una Invoices (Factura de Venta) trae el CardCode del cliente, pero el escenario solo debe aplicar si ese cliente pertenece al GroupCode 101. El dato para decidir el filtro no está en el payload de la Factura — hay que ir a buscarlo a BusinessPartners. Por eso filter puede ser async y usar ctx.get(...) (mismo cliente/sesión que ya trae el registro principal, sin loguearse de nuevo):

import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts';

// --- Filtro: necesita el GroupCode del cliente, que no viene en la Factura ---
export async function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): Promise<boolean> {
  const cardCode = record.CardCode;
  if (typeof cardCode !== 'string') return false;

  const res = await ctx.get(`/BusinessPartners('${cardCode}')`);
  if (res.status !== 200) {
    throw new Error(`No se pudo leer BusinessPartners('${cardCode}') de ${ctx.originCode}: HTTP ${res.status}`);
  }

  return res.data.GroupCode === 101;
}

Si ctx.get tira o devuelve un status inesperado, el throw marca el intento como error (destino 'N/A', ver ARQUITECTURA.md) — no rompe la corrida completa, solo ese evento en ese escenario.

Importante: esto resuelve la parte del filtro enriquecido, pero Invoices es una transacción (destinationMode = 'payload'), y ese modo todavía no está implementado en el motor (ver limitación en ARQUITECTURA.md) — hace falta además el ruteo del destino desde el propio mensaje antes de poder usar este escenario de punta a punta.

6. Comandos disponibles (referencia)

database:add <code> <name> <baseUrl> <companyDb> <user> <pass>
database:disable <code>
database:list

scenario:add <code> <name> <businessObject> <originDbCode> <keyField>
              [--key-strategy udfLink --match-field U_HQ_SlpCode] [--key-type number]
              [--destination-mode payload --routing-field U_ITC_Target]
              [--destination-object Orders]
scenario:rename <oldCode> <newCode>
scenario:disable <code>
scenario:list

scenario:add-destination <scenarioCode> <databaseCode>
scenario:remove-destination <scenarioCode> <databaseCode>

scenario:mapping:add <scenarioCode> <fieldName> <databaseCode> <sourceValue> <targetValue>
scenario:mapping:list <scenarioCode> <fieldName>

--destination-object es para cuando el objeto cambia de nombre entre origen y destino — ej. un escenario que lee PurchaseOrders de HQ01 pero necesita crear un Orders (Orden de Venta) en la subsidiaria, típico de comercio intercompañía. Si se omite, el destino usa el mismo objeto que <businessObject> (el caso de los 5 escenarios de hoy).

scenario:add y database:add son upsert por code: correr el mismo comando de nuevo actualiza la fila existente en vez de duplicarla. La primera vez que se crea un escenario nuevo, además genera su archivo en src/scenarios/ con un filter/transform de partida — no lo sobrescribe en corridas siguientes. scenario:rename cambia el code en la DB y mueve el archivo correspondiente en un solo paso — no renombrar el archivo a mano, porque el motor lo busca por el code actual. scenario:disable / database:disable desactivan sin borrar — útil para pausar un escenario o sacar una base de servicio sin perder su configuración ni el historial de replication_attempts que la referencia.