2026-10-06 16:48:36 +03:30
# نگاشت دیتابیس legacy → Prisma
> سند **نگاشت**، نه پلن اجرا. هیچ کدی بر اساس این سند نوشته نشده.
> پیشنیاز ابزار مهاجرت دادهی [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷.
2026-10-07 19:35:19 +03:30
**منبع اول:** `oapi-struncture.sql` — دامپ Navicat فقط ساختار، تاریخ ۰۵/۱۰/۲۰۲۶
**منبع دوم:** `oapi.sql` — دامپ کامل با داده، تاریخ ۰۴/۱۰/۲۰۲۶، **۲۷۴٬۷۷۳ سطر**
2026-10-06 16:48:36 +03:30
**مقصد:** `apps/api/prisma/schema.prisma` — ۴۳ مدل، ۴۷۷ فیلد
2026-10-07 19:35:19 +03:30
**تاریخ نگارش:** ۱۴ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۰۶) · بهروز ۱۵ مهر ۱۴۰۵ (دیتای واقعی)
2026-10-06 16:48:36 +03:30
---
## ۰ . مشخصات منبع
| ویژگی | مقدار |
|---|---|
| موتور | **MySQL 8.0.42** (`8.0.42-0ubuntu0.20.04.1` ) |
| سرور | `Henza` · `localhost:3305` |
| اسکیما | `oapi` |
| جداول | **۸۰** |
| ستونها | **۱٬۰۸۷** |
| Engine | `InnoDB` (هر ۸۰ جدول) |
2026-10-07 19:35:19 +03:30
| محتوا | **دامپ کامل — `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 شد §۱۰) |
2026-10-06 16:48:36 +03:30
این فایل شکافی را پر کرد که قبلاً باز بود: جداول دوران 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` ,
2026-10-07 17:37:34 +03:30
`token_mobile` , `new_city` .
2026-10-06 16:48:36 +03:30
2026-10-07 17:37:34 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — دو منتقل، دو حذف
| ستون | ردیف | تصمیم | دلیل مالک |
|---|---|---|---|
| `birth_date` | **۹۱۷** | ✅ **منتقل** | «نرخ پرشدگی بالای ۵۵٪ · کمپینهای وفاداری و پیامکی» |
| `new_city` | **۱۷۶** | ✅ **منتقل** | «خالی ماندن شهر ۱۷۶ مشتری نقص دیتا ایجاد میکند» |
| `token_mobile` | ۹۹ | ❌ **حذف** | «توکنها اغلب منقضی شدهاند · زباله دیتایی» |
| `marriage_date` | ۵ | ❌ **حذف** | «۵ ردیف توجیه معماری ندارد» |
**هر دو حذف قابلدفاعاند.** توکن push با تغییر کلید FCM/APNs بیاثر
میشود، و ۵ ردیف ارزش یک ستون دائمی را ندارد.
migration افزایشی لازم:
```prisma
model Customer {
birthDate DateTime? // legacy birth_date
// new_city → §۵.۹
}
```
### 🔶 `new_city` — «کدام فیلد» هنوز باز است
مالک گفت «یا در فیلد شهر بنشیند یا در یک فیلد آدرس متنی مپ شود». کدامیک،
به داده بستگی دارد — §۵.۹.
2026-10-06 16:48:36 +03:30
`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` شوند، **از «ردشده» به
«بررسینشده» برمیگردند** — یعنی دوباره در صف بررسی ظاهر میشوند.
2026-10-07 17:37:34 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — `REJECTED` + علت رد
2026-10-06 16:48:36 +03:30
2026-10-07 17:37:34 +03:30
> «رویکرد شما ناقص است؛ حالت «رد شد» باید همراه با فیلد «علت رد» اضافه شود.
> انتقال این افراد به حالت «در انتظار» کاملاً مردود است چون بار عملیاتی و
> خطای انسانی ایجاد میکند.»
**پذیرفته شد.** انتقال به `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` | ✅ **ساخته میشود** (خواست مالک · برای آینده) |
2026-10-07 19:35:19 +03:30
| مقدارش در مهاجرت **۷ ردیف** | متن ثابت مالک (پایین) — علت واقعی در مبدأ نیست |
2026-10-07 17:37:34 +03:30
| `verificationRejectedAt` | ** `NULL` ** — legacy زمان رد را هم ثبت نمیکرد |
2026-10-07 19:35:19 +03:30
⚠️ **این یک شکاف دادهی ذاتی است، نه نقص ابزار مهاجرت.** علت رد آن ۷
2026-10-07 17:37:34 +03:30
مشتری در سیستم قدیم هرگز ذخیره نشده بود و **قابل بازیابی نیست** . ستون
2026-10-07 19:35:19 +03:30
ساخته میشود تا از این پس پر شود.
2026-10-07 17:37:34 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — یادداشت خودکار درج شود
> «فیلد علت برای این ۱۴ ردیف نباید کامپلت NULL رها شود.»
2026-10-07 19:35:19 +03:30
(عدد درست **۷ ** است: ۲ رد در گام دو، ۳ در گام یک، ۲ در هر دو. عدد ۱۴ را
من اشتباه جمع کرده بودم و مالک بر اساس آن نوشت؛ تصمیم تغییری نمیکند.)
2026-10-07 17:37:34 +03:30
مقدار دقیق که ابزار درج میکند:
```
رد شده در سیستم قدیم — فاقد شرح علت ثبتشده
```
| ستون | مقدار |
|---|---|
| `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` )، احتمال دارد
اینجا هم تاریخ جلالی بهاشتباه بهعنوان میلادی وارد شده باشد.
اگر سالها در بازهی ۱۳۰۰–۱۴۰۰ باشند، یعنی **جلالی در ستون میلادی** و
تبدیل لازم است. اگر ۱۹۳۰–۲۰۱۰ باشند، سالماند.
کوئریاش در همان فایل سوم است.
---
2026-10-06 16:48:36 +03:30
### ۵ .۷ — 🔴 `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` |
2026-10-07 19:35:19 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۶ — علاقهمندیها لازماند
2026-10-06 16:48:36 +03:30
> «بله» — علاقهمندیها مهماند.
2026-10-07 19:35:19 +03:30
🔴 **تصحیح ۲۰۲۶-۱۰-۰۷: منبع درست ` fav` نیست.** ` fav` مال فروشگاه خرده است
(` Customer\FavController`، guard ` api-singleCustomer`). اپ عمده
**` major_favorites`** (۳٬۳۰۵ ردیف) را میخواند — §۱۰.۷. متن زیر دربارهی
` fav` نوشته شده؛ تصمیم مالک و مدل ` CustomerFavorite` همان میمانند، فقط منبع
عوض شد.
2026-10-06 16:48:36 +03:30
**یک 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 ` روی رشتهی خالی یعنی
فقط **یک** ردیف میتواند ` ''` باشد.
2026-10-07 17:37:34 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — nullable شود
> «وقتی دیتاست مبدا فاقد این داده است، هیچ گزینه دیگری جز Nullable کردن
> فیلد وجود ندارد. ساختن کد ملی فیک تخلف دیتایی است و مانع از
> احراز هویتهای قانونی بعدی میشود.»
` ``prisma
nationalCode String? @unique // بود: String @unique
` ``
⚠️ **نکتهی فنی که به نفع این تصمیم است:** در Postgres مقدار ` NULL` در
ایندکس ` @unique ` **تکراری حساب نمیشود**. پس هزار ردیف با
` nationalCode = NULL` بیمشکل کنار هم مینشینند — ولی اگر بهجای ` NULL`
رشتهی خالی ` ''` بگذاریم، **فقط یکی جا میشود**.
🔴 **قاعدهی اجباری ابزار: ` ''` را به ` NULL` تبدیل کند، نه اینکه خالی
بفرستد.**
2026-10-06 16:48:36 +03:30
**۳. گِردکردن رخ میدهد — ۲۴ + ۳۲ ردیف.**
` 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
` ``
2026-10-07 17:37:34 +03:30
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — 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 جداگانه، بعد از مهاجرت.
2026-10-06 16:48:36 +03:30
⚠️ آن ۵ رمز ` $2y$` (bcrypt) هستند و مقصد هم bcrypt است، پس **بدون
2026-10-07 17:37:34 +03:30
تغییر منتقل میشوند** و همانطور کار میکنند.
2026-10-06 16:48:36 +03:30
### 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` جدیدتر
برنده؛ بقیه منتقل میشوند ولی در گزارش «نیاز به بررسی دستی» فهرست شوند.
چون اهمیت کم است، میتوان این را به گزارش سپرد و دستی حل کرد.
---
2026-10-07 17:37:34 +03:30
## ۱۰.۵.۵ — فهرست کامل تغییرات افزایشی مقصد
همهی تصمیمهای مالک تا ۲۰۲۶-۱۰-۰۷، در یک 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` هنگام رد:
2026-10-07 19:35:19 +03:30
گام ۱ → ` UNVERIFIED`، گام ۲ → ` STEP_ONE_SUBMITTED`. یعنی ۷ مشتری مهاجر
2026-10-07 17:37:34 +03:30
` 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 کامپایل نمیشود؛ ۱۷۸ تست بقیه سبزند.
خارج از اسکوپ این کار، ثبت میشود.
---
2026-10-06 16:48:36 +03:30
## ۱۰.۶ — پرسش دوم از دیتابیس
📄 **[` migration-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی
که خروجی اول باز گذاشت. مثل فایل اول: فقط ` COUNT`/` SUM`، صفر نوشتن، هیچ
ستون متنی در خروجی (تنها استثنا: ` products_productimage.w` که یک برچسب
اندازه است، نه دادهی شخصی).
| # | سؤال | چرا مسدودکننده است |
|---|---|---|
2026-10-07 19:35:19 +03:30
| ۱ | ~~مبلغ سند مالی در کدام ستون است؟~~ | ✅ **حل — §۱۰.۷** |
2026-10-06 16:48:36 +03:30
| ۲ | آیا «مسئول فروش برنده» برای ۲۷ گروه تکراری کافی است؟ | اگر گروهی کارشناس نداشته باشد، قاعده بیاثر است |
| ۳ | سه ستون تصویر → چهار نقش ` ProductImageRole` | نگاشت غلط = تصویر غلط روی محصول |
| ۴ | تأیید §۵.۷ — ` reagent_id` به ` customers` اشاره میکند؟ | اگر غلط باشد، رابطهی کارشناس معرف از بین میرود |
---
2026-10-07 19:35:19 +03:30
## ۱۰.۷ — یافتههای دامپ کامل (` 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`. هرکدام لازم شد، قدم جدا اضافه میشود.
---
2026-10-08 10:40:22 +03:30
## ۱۰.۹ — «میزان فروش» و کارشناسِ هر سفارش (۲۰۲۶-۱۰-۰۸)
**مشکل:** کارفرما در مقایسهی چشمی دید که «میزان فروش» چند کارشناس با سیستم
قدیم فرق دارد (مثلاً موسوی کیانی و ملاطایفه). دو علت داشت:
۱. سیستم جدید سفارشهای **خود** کارشناس را نمیشمرد. قدیم میشمرد:
` 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`.
- «وضعیت ثبت سفارش» دوباره برچسب سبز/قرمزِ «فعال/عدم سفارش» را نشان میدهد.
---
2026-10-06 16:48:36 +03:30
## ۱۱. جمعبندی — کارهای لازم قبل از نوشتن ابزار
### 🔴 مسدودکننده (بدون اینها ابزار در میانه میشکند یا داده خراب میکند)
| # | مورد | § |
|---|---|---|
| ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) |
2026-10-07 17:37:34 +03:30
| ۱ .۱ | ~~` nationalCode`~~ | ✅ **حل — nullable شود** (§۱۰.۴) |
| ۱ .۲ | ~~` passwordHash`~~ | ✅ **حل — hash غیرقابلورود** (§۱۰.۴) |
2026-10-07 19:35:19 +03:30
| ۱.۳ | ~~مبلغ سند کجاست~~ | ✅ **حل — فرمول بدهی legacy با داده تأیید شد** (§۱۰.۷) |
2026-10-07 17:37:34 +03:30
| ۱ .۴ | ~~` REJECTED`~~ | ✅ **حل — enum + دو ستون علت** (§۵.۵) |
2026-10-07 19:35:19 +03:30
| ۱.۵ | ~~ترتیب دومرحلهای~~ | ✅ **در ابزار** — کارشناسان قبل از مشتریان (§۱۰.۸) |
| ۱ .۶ | ~~` birth_date`~~ | ✅ **حل — پالایش، ۵۴۱ از ۹۱۵** (§۱۰.۷) |
| ۱ .۷ | ~~` customers.type` ۳/۴/۵~~ | ✅ **سؤال لازم نبود** — در ` Customer::$TYPES` تعریف شدهاند (§۵.۳) |
| ۲ | ~~آستانهی تطبیق~~ | ✅ **در ابزار** — ±۰٫۰۰۰۱ گرم، ±۱ ریال (§۱۰.۸) |
| ۳ | ~~قاعدهی ` deleted_at`~~ | ✅ **در ابزار** — بهجز حذفشدههای ارجاعشده در سفارش (§۱۰.۷) |
2026-10-06 16:48:36 +03:30
| ۴ | ~~تصمیم ` orders_orders`~~ | ✅ **حل — به تأخیر افتاد** (§۷.۲) |
2026-10-07 19:35:19 +03:30
| ۵ | ~~تصحیح D0~~ | ✅ **در ابزار** — ` fname`/` lname` مستقیم |
| ۶ | ~~allow-list ستونها~~ | ✅ **در ابزار** — حسابداری بایتبهبایت یکسان ماند (§۱۰.۸) |
| ۷ | ~~کلید تطبیق~~ | ✅ **موبایل** (مالک) — ادمین تنزل نمییابد (§۱۰.۷) |
2026-10-06 16:48:36 +03:30
### 🔶 تصمیم مالک
| # | مورد | § |
|---|---|---|
2026-10-07 17:37:34 +03:30
| ۱ | ~~۴ ستون دادهی غیرقابلبازسازی~~ | ✅ **حل — تولد و شهر منتقل، توکن و سالگرد حذف** (§۵.۲) |
2026-10-07 19:35:19 +03:30
| ۱ .۱ | ~~` new_city`~~ | ✅ **حل — نگاشت دوگانه: متن در ` cityNameRaw` + ` cityId` با تطابق دقیق** (مالک · §۱۰.۷) |
2026-10-07 17:37:34 +03:30
| ۲ | ~~تفکیک ` STEP_TWO_SUBMITTED` از ` VERIFIED`~~ | ✅ **حل** (§۵.۵ ) |
2026-10-07 19:35:19 +03:30
| ۳ | ~~` type IS NULL`~~ | ✅ **` APP_SHOPKEEPER`** — ثبتنام اپ ` type = 2` میگذارد (Major/CustomerController.php:44)؛ NULL قدیمیتر از آن است |
| ۴ | ~~علاقهمندی~~ | ✅ **حل — از ` major_favorites`، نه ` fav`** (§۱۰.۷ ) |
| ۴.۲ | ~~مشتری ` #125 `~~ | ✅ **همان −۹٫۸۹۵۱ با سند تراز انتقالی** (مالک · §۱۰.۷) |
| ۴.۳ | 🔶 **رد در پنل جدید ` REJECTED` نمینویسد** و مشتری ردشده با ارسال دوباره خودکار تأیید میشود | §۱۰.۵.۵ |
| ۴.۴ | 🔶 **مدیر ارشدی که کارشناس هم بود** — نقش ادمین نگه داشته شد؛ آیا حساب کارشناسی هم لازم است؟ | §۱۰.۷ |
2026-10-06 16:48:36 +03:30
| ۴.۱ | ` 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 (ترتیب دستهها)، نقص #۱۹