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
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.
3. Ejemplo 2: clave asignada por base (SALESPERSONS, keyStrategy=udfLink)
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
SalesPersonsen el destino conU_HQ_SlpCodeigual alSalesEmployeeCodede origen → lo encuentra, usa la clave propia del destino para elPATCH. - Si no existe ninguno →
POST(conU_HQ_SlpCodeseteado en el payload automáticamente por el motor — no hace falta ponerlo en eltransform), y guarda la clave que SAP le asignó enreplication_attempts.result_keypara 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.