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