From 4d3b15c3b9fbd7e420eeabeab5db541dfc6e9c00 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Wed, 7 Oct 2026 17:37:34 +0330 Subject: [PATCH] =?UTF-8?q?docs:=20data=20migration=20map=20=E2=80=94=20pr?= =?UTF-8?q?echeck=20results,=20owner=20rulings,=20run=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DATA-MIGRATION-MAP.md | 443 ++++++++++++++++++++++++++++++++-- docs/migration-precheck-3.sql | 63 +++++ docs/parity/PROGRESS.md | 1 + 3 files changed, 485 insertions(+), 22 deletions(-) create mode 100644 docs/migration-precheck-3.sql diff --git a/docs/DATA-MIGRATION-MAP.md b/docs/DATA-MIGRATION-MAP.md index 698769b..3a8822c 100644 --- a/docs/DATA-MIGRATION-MAP.md +++ b/docs/DATA-MIGRATION-MAP.md @@ -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 ` برای هر ۹ مورد ممکن است، ولی در +`_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` — چیدمان نمایشی | ۱۰ | diff --git a/docs/migration-precheck-3.sql b/docs/migration-precheck-3.sql new file mode 100644 index 0000000..192c727 --- /dev/null +++ b/docs/migration-precheck-3.sql @@ -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; diff --git a/docs/parity/PROGRESS.md b/docs/parity/PROGRESS.md index 6c56ed5..f5ec9a4 100644 --- a/docs/parity/PROGRESS.md +++ b/docs/parity/PROGRESS.md @@ -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.