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

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());