diff --git a/apps/api/package.json b/apps/api/package.json index bca7681..8dec4f2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,6 +43,7 @@ "@types/supertest": "^7.2.1", "esbuild": "^0.25.0", "jest": "^30.5.2", + "mysql2": "^3.24.5", "prisma": "^5.19.0", "supertest": "^7.2.2", "ts-jest": "^29.4.12", diff --git a/apps/api/scripts/legacy-import/index.ts b/apps/api/scripts/legacy-import/index.ts new file mode 100644 index 0000000..1e89637 --- /dev/null +++ b/apps/api/scripts/legacy-import/index.ts @@ -0,0 +1,160 @@ +/** + * henza (MySQL `oapi`) → gold-bank (PostgreSQL) data import. + * + * The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section. + * + * Usage (from apps/api): + * + * npx ts-node --transpile-only scripts/legacy-import/index.ts \ + * --source "mysql://user:pass@127.0.0.1:3307/oapi" \ + * --target "postgresql://user:pass@localhost:5432/goldbank_import_test" \ + * [--images "F:/henza/storage" --upload-dir "./storage/uploads"] \ + * [--only customers,ledger] [--report ./legacy-import-report.md] --yes + * + * Safety: + * - The source is only read. Point it at a COPY of production anyway. + * - --target is required and never taken from .env, so the dev database is + * never written by accident. Without --yes nothing is written; a target + * whose name does not contain "import" or "staging" also needs + * --confirm-db=. + * - Re-runnable: every written row is recorded in legacy_import.id_map, so a + * second run updates instead of duplicating, and a run that stops half way + * is finished by running it again. + * - Never writes Customer/Product accounting* columns, ProductVariant.imageId, + * or the Accounting* tables, and never deletes a ProductImage. + * - Writes go straight through Prisma, not through the API services, so no + * SMS is sent and nothing is queued to the accounting software. + */ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { PrismaClient } from '@prisma/client'; +import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/context'; +import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people'; +import { brands, categories, products, variants } from './steps/catalog'; +import { favorites, ledger, orders, preOrders } from './steps/commerce'; +import { reconcile } from './steps/reconcile'; + +/** Order matters: each step reads the ids the earlier ones mapped. */ +const STEPS: [string, (ctx: Ctx) => Promise][] = [ + ['cities', cities], + ['roles', roles], + ['branches', branches], + ['admins', admins], + ['experts', experts], + ['workshops', workshops], + ['customers', customers], + ['brands', brands], + ['categories', categories], + ['products', products], + ['variants', variants], + ['orders', orders], + ['ledger', ledger], + ['favorites', favorites], + ['preorders', preOrders], + ['reconcile', reconcile], +]; + +const ENTITIES = [ + 'city', 'ut', 'branch', 'admin', 'expert', 'workshop', 'customer', 'file', 'brand', 'category', + 'product', 'pimage', 'variant', 'order', 'orderitem', 'ledger', 'preorder', +]; + +/** Columns the prep migrations added; the import refuses to run without them. */ +const REQUIRED = [ + ['Customer', 'nationalCode'], ['Customer', 'birthDate'], ['Customer', 'cityNameRaw'], + ['Customer', 'verificationRejectionReason'], ['CustomerFavorite', 'customerId'], +]; + +function parseArgs(argv: string[]) { + const a = new Map(); + 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 is required'), + targetUrl: a.get('target') ?? fail('--target 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); +}); diff --git a/apps/api/scripts/legacy-import/lib/context.ts b/apps/api/scripts/legacy-import/lib/context.ts new file mode 100644 index 0000000..a9ea22b --- /dev/null +++ b/apps/api/scripts/legacy-import/lib/context.ts @@ -0,0 +1,327 @@ +/** + * Shared state for one import run: both connections, the id map and the report. + */ +import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { PrismaClient } from '@prisma/client'; +import { createPool, type Pool, type RowDataPacket } from 'mysql2/promise'; + +export interface Options { + sourceUrl: string; + targetUrl: string; + /** henza `storage/` folder holding the image files (optional). */ + imagesDir?: string; + /** Target UPLOAD_DIR the files are copied into (required with imagesDir). */ + uploadDir?: string; + /** Run only these steps (by name); all when unset. */ + only?: Set; + reportPath: string; +} + +/* ───────────── report ───────────── */ + +interface Section { + counts: Map; + 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(); + + private section(step: string): Section { + let s = this.sections.get(step); + if (!s) { + s = { counts: new Map(), notes: [] }; + this.sections.set(step, s); + } + return s; + } + + count(step: string, key: string, n = 1) { + const s = this.section(step); + s.counts.set(key, (s.counts.get(key) ?? 0) + n); + } + + note(step: string, message: string) { + this.section(step).notes.push(message); + } + + get(step: string, key: string): number { + return this.sections.get(step)?.counts.get(key) ?? 0; + } + + render(header: string[]): string { + const out: string[] = ['# henza → gold-bank import report', '', ...header, '']; + for (const [step, s] of this.sections) { + out.push(`## ${step}`, ''); + if (s.counts.size) { + out.push('| | |', '|---|---:|'); + for (const [k, v] of s.counts) out.push(`| ${k} | ${v} |`); + out.push(''); + } + for (const n of s.notes) out.push(`- ${n}`); + if (s.notes.length) out.push(''); + } + return out.join('\n'); + } +} + +/* ───────────── id map ───────────── */ + +/** + * Legacy id -> target id, per entity, persisted in the TARGET database under + * its own schema `legacy_import` — outside the `public` schema Prisma manages, + * so it is no app-schema change and drops cleanly after cutover + * (`DROP SCHEMA legacy_import CASCADE`). + * + * It is what makes a re-run safe (a mapped row is updated, never re-created) + * and what later redirects old links such as /product/123 (GO-LIVE-PLAN §7). + * `how` records whether the row was created by the import or matched to a row + * that already existed in the target (merge, §10.5). + */ +export type How = 'created' | 'matched' | 'merged'; + +export class IdMap { + private readonly cache = new Map>(); + + 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; + find: () => Promise<{ id: string } | null>; + create: () => Promise<{ id: string }>; + update: (id: string, how: How) => Promise; + }, +): 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>(current: Record, patch: T): Partial { + const out: Partial = {}; + 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)[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>(sql: string, params?: unknown[]): Promise; + /** Public URL for a legacy `image` row, copying its file when a source dir is given. */ + imageUrl(imageId: unknown, step: string): Promise; + /** + * 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; +} + +const MIME: Record = { + 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(); + + const ctx: Ctx = { + src, + db, + map, + report, + opt, + async q(sql: string, params: unknown[] = []) { + const [rows] = await src.query(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(); + +function copyImage(ctx: Ctx, file: string, step: string) { + const { imagesDir, uploadDir } = ctx.opt; + if (!imagesDir || !uploadDir || copied.has(file)) return; + copied.add(file); + const from = join(imagesDir, file); + const to = join(uploadDir, file); + if (existsSync(to)) { + ctx.report.count('images', 'already in upload dir'); + return; + } + if (!existsSync(from) || !statSync(from).isFile()) { + ctx.report.count('images', 'missing in henza storage'); + ctx.report.note('images', `missing file ${file} (referenced from ${step})`); + return; + } + mkdirSync(uploadDir, { recursive: true }); + copyFileSync(from, to); + ctx.report.count('images', 'copied'); +} diff --git a/apps/api/scripts/legacy-import/lib/normalize.ts b/apps/api/scripts/legacy-import/lib/normalize.ts new file mode 100644 index 0000000..6c39d65 --- /dev/null +++ b/apps/api/scripts/legacy-import/lib/normalize.ts @@ -0,0 +1,204 @@ +/** + * Value conversion from the henza MySQL schema to the Prisma one. + * + * Every helper here is pure, so the rules can be read and tested in isolation. + * Each rule is the one recorded in docs/DATA-MIGRATION-MAP.md. + */ +import { Prisma } from '@prisma/client'; + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'; + +/** Persian/Arabic-Indic digits -> ASCII. Legacy did the same (Utility::faTOen). */ +export function latinDigits(s: string): string { + return s.replace(/[۰-۹٠-٩]/g, (d) => { + const i = PERSIAN_DIGITS.indexOf(d); + return String(i >= 0 ? i : ARABIC_DIGITS.indexOf(d)); + }); +} + +/** + * Persian text as stored: trimmed, single spaces, and Arabic ي/ك folded to + * Persian ی/ک. Empty -> null, so '' never reaches a nullable target column. + */ +export function text(v: unknown): string | null { + if (v === null || v === undefined) return null; + const s = String(v) + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک') + .replace(/\s+/g, ' ') + .trim(); + return s === '' ? null : s; +} + +/** + * Comparison key for matching names across systems. Also drops the zero-width + * non-joiner, which legacy typed inconsistently ("مدیر اصلی" vs "مدیراصلی"). + * The ي/ك fold alone raised exact city matches from 32 to 72 (§10.4). + */ +export function matchKey(v: unknown): string | null { + const t = text(v); + return t === null ? null : t.replace(/‌/g, '').replace(/\s/g, '').toLowerCase(); +} + +/** Mobile as the login key: ASCII digits only, or null. */ +export function mobile(v: unknown): string | null { + if (v === null || v === undefined) return null; + const d = latinDigits(String(v)).replace(/\D/g, ''); + return d === '' ? null : d; +} + +function num(v: unknown): number { + if (v === null || v === undefined || v === '') return 0; + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +/** + * Legacy stores money and weight as `double` (§4). Going through toFixed at the + * target scale drops the binary noise (12.3 is really 12.30000000000000071…) + * exactly as Decimal(14,4) would, so a reconciliation can compare like with like. + */ +export const grams = (v: unknown) => new Prisma.Decimal(num(v).toFixed(4)); +export const rial = (v: unknown) => new Prisma.Decimal(num(v).toFixed(2)); +export const percent = (v: unknown) => new Prisma.Decimal(num(v).toFixed(3)); +export const isZero = (v: unknown) => Math.abs(num(v)) < 0.00005; +export const toNumber = num; + +/** Integer or null — for tinyint flags and ids that may arrive as strings. */ +export function int(v: unknown): number | null { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isInteger(n) ? n : null; +} + +/* ───────────── time ───────────── */ + +/** + * The Laravel app ran with timezone 'Asia/Tehran' (henza_server config/app.php:70), + * so every timestamp is Tehran wall-clock time. Read as a UTC instant it would + * be 3:30 (4:30 under the pre-2022 summer time) off, moving evening documents + * to the next day on a statement. The ICU zone rules know Iran's historical + * DST, so the offset is taken from them per instant rather than hard-coded. + */ +const tehranFormat = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Tehran', + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function tehranOffsetMs(utcMs: number): number { + const p: Record = {}; + 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)); +} diff --git a/apps/api/scripts/legacy-import/steps/catalog.ts b/apps/api/scripts/legacy-import/steps/catalog.ts new file mode 100644 index 0000000..fa025d2 --- /dev/null +++ b/apps/api/scripts/legacy-import/steps/catalog.ts @@ -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; + +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( + `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('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( + `SELECT * FROM product + WHERE deleted_at IS NULL + OR id IN (SELECT v.product_id_id FROM products_productvarient v WHERE v.id IN (${LIVE_ORDER_LINES}))`, + ); + + // Legacy links products to categories many-to-many (category_collect) and + // keeps TWO trees: type 1 = site, type 2 = app (admin-categories.service.ts:12). + // Every product with more than one category (1,250) is simply in both trees. + // The target keeps one categoryId, which the app shows and links to + // (user-app-v2 SingleProduct.vue:228), so the APP category is the one kept; + // the site one only when the product has no app category. + const links = await ctx.q( + `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(); + const categoryCount = new Map(); + 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( + '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( + `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)' : ''}`); + } +} diff --git a/apps/api/scripts/legacy-import/steps/commerce.ts b/apps/api/scripts/legacy-import/steps/commerce.ts new file mode 100644 index 0000000..65d65a7 --- /dev/null +++ b/apps/api/scripts/legacy-import/steps/commerce.ts @@ -0,0 +1,412 @@ +/** + * Orders, the ledger, favourites and custom orders. + * Rules: docs/DATA-MIGRATION-MAP.md §7, §8, §10, §10.7. + */ +import { + LedgerCurrency, + LedgerDirection, + LedgerSourceType, + OrderChannel, + OrderStatus, + PreOrderStatus, + Prisma, +} from '@prisma/client'; +import { upsert, type Ctx } from '../lib/context'; +import { grams, int, jalaliDate, percent, rial, tehran, text, toNumber } from '../lib/normalize'; + +type Row = Record; +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 = { + 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('SELECT * FROM orders WHERE deleted_at IS NULL'); + for (const r of rows) { + const customerId = ctx.map.get('customer', r.customer_id); + if (!customerId) { + ctx.report.count(S, 'skipped: customer not migrated'); + ctx.report.note(S, `legacy order #${String(r.id)} skipped: its customer #${String(r.customer_id)} was not migrated (status ${String(r.sts)})`); + continue; + } + const sts = int(r.sts); + const status = sts !== null ? ORDER_STATUS[sts] : undefined; + if (!status) { + ctx.report.count(S, 'skipped: unknown status'); + ctx.report.note(S, `legacy order #${String(r.id)} has status ${String(r.sts)}, which has no mapping`); + continue; + } + const orderNumber = `H-${String(r.id)}`; + const data = { + customerId, + channel: OrderChannel.WHOLESALE, + status, + // `input` is what legacy debits (CustomerController::updateRemain) and + // equals `w` on all 300 live orders; `wage` is the making-charge part of it. + totalGoldWeightGrams: grams(r.input), + wageWeightGrams: grams(r.wage), + flatRialCharge: rial(r.rial_input), + // Legacy 0 «معلق» is the over-credit-limit hold. + isSuspendedOverCreditLimit: sts === 0, + // Legacy records no delivery time; the last change of a delivered order is the best available. + deliveredAt: sts === 6 ? tehran(r.updated_at) : null, + }; + const res = await upsert(ctx, 'order', r.id, { + exists: async (id) => !!(await ctx.db.order.findUnique({ where: { id } })), + find: () => ctx.db.order.findUnique({ where: { orderNumber }, select: { id: true } }), + create: () => ctx.db.order.create({ data: { ...data, orderNumber, createdAt: tehran(r.created_at) ?? undefined } }), + update: async (id, how) => { + if (how === 'created') await ctx.db.order.update({ where: { id }, data }); + }, + }); + ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); + if (r.reagent_id !== null) ctx.report.count(S, 'placing expert (orders.reagent_id) not carried — no target field'); + } + await orderItems(ctx); +} + +async function orderItems(ctx: Ctx) { + const S = 'order lines'; + const rows = await ctx.q( + `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 = { + 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('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('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('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL'); + const stored = new Map(); + 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(); + 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( + '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(); + 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 = { + 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('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'); + } +} diff --git a/apps/api/scripts/legacy-import/steps/people.ts b/apps/api/scripts/legacy-import/steps/people.ts new file mode 100644 index 0000000..0eb8515 --- /dev/null +++ b/apps/api/scripts/legacy-import/steps/people.ts @@ -0,0 +1,520 @@ +/** + * People and places: cities, roles, branches, admin users, sales experts, + * workshops and customers. Rules: docs/DATA-MIGRATION-MAP.md §2–§6, §10.4–§10.7. + */ +import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client'; +import { ALL_RIDS } from '../../../src/modules/auth/acl.constants'; +import { isPasswordHash, OTP_ONLY_PASSWORD_HASH } from '../../../src/modules/auth/password-hash'; +import { fillEmpty, upsert, type Ctx } from '../lib/context'; +import { birthday, grams, int, isZero, matchKey, mobile, percent, tehran, text } from '../lib/normalize'; + +type Row = Record; + +/** 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( + `SELECT id, title, parent, level, percent, percent_aux FROM cities + WHERE deleted_at IS NULL + OR id IN (SELECT city_id FROM customers WHERE deleted_at IS NULL AND city_id IS NOT NULL)`, + ); + const all = new Map((await ctx.q('SELECT id, title, parent, level FROM cities')).map((r) => [String(r.id), r])); + + // Legacy is a tree (country > province > city > district); the target City is + // flat with a `province` label, taken from the level-1 ancestor. + const provinceOf = (r: Row): string | null => { + let cur: Row | undefined = r; + for (let guard = 0; cur && guard < 10; guard++) { + if (int(cur.level) === 1) return cur === r ? null : text(cur.title); + cur = all.get(String(cur.parent)); + } + return null; + }; + + const existing = await ctx.db.city.findMany(); + const byKey = new Map(existing.map((c) => [matchKey(c.name), c.id])); + + for (const r of rows) { + const name = text(r.title); + if (!name) { + ctx.report.count(S, 'skipped: no title'); + continue; + } + const data = { + name, + province: provinceOf(r), + percent: r.percent === null ? null : percent(r.percent), + percentAux: r.percent_aux === null ? null : percent(r.percent_aux), + }; + const res = await upsert(ctx, 'city', r.id, { + exists: async (id) => !!(await ctx.db.city.findUnique({ where: { id } })), + find: async () => { + const id = byKey.get(matchKey(name)); + return id ? { id } : null; + }, + create: () => ctx.db.city.create({ data }), + update: async (id, how) => { + if (how === 'created') { + await ctx.db.city.update({ where: { id }, data }); + } else { + const cur = await ctx.db.city.findUniqueOrThrow({ where: { id } }); + const patch = fillEmpty(cur, data); + if (Object.keys(patch).length) await ctx.db.city.update({ where: { id }, data: patch }); + } + }, + }); + ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated'); + byKey.set(matchKey(name), res.id); + } +} + +/** Target City id for an exact (normalised) name match, or null. */ +async function cityByName(ctx: Ctx, name: string | null): Promise { + 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 = new Map([[85, 85]]); + +/** AdminRole is a label only — every permission comes from the user type. */ +function adminRoleFor(legacyUt: number | null): AdminRole { + if (legacyUt === 85 || legacyUt === 104 || legacyUt === 105) return AdminRole.SUPER_ADMIN; // مدیر · مدیر اصلی · مدیران + if (legacyUt === 86) return AdminRole.ACCOUNTANT; // حسابدار + return AdminRole.SYSTEM_APPROVER; +} + +export async function roles(ctx: Ctx) { + const S = 'roles & permissions'; + // Legacy user types are config_statics rows (UserController.php:26: + // `exists:config_statics,id`), titled by `val`. + const types = await ctx.q( + "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(ALL_RIDS as readonly string[]); + const target = await ctx.db.userType.findMany(); + const byTitle = new Map(target.map((t) => [matchKey(t.title), t.id])); + const takenIds = new Set(target.map((t) => t.id)); + + for (const t of types) { + const legacyId = Number(t.id); + const title = text(t.val) ?? `نقش ${legacyId}`; + const mapped = ctx.map.get('ut', legacyId); + let targetId: number; + let created = false; + + if (mapped) { + targetId = Number(mapped); + } else if (SAME_ROLE.has(legacyId) && takenIds.has(SAME_ROLE.get(legacyId)!)) { + targetId = SAME_ROLE.get(legacyId)!; + await ctx.map.set('ut', legacyId, String(targetId), 'matched'); + } else if (byTitle.has(matchKey(title))) { + targetId = byTitle.get(matchKey(title))!; + await ctx.map.set('ut', legacyId, String(targetId), 'matched'); + if (targetId !== legacyId) ctx.report.note(S, `legacy type ${legacyId} «${title}» → existing target type ${targetId} (same title, different number)`); + } else { + targetId = legacyId; + if (takenIds.has(targetId)) { + targetId = Math.max(...takenIds, ...types.map((x) => Number(x.id))) + 1; + ctx.report.note(S, `legacy type ${legacyId} «${title}»: number taken by a different target role, created as ${targetId}`); + } + await ctx.db.userType.create({ data: { id: targetId, title } }); + takenIds.add(targetId); + await ctx.map.set('ut', legacyId, String(targetId), 'created'); + created = true; + } + + const legacyRids = perms.filter((p) => Number(p.ut) === legacyId).map((p) => String(p.rid)); + if (created || ctx.map.how('ut', legacyId) === 'created') { + // A role the import created gets legacy's permissions, limited to rids the + // target ACL knows (the storefront's are out of scope, D13). + const usable = legacyRids.filter((r) => known.has(r)); + const res = await ctx.db.permission.createMany({ + data: usable.map((rid) => ({ rid, userTypeId: targetId })), + skipDuplicates: true, + }); + ctx.report.count(S, 'permissions copied', res.count); + ctx.report.count(S, 'permissions dropped (rid unknown to the target ACL)', legacyRids.length - usable.length); + ctx.report.count(S, 'types created'); + } else { + // An existing target role keeps the target's permission set (D1: the + // target's stricter auth stands). Nothing is added to it. + ctx.report.count(S, 'types matched (permissions left as the target has them)'); + ctx.report.count(S, 'legacy permissions not applied to matched types', legacyRids.length); + } + } + + const orphanUt = [...new Set(perms.map((p) => Number(p.ut)))].filter((u) => !types.some((t) => Number(t.id) === u)); + if (orphanUt.length) ctx.report.note(S, `permission rows for types with no config_statics row were skipped: ${orphanUt.join(', ')}`); +} + +/* ───────────── branches & workshops ───────────── */ + +export async function branches(ctx: Ctx) { + const S = 'branches'; + const rows = await ctx.q('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('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('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('SELECT * FROM customers WHERE deleted_at IS NULL AND type = 1'); + + const existingExpertType = await ctx.db.adminUser.groupBy({ + by: ['userTypeId'], + where: { role: AdminRole.SALES_EXPERT, userTypeId: { not: null } }, + _count: true, + orderBy: { _count: { userTypeId: 'desc' } }, + }); + const expertType = + existingExpertType[0]?.userTypeId ?? + (await ctx.db.userType.findMany()).find((t) => matchKey(t.title) === matchKey('کارشناس فروش'))?.id ?? + null; + if (expertType === null) ctx.report.note(S, 'no target user type for sales experts found; created experts have no user type'); + + for (const r of rows) { + const m = mobile(r.mobile); + if (!m) { + ctx.report.count(S, 'skipped: no mobile'); + continue; + } + const profile = { + cityId: ctx.map.get('city', r.city_id) ?? null, + gramLevel: grams(r.gram_level), + phone: text(r.phone), + nationalCode: text(r.ncode), + }; + const owned = { + fullName: fullNameOf(r), + firstName: text(r.fname), + lastName: text(r.lname), + // AdminUser.wagePercent is "legacy Customer.percent" (schema). + wagePercent: r.percent === null ? null : percent(r.percent), + isActive: int(r.active) !== -1, + avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S), + address: text(r.address), + storeName: text(r.store_name), + pasazhName: text(r.pasazh_name), + memberCode: text(r.mcode), + }; + const res = await upsert(ctx, 'expert', r.id, { + exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })), + find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }), + create: () => + ctx.db.adminUser.create({ + data: { + ...owned, + mobile: m, + passwordHash: passwordOf(r), + role: AdminRole.SALES_EXPERT, + userTypeId: expertType, + createdAt: tehran(r.created_at) ?? undefined, + }, + }), + update: async (id, how) => { + // A matched admin (the target's own, or a legacy `users` admin) is not + // rewritten here; the role check below decides what else happens. + if (how === 'created') await ctx.db.adminUser.update({ where: { id }, data: owned }); + }, + }); + + const admin = await ctx.db.adminUser.findUniqueOrThrow({ + where: { id: res.id }, + include: { salesExpertProfile: true }, + }); + if (admin.role !== AdminRole.SALES_EXPERT) { + ctx.report.count(S, `mobile already an admin with role ${admin.role} — kept, not demoted`); + ctx.report.note(S, `legacy expert #${String(r.id)} shares a mobile with admin role ${admin.role}; the admin was kept and referrals point to it.`); + continue; + } + if (!admin.salesExpertProfile) { + await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } }); + } else if (res.how === 'created') { + await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: profile }); + } else { + const patch = fillEmpty(admin.salesExpertProfile, profile); + if (Object.keys(patch).length) await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: patch }); + } + ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing expert' : 'updated'); + } +} + +/* ───────────── customers ───────────── */ + +/** Legacy Customer::$TYPES (Customer.php:85-92); NULL predates sign-up's `type = 2`. */ +function kindFor(type: number | null): CustomerKind { + switch (type) { + case 3: return CustomerKind.WALK_IN_SHOPKEEPER; + case 4: return CustomerKind.BANKER; + case 5: return CustomerKind.WORKSHOP_SUPERVISOR; + default: return CustomerKind.APP_SHOPKEEPER; // 2, 1 (expert's trading account), NULL + } +} + +/** + * Two 3-state legacy columns (1 pending, 2 accepted, 3 rejected; §5.5) to one + * enum. Step one governs: a rejected step one closed the case in legacy + * (`active = -1`), and a pending step one means review never got past it. + */ +function verificationFor(one: number | null, two: number | null): VerificationStep { + if (one === 3 || (one !== 1 && two === 3)) return VerificationStep.REJECTED; + if (one === 1) return VerificationStep.STEP_ONE_SUBMITTED; + if (one === 2) return two === 2 ? VerificationStep.VERIFIED : VerificationStep.STEP_TWO_SUBMITTED; + return VerificationStep.UNVERIFIED; +} + +export async function customers(ctx: Ctx) { + const S = 'customers'; + const rows = await ctx.q('SELECT * FROM customers WHERE deleted_at IS NULL'); + const withOrders = new Set((await ctx.q('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('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(); + for (const r of candidates) { + const m = mobile(r.mobile); + if (!m) { + // In the 2026-10-04 dump this is one test account whose "mobile" is the + // word «تست»: it cannot log in under any system, so it is not invented a number. + ctx.report.count(S, 'skipped: mobile has no digits'); + const own = withOrders.has(String(r.id)) || withCash.has(String(r.id)); + ctx.report.note(S, `legacy customer #${String(r.id)} skipped: its mobile contains no digits${own ? ' — its orders/documents are skipped with it' : ''}`); + continue; + } + groups.set(m, [...(groups.get(m) ?? []), r]); + } + + for (const [m, group] of groups) { + const ordered = [...group].sort( + (a, b) => + Number(hasMoney(b)) - Number(hasMoney(a)) || + String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')) || + Number(b.id) - Number(a.id), + ); + const [r, ...losers] = ordered; + const moneyLosers = losers.filter(hasMoney); + if (moneyLosers.length) { + // Not seen in the 2026-10-04 dump; if a fresh dump has it, the money of + // two legacy rows lands on one customer and must be reviewed by hand. + ctx.report.note(S, `⚠ duplicate mobile with money on several rows: legacy #${[r, ...moneyLosers].map((x) => String(x.id)).join(', #')} merged onto one customer — review`); + } + + const type = int(r.type); + const step = verificationFor(int(r.sts_verify_step_one), int(r.sts_verify_step_two)); + const typedCity = text(r.new_city); + // Sign-up forced city_id = 5 (تهران) on everyone (Major/CustomerController.php:21), + // so when the customer typed a city, that text — not city_id 5 — is the truth. + const cityId = typedCity + ? int(r.city_id) !== 5 && r.city_id !== null + ? ctx.map.get('city', r.city_id) ?? null + : await cityByName(ctx, typedCity) + : ctx.map.get('city', r.city_id) ?? null; + + const owned = { + firstName: text(r.fname), + lastName: text(r.lname), + nationalCode: text(r.ncode), // empty -> NULL; '' would collide under @unique (§10.4) + cityId, + cityNameRaw: typedCity, + kind: kindFor(type), + phone: text(r.phone), + address: text(r.address), + storeName: text(r.store_name), + pasazhName: text(r.pasazh_name), + zone: text(r.zone), + memberCode: text(r.mcode), + referredByAgentId: ctx.map.get('expert', r.reagent_id) ?? null, + goldCreditLimitGrams: grams(r.gram_level), + makingChargeOffsetPercent: percent(r.percent), + verificationStep: step, + verificationRejectionReason: step === VerificationStep.REJECTED ? LEGACY_REJECTION_REASON : null, + nationalIdImageUrl: await ctx.imageUrl(r.ncode_image_id, S), + businessLicenseUrl: await ctx.imageUrl(r.business_license_image_id, S), + shopImageUrl: await ctx.imageUrl(r.shop_image_id, S), + avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S), + birthDate: birthday(r.birth_date, r.created_at), + canPlaceOrders: int(r.sts) !== -1, + isActive: int(r.active) !== -1, + isArchived: int(r.is_archive) === 1, + }; + if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)'); + if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)'); + + const res = await upsert(ctx, 'customer', r.id, { + exists: async (id) => !!(await ctx.db.customer.findUnique({ where: { id } })), + find: () => ctx.db.customer.findUnique({ where: { mobile: m }, select: { id: true } }), + create: () => + ctx.db.customer.create({ + data: { + ...owned, + fullName: fullNameOf(r), + mobile: m, + passwordHash: passwordOf(r), + createdAt: tehran(r.created_at) ?? undefined, + } satisfies Prisma.CustomerUncheckedCreateInput, + }), + update: async (id, how) => { + if (how === 'created') { + // Never the accounting* columns (D27, §10.5): not in `owned`. + await ctx.db.customer.update({ where: { id }, data: owned }); + } else { + const cur = await ctx.db.customer.findUniqueOrThrow({ where: { id } }); + const patch = fillEmpty(cur, owned); + // The target's own verification state wins; a legacy rejection reason + // on a customer the target shows as anything but REJECTED would contradict it. + if (cur.verificationStep !== VerificationStep.REJECTED) delete patch.verificationRejectionReason; + if (Object.keys(patch).length) await ctx.db.customer.update({ where: { id }, data: patch }); + } + }, + }); + ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (empty fields filled)' : 'updated'); + if (step === VerificationStep.REJECTED) ctx.report.count(S, 'rejected (legacy reason sentinel written)'); + + for (const l of losers) { + await ctx.map.set('customer', l.id, res.id, 'merged'); + ctx.report.count(S, 'duplicate-mobile rows merged into the winner'); + } + + // An expert's trading account (D17): link it from their profile. + if (type === 1) { + const adminId = ctx.map.get('expert', r.id); + const profile = adminId ? await ctx.db.salesExpertProfile.findUnique({ where: { adminUserId: adminId } }) : null; + if (profile && !profile.tradingCustomerId) { + await ctx.db.salesExpertProfile.update({ where: { id: profile.id }, data: { tradingCustomerId: res.id } }); + ctx.report.count(S, 'expert trading accounts linked'); + } + } + } +} diff --git a/apps/api/scripts/legacy-import/steps/reconcile.ts b/apps/api/scripts/legacy-import/steps/reconcile.ts new file mode 100644 index 0000000..908abd4 --- /dev/null +++ b/apps/api/scripts/legacy-import/steps/reconcile.ts @@ -0,0 +1,91 @@ +/** + * Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now + * holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero + * difference", which double → Decimal rounding makes impossible. + */ +import { Prisma } from '@prisma/client'; +import type { Ctx } from '../lib/context'; +import { toNumber } from '../lib/normalize'; + +type Row = Record; +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('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL'); + const want = new Map(); + 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).`); +} diff --git a/apps/api/scripts/legacy-import/tsconfig.json b/apps/api/scripts/legacy-import/tsconfig.json new file mode 100644 index 0000000..77e788f --- /dev/null +++ b/apps/api/scripts/legacy-import/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true + }, + "include": ["./**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31d441c..f082cd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: jest: specifier: ^30.5.2 version: 30.5.2(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + mysql2: + specifier: ^3.24.5 + version: 3.24.5(@types/node@20.19.43) prisma: specifier: ^5.19.0 version: 5.22.0 @@ -2425,6 +2428,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} @@ -3171,6 +3178,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3309,6 +3319,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -3445,6 +3459,9 @@ packages: resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} engines: {node: '>=0.10.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3785,6 +3802,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3799,6 +3819,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3924,6 +3948,16 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mysql2@3.24.5: + resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4484,6 +4518,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + ssr-window@4.0.2: resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==} @@ -7425,6 +7463,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + axios@1.20.0: dependencies: follow-redirects: 1.16.0 @@ -8320,6 +8360,10 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -8464,6 +8508,10 @@ snapshots: safer-buffer: 2.1.2 optional: true + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + idb@7.1.1: {} ieee754@1.2.1: {} @@ -8617,6 +8665,8 @@ snapshots: is-plain-object@3.0.1: {} + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9150,6 +9200,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -9162,6 +9214,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru.min@1.1.5: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -9268,6 +9322,21 @@ snapshots: mute-stream@1.0.0: {} + mysql2@3.24.5(@types/node@20.19.43): + dependencies: + '@types/node': 20.19.43 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.2 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + nanoid@3.3.18: {} nanopop@2.4.2: {} @@ -9847,6 +9916,8 @@ snapshots: sprintf-js@1.0.3: {} + sql-escaper@1.5.2: {} + ssr-window@4.0.2: {} stack-utils@2.0.6: