Files
tehran_taj/docs/DATA-MIGRATION-MAP.md
T

1822 lines
102 KiB
Markdown
Raw Normal View History

# نگاشت دیتابیس legacy → Prisma
> سند **نگاشت**، نه پلن اجرا. هیچ کدی بر اساس این سند نوشته نشده.
> پیش‌نیاز ابزار مهاجرت داده‌ی [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷.
**منبع اول:** `oapi-struncture.sql` — دامپ Navicat فقط ساختار، تاریخ ۰۵/۱۰/۲۰۲۶
**منبع دوم:** `oapi.sql` — دامپ کامل با داده، تاریخ ۰۴/۱۰/۲۰۲۶، **۲۷۴٬۷۷۳ سطر**
**مقصد:** `apps/api/prisma/schema.prisma` — ۴۳ مدل، ۴۷۷ فیلد
**تاریخ نگارش:** ۱۴ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۰۶) · به‌روز ۱۵ مهر ۱۴۰۵ (دیتای واقعی)
---
## ۰. مشخصات منبع
| ویژگی | مقدار |
|---|---|
| موتور | **MySQL 8.0.42** (`8.0.42-0ubuntu0.20.04.1`) |
| سرور | `Henza` · `localhost:3305` |
| اسکیما | `oapi` |
| جداول | **۸۰** |
| ستون‌ها | **۱٬۰۸۷** |
| Engine | `InnoDB` (هر ۸۰ جدول) |
| محتوا | **دامپ کامل — `oapi.sql` دارد `INSERT`** |
### ۰.۱ شمارش ردیف‌های کلیدی (از `oapi.sql`)
| جدول | ردیف | یادداشت |
|---|---|---|
| `customers` | **۱٬۶۱۲** | `AUTO_INCREMENT=1720` — ۱۰۸ شکاف |
| `cashes` | **۶۴۸** | اسناد دفتر مالی |
| `products_productvarient` | **۷٬۴۴۳** | شامل حذف‌شده‌ها |
| `product` | **۳٬۲۷۲** | محصولات |
| `image` | **۱۷٬۹۵۵** | فایل‌های فیزیکی: ۱۷٬۸۲۶ (در `storage/`) |
| `products_productimage` | **۵٬۴۱۷** | رابطه product→image |
| `users` | **۲۲** | ادمین/کارشناسان |
| `orders_orders` | **۵۲۰** | سفارش‌ها (به تأخیر افتاد §۷.۲) |
| `wallets` | **۸۰** | کیف‌پول خرده‌فروشی |
| `fav` | **۲۵۱** | مورد علاقه (migration شد §۱۰) |
این فایل شکافی را پر کرد که قبلاً باز بود: جداول دوران Django
(`product`، `products_productvarient`، `category_collection` و …) هیچ migration
لاراولی نداشتند و فقط در دیتابیس زنده تعریف شده بودند. **موتور دیتابیس هم تا
پیش از این حدس بود؛ اکنون قطعی است: MySQL.**
⚠️ **دامپ داده ندارد.** پس این سند نگاشت *ساختار* است. آستانه‌های تطبیق §۷
`GO-LIVE` و شمارش ردیف‌های تکراری (§۳ همین سند) **داده‌ی واقعی لازم دارند** و
با این فایل قابل انجام نیستند.
---
## ۱. تفکیک ۸۰ جدول → ۵۰ زنده، ۳۰ مرده
«زنده» = مدل Eloquent دارد (`$table` صریح، یا نام جمعِ پیش‌فرض لاراول).
| دسته | تعداد | تصمیم |
|---|---|---|
| **زنده — در دامنه‌ی کار** | **۴۱** | مهاجرت می‌شوند |
| زنده — فروشگاه خرده (D13) | ۹ | **حفظ، بدون پورت** |
| نسخه‌ی پشتیبان دستی | ۶ | نادیده |
| بازمانده‌ی Django | ۱۱ | نادیده |
| زیرساخت لاراول | ۳ | نادیده |
### ۱.۱ — جداول پشتیبان دستی (۶) — نادیده
```
customers_copy1 customers_copy2 products_productvarient_copy1
cities5 citiesB cities_copy11
```
`customers_copy1` و `customers_copy2` هر دو ۴۹ ستون دارند، **برابر با
`customers`**. snapshot دستی قبل از یک تغییرند. `customers_test` (۴۰ ستون) مدل
`CustomerTest` دارد ولی جدول آزمایشی است.
⚠️ **قبل از نادیده‌گرفتن، تعداد ردیف هر سه `customers*` شمرده شود.** اگر
`copy2` ردیف‌هایی دارد که `customers` ندارد، یعنی جایی ردیف حذف شده و این کپی
آخرین نسخه‌ی آن است.
### ۱.۲ — بازمانده‌ی Django (۱۱) — نادیده
```
accounts_effectivedate accounts_profile accounts_rolepermission auth_permission
discount_discountcode discount_pricerule
orders_fulfillment orders_sendtime
products_comment products_promotion products_variantpromotion
```
هیچ‌کدام مدل Eloquent ندارند. `accounts_profile` با ۴۳ ستون بزرگ‌ترین‌شان است
(`sales_ceiling`، `commission`، `profit`) — سامانه‌ی کارشناس فروشِ نسخه‌ی
Django، که لاراول با `customers.type=1` جایش را گرفت.
⚠️ `orders_sendtime` از `orders_orders.send_time_id` **ارجاع می‌شود**. اگر
زمان ارسال سفارش‌های تاریخی مهم است، این جدول مرده نیست.
### ۱.۳ — فروشگاه خرده (۹) — حفظ، بدون پورت (D13)
```
wallets transactions transaction_metas transactions_cheque
transactions_deposittocard transactions_financialreport
transactions_meltedgold transactions_refund discount_codes
```
طبق D13 نگه داشته می‌شوند. `TransactionOrder` و `transaction_orders` مدل
دارند، ولی `transactions_*` ندارند.
---
## ۲. 🔴 `fname`/`lname` از قبل وجود دارند — D0 بر فرض غلط بنا شد
جدول `customers` **هر سه ستون** را دارد:
```
fname varchar(50) NULL
lname varchar(50) NULL
name varchar(75) NULL
```
و `users` هم همین سه را دارد (`fname`, `lname`, `name` — هر سه `varchar(255)`).
`DECISIONS.md` §D0 ثبت کرده که `fullName` باید «به `firstName`/`lastName`
شکسته شود» با backfill و «گزارش ردیف‌های چندتوکنی». **چیزی برای شکستن نیست.**
legacy از اول جدا داشته.
| نگاشت صحیح | نگاشت غلط |
|---|---|
| `fname → firstName` | ~~تجزیه‌ی `name` با توکن اول/بقیه~~ |
| `lname → lastName` | |
| `name → fullName` | |
**اگر از `name` تجزیه کنیم، داده‌ی درست را با حدس خراب می‌کنیم.**
### تله‌ی trigger
`customer_sync_full_name` در Postgres یک trigger است و طبق
`PROGRESS.md` رفتارش این است: **اگر هر سه را یک‌جا بنویسی، اجزا برنده می‌شوند
و `fullName` ارسالی دور ریخته می‌شود.** برای مهاجرت این دقیقاً رفتار مطلوب
است (اجزا منبع حقیقت‌اند) — ولی اگر `name` در legacy با `fname + ' ' + lname`
نخواند، **مقدار `name` در مهاجرت گم می‌شود**.
⚠️ قبل از مهاجرت شمرده شود: `WHERE name != CONCAT(fname,' ',lname)`. اگر
صفر نیست، تصمیم مالک لازم است کدام برنده باشد.
---
## ۳. 🔴 هیچ `UNIQUE` روی `mobile` و `ncode` نیست
کل ایندکس‌های `customers`:
```sql
PRIMARY KEY (`id`) USING BTREE
```
همین. **هیچ unique index دیگری، و هیچ foreign key.**
ولی Prisma اعلام کرده:
```prisma
mobile String @unique
nationalCode String @unique
```
**نتیجه:** به احتمال زیاد در داده‌ی تولید موبایل یا کد ملی تکراری وجود دارد.
مهاجرت در میانه با خطای unique متوقف می‌شود — و چون در میانه است، نیمی از
داده منتقل شده.
`GO-LIVE §۷` تکرار شماره **بین `Customer` و `AdminUser`** را ثبت کرده، ولی
**تکرار داخل خود `customers` را ندیده**. این یک شکاف تازه است.
### کوئری‌های شمارش — در فایل جداگانه
📄 **[`migration-precheck.sql`](./migration-precheck.sql)** — ۹ بخش،
فقط `COUNT`/`SUM`، صفر `INSERT`/`UPDATE`/`DELETE`. هیچ ستون متنی در
خروجی ظاهر نمی‌شود، پس **داده‌ی مشتری از سرور بیرون نمی‌آید**.
```
mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck.sql > out.txt
```
نمونه‌ی مختصر از بخش ۲ همان فایل:
```sql
-- ۱) موبایل تکراری
SELECT mobile, COUNT(*) c FROM customers
WHERE deleted_at IS NULL GROUP BY mobile HAVING c > 1;
-- ۲) کد ملی تکراری (رشته‌ی خالی هم تکرار است، برخلاف NULL)
SELECT ncode, COUNT(*) c FROM customers
WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode != ''
GROUP BY ncode HAVING c > 1;
-- ۳) موبایل یا کد ملی تهی — Prisma هر دو را NOT NULL می‌خواهد
SELECT
SUM(mobile IS NULL OR mobile = '') AS no_mobile,
SUM(ncode IS NULL OR ncode = '') AS no_ncode
FROM customers WHERE deleted_at IS NULL;
-- ۴) تداخل شماره با کارشناسان و ادمین‌ها (D21: «کارشناس برنده است»)
SELECT c.mobile FROM customers c
JOIN users u ON u.mobile = c.mobile
WHERE c.deleted_at IS NULL AND u.deleted_at IS NULL;
```
`nationalCode` در Prisma `String` بدون `?` است — **اجباری**. اگر کوئری ۳
عددی غیرصفر برگرداند، یا schema باید nullable شود، یا مالک باید قاعده‌ی
جایگزین بدهد.
---
## ۴. 🔴 ۱۱۰ ستون پول و وزن `double` هستند — صفر `decimal`
```
double/float : ۱۱۰ ستون
decimal : ۱ ستون (فقط users.permission — که مرده است، §۶)
```
نمونه‌ها: `customers.remain`, `customers.rial_remain`, `cashes.remain`,
`cashes.balance`, `orders.w`, `orders.wage`, `order_items.rash_weight`,
`products_productvarient.grams`.
Prisma درست‌کاری کرده: `Decimal(14,4)` برای گرم، `Decimal(20,2)` برای ریال.
**ولی `GO-LIVE §۷` شرط تطبیق را «صفر اختلاف» گذاشته — و این ذاتاً
دست‌نیافتنی است.**
`double` در IEEE 754 عدد `12.3` را واقعاً `12.3000000000000007105…`
ذخیره می‌کند. تبدیل به `Decimal(14,4)` آن را به `12.3000` گِرد می‌کند. در یک
ردیف بی‌اهمیت است؛ در جمعِ هزاران ردیفِ دفتر، اختلاف انباشته می‌شود.
### پیشنهاد — آستانه‌ی واقع‌بینانه جای صفر مطلق
| بررسی | آستانه‌ی فعلی §۷ | آستانه‌ی پیشنهادی |
|---|---|---|
| بدهی طلای هر مشتری | صفر اختلاف | `|Δ| < 0.0001` گرم |
| بدهی ریالی هر مشتری | صفر اختلاف | `|Δ| < 1` ریال |
| جمع کل بدهی طلا | — | `|Δ| < 0.01` گرم |
بدون این، ابزار مهاجرت **همیشه قرمز** گزارش می‌دهد و کسی به گزارشش اعتماد
نمی‌کند — که بدتر از نداشتنِ گزارش است.
⚠️ `rial_*` در legacy `bigint` است (نه `double`) — یعنی **ریال صحیح، بدون
اعشار**. ولی Prisma `Decimal(20,2)` گرفته. این سمت مشکلی ندارد (دقت بیشتر)،
ولی تأیید می‌کند که `fiatDebtRial` هرگز اعشار واقعی ندارد. نقص #۱۹ در
`PWA-PORT-PLAN.md` («بررسی واحد») اینجا پاسخ می‌گیرد: **واحد ریال است، صحیح.**
---
## ۵. `customers` — نگاشت ۴۹ ستون
۳۲ ستون نگاشت دارند، **۱۷ ستون خانه ندارند**.
### ۵.۱ — نگاشت‌شده (۳۲)
| legacy | نوع | Prisma | یادداشت |
|---|---|---|---|
| `id` | `bigint` | — | در جدول نگاشت `legacyId` نگه داشته شود (§۷ GO-LIVE) |
| `fname` | `varchar(50)` | `firstName` | §۲ — مستقیم، تجزیه نشود |
| `lname` | `varchar(50)` | `lastName` | §۲ |
| `name` | `varchar(75)` | `fullName` | trigger بازنویسی می‌کند — §۲ |
| `mobile` | `varchar(20)` | `mobile` | **§۳ — unique نیست در مبدأ** |
| `ncode` | `varchar(20)` | `nationalCode` | **§۳ — unique نیست، و در Prisma اجباری است** |
| `password` | `varchar(255)` | `passwordHash` | فرمت hash تأیید شود (bcrypt؟) |
| `city_id` | `int` | `cityId` | نگاشت id لازم |
| `type` | `tinyint` | `kind` | §۵.۳ |
| `sts` | `tinyint` | `canPlaceOrders` | `1`→`true`, `-1`→`false` |
| `active` | `tinyint` | `isActive` | سوییچ مجزا از `sts` |
| `is_archive` | `tinyint(1)` | `isArchived` | |
| `gram_level` | `double` | `goldCreditLimitGrams` | §۴ |
| `remain` | `double` | `goldDebtGrams` | §۴ — بدهی طلا |
| `rial_remain` | `bigint` | `fiatDebtRial` | §۴ — ریال صحیح |
| `percent` | `double` | `makingChargeOffsetPercent` | D9 — آفست اجرت |
| `reagent_id` | `int` | `referredByAgentId` | 🔴 **→ `customers.id`، نه `users.id`** · §۵.۷ |
| `phone` | `varchar(15)` | `phone` | |
| `address` | `varchar(255)` | `address` | |
| `store_name` | `varchar(255)` | `storeName` | |
| `pasazh_name` | `varchar(255)` | `pasazhName` | |
| `zone` | `varchar(255)` | `zone` | |
| `mcode` | `varchar(30)` | `memberCode` | |
| `image_id` | `int` | `avatarFileId` | FK → `UploadedFile` |
| `ncode_image_id` | `int` | `nationalIdImageUrl` | **FK → رشته** · §۵.۴ |
| `business_license_image_id` | `int` | `businessLicenseUrl` | **FK → رشته** · §۵.۴ |
| `shop_image_id` | `int` | `shopImageUrl` | **FK → رشته** · §۵.۴ |
| `sts_verify_step_one` | `tinyint(1)` | `verificationStep` | §۵.۵ — دو ستون → یک enum |
| `sts_verify_step_two` | `tinyint(1)` | `verificationStep` | §۵.۵ |
| `created_at` | `timestamp` | `createdAt` | |
| `updated_at` | `timestamp` | `updatedAt` | |
| `deleted_at` | `timestamp` | — | **§۵.۶ — soft-delete در Prisma وجود ندارد** |
### ۵.۲ — 🟡 ۱۷ ستون بی‌خانه
**هر ۱۷ مورد در کد legacy فعالاً استفاده می‌شوند** (با grep تأیید شد).
اگر ابزار مهاجرت بی‌خبر نوشته شود، **بی‌صدا دور ریخته می‌شوند**.
| ستون | نوع | فایل‌های استفاده‌کننده | چیست |
|---|---|---|---|
| `input` | `double` | — | جمع وارده‌ی طلا |
| `output` | `double` | — | جمع صادره‌ی طلا |
| `rial_input` | `bigint` | — | جمع وارده‌ی ریال |
| `rial_output` | `bigint` | — | جمع صادره‌ی ریال |
| `grams_sell` | `double` | ۳ | گرم فروخته‌شده |
| `total_order` | `int` | ۶ | شمارنده‌ی سفارش |
| `open_order` | `int` | ۵ | شمارنده‌ی سفارش باز |
| `child` | `int` | ? | شمارنده‌ی زیرمجموعه |
| `notify_count` | `int` | ۵ | شمارنده‌ی اعلان خوانده‌نشده |
| `count_login` | `bigint` | ۲ | شمارنده‌ی ورود |
| `token_mobile` | `varchar(255)` | **۱۰** | توکن push گوشی |
| `birth_date` | `timestamp` | ۴ | تولد — منطق تبریک |
| `marriage_date` | `timestamp` | ۳ | سالگرد |
| `new_city` | `varchar(255)` | ۴ | شهر دستی وقتی `city_id` تهی است |
| `api_token` | `varchar(60)` | — | auth قدیم |
| `send_password` | `varchar(255)` | ۴ | رمز ارسالی با پیامک |
| `rnd` | `varchar(60)` | ۴ | nonce |
**سه گروه با تصمیم متفاوت:**
**الف) `input`/`output`/`rial_input`/`rial_output` — طرف دیگر دفتر.**
Prisma فقط `remain` را به‌عنوان ستون دارد. در مدل جدید جمع وارده/صادره باید
از `LedgerEntry` محاسبه شود. **این بهتر است** — ولی یعنی هنگام مهاجرت باید
تأیید شود که `SUM(ledger) == remain` legacy. اگر نخواند، legacy خودش ناسازگار
بوده و باید گزارش شود، نه اصلاح.
**ب) شمارنده‌های denormalized** (`total_order`, `open_order`, `notify_count`,
`child`, `count_login`, `grams_sell`). قابل محاسبه از داده‌ی دیگرند. اضافه
نکردن‌شان درست است، **به شرطی که جایی که legacy نشان‌شان می‌داد، مقصد هم
محاسبه‌شان کند** — وگرنه عدد صفر نشان داده می‌شود.
**ج) داده‌ی واقعیِ غیرقابل‌بازسازی** — `birth_date`, `marriage_date`,
`token_mobile`, `new_city`.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — دو منتقل، دو حذف
| ستون | ردیف | تصمیم | دلیل مالک |
|---|---|---|---|
| `birth_date` | **۹۱۷** | ✅ **منتقل** | «نرخ پرشدگی بالای ۵۵٪ · کمپین‌های وفاداری و پیامکی» |
| `new_city` | **۱۷۶** | ✅ **منتقل** | «خالی ماندن شهر ۱۷۶ مشتری نقص دیتا ایجاد می‌کند» |
| `token_mobile` | ۹۹ | ❌ **حذف** | «توکن‌ها اغلب منقضی شده‌اند · زباله دیتایی» |
| `marriage_date` | ۵ | ❌ **حذف** | «۵ ردیف توجیه معماری ندارد» |
**هر دو حذف قابل‌دفاع‌اند.** توکن push با تغییر کلید FCM/APNs بی‌اثر
می‌شود، و ۵ ردیف ارزش یک ستون دائمی را ندارد.
migration افزایشی لازم:
```prisma
model Customer {
birthDate DateTime? // legacy birth_date
// new_city → §۵.۹
}
```
### 🔶 `new_city` — «کدام فیلد» هنوز باز است
مالک گفت «یا در فیلد شهر بنشیند یا در یک فیلد آدرس متنی مپ شود». کدام‌یک،
به داده بستگی دارد — §۵.۹.
`api_token`, `send_password`, `rnd` نیازی نیست منتقل شوند — سامانه‌ی auth
جدید JWT است.
### ۵.۳ — `type` → `CustomerKind`
`Customer::$TYPES` در legacy پنج مقدار دارد:
| legacy | برچسب | `CustomerKind` |
|---|---|---|
| `1` | کارشناس فروش | **→ `AdminUser` + `SalesExpertProfile`** (نه `Customer`) · D17 |
| `2` | مغازه‌دار اپلیکیشن | `APP_SHOPKEEPER` |
| `3` | مغازه‌دار حضوری | `WALK_IN_SHOPKEEPER` |
| `4` | بنکدار | `BANKER` |
| `5` | سرپرست کارگاه | `WORKSHOP_SUPERVISOR` |
| — | — | `RETAIL` ➕ مقصد-تنها (از `login`، §۸) |
| `NULL` | — | 🔶 **بی‌نگاشت** |
`type` در مبدأ **nullable بدون default** است. Prisma
`@default(APP_SHOPKEEPER)` دارد. پس `NULL` بی‌صدا «مغازه‌دار اپلیکیشن»
می‌شود. **قبل از مهاجرت `SELECT COUNT(*) WHERE type IS NULL` شمرده شود.**
⚠️ `type=1` **به جدول دیگری می‌رود** (`AdminUser`). یعنی مهاجرت `customers`
دوشاخه است و جدول نگاشت باید بداند هر `legacyId` به کدام جدول رفته.
### ۵.۴ — سه FK تصویر → سه رشته
| legacy | نوع | Prisma | نوع |
|---|---|---|---|
| `ncode_image_id` | `int` → `image.id` | `nationalIdImageUrl` | `String?` |
| `business_license_image_id` | `int` → `image.id` | `businessLicenseUrl` | `String?` |
| `shop_image_id` | `int` → `image.id` | `shopImageUrl` | `String?` |
در legacy اینها **FK** به جدول `image` بودند. در Prisma **رشته‌ی URL** شدند.
پس مهاجرت باید `image.image` (مسیر فایل، `char(100)`) را بخواند و URL بسازد.
⚠️ `image_id` (آواتار) **متفاوت** رفتار می‌کند: در Prisma هنوز FK است
(`avatarFileId → UploadedFile`). یعنی **چهار ستون تصویر، دو الگوی مختلف.**
این ناسازگاری در مقصد است، ولی عوض‌کردنش الان کار این سند نیست — فقط ثبت
می‌شود.
**فایل‌های تصویر هم باید کپی شوند** (§۷ GO-LIVE) — جدول `image` فقط مسیر دارد.
### ۵.۵ — 🔴 دو ستون تأیید **boolean نیستند** — سه‌حالته‌اند
**تصحیح ۲۰۲۶-۱۰-۰۶:** نسخه‌ی اول این سند فرض کرده بود این دو `tinyint(1)`
مقدار `0`/`1` دارند. **غلط بود.** داده‌ی واقعی و
`MajorCustomerController.php:106-135` نشان می‌دهند مقادیر `1`, `2`, `3`
هستند:
| مقدار | معنا | عارضه در legacy |
|---|---|---|
| `1` | در انتظار بررسی | — |
| `2` | **تأیید شد** | `step_one=2` → `active=1` + `api_token` + پیامک |
| `3` | **رد شد** | `step_one=3` → `active=-1` · `step_two=3` → پیامک رد |
| `NULL` | هنوز وارد نشده | — |
### توزیع واقعی (۱٬۵۹۵ ردیف زنده)
| `one,two` | تعداد | معنا | `VerificationStep` |
|---|---|---|---|
| `2,2` | **۱٬۴۶۵** | هر دو تأیید | `VERIFIED` |
| `1,NULL` | ۹۰ | گام ۱ در انتظار | `STEP_ONE_SUBMITTED` |
| `2,NULL` | ۲۵ | گام ۱ تأیید، گام ۲ وارد نشده | `STEP_ONE_SUBMITTED` |
| `1,2` | ۷ | 🔴 **ناسازگار** — گام ۲ تأیید ولی گام ۱ در انتظار | ؟ |
| `3,2` | ۳ | 🔴 **ناسازگار** — گام ۱ رد ولی گام ۲ تأیید | ؟ |
| `2,1` | ۱ | گام ۲ در انتظار | `STEP_TWO_SUBMITTED` |
| `2,3` | ۲ | گام ۲ رد | ؟ |
| `3,3` | ۲ | هر دو رد | ؟ |
🔴 **`VerificationStep` حالت «رد شد» ندارد.** مقادیرش
`UNVERIFIED` / `STEP_ONE_SUBMITTED` / `STEP_TWO_SUBMITTED` / `VERIFIED`
است — هیچ `REJECTED`. پس **۷ ردیف رد‌شده** (`2,3` + `3,3` + بخشی از `3,2`)
جایی برای رفتن ندارند و اگر `UNVERIFIED` شوند، **از «رد‌شده» به
«بررسی‌نشده» برمی‌گردند** — یعنی دوباره در صف بررسی ظاهر می‌شوند.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — `REJECTED` + علت رد
> «رویکرد شما ناقص است؛ حالت «رد شد» باید همراه با فیلد «علت رد» اضافه شود.
> انتقال این افراد به حالت «در انتظار» کاملاً مردود است چون بار عملیاتی و
> خطای انسانی ایجاد می‌کند.»
**پذیرفته شد.** انتقال به `UNVERIFIED` رد شد.
**دو تغییر افزایشی روی مقصد:**
```prisma
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
REJECTED // ← افزوده
}
model Customer {
// ...
verificationRejectionReason String? // ← افزوده
verificationRejectedAt DateTime? // ← افزوده
}
```
### 🔴 ولی legacy علت رد را ذخیره نمی‌کند
مالک خواست «در صورت وجود در سیستم قدیم» علت هم منتقل شود. **بررسی شد:
وجود ندارد.**
هر ۴۹ ستون `customers` خوانده شد — **هیچ ستون `reason`, `note`,
`description`, `comment` یا مشابهی ندارد.** جست‌وجوی کل دامپ هم نشان داد
`canceled_reason` فقط روی `orders_orders` است و `reason` روی یک جدول
`transactions_*`.
و در `MajorCustomerController.php:129-134`، هنگام رد، legacy **فقط یک
پیامک ثابت** می‌فرستد:
```
'اطلاعات شما تایید نشده است.'
'لطفا با پیشتیبانی تماس حاصل فرمایید، تا مشکل برطرف شده و ثبت‌نام شما تکمیل شود.'
```
متن ثابت است — **هیچ علتی در آن نیست و هیچ‌جا ذخیره نمی‌شود.** یعنی در
سیستم قدیم هم پشتیبان علت را نمی‌دانست؛ مشتری باید تلفن می‌زد.
### پس نتیجه‌ی عملی
| | |
|---|---|
| ستون `verificationRejectionReason` | ✅ **ساخته می‌شود** (خواست مالک · برای آینده) |
| مقدارش در مهاجرت **۷ ردیف** | متن ثابت مالک (پایین) — علت واقعی در مبدأ نیست |
| `verificationRejectedAt` | **`NULL`** — legacy زمان رد را هم ثبت نمی‌کرد |
⚠️ **این یک شکاف داده‌ی ذاتی است، نه نقص ابزار مهاجرت.** علت رد آن ۷
مشتری در سیستم قدیم هرگز ذخیره نشده بود و **قابل بازیابی نیست**. ستون
ساخته می‌شود تا از این پس پر شود.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — یادداشت خودکار درج شود
> «فیلد علت برای این ۱۴ ردیف نباید کامپلت NULL رها شود.»
(عدد درست **۷** است: ۲ رد در گام دو، ۳ در گام یک، ۲ در هر دو. عدد ۱۴ را
من اشتباه جمع کرده بودم و مالک بر اساس آن نوشت؛ تصمیم تغییری نمی‌کند.)
مقدار دقیق که ابزار درج می‌کند:
```
رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده
```
| ستون | مقدار |
|---|---|
| `verificationStep` | `REJECTED` |
| `verificationRejectionReason` | **متن بالا** (نه `NULL`) |
| `verificationRejectedAt` | `NULL` — legacy زمان رد را ثبت نمی‌کرد |
⚠️ **قاعده‌ی ابزار:** این متن **فقط** روی ردیف‌هایی درج شود که مهاجرت
`REJECTED`شان می‌کند. اگر ابزار دوباره اجرا شود، نباید روی مشتری‌ای که
**در سیستم جدید** رد شده و علت واقعی دارد بازنویسی کند. شرط:
`WHERE verificationRejectionReason IS NULL` — یا طبق §۱۰.۵، این ستون
هم در allow-list فقط برای ردیف‌های تازه نوشته شود.
### ۱۰ ردیف ناسازگار
`1,2` (۷ ردیف) و `3,2` (۳ ردیف) — گام دوم تأیید شده ولی گام اول نه.
در legacy ممکن بود چون دو ستون مستقل‌اند و هیچ محدودیتی بینشان نیست.
**قاعده‌ی پیشنهادی: گام اول حاکم است.**
- `1,2` → `STEP_ONE_SUBMITTED` (گام اول هنوز بررسی نشده)
- `3,2` → `REJECTED` (گام اول رد شده، پس کل پرونده رد است)
دلیل: در legacy `step_one=3` ستون `active` را `-1` می‌کرد، یعنی مشتری
**غیرفعال** می‌شد. پس رد گام اول واقعاً پرونده را می‌بست.
### ۵.۹ — `new_city` چیست و کجا بنشیند
`new_city` یک **نام شهر به‌صورت متن آزاد** است، مستقل از `city_id`.
`AdminController.php:296-298` و `Major/CustomerController.php:36` نشان
می‌دهند هرگاه فرم فیلد `city` را پر بفرستد، عیناً در `new_city` ذخیره
می‌شود — **بدون هیچ تطبیقی با جدول `cities`**.
و `MajorCustomerController.php:36` ثابت می‌کند **خوانده می‌شود**:
داشبورد مشتری `new_city` را در `select` دارد. پس ستون زنده است، نه
بازمانده.
**چرا ۱۷۶ ردیف دارد:** جدول `cities` فقط **۱۵ ردیف** دارد (§۱۰.۴). پس
بسیاری از شهرهای واقعی در آن نیستند و اپراتور نام را دستی تایپ می‌کرده.
### دو حالت ممکن، دو راه‌حل متفاوت
| اگر | یعنی | راه‌حل |
|---|---|---|
| اکثر ۱۷۶ ردیف `city_id` **تهی** دارند | شهرشان در `cities` نیست | **ستون متنی لازم است** |
| اکثرشان `city_id` **هم** دارند | `new_city` تکراری است | می‌توان رهایش کرد |
| متن‌شان با عنوان یک شهر موجود یکی است | قابل تبدیل به `city_id` | نگاشت به `cityId` |
🔶 **یک کوئری لازم است** —
[`migration-precheck-3.sql`](./migration-precheck-3.sql). تا جوابش نیاید،
نمی‌دانم ستون تازه بسازم یا به `cityId` نگاشت کنم. ساختن ستونی که لازم
نیست، همان‌قدر بد است که نساختن ستونی که لازم است.
⚠️ **اگر ستون متنی لازم شد، اسمش `cityNameRaw` پیشنهاد می‌شود**، نه
`newCity` — چون «new» در legacy معنای تاریخی داشت (ستون بعداً اضافه شده)
و در مقصد گمراه‌کننده است.
### `birth_date` — یک بررسی سلامت
`birth_date` نوعش `timestamp` است. ولی چون legacy تاریخ‌ها را جاهای دیگر
**به‌صورت رشته‌ی جلالی** ذخیره می‌کرد (§۸ — `recive_date`)، احتمال دارد
اینجا هم تاریخ جلالی به‌اشتباه به‌عنوان میلادی وارد شده باشد.
اگر سال‌ها در بازه‌ی ۱۳۰۰–۱۴۰۰ باشند، یعنی **جلالی در ستون میلادی** و
تبدیل لازم است. اگر ۱۹۳۰–۲۰۱۰ باشند، سالم‌اند.
کوئری‌اش در همان فایل سوم است.
---
### ۵.۷ — 🔴 `reagent_id` به `customers` اشاره می‌کند، نه `users`
کوئری پیش‌بررسی **۱٬۵۳۴ ارجاع یتیم** از `customers.reagent_id` به
`users.id` گزارش کرد — یعنی تقریباً همه‌ی ۱٬۵۹۵ ردیف.
**علت: کوئری من به جدول غلط join زده بود، نه اینکه داده خراب باشد.**
`Customer.php:119-122`:
```php
public function reagent()
{
return $this->belongsTo('App\Models\Customer'); // ← Customer، نه User
}
```
کارشناس فروش در legacy **یک ردیف در `customers` با `type=1`** است (۵۶ ردیف،
§۵.۳) — نه یک ردیف در `users`. پس `reagent_id` یک **خودارجاع** است.
### پس در مقصد
`Customer.referredByAgentId` به **`AdminUser`** اشاره می‌کند، چون D17 حکم
کرده کارشناس فروش `AdminUser` + `SalesExpertProfile` شود. یعنی مهاجرت باید
**دو مرحله‌ای** باشد:
1. اول ۵۶ ردیف `customers.type=1` → `AdminUser` + `SalesExpertProfile`،
و `legacyCustomerId → adminUserId` در جدول نگاشت ثبت شود
2. بعد بقیه‌ی مشتری‌ها، و `reagent_id` از **همان جدول نگاشت** ترجمه شود —
نه از `users`
🔴 **اگر این ترتیب رعایت نشود، `referredByAgentId` همه‌ی مشتری‌ها `NULL`
می‌شود** و رابطه‌ی «کارشناس معرف» کامل از بین می‌رود.
⚠️ `mobile_collision_customer_vs_user = 10` یعنی ۱۰ شماره در هر دو جدول
هست. چون کارشناس در `customers` است، این ۱۰ مورد **تداخل واقعی**اند و
D21 («کارشناس برنده است») دقیقاً برایشان نوشته شده.
---
### ۵.۸ — 🟡 `deleted_at` در Prisma وجود ندارد
هر جدول زنده‌ی legacy ستون `deleted_at` دارد (soft-delete لاراول).
**هیچ مدل Prisma معادلی ندارد.**
یعنی ردیف‌های حذف‌شده‌ی نرم:
- یا منتقل نمی‌شوند (**پیشنهاد** — `WHERE deleted_at IS NULL`)
- یا منتقل می‌شوند و **ناگهان زنده می‌شوند** 🔴
گزینه‌ی دوم فاجعه است: مشتری حذف‌شده برمی‌گردد، سفارش حذف‌شده در گزارش
ظاهر می‌شود، سند مالی حذف‌شده در موجودی حساب می‌شود.
**قاعده‌ی اجباری ابزار مهاجرت: هر `SELECT` باید `WHERE deleted_at IS NULL`
داشته باشد.** و تعداد ردیف‌های حذف‌شده‌ی هر جدول در گزارش تطبیق بیاید تا
معلوم باشد چه حجمی عمداً جا مانده.
---
## ۶. `users` → `AdminUser` — و یک ستون خراب
| legacy | نوع | Prisma | یادداشت |
|---|---|---|---|
| `fname`/`lname`/`name` | `varchar(255)` | `firstName`/`lastName` | D17 |
| `mobile` | `varchar(255)` | — | بررسی تداخل با `customers` (§۳) |
| `password` | `varchar(255)` | `passwordHash` | |
| `ut` | `int unsigned` | `userTypeId` → `UserType` | کلید ACL |
| `city_id`, `branch_id` | `int` | — | نگاشت id |
| `token_mobile`, `notify_count` | | 🟡 بی‌خانه | مثل §۵.۲ |
| `api_token`, `remember_token` | | — | لازم نیست |
| **`permission`** | **`decimal(16,16)`** | — | **🔴 ستون خراب — زیر** |
### 🔴 `users.permission decimal(16,16)` — ستونی که کار نمی‌کند
`decimal(16,16)` یعنی **۱۶ رقم کل، ۱۶ رقم اعشار** — پس **صفر رقم صحیح**.
این ستون فقط می‌تواند مقداری بین `0.0000…` و `0.9999…` نگه دارد. هر عدد
صحیحی مثل `1` در آن **سرریز** می‌کند.
خوشبختانه **مرده است**: ACL واقعی از جدول `permission(rid, ut)` می‌خواند —
`AdminController.php:74` → `Permission::whereUt($user->ut)->get()`.
**نگاشت: نادیده.** ثبت می‌شود چون نشان می‌دهد اسکیمای legacy در جاهایی
بازمانده‌ی بی‌استفاده دارد، و اگر کسی بعداً این ستون را ببیند فکر می‌کند
داده‌ی معنی‌دار است.
### `permission` → `Permission` + `UserType`
```
permission (rid varchar(255), ut int) -- ۲ ستون، بدون PK
```
جدول دو‌ستونیِ خالصِ نگاشت. ۱۴۴ rid در مقصد (`acl.constants.ts`) پوشش داده
شده‌اند. **بدون primary key** — پس ردیف تکراری ممکن است؛ هنگام مهاجرت
`DISTINCT` شود.
---
## ۷. سفارش‌ها — دو جدول جدا → یک مدل
legacy **دو جدول کاملاً مستقل** دارد، و هیچ‌کدام فیلتر نوع ندارند چون خودِ
جدول تفکیک‌کننده است:
| جدول | ستون | نقش | `OrderChannel` |
|---|---|---|---|
| `orders` | ۱۹ | عمده (B2B) | `WHOLESALE` |
| `orders_orders` | ۳۷ | خرده (Django-era) | `RETAIL` |
### ۷.۱ — `orders` → `Order` (عمده)
| legacy | نوع | Prisma | یادداشت |
|---|---|---|---|
| `customer_id` | `int` | `customerId` | نگاشت id |
| `reagent_id` | `int` | — | کارشناس سفارش |
| `type` | `tinyint` | — | 🔶 نوع ثبت (تلفنی/تلگرام/…) |
| `w` | `double` | وزن | §۴ |
| `wage` | `double` | اجرت | §۴ |
| `percent` | `double` | درصد اجرت | §۴ · معنای سوم `percent` |
| `sts` | `tinyint` | `OrderStatus` | §۷.۳ |
| `confirmed` | `tinyint(1)` | — | 🔶 جدا از `sts` |
| `input`/`output`/`remain` | `double` | — | طرف دفتر |
| `price`, `rial_input`, `discount` | | | §۴ |
| `city_id` | `int` | — | |
| `deleted_at` | `timestamp` | — | **§۵.۶** |
### ۷.۲ — `orders_orders` → `Order` با `channel=RETAIL`
۳۷ ستون، شامل `billing_address`, `shipping_address`, `browser_ip`,
`buyer_accepts_marketing`, `email`, `total_tax`, `use_wallet`,
`payment_after_wallet`, `gold_price`, `gold_price_original`.
طبق **D13 فروشگاه خرده خارج از دامنه است** — پس این جدول **حفظ می‌شود،
پورت نمی‌شود**. ولی `Order.channel` مقدار `RETAIL` دارد، یعنی مقصد جای
نگه‌داشتنش را دارد.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۶ — به تأخیر افتاد
> «الان مهم نیست و در زمان مناسب بهش ورود می‌کنیم برای تک‌فروشی.»
**`orders_orders` در این مرحله منتقل نمی‌شود.** نتایج:
- 🟢 **خطر تعارض `sts=7` فعلاً منتفی است** (§۷.۳). سفارش تحویل‌شده به
کنسل‌شده تبدیل نمی‌شود، چون هیچ ردیفی منتقل نمی‌شود.
- 🟢 `orders_orderitem` (۲۹ ستون) هم منتقل نمی‌شود.
- 🟢 `orders_sendtime` و `orders_fulfillment` هم بی‌اثر می‌شوند.
- 🟡 `OrderChannel.RETAIL` خالی می‌ماند — انتظارش همین است، باگ نیست.
- 🔴 **جدول در MySQL دست‌نخورده نگه داشته شود.** وقتی تک‌فروشی وارد کار شد،
منبع همین جدول است. هیچ `DROP` و `TRUNCATE` روی `orders_orders`،
`orders_orderitem`، `orders_sendtime`.
⚠️ **بدهی فنیِ ثبت‌شده:** هر وقت تک‌فروشی وارد شود، **جدول نگاشت وضعیتِ
دومی** لازم است چون `sts` در دو جدول معانی متضاد دارد (§۷.۳). این
نمی‌تواند با همان نگاشت عمده انجام شود.
### ۷.۳ — 🔴 دو enum وضعیت متضاد (MON-07 در سطح داده)
`Order::$STS` (عمده):
| # | برچسب | `OrderStatus` |
|---|---|---|
| `0` | معلق | `SUSPENDED` |
| `1` | ثبت سفارش | `PLACED` |
| `2` | تایید مدیر سیستم | `ADMIN_APPROVED` |
| `3` | درحال تامین | `SOURCING` |
| `4` | درحال آماده سازی | `PREPARING` |
| `5` | تحویل مسئول فروش | `DELIVERED_TO_AGENT` |
| `6` | تحویل مشتری | `DELIVERED_TO_CUSTOMER` |
| `7` | ~~کنسل شده~~ (کامنت) | `CANCELLED` |
`SingleCustomerOrders::$STS` (خرده) — **اعداد یکسان، معانی متفاوت:**
| # | برچسب خرده | تعارض |
|---|---|---|
| `1` | ثبت سفارش | ✅ یکی |
| `2` | تایید مدیر سیستم | ✅ یکی |
| `3` | در حال تامین | ✅ یکی |
| `4` | در حال آماده سازی | ✅ یکی |
| `5` | **خروج از انبار** | 🔴 ≠ تحویل مسئول فروش |
| `6` | **ارسال** | 🔴 ≠ تحویل مشتری |
| `7` | **تحویل مشتری** | 🔴 ≠ کنسل |
**`7` در یکی «تحویل مشتری» و در دیگری «کنسل شده» است.**
`OrderStatus` در Prisma فقط enum عمده را دارد. پس اگر `orders_orders`
منتقل شود، **`sts=7` یعنی سفارشِ تحویل‌شده به `CANCELLED` تبدیل می‌شود** —
یک سفارش موفق به کنسل‌شده. و `5`/`6` معنای اشتباه می‌گیرند.
🔶 اگر تصمیم §۷.۲ «منتقل نشود» باشد، این خطر منتفی است. اگر «منتقل شود»،
**جدول نگاشت دوم و جداگانه** لازم است.
### ۷.۴ — اقلام سفارش
`order_items` (۲۱ ستون) → `OrderItem` (۱۱ فیلد).
| legacy | `OrderItem` |
|---|---|
| `rash_weight` | `rashWeightGramsSnapshot` |
| `percent` | `wagePercentSnapshot` |
| `discount` | `discountPercentSnapshot` |
| `w` | `computedWeightGrams` |
| `rial_pay` | `flatRialCharge` |
| `qty` | `quantity` |
| `variant_id` | `productVariantId` |
🔴 **`original_rash_weight` و `original_percent` خانه ندارند.**
وجودِ جفت `original_*` در legacy یعنی مقدار اولیه برای ممیزی نگه داشته
می‌شد — تا معلوم باشد وزن یا درصد **بعداً دست‌کاری شده است یا نه**.
`OrderItem` فقط یک نسخه دارد (`…Snapshot`). پس این تاریخچه‌ی ممیزی
**در مهاجرت از بین می‌رود**. 🔶 تصمیم مالک.
🟡 بی‌خانه‌ی دیگر: `product_id` (مقصد فقط `variantId` دارد),
`product_title`, `variant_title` (عنوان لحظه‌ی خرید), `price`, `wage`,
`sts`, `note`.
⚠️ `product_title`/`variant_title` در legacy **snapshot عنوان** بودند —
اگر نام محصول بعداً عوض شود، فاکتور قدیمی نام قدیمی را نشان می‌داد. مقصد
عنوان را از رابطه می‌خواند، یعنی **فاکتورهای تاریخی با تغییر نام محصول
عوض می‌شوند**.
`orders_orderitem` (۲۹ ستون، خرده) — تابع تصمیم §۷.۲.
---
## ۸. اسناد مالی — `cashes` → `LedgerEntry`
`cashes` با ۳۳ ستون. `Cash::$TYPES` → `LedgerSourceType`:
| legacy | برچسب | `LedgerSourceType` |
|---|---|---|
| `9` | سفارش | `ORDER_STOCK` |
| `1` | آب شده | `REPAYMENT_MELTED_GOLD` |
| `2` | حواله | `REPAYMENT_TRANSFER` |
| `3` | چک | `REPAYMENT_CHEQUE` |
| `4` | پولی | `REPAYMENT_CASH` |
| — | — | `ORDER_PRE_ORDER` ➕ |
| — | — | `WALLET_BONUS` ➕ (D13) |
| — | — | `ADJUSTMENT` ➕ |
نگاشت تمیز است. `$STS`: `-1` بررسی‌نشده · `1` ثبت سند.
`$CONFIRMED`: `0`/`1`.
🔴 **فقط اسناد `confirmed=1` در موجودی حساب می‌شوند** (D24). پس مهاجرت باید
`confirmed` را حفظ کند، نه اینکه همه را تأییدشده فرض کند.
### ستون‌های مخصوص نوع سند — ✅ مقصد پوشش داده
`LedgerEntry` عمداً همین ستون‌ها را دارد:
| legacy | `LedgerEntry` |
|---|---|
| `ayar` | `karatPerMille` (`Int?`) |
| `lab` | `labName` |
| `ang` | `hallmark` |
| `account_name` | `accountName` |
| `bank_name` | `bankName` |
| `to` | `transferTo` |
| `original_value` | `originalValue` |
| `price` | `priceRial` |
| `price_per_gram` | `pricePerGramRialAtEntry` |
| `recive_date` | `receivedAt` (`DateTime?`) |
| `note` | `note` |
| `confirmed` | `confirmed` + `confirmedAt` |
| `order_id` | `orderId` |
| `remain`/`input`/`output` | `amount` + `direction` |
🟡 بی‌خانه: `balance`, `value`, `no` (شماره‌ی سند), `proceed_at`, `title`,
`user_id`, `sts`, `discount`.
🔴 **`ayar` در مبدأ `varchar(20)` است، در مقصد `Int?`.** یعنی اگر عیار
به‌صورت متن («۱۸ عیار» یا «750») ذخیره شده، تبدیل عددی لازم است و مقادیر
غیرعددی می‌شکنند.
🔴 **`recive_date` و `proceed_at` در مبدأ `varchar(255)` هستند، نه
`timestamp`** — به احتمال زیاد **تاریخ جلالی به‌صورت رشته‌ی فارسی**. مقصد
`receivedAt` را `DateTime?` گرفته. پس تجزیه‌ی جلالی → میلادی لازم است، نه
`CAST`. و `proceed_at` خانه ندارد.
### `login` → `Customer` با `kind=RETAIL`
`login` (۱۹ ستون) جدول مشتری خرده است (`fname`, `lname`, `percent`,
`user_type`, `birth_day`, `token_sms`). طبق D13 حفظ، بدون کار.
---
## ۹. کاتالوگ
### `product` → `Product` (۳۵ ستون)
نگاشت‌شدنی: `title`, `body_html`, `product_code`, `is_active`, `disabled`,
`brand_id`, `branch_id`, `min_gram`, `max_gram`, `show_type`,
`delivery_type`, `message_long_delivery`, `offer_day`, `is_exist`,
`has_warranty`, `desk_note`.
🟡 بی‌خانه‌ی احتمالی: `metafields_global_title_tag`,
`metafields_global_description_tag`, `tags`, `meta`, `supplier_id`,
`published_at`, `number_of_purchase`, `count_fav`, `count_order`,
`count_view`, `inventory_id`, `indicator_id_id`.
⚠️ `indicator_id_id` و `mobile_pic_id` و `file_id` — سه ستون تصویر با
نام‌گذاری ناهمگون (`_id_id` دوتایی، بازمانده‌ی Django). در Prisma
`ProductImage` با `ProductImageRole` (`FEATURED`, `FEATURED_MOBILE`,
`GALLERY`, `GALLERY_MOBILE`) جایشان را گرفته. **نگاشت سه ستون → نقش‌ها
باید صریح نوشته شود.**
### `products_productvarient` → `ProductVariant` (۲۵ ستون)
⚠️ **غلط املایی در نام جدول: `varient` (صحیح: `variant`).** در SQL مهاجرت
باید عیناً `products_productvarient` نوشته شود.
🔴 **`Inventory` با I بزرگ.** ستون در دامپ `` `Inventory` int `` است، ولی
`ProductVariant.php:36` در `$fillable` نوشته `'inventory'` (کوچک).
MySQL در لینوکس نام **ستون** را case-insensitive می‌گیرد، پس در legacy کار
می‌کند. **Postgres این‌طور نیست.** اگر کسی کوئری مهاجرت را با `inventory`
بنویسد، MySQL قبول می‌کند — ولی هر ابزار سخت‌گیرتری خطا می‌دهد. **در
کوئری‌ها `` `Inventory` `` با I بزرگ و داخل backtick نوشته شود.**
نگاشت بقیه: `grams`, `rial_pay` (اجرت ریالی/سنگ), `percentage_pay`,
`barcode`, `color` (`smallint` — جدول رنگ لازم), `qty`, `is_pre_order`,
`is_active`, `width`/`length`/`height`, `position`, `product_id_id`.
### `category_collection` → `Category` (۲۴ ستون)
`parent_id_id` → سلسله‌مراتب. ⚠️ `image_id_id` **و** `image_id` **و**
`icon_id` — سه ستون تصویر، یکی با `_id_id` دوتایی. و `type` **و**
`category_type` هر دو وجود دارند.
🔶 **ترتیب ۹ دسته‌ی خانه با id عددی هاردکد شده** (Q5 در `PWA-PORT-PLAN.md`).
جدول نگاشت `legacyId → id` برای این **اجباری** است.
`category_collect` (۹ ستون) → `CollectionProduct` — جدول واسط.
⚠️ سه FK دارد (`collection_id_id`, `post_id_id`, `product_id_id`) که
`post_id_id` در مقصد معادلی ندارد.
### `image` → `UploadedFile` (۲۰ ستون)
`image` (`char(100)`) مسیر فایل · `original_name`, `ext`, `width`, `height`.
🟡 بی‌خانه: `resize`, `resize_path`, `resize_watermark`,
`resize_watermark_path`, `resize_small`, `resize_small_path`, `banner`,
`position`, `link`.
legacy شش ستون برای نسخه‌های تغییراندازه‌یافته داشت. **اگر مقصد این‌ها را
ندارد، باید تصاویر بازتولید شوند یا فقط نسخه‌ی اصلی بماند.**
### `cities` → `City` (۱۱ ستون)
`parent`, `level`, `has_children` → سلسله‌مراتب.
⚠️ `percent` و `percent_aux` — **معنای چهارم `percent`!**
`MIGRATION-STATE.md` سه معنا را فهرست کرده؛ این چهارمی است: اضافه‌بهای اجرت
به‌تفکیک شهر.
✅ **مقصد هر دو را دارد:** `City.percent` و `City.percentAux`، هر دو
`Decimal(6,3)?`، با کامنت صریح که از `cities.percent`/`percent_aux` آمده‌اند.
نگاشت تمیز است.
### `branches`/`workshops` → `Branch`/`Workshop`
هر دو ۹ ستون، ساختار یکسان (`name`, `address`, `phone`, `user_id`,
`is_active`). نگاشت تمیز.
---
## ۱۰. بقیه‌ی جداول زنده
| legacy | ستون | مقصد | یادداشت |
|---|---|---|---|
| `custom_orders` | ۱۶ | `PreOrder` | `weight`, `color`, `size`, `color_stone`, `color_mina`, `workshop_id` |
| `custom_order_logs` | | `PreOrderStatusEvent` | |
| `order_activity` | | `OrderStatusEvent` | |
| `comments` | ۹ | `Comment` | `reply_to` خودارجاع |
| `major_promotion` | ۱۵ | `Promotion` | `type_customer`, `use_only`, `discount_percent`/`discount_price` |
| `major_promotion_ignores` | | `PromotionExclusion` | |
| `promotion_products` | ۱۳ | `PromotionExclusion`? | 🔶 ابهام |
| `inventory_branches` | ۹ | `BranchAllocation` | `count_item`, `sum_weight` |
| `inventory_transactions` | ۹ | `BranchAllocationItem` | |
| `messages` | ۹ | `SmsMessage` | ⚠️ `massage` — غلط املایی |
| `notify` | ۱۳ | `Notification` + `NotificationRecipient` | `read`, `type_send` |
| `message_notification` | ۹ | `Notification` | 🔶 دو جدول اعلان |
| `config_statics` | ۶ | `Setting` | key/value · شامل `percent` کیف پول (D13) |
| `fav` | ۲ | ❌ **مدل ندارد** | `PWA-PORT-PLAN` می‌گوید جدول تازه لازم است |
| `logs` | ۱۲ | `CustomerActivityLog` | |
| `apk_log` | ۴ | 🟡 بی‌خانه | `ip`, `version` — آمار نسخه‌ی اپ |
### جداول کوچکی که مقصد ندارند — ✅ بررسی شد
هر شش جدول مدل Eloquent دارند و `$fillable`شان خوانده شد:
| legacy | `$fillable` | چیست | مقصد |
|---|---|---|---|
| `fav` | `customer_id`, `product_id` | علاقه‌مندی | ❌ **ندارد** |
| `cinies` | `title`, `icon_id`, `is_active`, `description`, `collection_id` | «سینی» — گروه‌بندی نمایشی محصول | ❌ ندارد |
| `ciny_products` | `product_id`, `ciny_id` | واسط سینی↔محصول | ❌ ندارد |
| `kids` | `phone`, `name`, `num` | فهرست فرزندان (تبریک تولد؟) | ❌ ندارد |
| `links` | `url`, `counter` | شمارنده‌ی کلیک لینک | ❌ ندارد |
| `request_fashions` | `name`, `mobile` | درخواست «مد» — فرم تماس | ❌ ندارد |
| `request_logs` | `customer_id`, `ip`, `device`, `data`, `user_agent`, `route` | لاگ درخواست | تقریباً `CustomerActivityLog` |
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۶ — علاقه‌مندی‌ها لازم‌اند
> «بله» — علاقه‌مندی‌ها مهم‌اند.
🔴 **تصحیح ۲۰۲۶-۱۰-۰۷: منبع درست `fav` نیست.** `fav` مال فروشگاه خرده است
(`Customer\FavController`، guard `api-singleCustomer`). اپ عمده
**`major_favorites`** (۳٬۳۰۵ ردیف) را می‌خواند — §۱۰.۷. متن زیر درباره‌ی
`fav` نوشته شده؛ تصمیم مالک و مدل `CustomerFavorite` همان می‌مانند، فقط منبع
عوض شد.
**یک migration افزایشی لازم است.** چون additive است، با `PROTECTED.md` §G
تضادی ندارد و قابلیت موجودی را لمس نمی‌کند.
مدل پیشنهادی — ساده‌ترین چیزی که legacy را پوشش می‌دهد:
```prisma
model CustomerFavorite {
customerId String
customer Customer @relation(fields: [customerId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@id([customerId, productId])
}
```
⚠️ `fav` در legacy **فقط دو ستون دارد و هیچ PK ندارد** — پس ردیف تکراری
ممکن است. `@@id([customerId, productId])` این را می‌بندد، ولی مهاجرت باید
`DISTINCT` بزند وگرنه روی کلید مرکب می‌شکند.
⚠️ `Product.countFav` در legacy یک شمارنده‌ی denormalized بود
(`product.count_fav`). اگر مقصد آن را ندارد، از `CustomerFavorite` محاسبه
شود — نه اینکه صفر نشان داده شود (§۵.۲ گروه ب).
🔶 **`cinies`/`ciny_products` هنوز تصمیم ندارند.** ساختار نمایشی‌اند
(`title`, `icon_id`, `collection_id`) — اگر صفحه‌ی خانه‌ی اپ از آن‌ها
می‌خواند، نبودشان چیدمان را خالی می‌کند. این **داده‌ی کاربر نیست**، پس
فوریتش کمتر از `fav` است.
`kids`, `links`, `request_fashions` کم‌اهمیت‌اند و رهاکردنشان قابل‌دفاع است.
---
## ۱۰.۴ — نتایج پیش‌بررسی روی داده‌ی واقعی (۲۰۲۶-۱۰-۰۶)
خروجی [`migration-precheck.sql`](./migration-precheck.sql). این بخش **فرض‌های
بخش‌های قبل را با عدد واقعی تأیید یا رد می‌کند**.
### حجم کار — کوچک‌تر از انتظار
| جدول | زنده | حذف‌شده‌ی نرم | نکته |
|---|---|---|---|
| `customers` | **۱٬۶۱۶** | ۲۱ | |
| `users` | ۲۲ | ۰ | ادمین‌ها |
| `orders` | ۵۰۸ | **۲۰۸** | ۲۹٪ حذف‌شده |
| `order_items` | ۱٬۰۶۷ | **۴۵۳** | ۳۰٪ |
| `cashes` | ۶۴۸ | **۲۶۵** | ۲۹٪ |
| `product` | ۳٬۲۸۷ | **۲٬۰۱۵** | **۳۸٪ حذف‌شده** |
| `variant` | ۷٬۴۷۴ | **۶٬۱۹۴** | 🔴 **۴۵٪ حذف‌شده** |
| `image` | **۱۸٬۰۷۷** | ۰ | فایل‌ها باید کپی شوند |
| `messages` | **۲۴٬۴۸۰** | ۰ | بزرگ‌ترین جدول |
| `fav` | ۲۵۱ | — | |
| `permission` | ۷۲۸ | — | ۱۴۴ rid × چند `ut` |
🔴 **soft-delete حجم بالایی دارد** — ۶٬۱۹۴ واریانت و ۲٬۰۱۵ محصول حذف‌شده‌اند.
§۵.۸ می‌گوید مقصد `deleted_at` ندارد. پس قاعده‌ی
`WHERE deleted_at IS NULL` **نه یک احتیاط، بلکه تفاوت بین ۱٬۲۸۰ و ۷٬۴۷۴
واریانت** است. بدون آن، کاتالوگ شش برابر می‌شود و محصولات حذف‌شده زنده
برمی‌گردند.
### ✅ خبر خوب — سه نگرانی منتفی شد
| نگرانی | پیش‌بینی | واقعیت |
|---|---|---|
| کد ملی تکراری | محتمل | **۰ گروه** ✅ |
| `name` ناسازگار با اجزا | نامعلوم | **فقط ۴ ردیف** ✅ |
| اجزای نام تهی | نامعلوم | **۰** ✅ — §۲ کامل تأیید شد |
| `fav` تکراری | محتمل | **۰** ✅ |
| `permission` تکراری | محتمل | **۰** ✅ |
| `ayar` غیرعددی | محتمل | **۰ از ۳۳** ✅ تبدیل به `Int` بی‌خطر |
| یتیم `city_id`/`image_id`/سفارش/سند | نامعلوم | **۰** ✅ |
| `customers_copy1/2` ردیف یکتا | محتمل | **۰** ✅ بی‌خطر نادیده بگیرید |
**§۲ قطعی شد:** `empty_fname=0`, `empty_lname=0`, `parts_empty_name_set=0`.
یعنی **هر ۱٬۵۹۵ ردیف اجزای نام پر دارند** و تجزیه‌ی `name` مطلقاً لازم
نیست. D0 با اطمینان کامل تصحیح می‌شود.
### 🔴 سه مسدودکننده‌ی تأییدشده
**۱. موبایل تکراری — ۲۷ گروه، ۵۹ ردیف.**
`@unique` روی `mobile` **قطعاً می‌شکند**. تصمیم مالک: «مسئول فروش برنده است».
🔶 ولی اگر هر دو ردیفِ تکراری مشتری عادی باشند (نه کارشناس)، این قاعده
جواب نمی‌دهد. قاعده‌ی پشتیبان لازم است — پیشنهاد: **ردیف با
`updated_at` جدیدتر برنده، بقیه با پسوند در جدول نگاشت ثبت شوند تا دستی
بررسی شوند.**
**۲. کد ملی تهی — ۱٬۵۹۵ از ۱٬۵۹۵ ردیف... یعنی همه.**
`empty_ncode = 1595` و `n_customers = 1595`. **هیچ مشتری کد ملی ندارد.**
ولی Prisma:
```prisma
nationalCode String @unique // ← NOT NULL
```
🔴 **مهاجرت روی اولین ردیف می‌شکند.** و `@unique` روی رشته‌ی خالی یعنی
فقط **یک** ردیف می‌تواند `''` باشد.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — nullable شود
> «وقتی دیتاست مبدا فاقد این داده است، هیچ گزینه دیگری جز Nullable کردن
> فیلد وجود ندارد. ساختن کد ملی فیک تخلف دیتایی است و مانع از
> احراز هویت‌های قانونی بعدی می‌شود.»
```prisma
nationalCode String? @unique // بود: String @unique
```
⚠️ **نکته‌ی فنی که به نفع این تصمیم است:** در Postgres مقدار `NULL` در
ایندکس `@unique` **تکراری حساب نمی‌شود**. پس هزار ردیف با
`nationalCode = NULL` بی‌مشکل کنار هم می‌نشینند — ولی اگر به‌جای `NULL`
رشته‌ی خالی `''` بگذاریم، **فقط یکی جا می‌شود**.
🔴 **قاعده‌ی اجباری ابزار: `''` را به `NULL` تبدیل کند، نه اینکه خالی
بفرستد.**
**۳. گِردکردن رخ می‌دهد — ۲۴ + ۳۲ ردیف.**
`gold_needs_rounding = 24` و `order_weight_needs_rounding = 32`. یعنی §۴
درست بود: **«صفر اختلاف» دست‌نیافتنی است** و آستانه‌ی عددی لازم است.
مبنای تطبیق برای ابزار:
```
sum_gold_debt 427.5041945329501 ← ۱۶ رقم معنادار، شاهد خودِ مشکل
sum_rial_debt 250000
sum_credit_limit 423623.33999999997 ← .33999999997 یعنی 423623.34
n_nonzero_gold 35 n_negative_gold 12
```
⚠️ **۱۲ مشتری بدهی طلای منفی دارند** (طلبکارند). مقصد `Decimal` علامت‌دار
است، پس مشکلی نیست — ولی تطبیق باید علامت را هم چک کند.
### 🔴 `cashes` — همه‌ی ستون‌های مبلغ `NULL` هستند
```
cash_amount_needs_rounding NULL
confirmed_only sum_gold=NULL sum_rial=NULL n=375
```
`SUM()` فقط وقتی `NULL` برمی‌گرداند که **هیچ ردیف غیر‌NULL نباشد**. یعنی
`cashes.remain` و `cashes.rial_remain` در **هر ۶۴۸ ردیف** `NULL` هستند.
پس مبلغ سند مالی در این دو ستون **نیست**. جای دیگری است — `value`,
`original_value`, `input`/`output`, یا `price`. §۸ نگاشت
`remain/input/output → amount + direction` را پیشنهاد داده بود؛
**این نگاشت باید بازبینی شود.**
🔶 **یک کوئری تکمیلی لازم است** (§۱۰.۶) تا معلوم شود مبلغ در کدام ستون است.
بدون این، `LedgerEntry.amount` صفر یا `NULL` پر می‌شود و **کل دفتر مالی
خالی منتقل می‌شود**.
✅ نکته‌ی خوب: `confirmed=1` در ۳۷۵ و `confirmed=0` در ۸ ردیف — D24
(«فقط تأییدشده‌ها در موجودی») قابل اجراست. مجموع ۳۸۳ از ۶۴۸، پس ۲۶۵ ردیف
`confirmed` تهی دارند که با حذف‌شده‌ها هم‌خوانی دارد.
### 🔴 رمز عبور — ۱٬۵۹۰ از ۱٬۵۹۵ تهی
```
pw_bcrypt 5 pw_other 0 pw_empty 1590
```
**تقریباً هیچ مشتری رمز ندارد.** منطقی است: ورود مشتری با **OTP** است، نه
رمز. ولی Prisma:
```prisma
passwordHash String // ← NOT NULL
```
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — hash غیرقابل‌ورود، nullable نشود
> «برداشتن اجبار رمز (Nullable کردن پسورد) یک ضدالگوی امنیتی است و احتمال
> بای‌پس شدن احراز هویت در باگ‌های آینده را بالا می‌برد. نباید صرفاً یک
> رشته تصادفی پلین یا هش ضعیف بگذارید.»
**`passwordHash` اجباری می‌ماند.** مقدارش برای ۱٬۵۹۰ ردیف یک hash
**عامدانه غیرقابل‌ورود** می‌شود.
🔴 **تفکیک مهمی که مالک درست روی آن تأکید کرد:** «رمز تصادفی» و «hash
غیرقابل‌ورود» یکی نیستند.
| روش | نتیجه |
|---|---|
| ❌ رشته‌ی تصادفیِ خام در ستون | `bcrypt.compare()` همیشه `false` می‌دهد — **ولی** قالبش نامعتبر است و بعضی کتابخانه‌ها در این حالت throw می‌کنند یا رفتار نامعین دارند |
| ❌ hash یک رمز تصادفی | یک رمز **واقعی** وجود دارد که کار می‌کند؛ اگر جایی لاگ شده باشد، در دسترس است |
| ✅ **قالب نامعتبرِ عامدانه** | هیچ رمزی به آن نمی‌خورد، **و** قالبش صریحاً «غیرفعال» را اعلام می‌کند |
**روش پیشنهادی — پیشوند `!` که در Django و Rails قرارداد است:**
```
!OTP_ONLY_NO_PASSWORD_LOGIN
```
چرا کار می‌کند: `bcrypt.compare(anyPassword, "!OTP_ONLY...")` هرگز `true`
نمی‌دهد چون رشته با `$2a$`/`$2b$`/`$2y$` شروع نمی‌شود، پس **هیچ رمزی
نمی‌تواند با آن مطابقت کند**. و برخلاف رشته‌ی تصادفی، **خواندنی است** —
هر کسی که ستون را ببیند می‌فهمد این کاربر فقط OTP دارد.
### ⚠️ بررسی کد مقصد — نگرانی باریک‌تر از آن است که به‌نظر می‌رسد
مالک نگران «بای‌پس شدن احراز هویت در باگ‌های آینده» بود. کد مقصد خوانده
شد و دو واقعیت پیدا شد:
**۱. مشتری اصلاً مسیر ورود با رمز ندارد.**
`grep` روی کل `apps/api/src` نشان داد تنها دو جای `bcrypt.compare` روی
رمز اینهاست: `auth.service.ts:103` و `:135` — و هر دو داخل
`AuthService.login()` هستند که `prisma.adminUser.findUnique()` می‌خواند
(`auth.service.ts:40`)، **نه `customer`**.
ورود مشتری در `customer-auth.service.ts` است و تنها `bcrypt.compare`
آن روی `challenge.codeHash` است (خط ۳۶۱) — **کد OTP، نه رمز**.
`Customer.passwordHash` در هیچ مسیر احراز هویتی خوانده نمی‌شود.
**۲. مقصد از قبل `passwordHash: ''` می‌نویسد.**
در **پنج جا**: `customer-auth.service.ts:261`,
`admin-customers.service.ts:626,690`, `accounting-sync.service.ts:211`,
`admin-branches.service.ts:330`.
یعنی مشتری‌هایی که **همین حالا** از طریق OTP یا پنل ادمین ساخته می‌شوند،
رشته‌ی خالی دارند. **این وضع موجود است، نه چیزی که مهاجرت معرفی کند.**
### پس نتیجه
| | |
|---|---|
| مقدار پیشنهادی مهاجرت | `!OTP_ONLY_NO_PASSWORD_LOGIN` |
| ریسک بای‌پس | **صفر** — مشتری مسیر ورود با رمز ندارد |
| چرا با این حال انجام شود | **دفاع در عمق** (خواست مالک) + خوانا بودن |
✅ **و یک سود جانبی:** چون مقصد الان `''` می‌نویسد، این روش **بهتر** از
وضع موجود است. پیشنهاد می‌کنم آن پنج جا هم به همین مقدار تغییر کنند تا
یکدست شود — ولی **این تغییر کد است، نه مهاجرت**، و اجازه‌ی جداگانه
می‌خواهد.
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — محافظ در لایه‌ی سرویس
> «تست واحد الزامی است؛ اگر متد کرش کند، باید در لایه سرویس مقصد چک شود
> که اگر فرمت هش نامعتبر یا پیشوند `!` بود، مستقیماً بدون فراخوانی bcrypt
> خروجی false برگردد.»
**پذیرفته شد — و این روش از تست‌کردن تنها بهتر است**، چون به رفتار
کتابخانه وابسته نمی‌ماند:
```ts
// قبل از هر فراخوانی bcrypt
if (!passwordHash || !passwordHash.startsWith('$2')) {
return { ok: false }; // اکانت فقط-OTP یا هش نامعتبر
}
return { ok: await bcrypt.compare(dto.password, passwordHash) };
```
جای اعمال: `auth.service.ts:103` و `:135` (هر دو مسیر `verifyPassword`).
⚠️ **این محافظ از `''` فعلی هم محافظت می‌کند** — یعنی باگی را می‌بندد
که همین حالا وجود دارد، مستقل از مهاجرت.
🔶 **تست واحد همچنان لازم است** (خواست مالک)، ولی نقشش عوض شد: دیگر
«آیا bcrypt کرش می‌کند؟» نیست، بلکه «آیا محافظ قبل از bcrypt جلو را
می‌گیرد؟». در این محیط نتوانستم اجرا کنم (بررسی‌کننده‌ی سمت سرور خطا
داد)، پس **ادعا نمی‌کنم تست شده است**.
### 🔶 تسک رفکتور جداگانه — ثبت شد
> «تغییر آن پنج جا در اسکوپ پایپ‌لاین مهاجرت نیست، اما به عنوان یک تسک
> رفکتور ثبت شود تا در یک مرج ریکوئست جداگانه اصلاح گردد.»
**ثبت شد.** پنج جایی که `passwordHash: ''` می‌نویسند:
| فایل | خط |
|---|---|
| `customer-auth.service.ts` | ۲۶۱ |
| `admin-customers.service.ts` | ۶۲۶ · ۶۹۰ |
| `accounting-sync.service.ts` | ۲۱۱ |
| `admin-branches.service.ts` | ۳۳۰ |
**خارج از اسکوپ مهاجرت.** PR جداگانه، بعد از مهاجرت.
⚠️ آن ۵ رمز `$2y$` (bcrypt) هستند و مقصد هم bcrypt است، پس **بدون
تغییر منتقل می‌شوند** و همان‌طور کار می‌کنند.
### enum ها — دو نکته
**`type IS NULL` → ۵۸ ردیف.** §۵.۳ هشدار داده بود. این ۵۸ بی‌صدا
`APP_SHOPKEEPER` می‌شوند. 🔶 تصمیم مالک: درست است یا قاعده‌ی دیگری لازم
است؟ (۵۸ از ۱٬۶۱۶ ≈ ۳.۶٪)
**`sts` و `active` مقدار `-1` دارند، نه `0`.**
`sts: 1→۱۱۹۱, -1→۴۰۴` و `active: 1→۱۳۶۸, -1→۲۲۷`.
نگاشت `1→true`, `-1→false` که §۵.۱ نوشته بود **درست است** ✅.
**`orders.sts`** هر ۷ مقدار `0..6` را دارد، هیچ `7` — پس نگاشت
`OrderStatus` کامل است ✅ و `CANCELLED` در عمده استفاده نشده.
**`cashes.type`** هر ۵ مقدار (`1,2,3,4,9`) موجودند و همه نگاشت دارند ✅.
### تک‌فروشی (به تأخیر افتاده) — تأیید خطر
```
orders_orders 520 live retail_sts: 7 → ۳ ردیف
```
**۳ ردیف `sts=7` وجود دارند** — یعنی خطر §۷.۳ واقعی بود: اگر روزی منتقل
شوند، ۳ سفارشِ «تحویل مشتری» به `CANCELLED` تبدیل می‌شوند. ثبت شد برای
آینده.
### ⚠️ ۱۴ یتیم در `fav`
`fav.customer_id → customers` ۱۴ یتیم دارد. چون `fav` خودش PK ندارد و FK
هم نیست، این‌ها ردیف‌هایی‌اند که مشتری‌شان حذف شده. **در مهاجرت باید
نادیده گرفته شوند** وگرنه FK مقصد می‌شکند.
---
## ۱۰.۵ — 🔴 مهاجرت `merge` است، نه `import`
**تصمیم مالک ۲۰۲۶-۱۰-۰۶:** داده‌ی حسابداری و تصویر واریانت در سیستم جدید
**واقعی است**. پس دیتابیس مقصد خالی نیست.
این فرض بنیادی سند را عوض می‌کند:
| | `import` (فرض اولیه) | **`merge` (واقعیت)** |
|---|---|---|
| مقصد | خالی | **داده‌ی واقعی دارد** |
| خطا در میانه | دور بریز، از نو | 🔴 **داده‌ی واقعی خراب شد** |
| تکرارپذیری | آزاد | فقط با idempotency واقعی |
| `UPDATE` کامل ردیف | بی‌خطر | 🔴 **ممنوع** |
### ستون‌هایی که ابزار مهاجرت هرگز نباید بنویسد
این‌ها در legacy **هیچ معادلی ندارند** و از سامانه‌های مقصد پر می‌شوند.
یک `upsert` معمولی یا `UPDATE` کامل، همه را `NULL` می‌کند.
| ستون | مدل | پر می‌شود از | اگر پاک شود |
|---|---|---|---|
| `accountingGoldBalance` | `Customer` | نرم‌افزار حسابداری (D27) | موجودی طلای مشتری گم می‌شود |
| `accountingRialBalance` | `Customer` | نرم‌افزار حسابداری | موجودی ریالی گم می‌شود |
| `accountingBalanceAt` | `Customer` | نرم‌افزار حسابداری | زمان آخرین sync گم می‌شود |
| `accountingStockWeight` | `Product` | نرم‌افزار حسابداری | موجودی وزنی کارساخته گم می‌شود |
| `accountingStockCount` | `Product` | نرم‌افزار حسابداری | موجودی تعدادی گم می‌شود |
| `accountingStockAt` | `Product` | نرم‌افزار حسابداری | زمان آخرین sync گم می‌شود |
| **`imageId`** | **`ProductVariant`** | **انتخاب دستی ادمین** | **تصویر هر واریانت گم می‌شود** |
به‌علاوه **سه جدول کامل** که هیچ مبدأ legacy ندارند و باید کاملاً نادیده
گرفته شوند: `AccountingLink`, `AccountingOutbox`, `AccountingIssue`.
### 🔴 `ProductVariant.imageId` — حدس زده نشود
`products_productvarient` در legacy **هیچ ستون تصویر ندارد** (۲۵ ستون،
بررسی شد). پس این قابلیت **مقصد-تنها** است و `PROTECTED.md` §B آن را
«additive only» اعلام کرده.
**قاعده: مهاجرت `imageId` را لمس نمی‌کند — نه می‌نویسد، نه `NULL` می‌کند.**
اگر ابزار بخواهد از `file_id` محصول حدس بزند، تصویرِ انتخابیِ ادمین با
حدس جایگزین می‌شود.
⚠️ `ProductImage` با `onDelete: SetNull` به واریانت وصل است. پس اگر مهاجرت
تصاویر محصول را **حذف و از نو بسازد** (الگوی رایج در sync)، `imageId` هر
واریانت بی‌صدا `NULL` می‌شود — بدون هیچ خطایی. **تصاویر باید
افزایشی به‌روز شوند، نه جایگزین.**
### قاعده‌ی اجباری ابزار
> ابزار مهاجرت **فقط ستون‌هایی را می‌نویسد که نگاشت صریح legacy دارند.**
> فهرست ستون‌های نوشتنی **سفید** است (allow-list)، نه سیاه.
همان الگوی «ارسال تفاضلی» که در `CHANGELOG-PORT-HISTORY.md` §۵ برای فرم
مشتری اعمال شد — چون همان اشتباه (`Object.assign` ناقص) آنجا هم چهار ستون
را پاک می‌کرد.
### ✅ کلید تطبیق — تصمیم مالک ۲۰۲۶-۱۰-۰۶
| سؤال | جواب |
|---|---|
| کلید تطبیق | **`mobile`** |
| در تعارض کدام برنده | **مسئول فروش** |
| اهمیت | **کم** — داده‌های مقصد فعلاً روی لوکال‌اند |
**نتیجه‌ی عملی:** چون داده‌ی مقصد لوکال است و اهمیتش کم، **ریسک `merge`
بسیار کمتر از آن است که §۱۰.۵ فرض کرده بود.** ولی قاعده‌ی allow-list
**همچنان لازم است**، به دو دلیل:
1. همان ابزار بعداً روی **staging و تولید** اجرا می‌شود (`GO-LIVE §۸`)،
آنجا داده واقعی است
2. تصویر واریانت و اتصال حسابداری همین الان **واقعی** هستند (گفته‌ی مالک)
⚠️ **تداخل `mobile` با کلیدِ تطبیق تعارض دارد.** ۲۷ گروه موبایل تکراری در
مبدأ وجود دارد (§۱۰.۴). اگر کلید تطبیق `mobile` باشد، **برای ۵۹ ردیف
معلوم نیست به کدام ردیف مقصد وصل شوند.** قاعده‌ی «مسئول فروش برنده» فقط
۱۰ مورد تداخل `customers`↔`users` را حل می‌کند، نه ۲۷ گروه داخلی را.
🔶 قاعده‌ی پشتیبان پیشنهادی: در گروه تکراری، ردیف با `updated_at` جدیدتر
برنده؛ بقیه منتقل می‌شوند ولی در گزارش «نیاز به بررسی دستی» فهرست شوند.
چون اهمیت کم است، می‌توان این را به گزارش سپرد و دستی حل کرد.
---
## ۱۰.۵.۵ — فهرست کامل تغییرات افزایشی مقصد
همه‌ی تصمیم‌های مالک تا ۲۰۲۶-۱۰-۰۷، در یک migration جمع می‌شوند.
**همه افزایشی‌اند** — هیچ ستونی حذف یا تغییر نوع نمی‌دهد، پس با
`PROTECTED.md` §G و قابلیت‌های حسابداری/تصویر واریانت/پیامک **تضادی
ندارند**.
```prisma
model Customer {
nationalCode String? @unique // بود: String — §۱۰.۴
birthDate DateTime? // legacy birth_date — §۵.۲
verificationRejectionReason String? // §۵.۵
verificationRejectedAt DateTime?
// cityNameRaw String? // §۵.۹ — منتظر جواب کوئری سوم
favorites CustomerFavorite[] // §۱۰
}
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
REJECTED // §۵.۵
}
model CustomerFavorite { // §۱۰ — legacy fav
customerId String
customer Customer @relation(fields: [customerId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@id([customerId, productId])
}
```
و یک تغییر کد (نه schema) در `auth.service.ts:103,135` — محافظ پیشوند
`$2` قبل از `bcrypt.compare` (§۱۰.۴).
⚠️ طبق D14: `migrate dev` در توسعه، و SQL **idempotent**
(`IF NOT EXISTS`) چون کانتینر مهاجرت‌ها را خودش اجرا می‌کند (D15).
### ✅ وضعیت اجرا — ۲۰۲۶-۱۰-۰۷
شاخه: `migration/legacy-data` (از `19d490e`؛ `release/delivery` دست نخورد).
| مورد | وضعیت | تأیید |
|---|---|---|
| migration `20261007120000_migration_prep_additive` | ✅ اعمال شد | `migrate diff` دیتابیس زنده ↔ schema: **خالی** |
| ۴ ستون `Customer` | ✅ همه nullable | `information_schema` |
| `CustomerFavorite` | ✅ ساخته شد | `to_regclass` |
| `VerificationStep.REJECTED` | ✅ | `pg_enum` · Postgres 15.19 |
| نوع پارامتر `accounting-sync.service.ts:233` | ✅ `string \| null` | رفتار درگاه‌ها صفر تغییر |
| محافظ `$2` در `auth.service.ts` | ✅ `compareLocalHash()` | ۴ تست تازه |
| ممیزی مصرف‌کنندگان `REJECTED` | ✅ | پایین |
| تست‌ها | ✅ **۱۷۸ از ۱۷۸** | ۱ suite از قبل خراب — پایین |
| API زنده | ✅ | عمومی ۲۰۰ · ادمین بی‌توکن ۴۰۱ · رمز غلط ۴۰۱ · رمز درست ۲۰۰ |
### 🔴 خطر فرایندی کشف‌شده — فایل migration = اعمال خودکار
**migration بدون تأیید اعمال شد — نه توسط من.** زنجیره:
1. سرور توسعه با `nest start --watch` اجرا می‌شود
2. ویرایش هر فایلی زیر `src/` سرور را ری‌استارت می‌کند
3. در boot، [`bootstrap.ts:35`](../apps/api/src/prisma/bootstrap.ts) تابع
`runMigrations()` را صدا می‌زند که **هر فایل معلق در `prisma/migrations`
را اعمال می‌کند** (D15)
ویرایش `accounting-sync.service.ts` (برای رفع خطای نوع) ری‌استارت را زد و
migration در `13:42:36` در `_goldbank_migrations` ثبت شد — پیش از تأیید
مالک. نتیجه درست و تأییدشده است، ولی **روال «اول نشان بده، بعد اجرا کن»
(D0) روی این ماشین کار نمی‌کند**.
**قاعده از این پس:** تا تأیید نشده، migration **بیرون از
`prisma/migrations`** نوشته شود (مثلاً `docs/` یا scratchpad)، یا سرور
توسعه متوقف باشد. فقط پس از تأیید به پوشه منتقل شود.
### ⚠️ دفتر CLI پریزما ۹ migration عقب است (از قبل)
`prisma migrate status` نُه migration را «اعمال‌نشده» می‌داند، از
`20260922000000_customer_otp` تا همین تازه — چون این ماشین migration ها را
از مسیر runner خود اپ (`_goldbank_migrations`) گرفته، نه CLI. دیتابیس
کامل است (diff خالی). **پیامد:** روی این ماشین `prisma migrate dev` اجرا
نشود — می‌خواهد ۹ فایل را دوباره اجرا کند. همگام‌سازی با
`prisma migrate resolve --applied <name>` برای هر ۹ مورد ممکن است، ولی در
`_prisma_migrations` می‌نویسد و **تأیید مالک می‌خواهد** (D14).
### ممیزی مصرف‌کنندگان `REJECTED`
| جا | پیش از رفع | وضعیت |
|---|---|---|
| `admin-customers.service.ts` `verifyStepOne()` | 🔴 `default` → `2` (**پذیرفته**). نما سوییچ‌های سفارش/حساب را روی `step_one === 2` باز می‌کند → **مشتری رد‌شده سوییچ باز می‌گرفت** | ✅ صریحاً `3` |
| همان، `verifyStepTwo()` | `default` → `3` (درست، تصادفی) | ✅ صریح |
| `ProfileView.vue` هر دو اپ | برچسب نداشت → کلمه‌ی انگلیسی `REJECTED` نمایش داده می‌شد | ✅ «رد شده» |
| فیلتر «تاییدنشده‌ها» (`:221`) | فقط `STEP_ONE/TWO_SUBMITTED` | ✅ بدون تغییر — رد‌شده‌ها **در صف برنمی‌گردند** (خواست مالک) |
| روتر هر دو اپ · دکمه‌ی تکمیل پروفایل | رد‌شده مثل `UNVERIFIED` رفتار می‌شود | ✅ مطابق legacy — پایین |
`default` در هر دو تابع **حذف شد** تا switch جامع باشد: اگر روزی مقدار
ششمی به enum اضافه شود، TypeScript خطا می‌دهد و همین باگ بی‌صدا تکرار
نمی‌شود.
### 🔶 دو سؤال برای مالک — از ممیزی
**۱. رد در سیستم جدید `REJECTED` نمی‌نویسد.**
`verify()` در `admin-customers.service.ts:955-969` هنگام رد:
گام ۱ → `UNVERIFIED`، گام ۲ → `STEP_ONE_SUBMITTED`. یعنی ۷ مشتری مهاجر
`REJECTED` می‌شوند، ولی **هر ردِ تازه در پنل همان کاری را می‌کند که مالک
برای مهاجرت «کاملاً مردود» خواند** (برگشت به صف). و
`verificationRejectionReason` که مالک «برای نیازمندی‌های آتی» خواست، از
هیچ مسیری پر نمی‌شود — فرم ادمین فیلد علت ندارد.
پیشنهاد: `verify()` هنگام رد `REJECTED` + `verificationRejectedAt`
بنویسد، و یک فیلد اختیاری علت به دکمه‌ی رد اضافه شود. **تغییر رفتار یک
جریان زنده است — تأیید لازم.**
**۲. مشتری رد‌شده با ارسال دوباره‌ی مدارک خودکار تأیید می‌شود.**
legacy دکمه‌ی تکمیل پروفایل را فقط وقتی `sts_verify_step_two` برابر ۱ یا
۲ بود پنهان می‌کرد (طبق کامنت پورت در `ProfileView.vue:122-126`)، پس رد‌شده (۳) آن را
می‌دید؛ و ارسال، بی‌درنگ `VERIFIED` می‌کند چون legacy خط «منتظر
کارشناس» را کامنت کرده بود (`customer-profile.service.ts:16-19`). پورت
همین است. **نتیجه: «رد» در عمل یک «دوباره بفرست» است، نه بستن پرونده.**
اگر مالک می‌خواهد رد واقعاً ببندد، این تصمیم محصولی است، نه پورت.
### ⚠️ `sms.service.spec.ts` از قبل خراب است
دو خطای `TS2339: Property 'platform' does not exist` (خطوط ۸۳، ۱۰۲). این
فایل و `sms.service.ts` در این شاخه **دست نخورده‌اند** — خرابی در commit
`19d490e` است. به همین دلیل آن suite کامپایل نمی‌شود؛ ۱۷۸ تست بقیه سبزند.
خارج از اسکوپ این کار، ثبت می‌شود.
---
## ۱۰.۶ — پرسش دوم از دیتابیس
📄 **[`migration-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی
که خروجی اول باز گذاشت. مثل فایل اول: فقط `COUNT`/`SUM`، صفر نوشتن، هیچ
ستون متنی در خروجی (تنها استثنا: `products_productimage.w` که یک برچسب
اندازه است، نه داده‌ی شخصی).
| # | سؤال | چرا مسدودکننده است |
|---|---|---|
| ۱ | ~~مبلغ سند مالی در کدام ستون است؟~~ | ✅ **حل — §۱۰.۷** |
| ۲ | آیا «مسئول فروش برنده» برای ۲۷ گروه تکراری کافی است؟ | اگر گروهی کارشناس نداشته باشد، قاعده بی‌اثر است |
| ۳ | سه ستون تصویر → چهار نقش `ProductImageRole` | نگاشت غلط = تصویر غلط روی محصول |
| ۴ | تأیید §۵.۷ — `reagent_id` به `customers` اشاره می‌کند؟ | اگر غلط باشد، رابطه‌ی کارشناس معرف از بین می‌رود |
---
## ۱۰.۷ — یافته‌های دامپ کامل (`oapi.sql` — ۲۰۲۶-۱۰-۰۷)
`oapi.sql` (دامپ Navicat با داده، تاریخ ۰۴/۱۰/۲۰۲۶، ۶۳ مگابایت) در یک MySQL
محلی روی Docker بارگذاری و هر ادعا با کوئری روی همان داده بررسی شد. این
بخش ابتدا توسط نشست موازی `gold-bank-79` نوشته شد. **چند ادعای آن نسخه
غلط بود و این‌جا تصحیح شده است** (جدول پایین).
⚠️ دامپ **دو روز قدیمی‌تر** از اجرای پیش‌بررسی روی سرور زنده است (مثلاً
۷٬۴۴۳ تنوع این‌جا در برابر ۷٬۴۷۴ زنده). برای ساخت و آزمون کافی است.
**برای مهاجرت واقعی دامپ تازه گرفته شود.**
`oapi.sql` داده‌ی شخصی مشتریان است: در `.gitignore` قرار گرفت و هرگز commit
نشده است.
### تصحیح ادعاهای نسخه‌ی قبلی این بخش
| ادعای قبلی | واقعیت (کوئری روی داده) |
|---|---|
| `remain`/`rial_remain` صفرند | **NULL** هستند — در هر ۳۸۳ ردیف زنده؛ ساختار `DEFAULT NULL` دارد |
| `new_city` شناسه‌ی عددی `cities5` است | **هر ۱۷۲ مقدار نام شهرِ تایپ‌شده است؛ صفر مقدار عددی** — §۵.۹ |
| `birth_date` همه بین ۱۹۵۰ و ۲۰۱۰، بدون نیاز به پالایش | **۳۵۷ مورد بعد از ۲۰۱۰ تا ۲۰۳۱؛ ۱۹۰ مورد برابر تاریخ ثبت‌نام** |
| `type` ۳ و ۴ و ۵ نامعلوم، نیاز به تأیید مالک | در `Customer::$TYPES` (Customer.php:85-92) تعریف شده‌اند — §۵.۳ |
| `LedgerEntry.goldGrams = value`، `rialAmount = price` | این فیلدها وجود ندارند؛ مدل `amount` + `currency` + `direction` است |
| شمارش انواع سند: ۹ → ۴۸۲، ۲ → ۶۳، … | شامل حذف‌شده‌ها بود؛ زنده: ۹ → ۲۸۰، ۲ → ۳۷، ۱ → ۳۳، ۴ → ۳۲، ۳ → ۱ |
| علاقه‌مندی = `fav` (۲۵۱) | `fav` مال فروشگاه خرده است (guard `api-singleCustomer`)؛ اپ عمده **`major_favorites`** (۳٬۳۰۵) — پایین |
**درست بود:** مبلغ سند در `value` (گرم) و `price` (ریال) است؛ پوشه‌ی تصاویر
`F:\New folder (2)\storage` با **۱۷٬۸۳۲ فایل (۱.۸ گیگابایت)** وجود دارد.
### 🔴 فرمول بدهی legacy — با داده تأیید شد
`CustomerController::updateRemain` (Api/CustomerController.php:42-51):
```
remain = Σ orders.input (sts > 4) − Σ cashes.value (confirmed = 1, type 1–4)
rial_remain = Σ orders.rial_input (sts > 4)
```
هر دو مدل `SoftDeletes` دارند، پس حذف‌شده‌ها بیرون‌اند. بازمحاسبه روی
دامپ: **طلا ۱٬۵۹۰ از ۱٬۵۹۱ مشتری، ریال ۱٬۵۹۱ از ۱٬۵۹۱** با مانده‌ی ذخیره‌شده
برابر است. جمع کل ۴۲۷٫۵۰۴۲ گرم — همان عدد پیش‌بررسی روی سرور زنده.
سه پیامد که قبلاً دیده نشده بود:
1. **سند نوع ۹ («سفارش») در بدهی نیست.** بدهی از جدول `orders` می‌آید؛ نوع ۹
فقط آینه‌ی آن در فهرست است. انتقال هر دو = **دوبار شمردن بدهی هر سفارش**.
2. **ریال هرگز بدهی ریالی را کم نمی‌کند.** پرداخت پولی (نوع ۴) با قیمت روز به
گرم تبدیل می‌شود (`price ÷ (price_per_gram ÷ 4.331802)` — قیمت هر مثقال)
و بدهی **طلا** را کم می‌کند.
3. **چک (نوع ۳) اثری در مانده ندارد** — مقدار گرمی‌اش صفر است.
**تنها استثنا — مشتری `#125`:** مانده‌ی ذخیره‌شده −۹٫۸۹۵۱ گرم، بازمحاسبه صفر.
تنها سندش (۴۸٫۳۴ گرم آب‌شده) در ۲۰۲۱ حذف شده و مشتری در ۲۰۲۲ دوباره
ویرایش شده — مانده به‌احتمال زیاد **دستی** وارد شده (ستون در `$fillable`
بود، SEC-14). ✅ **تصمیم مالک ۲۰۲۶-۱۰-۰۷:** همان −۹٫۸۹۵۱ منتقل شود، به‌صورت
یک سند صریح «تراز انتقالی».
### ✅ تاریخ تولد — پالایش (تصمیم مالک ۲۰۲۶-۱۰-۰۷)
از ۹۱۵ تاریخ ثبت‌شده فقط **۵۴۱** واقعی است:
| دسته | تعداد | چرا کنار گذاشته شد |
|---|---|---|
| برابر روز ثبت‌نام | ۱۹۰ | پیش‌فرض فرم — در سال ۲۰۲۵ از ۱۷۶ مورد، ۱۶۵ |
| ساعت دارد یا سال بعد از ۲۰۰۸ | ۱۸۴ | تاریخ تولد نمی‌تواند باشد |
| **نگه داشته شد** | **۵۴۱** | |
### ✅ شهر — نگاشت دوگانه (تصمیم مالک ۲۰۲۶-۱۰-۰۷)
ثبت‌نام اپ روی **همه** `city_id = 5` (تهران) می‌گذاشت
(Major/CustomerController.php:21) و شهر واقعی را در `new_city` به‌صورت
متن. از ۹۲ مشتری که هر دو را دارند، ۹۱ همان تهرانِ اجباری‌اند.
- متن تایپ‌شده هست → عیناً در `Customer.cityNameRaw`؛ `cityId` فقط با تطابق
دقیق نام (پس از یکسان‌سازی «ي/ك») — تهرانِ اجباری نادیده گرفته می‌شود
- متن نیست → همان `city_id` قدیم
یکسان‌سازی «ي/ك» اهمیت داشت: تطابق با جدول ۱۵تایی از ۳۲ به ۷۲ رسید.
### نقش‌ها — تداخل شماره (`config_statics`)
نوع کاربر در legacy یک ردیف `config_statics` است (UserController.php:26).
عناوین: ۸۵ مدیر · ۸۶ **حسابدار** · ۱۰۲ ویرایشگر · ۱۰۴ مدیر اصلی · ۱۰۵ مدیران
· ۱۰۸ انباردار · ۱۳۲ بیکار · ۱۳۵ سئو · ۱۳۹ مدیر شعبه.
🔴 در مقصد (seed) **۸۶ = کارشناس فروش**. کپی شماره‌به‌شماره دسترسی ۷۱ گانه‌ی
حسابدار را به همه‌ی کارشناسان می‌داد. ابزار **با عنوان** نگاشت می‌کند
(۸۶ حسابدار → ۸۷ حسابدار)؛ فقط ۸۵ با ۸۵ برابر گرفته شده (هر دو نقش
تمام‌دسترسی‌اند).
۷ کارشناس فروش با موبایل یک کاربر ادمین مشترک‌اند — ۳ تا مدیر ارشد.
قاعده‌ی «کارشناس برنده» روی آن‌ها **مدیر ارشد را به کارشناس تنزل می‌داد و از
پنل بیرون می‌کرد**. ابزار نقش ادمین را نگه می‌دارد و معرفی‌ها به همان ادمین
وصل می‌شوند.
### علاقه‌مندی‌ها — جدول درست `major_favorites` است
`fav` (۲۵۱) را `Customer\FavController` زیر guard `api-singleCustomer`
(ورود مشتری خرده) می‌نویسد؛ `customer_id` آن به جدول دیگری اشاره دارد. اپ
عمده `Major\Favorite` = **`major_favorites`** (۳٬۳۰۵ ردیف) را می‌خواند
(ApiMobileController.php:73-465). ساختار `CustomerFavorite` بی‌تغییر جواب
می‌دهد؛ فقط منبع عوض شد. کامنت جدول در دیتابیس هنوز «legacy fav» می‌گوید —
در migration بعدی اصلاح شود.
### سایر یافته‌ها
- **زمان‌ها ساعت تهران‌اند** (`config/app.php:70` → `Asia/Tehran`). ابزار
با قواعد ICU تبدیل می‌کند، با ساعت تابستانی ایران تا ۲۰۲۲.
- **۴۷۳ از ۶۱۰ ردیف سفارش زنده** به تنوعِ حذف‌شده اشاره دارند → تنوع و محصول
حذف‌شده‌ی ارجاع‌شده با `isActive = false` منتقل می‌شوند.
- **همه‌ی ۱٬۲۵۰ محصولِ چنددسته** هم در درخت سایت (نوع ۱) هم اپ (نوع ۲)
هستند؛ مقصد یک `categoryId` دارد که اپ نشان می‌دهد → **دسته‌ی اپ** نگه
داشته می‌شود.
- **۲۷ گروه موبایل تکراری: در هیچ‌کدام بیش از یک ردیف پول ندارد** و هیچ
کارشناسی درگیر نیست → ادغام از نظر مالی بی‌خطر است.
- **سرپرست کارگاه** در legacy مشتری است (`belongsTo(Customer)`)، در مقصد
ادمین → پیوند ساخته نمی‌شود.
- **مشتری `#594`** به‌جای موبایل کلمه‌ی «تست» دارد؛ با یک سفارش (`#666`،
وضعیت ۱، بدون بدهی) کنار گذاشته می‌شود.
- **۶ تنوع** اجرت ریالی (`rial_pay`) دارند؛ مقصد فیلدی برایش ندارد.
- **عدد رد‌شده‌ها ۷ است، نه ۱۴** (۲ گام دو، ۳ گام یک، ۲ هر دو). عدد ۱۴ در
گزارش قبلی اشتباه جمع شده بود.
---
## ۱۰.۸ — ابزار انتقال (`apps/api/scripts/legacy-import/`)
### اجرا
```
cd apps/api
npx ts-node --transpile-only scripts/legacy-import/index.ts \
--source "mysql://USER:PASS@HOST:PORT/oapi" \
--target "postgresql://USER:PASS@HOST:5432/goldbank_import_test" \
--images "F:/New folder (2)/storage" --upload-dir "<UPLOAD_DIR مقصد>" \
--report ./legacy-import-report.md --yes
```
بدون `--yes` فقط پیش‌بررسی می‌کند و چیزی نمی‌نویسد. `--target` هرگز از
`.env` خوانده نمی‌شود. اگر نام دیتابیس مقصد `import` یا `staging` نداشته
باشد، `--confirm-db=<همان نام>` هم لازم است.
### قواعد ایمنی (همه در کد)
- منبع فقط خوانده می‌شود.
- قبل از اجرا وجود ستون‌های migration های آماده‌سازی چک می‌شود.
- هرگز نمی‌نویسد: `accounting*` مشتری و محصول، `ProductVariant.imageId`،
جداول `Accounting*`. هیچ `ProductImage`ی حذف نمی‌شود.
- ردیفی که از قبل در مقصد بوده (`matched`) فقط فیلدهای **خالی‌اش** پر می‌شود.
- مستقیم از Prisma می‌نویسد، نه از سرویس‌ها: **نه پیامکی می‌رود، نه چیزی به
نرم‌افزار حسابداری صف می‌شود.**
- نگاشت شناسه‌ها در schema جدای `legacy_import.id_map` در مقصد است (بیرون از
`public`، پس تغییر schema اپ نیست). اجرای دوباره به‌روزرسانی می‌کند، تکرار
نمی‌سازد؛ اجرایی که نیمه‌کاره ماند با اجرای بعدی تمام می‌شود. بعد از
جابه‌جایی: `DROP SCHEMA legacy_import CASCADE` — یا نگه داشته شود برای
redirect لینک‌های قدیمی محصول.
### نتیجه‌ی آزمون — ۲۰۲۶-۱۰-۰۷
مقصد: `goldbank_import_test`، کپی دیتابیس توسعه با ۵۹ مشتری و ۲۸ محصول
واقعی (تا ادغام هم آزموده شود). سه اجرای پیاپی:
| | نتیجه |
|---|---|
| مشتری ساخته‌شده | ۱٬۵۰۳ · ادغام با موجود ۱۳ · ادغام تکراری ۳۲ |
| بدهی طلا، مشتری‌های ساخته‌شده | **۱٬۵۰۳ از ۱٬۵۰۳ برابر legacy (±۰٫۰۰۰۱ گرم)** |
| بدهی ریال | **۱٬۵۰۳ از ۱٬۵۰۳** |
| جمع بدهی طلا | legacy ۴۲۷٫۵۰۳۹ · مقصد ۴۲۷٫۵۰۴۱ (دو گِرد ۰٫۰۰۰۱) |
| سند تراز انتقالی | فقط مشتری `#125`: بستانکار ۹٫۸۹۵۱ |
| داده‌ی حسابداری (۵۱ مشتری، ۱۸ محصول، ۷۴ پیوند) | **بایت‌به‌بایت یکسان** با دیتابیس توسعه |
| `imageId` روی تنوع وارداتی | **۰** |
| کارشناس | ۴۹ ساخته شد با پروفایل · ۸ حساب معاملاتی · ۷ ادمین تنزل **نیافت** |
| محصول / تنوع | ۱٬۴۷۳ / ۱٬۶۱۲ (شامل ۲۱۵ / ۳۴۷ حذف‌شده‌ی غیرفعال برای تاریخچه) |
| سفارش / ردیف | ۲۹۹ از ۳۰۰ (`#666` مشتری تست) / ۶۰۸ |
| سند مالی | ۸۶ بدهی سفارش + ۹۵ پرداخت تأییدشده + ۸ تأییدنشده |
| علاقه‌مندی | ۸۷۴ (۵۳۴ به محصول حذف‌شده و ۵۹ به مشتری منتقل‌نشده کنار رفت) |
| سفارش ساختنی | ۲۰ از ۲۰ |
| تصویر | ۶٬۱۹۶ فایل (۵۱۸ مگابایت) کپی شد · ۳ فایل در storage قدیم نبود |
| اجرای دوم و سوم | **صفر ردیف تکراری** |
### آنچه این ابزار عمداً منتقل نمی‌کند
خرده‌فروشی (`orders_orders`، `login`، `fav`، `wallets`، `transactions*` —
D13 و §۷.۲)، تاریخچه‌ی وضعیت سفارش (`order_activity`)، تخفیف‌ها
(`major_promotion*`)، اعلان‌ها، تاریخچه‌ی پیامک (`messages`)، لاگ‌ها،
نظرات، `cinies`. هرکدام لازم شد، قدم جدا اضافه می‌شود.
---
## ۱۰.۹ — «میزان فروش» و کارشناسِ هر سفارش (۲۰۲۶-۱۰-۰۸)
**مشکل:** کارفرما در مقایسه‌ی چشمی دید که «میزان فروش» چند کارشناس با سیستم
قدیم فرق دارد (مثلاً موسوی کیانی و ملاطایفه). دو علت داشت:
۱. سیستم جدید سفارش‌های **خود** کارشناس را نمی‌شمرد. قدیم می‌شمرد:
`updateGramSell`: `customer_id = X OR reagent_id = X`.
۲. سیستم جدید هر سفارش را به کارشناسِ **فعلیِ** مشتری نسبت می‌داد. قدیم آن را
به کارشناسِ **زمان ثبت** سفارش (`orders.reagent_id`) نسبت می‌داد. در
`ApiMobileController.php:601` این مقدار یک‌بار نوشته می‌شود و دیگر عوض نمی‌شود.
**حکم کارفرما:** رفتار قدیم دقیقاً حفظ شود. هر سفارش کارشناسِ خودش را در لحظه‌ی
ثبت نگه دارد، تا با جابه‌جایی بعدی مشتری، آمار فروش و کمیسیونِ گذشته عوض نشود.
«هیچ دیتایی نباید مغایرت داشته باشد».
**پیاده‌سازی:**
- **پایگاه داده:** مایگریشن `20261008120000_order_sales_agent` که فقط افزایشی است.
- `Order.salesAgentId` برای کارشناس سفارش.
- `Order.referrerCustomerId` فقط برای تاریخچه: یک مشتری غیرکارشناس، ۱۷ سفارش
سال ۲۰۲۲. قدیم آن‌ها را در «میزان فروش» همان مشتری می‌شمرد.
- **ثبت سفارش:** کارشناسِ همان لحظه‌ی مشتری را ذخیره می‌کند.
- **ابزار انتقال:** `orders.reagent_id` را عیناً می‌آورد.
- **محاسبه:** `agent-orders.ts` در هر چهار جا (لیست، جزئیات، داشبورد، اپ کارشناس).
- **صفحه‌ی مشتری:** «میزان فروش» به آن اضافه شد؛ در پورت جا افتاده بود.
- **فهرست سفارش‌های زیرمجموعه** در اپ کارشناس هم طبق همین قانون است
(`getChildOrder`).
- **مدیرهایی که قبلاً کارشناس بودند:** پروفایل کارشناسی‌شان نگه داشته شد تا
سفارش‌های خودشان به آن‌ها وصل بماند. نقش و دسترسی‌شان تغییری نکرد.
**نتیجه (`reconcile-sales`، روی نسخه‌ی آزمایشی):**
- **فروش:** ۱٬۵۵۷ از ۱٬۵۵۸ نفر دقیقاً برابر فرمول خود سیستم قدیم است. ردیف‌های
تکراریِ ادغام‌شده با هم مقایسه شدند.
- **بدهی:** همه‌ی ۱٬۵۰۳ نفر بدون تغییر ماندند.
**دو مورد که کارفرما تأیید کرد (۲۰۲۶-۱۰-۰۸، «درستن»):**
- **عدد کهنه در صفحه‌ی قدیم (۹ نفر):** صفحه‌ی قدیم عدد ذخیره‌شده‌ای را نشان
می‌دهد که فقط در بعضی رویدادها دوباره حساب می‌شود.
- نفرات: #62، #66، #622، #596، #597، #1138، #1198، #1418، #1579.
- سیستم جدید عددِ فرمولِ خود سیستم قدیم را نشان می‌دهد. **همین درست است.**
- **۱۵٫۲۵۵ گرم برای #413:** سفارش #666 مال حساب آزمایشی «تست» است که بدون
موبایل واقعی است و منتقل نمی‌شود. #413 الان مدیر است و در هیچ صفحه‌ای این عدد
را نمی‌بیند. **کنار گذاشتن آن پذیرفته شد.**
**هم‌زمان در همین تغییر (بدون اثر روی داده):**
- تاریخ ثبت در همه‌ی جدول‌های پنل شمسی شد، به شکل `1405/5/3-16:02`.
- «وضعیت ثبت سفارش» دوباره برچسب سبز/قرمزِ «فعال/عدم سفارش» را نشان می‌دهد.
---
## ۱۱. جمع‌بندی — کارهای لازم قبل از نوشتن ابزار
### 🔴 مسدودکننده (بدون این‌ها ابزار در میانه می‌شکند یا داده خراب می‌کند)
| # | مورد | § |
|---|---|---|
| ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) |
| ۱.۱ | ~~`nationalCode`~~ | ✅ **حل — nullable شود** (§۱۰.۴) |
| ۱.۲ | ~~`passwordHash`~~ | ✅ **حل — hash غیرقابل‌ورود** (§۱۰.۴) |
| ۱.۳ | ~~مبلغ سند کجاست~~ | ✅ **حل — فرمول بدهی legacy با داده تأیید شد** (§۱۰.۷) |
| ۱.۴ | ~~`REJECTED`~~ | ✅ **حل — enum + دو ستون علت** (§۵.۵) |
| ۱.۵ | ~~ترتیب دومرحله‌ای~~ | ✅ **در ابزار** — کارشناسان قبل از مشتریان (§۱۰.۸) |
| ۱.۶ | ~~`birth_date`~~ | ✅ **حل — پالایش، ۵۴۱ از ۹۱۵** (§۱۰.۷) |
| ۱.۷ | ~~`customers.type` ۳/۴/۵~~ | ✅ **سؤال لازم نبود** — در `Customer::$TYPES` تعریف شده‌اند (§۵.۳) |
| ۲ | ~~آستانه‌ی تطبیق~~ | ✅ **در ابزار** — ±۰٫۰۰۰۱ گرم، ±۱ ریال (§۱۰.۸) |
| ۳ | ~~قاعده‌ی `deleted_at`~~ | ✅ **در ابزار** — به‌جز حذف‌شده‌های ارجاع‌شده در سفارش (§۱۰.۷) |
| ۴ | ~~تصمیم `orders_orders`~~ | ✅ **حل — به تأخیر افتاد** (§۷.۲) |
| ۵ | ~~تصحیح D0~~ | ✅ **در ابزار** — `fname`/`lname` مستقیم |
| ۶ | ~~allow-list ستون‌ها~~ | ✅ **در ابزار** — حسابداری بایت‌به‌بایت یکسان ماند (§۱۰.۸) |
| ۷ | ~~کلید تطبیق~~ | ✅ **موبایل** (مالک) — ادمین تنزل نمی‌یابد (§۱۰.۷) |
### 🔶 تصمیم مالک
| # | مورد | § |
|---|---|---|
| ۱ | ~~۴ ستون داده‌ی غیرقابل‌بازسازی~~ | ✅ **حل — تولد و شهر منتقل، توکن و سالگرد حذف** (§۵.۲) |
| ۱.۱ | ~~`new_city`~~ | ✅ **حل — نگاشت دوگانه: متن در `cityNameRaw` + `cityId` با تطابق دقیق** (مالک · §۱۰.۷) |
| ۲ | ~~تفکیک `STEP_TWO_SUBMITTED` از `VERIFIED`~~ | ✅ **حل** (§۵.۵) |
| ۳ | ~~`type IS NULL`~~ | ✅ **`APP_SHOPKEEPER`** — ثبت‌نام اپ `type = 2` می‌گذارد (Major/CustomerController.php:44)؛ NULL قدیمی‌تر از آن است |
| ۴ | ~~علاقه‌مندی~~ | ✅ **حل — از `major_favorites`، نه `fav`** (§۱۰.۷) |
| ۴.۲ | ~~مشتری `#125`~~ | ✅ **همان −۹٫۸۹۵۱ با سند تراز انتقالی** (مالک · §۱۰.۷) |
| ۴.۳ | 🔶 **رد در پنل جدید `REJECTED` نمی‌نویسد** و مشتری رد‌شده با ارسال دوباره خودکار تأیید می‌شود | §۱۰.۵.۵ |
| ۴.۴ | 🔶 **مدیر ارشدی که کارشناس هم بود** — نقش ادمین نگه داشته شد؛ آیا حساب کارشناسی هم لازم است؟ | §۱۰.۷ |
| ۴.۱ | `cinies`/`ciny_products` — چیدمان نمایشی | ۱۰ |
| ۵ | شمارنده‌های denormalized محاسبه شوند یا صفر بمانند | ۵.۲ |
| ۶ | نسخه‌های تغییراندازه‌یافته‌ی تصویر | ۹ |
| ۷ | `original_rash_weight`/`original_percent` — تاریخچه‌ی ممیزی قلم سفارش | ۷.۴ |
| ۸ | snapshot عنوان محصول در فاکتورهای تاریخی | ۷.۴ |
### ✅ یافته‌های جانبی (ثبت، بدون کار)
- `users.permission decimal(16,16)` ستونی است که ریاضیاً کار نمی‌کند — مرده (§۶)
- `products_productvarient.Inventory` با I بزرگ، برخلاف `$fillable` (§۹)
- نام جدول `varient` غلط املایی دارد (§۹)
- `messages.massage` غلط املایی دارد (§۱۰)
- `cities.percent` معنای **چهارم** `percent` است (§۹)
- `orders_histroy` غلط املایی دارد
- صفر foreign key در کل ۸۰ جدول — یکپارچگی ارجاعی در اپ بود، نه دیتابیس
---
## ۱۲. آنچه این سند تأیید نمی‌کند
⚠️ **دامپ داده ندارد.** هر عددی در این سند از *ساختار* آمده. تعداد ردیف،
تکرار، تهی‌بودن و اختلاف محاسباتی **همه داده‌ی واقعی لازم دارند**.
⚠️ **`ayar` و `recive_date`/`proceed_at` بر اساس *نوع* ستون تحلیل شدند، نه
محتوا.** اینکه عیار متنی است یا عددی، و تاریخ جلالی است یا میلادی، فقط با
دیدن داده قطعی می‌شود.
⚠️ **ستون‌به‌ستونِ کامل فقط برای `customers` نوشته شد** (۴۹/۴۹). بقیه‌ی
جداول در سطح «نگاشت‌شدنی / بی‌خانه / مبهم» بررسی شدند. برای جداول بزرگ
(`cashes` ۳۳، `product` ۳۵، `orders_orders` ۳۷) اگر لازم است، ستون‌به‌ستون
در ادامه نوشته می‌شود.
⚠️ **موتور مقصد Postgres است و مبدأ MySQL** — تفاوت‌های حساسیت به بزرگی
حرف، `tinyint(1)` در برابر `boolean`، و `timestamp` با/بدون منطقه‌ی زمانی
هنگام نوشتن ابزار باید یک‌به‌یک بررسی شوند.
---
## مرجع
- [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷ — ابزار مهاجرت و شرط‌های تطبیق
- [`parity/DECISIONS.md`](./parity/DECISIONS.md) — D0, D9, D13, D17, D21, D24
- [`parity/MIGRATION-STATE.md`](./parity/MIGRATION-STATE.md) — سه معنای `percent`
- [`parity/ISSUES.md`](./parity/ISSUES.md) — MON-07 (دو enum وضعیت)
- [`PWA-PORT-PLAN.md`](./PWA-PORT-PLAN.md) — Q5 (ترتیب دسته‌ها)، نقص #۱۹