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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7019cad932
commit
be5ca829d2
@@ -43,6 +43,7 @@
|
||||
"@types/supertest": "^7.2.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"jest": "^30.5.2",
|
||||
"mysql2": "^3.24.5",
|
||||
"prisma": "^5.19.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.12",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Shared state for one import run: both connections, the id map and the report.
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createPool, type Pool, type RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
export interface Options {
|
||||
sourceUrl: string;
|
||||
targetUrl: string;
|
||||
/** henza `storage/` folder holding the image files (optional). */
|
||||
imagesDir?: string;
|
||||
/** Target UPLOAD_DIR the files are copied into (required with imagesDir). */
|
||||
uploadDir?: string;
|
||||
/** Run only these steps (by name); all when unset. */
|
||||
only?: Set<string>;
|
||||
reportPath: string;
|
||||
}
|
||||
|
||||
/* ───────────── report ───────────── */
|
||||
|
||||
interface Section {
|
||||
counts: Map<string, number>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the run did, step by step. Counts say how many rows went which way;
|
||||
* notes are the things a person must look at. Rendered to markdown at the end.
|
||||
*/
|
||||
export class Report {
|
||||
readonly startedAt = new Date();
|
||||
private readonly sections = new Map<string, Section>();
|
||||
|
||||
private section(step: string): Section {
|
||||
let s = this.sections.get(step);
|
||||
if (!s) {
|
||||
s = { counts: new Map(), notes: [] };
|
||||
this.sections.set(step, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
count(step: string, key: string, n = 1) {
|
||||
const s = this.section(step);
|
||||
s.counts.set(key, (s.counts.get(key) ?? 0) + n);
|
||||
}
|
||||
|
||||
note(step: string, message: string) {
|
||||
this.section(step).notes.push(message);
|
||||
}
|
||||
|
||||
get(step: string, key: string): number {
|
||||
return this.sections.get(step)?.counts.get(key) ?? 0;
|
||||
}
|
||||
|
||||
render(header: string[]): string {
|
||||
const out: string[] = ['# henza → gold-bank import report', '', ...header, ''];
|
||||
for (const [step, s] of this.sections) {
|
||||
out.push(`## ${step}`, '');
|
||||
if (s.counts.size) {
|
||||
out.push('| | |', '|---|---:|');
|
||||
for (const [k, v] of s.counts) out.push(`| ${k} | ${v} |`);
|
||||
out.push('');
|
||||
}
|
||||
for (const n of s.notes) out.push(`- ${n}`);
|
||||
if (s.notes.length) out.push('');
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── id map ───────────── */
|
||||
|
||||
/**
|
||||
* Legacy id -> target id, per entity, persisted in the TARGET database under
|
||||
* its own schema `legacy_import` — outside the `public` schema Prisma manages,
|
||||
* so it is no app-schema change and drops cleanly after cutover
|
||||
* (`DROP SCHEMA legacy_import CASCADE`).
|
||||
*
|
||||
* It is what makes a re-run safe (a mapped row is updated, never re-created)
|
||||
* and what later redirects old links such as /product/123 (GO-LIVE-PLAN §7).
|
||||
* `how` records whether the row was created by the import or matched to a row
|
||||
* that already existed in the target (merge, §10.5).
|
||||
*/
|
||||
export type How = 'created' | 'matched' | 'merged';
|
||||
|
||||
export class IdMap {
|
||||
private readonly cache = new Map<string, Map<string, { target: string; how: How }>>();
|
||||
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
|
||||
static async ensureSchema(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS legacy_import`);
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS legacy_import.id_map (
|
||||
entity text NOT NULL,
|
||||
legacy_id text NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
how text NOT NULL,
|
||||
mapped_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (entity, legacy_id)
|
||||
)`);
|
||||
}
|
||||
|
||||
async load(entity: string) {
|
||||
const rows = await this.db.$queryRawUnsafe<{ legacy_id: string; target_id: string; how: How }[]>(
|
||||
`SELECT legacy_id, target_id, how FROM legacy_import.id_map WHERE entity = $1`,
|
||||
entity,
|
||||
);
|
||||
this.cache.set(entity, new Map(rows.map((r) => [r.legacy_id, { target: r.target_id, how: r.how }])));
|
||||
}
|
||||
|
||||
get(entity: string, legacyId: unknown): string | undefined {
|
||||
if (legacyId === null || legacyId === undefined) return undefined;
|
||||
return this.cache.get(entity)?.get(String(legacyId))?.target;
|
||||
}
|
||||
|
||||
how(entity: string, legacyId: unknown): How | undefined {
|
||||
return this.cache.get(entity)?.get(String(legacyId))?.how;
|
||||
}
|
||||
|
||||
async set(entity: string, legacyId: unknown, targetId: string, how: How) {
|
||||
const key = String(legacyId);
|
||||
await this.db.$executeRawUnsafe(
|
||||
`INSERT INTO legacy_import.id_map (entity, legacy_id, target_id, how)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (entity, legacy_id) DO UPDATE SET target_id = EXCLUDED.target_id, how = EXCLUDED.how`,
|
||||
entity, key, targetId, how,
|
||||
);
|
||||
let m = this.cache.get(entity);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
this.cache.set(entity, m);
|
||||
}
|
||||
m.set(key, { target: targetId, how });
|
||||
}
|
||||
|
||||
async drop(entity: string, legacyId: unknown) {
|
||||
await this.db.$executeRawUnsafe(
|
||||
`DELETE FROM legacy_import.id_map WHERE entity = $1 AND legacy_id = $2`,
|
||||
entity, String(legacyId),
|
||||
);
|
||||
this.cache.get(entity)?.delete(String(legacyId));
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── upsert discipline ───────────── */
|
||||
|
||||
/**
|
||||
* The one way a step writes a row (§10.5: this is a MERGE into a live target):
|
||||
*
|
||||
* - already mapped -> `update(id, how)`; the step decides what it may touch
|
||||
* - matches a target row -> mapped as 'matched', then `update(id, 'matched')`
|
||||
* - otherwise -> `create()`, mapped as 'created'
|
||||
*
|
||||
* A mapping whose target row has since been deleted is dropped and redone, so a
|
||||
* re-run heals instead of failing on a dangling id.
|
||||
*/
|
||||
export async function upsert(
|
||||
ctx: Ctx,
|
||||
entity: string,
|
||||
legacyId: unknown,
|
||||
ops: {
|
||||
exists: (id: string) => Promise<boolean>;
|
||||
find: () => Promise<{ id: string } | null>;
|
||||
create: () => Promise<{ id: string }>;
|
||||
update: (id: string, how: How) => Promise<void>;
|
||||
},
|
||||
): Promise<{ id: string; how: How; isNew: boolean }> {
|
||||
const mapped = ctx.map.get(entity, legacyId);
|
||||
if (mapped) {
|
||||
if (await ops.exists(mapped)) {
|
||||
const how = ctx.map.how(entity, legacyId) ?? 'created';
|
||||
await ops.update(mapped, how);
|
||||
return { id: mapped, how, isNew: false };
|
||||
}
|
||||
await ctx.map.drop(entity, legacyId);
|
||||
}
|
||||
const found = await ops.find();
|
||||
if (found) {
|
||||
await ctx.map.set(entity, legacyId, found.id, 'matched');
|
||||
await ops.update(found.id, 'matched');
|
||||
return { id: found.id, how: 'matched', isNew: false };
|
||||
}
|
||||
const created = await ops.create();
|
||||
await ctx.map.set(entity, legacyId, created.id, 'created');
|
||||
return { id: created.id, how: 'created', isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* For a row that already existed in the target (`matched`): only the fields
|
||||
* the target left empty are filled. The target's own values win — the owner
|
||||
* confirmed its data is real (§10.5).
|
||||
*/
|
||||
export function fillEmpty<T extends Record<string, unknown>>(current: Record<string, unknown>, patch: T): Partial<T> {
|
||||
const out: Partial<T> = {};
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
const cur = current[k];
|
||||
if ((cur === null || cur === undefined || cur === '') && v !== null && v !== undefined) {
|
||||
(out as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ───────────── context ───────────── */
|
||||
|
||||
export interface Ctx {
|
||||
src: Pool;
|
||||
db: PrismaClient;
|
||||
map: IdMap;
|
||||
report: Report;
|
||||
opt: Options;
|
||||
/** A read-only query against the legacy MySQL. */
|
||||
q<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||
/** Public URL for a legacy `image` row, copying its file when a source dir is given. */
|
||||
imageUrl(imageId: unknown, step: string): Promise<string | null>;
|
||||
/**
|
||||
* An `UploadedFile` row for a legacy image — what avatars point at
|
||||
* (Customer/AdminUser.avatarFileId are FKs, not URLs). Mapped as 'file'.
|
||||
*/
|
||||
uploadedFile(imageId: unknown, purpose: string, step: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
||||
webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
export function openSource(url: string): Pool {
|
||||
return createPool({
|
||||
uri: url,
|
||||
connectionLimit: 4,
|
||||
charset: 'utf8mb4',
|
||||
// Timestamps come back as the wall-clock text legacy stored, and are
|
||||
// converted from Tehran time explicitly (normalize.tehran). The session
|
||||
// time zone is left at the server's own, which is what Laravel and the
|
||||
// Navicat dump both used.
|
||||
dateStrings: true,
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeContext(opt: Options, src: Pool, db: PrismaClient): Ctx {
|
||||
const map = new IdMap(db);
|
||||
const report = new Report();
|
||||
const imageRows = new Map<string, string | null>();
|
||||
|
||||
const ctx: Ctx = {
|
||||
src,
|
||||
db,
|
||||
map,
|
||||
report,
|
||||
opt,
|
||||
async q<T>(sql: string, params: unknown[] = []) {
|
||||
const [rows] = await src.query<RowDataPacket[]>(sql, params);
|
||||
return rows as unknown as T[];
|
||||
},
|
||||
async imageUrl(imageId: unknown, step: string) {
|
||||
if (imageId === null || imageId === undefined || imageId === '') return null;
|
||||
const key = String(imageId);
|
||||
if (!imageRows.has(key)) {
|
||||
const [row] = await ctx.q<{ image: string | null }>('SELECT image FROM image WHERE id = ?', [key]);
|
||||
imageRows.set(key, row?.image ? String(row.image).trim() : null);
|
||||
}
|
||||
const file = imageRows.get(key);
|
||||
if (!file) {
|
||||
report.count(step, 'image reference with no `image` row');
|
||||
return null;
|
||||
}
|
||||
copyImage(ctx, file, step);
|
||||
// Same shape the admin upload writes (admin-uploads.service.ts:61).
|
||||
return `/uploads/${file}`;
|
||||
},
|
||||
async uploadedFile(imageId: unknown, purpose: string, step: string) {
|
||||
if (imageId === null || imageId === undefined || imageId === '') return null;
|
||||
// Resolve first even when mapped: resolving copies the file (see productImages).
|
||||
const url = await ctx.imageUrl(imageId, step);
|
||||
const mapped = map.get('file', imageId);
|
||||
if (mapped) return mapped;
|
||||
if (!url) return null;
|
||||
const [row] = await ctx.q<{ original_name: string | null; ext: string | null }>(
|
||||
'SELECT original_name, ext FROM image WHERE id = ?', [String(imageId)],
|
||||
);
|
||||
const file = url.slice('/uploads/'.length);
|
||||
const ext = (row?.ext ?? file.split('.').pop() ?? '').toLowerCase().trim();
|
||||
const src = ctx.opt.imagesDir ? join(ctx.opt.imagesDir, file) : null;
|
||||
const created = await db.uploadedFile.create({
|
||||
data: {
|
||||
url,
|
||||
originalName: (row?.original_name ?? '').trim() || file,
|
||||
mimeType: MIME[ext] ?? 'application/octet-stream',
|
||||
sizeBytes: src && existsSync(src) ? statSync(src).size : 0,
|
||||
purpose,
|
||||
},
|
||||
});
|
||||
await map.set('file', imageId, created.id, 'created');
|
||||
return created.id;
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const copied = new Set<string>();
|
||||
|
||||
function copyImage(ctx: Ctx, file: string, step: string) {
|
||||
const { imagesDir, uploadDir } = ctx.opt;
|
||||
if (!imagesDir || !uploadDir || copied.has(file)) return;
|
||||
copied.add(file);
|
||||
const from = join(imagesDir, file);
|
||||
const to = join(uploadDir, file);
|
||||
if (existsSync(to)) {
|
||||
ctx.report.count('images', 'already in upload dir');
|
||||
return;
|
||||
}
|
||||
if (!existsSync(from) || !statSync(from).isFile()) {
|
||||
ctx.report.count('images', 'missing in henza storage');
|
||||
ctx.report.note('images', `missing file ${file} (referenced from ${step})`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
ctx.report.count('images', 'copied');
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Value conversion from the henza MySQL schema to the Prisma one.
|
||||
*
|
||||
* Every helper here is pure, so the rules can be read and tested in isolation.
|
||||
* Each rule is the one recorded in docs/DATA-MIGRATION-MAP.md.
|
||||
*/
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
/** Persian/Arabic-Indic digits -> ASCII. Legacy did the same (Utility::faTOen). */
|
||||
export function latinDigits(s: string): string {
|
||||
return s.replace(/[۰-۹٠-٩]/g, (d) => {
|
||||
const i = PERSIAN_DIGITS.indexOf(d);
|
||||
return String(i >= 0 ? i : ARABIC_DIGITS.indexOf(d));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persian text as stored: trimmed, single spaces, and Arabic ي/ك folded to
|
||||
* Persian ی/ک. Empty -> null, so '' never reaches a nullable target column.
|
||||
*/
|
||||
export function text(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = String(v)
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison key for matching names across systems. Also drops the zero-width
|
||||
* non-joiner, which legacy typed inconsistently ("مدیر اصلی" vs "مدیراصلی").
|
||||
* The ي/ك fold alone raised exact city matches from 32 to 72 (§10.4).
|
||||
*/
|
||||
export function matchKey(v: unknown): string | null {
|
||||
const t = text(v);
|
||||
return t === null ? null : t.replace(//g, '').replace(/\s/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
/** Mobile as the login key: ASCII digits only, or null. */
|
||||
export function mobile(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const d = latinDigits(String(v)).replace(/\D/g, '');
|
||||
return d === '' ? null : d;
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy stores money and weight as `double` (§4). Going through toFixed at the
|
||||
* target scale drops the binary noise (12.3 is really 12.30000000000000071…)
|
||||
* exactly as Decimal(14,4) would, so a reconciliation can compare like with like.
|
||||
*/
|
||||
export const grams = (v: unknown) => new Prisma.Decimal(num(v).toFixed(4));
|
||||
export const rial = (v: unknown) => new Prisma.Decimal(num(v).toFixed(2));
|
||||
export const percent = (v: unknown) => new Prisma.Decimal(num(v).toFixed(3));
|
||||
export const isZero = (v: unknown) => Math.abs(num(v)) < 0.00005;
|
||||
export const toNumber = num;
|
||||
|
||||
/** Integer or null — for tinyint flags and ids that may arrive as strings. */
|
||||
export function int(v: unknown): number | null {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
|
||||
/* ───────────── time ───────────── */
|
||||
|
||||
/**
|
||||
* The Laravel app ran with timezone 'Asia/Tehran' (henza_server config/app.php:70),
|
||||
* so every timestamp is Tehran wall-clock time. Read as a UTC instant it would
|
||||
* be 3:30 (4:30 under the pre-2022 summer time) off, moving evening documents
|
||||
* to the next day on a statement. The ICU zone rules know Iran's historical
|
||||
* DST, so the offset is taken from them per instant rather than hard-coded.
|
||||
*/
|
||||
const tehranFormat = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
function tehranOffsetMs(utcMs: number): number {
|
||||
const p: Record<string, string> = {};
|
||||
for (const part of tehranFormat.formatToParts(new Date(utcMs))) p[part.type] = part.value;
|
||||
const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
|
||||
return asUtc - utcMs;
|
||||
}
|
||||
|
||||
const WALL = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?/;
|
||||
|
||||
/** 'YYYY-MM-DD HH:MM:SS' in Tehran wall-clock time -> the UTC instant. */
|
||||
export function tehran(v: unknown): Date | null {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const m = WALL.exec(String(v));
|
||||
if (!m || m[1] === '0000') return null;
|
||||
const wall = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
|
||||
// Two passes: the offset at the guessed instant can differ across a DST edge.
|
||||
let utc = wall - tehranOffsetMs(wall);
|
||||
utc = wall - tehranOffsetMs(utc);
|
||||
return new Date(utc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthdays (owner ruling 2026-10-07, §10.7): of 915 set values only 541 are
|
||||
* real. Dropped: the sign-up default (birth date == sign-up date, 190 rows) and
|
||||
* the impossible ones (a time of day, or born after 2008; 184 rows). A
|
||||
* birthday is a calendar date, so it is kept as UTC midnight of that date.
|
||||
*/
|
||||
export function birthday(birth: unknown, signup: unknown): Date | null {
|
||||
const b = WALL.exec(String(birth ?? ''));
|
||||
if (!b) return null;
|
||||
if ((b[4] ?? '00') !== '00' || (b[5] ?? '00') !== '00' || (b[6] ?? '00') !== '00') return null;
|
||||
if (+b[1] > 2008 || +b[1] < 1900) return null;
|
||||
const s = WALL.exec(String(signup ?? ''));
|
||||
if (s && s[1] === b[1] && s[2] === b[2] && s[3] === b[3]) return null;
|
||||
return new Date(Date.UTC(+b[1], +b[2] - 1, +b[3]));
|
||||
}
|
||||
|
||||
/* ───────────── Jalali dates ───────────── */
|
||||
|
||||
/**
|
||||
* `cashes.recive_date` is a varchar holding a Jalali date ("1403/05/12"; 88 of
|
||||
* 89 values start with 14). This is the standard jalaali-js arithmetic
|
||||
* (Borkowski), inlined so the tool needs no extra dependency.
|
||||
*
|
||||
* Its integer division TRUNCATES toward zero. Math.floor is not a substitute:
|
||||
* g2d's (gm - 8) / 6 is negative for March, and flooring it shifted every
|
||||
* converted date by a year.
|
||||
*/
|
||||
const div = (a: number, b: number) => Math.trunc(a / b);
|
||||
const mod = (a: number, b: number) => a - Math.trunc(a / b) * b;
|
||||
|
||||
function jalCal(jy: number) {
|
||||
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
|
||||
const gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = breaks[0];
|
||||
let jump = 0;
|
||||
for (let i = 1; i < breaks.length; i++) {
|
||||
const jm = breaks[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
const n = jy - jp;
|
||||
leapJ += div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
return { gy, march: 20 + leapJ - leapG };
|
||||
}
|
||||
|
||||
function g2d(gy: number, gm: number, gd: number) {
|
||||
const d =
|
||||
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
|
||||
div(153 * mod(gm + 9, 12) + 2, 5) +
|
||||
gd - 34840408;
|
||||
return d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
|
||||
}
|
||||
|
||||
function d2g(jdn: number) {
|
||||
let j = 4 * jdn + 139361631;
|
||||
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
|
||||
const i = div(mod(j, 1461), 4) * 5 + 308;
|
||||
const gd = div(mod(i, 153), 5) + 1;
|
||||
const gm = mod(div(i, 153), 12) + 1;
|
||||
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
/** Jalali y/m/d -> Gregorian y/m/d. */
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
|
||||
const r = jalCal(jy);
|
||||
const jdn = g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
|
||||
return d2g(jdn);
|
||||
}
|
||||
|
||||
/**
|
||||
* A free-text Jalali date ("1403/5/12", "۱۴۰۳-۰۵-۱۲") -> UTC midnight of the
|
||||
* same day, or null when it does not parse. Callers keep the raw text too.
|
||||
*/
|
||||
export function jalaliDate(v: unknown): Date | null {
|
||||
const s = latinDigits(String(v ?? '')).trim();
|
||||
const m = /^(1[34]\d{2})\D(\d{1,2})\D(\d{1,2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const jm = +m[2];
|
||||
const jd = +m[3];
|
||||
if (jm < 1 || jm > 12 || jd < 1 || jd > 31) return null;
|
||||
const g = jalaliToGregorian(+m[1], jm, jd);
|
||||
return new Date(Date.UTC(g.gy, g.gm - 1, g.gd));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Catalogue: brands, categories, products, variants and product images.
|
||||
* Rules: docs/DATA-MIGRATION-MAP.md §9, §10.5.
|
||||
*
|
||||
* Which rows: every live product/variant, PLUS every soft-deleted variant (and
|
||||
* its product) that a live order line still points at. 473 of the 610 live
|
||||
* order lines reference a deleted variant; OrderItem.productVariantId is
|
||||
* required, so without those the order history would lose most of its lines.
|
||||
* They come over with isActive = false, so the shop never shows them.
|
||||
*
|
||||
* ProductVariant.imageId is never written (PROTECTED.md §B), and no
|
||||
* ProductImage is ever deleted: the variant FK is onDelete SetNull, so a
|
||||
* delete-and-recreate would silently clear every admin-picked variant image.
|
||||
*/
|
||||
import { ProductImageRole, Prisma } from '@prisma/client';
|
||||
import { fillEmpty, upsert, type Ctx } from '../lib/context';
|
||||
import { grams, int, isZero, matchKey, percent, tehran, text } from '../lib/normalize';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const LIVE_ORDER_LINES = `
|
||||
SELECT oi.variant_id FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id AND o.deleted_at IS NULL
|
||||
WHERE oi.deleted_at IS NULL AND oi.variant_id IS NOT NULL`;
|
||||
|
||||
export async function brands(ctx: Ctx) {
|
||||
const S = 'brands';
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT * FROM product_brand WHERE deleted_at IS NULL
|
||||
OR id IN (SELECT brand_id FROM product WHERE brand_id IS NOT NULL)`,
|
||||
);
|
||||
const existing = await ctx.db.brand.findMany();
|
||||
for (const r of rows) {
|
||||
const title = text(r.title);
|
||||
if (!title) continue;
|
||||
const res = await upsert(ctx, 'brand', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.brand.findUnique({ where: { id } })),
|
||||
find: async () => existing.find((b) => matchKey(b.title) === matchKey(title)) ?? null,
|
||||
create: () => ctx.db.brand.create({ data: { title } }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') await ctx.db.brand.update({ where: { id }, data: { title } });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
|
||||
}
|
||||
}
|
||||
|
||||
export async function categories(ctx: Ctx) {
|
||||
const S = 'categories';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM category_collection WHERE deleted_at IS NULL');
|
||||
const existing = await ctx.db.category.findMany();
|
||||
|
||||
for (const r of rows) {
|
||||
const title = text(r.title);
|
||||
if (!title) continue;
|
||||
const type = int(r.type) ?? 1;
|
||||
// The port's mapping (admin-categories.service.ts:75-80): image, icon,
|
||||
// meta -> metaTag, body_html -> description.
|
||||
const data = {
|
||||
title,
|
||||
type,
|
||||
imageUrl: await ctx.imageUrl(r.image_id ?? r.image_id_id, S),
|
||||
iconUrl: await ctx.imageUrl(r.icon_id, S),
|
||||
metaTag: text(r.meta),
|
||||
description: text(r.body_html),
|
||||
};
|
||||
const res = await upsert(ctx, 'category', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.category.findUnique({ where: { id } })),
|
||||
// Same title in the same tree (site / app) is the same category.
|
||||
find: async () => existing.find((c) => matchKey(c.title) === matchKey(title) && c.type === type) ?? null,
|
||||
create: () => ctx.db.category.create({ data }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') {
|
||||
await ctx.db.category.update({ where: { id }, data });
|
||||
} else {
|
||||
const cur = await ctx.db.category.findUniqueOrThrow({ where: { id } });
|
||||
const patch = fillEmpty(cur, data);
|
||||
if (Object.keys(patch).length) await ctx.db.category.update({ where: { id }, data: patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
|
||||
}
|
||||
|
||||
// Second pass, once every category has an id: the parent links.
|
||||
for (const r of rows) {
|
||||
const id = ctx.map.get('category', r.id);
|
||||
if (!id || r.parent_id_id === null) continue;
|
||||
const parentId = ctx.map.get('category', r.parent_id_id);
|
||||
if (!parentId) {
|
||||
ctx.report.count(S, 'parent not migrated (deleted in legacy) — left at top level');
|
||||
continue;
|
||||
}
|
||||
if (ctx.map.how('category', r.id) !== 'matched') {
|
||||
await ctx.db.category.update({ where: { id }, data: { parentId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function products(ctx: Ctx) {
|
||||
const S = 'products';
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT * FROM product
|
||||
WHERE deleted_at IS NULL
|
||||
OR id IN (SELECT v.product_id_id FROM products_productvarient v WHERE v.id IN (${LIVE_ORDER_LINES}))`,
|
||||
);
|
||||
|
||||
// Legacy links products to categories many-to-many (category_collect) and
|
||||
// keeps TWO trees: type 1 = site, type 2 = app (admin-categories.service.ts:12).
|
||||
// Every product with more than one category (1,250) is simply in both trees.
|
||||
// The target keeps one categoryId, which the app shows and links to
|
||||
// (user-app-v2 SingleProduct.vue:228), so the APP category is the one kept;
|
||||
// the site one only when the product has no app category.
|
||||
const links = await ctx.q<Row>(
|
||||
`SELECT cc.product_id_id AS pid, cc.collection_id_id AS cid, c.type AS ctype FROM category_collect cc
|
||||
JOIN category_collection c ON c.id = cc.collection_id_id AND c.deleted_at IS NULL
|
||||
WHERE cc.deleted_at IS NULL ORDER BY cc.position, cc.id`,
|
||||
);
|
||||
const chosenCategory = new Map<string, { cid: string; app: boolean }>();
|
||||
const categoryCount = new Map<string, number>();
|
||||
for (const l of links) {
|
||||
const pid = String(l.pid);
|
||||
const app = int(l.ctype) === 2;
|
||||
categoryCount.set(pid, (categoryCount.get(pid) ?? 0) + 1);
|
||||
const cur = chosenCategory.get(pid);
|
||||
if (!cur || (app && !cur.app)) chosenCategory.set(pid, { cid: String(l.cid), app });
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
const title = text(r.title) ?? `محصول ${String(r.id)}`;
|
||||
const deleted = r.deleted_at !== null;
|
||||
const code = text(r.product_code);
|
||||
const data = {
|
||||
title,
|
||||
description: text(r.body_html),
|
||||
categoryId: ctx.map.get('category', chosenCategory.get(String(r.id))?.cid) ?? null,
|
||||
brandId: ctx.map.get('brand', r.brand_id) ?? null,
|
||||
productCode: code,
|
||||
showType: int(r.show_type) ?? 3,
|
||||
isActive: !deleted && int(r.is_active) === 1,
|
||||
};
|
||||
const chosen = chosenCategory.get(String(r.id));
|
||||
if (!chosen) ctx.report.count(S, 'no live category');
|
||||
else if ((categoryCount.get(String(r.id)) ?? 0) > 1) ctx.report.count(S, `in several legacy categories — ${chosen.app ? 'app' : 'site'} category kept`);
|
||||
if (int(r.disabled) === 1) ctx.report.count(S, 'legacy `disabled` = 1 (not represented; port ignores it too)');
|
||||
|
||||
const res = await upsert(ctx, 'product', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.product.findUnique({ where: { id } })),
|
||||
find: async () => (code ? ctx.db.product.findUnique({ where: { productCode: code }, select: { id: true } }) : null),
|
||||
create: () => ctx.db.product.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
|
||||
update: async (id, how) => {
|
||||
// Never accountingStock* (D27): not in `data`.
|
||||
if (how === 'created') {
|
||||
await ctx.db.product.update({ where: { id }, data });
|
||||
} else {
|
||||
const cur = await ctx.db.product.findUniqueOrThrow({ where: { id } });
|
||||
const patch = fillEmpty(cur, data);
|
||||
if (Object.keys(patch).length) await ctx.db.product.update({ where: { id }, data: patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated'}${deleted ? ' (deleted in legacy, kept inactive for order history)' : ''}`);
|
||||
if (res.how !== 'matched') await productImages(ctx, r, res.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The port's image roles (admin-products.service.ts:185-188):
|
||||
* indicator_id -> FEATURED, mobile_pic -> FEATURED_MOBILE; the gallery is
|
||||
* products_productimage. Only for products the import created — a product the
|
||||
* target already had keeps exactly the images it has.
|
||||
*/
|
||||
async function productImages(ctx: Ctx, p: Row, productId: string) {
|
||||
const S = 'product images';
|
||||
const add = async (key: string, imageId: unknown, role: ProductImageRole, sortOrder: number) => {
|
||||
// Resolved even when the row exists, because resolving is what copies the
|
||||
// file: a first run without --images followed by one with it must still
|
||||
// bring the files over.
|
||||
const url = await ctx.imageUrl(imageId, S);
|
||||
if (!url || ctx.map.get('pimage', key)) return;
|
||||
const img = await ctx.db.productImage.create({ data: { productId, url, role, sortOrder } });
|
||||
await ctx.map.set('pimage', key, img.id, 'created');
|
||||
ctx.report.count(S, role);
|
||||
};
|
||||
await add(`featured:${String(p.id)}`, p.indicator_id_id, ProductImageRole.FEATURED, 0);
|
||||
await add(`mobile:${String(p.id)}`, p.mobile_pic_id, ProductImageRole.FEATURED_MOBILE, 0);
|
||||
const gallery = await ctx.q<Row>(
|
||||
'SELECT id, image_id FROM products_productimage WHERE deleted_at IS NULL AND product_id = ? ORDER BY id',
|
||||
[p.id],
|
||||
);
|
||||
let i = 1;
|
||||
for (const g of gallery) await add(`gallery:${String(g.id)}`, g.image_id, ProductImageRole.GALLERY, i++);
|
||||
}
|
||||
|
||||
export async function variants(ctx: Ctx) {
|
||||
const S = 'variants';
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT * FROM products_productvarient
|
||||
WHERE deleted_at IS NULL OR id IN (${LIVE_ORDER_LINES})`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
const productId = ctx.map.get('product', r.product_id_id);
|
||||
if (!productId) {
|
||||
ctx.report.count(S, 'skipped: product not migrated');
|
||||
continue;
|
||||
}
|
||||
const deleted = r.deleted_at !== null;
|
||||
let sku = text(r.barcode);
|
||||
if (sku) {
|
||||
const clash = await ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } });
|
||||
if (clash && clash.id !== ctx.map.get('variant', r.id)) {
|
||||
ctx.report.count(S, 'barcode already used by another variant — sku left empty');
|
||||
sku = null;
|
||||
}
|
||||
}
|
||||
// `Inventory` (capital I in the source column, §9) is legacy's stock count;
|
||||
// `qty` is what the port reads, so that is what is carried.
|
||||
const data: Prisma.ProductVariantUncheckedUpdateInput = {
|
||||
productId,
|
||||
title: text(r.title) ?? '-',
|
||||
sku,
|
||||
rashWeightGrams: grams(r.grams),
|
||||
defaultWagePercent: percent(r.percentage_pay),
|
||||
qty: int(r.qty) ?? 0,
|
||||
isPreOrder: int(r.is_pre_order) === 1,
|
||||
isActive: !deleted && int(r.is_active) === 1,
|
||||
// imageId: deliberately absent — PROTECTED.md §B.
|
||||
};
|
||||
if (!isZero(r.rial_pay)) ctx.report.count(S, 'rial charge (rial_pay) not carried — no target field');
|
||||
|
||||
const res = await upsert(ctx, 'variant', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.productVariant.findUnique({ where: { id } })),
|
||||
find: async () => (sku ? ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } }) : null),
|
||||
create: () => ctx.db.productVariant.create({ data: data as Prisma.ProductVariantUncheckedCreateInput }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') await ctx.db.productVariant.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated'}${deleted ? ' (deleted in legacy, inactive)' : ''}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Orders, the ledger, favourites and custom orders.
|
||||
* Rules: docs/DATA-MIGRATION-MAP.md §7, §8, §10, §10.7.
|
||||
*/
|
||||
import {
|
||||
LedgerCurrency,
|
||||
LedgerDirection,
|
||||
LedgerSourceType,
|
||||
OrderChannel,
|
||||
OrderStatus,
|
||||
PreOrderStatus,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { upsert, type Ctx } from '../lib/context';
|
||||
import { grams, int, jalaliDate, percent, rial, tehran, text, toNumber } from '../lib/normalize';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const D = Prisma.Decimal;
|
||||
|
||||
/** Order::$STS (Order.php:66-74). 7 «کنسل شده» is commented out in legacy and absent from the data. */
|
||||
const ORDER_STATUS: Record<number, OrderStatus> = {
|
||||
0: OrderStatus.SUSPENDED,
|
||||
1: OrderStatus.PLACED,
|
||||
2: OrderStatus.ADMIN_APPROVED,
|
||||
3: OrderStatus.SOURCING,
|
||||
4: OrderStatus.PREPARING,
|
||||
5: OrderStatus.DELIVERED_TO_AGENT,
|
||||
6: OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
};
|
||||
|
||||
/* ───────────── orders ───────────── */
|
||||
|
||||
export async function orders(ctx: Ctx) {
|
||||
const S = 'orders';
|
||||
// Wholesale only. Retail `orders_orders` is deferred by the owner (§7.2) and
|
||||
// must not be mapped with this status table: its 7 means «تحویل مشتری».
|
||||
const rows = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL');
|
||||
for (const r of rows) {
|
||||
const customerId = ctx.map.get('customer', r.customer_id);
|
||||
if (!customerId) {
|
||||
ctx.report.count(S, 'skipped: customer not migrated');
|
||||
ctx.report.note(S, `legacy order #${String(r.id)} skipped: its customer #${String(r.customer_id)} was not migrated (status ${String(r.sts)})`);
|
||||
continue;
|
||||
}
|
||||
const sts = int(r.sts);
|
||||
const status = sts !== null ? ORDER_STATUS[sts] : undefined;
|
||||
if (!status) {
|
||||
ctx.report.count(S, 'skipped: unknown status');
|
||||
ctx.report.note(S, `legacy order #${String(r.id)} has status ${String(r.sts)}, which has no mapping`);
|
||||
continue;
|
||||
}
|
||||
const orderNumber = `H-${String(r.id)}`;
|
||||
const data = {
|
||||
customerId,
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
status,
|
||||
// `input` is what legacy debits (CustomerController::updateRemain) and
|
||||
// equals `w` on all 300 live orders; `wage` is the making-charge part of it.
|
||||
totalGoldWeightGrams: grams(r.input),
|
||||
wageWeightGrams: grams(r.wage),
|
||||
flatRialCharge: rial(r.rial_input),
|
||||
// Legacy 0 «معلق» is the over-credit-limit hold.
|
||||
isSuspendedOverCreditLimit: sts === 0,
|
||||
// Legacy records no delivery time; the last change of a delivered order is the best available.
|
||||
deliveredAt: sts === 6 ? tehran(r.updated_at) : null,
|
||||
};
|
||||
const res = await upsert(ctx, 'order', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.order.findUnique({ where: { id } })),
|
||||
find: () => ctx.db.order.findUnique({ where: { orderNumber }, select: { id: true } }),
|
||||
create: () => ctx.db.order.create({ data: { ...data, orderNumber, createdAt: tehran(r.created_at) ?? undefined } }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`);
|
||||
if (r.reagent_id !== null) ctx.report.count(S, 'placing expert (orders.reagent_id) not carried — no target field');
|
||||
}
|
||||
await orderItems(ctx);
|
||||
}
|
||||
|
||||
async function orderItems(ctx: Ctx) {
|
||||
const S = 'order lines';
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT oi.* FROM order_items oi JOIN orders o ON o.id = oi.order_id AND o.deleted_at IS NULL
|
||||
WHERE oi.deleted_at IS NULL`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
const orderId = ctx.map.get('order', r.order_id);
|
||||
if (!orderId) continue;
|
||||
const productVariantId = ctx.map.get('variant', r.variant_id);
|
||||
if (!productVariantId) {
|
||||
ctx.report.count(S, 'skipped: variant not migrated');
|
||||
ctx.report.note(S, `legacy order line #${String(r.id)} (order #${String(r.order_id)}) lost: variant #${String(r.variant_id)} not found`);
|
||||
continue;
|
||||
}
|
||||
const data = {
|
||||
orderId,
|
||||
productVariantId,
|
||||
quantity: int(r.qty) ?? 1,
|
||||
rashWeightGramsSnapshot: grams(r.rash_weight),
|
||||
wagePercentSnapshot: percent(r.percent),
|
||||
discountPercentSnapshot: percent(r.discount),
|
||||
computedWeightGrams: grams(r.w),
|
||||
flatRialCharge: rial(r.rial_pay),
|
||||
};
|
||||
const res = await upsert(ctx, 'orderitem', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.orderItem.findUnique({ where: { id } })),
|
||||
find: async () => null,
|
||||
create: () => ctx.db.orderItem.create({ data }),
|
||||
update: async (id) => {
|
||||
await ctx.db.orderItem.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : 'updated');
|
||||
// §7.4: the target keeps one snapshot; legacy's audit pair is lost.
|
||||
if (toNumber(r.original_rash_weight) !== toNumber(r.rash_weight) || toNumber(r.original_percent) !== toNumber(r.percent)) {
|
||||
ctx.report.count(S, 'weight or wage edited after ordering (original_* not carried)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── ledger ───────────── */
|
||||
|
||||
/** Cash::$TYPES (Major/Cash.php:56-62). Type 9 «سفارش» is handled from `orders`. */
|
||||
const CASH_SOURCE: Record<number, LedgerSourceType> = {
|
||||
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
2: LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
3: LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
4: LedgerSourceType.REPAYMENT_CASH,
|
||||
};
|
||||
|
||||
/** Grams per mesghal. Legacy's price_per_gram is per mesghal (CashController.php:68). */
|
||||
const MESGHAL = 4.331802;
|
||||
|
||||
/**
|
||||
* Legacy debt is COMPUTED, never posted (CustomerController::updateRemain):
|
||||
*
|
||||
* remain = Σ orders.input (sts > 4) − Σ cashes.value (confirmed, type 1–4)
|
||||
* rial_remain = Σ orders.rial_input (sts > 4)
|
||||
*
|
||||
* so the ledger is rebuilt from exactly those rows. Recomputing that formula on
|
||||
* the 2026-10-04 dump reproduces the stored balance for 1,590 of 1,591
|
||||
* customers to 0.0001 g (§10.7). Consequences:
|
||||
* - `cashes` type 9 («سفارش») is NOT carried: it mirrors an order, and the
|
||||
* order already posts that debt. Both would count it twice.
|
||||
* - Rials paid (types 3/4 `price`) never reduce rial debt; a cash payment is
|
||||
* converted to gold (`value`) and reduces the gold debt. Kept as is.
|
||||
* - A balance the records cannot explain (customer #125: −9.8951 g, set by
|
||||
* hand after its only document was deleted) is carried as an explicit
|
||||
* ADJUSTMENT entry, so the customer sees the same number (owner, 2026-10-07).
|
||||
*/
|
||||
export async function ledger(ctx: Ctx) {
|
||||
const S = 'ledger';
|
||||
const entry = async (key: string, data: Prisma.LedgerEntryUncheckedCreateInput) => {
|
||||
const res = await upsert(ctx, 'ledger', key, {
|
||||
exists: async (id) => !!(await ctx.db.ledgerEntry.findUnique({ where: { id } })),
|
||||
find: async () => null,
|
||||
create: () => ctx.db.ledgerEntry.create({ data }),
|
||||
update: async (id) => {
|
||||
await ctx.db.ledgerEntry.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
return res.id;
|
||||
};
|
||||
const signed = (amount: number) => ({
|
||||
direction: amount >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
|
||||
amount: grams(Math.abs(amount)),
|
||||
});
|
||||
|
||||
// Orders delivered to the agent or the customer carry the debt.
|
||||
const delivered = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL AND sts > 4');
|
||||
for (const o of delivered) {
|
||||
const customerId = ctx.map.get('customer', o.customer_id);
|
||||
const orderId = ctx.map.get('order', o.id);
|
||||
if (!customerId || !orderId) continue;
|
||||
const at = tehran(o.updated_at) ?? undefined;
|
||||
if (toNumber(o.input) !== 0) {
|
||||
await entry(`order:${String(o.id)}:gold`, {
|
||||
customerId, orderId, ...signed(toNumber(o.input)),
|
||||
currency: LedgerCurrency.GOLD_GRAM, sourceType: LedgerSourceType.ORDER_STOCK,
|
||||
confirmed: true, confirmedAt: at, createdAt: at,
|
||||
note: 'بدهی وزنی سفارش — انتقال از سیستم قدیم',
|
||||
});
|
||||
ctx.report.count(S, 'order debt (gold)');
|
||||
}
|
||||
if (toNumber(o.rial_input) !== 0) {
|
||||
const amt = toNumber(o.rial_input);
|
||||
await entry(`order:${String(o.id)}:rial`, {
|
||||
customerId, orderId,
|
||||
direction: amt >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
|
||||
amount: rial(Math.abs(amt)),
|
||||
currency: LedgerCurrency.RIAL, sourceType: LedgerSourceType.ORDER_STOCK,
|
||||
confirmed: true, confirmedAt: at, createdAt: at,
|
||||
note: 'بدهی ریالی سفارش — انتقال از سیستم قدیم',
|
||||
});
|
||||
ctx.report.count(S, 'order debt (rial)');
|
||||
}
|
||||
}
|
||||
|
||||
const cashes = await ctx.q<Row>('SELECT * FROM cashes WHERE deleted_at IS NULL');
|
||||
for (const c of cashes) {
|
||||
const type = int(c.type);
|
||||
if (type === 9) {
|
||||
ctx.report.count(S, 'cash type 9 (order mirror) skipped — debt comes from the order');
|
||||
continue;
|
||||
}
|
||||
const sourceType = type !== null ? CASH_SOURCE[type] : undefined;
|
||||
if (!sourceType) {
|
||||
ctx.report.count(S, 'skipped: unknown document type');
|
||||
ctx.report.note(S, `legacy document #${String(c.id)} has type ${String(c.type)}`);
|
||||
continue;
|
||||
}
|
||||
const customerId = ctx.map.get('customer', c.customer_id);
|
||||
if (!customerId) {
|
||||
ctx.report.count(S, 'skipped: customer not migrated');
|
||||
continue;
|
||||
}
|
||||
// A repayment is a CREDIT; a negative `value` (4 transfers) is a debit.
|
||||
const value = toNumber(c.value);
|
||||
const received = jalaliDate(c.recive_date);
|
||||
const confirmed = int(c.confirmed) === 1;
|
||||
const ppg = toNumber(c.price_per_gram);
|
||||
await entry(`cash:${String(c.id)}`, {
|
||||
customerId,
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: value >= 0 ? LedgerDirection.CREDIT : LedgerDirection.DEBIT,
|
||||
amount: grams(Math.abs(value)),
|
||||
sourceType,
|
||||
confirmed,
|
||||
confirmedAt: confirmed ? tehran(c.updated_at) : null,
|
||||
postedByAdminUserId: ctx.map.get('admin', c.user_id) ?? null,
|
||||
karatPerMille: int(c.ayar),
|
||||
labName: text(c.lab),
|
||||
hallmark: text(c.ang),
|
||||
accountName: text(c.account_name),
|
||||
bankName: text(c.bank_name),
|
||||
transferTo: text(c.to),
|
||||
originalValue: c.original_value === null ? null : grams(c.original_value),
|
||||
priceRial: c.price === null ? null : rial(c.price),
|
||||
pricePerGramRialAtEntry: ppg ? rial(ppg / MESGHAL) : null,
|
||||
receivedAt: received,
|
||||
// The raw Jalali text is kept in the note when it did not parse.
|
||||
note: [text(c.title), text(c.note), !received && text(c.recive_date) ? `تاریخ دریافت: ${text(c.recive_date)}` : null]
|
||||
.filter(Boolean).join(' — ') || null,
|
||||
createdAt: tehran(c.created_at) ?? undefined,
|
||||
});
|
||||
ctx.report.count(S, `document ${sourceType}${confirmed ? '' : ' (unconfirmed — not in the balance)'}`);
|
||||
}
|
||||
|
||||
await balances(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per migrated customer: compare what the import posted with the balance legacy
|
||||
* stored, carry any real difference as one ADJUSTMENT entry, then set the
|
||||
* cached balance to the sum of ALL confirmed entries (the rule every other
|
||||
* ledger path keeps: confirmed DEBIT adds, confirmed CREDIT subtracts).
|
||||
*/
|
||||
async function balances(ctx: Ctx) {
|
||||
const S = 'balances';
|
||||
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
|
||||
const stored = new Map<string, { gold: number; rial: number }>();
|
||||
for (const r of legacy) {
|
||||
const target = ctx.map.get('customer', r.id);
|
||||
if (!target) continue;
|
||||
const cur = stored.get(target) ?? { gold: 0, rial: 0 };
|
||||
cur.gold += toNumber(r.remain);
|
||||
cur.rial += toNumber(r.rial_remain);
|
||||
stored.set(target, cur);
|
||||
}
|
||||
|
||||
const importedIds = new Set<string>();
|
||||
for (const row of await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
|
||||
`SELECT target_id FROM legacy_import.id_map WHERE entity = 'ledger' AND legacy_id NOT LIKE 'adjust:%'`,
|
||||
)) importedIds.add(row.target_id);
|
||||
|
||||
// Up to this a difference is the double → Decimal(14,4) rounding of §4, not a
|
||||
// real gap — inclusive: the first run posted two −0.0001 "adjustments" that
|
||||
// were nothing but rounding, and would have shown on those statements.
|
||||
const TOLERANCE = 0.0001;
|
||||
|
||||
for (const [customerId, want] of stored) {
|
||||
const entries = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
|
||||
const sum = (currency: LedgerCurrency, onlyImported: boolean) =>
|
||||
entries
|
||||
.filter((e) => e.currency === currency && (!onlyImported || importedIds.has(e.id)))
|
||||
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
|
||||
|
||||
for (const [currency, wanted, label] of [
|
||||
[LedgerCurrency.GOLD_GRAM, want.gold, 'gold'],
|
||||
[LedgerCurrency.RIAL, want.rial, 'rial'],
|
||||
] as const) {
|
||||
const key = `adjust:${customerId}:${label}`;
|
||||
const diff = new D(wanted.toFixed(4)).sub(sum(currency, true));
|
||||
const existing = ctx.map.get('ledger', key);
|
||||
if (diff.abs().lte(TOLERANCE)) {
|
||||
if (!diff.isZero()) ctx.report.count(S, `rounding-only difference (${label}, ≤ ${TOLERANCE}) — no adjustment`);
|
||||
if (existing) {
|
||||
await ctx.db.ledgerEntry.deleteMany({ where: { id: existing } });
|
||||
await ctx.map.drop('ledger', key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const data: Prisma.LedgerEntryUncheckedCreateInput = {
|
||||
customerId,
|
||||
currency,
|
||||
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
|
||||
amount: currency === LedgerCurrency.RIAL ? new D(diff.abs().toFixed(2)) : new D(diff.abs().toFixed(4)),
|
||||
sourceType: LedgerSourceType.ADJUSTMENT,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date(),
|
||||
note: 'تراز انتقالی از سیستم قدیم — ماندهی ذخیرهشده در سیستم قدیم با اسناد قابل بازسازی نبود',
|
||||
};
|
||||
if (existing) await ctx.db.ledgerEntry.update({ where: { id: existing }, data });
|
||||
else {
|
||||
const created = await ctx.db.ledgerEntry.create({ data });
|
||||
await ctx.map.set('ledger', key, created.id, 'created');
|
||||
}
|
||||
ctx.report.count(S, `opening-balance adjustment (${label})`);
|
||||
ctx.report.note(S, `customer ${customerId}: ${label} adjustment ${diff.toString()} so the balance equals legacy's stored ${wanted.toFixed(4)}`);
|
||||
}
|
||||
|
||||
// The cached balance = Σ every confirmed entry, the target's own and the imported ones.
|
||||
const fresh = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
|
||||
const total = (currency: LedgerCurrency) =>
|
||||
fresh
|
||||
.filter((e) => e.currency === currency)
|
||||
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
|
||||
const before = await ctx.db.customer.findUniqueOrThrow({ where: { id: customerId }, select: { goldDebtGrams: true, fiatDebtRial: true } });
|
||||
const gold = total(LedgerCurrency.GOLD_GRAM);
|
||||
const rialSum = total(LedgerCurrency.RIAL);
|
||||
if (!before.goldDebtGrams.eq(gold) || !before.fiatDebtRial.eq(rialSum)) {
|
||||
await ctx.db.customer.update({ where: { id: customerId }, data: { goldDebtGrams: gold, fiatDebtRial: rialSum } });
|
||||
ctx.report.count(S, 'cached balance set from the ledger');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── favourites ───────────── */
|
||||
|
||||
/**
|
||||
* The wholesale app's favourites are `major_favorites` (ApiMobileController
|
||||
* :73-465, model Major\Favorite) — 3,305 rows. `fav` (251) belongs to the
|
||||
* retail storefront (Customer\FavController, guard api-singleCustomer); its
|
||||
* customer_id points at retail logins, so it is deliberately NOT read here.
|
||||
*/
|
||||
export async function favorites(ctx: Ctx) {
|
||||
const S = 'favorites';
|
||||
const rows = await ctx.q<Row>(
|
||||
'SELECT customer_id, product_id, MIN(created_at) AS created_at FROM major_favorites WHERE deleted_at IS NULL GROUP BY customer_id, product_id',
|
||||
);
|
||||
const data: Prisma.CustomerFavoriteCreateManyInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const customerId = ctx.map.get('customer', r.customer_id);
|
||||
const productId = ctx.map.get('product', r.product_id);
|
||||
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
|
||||
if (!productId) { ctx.report.count(S, 'skipped: product deleted in legacy'); continue; }
|
||||
const k = `${customerId}|${productId}`;
|
||||
if (seen.has(k)) { ctx.report.count(S, 'duplicate collapsed (merged customers)'); continue; }
|
||||
seen.add(k);
|
||||
data.push({ customerId, productId, createdAt: tehran(r.created_at) ?? undefined });
|
||||
}
|
||||
const res = await ctx.db.customerFavorite.createMany({ data, skipDuplicates: true });
|
||||
ctx.report.count(S, 'created', res.count);
|
||||
ctx.report.count(S, 'already present', data.length - res.count);
|
||||
}
|
||||
|
||||
/* ───────────── custom orders ───────────── */
|
||||
|
||||
/** CustomOrder::$STS_EN (CustomOrder.php:41-47) maps one-to-one. */
|
||||
const PREORDER_STATUS: Record<number, PreOrderStatus> = {
|
||||
1: PreOrderStatus.PLACED,
|
||||
2: PreOrderStatus.ADMIN_APPROVED,
|
||||
3: PreOrderStatus.WORKSHOP_APPROVED,
|
||||
4: PreOrderStatus.READY_TO_SHIP,
|
||||
5: PreOrderStatus.DELIVERED,
|
||||
6: PreOrderStatus.CANCELLED,
|
||||
};
|
||||
|
||||
export async function preOrders(ctx: Ctx) {
|
||||
const S = 'custom orders';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM custom_orders WHERE deleted_at IS NULL');
|
||||
for (const r of rows) {
|
||||
const customerId = ctx.map.get('customer', r.customer_id);
|
||||
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
|
||||
const sumText = text(r.sum);
|
||||
const data = {
|
||||
customerId,
|
||||
cityId: ctx.map.get('city', r.city_id) ?? null,
|
||||
workshopId: ctx.map.get('workshop', r.workshop_id) ?? null,
|
||||
status: PREORDER_STATUS[int(r.sts) ?? 1] ?? PreOrderStatus.PLACED,
|
||||
designImageUrl: await ctx.imageUrl(r.file_id, S),
|
||||
colorName: text(r.color),
|
||||
sizeSpec: text(r.size),
|
||||
stoneColor: text(r.color_stone),
|
||||
minaColor: text(r.color_mina),
|
||||
estimatedWeightGrams: r.weight === null ? null : grams(r.weight),
|
||||
estimatedPriceRial: sumText && /^\d+(\.\d+)?$/.test(sumText) ? rial(sumText) : null,
|
||||
description: text(r.description),
|
||||
};
|
||||
const res = await upsert(ctx, 'preorder', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.preOrder.findUnique({ where: { id } })),
|
||||
find: async () => null,
|
||||
create: () => ctx.db.preOrder.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
|
||||
update: async (id) => {
|
||||
await ctx.db.preOrder.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : 'updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* People and places: cities, roles, branches, admin users, sales experts,
|
||||
* workshops and customers. Rules: docs/DATA-MIGRATION-MAP.md §2–§6, §10.4–§10.7.
|
||||
*/
|
||||
import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client';
|
||||
import { ALL_RIDS } from '../../../src/modules/auth/acl.constants';
|
||||
import { isPasswordHash, OTP_ONLY_PASSWORD_HASH } from '../../../src/modules/auth/password-hash';
|
||||
import { fillEmpty, upsert, type Ctx } from '../lib/context';
|
||||
import { birthday, grams, int, isZero, matchKey, mobile, percent, tehran, text } from '../lib/normalize';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Owner ruling 2026-10-07 for the 7 legacy rejections, which recorded no reason. */
|
||||
export const LEGACY_REJECTION_REASON = 'رد شده در سیستم قدیم — فاقد شرح علت ثبتشده';
|
||||
|
||||
const fullNameOf = (r: Row) =>
|
||||
text(r.name) ?? text([text(r.fname), text(r.lname)].filter(Boolean).join(' ')) ?? String(r.mobile ?? '');
|
||||
|
||||
/** A legacy bcrypt hash keeps working; anything else becomes OTP-only (§10.4). */
|
||||
const passwordOf = (r: Row) => (isPasswordHash(r.password) ? String(r.password) : OTP_ONLY_PASSWORD_HASH);
|
||||
|
||||
/* ───────────── cities ───────────── */
|
||||
|
||||
export async function cities(ctx: Ctx) {
|
||||
const S = 'cities';
|
||||
// Live cities plus any deleted one a live customer still points at.
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT id, title, parent, level, percent, percent_aux FROM cities
|
||||
WHERE deleted_at IS NULL
|
||||
OR id IN (SELECT city_id FROM customers WHERE deleted_at IS NULL AND city_id IS NOT NULL)`,
|
||||
);
|
||||
const all = new Map((await ctx.q<Row>('SELECT id, title, parent, level FROM cities')).map((r) => [String(r.id), r]));
|
||||
|
||||
// Legacy is a tree (country > province > city > district); the target City is
|
||||
// flat with a `province` label, taken from the level-1 ancestor.
|
||||
const provinceOf = (r: Row): string | null => {
|
||||
let cur: Row | undefined = r;
|
||||
for (let guard = 0; cur && guard < 10; guard++) {
|
||||
if (int(cur.level) === 1) return cur === r ? null : text(cur.title);
|
||||
cur = all.get(String(cur.parent));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const existing = await ctx.db.city.findMany();
|
||||
const byKey = new Map(existing.map((c) => [matchKey(c.name), c.id]));
|
||||
|
||||
for (const r of rows) {
|
||||
const name = text(r.title);
|
||||
if (!name) {
|
||||
ctx.report.count(S, 'skipped: no title');
|
||||
continue;
|
||||
}
|
||||
const data = {
|
||||
name,
|
||||
province: provinceOf(r),
|
||||
percent: r.percent === null ? null : percent(r.percent),
|
||||
percentAux: r.percent_aux === null ? null : percent(r.percent_aux),
|
||||
};
|
||||
const res = await upsert(ctx, 'city', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.city.findUnique({ where: { id } })),
|
||||
find: async () => {
|
||||
const id = byKey.get(matchKey(name));
|
||||
return id ? { id } : null;
|
||||
},
|
||||
create: () => ctx.db.city.create({ data }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') {
|
||||
await ctx.db.city.update({ where: { id }, data });
|
||||
} else {
|
||||
const cur = await ctx.db.city.findUniqueOrThrow({ where: { id } });
|
||||
const patch = fillEmpty(cur, data);
|
||||
if (Object.keys(patch).length) await ctx.db.city.update({ where: { id }, data: patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
|
||||
byKey.set(matchKey(name), res.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Target City id for an exact (normalised) name match, or null. */
|
||||
async function cityByName(ctx: Ctx, name: string | null): Promise<string | null> {
|
||||
if (!name) return null;
|
||||
const all = await ctx.db.city.findMany({ select: { id: true, name: true } });
|
||||
const key = matchKey(name);
|
||||
return all.find((c) => matchKey(c.name) === key)?.id ?? null;
|
||||
}
|
||||
|
||||
/* ───────────── roles & permissions ───────────── */
|
||||
|
||||
/**
|
||||
* Legacy 85 «مدیر» and target 85 «مدیر ارشد» are the same full-access role:
|
||||
* legacy 85 holds 173 permissions, more than any other type, and target 85 holds
|
||||
* every one of the 144 rids. Every other type is matched by TITLE, never by id —
|
||||
* legacy 86 is «حسابدار» but target 86 is «کارشناس فروش» (seed), so an id copy
|
||||
* would hand the accountant's 71 permissions to every sales expert.
|
||||
*/
|
||||
const SAME_ROLE: ReadonlyMap<number, number> = new Map([[85, 85]]);
|
||||
|
||||
/** AdminRole is a label only — every permission comes from the user type. */
|
||||
function adminRoleFor(legacyUt: number | null): AdminRole {
|
||||
if (legacyUt === 85 || legacyUt === 104 || legacyUt === 105) return AdminRole.SUPER_ADMIN; // مدیر · مدیر اصلی · مدیران
|
||||
if (legacyUt === 86) return AdminRole.ACCOUNTANT; // حسابدار
|
||||
return AdminRole.SYSTEM_APPROVER;
|
||||
}
|
||||
|
||||
export async function roles(ctx: Ctx) {
|
||||
const S = 'roles & permissions';
|
||||
// Legacy user types are config_statics rows (UserController.php:26:
|
||||
// `exists:config_statics,id`), titled by `val`.
|
||||
const types = await ctx.q<Row>(
|
||||
"SELECT id, val FROM config_statics WHERE `key` = '__list__ut' AND deleted_at IS NULL ORDER BY id",
|
||||
);
|
||||
const perms = await ctx.q<{ rid: string; ut: number }>('SELECT DISTINCT rid, ut FROM permission');
|
||||
const known = new Set<string>(ALL_RIDS as readonly string[]);
|
||||
const target = await ctx.db.userType.findMany();
|
||||
const byTitle = new Map(target.map((t) => [matchKey(t.title), t.id]));
|
||||
const takenIds = new Set(target.map((t) => t.id));
|
||||
|
||||
for (const t of types) {
|
||||
const legacyId = Number(t.id);
|
||||
const title = text(t.val) ?? `نقش ${legacyId}`;
|
||||
const mapped = ctx.map.get('ut', legacyId);
|
||||
let targetId: number;
|
||||
let created = false;
|
||||
|
||||
if (mapped) {
|
||||
targetId = Number(mapped);
|
||||
} else if (SAME_ROLE.has(legacyId) && takenIds.has(SAME_ROLE.get(legacyId)!)) {
|
||||
targetId = SAME_ROLE.get(legacyId)!;
|
||||
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
|
||||
} else if (byTitle.has(matchKey(title))) {
|
||||
targetId = byTitle.get(matchKey(title))!;
|
||||
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
|
||||
if (targetId !== legacyId) ctx.report.note(S, `legacy type ${legacyId} «${title}» → existing target type ${targetId} (same title, different number)`);
|
||||
} else {
|
||||
targetId = legacyId;
|
||||
if (takenIds.has(targetId)) {
|
||||
targetId = Math.max(...takenIds, ...types.map((x) => Number(x.id))) + 1;
|
||||
ctx.report.note(S, `legacy type ${legacyId} «${title}»: number taken by a different target role, created as ${targetId}`);
|
||||
}
|
||||
await ctx.db.userType.create({ data: { id: targetId, title } });
|
||||
takenIds.add(targetId);
|
||||
await ctx.map.set('ut', legacyId, String(targetId), 'created');
|
||||
created = true;
|
||||
}
|
||||
|
||||
const legacyRids = perms.filter((p) => Number(p.ut) === legacyId).map((p) => String(p.rid));
|
||||
if (created || ctx.map.how('ut', legacyId) === 'created') {
|
||||
// A role the import created gets legacy's permissions, limited to rids the
|
||||
// target ACL knows (the storefront's are out of scope, D13).
|
||||
const usable = legacyRids.filter((r) => known.has(r));
|
||||
const res = await ctx.db.permission.createMany({
|
||||
data: usable.map((rid) => ({ rid, userTypeId: targetId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
ctx.report.count(S, 'permissions copied', res.count);
|
||||
ctx.report.count(S, 'permissions dropped (rid unknown to the target ACL)', legacyRids.length - usable.length);
|
||||
ctx.report.count(S, 'types created');
|
||||
} else {
|
||||
// An existing target role keeps the target's permission set (D1: the
|
||||
// target's stricter auth stands). Nothing is added to it.
|
||||
ctx.report.count(S, 'types matched (permissions left as the target has them)');
|
||||
ctx.report.count(S, 'legacy permissions not applied to matched types', legacyRids.length);
|
||||
}
|
||||
}
|
||||
|
||||
const orphanUt = [...new Set(perms.map((p) => Number(p.ut)))].filter((u) => !types.some((t) => Number(t.id) === u));
|
||||
if (orphanUt.length) ctx.report.note(S, `permission rows for types with no config_statics row were skipped: ${orphanUt.join(', ')}`);
|
||||
}
|
||||
|
||||
/* ───────────── branches & workshops ───────────── */
|
||||
|
||||
export async function branches(ctx: Ctx) {
|
||||
const S = 'branches';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM branches WHERE deleted_at IS NULL');
|
||||
const existing = await ctx.db.branch.findMany();
|
||||
for (const r of rows) {
|
||||
const name = text(r.name);
|
||||
if (!name) continue;
|
||||
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
|
||||
const res = await upsert(ctx, 'branch', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.branch.findUnique({ where: { id } })),
|
||||
find: async () => existing.find((b) => matchKey(b.name) === matchKey(name)) ?? null,
|
||||
create: () => ctx.db.branch.create({ data }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') await ctx.db.branch.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
|
||||
}
|
||||
// branches.user_id names the branch's manager; the target Branch has no such field.
|
||||
ctx.report.note(S, 'branches.user_id (branch manager) has no target field and was not carried over.');
|
||||
}
|
||||
|
||||
export async function workshops(ctx: Ctx) {
|
||||
const S = 'workshops';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM workshops WHERE deleted_at IS NULL');
|
||||
const existing = await ctx.db.workshop.findMany();
|
||||
for (const r of rows) {
|
||||
const name = text(r.name);
|
||||
if (!name) continue;
|
||||
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
|
||||
const res = await upsert(ctx, 'workshop', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.workshop.findUnique({ where: { id } })),
|
||||
find: async () => existing.find((w) => matchKey(w.name) === matchKey(name)) ?? null,
|
||||
create: () => ctx.db.workshop.create({ data }),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') await ctx.db.workshop.update({ where: { id }, data });
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
|
||||
}
|
||||
// Legacy Workshop::user() is belongsTo(Customer) (a type-5 customer); the
|
||||
// target supervisor is an AdminUser. No safe link exists, so none is guessed.
|
||||
ctx.report.note(S, 'supervisor not linked: legacy points at a customer (Workshop::user → Customer), the target expects an admin user.');
|
||||
}
|
||||
|
||||
/* ───────────── admin users (legacy `users`) ───────────── */
|
||||
|
||||
export async function admins(ctx: Ctx) {
|
||||
const S = 'admin users';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM users WHERE deleted_at IS NULL');
|
||||
for (const r of rows) {
|
||||
const m = mobile(r.mobile);
|
||||
if (!m) {
|
||||
ctx.report.count(S, 'skipped: no mobile');
|
||||
continue;
|
||||
}
|
||||
const ut = int(r.ut);
|
||||
const userTypeId = ctx.map.get('ut', ut);
|
||||
const branchId = ctx.map.get('branch', r.branch_id) ?? null;
|
||||
const base = {
|
||||
fullName: fullNameOf(r),
|
||||
firstName: text(r.fname),
|
||||
lastName: text(r.lname),
|
||||
branchId,
|
||||
userTypeId: userTypeId ? Number(userTypeId) : null,
|
||||
};
|
||||
const res = await upsert(ctx, 'admin', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
|
||||
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
|
||||
create: () =>
|
||||
ctx.db.adminUser.create({
|
||||
data: {
|
||||
...base,
|
||||
mobile: m,
|
||||
passwordHash: passwordOf(r),
|
||||
role: adminRoleFor(ut),
|
||||
createdAt: tehran(r.created_at) ?? undefined,
|
||||
},
|
||||
}),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') {
|
||||
await ctx.db.adminUser.update({ where: { id }, data: base });
|
||||
} else {
|
||||
// An admin the target already has keeps its role, type, password and
|
||||
// branch; only empty name parts are filled.
|
||||
const cur = await ctx.db.adminUser.findUniqueOrThrow({ where: { id } });
|
||||
const patch = fillEmpty(cur, { firstName: base.firstName, lastName: base.lastName });
|
||||
if (Object.keys(patch).length) await ctx.db.adminUser.update({ where: { id }, data: patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated');
|
||||
if (res.isNew && !isPasswordHash(r.password)) ctx.report.count(S, 'created without a usable password');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── sales experts (legacy customers.type = 1) ───────────── */
|
||||
|
||||
/**
|
||||
* D17: an expert is an AdminUser (role SALES_EXPERT) with a SalesExpertProfile.
|
||||
* Their own orders and debt live on a separate trading Customer, created by the
|
||||
* customers step for the experts who have any (13 with orders, 14 with
|
||||
* documents in the 2026-10-04 dump).
|
||||
*
|
||||
* An expert whose mobile already belongs to an admin with another role (7 in
|
||||
* the dump, three of them full admins) is NOT demoted: the admin keeps its
|
||||
* role, and the expert's referrals point at that admin.
|
||||
*/
|
||||
export async function experts(ctx: Ctx) {
|
||||
const S = 'sales experts';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL AND type = 1');
|
||||
|
||||
const existingExpertType = await ctx.db.adminUser.groupBy({
|
||||
by: ['userTypeId'],
|
||||
where: { role: AdminRole.SALES_EXPERT, userTypeId: { not: null } },
|
||||
_count: true,
|
||||
orderBy: { _count: { userTypeId: 'desc' } },
|
||||
});
|
||||
const expertType =
|
||||
existingExpertType[0]?.userTypeId ??
|
||||
(await ctx.db.userType.findMany()).find((t) => matchKey(t.title) === matchKey('کارشناس فروش'))?.id ??
|
||||
null;
|
||||
if (expertType === null) ctx.report.note(S, 'no target user type for sales experts found; created experts have no user type');
|
||||
|
||||
for (const r of rows) {
|
||||
const m = mobile(r.mobile);
|
||||
if (!m) {
|
||||
ctx.report.count(S, 'skipped: no mobile');
|
||||
continue;
|
||||
}
|
||||
const profile = {
|
||||
cityId: ctx.map.get('city', r.city_id) ?? null,
|
||||
gramLevel: grams(r.gram_level),
|
||||
phone: text(r.phone),
|
||||
nationalCode: text(r.ncode),
|
||||
};
|
||||
const owned = {
|
||||
fullName: fullNameOf(r),
|
||||
firstName: text(r.fname),
|
||||
lastName: text(r.lname),
|
||||
// AdminUser.wagePercent is "legacy Customer.percent" (schema).
|
||||
wagePercent: r.percent === null ? null : percent(r.percent),
|
||||
isActive: int(r.active) !== -1,
|
||||
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
|
||||
address: text(r.address),
|
||||
storeName: text(r.store_name),
|
||||
pasazhName: text(r.pasazh_name),
|
||||
memberCode: text(r.mcode),
|
||||
};
|
||||
const res = await upsert(ctx, 'expert', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
|
||||
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
|
||||
create: () =>
|
||||
ctx.db.adminUser.create({
|
||||
data: {
|
||||
...owned,
|
||||
mobile: m,
|
||||
passwordHash: passwordOf(r),
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
userTypeId: expertType,
|
||||
createdAt: tehran(r.created_at) ?? undefined,
|
||||
},
|
||||
}),
|
||||
update: async (id, how) => {
|
||||
// A matched admin (the target's own, or a legacy `users` admin) is not
|
||||
// rewritten here; the role check below decides what else happens.
|
||||
if (how === 'created') await ctx.db.adminUser.update({ where: { id }, data: owned });
|
||||
},
|
||||
});
|
||||
|
||||
const admin = await ctx.db.adminUser.findUniqueOrThrow({
|
||||
where: { id: res.id },
|
||||
include: { salesExpertProfile: true },
|
||||
});
|
||||
if (admin.role !== AdminRole.SALES_EXPERT) {
|
||||
ctx.report.count(S, `mobile already an admin with role ${admin.role} — kept, not demoted`);
|
||||
ctx.report.note(S, `legacy expert #${String(r.id)} shares a mobile with admin role ${admin.role}; the admin was kept and referrals point to it.`);
|
||||
continue;
|
||||
}
|
||||
if (!admin.salesExpertProfile) {
|
||||
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
|
||||
} else if (res.how === 'created') {
|
||||
await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: profile });
|
||||
} else {
|
||||
const patch = fillEmpty(admin.salesExpertProfile, profile);
|
||||
if (Object.keys(patch).length) await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: patch });
|
||||
}
|
||||
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing expert' : 'updated');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── customers ───────────── */
|
||||
|
||||
/** Legacy Customer::$TYPES (Customer.php:85-92); NULL predates sign-up's `type = 2`. */
|
||||
function kindFor(type: number | null): CustomerKind {
|
||||
switch (type) {
|
||||
case 3: return CustomerKind.WALK_IN_SHOPKEEPER;
|
||||
case 4: return CustomerKind.BANKER;
|
||||
case 5: return CustomerKind.WORKSHOP_SUPERVISOR;
|
||||
default: return CustomerKind.APP_SHOPKEEPER; // 2, 1 (expert's trading account), NULL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two 3-state legacy columns (1 pending, 2 accepted, 3 rejected; §5.5) to one
|
||||
* enum. Step one governs: a rejected step one closed the case in legacy
|
||||
* (`active = -1`), and a pending step one means review never got past it.
|
||||
*/
|
||||
function verificationFor(one: number | null, two: number | null): VerificationStep {
|
||||
if (one === 3 || (one !== 1 && two === 3)) return VerificationStep.REJECTED;
|
||||
if (one === 1) return VerificationStep.STEP_ONE_SUBMITTED;
|
||||
if (one === 2) return two === 2 ? VerificationStep.VERIFIED : VerificationStep.STEP_TWO_SUBMITTED;
|
||||
return VerificationStep.UNVERIFIED;
|
||||
}
|
||||
|
||||
export async function customers(ctx: Ctx) {
|
||||
const S = 'customers';
|
||||
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL');
|
||||
const withOrders = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM orders WHERE deleted_at IS NULL')).map((r) => String(r.id)));
|
||||
const withCash = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM cashes WHERE deleted_at IS NULL')).map((r) => String(r.id)));
|
||||
const hasMoney = (r: Row) =>
|
||||
withOrders.has(String(r.id)) || withCash.has(String(r.id)) || !isZero(r.remain) || !isZero(r.rial_remain);
|
||||
|
||||
// An expert becomes a Customer only as a trading account, i.e. when they
|
||||
// have money of their own; otherwise they exist only as an AdminUser.
|
||||
const candidates = rows.filter((r) => int(r.type) !== 1 || hasMoney(r));
|
||||
ctx.report.count(S, 'experts without money (admin user only)', rows.length - candidates.length);
|
||||
|
||||
// Duplicate mobiles (27 groups, §10.4). The winner is the row with money
|
||||
// (never more than one in the dump), else the most recently updated.
|
||||
const groups = new Map<string, Row[]>();
|
||||
for (const r of candidates) {
|
||||
const m = mobile(r.mobile);
|
||||
if (!m) {
|
||||
// In the 2026-10-04 dump this is one test account whose "mobile" is the
|
||||
// word «تست»: it cannot log in under any system, so it is not invented a number.
|
||||
ctx.report.count(S, 'skipped: mobile has no digits');
|
||||
const own = withOrders.has(String(r.id)) || withCash.has(String(r.id));
|
||||
ctx.report.note(S, `legacy customer #${String(r.id)} skipped: its mobile contains no digits${own ? ' — its orders/documents are skipped with it' : ''}`);
|
||||
continue;
|
||||
}
|
||||
groups.set(m, [...(groups.get(m) ?? []), r]);
|
||||
}
|
||||
|
||||
for (const [m, group] of groups) {
|
||||
const ordered = [...group].sort(
|
||||
(a, b) =>
|
||||
Number(hasMoney(b)) - Number(hasMoney(a)) ||
|
||||
String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')) ||
|
||||
Number(b.id) - Number(a.id),
|
||||
);
|
||||
const [r, ...losers] = ordered;
|
||||
const moneyLosers = losers.filter(hasMoney);
|
||||
if (moneyLosers.length) {
|
||||
// Not seen in the 2026-10-04 dump; if a fresh dump has it, the money of
|
||||
// two legacy rows lands on one customer and must be reviewed by hand.
|
||||
ctx.report.note(S, `⚠ duplicate mobile with money on several rows: legacy #${[r, ...moneyLosers].map((x) => String(x.id)).join(', #')} merged onto one customer — review`);
|
||||
}
|
||||
|
||||
const type = int(r.type);
|
||||
const step = verificationFor(int(r.sts_verify_step_one), int(r.sts_verify_step_two));
|
||||
const typedCity = text(r.new_city);
|
||||
// Sign-up forced city_id = 5 (تهران) on everyone (Major/CustomerController.php:21),
|
||||
// so when the customer typed a city, that text — not city_id 5 — is the truth.
|
||||
const cityId = typedCity
|
||||
? int(r.city_id) !== 5 && r.city_id !== null
|
||||
? ctx.map.get('city', r.city_id) ?? null
|
||||
: await cityByName(ctx, typedCity)
|
||||
: ctx.map.get('city', r.city_id) ?? null;
|
||||
|
||||
const owned = {
|
||||
firstName: text(r.fname),
|
||||
lastName: text(r.lname),
|
||||
nationalCode: text(r.ncode), // empty -> NULL; '' would collide under @unique (§10.4)
|
||||
cityId,
|
||||
cityNameRaw: typedCity,
|
||||
kind: kindFor(type),
|
||||
phone: text(r.phone),
|
||||
address: text(r.address),
|
||||
storeName: text(r.store_name),
|
||||
pasazhName: text(r.pasazh_name),
|
||||
zone: text(r.zone),
|
||||
memberCode: text(r.mcode),
|
||||
referredByAgentId: ctx.map.get('expert', r.reagent_id) ?? null,
|
||||
goldCreditLimitGrams: grams(r.gram_level),
|
||||
makingChargeOffsetPercent: percent(r.percent),
|
||||
verificationStep: step,
|
||||
verificationRejectionReason: step === VerificationStep.REJECTED ? LEGACY_REJECTION_REASON : null,
|
||||
nationalIdImageUrl: await ctx.imageUrl(r.ncode_image_id, S),
|
||||
businessLicenseUrl: await ctx.imageUrl(r.business_license_image_id, S),
|
||||
shopImageUrl: await ctx.imageUrl(r.shop_image_id, S),
|
||||
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
|
||||
birthDate: birthday(r.birth_date, r.created_at),
|
||||
canPlaceOrders: int(r.sts) !== -1,
|
||||
isActive: int(r.active) !== -1,
|
||||
isArchived: int(r.is_archive) === 1,
|
||||
};
|
||||
if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)');
|
||||
if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)');
|
||||
|
||||
const res = await upsert(ctx, 'customer', r.id, {
|
||||
exists: async (id) => !!(await ctx.db.customer.findUnique({ where: { id } })),
|
||||
find: () => ctx.db.customer.findUnique({ where: { mobile: m }, select: { id: true } }),
|
||||
create: () =>
|
||||
ctx.db.customer.create({
|
||||
data: {
|
||||
...owned,
|
||||
fullName: fullNameOf(r),
|
||||
mobile: m,
|
||||
passwordHash: passwordOf(r),
|
||||
createdAt: tehran(r.created_at) ?? undefined,
|
||||
} satisfies Prisma.CustomerUncheckedCreateInput,
|
||||
}),
|
||||
update: async (id, how) => {
|
||||
if (how === 'created') {
|
||||
// Never the accounting* columns (D27, §10.5): not in `owned`.
|
||||
await ctx.db.customer.update({ where: { id }, data: owned });
|
||||
} else {
|
||||
const cur = await ctx.db.customer.findUniqueOrThrow({ where: { id } });
|
||||
const patch = fillEmpty(cur, owned);
|
||||
// The target's own verification state wins; a legacy rejection reason
|
||||
// on a customer the target shows as anything but REJECTED would contradict it.
|
||||
if (cur.verificationStep !== VerificationStep.REJECTED) delete patch.verificationRejectionReason;
|
||||
if (Object.keys(patch).length) await ctx.db.customer.update({ where: { id }, data: patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (empty fields filled)' : 'updated');
|
||||
if (step === VerificationStep.REJECTED) ctx.report.count(S, 'rejected (legacy reason sentinel written)');
|
||||
|
||||
for (const l of losers) {
|
||||
await ctx.map.set('customer', l.id, res.id, 'merged');
|
||||
ctx.report.count(S, 'duplicate-mobile rows merged into the winner');
|
||||
}
|
||||
|
||||
// An expert's trading account (D17): link it from their profile.
|
||||
if (type === 1) {
|
||||
const adminId = ctx.map.get('expert', r.id);
|
||||
const profile = adminId ? await ctx.db.salesExpertProfile.findUnique({ where: { adminUserId: adminId } }) : null;
|
||||
if (profile && !profile.tradingCustomerId) {
|
||||
await ctx.db.salesExpertProfile.update({ where: { id: profile.id }, data: { tradingCustomerId: res.id } });
|
||||
ctx.report.count(S, 'expert trading accounts linked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now
|
||||
* holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero
|
||||
* difference", which double → Decimal rounding makes impossible.
|
||||
*/
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Ctx } from '../lib/context';
|
||||
import { toNumber } from '../lib/normalize';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const TOLERANCE = new Prisma.Decimal('0.0001');
|
||||
|
||||
export async function reconcile(ctx: Ctx) {
|
||||
const S = 'reconciliation';
|
||||
|
||||
/* counts: legacy rows in scope vs ids mapped */
|
||||
const scope: [string, string, string][] = [
|
||||
['cities', 'city', `SELECT COUNT(*) n FROM cities WHERE deleted_at IS NULL`],
|
||||
['branches', 'branch', `SELECT COUNT(*) n FROM branches WHERE deleted_at IS NULL`],
|
||||
['workshops', 'workshop', `SELECT COUNT(*) n FROM workshops WHERE deleted_at IS NULL`],
|
||||
['admin users', 'admin', `SELECT COUNT(*) n FROM users WHERE deleted_at IS NULL`],
|
||||
['sales experts', 'expert', `SELECT COUNT(*) n FROM customers WHERE deleted_at IS NULL AND type = 1`],
|
||||
['live products', 'product', `SELECT COUNT(*) n FROM product WHERE deleted_at IS NULL`],
|
||||
['live variants', 'variant', `SELECT COUNT(*) n FROM products_productvarient WHERE deleted_at IS NULL`],
|
||||
['live orders', 'order', `SELECT COUNT(*) n FROM orders WHERE deleted_at IS NULL`],
|
||||
['custom orders', 'preorder', `SELECT COUNT(*) n FROM custom_orders WHERE deleted_at IS NULL`],
|
||||
];
|
||||
const rows: string[] = ['| | legacy | mapped |', '|---|---:|---:|'];
|
||||
for (const [label, entity, sql] of scope) {
|
||||
const [{ n }] = await ctx.q<{ n: number }>(sql);
|
||||
const [{ m }] = await ctx.db.$queryRawUnsafe<{ m: bigint }[]>(
|
||||
`SELECT COUNT(*) AS m FROM legacy_import.id_map WHERE entity = $1`, entity,
|
||||
);
|
||||
rows.push(`| ${label} | ${String(n)} | ${String(m)} |`);
|
||||
}
|
||||
ctx.report.note(S, `Counts (mapped may exceed legacy live rows where deleted rows were kept for order history):\n\n${rows.join('\n')}\n`);
|
||||
|
||||
/* balances: every migrated customer, legacy stored vs target cached */
|
||||
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
|
||||
const want = new Map<string, { gold: number; rial: number }>();
|
||||
for (const r of legacy) {
|
||||
const id = ctx.map.get('customer', r.id);
|
||||
if (!id) continue;
|
||||
const w = want.get(id) ?? { gold: 0, rial: 0 };
|
||||
w.gold += toNumber(r.remain);
|
||||
w.rial += toNumber(r.rial_remain);
|
||||
want.set(id, w);
|
||||
}
|
||||
const targets = await ctx.db.customer.findMany({
|
||||
where: { id: { in: [...want.keys()] } },
|
||||
select: { id: true, goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
// Matched customers may carry target-native entries too, so only the
|
||||
// customers the import created are held to legacy's number.
|
||||
const created = new Set(
|
||||
(await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
|
||||
`SELECT DISTINCT target_id FROM legacy_import.id_map WHERE entity = 'customer' AND how = 'created'`,
|
||||
)).map((r) => r.target_id),
|
||||
);
|
||||
|
||||
let goldOk = 0, goldBad = 0, rialOk = 0, rialBad = 0;
|
||||
let legacyGold = new Prisma.Decimal(0), targetGold = new Prisma.Decimal(0);
|
||||
let legacyRial = new Prisma.Decimal(0), targetRial = new Prisma.Decimal(0);
|
||||
for (const t of targets) {
|
||||
if (!created.has(t.id)) continue;
|
||||
const w = want.get(t.id)!;
|
||||
const g = new Prisma.Decimal(w.gold.toFixed(4));
|
||||
const r = new Prisma.Decimal(w.rial.toFixed(2));
|
||||
legacyGold = legacyGold.add(g); targetGold = targetGold.add(t.goldDebtGrams);
|
||||
legacyRial = legacyRial.add(r); targetRial = targetRial.add(t.fiatDebtRial);
|
||||
if (g.sub(t.goldDebtGrams).abs().lte(TOLERANCE)) goldOk++;
|
||||
else { goldBad++; ctx.report.note(S, `⚠ gold balance differs for customer ${t.id}: legacy ${g.toString()} / target ${t.goldDebtGrams.toString()}`); }
|
||||
if (r.sub(t.fiatDebtRial).abs().lt(1)) rialOk++;
|
||||
else { rialBad++; ctx.report.note(S, `⚠ rial balance differs for customer ${t.id}: legacy ${r.toString()} / target ${t.fiatDebtRial.toString()}`); }
|
||||
}
|
||||
ctx.report.count(S, 'created customers — gold balance matches legacy (±0.0001 g)', goldOk);
|
||||
ctx.report.count(S, 'created customers — gold balance DIFFERS', goldBad);
|
||||
ctx.report.count(S, 'created customers — rial balance matches legacy (±1 rial)', rialOk);
|
||||
ctx.report.count(S, 'created customers — rial balance DIFFERS', rialBad);
|
||||
ctx.report.note(S, `Total gold debt (created customers): legacy ${legacyGold.toFixed(4)} g · target ${targetGold.toFixed(4)} g`);
|
||||
ctx.report.note(S, `Total rial debt (created customers): legacy ${legacyRial.toFixed(2)} · target ${targetRial.toFixed(2)}`);
|
||||
ctx.report.note(S, `Matched (pre-existing) customers are not held to legacy's number: they may carry target-native ledger entries. ${targets.length - created.size} of them; see the "balances" section for whose cached balance changed.`);
|
||||
|
||||
/* protected columns: the import must never have written them */
|
||||
const [{ n: imageIds }] = await ctx.db.$queryRawUnsafe<{ n: bigint }[]>(
|
||||
`SELECT COUNT(*) AS n FROM "ProductVariant" v
|
||||
JOIN legacy_import.id_map m ON m.entity = 'variant' AND m.target_id = v.id AND m.how = 'created'
|
||||
WHERE v."imageId" IS NOT NULL`,
|
||||
);
|
||||
ctx.report.note(S, `Imported variants with an imageId: ${String(imageIds)} (must be 0 — PROTECTED.md §B).`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Generated
+71
@@ -154,6 +154,9 @@ importers:
|
||||
jest:
|
||||
specifier: ^30.5.2
|
||||
version: 30.5.2(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))
|
||||
mysql2:
|
||||
specifier: ^3.24.5
|
||||
version: 3.24.5(@types/node@20.19.43)
|
||||
prisma:
|
||||
specifier: ^5.19.0
|
||||
version: 5.22.0
|
||||
@@ -2425,6 +2428,10 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
aws-ssl-profiles@1.1.2:
|
||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
axios@1.20.0:
|
||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||
|
||||
@@ -3171,6 +3178,9 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
generate-function@2.3.1:
|
||||
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3309,6 +3319,10 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
@@ -3445,6 +3459,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-property@1.0.2:
|
||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||
|
||||
is-regex@1.2.1:
|
||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3785,6 +3802,9 @@ packages:
|
||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
@@ -3799,6 +3819,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru.min@1.1.5:
|
||||
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -3924,6 +3948,16 @@ packages:
|
||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
mysql2@3.24.5:
|
||||
resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>= 8'
|
||||
|
||||
named-placeholders@1.1.6:
|
||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -4484,6 +4518,10 @@ packages:
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
sql-escaper@1.5.2:
|
||||
resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||
|
||||
ssr-window@4.0.2:
|
||||
resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==}
|
||||
|
||||
@@ -7425,6 +7463,8 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
|
||||
axios@1.20.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
@@ -8320,6 +8360,10 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
generate-function@2.3.1:
|
||||
dependencies:
|
||||
is-property: 1.0.2
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
@@ -8464,6 +8508,10 @@ snapshots:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
@@ -8617,6 +8665,8 @@ snapshots:
|
||||
|
||||
is-plain-object@3.0.1: {}
|
||||
|
||||
is-property@1.0.2: {}
|
||||
|
||||
is-regex@1.2.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -9150,6 +9200,8 @@ snapshots:
|
||||
chalk: 4.1.2
|
||||
is-unicode-supported: 0.1.0
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
@@ -9162,6 +9214,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru.min@1.1.5: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
@@ -9268,6 +9322,21 @@ snapshots:
|
||||
|
||||
mute-stream@1.0.0: {}
|
||||
|
||||
mysql2@3.24.5(@types/node@20.19.43):
|
||||
dependencies:
|
||||
'@types/node': 20.19.43
|
||||
aws-ssl-profiles: 1.1.2
|
||||
generate-function: 2.3.1
|
||||
iconv-lite: 0.7.3
|
||||
long: 5.3.2
|
||||
lru.min: 1.1.5
|
||||
named-placeholders: 1.1.6
|
||||
sql-escaper: 1.5.2
|
||||
|
||||
named-placeholders@1.1.6:
|
||||
dependencies:
|
||||
lru.min: 1.1.5
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nanopop@2.4.2: {}
|
||||
@@ -9847,6 +9916,8 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sql-escaper@1.5.2: {}
|
||||
|
||||
ssr-window@4.0.2: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
|
||||
Reference in New Issue
Block a user