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>
This commit is contained in:
commit
ca57403663
36 files changed
+5539
No files matched your search
@@ -0,0 +1,218 @@
|
||||
# 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`](./TABLAS.md#databases).
|
||||
|
||||
### 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`](./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`](./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**:
|
||||
|
||||
```ts
|
||||
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`](./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`](./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`](./FLUJO.md).
|
||||
|
||||
```mermaid
|
||||
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`](./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.
|
||||
@@ -0,0 +1,259 @@
|
||||
# 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.
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Flujo de un mensaje, de punta a punta
|
||||
|
||||
Este documento traza el camino completo de una notificación de SAP —desde
|
||||
que sale del Webhook Messenger hasta que queda replicada en una
|
||||
subsidiaria— nombrando el archivo y la función concreta que interviene en
|
||||
cada paso. Complementa a [`ARQUITECTURA.md`](./ARQUITECTURA.md) (conceptos
|
||||
y reglas de negocio) con la mecánica real de código.
|
||||
|
||||
Dos proyectos intervienen: **`sap-webhook-api`** (recibe el webhook, lo
|
||||
guarda) y **`sap-intercompany-worker`** (lo procesa y replica) — separados
|
||||
a propósito por el timeout de 10s de SAP, ver `README.md` de cada uno.
|
||||
|
||||
## Diagrama de secuencia
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SAP as SAP (Webhook Messenger)
|
||||
participant Ctrl as WebhooksController<br/>(sap-webhook-api)
|
||||
participant Repo as webhook-events.repository<br/>(sap-webhook-api)
|
||||
participant DB as Postgres<br/>webhook_events
|
||||
participant Idx as index.ts<br/>(worker, runOnce)
|
||||
participant Cfg as replicationConfig.ts
|
||||
participant Eng as replicateEvents.ts<br/>processPendingEvents
|
||||
participant Mod as scenarioTransform.ts
|
||||
participant Scn as scenarios/*.ts
|
||||
participant SL as serviceLayer.ts<br/>ServiceLayerClient
|
||||
participant Origen as SAP Service Layer<br/>(base origen)
|
||||
participant Destino as SAP Service Layer<br/>(base destino)
|
||||
participant Att as Postgres<br/>replication_attempts
|
||||
|
||||
SAP->>Ctrl: POST /webhooks/sap (CloudEvent)
|
||||
Ctrl->>Ctrl: receiveSapEvent()
|
||||
Ctrl->>Repo: saveWebhookEventToInbox(notification)
|
||||
Repo->>DB: INSERT status='pending'
|
||||
Ctrl-->>SAP: 200 OK
|
||||
|
||||
Note over Idx: cada POLL_INTERVAL_SECONDS (o --once)
|
||||
Idx->>Cfg: loadReplicationConfig(prisma)
|
||||
Cfg-->>Idx: databases, scenarios, destinations, valueMappings
|
||||
Idx->>Eng: processPendingEvents(prisma, cfg)
|
||||
|
||||
Eng->>DB: findMany({status:'pending'})
|
||||
DB-->>Eng: eventos pendientes
|
||||
|
||||
loop por cada escenario activo del businessObject
|
||||
Eng->>Eng: extractSourceCompanyDb(event.payload)
|
||||
Note right of Eng: ¿source coincide con<br/>la base origen del escenario?
|
||||
Eng->>SL: new ServiceLayerClient(origen).get(key)
|
||||
SL->>Origen: Login + GET /{obj}(key)
|
||||
Origen-->>SL: registro completo
|
||||
SL-->>Eng: sourceRecord
|
||||
|
||||
Eng->>Mod: loadScenarioModule(code)
|
||||
Mod-->>Eng: { filter, transform }
|
||||
Eng->>Mod: buildScenarioFilterContext(origen, ...)
|
||||
Eng->>Scn: filter(sourceRecord, ctx)
|
||||
opt el filtro necesita un dato que no viene en el payload
|
||||
Scn->>SL: ctx.get(path) — reusa la sesión del origen
|
||||
SL->>Origen: GET (ej. BusinessPartners del CardCode)
|
||||
Origen-->>SL: registro relacionado
|
||||
SL-->>Scn: respuesta
|
||||
end
|
||||
Scn-->>Eng: true/false
|
||||
|
||||
alt filter() == true
|
||||
alt destinationMode == fixed
|
||||
Note right of Eng: destinos = scenario_destinations
|
||||
else destinationMode == payload
|
||||
Note right of Eng: destino = sourceRecord[routingField]<br/>(un solo destino, resuelto del mensaje)
|
||||
end
|
||||
|
||||
loop por cada destino resuelto (uno o varios)
|
||||
Eng->>Eng: replicateToDestination(...)
|
||||
Eng->>Mod: buildScenarioTransformContext(dest, scenarioId, valueMappings)
|
||||
Eng->>Scn: transform(sourceRecord, ctx)
|
||||
Scn->>Mod: ctx.mapValue(field, sourceValue)
|
||||
Mod-->>Scn: targetValue | undefined
|
||||
Scn-->>Eng: payload transformado
|
||||
|
||||
Eng->>SL: new ServiceLayerClient(destino)
|
||||
Eng->>SL: resolveDestinationRecord(...)
|
||||
SL->>Destino: GET por keyField o por $filter=matchField
|
||||
Destino-->>SL: existe / no existe
|
||||
|
||||
alt existe
|
||||
SL->>Destino: PATCH /{destinationBusinessObject}(destKey)
|
||||
else no existe
|
||||
SL->>Destino: POST /{destinationBusinessObject}
|
||||
end
|
||||
Destino-->>SL: HTTP status + body
|
||||
SL-->>Eng: resultado
|
||||
|
||||
Eng->>Att: replicationAttempt.create({verbo, status, error, payloadSent, resultKey})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Eng->>DB: webhookEvent.update({status: done|partial|failed|filtered})
|
||||
```
|
||||
|
||||
## Funciones principales por archivo
|
||||
|
||||
### `sap-webhook-api`
|
||||
|
||||
| Archivo | Función | Qué hace |
|
||||
|---|---|---|
|
||||
| `src/webhooks/webhooks.controller.ts` | `receiveSapEvent()` | Recibe el `POST /webhooks/sap`, valida HMAC si aplica, guarda el `.json` local y llama al repositorio. |
|
||||
| `src/webhooks/webhook-events.repository.ts` | `saveWebhookEventToInbox()` | Inserta la fila en `webhook_events` (`status='pending'`). |
|
||||
|
||||
### `sap-intercompany-worker`
|
||||
|
||||
| Archivo | Función | Qué hace |
|
||||
|---|---|---|
|
||||
| `src/index.ts` | `runOnce()` | Orquesta un tick: carga config, corre el motor. Se llama por cron o una vez con `--once`. |
|
||||
| `src/replicationConfig.ts` | `loadReplicationConfig()` | Trae de Postgres las bases, escenarios, destinos y mapeos de valor activos — una sola vez por tick. |
|
||||
| `src/replicateEvents.ts` | `processPendingEvents()` | El motor: recorre eventos pendientes × escenarios activos (datos maestros y transacciones). Resuelve el/los destino(s) según `destinationMode` — lista fija (`scenario_destinations`) en `fixed`, o `sourceRecord[routingField]` en `payload` — y calcula `destinationBusinessObject` (mismo objeto que el origen salvo que el escenario declare uno distinto). |
|
||||
| | `replicateToDestination()` | Arma el payload para UN destino puntual, resuelve `PATCH`/`POST` y registra el intento — la misma función sirve para `fixed` y `payload`, ya no importa de dónde salió el código de destino. |
|
||||
| | `extractSourceCompanyDb()` | Saca el `CompanyDB` del campo `source` del CloudEvent — protección contra loops. |
|
||||
| | `resolveDestinationRecord()` | Decide si el registro existe en el destino: `GET` directo (`portable`) o por `$filter` sobre `matchField` (`udfLink`). |
|
||||
| | `formatKeySegment()` / `escapeODataString()` | Arman la URL OData según `keyType` (con o sin comillas) y escapan valores. |
|
||||
| | `cleanRecord()` | Quita metadata OData (`@odata.context`, etc.) del registro leído. |
|
||||
| `src/scenarioTransform.ts` | `loadScenarioModule()` | Importa dinámicamente `src/scenarios/<code>.ts` y valida que exporte `filter`/`transform`. |
|
||||
| | `scenarioFileSlug()` | Deriva el nombre de archivo (minúsculas) a partir del `code` (mayúsculas). |
|
||||
| | `buildScenarioFilterContext()` | Arma el `ctx` (con `get`) que recibe `filter` — permite un `GET` extra contra la base origen. |
|
||||
| | `buildScenarioTransformContext()` | Arma el `ctx` (con `mapValue`) que recibe `transform`. |
|
||||
| | `applyScenarioTransform()` | Ejecuta `transform` y valida que devuelva un objeto JSON. |
|
||||
| `src/scenarios/<code>.ts` | `filter()` | Decide si el escenario aplica a ESE registro (una vez por evento). Puede ser `async` y usar `ctx.get(path)` si necesita un dato que no viene en el payload. |
|
||||
| | `transform()` | Arma el payload final para UN destino (una vez por destino), usando `ctx.mapValue` para los mapeos de campo. |
|
||||
| `src/serviceLayer.ts` | `ServiceLayerClient.login/get/post/patch()` | Cliente mínimo del Service Layer — login lazy, sin cachear sesión entre corridas. |
|
||||
|
||||
## Dónde termina cada mensaje
|
||||
|
||||
- **`webhook_events.status`**: `pending` → `processing` → `done` / `partial` / `failed` / `filtered` (ver la tabla de reglas en `ARQUITECTURA.md`).
|
||||
- **`replication_attempts`**: una fila por cada destino intentado dentro de cada escenario que aplicó — incluye el payload enviado (éxito y falla) y, para `keyStrategy=udfLink`, la clave real del destino en `result_key`.
|
||||
Binary file not shown.
+156
@@ -0,0 +1,156 @@
|
||||
# Referencia de tablas
|
||||
|
||||
Todas viven en la base Postgres compartida (`docker-compose.yml` en la raíz
|
||||
del workspace), definidas en [`prisma/schema.prisma`](../prisma/schema.prisma).
|
||||
Los ejemplos de esta página usan datos reales de la config actual del
|
||||
ambiente de desarrollo (bases `HQ01`/`SUB_01`.."SUB_04", escenario
|
||||
`BP_PROVEEDORES`).
|
||||
|
||||
## `webhook_events`
|
||||
|
||||
Bandeja de entrada. `sap-webhook-api` inserta una fila acá por cada
|
||||
notificación real que recibe de SAP (además de guardar el `.json` local).
|
||||
No se toca a mano — el worker es el único que la actualiza.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `id` | `String` (PK) | El `id` del CloudEvent que manda SAP. |
|
||||
| `business_object` | `String` | `'BusinessPartners'`, `'SalesPersons'`, `'ProfitCenters'`, ... |
|
||||
| `operation` | `String` | `'Created'` \| `'Updated'`. |
|
||||
| `subject` | `String` | Clave del registro en la base que lo generó (`CardCode`, `SalesEmployeeCode`, `CenterCode`, según el objeto). |
|
||||
| `payload` | `Json` | El CloudEvent completo tal cual llegó — incluye `source` (`/default/sap.b1/<CompanyDB>`), que el motor usa para la protección contra loops (ver `ARQUITECTURA.md`). |
|
||||
| `status` | `EventStatus` | `pending` → `processing` → `done` \| `partial` \| `failed` \| `filtered`. |
|
||||
| `received_at` | `DateTime` | |
|
||||
|
||||
## `databases`
|
||||
|
||||
Registro único de toda base SAP alcanzable por Service Layer. Sin
|
||||
distinción HQ/subsidiaria — ver [`ARQUITECTURA.md`](./ARQUITECTURA.md).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `id` | `Int` (PK, autoincremental) | Identificador interno, no se usa como referencia legible. |
|
||||
| `code` | `String` (único) | Mnemónico corto y **estable**, asignado una sola vez al crear (ej. `HQ01`, `SUB_01`). No autoincremental — es lo que aparece en los logs. |
|
||||
| `name` | `String` | Descriptivo, solo para mostrar. |
|
||||
| `base_url` | `String` | URL del Service Layer, ej. `https://sap10:50000/b1s/v2`. |
|
||||
| `company_db` | `String` | `CompanyDB` de esa base. |
|
||||
| `username` / `password` | `String` | Credenciales de login del Service Layer. |
|
||||
| `active` | `Boolean` | Una base inactiva no se usa como origen ni destino en ningún escenario. |
|
||||
|
||||
Ejemplo (ambiente de desarrollo actual):
|
||||
|
||||
| code | name | base_url | company_db | active |
|
||||
|---|---|---|---|---|
|
||||
| `HQ01` | HQ DemoCL | `https://sap10:50000/b1s/v2` | `SBODemoCL` | `true` |
|
||||
| `SUB_01` | SUB 01 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_01` | `true` |
|
||||
| `SUB_02` | SUB 02 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_02` | `true` |
|
||||
| `SUB_03` | SUB 03 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_03` | `true` |
|
||||
| `SUB_04` | SUB 04 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_04` | `true` |
|
||||
|
||||
## `scenarios`
|
||||
|
||||
Una fila por escenario. Ver [`ESCENARIOS.md`](./ESCENARIOS.md) para cómo
|
||||
armar uno con el CLI.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `id` | `Int` (PK) | |
|
||||
| `code` | `String` (único) | Identificador legible del escenario, en mayúsculas (ej. `BP_PROVEEDORES`). Determina el nombre de su archivo (`filter` + `transform`) en minúsculas: `src/scenarios/<code en minúsculas>.ts` (ej. `bp_proveedores.ts`) — ver `scenarioFileSlug` en `src/scenarioTransform.ts`. |
|
||||
| `name` | `String` | Descriptivo. |
|
||||
| `business_object` | `String` | Objeto que dispara el escenario: con qué se matchea `webhook_events` y con qué se lee el registro del origen. |
|
||||
| `destination_business_object` | `String?` | Objeto a usar en el `GET`/`POST`/`PATCH` del **destino**, si es distinto al de origen (ej. origen `PurchaseOrders` → destino `Orders`, típico de comercio intercompañía). `null` ⇒ mismo objeto que `business_object` (el caso común). |
|
||||
| `origin_database_code` | `String` (FK → `databases.code`) | De dónde se lee el registro completo. |
|
||||
| `destination_mode` | `ScenarioDestinationMode` | `fixed` (lista fija en `scenario_destinations`) \| `payload` (destino resuelto por `routing_field` — ambos implementados). |
|
||||
| `key_field` | `String` | Campo clave del registro, ej. `CardCode`, `SalesEmployeeCode`. |
|
||||
| `key_type` | `ScenarioKeyType` | `string` (va entre comillas en la URL OData) \| `number` (sin comillas — un campo numérico con comillas da HTTP 400). |
|
||||
| `key_strategy` | `ScenarioKeyStrategy` | `portable` (la clave vale lo mismo en origen y destino) \| `udfLink` (SAP asigna la clave por base; se busca por `match_field`). |
|
||||
| `match_field` | `String?` | Solo si `key_strategy = udfLink`: nombre del UDF en el destino que guarda la clave de origen (ej. `U_ITC_Source_JdtNum`). |
|
||||
| `routing_field` | `String?` | Solo si `destination_mode = payload`: UDF en el origen cuyo valor es el `code` de la base destino (ej. `U_ITC_Target`). |
|
||||
| `active` | `Boolean` | |
|
||||
|
||||
No hay columnas de filtro: el filtro es la función `filter()` del archivo
|
||||
del escenario (ver [`ARQUITECTURA.md`](./ARQUITECTURA.md)).
|
||||
|
||||
Ejemplo (los 5 escenarios activos hoy en desarrollo):
|
||||
|
||||
| code | business_object | destination_business_object | origin | destinations | key_field | key_strategy |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `BP_PROVEEDORES` | `BusinessPartners` | — | `HQ01` | `SUB_01`, `SUB_02` | `CardCode` | `portable` |
|
||||
| `CHARTOFACCOUNTS` | `ChartOfAccounts` | — | `HQ01` | `SUB_01`, `SUB_02` | `Code` | `portable` |
|
||||
| `JOURNALENTRIES` | `JournalEntries` | — | `HQ01` | (payload: `U_ITC_Target`) | `JdtNum` | `udfLink` (`U_ITC_Source_JdtNum`) |
|
||||
| `PROFITCENTERS` | `ProfitCenters` | — | `HQ01` | `SUB_01`, `SUB_02`, `SUB_03` | `CenterCode` | `portable` |
|
||||
| `SALESPERSONS` | `SalesPersons` | — | `HQ01` | `SUB_01`, `SUB_03` | `SalesEmployeeCode` | `udfLink` (`U_HQ_SlpCode`) |
|
||||
|
||||
Ninguno usa todavía `destination_business_object` (ninguno lo necesita por
|
||||
ahora) — quedaría, por ejemplo, `PurchaseOrders` → `Orders` en un futuro
|
||||
escenario de compras intercompañía.
|
||||
|
||||
## `scenario_destinations`
|
||||
|
||||
Destinos fijos de un escenario (solo aplica a `destination_mode = fixed`).
|
||||
Único por `(scenario_id, database_code)`.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `scenario_id` | `Int` (FK → `scenarios.id`) | |
|
||||
| `database_code` | `String` (FK → `databases.code`) | |
|
||||
|
||||
## `scenario_value_mappings`
|
||||
|
||||
Datos puros que el archivo de transformación de un escenario consulta vía
|
||||
`ctx.mapValue(fieldName, sourceValue)` — ver
|
||||
[`ARQUITECTURA.md`](./ARQUITECTURA.md). El código decide QUÉ campo mapear y
|
||||
QUÉ hacer si falta el valor (tirar error, dejar sin traducir, etc.); acá
|
||||
solo vive la equivalencia. Escopada por escenario (no global por
|
||||
objeto+campo) porque el mismo campo puede tener dominios de valores
|
||||
distintos según el escenario (ej. `GroupCode` de clientes vs. de
|
||||
proveedores).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `scenario_id` | `Int` (FK) | |
|
||||
| `field_name` | `String` | Ej. `GroupCode`. |
|
||||
| `database_code` | `String` (FK → `databases.code`) | El destino al que aplica esta traducción puntual. |
|
||||
| `source_value` | `String` | Valor tal como viene de la base origen. |
|
||||
| `target_value` | `String` | Valor a usar en ese destino. |
|
||||
|
||||
Único por `(scenario_id, field_name, database_code, source_value)`.
|
||||
|
||||
Ejemplo real (`BP_PROVEEDORES`, `GroupCode`, hoy 1:1 en ambas subsidiarias):
|
||||
|
||||
| scenario | field_name | database_code | source_value | target_value |
|
||||
|---|---|---|---|---|
|
||||
| `BP_PROVEEDORES` | `GroupCode` | `SUB_01` | `101` | `101` |
|
||||
| `BP_PROVEEDORES` | `GroupCode` | `SUB_01` | `110` | `110` |
|
||||
| `BP_PROVEEDORES` | `GroupCode` | `SUB_02` | `101` | `101` |
|
||||
| ... | | | | (7 códigos × 2 destinos = 14 filas) |
|
||||
|
||||
## `replication_attempts`
|
||||
|
||||
Log de auditoría inmutable. Una fila por cada intento de un escenario hacia
|
||||
una base destino.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `event_id` | `String` (FK → `webhook_events.id`) | |
|
||||
| `scenario_id` | `Int` (FK → `scenarios.id`) | Imprescindible porque un evento puede pasar por varios escenarios a la vez. |
|
||||
| `origin_database` | `String` | Código de la base origen usada en **este** intento — texto libre, no FK: sobrevive si la base se borra o renombra después. |
|
||||
| `destination_database` | `String` | Ídem para el destino. `'N/A'` si el intento falló antes de resolver un destino (ej. error leyendo el origen). |
|
||||
| `verb_used` | `String` | `POST` \| `PATCH` \| `SKIPPED` (falta mapeo, o transform tiró error) \| `N/A` (error antes de llamar al destino). |
|
||||
| `http_status` | `Int?` | |
|
||||
| `error_message` | `String?` | |
|
||||
| `payload_sent` | `Json?` | El payload ya transformado enviado al destino — se guarda **siempre**, en éxito y en falla. |
|
||||
| `result_key` | `String?` | Para `key_strategy=udfLink`: la clave propia del destino (encontrada o recién asignada por SAP en un `POST`). `null` en `key_strategy=portable` y en datos maestros sin esta estrategia. |
|
||||
| `attempted_at` | `DateTime` | |
|
||||
|
||||
Índices sobre `event_id`, `scenario_id` y `attempted_at`, pensados para la
|
||||
futura UI de consulta (`sap-intercompany-viewer`, ver `ARQUITECTURA.md`).
|
||||
|
||||
Ejemplo real (`SALESPERSONS`, vendedor nuevo `SalesEmployeeCode=7` en HQ01,
|
||||
`POST` en ambas subsidiarias — SAP le asignó el mismo número por
|
||||
coincidencia, pero el motor no lo asume, lo captura del response):
|
||||
|
||||
| event_id | scenario | destination_database | verb_used | http_status | result_key |
|
||||
|---|---|---|---|---|---|
|
||||
| `149be29e-...` | `SALESPERSONS` | `SUB_01` | `POST` | `201` | `7` |
|
||||
| `149be29e-...` | `SALESPERSONS` | `SUB_03` | `POST` | `201` | `7` |
|
||||
Reference in new issue
Block a user