Files
hedena_gold/docs/DATA-MIGRATION-MAP.md
T
s.esmaeiliandClaude Opus 5.5 4d3b15c3b9 docs: data migration map — precheck results, owner rulings, run status
DATA-MIGRATION-MAP.md now records:
- the precheck numbers from the live henza DB and the assumptions they
  overturned: verify steps are 1/2/3, not boolean; reagent_id points at
  customers, not users; cashes.remain/rial_remain are NULL on every row
- owner rulings: mobile as the match key, sales expert wins conflicts,
  merge rather than import, allow-list columns only, retail
  orders_orders deferred, fav and birth_date kept, token_mobile and
  marriage_date dropped
- run status of the prep migration and the REJECTED consumer audit
- a process hazard: on the dev machine, a file in prisma/migrations is
  applied by the watch-mode server on the next src/ edit
- two open owner questions about the live verify() flow

migration-precheck-3.sql: read-only (COUNT only). Checks where new_city
should land and whether birth_date holds Jalali years.

PROGRESS.md: workstream row pointing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:37:34 +03:30

83 KiB
Raw Blame History

نگاشت دیتابیس legacy → Prisma

سند نگاشت، نه پلن اجرا. هیچ کدی بر اساس این سند نوشته نشده. پیش‌نیاز ابزار مهاجرت داده‌ی 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:

PRIMARY KEY (`id`) USING BTREE

همین. هیچ unique index دیگری، و هیچ foreign key.

ولی Prisma اعلام کرده:

mobile       String  @unique
nationalCode String  @unique

نتیجه: به احتمال زیاد در داده‌ی تولید موبایل یا کد ملی تکراری وجود دارد. مهاجرت در میانه با خطای unique متوقف می‌شود — و چون در میانه است، نیمی از داده منتقل شده.

GO-LIVE §۷ تکرار شماره بین Customer و AdminUser را ثبت کرده، ولی تکرار داخل خود customers را ندیده. این یک شکاف تازه است.

کوئری‌های شمارش — در فایل جداگانه

📄 migration-precheck.sql — ۹ بخش، فقط COUNT/SUM، صفر INSERT/UPDATE/DELETE. هیچ ستون متنی در خروجی ظاهر نمی‌شود، پس داده‌ی مشتری از سرور بیرون نمی‌آید.

mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck.sql > out.txt

نمونه‌ی مختصر از بخش ۲ همان فایل:

-- ۱) موبایل تکراری
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 گِرد می‌کند. در یک ردیف بی‌اهمیت است؛ در جمعِ هزاران ردیفِ دفتر، اختلاف انباشته می‌شود.

پیشنهاد — آستانه‌ی واقع‌بینانه جای صفر مطلق

بررسی آستانه‌ی فعلی §۷ آستانه‌ی پیشنهادی
بدهی طلای هر مشتری صفر اختلاف `
بدهی ریالی هر مشتری صفر اختلاف `
جمع کل بدهی طلا — `

بدون این، ابزار مهاجرت همیشه قرمز گزارش می‌دهد و کسی به گزارشش اعتماد نمی‌کند — که بدتر از نداشتنِ گزارش است.

⚠️ 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 افزایشی لازم:

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 رد شد.

دو تغییر افزایشی روی مقصد:

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 ✅ ساخته می‌شود (خواست مالک · برای آینده)
مقدارش در مهاجرت ۱۴ ردیف NULL — چون مبدأ داده‌ای ندارد
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. تا جوابش نیاید، نمی‌دانم ستون تازه بسازم یا به 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:

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 را پوشش می‌دهد:

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. این بخش فرض‌های بخش‌های قبل را با عدد واقعی تأیید یا رد می‌کند.

حجم کار — کوچک‌تر از انتظار

جدول زنده حذف‌شده‌ی نرم نکته
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:

nationalCode String  @unique     // ← NOT NULL

🔴 مهاجرت روی اولین ردیف می‌شکند. و @unique روی رشته‌ی خالی یعنی فقط یک ردیف می‌تواند '' باشد.

✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — nullable شود

«وقتی دیتاست مبدا فاقد این داده است، هیچ گزینه دیگری جز Nullable کردن فیلد وجود ندارد. ساختن کد ملی فیک تخلف دیتایی است و مانع از احراز هویت‌های قانونی بعدی می‌شود.»

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:

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 برگردد.»

پذیرفته شد — و این روش از تست‌کردن تنها بهتر است، چون به رفتار کتابخانه وابسته نمی‌ماند:

// قبل از هر فراخوانی 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 و قابلیت‌های حسابداری/تصویر واریانت/پیامک تضادی ندارند.

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 تابع runMigrations() را صدا می‌زند که هر فایل معلق در prisma/migrations را اعمال می‌کند (D15)

ویرایش accounting-sync.service.ts (برای رفع خطای نوع) ری‌استارت را زد و migration در 13:42:36 در _goldbank_migrations ثبت شد — پیش از تأیید مالک. نتیجه درست و تأییدشده است، ولی روال «اول نشان بده، بعد اجرا کن» (D0) روی این ماشین کار نمی‌کند.

قاعده از این پس: تا تأیید نشده، migration بیرون از prisma/migrations نوشته شود (مثلاً docs/ یا scratchpad)، یا سرور توسعه متوقف باشد. فقط پس از تأیید به پوشه منتقل شود.

⚠️ دفتر CLI پریزما ۹ migration عقب است (از قبل)

prisma migrate status نُه migration را «اعمال‌نشده» می‌داند، از 20260922000000_customer_otp تا همین تازه — چون این ماشین migration ها را از مسیر runner خود اپ (_goldbank_migrations) گرفته، نه CLI. دیتابیس کامل است (diff خالی). پیامد: روی این ماشین prisma migrate dev اجرا نشود — می‌خواهد ۹ فایل را دوباره اجرا کند. همگام‌سازی با prisma migrate resolve --applied <name> برای هر ۹ مورد ممکن است، ولی در _prisma_migrations می‌نویسد و تأیید مالک می‌خواهد (D14).

ممیزی مصرف‌کنندگان REJECTED

جا پیش از رفع وضعیت
admin-customers.service.ts verifyStepOne() 🔴 default → 2 (پذیرفته). نما سوییچ‌های سفارش/حساب را روی step_one === 2 باز می‌کند → مشتری رد‌شده سوییچ باز می‌گرفت ✅ صریحاً 3
همان، verifyStepTwo() default → 3 (درست، تصادفی) ✅ صریح
ProfileView.vue هر دو اپ برچسب نداشت → کلمه‌ی انگلیسی REJECTED نمایش داده می‌شد ✅ «رد شده»
فیلتر «تاییدنشده‌ها» (:221) فقط STEP_ONE/TWO_SUBMITTED ✅ بدون تغییر — رد‌شده‌ها در صف برنمی‌گردند (خواست مالک)
روتر هر دو اپ · دکمه‌ی تکمیل پروفایل رد‌شده مثل UNVERIFIED رفتار می‌شود ✅ مطابق legacy — پایین

default در هر دو تابع حذف شد تا switch جامع باشد: اگر روزی مقدار ششمی به enum اضافه شود، TypeScript خطا می‌دهد و همین باگ بی‌صدا تکرار نمی‌شود.

🔶 دو سؤال برای مالک — از ممیزی

۱. رد در سیستم جدید REJECTED نمی‌نویسد. verify() در admin-customers.service.ts:955-969 هنگام رد: گام ۱ → UNVERIFIED، گام ۲ → STEP_ONE_SUBMITTED. یعنی ۱۴ مشتری مهاجر REJECTED می‌شوند، ولی هر ردِ تازه در پنل همان کاری را می‌کند که مالک برای مهاجرت «کاملاً مردود» خواند (برگشت به صف). و verificationRejectionReason که مالک «برای نیازمندی‌های آتی» خواست، از هیچ مسیری پر نمی‌شود — فرم ادمین فیلد علت ندارد. پیشنهاد: verify() هنگام رد REJECTED + verificationRejectedAt بنویسد، و یک فیلد اختیاری علت به دکمه‌ی رد اضافه شود. تغییر رفتار یک جریان زنده است — تأیید لازم.

۲. مشتری رد‌شده با ارسال دوباره‌ی مدارک خودکار تأیید می‌شود. legacy دکمه‌ی تکمیل پروفایل را فقط وقتی sts_verify_step_two برابر ۱ یا ۲ بود پنهان می‌کرد (طبق کامنت پورت در ProfileView.vue:122-126)، پس رد‌شده (۳) آن را می‌دید؛ و ارسال، بی‌درنگ VERIFIED می‌کند چون legacy خط «منتظر کارشناس» را کامنت کرده بود (customer-profile.service.ts:16-19). پورت همین است. نتیجه: «رد» در عمل یک «دوباره بفرست» است، نه بستن پرونده. اگر مالک می‌خواهد رد واقعاً ببندد، این تصمیم محصولی است، نه پورت.

⚠️ sms.service.spec.ts از قبل خراب است

دو خطای TS2339: Property 'platform' does not exist (خطوط ۸۳، ۱۰۲). این فایل و sms.service.ts در این شاخه دست نخورده‌اند — خرابی در commit 19d490e است. به همین دلیل آن suite کامپایل نمی‌شود؛ ۱۷۸ تست بقیه سبزند. خارج از اسکوپ این کار، ثبت می‌شود.


۱۰.۶ — پرسش دوم از دیتابیس

📄 migration-precheck-2.sql — چهار سؤالی که خروجی اول باز گذاشت. مثل فایل اول: فقط COUNT/SUM، صفر نوشتن، هیچ ستون متنی در خروجی (تنها استثنا: products_productimage.w که یک برچسب اندازه است، نه داده‌ی شخصی).

# سؤال چرا مسدودکننده است
۱ مبلغ سند مالی در کدام ستون است؟ remain/rial_remain همه NULLاند. بدون جواب، LedgerEntry.amount خالی می‌ماند و کل دفتر مالی بی‌مبلغ منتقل می‌شود
۲ آیا «مسئول فروش برنده» برای ۲۷ گروه تکراری کافی است؟ اگر گروهی کارشناس نداشته باشد، قاعده بی‌اثر است
۳ سه ستون تصویر → چهار نقش ProductImageRole نگاشت غلط = تصویر غلط روی محصول
۴ تأیید §۵.۷ — reagent_id به customers اشاره می‌کند؟ اگر غلط باشد، رابطه‌ی کارشناس معرف از بین می‌رود

۱۱. جمع‌بندی — کارهای لازم قبل از نوشتن ابزار

🔴 مسدودکننده (بدون این‌ها ابزار در میانه می‌شکند یا داده خراب می‌کند)

# مورد §
۱ شمارش تکرار ✅ انجام شد — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴)
۱.۱ nationalCode ✅ حل — nullable شود (§۱۰.۴)
۱.۲ passwordHash ✅ حل — hash غیرقابل‌ورود (§۱۰.۴)
۱.۳ 🔴 مبلغ سند مالی کجاست؟ cashes.remain همه NULL ۱۰.۶
۱.۴ REJECTED ✅ حل — enum + دو ستون علت (§۵.۵)
۱.۵ 🔴 ترتیب دومرحله‌ای — کارشناسان قبل از مشتریان ۵.۷
۲ آستانه‌ی تطبیق عددی جای «صفر اختلاف» — تأیید شد: ۵۶ ردیف گِرد می‌شوند ۴ · ۱۰.۴
۳ قاعده‌ی WHERE deleted_at IS NULL — حیاتی‌تر از انتظار: ۴۵٪ واریانت‌ها ۵.۸ · ۱۰.۴
۴ تصمیم orders_orders ✅ حل — به تأخیر افتاد (§۷.۲)
۵ تصحیح D0: fname/lname مستقیم، تجزیه نشود ۲
۶ allow-list ستون‌های نوشتنی — ۷ ستون مقصد-تنها لمس نشوند ۱۰.۵
۷ کلید تطبیق ردیف‌های موجود مقصد ۱۰.۵

🔶 تصمیم مالک

# مورد §
۱ ۴ ستون داده‌ی غیرقابل‌بازسازی ✅ حل — تولد و شهر منتقل، توکن و سالگرد حذف (§۵.۲)
۱.۱ 🔶 new_city → cityId یا ستون متنی؟ ۵.۹
۲ تفکیک 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 با/بدون منطقه‌ی زمانی هنگام نوشتن ابزار باید یک‌به‌یک بررسی شوند.


مرجع