161 lines
6.5 KiB
TypeScript
161 lines
6.5 KiB
TypeScript
/**
|
|||
|
|
* henza (MySQL `oapi`) → gold-bank (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:/henza/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=<its exact name>.
|
||
|
|
* - 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 } from './steps/reconcile';
|
||
|
|
|
||
|
|
/** Order matters: each step reads the ids the earlier ones mapped. */
|
||
|
|
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
|
||
|
|
['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],
|
||
|
|
];
|
||
|
|
|
||
|
|
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<string, string>();
|
||
|
|
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 <mysql url> is required'),
|
||
|
|
targetUrl: a.get('target') ?? fail('--target <postgres url> 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);
|
||
|
|
});
|