# نگاشت دیتابیس 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 ` برای هر ۹ مورد ممکن است، ولی در `_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 "" \ --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 (ترتیب دسته‌ها)، نقص #۱۹