Files
tehran_tala/apps/api/scripts/legacy-import/index.ts
T
s.esmaeiliandClaude Opus 5.5 be5ca829d2 feat(import): henza → gold-bank data import tool
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>
2026-10-07 19:35:18 +03:30

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);
});