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

1822 lines
102 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نگاشت دیتابیس 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 (ترتیب دسته‌ها)، نقص #۱۹