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:
co-authored by
Claude Opus 5.5
parent
884413513d
commit
4d3b15c3b9
+421
-22
@@ -307,11 +307,33 @@ Prisma فقط `remain` را بهعنوان ستون دارد. در مدل ج
|
||||
محاسبهشان کند** — وگرنه عدد صفر نشان داده میشود.
|
||||
|
||||
**ج) دادهی واقعیِ غیرقابلبازسازی** — `birth_date`, `marriage_date`,
|
||||
`token_mobile`, `new_city`. **اینها اگر منتقل نشوند، برای همیشه از بین
|
||||
میروند.** `token_mobile` در ۱۰ فایل استفاده میشود؛ بدون آن، اعلان push به
|
||||
گوشیهای موجود قطع میشود.
|
||||
`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 است.
|
||||
@@ -388,14 +410,141 @@ Prisma فقط `remain` را بهعنوان ستون دارد. در مدل ج
|
||||
جایی برای رفتن ندارند و اگر `UNVERIFIED` شوند، **از «ردشده» به
|
||||
«بررسینشده» برمیگردند** — یعنی دوباره در صف بررسی ظاهر میشوند.
|
||||
|
||||
🔶 **تصمیم مالک — دو گزینه:**
|
||||
1. افزودن `REJECTED` به enum (additive، با §G تضاد ندارد) — ۱۴ ردیف درست
|
||||
منتقل میشوند
|
||||
2. نگاشت ردشدهها به `UNVERIFIED` و پذیرفتن اینکه در صف برمیگردند
|
||||
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — `REJECTED` + علت رد
|
||||
|
||||
و **۱۰ ردیف ناسازگار** (`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`
|
||||
|
||||
@@ -891,8 +1040,23 @@ nationalCode String @unique // ← NOT NULL
|
||||
🔴 **مهاجرت روی اولین ردیف میشکند.** و `@unique` روی رشتهی خالی یعنی
|
||||
فقط **یک** ردیف میتواند `''` باشد.
|
||||
|
||||
🔶 **تصمیم فوری لازم:** `nationalCode` باید `String?` شود (nullable). این
|
||||
یک migration افزایشی روی مقصد است. بدون آن مهاجرت مشتری **غیرممکن** است.
|
||||
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — nullable شود
|
||||
|
||||
> «وقتی دیتاست مبدا فاقد این داده است، هیچ گزینه دیگری جز Nullable کردن
|
||||
> فیلد وجود ندارد. ساختن کد ملی فیک تخلف دیتایی است و مانع از
|
||||
> احراز هویتهای قانونی بعدی میشود.»
|
||||
|
||||
```prisma
|
||||
nationalCode String? @unique // بود: String @unique
|
||||
```
|
||||
|
||||
⚠️ **نکتهی فنی که به نفع این تصمیم است:** در Postgres مقدار `NULL` در
|
||||
ایندکس `@unique` **تکراری حساب نمیشود**. پس هزار ردیف با
|
||||
`nationalCode = NULL` بیمشکل کنار هم مینشینند — ولی اگر بهجای `NULL`
|
||||
رشتهی خالی `''` بگذاریم، **فقط یکی جا میشود**.
|
||||
|
||||
🔴 **قاعدهی اجباری ابزار: `''` را به `NULL` تبدیل کند، نه اینکه خالی
|
||||
بفرستد.**
|
||||
|
||||
**۳. گِردکردن رخ میدهد — ۲۴ + ۳۲ ردیف.**
|
||||
`gold_needs_rounding = 24` و `order_weight_needs_rounding = 32`. یعنی §۴
|
||||
@@ -942,12 +1106,116 @@ pw_bcrypt 5 pw_other 0 pw_empty 1590
|
||||
```prisma
|
||||
passwordHash String // ← NOT NULL
|
||||
```
|
||||
🔴 **مهاجرت میشکند.** دو راه: `passwordHash` nullable شود، یا مهاجرت یک
|
||||
hash تصادفیِ غیرقابلاستفاده بگذارد. **گزینهی دوم امنتر است** — ستون
|
||||
اجباری میماند و هیچکس نمیتواند با رمز خالی وارد شود.
|
||||
### ✅ تصمیم مالک ۲۰۲۶-۱۰-۰۷ — 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 ها — دو نکته
|
||||
|
||||
@@ -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)** — چهار سؤالی
|
||||
@@ -1087,10 +1485,10 @@ orders_orders 520 live retail_sts: 7 → ۳ ردیف
|
||||
| # | مورد | § |
|
||||
|---|---|---|
|
||||
| ۱ | ~~شمارش تکرار~~ | ✅ **انجام شد** — ۲۷ گروه موبایل، ۰ کد ملی (§۱۰.۴) |
|
||||
| ۱.۱ | 🔴 **`nationalCode` باید nullable شود** — هر ۱٬۵۹۵ ردیف تهی است | ۱۰.۴ |
|
||||
| ۱.۲ | 🔴 **`passwordHash`** — ۱٬۵۹۰ تهی؛ hash تصادفی یا nullable | ۱۰.۴ |
|
||||
| ۱.۱ | ~~`nationalCode`~~ | ✅ **حل — nullable شود** (§۱۰.۴) |
|
||||
| ۱.۲ | ~~`passwordHash`~~ | ✅ **حل — hash غیرقابلورود** (§۱۰.۴) |
|
||||
| ۱.۳ | 🔴 **مبلغ سند مالی کجاست؟** `cashes.remain` همه `NULL` | ۱۰.۶ |
|
||||
| ۱.۴ | 🔴 **`VerificationStep` حالت `REJECTED` ندارد** — ۱۴ ردیف | ۵.۵ |
|
||||
| ۱.۴ | ~~`REJECTED`~~ | ✅ **حل — enum + دو ستون علت** (§۵.۵) |
|
||||
| ۱.۵ | 🔴 **ترتیب دومرحلهای** — کارشناسان قبل از مشتریان | ۵.۷ |
|
||||
| ۲ | آستانهی تطبیق عددی جای «صفر اختلاف» — **تأیید شد: ۵۶ ردیف گِرد میشوند** | ۴ · ۱۰.۴ |
|
||||
| ۳ | قاعدهی `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` شود | ۵.۳ |
|
||||
| ۴ | ~~`fav`~~ | ✅ **حل — لازم است، migration افزایشی** (§۱۰) |
|
||||
| ۴.۱ | `cinies`/`ciny_products` — چیدمان نمایشی | ۱۰ |
|
||||
|
||||
@@ -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;
|
||||
@@ -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/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/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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user