apps/api/scripts/legacy-import/ reads the legacy MySQL (read only) and merges into the target Postgres. Every rule is from docs/DATA-MIGRATION-MAP.md, and each step cites its section. It covers cities, roles and permissions, branches, admin users, sales experts, workshops, customers, brands, categories, products, variants, product images, orders, the ledger, favourites, custom orders and a reconciliation report. Key rules: - Debt is rebuilt from legacy's own formula (updateRemain): delivered orders minus confirmed type 1-4 documents. Type 9 «سفارش» documents mirror orders and are skipped, or every order's debt would count twice. A balance the records cannot explain is carried as one ADJUSTMENT entry. In the 2026-10-04 dump that is only customer #125 (owner ruling). - Merge, not import: rows already in the target are matched by mobile or product code, and only their empty fields are filled. Never written: accounting* columns, ProductVariant.imageId, Accounting* tables. No ProductImage is ever deleted. - Roles map by title, not number. Legacy 86 is «حسابدار» but target 86 is «کارشناس فروش». An expert sharing a mobile with an admin never demotes the admin. - Soft-deleted variants and products that live order lines still point at (473 of 610 lines) come over inactive, so order history stays whole. - Owner rulings 2026-10-07: birthdays filtered (541 of 915), typed city kept plus exact-match cityId, the 7 legacy rejections get the sentinel reason, OTP-only accounts get OTP_ONLY_PASSWORD_HASH. - Tehran wall-clock timestamps are converted with ICU rules, including Iran's pre-2022 DST. Jalali receipt dates are converted with the jalaali-js arithmetic (truncating division; verified on 8 known dates). Safety: --target is required and never read from .env. Nothing is written without --yes, and a target not named *import*/*staging* also needs --confirm-db. The id map lives in its own schema, legacy_import, so a re-run updates instead of duplicating. Writes bypass the API services, so no SMS is sent and nothing is queued to accounting. Tested against goldbank_import_test, a clone of the dev DB, in three runs: - 1,503/1,503 created customers match legacy gold and rial balances (±0.0001 g / ±1 rial) - accounting data is byte-identical before and after - 0 imported variants have an imageId - runs 2 and 3 created zero duplicate rows - 6,196 image files copied, 3 missing in the legacy storage New devDependency: mysql2 ^3.24.5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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);
|
|
});
|