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