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>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
// CLI de administración de la configuración de replicación (bases de datos,
|
|
// escenarios, destinos y mapeos de valor). Reemplaza la edición manual de
|
|
// SUBSIDIARIES/HQ_* en .env: los cambios acá tienen efecto en el próximo
|
|
// tick del worker, sin reiniciar el proceso.
|
|
//
|
|
// El filtro y la transformación del payload de un escenario son un archivo
|
|
// de código en src/scenarios/<code>.ts (ver docs/ESCENARIOS.md) — SIEMPRE
|
|
// con las mismas dos partes (filter + transform). `scenario:add` genera uno
|
|
// de partida la primera vez que se crea un escenario nuevo. Los mapeos de
|
|
// valor (scenario:mapping:*) SÍ se administran acá: son datos que ese
|
|
// archivo consulta vía ctx.mapValue(...) dentro de transform, pero la
|
|
// decisión de qué campo mapear y qué hacer si falta el mapeo queda siempre
|
|
// en el código.
|
|
//
|
|
// Uso:
|
|
// tsx scripts/manage-config.ts database:add <code> <name> <baseUrl> <companyDb> <user> <pass>
|
|
// tsx scripts/manage-config.ts database:disable <code>
|
|
// tsx scripts/manage-config.ts database:list
|
|
//
|
|
// tsx scripts/manage-config.ts 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]
|
|
// tsx scripts/manage-config.ts scenario:rename <oldCode> <newCode>
|
|
// tsx scripts/manage-config.ts scenario:disable <code>
|
|
// tsx scripts/manage-config.ts scenario:list
|
|
//
|
|
// tsx scripts/manage-config.ts scenario:add-destination <scenarioCode> <databaseCode>
|
|
// tsx scripts/manage-config.ts scenario:remove-destination <scenarioCode> <databaseCode>
|
|
//
|
|
// tsx scripts/manage-config.ts scenario:mapping:add <scenarioCode> <fieldName> <databaseCode> <sourceValue> <targetValue>
|
|
// tsx scripts/manage-config.ts scenario:mapping:list <scenarioCode> <fieldName>
|
|
|
|
import 'dotenv/config';
|
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { PrismaPg } from '@prisma/adapter-pg';
|
|
import { PrismaClient } from '../src/generated/prisma/client.ts';
|
|
import { scenarioFileSlug } from '../src/scenarioTransform.ts';
|
|
|
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
const prisma = new PrismaClient({ adapter });
|
|
|
|
const scenariosDir = fileURLToPath(new URL('../src/scenarios/', import.meta.url));
|
|
|
|
function scenarioFilePath(code: string): string {
|
|
return `${scenariosDir}${scenarioFileSlug(code)}.ts`;
|
|
}
|
|
|
|
function scaffoldTransformFile(code: string): string | null {
|
|
const path = scenarioFilePath(code);
|
|
if (existsSync(path)) return null;
|
|
mkdirSync(scenariosDir, { recursive: true });
|
|
writeFileSync(
|
|
path,
|
|
`// Escenario ${code} — completar según corresponda (ver docs/ESCENARIOS.md).
|
|
import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts';
|
|
|
|
// --- Filtro: decide si este escenario aplica al registro ---
|
|
// Puede ser async: ctx.get(path) permite consultar la base ORIGEN (ej. si
|
|
// el filtro depende de un dato que no viene en el payload del evento).
|
|
export function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): boolean | Promise<boolean> {
|
|
return true; // reemplazar con la condición real, o dejar así para "sin filtro"
|
|
}
|
|
|
|
// --- Transformación: arma el payload para un destino puntual ---
|
|
// Se llama una vez por destino: ctx.destinationCode permite transformar
|
|
// distinto según a cuál le toque. Puede lanzar una excepción para marcar
|
|
// el intento como SKIPPED (ej. un valor sin mapeo cargado).
|
|
export function transform(record: Record<string, unknown>, ctx: ScenarioTransformContext): Record<string, unknown> {
|
|
// --- Mapeo: ctx.mapValue(fieldName, sourceValue) consulta scenario_value_mappings
|
|
// (cargados con 'scenario:mapping:add') para ESTE escenario y destino ---
|
|
|
|
return { ...record };
|
|
}
|
|
`,
|
|
);
|
|
return path;
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function flag(args: string[], name: string): string | undefined {
|
|
const index = args.indexOf(`--${name}`);
|
|
return index === -1 ? undefined : args[index + 1];
|
|
}
|
|
|
|
async function requireScenario(code: string) {
|
|
const scenario = await prisma.scenario.findUnique({ where: { code } });
|
|
if (!scenario) fail(`no existe el escenario '${code}'`);
|
|
return scenario;
|
|
}
|
|
|
|
async function main() {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
|
|
switch (command) {
|
|
case 'database:add': {
|
|
const [code, name, baseUrl, companyDb, username, password] = args;
|
|
if (!code || !name || !baseUrl || !companyDb || !username || !password) {
|
|
fail('database:add <code> <name> <baseUrl> <companyDb> <user> <pass>');
|
|
}
|
|
const db = await prisma.database.upsert({
|
|
where: { code },
|
|
create: { code, name, baseUrl, companyDb, username, password },
|
|
update: { name, baseUrl, companyDb, username, password, active: true },
|
|
});
|
|
console.log(`Base de datos ${db.code} guardada (id=${db.id}).`);
|
|
break;
|
|
}
|
|
|
|
case 'database:disable': {
|
|
const [code] = args;
|
|
if (!code) fail('database:disable <code>');
|
|
await prisma.database.update({ where: { code }, data: { active: false } });
|
|
console.log(`Base de datos ${code} desactivada.`);
|
|
break;
|
|
}
|
|
|
|
case 'database:list': {
|
|
const dbs = await prisma.database.findMany({ orderBy: { code: 'asc' } });
|
|
console.table(dbs.map((d) => ({ code: d.code, name: d.name, baseUrl: d.baseUrl, companyDb: d.companyDb, active: d.active })));
|
|
break;
|
|
}
|
|
|
|
case 'scenario:add': {
|
|
const [code, name, businessObject, originDatabaseCode, keyField] = args;
|
|
if (!code || !name || !businessObject || !originDatabaseCode || !keyField) {
|
|
fail(
|
|
'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]',
|
|
);
|
|
}
|
|
|
|
const keyStrategyFlag = flag(args, 'key-strategy') ?? 'portable';
|
|
if (keyStrategyFlag !== 'portable' && keyStrategyFlag !== 'udfLink') {
|
|
fail("--key-strategy debe ser 'portable' o 'udfLink'");
|
|
}
|
|
const matchField = flag(args, 'match-field') ?? null;
|
|
if (keyStrategyFlag === 'udfLink' && !matchField) {
|
|
fail('--key-strategy udfLink necesita --match-field <UDF>');
|
|
}
|
|
const keyTypeFlag = flag(args, 'key-type') ?? 'string';
|
|
if (keyTypeFlag !== 'string' && keyTypeFlag !== 'number') {
|
|
fail("--key-type debe ser 'string' o 'number' (numérico va sin comillas en la URL OData, ej. SalesEmployeeCode)");
|
|
}
|
|
const destinationModeFlag = flag(args, 'destination-mode') ?? 'fixed';
|
|
if (destinationModeFlag !== 'fixed' && destinationModeFlag !== 'payload') {
|
|
fail("--destination-mode debe ser 'fixed' o 'payload'");
|
|
}
|
|
const routingField = flag(args, 'routing-field') ?? null;
|
|
if (destinationModeFlag === 'payload' && !routingField) {
|
|
fail('--destination-mode payload necesita --routing-field <UDF> (campo en el origen con el code de la base destino)');
|
|
}
|
|
const destinationObject = flag(args, 'destination-object') ?? null;
|
|
|
|
const existed = (await prisma.scenario.findUnique({ where: { code } })) !== null;
|
|
|
|
const scenario = await prisma.scenario.upsert({
|
|
where: { code },
|
|
create: {
|
|
code,
|
|
name,
|
|
businessObject,
|
|
destinationBusinessObject: destinationObject,
|
|
originDatabaseCode,
|
|
destinationMode: destinationModeFlag,
|
|
routingField,
|
|
keyField,
|
|
keyType: keyTypeFlag,
|
|
keyStrategy: keyStrategyFlag,
|
|
matchField,
|
|
},
|
|
update: {
|
|
name,
|
|
businessObject,
|
|
destinationBusinessObject: destinationObject,
|
|
originDatabaseCode,
|
|
destinationMode: destinationModeFlag,
|
|
routingField,
|
|
keyField,
|
|
keyType: keyTypeFlag,
|
|
keyStrategy: keyStrategyFlag,
|
|
matchField,
|
|
active: true,
|
|
},
|
|
});
|
|
console.log(`Escenario ${scenario.code} guardado (id=${scenario.id}).`);
|
|
|
|
if (!existed) {
|
|
const scaffolded = scaffoldTransformFile(scenario.code);
|
|
if (scaffolded) console.log(`Archivo de transformación creado: ${scaffolded} (editalo antes de usar el escenario).`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'scenario:rename': {
|
|
const [oldCode, newCode] = args;
|
|
if (!oldCode || !newCode) fail('scenario:rename <oldCode> <newCode>');
|
|
const scenario = await requireScenario(oldCode);
|
|
const oldPath = scenarioFilePath(oldCode);
|
|
const newPath = scenarioFilePath(newCode);
|
|
if (existsSync(newPath)) fail(`ya existe un archivo en ${newPath}`);
|
|
|
|
await prisma.scenario.update({ where: { id: scenario.id }, data: { code: newCode } });
|
|
if (existsSync(oldPath)) {
|
|
renameSync(oldPath, newPath);
|
|
console.log(`Archivo renombrado: ${oldPath} -> ${newPath}`);
|
|
} else {
|
|
console.warn(`Aviso: no se encontró ${oldPath} — creá ${newPath} a mano antes de usar el escenario.`);
|
|
}
|
|
console.log(`Escenario ${oldCode} renombrado a ${newCode}.`);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:disable': {
|
|
const [code] = args;
|
|
if (!code) fail('scenario:disable <code>');
|
|
await prisma.scenario.update({ where: { code }, data: { active: false } });
|
|
console.log(`Escenario ${code} desactivado.`);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:list': {
|
|
const scenarios = await prisma.scenario.findMany({
|
|
orderBy: { code: 'asc' },
|
|
include: { destinations: true },
|
|
});
|
|
console.table(
|
|
scenarios.map((s) => ({
|
|
code: s.code,
|
|
businessObject: s.destinationBusinessObject ? `${s.businessObject} -> ${s.destinationBusinessObject}` : s.businessObject,
|
|
origin: s.originDatabaseCode,
|
|
destinations:
|
|
s.destinationMode === 'payload' ? `(via payload: ${s.routingField})` : s.destinations.map((d) => d.databaseCode).join(', '),
|
|
keyField: s.keyStrategy === 'udfLink' ? `${s.keyField} (via ${s.matchField})` : s.keyField,
|
|
archivo: `src/scenarios/${scenarioFileSlug(s.code)}.ts`,
|
|
active: s.active,
|
|
})),
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:add-destination': {
|
|
const [scenarioCode, databaseCode] = args;
|
|
if (!scenarioCode || !databaseCode) fail('scenario:add-destination <scenarioCode> <databaseCode>');
|
|
const scenario = await requireScenario(scenarioCode);
|
|
await prisma.scenarioDestination.upsert({
|
|
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
|
create: { scenarioId: scenario.id, databaseCode },
|
|
update: {},
|
|
});
|
|
console.log(`Destino ${databaseCode} agregado a escenario ${scenarioCode}.`);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:remove-destination': {
|
|
const [scenarioCode, databaseCode] = args;
|
|
if (!scenarioCode || !databaseCode) fail('scenario:remove-destination <scenarioCode> <databaseCode>');
|
|
const scenario = await requireScenario(scenarioCode);
|
|
await prisma.scenarioDestination.delete({
|
|
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
|
});
|
|
console.log(`Destino ${databaseCode} quitado de escenario ${scenarioCode}.`);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:mapping:add': {
|
|
const [scenarioCode, fieldName, databaseCode, sourceValue, targetValue] = args;
|
|
if (!scenarioCode || !fieldName || !databaseCode || !sourceValue || !targetValue) {
|
|
fail('scenario:mapping:add <scenarioCode> <fieldName> <databaseCode> <sourceValue> <targetValue>');
|
|
}
|
|
const scenario = await requireScenario(scenarioCode);
|
|
await prisma.scenarioValueMapping.upsert({
|
|
where: {
|
|
scenarioId_fieldName_databaseCode_sourceValue: { scenarioId: scenario.id, fieldName, databaseCode, sourceValue },
|
|
},
|
|
create: { scenarioId: scenario.id, fieldName, databaseCode, sourceValue, targetValue },
|
|
update: { targetValue },
|
|
});
|
|
console.log(`Mapeo ${scenarioCode}/${fieldName} [${databaseCode}]: ${sourceValue} -> ${targetValue} guardado.`);
|
|
break;
|
|
}
|
|
|
|
case 'scenario:mapping:list': {
|
|
const [scenarioCode, fieldName] = args;
|
|
if (!scenarioCode || !fieldName) fail('scenario:mapping:list <scenarioCode> <fieldName>');
|
|
const scenario = await requireScenario(scenarioCode);
|
|
const mappings = await prisma.scenarioValueMapping.findMany({
|
|
where: { scenarioId: scenario.id, fieldName },
|
|
orderBy: [{ databaseCode: 'asc' }, { sourceValue: 'asc' }],
|
|
});
|
|
console.table(mappings.map((m) => ({ database: m.databaseCode, sourceValue: m.sourceValue, targetValue: m.targetValue })));
|
|
break;
|
|
}
|
|
|
|
default:
|
|
fail(
|
|
`comando desconocido: ${command ?? '(ninguno)'}\nComandos disponibles: database:add, database:disable, database:list, ` +
|
|
'scenario:add, scenario:rename, scenario:disable, scenario:list, scenario:add-destination, scenario:remove-destination, ' +
|
|
'scenario:mapping:add, scenario:mapping:list',
|
|
);
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(err);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(() => prisma.$disconnect());
|