# نگاشت دیتابیس legacy → Prisma > سند **نگاشت**، نه پلن اجرا. هیچ کدی بر اساس این سند نوشته نشده. > پیش‌نیاز ابزار مهاجرت داده‌ی [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷. **منبع:** `oapi-struncture.sql` — دامپ Navicat، تاریخ ۰۵/۱۰/۲۰۲۶ **مقصد:** `apps/api/prisma/schema.prisma` — ۴۳ مدل، ۴۷۷ فیلد **تاریخ نگارش:** ۱۴ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۰۶) --- ## ۰. مشخصات منبع | ویژگی | مقدار | |---|---| | موتور | **MySQL 8.0.42** (`8.0.42-0ubuntu0.20.04.1`) | | سرور | `Henza` · `localhost:3305` | | اسکیما | `oapi` | | جداول | **۸۰** | | ستون‌ها | **۱٬۰۸۷** | | Engine | `InnoDB` (هر ۸۰ جدول) | | محتوا | **فقط ساختار — صفر `INSERT`** | این فایل شکافی را پر کرد که قبلاً باز بود: جداول دوران Django (`product`، `products_productvarient`، `category_collection` و …) هیچ migration لاراولی نداشتند و فقط در دیتابیس زنده تعریف شده بودند. **موتور دیتابیس هم تا پیش از این حدس بود؛ اکنون قطعی است: MySQL.** ⚠️ **دامپ داده ندارد.** پس این سند نگاشت *ساختار* است. آستانه‌های تطبیق §۷ `GO-LIVE` و شمارش ردیف‌های تکراری (§۳ همین سند) **داده‌ی واقعی لازم دارند** و با این فایل قابل انجام نیستند. --- ## ۱. تفکیک ۸۰ جدول → ۵۰ زنده، ۳۰ مرده «زنده» = مدل Eloquent دارد (`$table` صریح، یا نام جمعِ پیش‌فرض لاراول). | دسته | تعداد | تصمیم | |---|---|---| | **زنده — در دامنه‌ی کار** | **۴۱** | مهاجرت می‌شوند | | زنده — فروشگاه خرده (D13) | ۹ | **حفظ، بدون پورت** | | نسخه‌ی پشتیبان دستی | ۶ | نادیده | | بازمانده‌ی Django | ۱۱ | نادیده | | زیرساخت لاراول | ۳ | نادیده | ### ۱.۱ — جداول پشتیبان دستی (۶) — نادیده ``` customers_copy1 customers_copy2 products_productvarient_copy1 cities5 citiesB cities_copy11 ``` `customers_copy1` و `customers_copy2` هر دو ۴۹ ستون دارند، **برابر با `customers`**. snapshot دستی قبل از یک تغییرند. `customers_test` (۴۰ ستون) مدل `CustomerTest` دارد ولی جدول آزمایشی است. ⚠️ **قبل از نادیده‌گرفتن، تعداد ردیف هر سه `customers*` شمرده شود.** اگر `copy2` ردیف‌هایی دارد که `customers` ندارد، یعنی جایی ردیف حذف شده و این کپی آخرین نسخه‌ی آن است. ### ۱.۲ — بازمانده‌ی Django (۱۱) — نادیده ``` accounts_effectivedate accounts_profile accounts_rolepermission auth_permission discount_discountcode discount_pricerule orders_fulfillment orders_sendtime products_comment products_promotion products_variantpromotion ``` هیچ‌کدام مدل Eloquent ندارند. `accounts_profile` با ۴۳ ستون بزرگ‌ترین‌شان است (`sales_ceiling`، `commission`، `profit`) — سامانه‌ی کارشناس فروشِ نسخه‌ی Django، که لاراول با `customers.type=1` جایش را گرفت. ⚠️ `orders_sendtime` از `orders_orders.send_time_id` **ارجاع می‌شود**. اگر زمان ارسال سفارش‌های تاریخی مهم است، این جدول مرده نیست. ### ۱.۳ — فروشگاه خرده (۹) — حفظ، بدون پورت (D13) ``` wallets transactions transaction_metas transactions_cheque transactions_deposittocard transactions_financialreport transactions_meltedgold transactions_refund discount_codes ``` طبق D13 نگه داشته می‌شوند. `TransactionOrder` و `transaction_orders` مدل دارند، ولی `transactions_*` ندارند. --- ## ۲. 🔴 `fname`/`lname` از قبل وجود دارند — D0 بر فرض غلط بنا شد جدول `customers` **هر سه ستون** را دارد: ``` fname varchar(50) NULL lname varchar(50) NULL name varchar(75) NULL ``` و `users` هم همین سه را دارد (`fname`, `lname`, `name` — هر سه `varchar(255)`). `DECISIONS.md` §D0 ثبت کرده که `fullName` باید «به `firstName`/`lastName` شکسته شود» با backfill و «گزارش ردیف‌های چندتوکنی». **چیزی برای شکستن نیست.** legacy از اول جدا داشته. | نگاشت صحیح | نگاشت غلط | |---|---| | `fname → firstName` | ~~تجزیه‌ی `name` با توکن اول/بقیه~~ | | `lname → lastName` | | | `name → fullName` | | **اگر از `name` تجزیه کنیم، داده‌ی درست را با حدس خراب می‌کنیم.** ### تله‌ی trigger `customer_sync_full_name` در Postgres یک trigger است و طبق `PROGRESS.md` رفتارش این است: **اگر هر سه را یک‌جا بنویسی، اجزا برنده می‌شوند و `fullName` ارسالی دور ریخته می‌شود.** برای مهاجرت این دقیقاً رفتار مطلوب است (اجزا منبع حقیقت‌اند) — ولی اگر `name` در legacy با `fname + ' ' + lname` نخواند، **مقدار `name` در مهاجرت گم می‌شود**. ⚠️ قبل از مهاجرت شمرده شود: `WHERE name != CONCAT(fname,' ',lname)`. اگر صفر نیست، تصمیم مالک لازم است کدام برنده باشد. --- ## ۳. 🔴 هیچ `UNIQUE` روی `mobile` و `ncode` نیست کل ایندکس‌های `customers`: ```sql PRIMARY KEY (`id`) USING BTREE ``` همین. **هیچ unique index دیگری، و هیچ foreign key.** ولی Prisma اعلام کرده: ```prisma mobile String @unique nationalCode String @unique ``` **نتیجه:** به احتمال زیاد در داده‌ی تولید موبایل یا کد ملی تکراری وجود دارد. مهاجرت در میانه با خطای unique متوقف می‌شود — و چون در میانه است، نیمی از داده منتقل شده. `GO-LIVE §۷` تکرار شماره **بین `Customer` و `AdminUser`** را ثبت کرده، ولی **تکرار داخل خود `customers` را ندیده**. این یک شکاف تازه است. ### کوئری‌های شمارش — در فایل جداگانه 📄 **[`migration-precheck.sql`](./migration-precheck.sql)** — ۹ بخش، فقط `COUNT`/`SUM`، صفر `INSERT`/`UPDATE`/`DELETE`. هیچ ستون متنی در خروجی ظاهر نمی‌شود، پس **داده‌ی مشتری از سرور بیرون نمی‌آید**. ``` mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck.sql > out.txt ``` نمونه‌ی مختصر از بخش ۲ همان فایل: ```sql -- ۱) موبایل تکراری SELECT mobile, COUNT(*) c FROM customers WHERE deleted_at IS NULL GROUP BY mobile HAVING c > 1; -- ۲) کد ملی تکراری (رشته‌ی خالی هم تکرار است، برخلاف NULL) SELECT ncode, COUNT(*) c FROM customers WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode != '' GROUP BY ncode HAVING c > 1; -- ۳) موبایل یا کد ملی تهی — Prisma هر دو را NOT NULL می‌خواهد SELECT SUM(mobile IS NULL OR mobile = '') AS no_mobile, SUM(ncode IS NULL OR ncode = '') AS no_ncode FROM customers WHERE deleted_at IS NULL; -- ۴) تداخل شماره با کارشناسان و ادمین‌ها (D21: «کارشناس برنده است») SELECT c.mobile FROM customers c JOIN users u ON u.mobile = c.mobile WHERE c.deleted_at IS NULL AND u.deleted_at IS NULL; ``` `nationalCode` در Prisma `String` بدون `?` است — **اجباری**. اگر کوئری ۳ عددی غیرصفر برگرداند، یا schema باید nullable شود، یا مالک باید قاعده‌ی جایگزین بدهد. --- ## ۴. 🔴 ۱۱۰ ستون پول و وزن `double` هستند — صفر `decimal` ``` double/float : ۱۱۰ ستون decimal : ۱ ستون (فقط users.permission — که مرده است، §۶) ``` نمونه‌ها: `customers.remain`, `customers.rial_remain`, `cashes.remain`, `cashes.balance`, `orders.w`, `orders.wage`, `order_items.rash_weight`, `products_productvarient.grams`. Prisma درست‌کاری کرده: `Decimal(14,4)` برای گرم، `Decimal(20,2)` برای ریال. **ولی `GO-LIVE §۷` شرط تطبیق را «صفر اختلاف» گذاشته — و این ذاتاً دست‌نیافتنی است.** `double` در IEEE 754 عدد `12.3` را واقعاً `12.3000000000000007105…` ذخیره می‌کند. تبدیل به `Decimal(14,4)` آن را به `12.3000` گِرد می‌کند. در یک ردیف بی‌اهمیت است؛ در جمعِ هزاران ردیفِ دفتر، اختلاف انباشته می‌شود. ### پیشنهاد — آستانه‌ی واقع‌بینانه جای صفر مطلق | بررسی | آستانه‌ی فعلی §۷ | آستانه‌ی پیشنهادی | |---|---|---| | بدهی طلای هر مشتری | صفر اختلاف | `|Δ| < 0.0001` گرم | | بدهی ریالی هر مشتری | صفر اختلاف | `|Δ| < 1` ریال | | جمع کل بدهی طلا | — | `|Δ| < 0.01` گرم | بدون این، ابزار مهاجرت **همیشه قرمز** گزارش می‌دهد و کسی به گزارشش اعتماد نمی‌کند — که بدتر از نداشتنِ گزارش است. ⚠️ `rial_*` در legacy `bigint` است (نه `double`) — یعنی **ریال صحیح، بدون اعشار**. ولی Prisma `Decimal(20,2)` گرفته. این سمت مشکلی ندارد (دقت بیشتر)، ولی تأیید می‌کند که `fiatDebtRial` هرگز اعشار واقعی ندارد. نقص #۱۹ در `PWA-PORT-PLAN.md` («بررسی واحد») اینجا پاسخ می‌گیرد: **واحد ریال است، صحیح.** --- ## ۵. `customers` — نگاشت ۴۹ ستون ۳۲ ستون نگاشت دارند، **۱۷ ستون خانه ندارند**. ### ۵.۱ — نگاشت‌شده (۳۲) | legacy | نوع | Prisma | یادداشت | |---|---|---|---| | `id` | `bigint` | — | در جدول نگاشت `legacyId` نگه داشته شود (§۷ GO-LIVE) | | `fname` | `varchar(50)` | `firstName` | §۲ — مستقیم، تجزیه نشود | | `lname` | `varchar(50)` | `lastName` | §۲ | | `name` | `varchar(75)` | `fullName` | trigger بازنویسی می‌کند — §۲ | | `mobile` | `varchar(20)` | `mobile` | **§۳ — unique نیست در مبدأ** | | `ncode` | `varchar(20)` | `nationalCode` | **§۳ — unique نیست، و در Prisma اجباری است** | | `password` | `varchar(255)` | `passwordHash` | فرمت hash تأیید شود (bcrypt؟) | | `city_id` | `int` | `cityId` | نگاشت id لازم | | `type` | `tinyint` | `kind` | §۵.۳ | | `sts` | `tinyint` | `canPlaceOrders` | `1`→`true`, `-1`→`false` | | `active` | `tinyint` | `isActive` | سوییچ مجزا از `sts` | | `is_archive` | `tinyint(1)` | `isArchived` | | | `gram_level` | `double` | `goldCreditLimitGrams` | §۴ | | `remain` | `double` | `goldDebtGrams` | §۴ — بدهی طلا | | `rial_remain` | `bigint` | `fiatDebtRial` | §۴ — ریال صحیح | | `percent` | `double` | `makingChargeOffsetPercent` | D9 — آفست اجرت | | `reagent_id` | `int` | `referredByAgentId` | 🔴 **→ `customers.id`، نه `users.id`** · §۵.۷ | | `phone` | `varchar(15)` | `phone` | | | `address` | `varchar(255)` | `address` | | | `store_name` | `varchar(255)` | `storeName` | | | `pasazh_name` | `varchar(255)` | `pasazhName` | | | `zone` | `varchar(255)` | `zone` | | | `mcode` | `varchar(30)` | `memberCode` | | | `image_id` | `int` | `avatarFileId` | FK → `UploadedFile` | | `ncode_image_id` | `int` | `nationalIdImageUrl` | **FK → رشته** · §۵.۴ | | `business_license_image_id` | `int` | `businessLicenseUrl` | **FK → رشته** · §۵.۴ | | `shop_image_id` | `int` | `shopImageUrl` | **FK → رشته** · §۵.۴ | | `sts_verify_step_one` | `tinyint(1)` | `verificationStep` | §۵.۵ — دو ستون → یک enum | | `sts_verify_step_two` | `tinyint(1)` | `verificationStep` | §۵.۵ | | `created_at` | `timestamp` | `createdAt` | | | `updated_at` | `timestamp` | `updatedAt` | | | `deleted_at` | `timestamp` | — | **§۵.۶ — soft-delete در Prisma وجود ندارد** | ### ۵.۲ — 🟡 ۱۷ ستون بی‌خانه **هر ۱۷ مورد در کد legacy فعالاً استفاده می‌شوند** (با grep تأیید شد). اگر ابزار مهاجرت بی‌خبر نوشته شود، **بی‌صدا دور ریخته می‌شوند**. | ستون | نوع | فایل‌های استفاده‌کننده | چیست | |---|---|---|---| | `input` | `double` | — | جمع وارده‌ی طلا | | `output` | `double` | — | جمع صادره‌ی طلا | | `rial_input` | `bigint` | — | جمع وارده‌ی ریال | | `rial_output` | `bigint` | — | جمع صادره‌ی ریال | | `grams_sell` | `double` | ۳ | گرم فروخته‌شده | | `total_order` | `int` | ۶ | شمارنده‌ی سفارش | | `open_order` | `int` | ۵ | شمارنده‌ی سفارش باز | | `child` | `int` | ? | شمارنده‌ی زیرمجموعه | | `notify_count` | `int` | ۵ | شمارنده‌ی اعلان خوانده‌نشده | | `count_login` | `bigint` | ۲ | شمارنده‌ی ورود | | `token_mobile` | `varchar(255)` | **۱۰** | توکن push گوشی | | `birth_date` | `timestamp` | ۴ | تولد — منطق تبریک | | `marriage_date` | `timestamp` | ۳ | سالگرد | | `new_city` | `varchar(255)` | ۴ | شهر دستی وقتی `city_id` تهی است | | `api_token` | `varchar(60)` | — | auth قدیم | | `send_password` | `varchar(255)` | ۴ | رمز ارسالی با پیامک | | `rnd` | `varchar(60)` | ۴ | nonce | **سه گروه با تصمیم متفاوت:** **الف) `input`/`output`/`rial_input`/`rial_output` — طرف دیگر دفتر.** Prisma فقط `remain` را به‌عنوان ستون دارد. در مدل جدید جمع وارده/صادره باید از `LedgerEntry` محاسبه شود. **این بهتر است** — ولی یعنی هنگام مهاجرت باید تأیید شود که `SUM(ledger) == remain` legacy. اگر نخواند، legacy خودش ناسازگار بوده و باید گزارش شود، نه اصلاح. **ب) شمارنده‌های denormalized** (`total_order`, `open_order`, `notify_count`, `child`, `count_login`, `grams_sell`). قابل محاسبه از داده‌ی دیگرند. اضافه نکردن‌شان درست است، **به شرطی که جایی که legacy نشان‌شان می‌داد، مقصد هم محاسبه‌شان کند** — وگرنه عدد صفر نشان داده می‌شود. **ج) داده‌ی واقعیِ غیرقابل‌بازسازی** — `birth_date`, `marriage_date`, `token_mobile`, `new_city`. **این‌ها اگر منتقل نشوند، برای همیشه از بین می‌روند.** `token_mobile` در ۱۰ فایل استفاده می‌شود؛ بدون آن، اعلان push به گوشی‌های موجود قطع می‌شود. 🔶 **تصمیم مالک لازم است برای گروه (ج).** `api_token`, `send_password`, `rnd` نیازی نیست منتقل شوند — سامانه‌ی auth جدید JWT است. ### ۵.۳ — `type` → `CustomerKind` `Customer::$TYPES` در legacy پنج مقدار دارد: | legacy | برچسب | `CustomerKind` | |---|---|---| | `1` | کارشناس فروش | **→ `AdminUser` + `SalesExpertProfile`** (نه `Customer`) · D17 | | `2` | مغازه‌دار اپلیکیشن | `APP_SHOPKEEPER` | | `3` | مغازه‌دار حضوری | `WALK_IN_SHOPKEEPER` | | `4` | بنکدار | `BANKER` | | `5` | سرپرست کارگاه | `WORKSHOP_SUPERVISOR` | | — | — | `RETAIL` ➕ مقصد-تنها (از `login`، §۸) | | `NULL` | — | 🔶 **بی‌نگاشت** | `type` در مبدأ **nullable بدون default** است. Prisma `@default(APP_SHOPKEEPER)` دارد. پس `NULL` بی‌صدا «مغازه‌دار اپلیکیشن» می‌شود. **قبل از مهاجرت `SELECT COUNT(*) WHERE type IS NULL` شمرده شود.** ⚠️ `type=1` **به جدول دیگری می‌رود** (`AdminUser`). یعنی مهاجرت `customers` دوشاخه است و جدول نگاشت باید بداند هر `legacyId` به کدام جدول رفته. ### ۵.۴ — سه FK تصویر → سه رشته | legacy | نوع | Prisma | نوع | |---|---|---|---| | `ncode_image_id` | `int` → `image.id` | `nationalIdImageUrl` | `String?` | | `business_license_image_id` | `int` → `image.id` | `businessLicenseUrl` | `String?` | | `shop_image_id` | `int` → `image.id` | `shopImageUrl` | `String?` | در legacy اینها **FK** به جدول `image` بودند. در Prisma **رشته‌ی URL** شدند. پس مهاجرت باید `image.image` (مسیر فایل، `char(100)`) را بخواند و URL بسازد. ⚠️ `image_id` (آواتار) **متفاوت** رفتار می‌کند: در Prisma هنوز FK است (`avatarFileId → UploadedFile`). یعنی **چهار ستون تصویر، دو الگوی مختلف.** این ناسازگاری در مقصد است، ولی عوض‌کردنش الان کار این سند نیست — فقط ثبت می‌شود. **فایل‌های تصویر هم باید کپی شوند** (§۷ GO-LIVE) — جدول `image` فقط مسیر دارد. ### ۵.۵ — 🔴 دو ستون تأیید **boolean نیستند** — سه‌حالته‌اند **تصحیح ۲۰۲۶-۱۰-۰۶:** نسخه‌ی اول این سند فرض کرده بود این دو `tinyint(1)` مقدار `0`/`1` دارند. **غلط بود.** داده‌ی واقعی و `MajorCustomerController.php:106-135` نشان می‌دهند مقادیر `1`, `2`, `3` هستند: | مقدار | معنا | عارضه در legacy | |---|---|---| | `1` | در انتظار بررسی | — | | `2` | **تأیید شد** | `step_one=2` → `active=1` + `api_token` + پیامک | | `3` | **رد شد** | `step_one=3` → `active=-1` · `step_two=3` → پیامک رد | | `NULL` | هنوز وارد نشده | — | ### توزیع واقعی (۱٬۵۹۵ ردیف زنده) | `one,two` | تعداد | معنا | `VerificationStep` | |---|---|---|---| | `2,2` | **۱٬۴۶۵** | هر دو تأیید | `VERIFIED` | | `1,NULL` | ۹۰ | گام ۱ در انتظار | `STEP_ONE_SUBMITTED` | | `2,NULL` | ۲۵ | گام ۱ تأیید، گام ۲ وارد نشده | `STEP_ONE_SUBMITTED` | | `1,2` | ۷ | 🔴 **ناسازگار** — گام ۲ تأیید ولی گام ۱ در انتظار | ؟ | | `3,2` | ۳ | 🔴 **ناسازگار** — گام ۱ رد ولی گام ۲ تأیید | ؟ | | `2,1` | ۱ | گام ۲ در انتظار | `STEP_TWO_SUBMITTED` | | `2,3` | ۲ | گام ۲ رد | ؟ | | `3,3` | ۲ | هر دو رد | ؟ | 🔴 **`VerificationStep` حالت «رد شد» ندارد.** مقادیرش `UNVERIFIED` / `STEP_ONE_SUBMITTED` / `STEP_TWO_SUBMITTED` / `VERIFIED` است — هیچ `REJECTED`. پس **۷ ردیف رد‌شده** (`2,3` + `3,3` + بخشی از `3,2`) جایی برای رفتن ندارند و اگر `UNVERIFIED` شوند، **از «رد‌شده» به «بررسی‌نشده» برمی‌گردند** — یعنی دوباره در صف بررسی ظاهر می‌شوند. 🔶 **تصمیم مالک — دو گزینه:** 1. افزودن `REJECTED` به enum (additive، با §G تضاد ندارد) — ۱۴ ردیف درست منتقل می‌شوند 2. نگاشت رد‌شده‌ها به `UNVERIFIED` و پذیرفتن اینکه در صف برمی‌گردند و **۱۰ ردیف ناسازگار** (`1,2` و `3,2`) هر تصمیمی بگیرید، نیاز به قاعده‌ی صریح دارند: گام دوم تأیید شده ولی گام اول نه. در legacy این حالت ممکن بود چون دو ستون مستقل‌اند و هیچ محدودیتی بینشان نیست. ### ۵.۷ — 🔴 `reagent_id` به `customers` اشاره می‌کند، نه `users` کوئری پیش‌بررسی **۱٬۵۳۴ ارجاع یتیم** از `customers.reagent_id` به `users.id` گزارش کرد — یعنی تقریباً همه‌ی ۱٬۵۹۵ ردیف. **علت: کوئری من به جدول غلط join زده بود، نه اینکه داده خراب باشد.** `Customer.php:119-122`: ```php public function reagent() { return $this->belongsTo('App\Models\Customer'); // ← Customer، نه User } ``` کارشناس فروش در legacy **یک ردیف در `customers` با `type=1`** است (۵۶ ردیف، §۵.۳) — نه یک ردیف در `users`. پس `reagent_id` یک **خودارجاع** است. ### پس در مقصد `Customer.referredByAgentId` به **`AdminUser`** اشاره می‌کند، چون D17 حکم کرده کارشناس فروش `AdminUser` + `SalesExpertProfile` شود. یعنی مهاجرت باید **دو مرحله‌ای** باشد: 1. اول ۵۶ ردیف `customers.type=1` → `AdminUser` + `SalesExpertProfile`، و `legacyCustomerId → adminUserId` در جدول نگاشت ثبت شود 2. بعد بقیه‌ی مشتری‌ها، و `reagent_id` از **همان جدول نگاشت** ترجمه شود — نه از `users` 🔴 **اگر این ترتیب رعایت نشود، `referredByAgentId` همه‌ی مشتری‌ها `NULL` می‌شود** و رابطه‌ی «کارشناس معرف» کامل از بین می‌رود. ⚠️ `mobile_collision_customer_vs_user = 10` یعنی ۱۰ شماره در هر دو جدول هست. چون کارشناس در `customers` است، این ۱۰ مورد **تداخل واقعی**اند و D21 («کارشناس برنده است») دقیقاً برایشان نوشته شده. --- ### ۵.۸ — 🟡 `deleted_at` در Prisma وجود ندارد هر جدول زنده‌ی legacy ستون `deleted_at` دارد (soft-delete لاراول). **هیچ مدل Prisma معادلی ندارد.** یعنی ردیف‌های حذف‌شده‌ی نرم: - یا منتقل نمی‌شوند (**پیشنهاد** — `WHERE deleted_at IS NULL`) - یا منتقل می‌شوند و **ناگهان زنده می‌شوند** 🔴 گزینه‌ی دوم فاجعه است: مشتری حذف‌شده برمی‌گردد، سفارش حذف‌شده در گزارش ظاهر می‌شود، سند مالی حذف‌شده در موجودی حساب می‌شود. **قاعده‌ی اجباری ابزار مهاجرت: هر `SELECT` باید `WHERE deleted_at IS NULL` داشته باشد.** و تعداد ردیف‌های حذف‌شده‌ی هر جدول در گزارش تطبیق بیاید تا معلوم باشد چه حجمی عمداً جا مانده. --- ## ۶. `users` → `AdminUser` — و یک ستون خراب | legacy | نوع | Prisma | یادداشت | |---|---|---|---| | `fname`/`lname`/`name` | `varchar(255)` | `firstName`/`lastName` | D17 | | `mobile` | `varchar(255)` | — | بررسی تداخل با `customers` (§۳) | | `password` | `varchar(255)` | `passwordHash` | | | `ut` | `int unsigned` | `userTypeId` → `UserType` | کلید ACL | | `city_id`, `branch_id` | `int` | — | نگاشت id | | `token_mobile`, `notify_count` | | 🟡 بی‌خانه | مثل §۵.۲ | | `api_token`, `remember_token` | | — | لازم نیست | | **`permission`** | **`decimal(16,16)`** | — | **🔴 ستون خراب — زیر** | ### 🔴 `users.permission decimal(16,16)` — ستونی که کار نمی‌کند `decimal(16,16)` یعنی **۱۶ رقم کل، ۱۶ رقم اعشار** — پس **صفر رقم صحیح**. این ستون فقط می‌تواند مقداری بین `0.0000…` و `0.9999…` نگه دارد. هر عدد صحیحی مثل `1` در آن **سرریز** می‌کند. خوشبختانه **مرده است**: ACL واقعی از جدول `permission(rid, ut)` می‌خواند — `AdminController.php:74` → `Permission::whereUt($user->ut)->get()`. **نگاشت: نادیده.** ثبت می‌شود چون نشان می‌دهد اسکیمای legacy در جاهایی بازمانده‌ی بی‌استفاده دارد، و اگر کسی بعداً این ستون را ببیند فکر می‌کند داده‌ی معنی‌دار است. ### `permission` → `Permission` + `UserType` ``` permission (rid varchar(255), ut int) -- ۲ ستون، بدون PK ``` جدول دو‌ستونیِ خالصِ نگاشت. ۱۴۴ rid در مقصد (`acl.constants.ts`) پوشش داده شده‌اند. **بدون primary key** — پس ردیف تکراری ممکن است؛ هنگام مهاجرت `DISTINCT` شود. --- ## ۷. سفارش‌ها — دو جدول جدا → یک مدل legacy **دو جدول کاملاً مستقل** دارد، و هیچ‌کدام فیلتر نوع ندارند چون خودِ جدول تفکیک‌کننده است: | جدول | ستون | نقش | `OrderChannel` | |---|---|---|---| | `orders` | ۱۹ | عمده (B2B) | `WHOLESALE` | | `orders_orders` | ۳۷ | خرده (Django-era) | `RETAIL` | ### ۷.۱ — `orders` → `Order` (عمده) | legacy | نوع | Prisma | یادداشت | |---|---|---|---| | `customer_id` | `int` | `customerId` | نگاشت id | | `reagent_id` | `int` | — | کارشناس سفارش | | `type` | `tinyint` | — | 🔶 نوع ثبت (تلفنی/تلگرام/…) | | `w` | `double` | وزن | §۴ | | `wage` | `double` | اجرت | §۴ | | `percent` | `double` | درصد اجرت | §۴ · معنای سوم `percent` | | `sts` | `tinyint` | `OrderStatus` | §۷.۳ | | `confirmed` | `tinyint(1)` | — | 🔶 جدا از `sts` | | `input`/`output`/`remain` | `double` | — | طرف دفتر | | `price`, `rial_input`, `discount` | | | §۴ | | `city_id` | `int` | — | | | `deleted_at` | `timestamp` | — | **§۵.۶** | ### ۷.۲ — `orders_orders` → `Order` با `channel=RETAIL` ۳۷ ستون، شامل `billing_address`, `shipping_address`, `browser_ip`, `buyer_accepts_marketing`, `email`, `total_tax`, `use_wallet`, `payment_after_wallet`, `gold_price`, `gold_price_original`. طبق **D13 فروشگاه خرده خارج از دامنه است** — پس این جدول **حفظ می‌شود، پورت نمی‌شود**. ولی `Order.channel` مقدار `RETAIL` دارد، یعنی مقصد جای نگه‌داشتنش را دارد. ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۶ — به تأخیر افتاد > «الان مهم نیست و در زمان مناسب بهش ورود می‌کنیم برای تک‌فروشی.» **`orders_orders` در این مرحله منتقل نمی‌شود.** نتایج: - 🟢 **خطر تعارض `sts=7` فعلاً منتفی است** (§۷.۳). سفارش تحویل‌شده به کنسل‌شده تبدیل نمی‌شود، چون هیچ ردیفی منتقل نمی‌شود. - 🟢 `orders_orderitem` (۲۹ ستون) هم منتقل نمی‌شود. - 🟢 `orders_sendtime` و `orders_fulfillment` هم بی‌اثر می‌شوند. - 🟡 `OrderChannel.RETAIL` خالی می‌ماند — انتظارش همین است، باگ نیست. - 🔴 **جدول در MySQL دست‌نخورده نگه داشته شود.** وقتی تک‌فروشی وارد کار شد، منبع همین جدول است. هیچ `DROP` و `TRUNCATE` روی `orders_orders`، `orders_orderitem`، `orders_sendtime`. ⚠️ **بدهی فنیِ ثبت‌شده:** هر وقت تک‌فروشی وارد شود، **جدول نگاشت وضعیتِ دومی** لازم است چون `sts` در دو جدول معانی متضاد دارد (§۷.۳). این نمی‌تواند با همان نگاشت عمده انجام شود. ### ۷.۳ — 🔴 دو enum وضعیت متضاد (MON-07 در سطح داده) `Order::$STS` (عمده): | # | برچسب | `OrderStatus` | |---|---|---| | `0` | معلق | `SUSPENDED` | | `1` | ثبت سفارش | `PLACED` | | `2` | تایید مدیر سیستم | `ADMIN_APPROVED` | | `3` | درحال تامین | `SOURCING` | | `4` | درحال آماده سازی | `PREPARING` | | `5` | تحویل مسئول فروش | `DELIVERED_TO_AGENT` | | `6` | تحویل مشتری | `DELIVERED_TO_CUSTOMER` | | `7` | ~~کنسل شده~~ (کامنت) | `CANCELLED` | `SingleCustomerOrders::$STS` (خرده) — **اعداد یکسان، معانی متفاوت:** | # | برچسب خرده | تعارض | |---|---|---| | `1` | ثبت سفارش | ✅ یکی | | `2` | تایید مدیر سیستم | ✅ یکی | | `3` | در حال تامین | ✅ یکی | | `4` | در حال آماده سازی | ✅ یکی | | `5` | **خروج از انبار** | 🔴 ≠ تحویل مسئول فروش | | `6` | **ارسال** | 🔴 ≠ تحویل مشتری | | `7` | **تحویل مشتری** | 🔴 ≠ کنسل | **`7` در یکی «تحویل مشتری» و در دیگری «کنسل شده» است.** `OrderStatus` در Prisma فقط enum عمده را دارد. پس اگر `orders_orders` منتقل شود، **`sts=7` یعنی سفارشِ تحویل‌شده به `CANCELLED` تبدیل می‌شود** — یک سفارش موفق به کنسل‌شده. و `5`/`6` معنای اشتباه می‌گیرند. 🔶 اگر تصمیم §۷.۲ «منتقل نشود» باشد، این خطر منتفی است. اگر «منتقل شود»، **جدول نگاشت دوم و جداگانه** لازم است. ### ۷.۴ — اقلام سفارش `order_items` (۲۱ ستون) → `OrderItem` (۱۱ فیلد). | legacy | `OrderItem` | |---|---| | `rash_weight` | `rashWeightGramsSnapshot` | | `percent` | `wagePercentSnapshot` | | `discount` | `discountPercentSnapshot` | | `w` | `computedWeightGrams` | | `rial_pay` | `flatRialCharge` | | `qty` | `quantity` | | `variant_id` | `productVariantId` | 🔴 **`original_rash_weight` و `original_percent` خانه ندارند.** وجودِ جفت `original_*` در legacy یعنی مقدار اولیه برای ممیزی نگه داشته می‌شد — تا معلوم باشد وزن یا درصد **بعداً دست‌کاری شده است یا نه**. `OrderItem` فقط یک نسخه دارد (`…Snapshot`). پس این تاریخچه‌ی ممیزی **در مهاجرت از بین می‌رود**. 🔶 تصمیم مالک. 🟡 بی‌خانه‌ی دیگر: `product_id` (مقصد فقط `variantId` دارد), `product_title`, `variant_title` (عنوان لحظه‌ی خرید), `price`, `wage`, `sts`, `note`. ⚠️ `product_title`/`variant_title` در legacy **snapshot عنوان** بودند — اگر نام محصول بعداً عوض شود، فاکتور قدیمی نام قدیمی را نشان می‌داد. مقصد عنوان را از رابطه می‌خواند، یعنی **فاکتورهای تاریخی با تغییر نام محصول عوض می‌شوند**. `orders_orderitem` (۲۹ ستون، خرده) — تابع تصمیم §۷.۲. --- ## ۸. اسناد مالی — `cashes` → `LedgerEntry` `cashes` با ۳۳ ستون. `Cash::$TYPES` → `LedgerSourceType`: | legacy | برچسب | `LedgerSourceType` | |---|---|---| | `9` | سفارش | `ORDER_STOCK` | | `1` | آب شده | `REPAYMENT_MELTED_GOLD` | | `2` | حواله | `REPAYMENT_TRANSFER` | | `3` | چک | `REPAYMENT_CHEQUE` | | `4` | پولی | `REPAYMENT_CASH` | | — | — | `ORDER_PRE_ORDER` ➕ | | — | — | `WALLET_BONUS` ➕ (D13) | | — | — | `ADJUSTMENT` ➕ | نگاشت تمیز است. `$STS`: `-1` بررسی‌نشده · `1` ثبت سند. `$CONFIRMED`: `0`/`1`. 🔴 **فقط اسناد `confirmed=1` در موجودی حساب می‌شوند** (D24). پس مهاجرت باید `confirmed` را حفظ کند، نه اینکه همه را تأییدشده فرض کند. ### ستون‌های مخصوص نوع سند — ✅ مقصد پوشش داده `LedgerEntry` عمداً همین ستون‌ها را دارد: | legacy | `LedgerEntry` | |---|---| | `ayar` | `karatPerMille` (`Int?`) | | `lab` | `labName` | | `ang` | `hallmark` | | `account_name` | `accountName` | | `bank_name` | `bankName` | | `to` | `transferTo` | | `original_value` | `originalValue` | | `price` | `priceRial` | | `price_per_gram` | `pricePerGramRialAtEntry` | | `recive_date` | `receivedAt` (`DateTime?`) | | `note` | `note` | | `confirmed` | `confirmed` + `confirmedAt` | | `order_id` | `orderId` | | `remain`/`input`/`output` | `amount` + `direction` | 🟡 بی‌خانه: `balance`, `value`, `no` (شماره‌ی سند), `proceed_at`, `title`, `user_id`, `sts`, `discount`. 🔴 **`ayar` در مبدأ `varchar(20)` است، در مقصد `Int?`.** یعنی اگر عیار به‌صورت متن («۱۸ عیار» یا «750») ذخیره شده، تبدیل عددی لازم است و مقادیر غیرعددی می‌شکنند. 🔴 **`recive_date` و `proceed_at` در مبدأ `varchar(255)` هستند، نه `timestamp`** — به احتمال زیاد **تاریخ جلالی به‌صورت رشته‌ی فارسی**. مقصد `receivedAt` را `DateTime?` گرفته. پس تجزیه‌ی جلالی → میلادی لازم است، نه `CAST`. و `proceed_at` خانه ندارد. ### `login` → `Customer` با `kind=RETAIL` `login` (۱۹ ستون) جدول مشتری خرده است (`fname`, `lname`, `percent`, `user_type`, `birth_day`, `token_sms`). طبق D13 حفظ، بدون کار. --- ## ۹. کاتالوگ ### `product` → `Product` (۳۵ ستون) نگاشت‌شدنی: `title`, `body_html`, `product_code`, `is_active`, `disabled`, `brand_id`, `branch_id`, `min_gram`, `max_gram`, `show_type`, `delivery_type`, `message_long_delivery`, `offer_day`, `is_exist`, `has_warranty`, `desk_note`. 🟡 بی‌خانه‌ی احتمالی: `metafields_global_title_tag`, `metafields_global_description_tag`, `tags`, `meta`, `supplier_id`, `published_at`, `number_of_purchase`, `count_fav`, `count_order`, `count_view`, `inventory_id`, `indicator_id_id`. ⚠️ `indicator_id_id` و `mobile_pic_id` و `file_id` — سه ستون تصویر با نام‌گذاری ناهمگون (`_id_id` دوتایی، بازمانده‌ی Django). در Prisma `ProductImage` با `ProductImageRole` (`FEATURED`, `FEATURED_MOBILE`, `GALLERY`, `GALLERY_MOBILE`) جایشان را گرفته. **نگاشت سه ستون → نقش‌ها باید صریح نوشته شود.** ### `products_productvarient` → `ProductVariant` (۲۵ ستون) ⚠️ **غلط املایی در نام جدول: `varient` (صحیح: `variant`).** در SQL مهاجرت باید عیناً `products_productvarient` نوشته شود. 🔴 **`Inventory` با I بزرگ.** ستون در دامپ `` `Inventory` int `` است، ولی `ProductVariant.php:36` در `$fillable` نوشته `'inventory'` (کوچک). MySQL در لینوکس نام **ستون** را case-insensitive می‌گیرد، پس در legacy کار می‌کند. **Postgres این‌طور نیست.** اگر کسی کوئری مهاجرت را با `inventory` بنویسد، MySQL قبول می‌کند — ولی هر ابزار سخت‌گیرتری خطا می‌دهد. **در کوئری‌ها `` `Inventory` `` با I بزرگ و داخل backtick نوشته شود.** نگاشت بقیه: `grams`, `rial_pay` (اجرت ریالی/سنگ), `percentage_pay`, `barcode`, `color` (`smallint` — جدول رنگ لازم), `qty`, `is_pre_order`, `is_active`, `width`/`length`/`height`, `position`, `product_id_id`. ### `category_collection` → `Category` (۲۴ ستون) `parent_id_id` → سلسله‌مراتب. ⚠️ `image_id_id` **و** `image_id` **و** `icon_id` — سه ستون تصویر، یکی با `_id_id` دوتایی. و `type` **و** `category_type` هر دو وجود دارند. 🔶 **ترتیب ۹ دسته‌ی خانه با id عددی هاردکد شده** (Q5 در `PWA-PORT-PLAN.md`). جدول نگاشت `legacyId → id` برای این **اجباری** است. `category_collect` (۹ ستون) → `CollectionProduct` — جدول واسط. ⚠️ سه FK دارد (`collection_id_id`, `post_id_id`, `product_id_id`) که `post_id_id` در مقصد معادلی ندارد. ### `image` → `UploadedFile` (۲۰ ستون) `image` (`char(100)`) مسیر فایل · `original_name`, `ext`, `width`, `height`. 🟡 بی‌خانه: `resize`, `resize_path`, `resize_watermark`, `resize_watermark_path`, `resize_small`, `resize_small_path`, `banner`, `position`, `link`. legacy شش ستون برای نسخه‌های تغییراندازه‌یافته داشت. **اگر مقصد این‌ها را ندارد، باید تصاویر بازتولید شوند یا فقط نسخه‌ی اصلی بماند.** ### `cities` → `City` (۱۱ ستون) `parent`, `level`, `has_children` → سلسله‌مراتب. ⚠️ `percent` و `percent_aux` — **معنای چهارم `percent`!** `MIGRATION-STATE.md` سه معنا را فهرست کرده؛ این چهارمی است: اضافه‌بهای اجرت به‌تفکیک شهر. ✅ **مقصد هر دو را دارد:** `City.percent` و `City.percentAux`، هر دو `Decimal(6,3)?`، با کامنت صریح که از `cities.percent`/`percent_aux` آمده‌اند. نگاشت تمیز است. ### `branches`/`workshops` → `Branch`/`Workshop` هر دو ۹ ستون، ساختار یکسان (`name`, `address`, `phone`, `user_id`, `is_active`). نگاشت تمیز. --- ## ۱۰. بقیه‌ی جداول زنده | legacy | ستون | مقصد | یادداشت | |---|---|---|---| | `custom_orders` | ۱۶ | `PreOrder` | `weight`, `color`, `size`, `color_stone`, `color_mina`, `workshop_id` | | `custom_order_logs` | | `PreOrderStatusEvent` | | | `order_activity` | | `OrderStatusEvent` | | | `comments` | ۹ | `Comment` | `reply_to` خودارجاع | | `major_promotion` | ۱۵ | `Promotion` | `type_customer`, `use_only`, `discount_percent`/`discount_price` | | `major_promotion_ignores` | | `PromotionExclusion` | | | `promotion_products` | ۱۳ | `PromotionExclusion`? | 🔶 ابهام | | `inventory_branches` | ۹ | `BranchAllocation` | `count_item`, `sum_weight` | | `inventory_transactions` | ۹ | `BranchAllocationItem` | | | `messages` | ۹ | `SmsMessage` | ⚠️ `massage` — غلط املایی | | `notify` | ۱۳ | `Notification` + `NotificationRecipient` | `read`, `type_send` | | `message_notification` | ۹ | `Notification` | 🔶 دو جدول اعلان | | `config_statics` | ۶ | `Setting` | key/value · شامل `percent` کیف پول (D13) | | `fav` | ۲ | ❌ **مدل ندارد** | `PWA-PORT-PLAN` می‌گوید جدول تازه لازم است | | `logs` | ۱۲ | `CustomerActivityLog` | | | `apk_log` | ۴ | 🟡 بی‌خانه | `ip`, `version` — آمار نسخه‌ی اپ | ### جداول کوچکی که مقصد ندارند — ✅ بررسی شد هر شش جدول مدل Eloquent دارند و `$fillable`شان خوانده شد: | legacy | `$fillable` | چیست | مقصد | |---|---|---|---| | `fav` | `customer_id`, `product_id` | علاقه‌مندی | ❌ **ندارد** | | `cinies` | `title`, `icon_id`, `is_active`, `description`, `collection_id` | «سینی» — گروه‌بندی نمایشی محصول | ❌ ندارد | | `ciny_products` | `product_id`, `ciny_id` | واسط سینی↔محصول | ❌ ندارد | | `kids` | `phone`, `name`, `num` | فهرست فرزندان (تبریک تولد؟) | ❌ ندارد | | `links` | `url`, `counter` | شمارنده‌ی کلیک لینک | ❌ ندارد | | `request_fashions` | `name`, `mobile` | درخواست «مد» — فرم تماس | ❌ ندارد | | `request_logs` | `customer_id`, `ip`, `device`, `data`, `user_agent`, `route` | لاگ درخواست | تقریباً `CustomerActivityLog` | ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۶ — `fav` لازم است > «بله» — علاقه‌مندی‌ها مهم‌اند. **یک migration افزایشی لازم است.** چون additive است، با `PROTECTED.md` §G تضادی ندارد و قابلیت موجودی را لمس نمی‌کند. مدل پیشنهادی — ساده‌ترین چیزی که legacy را پوشش می‌دهد: ```prisma model CustomerFavorite { customerId String customer Customer @relation(fields: [customerId], references: [id]) productId String product Product @relation(fields: [productId], references: [id]) createdAt DateTime @default(now()) @@id([customerId, productId]) } ``` ⚠️ `fav` در legacy **فقط دو ستون دارد و هیچ PK ندارد** — پس ردیف تکراری ممکن است. `@@id([customerId, productId])` این را می‌بندد، ولی مهاجرت باید `DISTINCT` بزند وگرنه روی کلید مرکب می‌شکند. ⚠️ `Product.countFav` در legacy یک شمارنده‌ی denormalized بود (`product.count_fav`). اگر مقصد آن را ندارد، از `CustomerFavorite` محاسبه شود — نه اینکه صفر نشان داده شود (§۵.۲ گروه ب). 🔶 **`cinies`/`ciny_products` هنوز تصمیم ندارند.** ساختار نمایشی‌اند (`title`, `icon_id`, `collection_id`) — اگر صفحه‌ی خانه‌ی اپ از آن‌ها می‌خواند، نبودشان چیدمان را خالی می‌کند. این **داده‌ی کاربر نیست**، پس فوریتش کمتر از `fav` است. `kids`, `links`, `request_fashions` کم‌اهمیت‌اند و رهاکردنشان قابل‌دفاع است. --- ## ۱۰.۴ — نتایج پیش‌بررسی روی داده‌ی واقعی (۲۰۲۶-۱۰-۰۶) خروجی [`migration-precheck.sql`](./migration-precheck.sql). این بخش **فرض‌های بخش‌های قبل را با عدد واقعی تأیید یا رد می‌کند**. ### حجم کار — کوچک‌تر از انتظار | جدول | زنده | حذف‌شده‌ی نرم | نکته | |---|---|---|---| | `customers` | **۱٬۶۱۶** | ۲۱ | | | `users` | ۲۲ | ۰ | ادمین‌ها | | `orders` | ۵۰۸ | **۲۰۸** | ۲۹٪ حذف‌شده | | `order_items` | ۱٬۰۶۷ | **۴۵۳** | ۳۰٪ | | `cashes` | ۶۴۸ | **۲۶۵** | ۲۹٪ | | `product` | ۳٬۲۸۷ | **۲٬۰۱۵** | **۳۸٪ حذف‌شده** | | `variant` | ۷٬۴۷۴ | **۶٬۱۹۴** | 🔴 **۴۵٪ حذف‌شده** | | `image` | **۱۸٬۰۷۷** | ۰ | فایل‌ها باید کپی شوند | | `messages` | **۲۴٬۴۸۰** | ۰ | بزرگ‌ترین جدول | | `fav` | ۲۵۱ | — | | | `permission` | ۷۲۸ | — | ۱۴۴ rid × چند `ut` | 🔴 **soft-delete حجم بالایی دارد** — ۶٬۱۹۴ واریانت و ۲٬۰۱۵ محصول حذف‌شده‌اند. §۵.۸ می‌گوید مقصد `deleted_at` ندارد. پس قاعده‌ی `WHERE deleted_at IS NULL` **نه یک احتیاط، بلکه تفاوت بین ۱٬۲۸۰ و ۷٬۴۷۴ واریانت** است. بدون آن، کاتالوگ شش برابر می‌شود و محصولات حذف‌شده زنده برمی‌گردند. ### ✅ خبر خوب — سه نگرانی منتفی شد | نگرانی | پیش‌بینی | واقعیت | |---|---|---| | کد ملی تکراری | محتمل | **۰ گروه** ✅ | | `name` ناسازگار با اجزا | نامعلوم | **فقط ۴ ردیف** ✅ | | اجزای نام تهی | نامعلوم | **۰** ✅ — §۲ کامل تأیید شد | | `fav` تکراری | محتمل | **۰** ✅ | | `permission` تکراری | محتمل | **۰** ✅ | | `ayar` غیرعددی | محتمل | **۰ از ۳۳** ✅ تبدیل به `Int` بی‌خطر | | یتیم `city_id`/`image_id`/سفارش/سند | نامعلوم | **۰** ✅ | | `customers_copy1/2` ردیف یکتا | محتمل | **۰** ✅ بی‌خطر نادیده بگیرید | **§۲ قطعی شد:** `empty_fname=0`, `empty_lname=0`, `parts_empty_name_set=0`. یعنی **هر ۱٬۵۹۵ ردیف اجزای نام پر دارند** و تجزیه‌ی `name` مطلقاً لازم نیست. D0 با اطمینان کامل تصحیح می‌شود. ### 🔴 سه مسدودکننده‌ی تأییدشده **۱. موبایل تکراری — ۲۷ گروه، ۵۹ ردیف.** `@unique` روی `mobile` **قطعاً می‌شکند**. تصمیم مالک: «مسئول فروش برنده است». 🔶 ولی اگر هر دو ردیفِ تکراری مشتری عادی باشند (نه کارشناس)، این قاعده جواب نمی‌دهد. قاعده‌ی پشتیبان لازم است — پیشنهاد: **ردیف با `updated_at` جدیدتر برنده، بقیه با پسوند در جدول نگاشت ثبت شوند تا دستی بررسی شوند.** **۲. کد ملی تهی — ۱٬۵۹۵ از ۱٬۵۹۵ ردیف... یعنی همه.** `empty_ncode = 1595` و `n_customers = 1595`. **هیچ مشتری کد ملی ندارد.** ولی Prisma: ```prisma nationalCode String @unique // ← NOT NULL ``` 🔴 **مهاجرت روی اولین ردیف می‌شکند.** و `@unique` روی رشته‌ی خالی یعنی فقط **یک** ردیف می‌تواند `''` باشد. 🔶 **تصمیم فوری لازم:** `nationalCode` باید `String?` شود (nullable). این یک migration افزایشی روی مقصد است. بدون آن مهاجرت مشتری **غیرممکن** است. **۳. گِردکردن رخ می‌دهد — ۲۴ + ۳۲ ردیف.** `gold_needs_rounding = 24` و `order_weight_needs_rounding = 32`. یعنی §۴ درست بود: **«صفر اختلاف» دست‌نیافتنی است** و آستانه‌ی عددی لازم است. مبنای تطبیق برای ابزار: ``` sum_gold_debt 427.5041945329501 ← ۱۶ رقم معنادار، شاهد خودِ مشکل sum_rial_debt 250000 sum_credit_limit 423623.33999999997 ← .33999999997 یعنی 423623.34 n_nonzero_gold 35 n_negative_gold 12 ``` ⚠️ **۱۲ مشتری بدهی طلای منفی دارند** (طلبکارند). مقصد `Decimal` علامت‌دار است، پس مشکلی نیست — ولی تطبیق باید علامت را هم چک کند. ### 🔴 `cashes` — همه‌ی ستون‌های مبلغ `NULL` هستند ``` cash_amount_needs_rounding NULL confirmed_only sum_gold=NULL sum_rial=NULL n=375 ``` `SUM()` فقط وقتی `NULL` برمی‌گرداند که **هیچ ردیف غیر‌NULL نباشد**. یعنی `cashes.remain` و `cashes.rial_remain` در **هر ۶۴۸ ردیف** `NULL` هستند. پس مبلغ سند مالی در این دو ستون **نیست**. جای دیگری است — `value`, `original_value`, `input`/`output`, یا `price`. §۸ نگاشت `remain/input/output → amount + direction` را پیشنهاد داده بود؛ **این نگاشت باید بازبینی شود.** 🔶 **یک کوئری تکمیلی لازم است** (§۱۰.۶) تا معلوم شود مبلغ در کدام ستون است. بدون این، `LedgerEntry.amount` صفر یا `NULL` پر می‌شود و **کل دفتر مالی خالی منتقل می‌شود**. ✅ نکته‌ی خوب: `confirmed=1` در ۳۷۵ و `confirmed=0` در ۸ ردیف — D24 («فقط تأییدشده‌ها در موجودی») قابل اجراست. مجموع ۳۸۳ از ۶۴۸، پس ۲۶۵ ردیف `confirmed` تهی دارند که با حذف‌شده‌ها هم‌خوانی دارد. ### 🔴 رمز عبور — ۱٬۵۹۰ از ۱٬۵۹۵ تهی ``` pw_bcrypt 5 pw_other 0 pw_empty 1590 ``` **تقریباً هیچ مشتری رمز ندارد.** منطقی است: ورود مشتری با **OTP** است، نه رمز. ولی Prisma: ```prisma passwordHash String // ← NOT NULL ``` 🔴 **مهاجرت می‌شکند.** دو راه: `passwordHash` nullable شود، یا مهاجرت یک hash تصادفیِ غیرقابل‌استفاده بگذارد. **گزینه‌ی دوم امن‌تر است** — ستون اجباری می‌ماند و هیچ‌کس نمی‌تواند با رمز خالی وارد شود. ⚠️ آن ۵ رمز `$2y$` (bcrypt) هستند و مقصد هم bcrypt است، پس **بدون تغییر منتقل می‌شوند**. ### enum ها — دو نکته **`type IS NULL` → ۵۸ ردیف.** §۵.۳ هشدار داده بود. این ۵۸ بی‌صدا `APP_SHOPKEEPER` می‌شوند. 🔶 تصمیم مالک: درست است یا قاعده‌ی دیگری لازم است؟ (۵۸ از ۱٬۶۱۶ ≈ ۳.۶٪) **`sts` و `active` مقدار `-1` دارند، نه `0`.** `sts: 1→۱۱۹۱, -1→۴۰۴` و `active: 1→۱۳۶۸, -1→۲۲۷`. نگاشت `1→true`, `-1→false` که §۵.۱ نوشته بود **درست است** ✅. **`orders.sts`** هر ۷ مقدار `0..6` را دارد، هیچ `7` — پس نگاشت `OrderStatus` کامل است ✅ و `CANCELLED` در عمده استفاده نشده. **`cashes.type`** هر ۵ مقدار (`1,2,3,4,9`) موجودند و همه نگاشت دارند ✅. ### تک‌فروشی (به تأخیر افتاده) — تأیید خطر ``` orders_orders 520 live retail_sts: 7 → ۳ ردیف ``` **۳ ردیف `sts=7` وجود دارند** — یعنی خطر §۷.۳ واقعی بود: اگر روزی منتقل شوند، ۳ سفارشِ «تحویل مشتری» به `CANCELLED` تبدیل می‌شوند. ثبت شد برای آینده. ### ⚠️ ۱۴ یتیم در `fav` `fav.customer_id → customers` ۱۴ یتیم دارد. چون `fav` خودش PK ندارد و FK هم نیست، این‌ها ردیف‌هایی‌اند که مشتری‌شان حذف شده. **در مهاجرت باید نادیده گرفته شوند** وگرنه FK مقصد می‌شکند. --- ## ۱۰.۵ — 🔴 مهاجرت `merge` است، نه `import` **تصمیم مالک ۲۰۲۶-۱۰-۰۶:** داده‌ی حسابداری و تصویر واریانت در سیستم جدید **واقعی است**. پس دیتابیس مقصد خالی نیست. این فرض بنیادی سند را عوض می‌کند: | | `import` (فرض اولیه) | **`merge` (واقعیت)** | |---|---|---| | مقصد | خالی | **داده‌ی واقعی دارد** | | خطا در میانه | دور بریز، از نو | 🔴 **داده‌ی واقعی خراب شد** | | تکرارپذیری | آزاد | فقط با idempotency واقعی | | `UPDATE` کامل ردیف | بی‌خطر | 🔴 **ممنوع** | ### ستون‌هایی که ابزار مهاجرت هرگز نباید بنویسد این‌ها در legacy **هیچ معادلی ندارند** و از سامانه‌های مقصد پر می‌شوند. یک `upsert` معمولی یا `UPDATE` کامل، همه را `NULL` می‌کند. | ستون | مدل | پر می‌شود از | اگر پاک شود | |---|---|---|---| | `accountingGoldBalance` | `Customer` | نرم‌افزار حسابداری (D27) | موجودی طلای مشتری گم می‌شود | | `accountingRialBalance` | `Customer` | نرم‌افزار حسابداری | موجودی ریالی گم می‌شود | | `accountingBalanceAt` | `Customer` | نرم‌افزار حسابداری | زمان آخرین sync گم می‌شود | | `accountingStockWeight` | `Product` | نرم‌افزار حسابداری | موجودی وزنی کارساخته گم می‌شود | | `accountingStockCount` | `Product` | نرم‌افزار حسابداری | موجودی تعدادی گم می‌شود | | `accountingStockAt` | `Product` | نرم‌افزار حسابداری | زمان آخرین sync گم می‌شود | | **`imageId`** | **`ProductVariant`** | **انتخاب دستی ادمین** | **تصویر هر واریانت گم می‌شود** | به‌علاوه **سه جدول کامل** که هیچ مبدأ legacy ندارند و باید کاملاً نادیده گرفته شوند: `AccountingLink`, `AccountingOutbox`, `AccountingIssue`. ### 🔴 `ProductVariant.imageId` — حدس زده نشود `products_productvarient` در legacy **هیچ ستون تصویر ندارد** (۲۵ ستون، بررسی شد). پس این قابلیت **مقصد-تنها** است و `PROTECTED.md` §B آن را «additive only» اعلام کرده. **قاعده: مهاجرت `imageId` را لمس نمی‌کند — نه می‌نویسد، نه `NULL` می‌کند.** اگر ابزار بخواهد از `file_id` محصول حدس بزند، تصویرِ انتخابیِ ادمین با حدس جایگزین می‌شود. ⚠️ `ProductImage` با `onDelete: SetNull` به واریانت وصل است. پس اگر مهاجرت تصاویر محصول را **حذف و از نو بسازد** (الگوی رایج در sync)، `imageId` هر واریانت بی‌صدا `NULL` می‌شود — بدون هیچ خطایی. **تصاویر باید افزایشی به‌روز شوند، نه جایگزین.** ### قاعده‌ی اجباری ابزار > ابزار مهاجرت **فقط ستون‌هایی را می‌نویسد که نگاشت صریح legacy دارند.** > فهرست ستون‌های نوشتنی **سفید** است (allow-list)، نه سیاه. همان الگوی «ارسال تفاضلی» که در `CHANGELOG-PORT-HISTORY.md` §۵ برای فرم مشتری اعمال شد — چون همان اشتباه (`Object.assign` ناقص) آنجا هم چهار ستون را پاک می‌کرد. ### ✅ کلید تطبیق — تصمیم مالک ۲۰۲۶-۱۰-۰۶ | سؤال | جواب | |---|---| | کلید تطبیق | **`mobile`** | | در تعارض کدام برنده | **مسئول فروش** | | اهمیت | **کم** — داده‌های مقصد فعلاً روی لوکال‌اند | **نتیجه‌ی عملی:** چون داده‌ی مقصد لوکال است و اهمیتش کم، **ریسک `merge` بسیار کمتر از آن است که §۱۰.۵ فرض کرده بود.** ولی قاعده‌ی allow-list **همچنان لازم است**، به دو دلیل: 1. همان ابزار بعداً روی **staging و تولید** اجرا می‌شود (`GO-LIVE §۸`)، آنجا داده واقعی است 2. تصویر واریانت و اتصال حسابداری همین الان **واقعی** هستند (گفته‌ی مالک) ⚠️ **تداخل `mobile` با کلیدِ تطبیق تعارض دارد.** ۲۷ گروه موبایل تکراری در مبدأ وجود دارد (§۱۰.۴). اگر کلید تطبیق `mobile` باشد، **برای ۵۹ ردیف معلوم نیست به کدام ردیف مقصد وصل شوند.** قاعده‌ی «مسئول فروش برنده» فقط ۱۰ مورد تداخل `customers`↔`users` را حل می‌کند، نه ۲۷ گروه داخلی را. 🔶 قاعده‌ی پشتیبان پیشنهادی: در گروه تکراری، ردیف با `updated_at` جدیدتر برنده؛ بقیه منتقل می‌شوند ولی در گزارش «نیاز به بررسی دستی» فهرست شوند. چون اهمیت کم است، می‌توان این را به گزارش سپرد و دستی حل کرد. --- ## ۱۰.۶ — پرسش دوم از دیتابیس 📄 **[`migration-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی که خروجی اول باز گذاشت. مثل فایل اول: فقط `COUNT`/`SUM`، صفر نوشتن، هیچ ستون متنی در خروجی (تنها استثنا: `products_productimage.w` که یک برچسب اندازه است، نه داده‌ی شخصی). | # | سؤال | چرا مسدودکننده است | |---|---|---| | ۱ | **مبلغ سند مالی در کدام ستون است؟** | `remain`/`rial_remain` همه `NULL`اند. بدون جواب، `LedgerEntry.amount` خالی می‌ماند و **کل دفتر مالی بی‌مبلغ منتقل می‌شود** | | ۲ | آیا «مسئول فروش برنده» برای ۲۷ گروه تکراری کافی است؟ | اگر گروهی کارشناس نداشته باشد، قاعده بی‌اثر است | | ۳ | سه ستون تصویر → چهار نقش `ProductImageRole` | نگاشت غلط = تصویر غلط روی محصول | | ۴ | تأیید §۵.۷ — `reagent_id` به `customers` اشاره می‌کند؟ | اگر غلط باشد، رابطه‌ی کارشناس معرف از بین می‌رود | --- ## ۱۱. جمع‌بندی — کارهای لازم قبل از نوشتن ابزار ### 🔴 مسدودکننده (بدون این‌ها ابزار در میانه می‌شکند یا داده خراب می‌کند) | # | مورد | § | |---|---|---| | ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) | | ۱.۱ | 🔴 **`nationalCode` باید nullable شود** — هر ۱٬۵۹۵ ردیف تهی است | ۱۰.۴ | | ۱.۲ | 🔴 **`passwordHash`** — ۱٬۵۹۰ تهی؛ hash تصادفی یا nullable | ۱۰.۴ | | ۱.۳ | 🔴 **مبلغ سند مالی کجاست؟** `cashes.remain` همه `NULL` | ۱۰.۶ | | ۱.۴ | 🔴 **`VerificationStep` حالت `REJECTED` ندارد** — ۱۴ ردیف | ۵.۵ | | ۱.۵ | 🔴 **ترتیب دومرحله‌ای** — کارشناسان قبل از مشتریان | ۵.۷ | | ۲ | آستانه‌ی تطبیق عددی جای «صفر اختلاف» — **تأیید شد: ۵۶ ردیف گِرد می‌شوند** | ۴ · ۱۰.۴ | | ۳ | قاعده‌ی `WHERE deleted_at IS NULL` — **حیاتی‌تر از انتظار: ۴۵٪ واریانت‌ها** | ۵.۸ · ۱۰.۴ | | ۴ | ~~تصمیم `orders_orders`~~ | ✅ **حل — به تأخیر افتاد** (§۷.۲) | | ۵ | تصحیح D0: `fname`/`lname` مستقیم، تجزیه نشود | ۲ | | ۶ | **allow-list ستون‌های نوشتنی** — ۷ ستون مقصد-تنها لمس نشوند | **۱۰.۵** | | ۷ | **کلید تطبیق ردیف‌های موجود مقصد** | **۱۰.۵** | ### 🔶 تصمیم مالک | # | مورد | § | |---|---|---| | ۱ | ۴ ستون داده‌ی غیرقابل‌بازسازی (`birth_date`, `marriage_date`, `token_mobile`, `new_city`) | ۵.۲ | | ۲ | `verificationStep` — تفکیک `STEP_TWO_SUBMITTED` از `VERIFIED` | ۵.۵ | | ۳ | `type IS NULL` چه `CustomerKind` شود | ۵.۳ | | ۴ | ~~`fav`~~ | ✅ **حل — لازم است، migration افزایشی** (§۱۰) | | ۴.۱ | `cinies`/`ciny_products` — چیدمان نمایشی | ۱۰ | | ۵ | شمارنده‌های denormalized محاسبه شوند یا صفر بمانند | ۵.۲ | | ۶ | نسخه‌های تغییراندازه‌یافته‌ی تصویر | ۹ | | ۷ | `original_rash_weight`/`original_percent` — تاریخچه‌ی ممیزی قلم سفارش | ۷.۴ | | ۸ | snapshot عنوان محصول در فاکتورهای تاریخی | ۷.۴ | ### ✅ یافته‌های جانبی (ثبت، بدون کار) - `users.permission decimal(16,16)` ستونی است که ریاضیاً کار نمی‌کند — مرده (§۶) - `products_productvarient.Inventory` با I بزرگ، برخلاف `$fillable` (§۹) - نام جدول `varient` غلط املایی دارد (§۹) - `messages.massage` غلط املایی دارد (§۱۰) - `cities.percent` معنای **چهارم** `percent` است (§۹) - `orders_histroy` غلط املایی دارد - صفر foreign key در کل ۸۰ جدول — یکپارچگی ارجاعی در اپ بود، نه دیتابیس --- ## ۱۲. آنچه این سند تأیید نمی‌کند ⚠️ **دامپ داده ندارد.** هر عددی در این سند از *ساختار* آمده. تعداد ردیف، تکرار، تهی‌بودن و اختلاف محاسباتی **همه داده‌ی واقعی لازم دارند**. ⚠️ **`ayar` و `recive_date`/`proceed_at` بر اساس *نوع* ستون تحلیل شدند، نه محتوا.** اینکه عیار متنی است یا عددی، و تاریخ جلالی است یا میلادی، فقط با دیدن داده قطعی می‌شود. ⚠️ **ستون‌به‌ستونِ کامل فقط برای `customers` نوشته شد** (۴۹/۴۹). بقیه‌ی جداول در سطح «نگاشت‌شدنی / بی‌خانه / مبهم» بررسی شدند. برای جداول بزرگ (`cashes` ۳۳، `product` ۳۵، `orders_orders` ۳۷) اگر لازم است، ستون‌به‌ستون در ادامه نوشته می‌شود. ⚠️ **موتور مقصد Postgres است و مبدأ MySQL** — تفاوت‌های حساسیت به بزرگی حرف، `tinyint(1)` در برابر `boolean`، و `timestamp` با/بدون منطقه‌ی زمانی هنگام نوشتن ابزار باید یک‌به‌یک بررسی شوند. --- ## مرجع - [`GO-LIVE-PLAN.md`](./GO-LIVE-PLAN.md) §۷ — ابزار مهاجرت و شرط‌های تطبیق - [`parity/DECISIONS.md`](./parity/DECISIONS.md) — D0, D9, D13, D17, D21, D24 - [`parity/MIGRATION-STATE.md`](./parity/MIGRATION-STATE.md) — سه معنای `percent` - [`parity/ISSUES.md`](./parity/ISSUES.md) — MON-07 (دو enum وضعیت) - [`PWA-PORT-PLAN.md`](./PWA-PORT-PLAN.md) — Q5 (ترتیب دسته‌ها)، نقص #۱۹