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>
This commit is contained in:
s.esmaeili
2026-10-07 17:37:34 +03:30
co-authored by Claude Opus 5.5
parent 884413513d
commit 4d3b15c3b9
3 changed files with 485 additions and 22 deletions
+421 -22
View File
@@ -307,11 +307,33 @@ Prisma فقط `remain` را به‌عنوان ستون دارد. در مدل ج
محاسبه‌شان کند** — وگرنه عدد صفر نشان داده می‌شود. محاسبه‌شان کند** — وگرنه عدد صفر نشان داده می‌شود.
**ج) داده‌ی واقعیِ غیرقابل‌بازسازی** — `birth_date`, `marriage_date`, **ج) داده‌ی واقعیِ غیرقابل‌بازسازی** — `birth_date`, `marriage_date`,
`token_mobile`, `new_city`. **این‌ها اگر منتقل نشوند، برای همیشه از بین `token_mobile`, `new_city`.
می‌روند.** `token_mobile` در ۱۰ فایل استفاده می‌شود؛ بدون آن، اعلان push به
گوشی‌های موجود قطع می‌شود.
🔶 **تصمیم مالک لازم است برای گروه (ج).** ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — دو منتقل، دو حذف
| ستون | ردیف | تصمیم | دلیل مالک |
|---|---|---|---|
| `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 `api_token`, `send_password`, `rnd` نیازی نیست منتقل شوند — سامانه‌ی auth
جدید JWT است. جدید JWT است.
@@ -388,14 +410,141 @@ Prisma فقط `remain` را به‌عنوان ستون دارد. در مدل ج
جایی برای رفتن ندارند و اگر `UNVERIFIED` شوند، **از «رد‌شده» به جایی برای رفتن ندارند و اگر `UNVERIFIED` شوند، **از «رد‌شده» به
«بررسی‌نشده» برمی‌گردند** — یعنی دوباره در صف بررسی ظاهر می‌شوند. «بررسی‌نشده» برمی‌گردند** — یعنی دوباره در صف بررسی ظاهر می‌شوند.
🔶 **تصمیم مالک — دو گزینه:** ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — `REJECTED` + علت رد
1. افزودن `REJECTED` به enum (additive، با §G تضاد ندارد) — ۱۴ ردیف درست
منتقل می‌شوند
2. نگاشت رد‌شده‌ها به `UNVERIFIED` و پذیرفتن اینکه در صف برمی‌گردند
و **۱۰ ردیف ناسازگار** (`1,2` و `3,2`) هر تصمیمی بگیرید، نیاز به قاعده‌ی > «رویکرد شما ناقص است؛ حالت «رد شد» باید همراه با فیلد «علت رد» اضافه شود.
صریح دارند: گام دوم تأیید شده ولی گام اول نه. در legacy این حالت ممکن بود > انتقال این افراد به حالت «در انتظار» کاملاً مردود است چون بار عملیاتی و
چون دو ستون مستقل‌اند و هیچ محدودیتی بینشان نیست. > خطای انسانی ایجاد می‌کند.»
**پذیرفته شد.** انتقال به `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` | ✅ **ساخته می‌شود** (خواست مالک · برای آینده) |
| مقدارش در مهاجرت ۱۴ ردیف | **`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`](./migration-precheck-3.sql). تا جوابش نیاید،
نمی‌دانم ستون تازه بسازم یا به `cityId` نگاشت کنم. ساختن ستونی که لازم
نیست، همان‌قدر بد است که نساختن ستونی که لازم است.
⚠️ **اگر ستون متنی لازم شد، اسمش `cityNameRaw` پیشنهاد می‌شود**، نه
`newCity` — چون «new» در legacy معنای تاریخی داشت (ستون بعداً اضافه شده)
و در مقصد گمراه‌کننده است.
### `birth_date` — یک بررسی سلامت
`birth_date` نوعش `timestamp` است. ولی چون legacy تاریخ‌ها را جاهای دیگر
**به‌صورت رشته‌ی جلالی** ذخیره می‌کرد (§۸ — `recive_date`)، احتمال دارد
اینجا هم تاریخ جلالی به‌اشتباه به‌عنوان میلادی وارد شده باشد.
اگر سال‌ها در بازه‌ی ۱۳۰۰–۱۴۰۰ باشند، یعنی **جلالی در ستون میلادی** و
تبدیل لازم است. اگر ۱۹۳۰–۲۰۱۰ باشند، سالم‌اند.
کوئری‌اش در همان فایل سوم است.
---
### ۵.۷ — 🔴 `reagent_id` به `customers` اشاره می‌کند، نه `users` ### ۵.۷ — 🔴 `reagent_id` به `customers` اشاره می‌کند، نه `users`
@@ -891,8 +1040,23 @@ nationalCode String @unique // ← NOT NULL
🔴 **مهاجرت روی اولین ردیف می‌شکند.** و `@unique` روی رشته‌ی خالی یعنی 🔴 **مهاجرت روی اولین ردیف می‌شکند.** و `@unique` روی رشته‌ی خالی یعنی
فقط **یک** ردیف می‌تواند `''` باشد. فقط **یک** ردیف می‌تواند `''` باشد.
🔶 **تصمیم فوری لازم:** `nationalCode` باید `String?` شود (nullable). این ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — nullable شود
یک migration افزایشی روی مقصد است. بدون آن مهاجرت مشتری **غیرممکن** است.
> «وقتی دیتاست مبدا فاقد این داده است، هیچ گزینه دیگری جز Nullable کردن
> فیلد وجود ندارد. ساختن کد ملی فیک تخلف دیتایی است و مانع از
> احراز هویت‌های قانونی بعدی می‌شود.»
```prisma
nationalCode String? @unique // بود: String @unique
```
⚠️ **نکته‌ی فنی که به نفع این تصمیم است:** در Postgres مقدار `NULL` در
ایندکس `@unique` **تکراری حساب نمی‌شود**. پس هزار ردیف با
`nationalCode = NULL` بی‌مشکل کنار هم می‌نشینند — ولی اگر به‌جای `NULL`
رشته‌ی خالی `''` بگذاریم، **فقط یکی جا می‌شود**.
🔴 **قاعده‌ی اجباری ابزار: `''` را به `NULL` تبدیل کند، نه اینکه خالی
بفرستد.**
**۳. گِردکردن رخ می‌دهد — ۲۴ + ۳۲ ردیف.** **۳. گِردکردن رخ می‌دهد — ۲۴ + ۳۲ ردیف.**
`gold_needs_rounding = 24` و `order_weight_needs_rounding = 32`. یعنی §۴ `gold_needs_rounding = 24` و `order_weight_needs_rounding = 32`. یعنی §۴
@@ -942,12 +1106,116 @@ pw_bcrypt 5 pw_other 0 pw_empty 1590
```prisma ```prisma
passwordHash String // ← NOT NULL passwordHash String // ← NOT NULL
``` ```
🔴 **مهاجرت می‌شکند.** دو راه: `passwordHash` nullable شود، یا مهاجرت یک ### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — hash غیرقابل‌ورود، nullable نشود
hash تصادفیِ غیرقابل‌استفاده بگذارد. **گزینه‌ی دوم امن‌تر است** — ستون
اجباری می‌ماند و هیچ‌کس نمی‌تواند با رمز خالی وارد شود. > «برداشتن اجبار رمز (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 است، پس **بدون ⚠️ آن ۵ رمز `$2y$` (bcrypt) هستند و مقصد هم bcrypt است، پس **بدون
تغییر منتقل می‌شوند**. تغییر منتقل می‌شوند** و همان‌طور کار می‌کنند.
### enum ها — دو نکته ### enum ها — دو نکته
@@ -1064,6 +1332,136 @@ orders_orders 520 live retail_sts: 7 → ۳ ردیف
--- ---
## ۱۰.۵.۵ — فهرست کامل تغییرات افزایشی مقصد
همه‌ی تصمیم‌های مالک تا ۲۰۲۶-۱۰-۰۷، در یک migration جمع می‌شوند.
**همه افزایشی‌اند** — هیچ ستونی حذف یا تغییر نوع نمی‌دهد، پس با
`PROTECTED.md` §G و قابلیت‌های حسابداری/تصویر واریانت/پیامک **تضادی
ندارند**.
```prisma
model Customer {
nationalCode String? @unique // بود: String — §۱۰.۴
birthDate DateTime? // legacy birth_date — §۵.۲
verificationRejectionReason String? // §۵.۵
verificationRejectedAt DateTime?
// cityNameRaw String? // §۵.۹ — منتظر جواب کوئری سوم
favorites CustomerFavorite[] // §۱۰
}
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
REJECTED // §۵.۵
}
model CustomerFavorite { // §۱۰ — legacy fav
customerId String
customer Customer @relation(fields: [customerId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@id([customerId, productId])
}
```
و یک تغییر کد (نه schema) در `auth.service.ts:103,135` — محافظ پیشوند
`$2` قبل از `bcrypt.compare` (§۱۰.۴).
⚠️ طبق D14: `migrate dev` در توسعه، و SQL **idempotent**
(`IF NOT EXISTS`) چون کانتینر مهاجرت‌ها را خودش اجرا می‌کند (D15).
### ✅ وضعیت اجرا — ۲۰۲۶-۱۰-۰۷
شاخه: `migration/legacy-data` (از `19d490e`؛ `release/delivery` دست نخورد).
| مورد | وضعیت | تأیید |
|---|---|---|
| migration `20261007120000_migration_prep_additive` | ✅ اعمال شد | `migrate diff` دیتابیس زنده ↔ schema: **خالی** |
| ۴ ستون `Customer` | ✅ همه nullable | `information_schema` |
| `CustomerFavorite` | ✅ ساخته شد | `to_regclass` |
| `VerificationStep.REJECTED` | ✅ | `pg_enum` · Postgres 15.19 |
| نوع پارامتر `accounting-sync.service.ts:233` | ✅ `string \| null` | رفتار درگاه‌ها صفر تغییر |
| محافظ `$2` در `auth.service.ts` | ✅ `compareLocalHash()` | ۴ تست تازه |
| ممیزی مصرف‌کنندگان `REJECTED` | ✅ | پایین |
| تست‌ها | ✅ **۱۷۸ از ۱۷۸** | ۱ suite از قبل خراب — پایین |
| API زنده | ✅ | عمومی ۲۰۰ · ادمین بی‌توکن ۴۰۱ · رمز غلط ۴۰۱ · رمز درست ۲۰۰ |
### 🔴 خطر فرایندی کشف‌شده — فایل migration = اعمال خودکار
**migration بدون تأیید اعمال شد — نه توسط من.** زنجیره:
1. سرور توسعه با `nest start --watch` اجرا می‌شود
2. ویرایش هر فایلی زیر `src/` سرور را ری‌استارت می‌کند
3. در boot، [`bootstrap.ts:35`](../apps/api/src/prisma/bootstrap.ts) تابع
`runMigrations()` را صدا می‌زند که **هر فایل معلق در `prisma/migrations`
را اعمال می‌کند** (D15)
ویرایش `accounting-sync.service.ts` (برای رفع خطای نوع) ری‌استارت را زد و
migration در `13:42:36` در `_goldbank_migrations` ثبت شد — پیش از تأیید
مالک. نتیجه درست و تأییدشده است، ولی **روال «اول نشان بده، بعد اجرا کن»
(D0) روی این ماشین کار نمی‌کند**.
**قاعده از این پس:** تا تأیید نشده، migration **بیرون از
`prisma/migrations`** نوشته شود (مثلاً `docs/` یا scratchpad)، یا سرور
توسعه متوقف باشد. فقط پس از تأیید به پوشه منتقل شود.
### ⚠️ دفتر CLI پریزما ۹ migration عقب است (از قبل)
`prisma migrate status` نُه migration را «اعمال‌نشده» می‌داند، از
`20260922000000_customer_otp` تا همین تازه — چون این ماشین migration ها را
از مسیر runner خود اپ (`_goldbank_migrations`) گرفته، نه CLI. دیتابیس
کامل است (diff خالی). **پیامد:** روی این ماشین `prisma migrate dev` اجرا
نشود — می‌خواهد ۹ فایل را دوباره اجرا کند. همگام‌سازی با
`prisma migrate resolve --applied <name>` برای هر ۹ مورد ممکن است، ولی در
`_prisma_migrations` می‌نویسد و **تأیید مالک می‌خواهد** (D14).
### ممیزی مصرف‌کنندگان `REJECTED`
| جا | پیش از رفع | وضعیت |
|---|---|---|
| `admin-customers.service.ts` `verifyStepOne()` | 🔴 `default` → `2` (**پذیرفته**). نما سوییچ‌های سفارش/حساب را روی `step_one === 2` باز می‌کند → **مشتری رد‌شده سوییچ باز می‌گرفت** | ✅ صریحاً `3` |
| همان، `verifyStepTwo()` | `default` → `3` (درست، تصادفی) | ✅ صریح |
| `ProfileView.vue` هر دو اپ | برچسب نداشت → کلمه‌ی انگلیسی `REJECTED` نمایش داده می‌شد | ✅ «رد شده» |
| فیلتر «تاییدنشده‌ها» (`:221`) | فقط `STEP_ONE/TWO_SUBMITTED` | ✅ بدون تغییر — رد‌شده‌ها **در صف برنمی‌گردند** (خواست مالک) |
| روتر هر دو اپ · دکمه‌ی تکمیل پروفایل | رد‌شده مثل `UNVERIFIED` رفتار می‌شود | ✅ مطابق legacy — پایین |
`default` در هر دو تابع **حذف شد** تا switch جامع باشد: اگر روزی مقدار
ششمی به enum اضافه شود، TypeScript خطا می‌دهد و همین باگ بی‌صدا تکرار
نمی‌شود.
### 🔶 دو سؤال برای مالک — از ممیزی
**۱. رد در سیستم جدید `REJECTED` نمی‌نویسد.**
`verify()` در `admin-customers.service.ts:955-969` هنگام رد:
گام ۱ → `UNVERIFIED`، گام ۲ → `STEP_ONE_SUBMITTED`. یعنی ۱۴ مشتری مهاجر
`REJECTED` می‌شوند، ولی **هر ردِ تازه در پنل همان کاری را می‌کند که مالک
برای مهاجرت «کاملاً مردود» خواند** (برگشت به صف). و
`verificationRejectionReason` که مالک «برای نیازمندی‌های آتی» خواست، از
هیچ مسیری پر نمی‌شود — فرم ادمین فیلد علت ندارد.
پیشنهاد: `verify()` هنگام رد `REJECTED` + `verificationRejectedAt`
بنویسد، و یک فیلد اختیاری علت به دکمه‌ی رد اضافه شود. **تغییر رفتار یک
جریان زنده است — تأیید لازم.**
**۲. مشتری رد‌شده با ارسال دوباره‌ی مدارک خودکار تأیید می‌شود.**
legacy دکمه‌ی تکمیل پروفایل را فقط وقتی `sts_verify_step_two` برابر ۱ یا
۲ بود پنهان می‌کرد (طبق کامنت پورت در `ProfileView.vue:122-126`)، پس رد‌شده (۳) آن را
می‌دید؛ و ارسال، بی‌درنگ `VERIFIED` می‌کند چون legacy خط «منتظر
کارشناس» را کامنت کرده بود (`customer-profile.service.ts:16-19`). پورت
همین است. **نتیجه: «رد» در عمل یک «دوباره بفرست» است، نه بستن پرونده.**
اگر مالک می‌خواهد رد واقعاً ببندد، این تصمیم محصولی است، نه پورت.
### ⚠️ `sms.service.spec.ts` از قبل خراب است
دو خطای `TS2339: Property 'platform' does not exist` (خطوط ۸۳، ۱۰۲). این
فایل و `sms.service.ts` در این شاخه **دست نخورده‌اند** — خرابی در commit
`19d490e` است. به همین دلیل آن suite کامپایل نمی‌شود؛ ۱۷۸ تست بقیه سبزند.
خارج از اسکوپ این کار، ثبت می‌شود.
---
## ۱۰.۶ — پرسش دوم از دیتابیس ## ۱۰.۶ — پرسش دوم از دیتابیس
📄 **[`migration-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی 📄 **[`migration-precheck-2.sql`](./migration-precheck-2.sql)** — چهار سؤالی
@@ -1087,10 +1485,10 @@ orders_orders 520 live retail_sts: 7 → ۳ ردیف
| # | مورد | § | | # | مورد | § |
|---|---|---| |---|---|---|
| ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) | | ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) |
| ۱.۱ | 🔴 **`nationalCode` باید nullable شود** — هر ۱٬۵۹۵ ردیف تهی است | ۱۰.۴ | | ۱.۱ | ~~`nationalCode`~~ | ✅ **حل — nullable شود** (§۱۰.۴) |
| ۱.۲ | 🔴 **`passwordHash`** — ۱٬۵۹۰ تهی؛ hash تصادفی یا nullable | ۱۰.۴ | | ۱.۲ | ~~`passwordHash`~~ | ✅ **حل — hash غیرقابل‌ورود** (§۱۰.۴) |
| ۱.۳ | 🔴 **مبلغ سند مالی کجاست؟** `cashes.remain` همه `NULL` | ۱۰.۶ | | ۱.۳ | 🔴 **مبلغ سند مالی کجاست؟** `cashes.remain` همه `NULL` | ۱۰.۶ |
| ۱.۴ | 🔴 **`VerificationStep` حالت `REJECTED` ندارد** — ۱۴ ردیف | ۵.۵ | | ۱.۴ | ~~`REJECTED`~~ | ✅ **حل — enum + دو ستون علت** (§۵.۵) |
| ۱.۵ | 🔴 **ترتیب دومرحله‌ای** — کارشناسان قبل از مشتریان | ۵.۷ | | ۱.۵ | 🔴 **ترتیب دومرحله‌ای** — کارشناسان قبل از مشتریان | ۵.۷ |
| ۲ | آستانه‌ی تطبیق عددی جای «صفر اختلاف» — **تأیید شد: ۵۶ ردیف گِرد می‌شوند** | ۴ · ۱۰.۴ | | ۲ | آستانه‌ی تطبیق عددی جای «صفر اختلاف» — **تأیید شد: ۵۶ ردیف گِرد می‌شوند** | ۴ · ۱۰.۴ |
| ۳ | قاعده‌ی `WHERE deleted_at IS NULL` — **حیاتی‌تر از انتظار: ۴۵٪ واریانت‌ها** | ۵.۸ · ۱۰.۴ | | ۳ | قاعده‌ی `WHERE deleted_at IS NULL` — **حیاتی‌تر از انتظار: ۴۵٪ واریانت‌ها** | ۵.۸ · ۱۰.۴ |
@@ -1103,8 +1501,9 @@ orders_orders 520 live retail_sts: 7 → ۳ ردیف
| # | مورد | § | | # | مورد | § |
|---|---|---| |---|---|---|
| ۱ | ۴ ستون داده‌ی غیرقابل‌بازسازی (`birth_date`, `marriage_date`, `token_mobile`, `new_city`) | ۵.۲ | | ۱ | ~~۴ ستون داده‌ی غیرقابل‌بازسازی~~ | ✅ **حل — تولد و شهر منتقل، توکن و سالگرد حذف** (§۵.۲) |
| ۲ | `verificationStep` — تفکیک `STEP_TWO_SUBMITTED` از `VERIFIED` | ۵.۵ | | ۱.۱ | 🔶 `new_city` → `cityId` یا ستون متنی؟ | **۵.۹** |
| ۲ | ~~تفکیک `STEP_TWO_SUBMITTED` از `VERIFIED`~~ | ✅ **حل** (§۵.۵) |
| ۳ | `type IS NULL` چه `CustomerKind` شود | ۵.۳ | | ۳ | `type IS NULL` چه `CustomerKind` شود | ۵.۳ |
| ۴ | ~~`fav`~~ | ✅ **حل — لازم است، migration افزایشی** (§۱۰) | | ۴ | ~~`fav`~~ | ✅ **حل — لازم است، migration افزایشی** (§۱۰) |
| ۴.۱ | `cinies`/`ciny_products` — چیدمان نمایشی | ۱۰ | | ۴.۱ | `cinies`/`ciny_products` — چیدمان نمایشی | ۱۰ |
+63
View File
@@ -0,0 +1,63 @@
-- ============================================================================
-- پیش‌بررسی سوم — فقط دو سؤال باقی‌مانده
-- ============================================================================
--
-- ⚠️ فقط SELECT COUNT. صفر INSERT/UPDATE/DELETE. هیچ ستون متنی در خروجی.
--
-- اجرا:
-- mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck-3.sql > out3.txt
--
-- مرجع: docs/DATA-MIGRATION-MAP.md §۵.۹
-- ============================================================================
-- ---------------------------------------------------------------------------
-- سؤال: new_city باید به cityId نگاشت شود یا ستون جدا بگیرد؟
--
-- تصمیم مالک: «یا در فیلد شهر بنشیند یا در یک فیلد آدرس متنی مپ شود».
-- کدام‌یک، به این بستگی دارد که آیا این ۱۷۶ ردیف city_id هم دارند یا نه:
--
-- فقط new_city (city_id تهی) → ستون متنی لازم است، چون شهر در جدول cities نیست
-- هر دو را دارند → new_city تکراری است، می‌توان رهایش کرد
-- ---------------------------------------------------------------------------
SELECT '=== new_city vs city_id ===' AS section;
SELECT
SUM(new_city IS NOT NULL AND new_city <> '' AND city_id IS NULL) AS only_new_city,
SUM(new_city IS NOT NULL AND new_city <> '' AND city_id IS NOT NULL) AS both,
SUM((new_city IS NULL OR new_city = '') AND city_id IS NOT NULL) AS only_city_id,
SUM((new_city IS NULL OR new_city = '') AND city_id IS NULL) AS neither
FROM customers WHERE deleted_at IS NULL;
-- از آن ۱۷۶ ردیف، چندتا متنی‌شان با عنوان یک شهر موجود در `cities` یکی است؟
-- اگر بالا باشد، اکثرشان قابل تبدیل به city_id هستند و ستون متنی لازم نیست.
SELECT 'new_city matches an existing cities.title' AS metric, COUNT(*) AS n
FROM customers c
JOIN cities ci ON TRIM(ci.title) = TRIM(c.new_city)
WHERE c.deleted_at IS NULL AND c.new_city IS NOT NULL AND c.new_city <> ''
AND c.city_id IS NULL;
-- چند مقدار متمایز؟ (عدد است، نه متن — اگر کم باشد نگاشت دستی ممکن است)
SELECT 'distinct_new_city_values' AS metric, COUNT(DISTINCT TRIM(new_city)) AS n
FROM customers WHERE deleted_at IS NULL AND new_city IS NOT NULL AND new_city <> '';
-- ---------------------------------------------------------------------------
-- سؤال: birth_date — آیا تاریخ‌ها معتبرند؟
--
-- ستون timestamp است. ولی اگر تاریخ جلالی به‌اشتباه به‌عنوان میلادی
-- ذخیره شده باشد، سال‌ها ۱۳xx می‌شوند که در timestamp جا نمی‌شوند،
-- یا به مقادیر عجیب تبدیل شده‌اند.
-- ---------------------------------------------------------------------------
SELECT '=== birth_date sanity ===' AS section;
SELECT
COUNT(birth_date) AS total_set,
SUM(YEAR(birth_date) < 1900) AS year_before_1900,
SUM(YEAR(birth_date) BETWEEN 1900 AND 1950) AS year_1900_1950,
SUM(YEAR(birth_date) BETWEEN 1951 AND 2010) AS year_1951_2010,
SUM(YEAR(birth_date) > 2010) AS year_after_2010,
CAST(MIN(YEAR(birth_date)) AS CHAR) AS min_year,
CAST(MAX(YEAR(birth_date)) AS CHAR) AS max_year
FROM customers WHERE deleted_at IS NULL;
SELECT '=== END ===' AS section;
+1
View File
@@ -11,6 +11,7 @@
| `docs/GO-LIVE-PLAN.md` | Stack upgrade, release isolation, data migration and cutover from the live henza system. **§0 explains how to continue on another machine.** | Owner go-ahead per step | | `docs/GO-LIVE-PLAN.md` | Stack upgrade, release isolation, data migration and cutover from the live henza system. **§0 explains how to continue on another machine.** | Owner go-ahead per step |
| `docs/PWA-PORT-PLAN.md` | Third user app `apps/user-app-classic`: a pixel-exact port of `henza_pwa@b171fff` onto the new API. | Owner rulings on its tensions 1–5 and Q1–Q9 | | `docs/PWA-PORT-PLAN.md` | Third user app `apps/user-app-classic`: a pixel-exact port of `henza_pwa@b171fff` onto the new API. | Owner rulings on its tensions 1–5 and Q1–Q9 |
| `docs/ACCOUNTING-INTEGRATION-PLAN.md` | Tahesab / Zarrin integration, switchable in the admin panel (D27). **Implemented 2026-10-05**, off until switched on; progress in its §10. | Zarrin trial on a real install (ACC-01); ACC-03 ruling | | `docs/ACCOUNTING-INTEGRATION-PLAN.md` | Tahesab / Zarrin integration, switchable in the admin panel (D27). **Implemented 2026-10-05**, off until switched on; progress in its §10. | Zarrin trial on a real install (ACC-01); ACC-03 ruling |
| `docs/DATA-MIGRATION-MAP.md` | Column-by-column map of the live henza MySQL schema (`oapi-struncture.sql`, 80 tables) onto Prisma, checked against real row counts via the read-only `docs/migration-precheck*.sql`. Branch `migration/legacy-data`. **2026-10-07:** additive prep migration applied (nullable `nationalCode`, `birthDate`, rejection trail + `REJECTED`, `CustomerFavorite`), bcrypt guard, REJECTED consumer audit. ⚠ On this machine a file in `prisma/migrations` is applied by the watch-mode dev server on the next `src/` edit — see its §10.5.5. | `precheck-2` (where the ledger amount lives) and `precheck-3` output; two `verify()` rulings |
The "Current state" table below is the parity project's and was last updated 2026-09-22. The "Current state" table below is the parity project's and was last updated 2026-09-22.