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

1155 lines
61 KiB
Markdown
Raw Normal View History

# نگاشت دیتابیس legacy → Prisma
> سند **نگاشت**، نه پلن اجرا. هیچ کدی بر اساس این سند نوشته نشده.
> پیش‌نیاز ابزار مهاجرت داده‌ی [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷.
**منبع:** `oapi-struncture.sql` — دامپ Navicat، تاریخ ۰۵/۱۰/۲۰۲۶
**مقصد:** `apps/api/prisma/schema.prisma` — ۴۳ مدل، ۴۷۷ فیلد
**تاریخ نگارش:** ۱۴ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۰۶)
---
## ۰. مشخصات منبع
| ویژگی | مقدار |
|---|---|
| موتور | **MySQL 8.0.42** (`8.0.42-0ubuntu0.20.04.1`) |
| سرور | `Henza` · `localhost:3305` |
| اسکیما | `oapi` |
| جداول | **۸۰** |
| ستون‌ها | **۱٬۰۸۷** |
| Engine | `InnoDB` (هر ۸۰ جدول) |
| محتوا | **فقط ساختار — صفر `INSERT`** |
این فایل شکافی را پر کرد که قبلاً باز بود: جداول دوران 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`. **این‌ها اگر منتقل نشوند، برای همیشه از بین
می‌روند.** `token_mobile` در ۱۰ فایل استفاده می‌شود؛ بدون آن، اعلان push به
گوشی‌های موجود قطع می‌شود.
🔶 **تصمیم مالک لازم است برای گروه (ج).**
`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` شوند، **از «رد‌شده» به
«بررسی‌نشده» برمی‌گردند** — یعنی دوباره در صف بررسی ظاهر می‌شوند.
🔶 **تصمیم مالک — دو گزینه:**
1. افزودن `REJECTED` به enum (additive، با §G تضاد ندارد) — ۱۴ ردیف درست
منتقل می‌شوند
2. نگاشت رد‌شده‌ها به `UNVERIFIED` و پذیرفتن اینکه در صف برمی‌گردند
و **۱۰ ردیف ناسازگار** (`1,2` و `3,2`) هر تصمیمی بگیرید، نیاز به قاعده‌ی
صریح دارند: گام دوم تأیید شده ولی گام اول نه. در legacy این حالت ممکن بود
چون دو ستون مستقل‌اند و هیچ محدودیتی بینشان نیست.
### ۵.۷ — 🔴 `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` لازم است
> «بله» — علاقه‌مندی‌ها مهم‌اند.
**یک 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` روی رشته‌ی خالی یعنی
فقط **یک** ردیف می‌تواند `''` باشد.
🔶 **تصمیم فوری لازم:** `nationalCode` باید `String?` شود (nullable). این
یک migration افزایشی روی مقصد است. بدون آن مهاجرت مشتری **غیرممکن** است.
**۳. گِردکردن رخ می‌دهد — ۲۴ + ۳۲ ردیف.**
`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
```
🔴 **مهاجرت می‌شکند.** دو راه: `passwordHash` nullable شود، یا مهاجرت یک
hash تصادفیِ غیرقابل‌استفاده بگذارد. **گزینه‌ی دوم امن‌تر است** — ستون
اجباری می‌ماند و هیچ‌کس نمی‌تواند با رمز خالی وارد شود.
⚠️ آن ۵ رمز `$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-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی
که خروجی اول باز گذاشت. مثل فایل اول: فقط `COUNT`/`SUM`، صفر نوشتن، هیچ
ستون متنی در خروجی (تنها استثنا: `products_productimage.w` که یک برچسب
اندازه است، نه داده‌ی شخصی).
| # | سؤال | چرا مسدودکننده است |
|---|---|---|
| ۱ | **مبلغ سند مالی در کدام ستون است؟** | `remain`/`rial_remain` همه `NULL`اند. بدون جواب، `LedgerEntry.amount` خالی می‌ماند و **کل دفتر مالی بی‌مبلغ منتقل می‌شود** |
| ۲ | آیا «مسئول فروش برنده» برای ۲۷ گروه تکراری کافی است؟ | اگر گروهی کارشناس نداشته باشد، قاعده بی‌اثر است |
| ۳ | سه ستون تصویر → چهار نقش `ProductImageRole` | نگاشت غلط = تصویر غلط روی محصول |
| ۴ | تأیید §۵.۷ — `reagent_id` به `customers` اشاره می‌کند؟ | اگر غلط باشد، رابطه‌ی کارشناس معرف از بین می‌رود |
---
## ۱۱. جمع‌بندی — کارهای لازم قبل از نوشتن ابزار
### 🔴 مسدودکننده (بدون این‌ها ابزار در میانه می‌شکند یا داده خراب می‌کند)
| # | مورد | § |
|---|---|---|
| ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) |
| ۱.۱ | 🔴 **`nationalCode` باید nullable شود** — هر ۱٬۵۹۵ ردیف تهی است | ۱۰.۴ |
| ۱.۲ | 🔴 **`passwordHash`** — ۱٬۵۹۰ تهی؛ hash تصادفی یا nullable | ۱۰.۴ |
| ۱.۳ | 🔴 **مبلغ سند مالی کجاست؟** `cashes.remain` همه `NULL` | ۱۰.۶ |
| ۱.۴ | 🔴 **`VerificationStep` حالت `REJECTED` ندارد** — ۱۴ ردیف | ۵.۵ |
| ۱.۵ | 🔴 **ترتیب دومرحله‌ای** — کارشناسان قبل از مشتریان | ۵.۷ |
| ۲ | آستانه‌ی تطبیق عددی جای «صفر اختلاف» — **تأیید شد: ۵۶ ردیف گِرد می‌شوند** | ۴ · ۱۰.۴ |
| ۳ | قاعده‌ی `WHERE deleted_at IS NULL` — **حیاتی‌تر از انتظار: ۴۵٪ واریانت‌ها** | ۵.۸ · ۱۰.۴ |
| ۴ | ~~تصمیم `orders_orders`~~ | ✅ **حل — به تأخیر افتاد** (§۷.۲) |
| ۵ | تصحیح D0: `fname`/`lname` مستقیم، تجزیه نشود | ۲ |
| ۶ | **allow-list ستون‌های نوشتنی** — ۷ ستون مقصد-تنها لمس نشوند | **۱۰.۵** |
| ۷ | **کلید تطبیق ردیف‌های موجود مقصد** | **۱۰.۵** |
### 🔶 تصمیم مالک
| # | مورد | § |
|---|---|---|
| ۱ | ۴ ستون داده‌ی غیرقابل‌بازسازی (`birth_date`, `marriage_date`, `token_mobile`, `new_city`) | ۵.۲ |
| ۲ | `verificationStep` — تفکیک `STEP_TWO_SUBMITTED` از `VERIFIED` | ۵.۵ |
| ۳ | `type IS NULL` چه `CustomerKind` شود | ۵.۳ |
| ۴ | ~~`fav`~~ | ✅ **حل — لازم است، migration افزایشی** (§۱۰) |
| ۴.۱ | `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 (ترتیب دسته‌ها)، نقص #۱۹