/** * Legacy system (MySQL `oapi`) → tehran-taj (PostgreSQL) data import. * * The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section. * * Usage (from apps/api): * * npx ts-node --transpile-only scripts/legacy-import/index.ts \ * --source "mysql://user:pass@127.0.0.1:3307/oapi" \ * --target "postgresql://user:pass@localhost:5432/goldbank_import_test" \ * [--images "F:/legacy/storage" --upload-dir "./storage/uploads"] \ * [--only customers,ledger] [--report ./legacy-import-report.md] --yes * * Safety: * - The source is only read. Point it at a COPY of production anyway. * - --target is required and never taken from .env, so the dev database is * never written by accident. Without --yes nothing is written; a target * whose name does not contain "import" or "staging" also needs * --confirm-db=. * - Re-runnable: every written row is recorded in legacy_import.id_map, so a * second run updates instead of duplicating, and a run that stops half way * is finished by running it again. * - Never writes Customer/Product accounting* columns, ProductVariant.imageId, * or the Accounting* tables, and never deletes a ProductImage. * - Writes go straight through Prisma, not through the API services, so no * SMS is sent and nothing is queued to the accounting software. */ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { PrismaClient } from '@prisma/client'; import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/context'; import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people'; import { brands, categories, products, variants } from './steps/catalog'; import { favorites, ledger, orders, preOrders } from './steps/commerce'; import { reconcile, reconcileSales } from './steps/reconcile'; /** Order matters: each step reads the ids the earlier ones mapped. */ const STEPS: [string, (ctx: Ctx) => Promise][] = [ ['cities', cities], ['roles', roles], ['branches', branches], ['admins', admins], ['experts', experts], ['workshops', workshops], ['customers', customers], ['brands', brands], ['categories', categories], ['products', products], ['variants', variants], ['orders', orders], ['ledger', ledger], ['favorites', favorites], ['preorders', preOrders], ['reconcile', reconcile], ['reconcile-sales', reconcileSales], ]; const ENTITIES = [ 'city', 'ut', 'branch', 'admin', 'expert', 'workshop', 'customer', 'file', 'brand', 'category', 'product', 'pimage', 'variant', 'order', 'orderitem', 'ledger', 'preorder', ]; /** Columns the prep migrations added; the import refuses to run without them. */ const REQUIRED = [ ['Customer', 'nationalCode'], ['Customer', 'birthDate'], ['Customer', 'cityNameRaw'], ['Customer', 'verificationRejectionReason'], ['CustomerFavorite', 'customerId'], ]; function parseArgs(argv: string[]) { const a = new Map(); for (let i = 0; i < argv.length; i++) { const k = argv[i]; if (!k.startsWith('--')) continue; const eq = k.indexOf('='); if (eq > 0) a.set(k.slice(2, eq), k.slice(eq + 1)); else if (argv[i + 1] && !argv[i + 1].startsWith('--')) a.set(k.slice(2), argv[++i]); else a.set(k.slice(2), 'true'); } return a; } const fail = (msg: string): never => { console.error(`\n✖ ${msg}\n`); process.exit(1); }; async function main() { const a = parseArgs(process.argv.slice(2)); const opt: Options = { sourceUrl: a.get('source') ?? fail('--source is required'), targetUrl: a.get('target') ?? fail('--target is required (never read from .env)'), imagesDir: a.get('images'), uploadDir: a.get('upload-dir'), only: a.get('only') ? new Set(a.get('only')!.split(',').map((s) => s.trim())) : undefined, reportPath: resolve(a.get('report') ?? 'legacy-import-report.md'), }; if (opt.imagesDir && !opt.uploadDir) fail('--images needs --upload-dir (the target UPLOAD_DIR)'); if (opt.only) for (const s of opt.only) if (!STEPS.some(([n]) => n === s)) fail(`unknown step "${s}"`); const db = new PrismaClient({ datasources: { db: { url: opt.targetUrl } } }); const src = openSource(opt.sourceUrl); try { /* preflight — read only */ const [{ db: dbName }] = await db.$queryRawUnsafe<{ db: string }[]>('SELECT current_database() AS db'); const [srcInfo] = await (await src.query('SELECT DATABASE() AS db, VERSION() AS v'))[0] as { db: string; v: string }[]; const missing: string[] = []; for (const [t, c] of REQUIRED) { const [{ n }] = await db.$queryRawUnsafe<{ n: bigint }[]>( `SELECT COUNT(*) AS n FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`, t, c, ); if (Number(n) === 0) missing.push(`${t}.${c}`); } const counts = { customers: await db.customer.count(), products: await db.product.count(), ledger: await db.ledgerEntry.count(), }; console.log(`source mysql ${srcInfo.v} · database ${srcInfo.db}`); console.log(`target postgres · database ${dbName} · ${counts.customers} customers, ${counts.products} products, ${counts.ledger} ledger entries`); if (missing.length) fail(`target is missing ${missing.join(', ')} — apply the prep migrations first`); if (a.get('yes') !== 'true') { console.log('\nNothing written. Re-run with --yes to import.'); return; } if (!/import|staging/i.test(dbName) && a.get('confirm-db') !== dbName) { fail(`"${dbName}" does not look like a test or staging copy. To write to it anyway add --confirm-db=${dbName}`); } /* run */ await IdMap.ensureSchema(db); const ctx = makeContext(opt, src, db); for (const e of ENTITIES) await ctx.map.load(e); for (const [name, step] of STEPS) { if (opt.only && !opt.only.has(name)) continue; const t0 = Date.now(); process.stdout.write(`▸ ${name} … `); await step(ctx); console.log(`${((Date.now() - t0) / 1000).toFixed(1)}s`); } const md = ctx.report.render([ `- started: ${ctx.report.startedAt.toISOString()} · finished: ${new Date().toISOString()}`, `- source: \`${srcInfo.db}\` (MySQL ${srcInfo.v}) · target: \`${dbName}\``, `- steps: ${opt.only ? [...opt.only].join(', ') : 'all'}`, `- images: ${opt.imagesDir ? `${opt.imagesDir} → ${opt.uploadDir}` : 'not copied (no --images)'}`, ]); writeFileSync(opt.reportPath, md); console.log(`\nreport: ${opt.reportPath}`); } finally { await src.end(); await db.$disconnect(); } } main().catch((e) => { console.error(e); process.exit(1); });