Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Legacy system (MySQL `oapi`) → hedena-gold (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/hedenagold_import_test" \
|
||||
* [--images "F:/legacy/storage" --upload-dir "./storage/uploads"] \
|
||||
* [--only customers,ledger] [--report ./legacy-import-report.md] --yes
|
||||
*
|
||||
* Safety:
|
||||
* - The source is only read. Point it at a COPY of production anyway.
|
||||
* - --target is required and never taken from .env, so the dev database is
|
||||
* never written by accident. Without --yes nothing is written; a target
|
||||
* whose name does not contain "import" or "staging" also needs
|
||||
* --confirm-db=<its exact name>.
|
||||
* - Re-runnable: every written row is recorded in legacy_import.id_map, so a
|
||||
* second run updates instead of duplicating, and a run that stops half way
|
||||
* is finished by running it again.
|
||||
* - Never writes Customer/Product accounting* columns, ProductVariant.imageId,
|
||||
* or the Accounting* tables, and never deletes a ProductImage.
|
||||
* - Writes go straight through Prisma, not through the API services, so no
|
||||
* SMS is sent and nothing is queued to the accounting software.
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/context';
|
||||
import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people';
|
||||
import { brands, categories, products, variants } from './steps/catalog';
|
||||
import { favorites, ledger, orders, preOrders } from './steps/commerce';
|
||||
import { reconcile, reconcileSales } from './steps/reconcile';
|
||||
|
||||
/** Order matters: each step reads the ids the earlier ones mapped. */
|
||||
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
|
||||
['cities', cities],
|
||||
['roles', roles],
|
||||
['branches', branches],
|
||||
['admins', admins],
|
||||
['experts', experts],
|
||||
['workshops', workshops],
|
||||
['customers', customers],
|
||||
['brands', brands],
|
||||
['categories', categories],
|
||||
['products', products],
|
||||
['variants', variants],
|
||||
['orders', orders],
|
||||
['ledger', ledger],
|
||||
['favorites', favorites],
|
||||
['preorders', preOrders],
|
||||
['reconcile', reconcile],
|
||||
['reconcile-sales', reconcileSales],
|
||||
];
|
||||
|
||||
const ENTITIES = [
|
||||
'city', 'ut', 'branch', 'admin', 'expert', 'workshop', 'customer', 'file', 'brand', 'category',
|
||||
'product', 'pimage', 'variant', 'order', 'orderitem', 'ledger', 'preorder',
|
||||
];
|
||||
|
||||
/** Columns the prep migrations added; the import refuses to run without them. */
|
||||
const REQUIRED = [
|
||||
['Customer', 'nationalCode'], ['Customer', 'birthDate'], ['Customer', 'cityNameRaw'],
|
||||
['Customer', 'verificationRejectionReason'], ['CustomerFavorite', 'customerId'],
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const a = new Map<string, string>();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const k = argv[i];
|
||||
if (!k.startsWith('--')) continue;
|
||||
const eq = k.indexOf('=');
|
||||
if (eq > 0) a.set(k.slice(2, eq), k.slice(eq + 1));
|
||||
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) a.set(k.slice(2), argv[++i]);
|
||||
else a.set(k.slice(2), 'true');
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const fail = (msg: string): never => {
|
||||
console.error(`\n✖ ${msg}\n`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const a = parseArgs(process.argv.slice(2));
|
||||
const opt: Options = {
|
||||
sourceUrl: a.get('source') ?? fail('--source <mysql url> is required'),
|
||||
targetUrl: a.get('target') ?? fail('--target <postgres url> is required (never read from .env)'),
|
||||
imagesDir: a.get('images'),
|
||||
uploadDir: a.get('upload-dir'),
|
||||
only: a.get('only') ? new Set(a.get('only')!.split(',').map((s) => s.trim())) : undefined,
|
||||
reportPath: resolve(a.get('report') ?? 'legacy-import-report.md'),
|
||||
};
|
||||
if (opt.imagesDir && !opt.uploadDir) fail('--images needs --upload-dir (the target UPLOAD_DIR)');
|
||||
if (opt.only) for (const s of opt.only) if (!STEPS.some(([n]) => n === s)) fail(`unknown step "${s}"`);
|
||||
|
||||
const db = new PrismaClient({ datasources: { db: { url: opt.targetUrl } } });
|
||||
const src = openSource(opt.sourceUrl);
|
||||
|
||||
try {
|
||||
/* preflight — read only */
|
||||
const [{ db: dbName }] = await db.$queryRawUnsafe<{ db: string }[]>('SELECT current_database() AS db');
|
||||
const [srcInfo] = await (await src.query('SELECT DATABASE() AS db, VERSION() AS v'))[0] as { db: string; v: string }[];
|
||||
const missing: string[] = [];
|
||||
for (const [t, c] of REQUIRED) {
|
||||
const [{ n }] = await db.$queryRawUnsafe<{ n: bigint }[]>(
|
||||
`SELECT COUNT(*) AS n FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`, t, c,
|
||||
);
|
||||
if (Number(n) === 0) missing.push(`${t}.${c}`);
|
||||
}
|
||||
const counts = {
|
||||
customers: await db.customer.count(),
|
||||
products: await db.product.count(),
|
||||
ledger: await db.ledgerEntry.count(),
|
||||
};
|
||||
console.log(`source mysql ${srcInfo.v} · database ${srcInfo.db}`);
|
||||
console.log(`target postgres · database ${dbName} · ${counts.customers} customers, ${counts.products} products, ${counts.ledger} ledger entries`);
|
||||
if (missing.length) fail(`target is missing ${missing.join(', ')} — apply the prep migrations first`);
|
||||
|
||||
if (a.get('yes') !== 'true') {
|
||||
console.log('\nNothing written. Re-run with --yes to import.');
|
||||
return;
|
||||
}
|
||||
if (!/import|staging/i.test(dbName) && a.get('confirm-db') !== dbName) {
|
||||
fail(`"${dbName}" does not look like a test or staging copy. To write to it anyway add --confirm-db=${dbName}`);
|
||||
}
|
||||
|
||||
/* run */
|
||||
await IdMap.ensureSchema(db);
|
||||
const ctx = makeContext(opt, src, db);
|
||||
for (const e of ENTITIES) await ctx.map.load(e);
|
||||
|
||||
for (const [name, step] of STEPS) {
|
||||
if (opt.only && !opt.only.has(name)) continue;
|
||||
const t0 = Date.now();
|
||||
process.stdout.write(`▸ ${name} … `);
|
||||
await step(ctx);
|
||||
console.log(`${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
}
|
||||
|
||||
const md = ctx.report.render([
|
||||
`- started: ${ctx.report.startedAt.toISOString()} · finished: ${new Date().toISOString()}`,
|
||||
`- source: \`${srcInfo.db}\` (MySQL ${srcInfo.v}) · target: \`${dbName}\``,
|
||||
`- steps: ${opt.only ? [...opt.only].join(', ') : 'all'}`,
|
||||
`- images: ${opt.imagesDir ? `${opt.imagesDir} → ${opt.uploadDir}` : 'not copied (no --images)'}`,
|
||||
]);
|
||||
writeFileSync(opt.reportPath, md);
|
||||
console.log(`\nreport: ${opt.reportPath}`);
|
||||
} finally {
|
||||
await src.end();
|
||||
await db.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
/** Legacy system's `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[] = ['# legacy (oapi) → hedena-gold 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 legacy 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 legacy system's 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 SingleProduct.vue), 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,429 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
// `orders.reagent_id` is the expert the order was credited to when it was
|
||||
// placed (never moved afterwards) — carried as is, NOT re-derived from the
|
||||
// customer's current referrer (owner ruling 2026-10-08). It names a
|
||||
// Customer row in legacy: an expert maps to their AdminUser; anyone else
|
||||
// (one customer, 17 orders from 2022) is kept as the referrer customer.
|
||||
let salesAgentId: string | null = null;
|
||||
let referrerCustomerId: string | null = null;
|
||||
if (r.reagent_id !== null) {
|
||||
salesAgentId = ctx.map.get('expert', r.reagent_id) ?? null;
|
||||
if (!salesAgentId) referrerCustomerId = ctx.map.get('customer', r.reagent_id) ?? null;
|
||||
if (!salesAgentId && !referrerCustomerId) {
|
||||
ctx.report.count(S, 'placing expert not migrated — left empty');
|
||||
ctx.report.note(S, `legacy order #${String(r.id)}: its reagent #${String(r.reagent_id)} was not migrated`);
|
||||
} else {
|
||||
ctx.report.count(S, salesAgentId ? 'credited to its legacy expert' : 'credited to its legacy referrer customer');
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
salesAgentId,
|
||||
referrerCustomerId,
|
||||
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}`); }
|
||||
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,558 @@
|
||||
/**
|
||||
* 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)`,
|
||||
);
|
||||
|
||||
// Legacy is a tree (country > province > city > district) and so is the
|
||||
// target (PERS-02): rows first, then each one's parent once all are mapped.
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
// Parents. A row the import created takes legacy's parent; a matched row
|
||||
// keeps the parent it already has (fill-empty, as every other field). A
|
||||
// parent that was not imported (deleted in legacy) leaves the row on top.
|
||||
const parentOf = new Map((await ctx.db.city.findMany({ select: { id: true, parentId: true } })).map((c) => [c.id, c.parentId]));
|
||||
for (const r of rows) {
|
||||
const id = ctx.map.get('city', r.id);
|
||||
const parentId = r.parent === null ? null : ctx.map.get('city', r.parent);
|
||||
if (!id || !parentId || parentId === id) continue;
|
||||
if (ctx.map.how('city', r.id) !== 'created' && parentOf.get(id)) continue;
|
||||
if (parentOf.get(id) === parentId) continue;
|
||||
// Never close a loop: `id` must not be an ancestor of its new parent.
|
||||
let cur: string | null | undefined = parentId;
|
||||
let loop = false;
|
||||
for (let guard = 0; cur && guard < 50; guard++) {
|
||||
if (cur === id) loop = true;
|
||||
cur = parentOf.get(cur);
|
||||
}
|
||||
if (loop) {
|
||||
ctx.report.note(S, `legacy city ${r.id}: parent ${r.parent} would make a loop — left as is`);
|
||||
continue;
|
||||
}
|
||||
await ctx.db.city.update({ where: { id }, data: { parentId } });
|
||||
parentOf.set(id, parentId);
|
||||
ctx.report.count(S, 'parent set');
|
||||
}
|
||||
}
|
||||
|
||||
/** 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]]);
|
||||
|
||||
/**
|
||||
* Legacy rids the target renamed (PERM-01): the wholesale and retail dashboards
|
||||
* («آمار عمده فروشی» dashboard.omde, «آمار تک فروشی» dashboard.tak) and their
|
||||
* tree parent became the one dashboard behind "dashboard.view". Without this a
|
||||
* role that saw a dashboard in legacy would lose it.
|
||||
*/
|
||||
const RENAMED_RIDS: ReadonlyMap<string, string> = new Map([
|
||||
['dashboard', 'dashboard.view'],
|
||||
['dashboard.omde', 'dashboard.view'],
|
||||
['dashboard.tak', 'dashboard.view'],
|
||||
]);
|
||||
|
||||
/** 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 = [
|
||||
...new Set(perms.filter((p) => Number(p.ut) === legacyId).map((p) => RENAMED_RIDS.get(String(p.rid)) ?? 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) {
|
||||
// Owner 2026-10-07: the admin keeps their role. Their expert profile is
|
||||
// still kept, because it is what links their OWN legacy orders (their
|
||||
// trading account) to them — without it their «میزان فروش» lost those
|
||||
// orders. The profile grants nothing: the expert app checks the role.
|
||||
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.`);
|
||||
}
|
||||
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),
|
||||
// Owner ruling 2026-10-08 (MON-05, option 2): everyone may order after the
|
||||
// move. Legacy customers.sts was never read by any code — most of the 313
|
||||
// «عدم سفارش» rows are just the sign-up default (ApiMobileController.php:704)
|
||||
// — so carrying it over would suddenly block real customers once the new
|
||||
// system enforces the switch. Only a deliberate suspension after go-live counts.
|
||||
canPlaceOrders: true,
|
||||
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 (int(r.sts) === -1) ctx.report.count(S, 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)');
|
||||
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,147 @@
|
||||
/**
|
||||
* 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';
|
||||
import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders';
|
||||
|
||||
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).`);
|
||||
}
|
||||
/**
|
||||
* «میزان فروش» for every migrated customer and expert, against legacy's own
|
||||
* formula (CustomerController::updateGramSell, soft-deleted orders excluded):
|
||||
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
|
||||
* computed live from legacy's rows — and, separately, legacy's STORED
|
||||
* `customers.grams_sell`, which the legacy screens print. The stored value is
|
||||
* a cache refreshed only on some order events, so where it disagrees with the
|
||||
* formula the legacy screen itself is out of date; each such row is listed.
|
||||
*
|
||||
* Legacy rows the import merged into one target row (duplicate mobiles) are
|
||||
* compared as one: their legacy figures are summed.
|
||||
*/
|
||||
export async function reconcileSales(ctx: Ctx) {
|
||||
const S = 'reconciliation — sales («میزان فروش»)';
|
||||
const rows = await ctx.q<Row>(
|
||||
`SELECT c.id, c.grams_sell AS kept,
|
||||
(SELECT COALESCE(SUM(o.input), 0) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND (o.customer_id = c.id OR o.reagent_id = c.id)) AS live
|
||||
FROM customers c WHERE c.deleted_at IS NULL`,
|
||||
);
|
||||
const groups = new Map<string, { where: Prisma.OrderWhereInput; live: number; ids: string[] }>();
|
||||
let stale = 0;
|
||||
for (const r of rows) {
|
||||
const agentId = ctx.map.get('expert', r.id);
|
||||
const customerId = ctx.map.get('customer', r.id);
|
||||
if (!agentId && !customerId) continue;
|
||||
const key = agentId ? `expert:${agentId}` : `customer:${customerId!}`;
|
||||
const g = groups.get(key) ?? {
|
||||
where: agentId ? agentSalesWhere(agentId) : customerSalesWhere(customerId!),
|
||||
live: 0,
|
||||
ids: [],
|
||||
};
|
||||
g.live += toNumber(r.live);
|
||||
g.ids.push(String(r.id));
|
||||
groups.set(key, g);
|
||||
|
||||
const live = new Prisma.Decimal(toNumber(r.live).toFixed(4));
|
||||
const kept = new Prisma.Decimal(toNumber(r.kept).toFixed(4));
|
||||
if (kept.sub(live).abs().gt(TOLERANCE)) {
|
||||
stale++;
|
||||
ctx.report.note(S, `legacy's stored figure is out of date for #${String(r.id)}: legacy screen shows ${kept.toString()}, legacy's own formula gives ${live.toString()}`);
|
||||
}
|
||||
}
|
||||
let ok = 0, bad = 0;
|
||||
for (const [key, g] of groups) {
|
||||
const agg = await ctx.db.order.aggregate({ where: g.where, _sum: { totalGoldWeightGrams: true } });
|
||||
const target = agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0);
|
||||
const live = new Prisma.Decimal(g.live.toFixed(4));
|
||||
if (live.sub(target).abs().lte(TOLERANCE)) ok++;
|
||||
else { bad++; ctx.report.note(S, `⚠ ${key.split(':')[0]} legacy #${g.ids.join(' + #')}: legacy formula ${live.toString()} / target ${target.toString()}`); }
|
||||
}
|
||||
ctx.report.count(S, 'matches legacy formula (±0.0001 g)', ok);
|
||||
ctx.report.count(S, 'DIFFERS from legacy formula', bad);
|
||||
ctx.report.count(S, "legacy's stored screen value is stale (legacy disagrees with itself)", stale);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user