Files
hedena_gold/apps/api/scripts/legacy-import/index.ts
T
s.esmaeiliandCursor d613d16c82 fix: rename the henza brand to montzar
اپ: اصلاح نام برند به منتزر

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 17:17:14 +03:30

162 lines
6.6 KiB
TypeScript

/**
* Legacy system (MySQL `oapi`) → montzar (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=<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, reconcileSales } 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],
['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<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);
});