// 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/.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 // tsx scripts/manage-config.ts database:disable // tsx scripts/manage-config.ts database:list // // tsx scripts/manage-config.ts scenario:add // [--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 // tsx scripts/manage-config.ts scenario:disable // tsx scripts/manage-config.ts scenario:list // // tsx scripts/manage-config.ts scenario:add-destination // tsx scripts/manage-config.ts scenario:remove-destination // // tsx scripts/manage-config.ts scenario:mapping:add // tsx scripts/manage-config.ts scenario:mapping:list 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, ctx: ScenarioFilterContext): boolean | Promise { 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, ctx: ScenarioTransformContext): Record { // --- 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 '); } 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 '); 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 ' + '[--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 '); } 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 (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 '); 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 '); 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 '); 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 '); 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 '); } 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 '); 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());