259 lines
11 KiB
Markdown
259 lines
11 KiB
Markdown
# 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`:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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.
|