Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
# طرح: ستون مانده‌ی حسابداری در لیست مشتریان عمده
تاریخ: ۱۴۰۵/۰۷/۲۲ — وضعیت: **پیاده‌سازی شد** (۱۴۰۵/۰۷/۲۲) — تصمیم‌ها: کلمه‌ی بدهکار/بستانکار؛ ستون‌های فعلی هنگام اتصال «بدهی داخل اپ» نام می‌گیرند؛ اپ کارشناس فروش بعداً
## مشکل
در لیست مشتریان عمده، ستون‌های «بدهی گرمی» و «بدهی ریالی» بدهیِ **داخل اپ** را نشان می‌دهند
(جمع سفارش‌هایی که در منتزر ثبت شده). مشتری‌هایی که از نرم‌افزار حسابداری آمده‌اند هنوز در اپ
سفارشی ندارند، پس این ستون‌ها برایشان صفر است.
مانده‌ی واقعیِ حسابداری درست خوانده و ذخیره می‌شود (بررسی ۱۴۰۵/۰۷/۲۲: هر ۷۳۳ مشتری واردشده از زرین
مانده دارند، ۳۸۴ نفر غیرصفر؛ مثلاً محمدرضا لطیفی کد ۹۶۱: ۷۲٫۲۸ گرم بدهکار) ولی فقط در صفحه‌ی
«مشاهده اطلاعات» هر مشتری دیده می‌شود، نه در لیست.
## راه‌حل: ستون‌های پویا
| وضعیت اتصال حسابداری | چه نمایش داده می‌شود |
|---|---|
| به **زرین** وصل است | دو ستون «مانده‌ی زرین (گرم)» و «مانده‌ی زرین (ریال)» |
| به **ته‌حساب** وصل است | دو ستون «مانده‌ی ته‌حساب (گرم)» و «مانده‌ی ته‌حساب (ریال)» |
| به هیچ‌کدام وصل نیست (یا اتصال خاموش است) | این دو ستون اصلاً نشان داده نمی‌شوند |
نام نرم‌افزار و وصل‌بودن از همان‌جایی خوانده می‌شود که دکمه‌ی «سینک با زرین» بالای لیست می‌خواند
(`admin/accounting-status`)، پس دکمه و ستون‌ها همیشه با هم هماهنگ‌اند.
ستون‌های فعلی «بدهی گرمی / ریالی» (بدهی داخل اپ) **دست نمی‌خورند**، چون سقف اعتبار هنوز با آن‌ها
حساب می‌شود.
## نحوه‌ی نمایش هر خانه
| حالت | نمایش |
|---|---|
| مشتری بدهکار است | عدد + «بدهکار» به رنگ قرمز — مثلاً `۷۲٫۲۸ بدهکار` |
| مشتری طلبکار است | عدد + «بستانکار» به رنگ سبز — مثلاً `۱۶۷۳٫۹۳ بستانکار` |
| مانده صفر است | `۰` |
| مشتری به حسابی در نرم‌افزار وصل نیست | `—` |
با بردن موس روی عدد، زمان آخرین به‌روزرسانی مانده نشان داده می‌شود (چون اگر «سینک مانده» خاموش
باشد، عدد فقط با زدن دکمه‌ی سینک تازه می‌شود).
## کجاها
هر پنج لیست مشتریان عمده (عمده، بنکداران، مغازه‌داران حضوری، تأییدنشده، بایگانی) چون همه یک جدول
مشترک دارند. مرتب‌سازی (مشتری‌های وصل‌نشده همیشه آخر) و فیلتر عددی روی ستون‌های جدید کار می‌کند. (دکمه‌ی چاپ جدول از قبل کاری انجام نمی‌دهد؛ ربطی به این طرح ندارد.)
## تغییرات فنی (خلاصه)
- **API** — `admin-customers.service.ts`: در خروجی هر ردیف سه فیلد `acc_remain`، `acc_rial_remain`،
`acc_at` (از `accountingGoldBalance` / `accountingRialBalance` / `accountingBalanceAt`) و `acc_linked`؛
و نگاشت مرتب‌سازی/فیلتر برای دو ستون اول. هیچ تغییری در دفتر حساب و محاسبه‌ی بدهی اپ نیست.
- **پنل** — `omdeListModel.ts` / `OmdeCustomerList.vue`: بعد از خواندن وضعیت اتصال، اگر وصل بود دو ستون
با نام نرم‌افزار اضافه می‌شوند؛ اسلات نمایش بدهکار/بستانکار.
- بدون migration و بدون تغییر در پایگاه داده.
## سؤال‌ها از شما
1. **نمایش بدهکار/بستانکار:** پیشنهاد من همان جدول بالاست (عدد مثبت + کلمه‌ی «بدهکار» قرمز یا
«بستانکار» سبز). جایگزین: فقط عدد با علامت منفی برای طلبکار — کوتاه‌تر است ولی ۲۴۸ مشتری
طلبکار طلا داریم و علامت منفی راحت اشتباه خوانده می‌شود. **پیشنهاد: کلمه‌ی بدهکار/بستانکار.**
2. **ستون‌های فعلی «بدهی گرمی / ریالی» بمانند؟** وقتی حسابداری وصل است، این ستون‌ها فقط بدهی
سفارش‌های داخل اپ را نشان می‌دهند و ممکن است گیج‌کننده باشند. سه راه:
الف) بمانند کنار ستون‌های جدید (کنترل سقف اعتبار هم دیده شود)؛
ب) وقتی وصل است پنهان شوند و فقط ستون حسابداری بماند؛
ج) بمانند ولی اسمشان «بدهی داخل اپ» شود.
**پیشنهاد: ج** — چیزی پنهان نمی‌شود و اشتباه هم خوانده نمی‌شوند.
3. **اپ کارشناس فروش** هم ستون «بدهی» دارد و همین مشکل را دارد. همین الان آن را هم درست کنیم یا
بعداً؟ **پیشنهاد: بعداً، جدا** — این طرح فقط پنل مدیریت.
+258
View File
@@ -0,0 +1,258 @@
# طرح اتصال به نرم‌افزار حسابداری (ته‌حساب / زرین)
> وضعیت: **تأیید شد (2026-10-05) — در حال اجرا.** پیشرفت در بخش ۱۰ («وضعیت اجرا»). تصمیم: D27 در `docs/parity/DECISIONS.md`.
> تاریخ: 2026-10-05
## ۰. زمینه
الان اپ هیچ اتصال خارجی به حسابداری ندارد: مانده، گزارش و بدهی سفارش همه از دفتر داخلی (`LedgerEntry`) است (D24). سیستم قدیم (henza) به ته‌حساب وصل بود ولی با توکن هاردکد، TLS خاموش و بدون چک مالکیت (SEC-06/16/17). حالا باید اپ به **یکی از دو** نرم‌افزار حسابداری وصل شود و در پنل ادمین با یک سوییچ انتخاب شود کدام. خروجی: همگام‌سازی مشتری، مانده، کارساخته/محصول، و ثبت سند سفارش — امن، idempotent و قابل پیگیری از ادمین.
## ۱. خلاصه‌ی دو API (از داکیومنت‌ها)
| | ته‌حساب | زرین |
|---|---|---|
| مستند | OpenAPI 3.1 کامل، ۶۶ متد (`tahesab.ir/APiDoc/openapi.json`) | ۸۲ API (`zarrin.ir/wp-content/themes/Hirad/data/api.json`) |
| نصب | Self-hosted روی سیستم/سرور مشتری؛ پورت https 8081 / http 9550 | Self-hosted، `http://host:8000/ZarrinService/accounting` |
| فراخوانی | `POST /<Method>` با بدنه‌ی `{"<Method>": [آرگومان‌ها به ترتیب اندیس]}` | `GET ?kind=<api>&token=…&param=…` |
| احراز هویت | `Authorization: Bearer` + هدر `DBname` | `token` در query string |
| ضد تکرار | `Factor_Code` یکتای ≥۲۰ کاراکتر + `DoNewSanadInquiry-By-Factor-Code` + `DoDeleteSanad` | فقط `codeRahgiri` در `sanadPooli`؛ `foroushSakhte` کلید ضد تکرار **ندارد** |
| سرور تست | دارد (`api-test.tahesab.ir`، توکن عمومی در خود داکیومنت) | ندارد (مالک: فعلاً دسترسی نداریم) |
نگاشت قابلیت‌ها به متدها:
| قابلیت | ته‌حساب | زرین |
|---|---|---|
| لیست/جستجوی مشتری | `DoListMoshtari-ByCode`، `-ByPhone`، `DoListGorooh` | `listhesabha` / `Get100Account`، `getinfoByPhone`، `listGorooheHesabha` |
| ساخت/ویرایش مشتری | `DoNewMoshtari` (کد `-1` = خودکار)، `DoEditMoshtari` | `addAccount` (`fkNoe`=گروه)، `edithesab` |
| مانده | `GetMandeHesabByCode` (لیست کدها، دسته‌ای) → `MandeyeVazni`، `MandeyeMali` | `listMandeHesabha` (همه‌ی حساب‌ها یک‌جا) / `mandeHesabGeramiPooliTedadi` |
| ریز اسناد (گزارش) | `DoListAsnad` (با مانده‌ی ردیف‌به‌ردیف `TahesabVazni/Mali`) | `parvande` (صفحه‌بندی، ردیف «مانده از قبل») |
| کارساخته: لیست/اطلاعات | `DoListNameKarsakhte` (فقط کارهای دارای سند) | `listKarSakhte` (کند: ~۶ ثانیه)، `infosakhte`، `goroohSakhte` |
| کارساخته: موجودی | `GetMojoodiKarSakhte` (با نام کار) | `mojoodiKala` |
| کارساخته: گردش | `DoListAsnad` با فیلتر نوع سند (باید روی سرور تست تأیید شود) | `infogardeshsakhte` |
| کارساخته: ساخت | **متد ندارد** — نام کار با اولین سند ساخته می‌شود | `addFelezsakhteModel` (ویرایش ندارد) |
| سند سفارش (کارساخته، بنکداری) | `DoNewSanadVKHKar` (خروج کارساخته، چندردیفی با `MultiRadif`) | `foroushSakhte` با `bonak=t` (ردیف اول با `idHesab`، بقیه با `idFactor`) |
| موقت/حذف سند | `Sabte_Kol_Or_Movaghat=0`، `DoDeleteSanad` | `movaghat=1`، `hazfRadifSanad` (فقط ردیف‌های API) |
## ۲. تصمیم‌های مالک (این جلسه)
1. سه محور + کارساخته: سینک مشتری، سینک مانده، ثبت سند سفارش، و همگام‌سازی کارساخته↔محصول. **بقیه‌ی چیزها از اپ خودمان** (اسناد دریافت/آبشده/چک در اپ می‌مانند و ارسال نمی‌شوند).
2. مانده‌ی حسابداری **به مشتری نمایش داده می‌شود**؛ **سقف اعتبار از دفتر داخلی اپ** می‌ماند.
3. **گزارش (ریز اسناد) هم از حسابداری** خوانده شود تا با مانده یکی باشد.
4. مشتری جدید اپ **بعد از احراز هویت (VERIFIED)** در حسابداری ساخته می‌شود؛ مشتری‌های موجود با **موبایل** لینک می‌شوند. مشتری‌ای که فقط در حسابداری است **در اپ ساخته شود** — اولویت با موبایل است، نه کد ملی.
5. سند سفارش در **لحظه‌ی ثبت بدهی (تحویل مسئول فروش)** و به‌صورت **موقت** ثبت می‌شود.
6. فعلاً **فقط کارساخته**؛ معاملات طلا/سکه بعداً (لایه طوری ساخته می‌شود که با یک متد اضافه شود).
7. کارساخته‌ی حسابداری = **محصول** اپ. چه در ادمین ثبت شود چه در حسابداری، باید همگام شود.
8. فیلدهای مشترک (عنوان، کد، موجودی…) **مال حسابداری** است؛ عکس، توضیحات، واریانت‌ها، دسته‌بندی و **درصد اجرت مال اپ** (درصد حسابداری فقط پیش‌فرض واریانت جدید).
9. **بدون عکس در اپ نمایش داده نشود** — خودکار، برای همه‌ی محصولات.
10. موجودی حسابداری در ادمین نمایش داده می‌شود و اگر صفر شد محصول در اپ **ناموجود** می‌شود (مگر پیش‌سفارش).
11. محیط تست زرین نداریم → درایور زرین با داده‌های نمونه‌ی داکیومنت تست می‌شود و روشن‌کردنش روی نصب واقعی فقط بعد از یک دوره‌ی آزمایشی با سند موقت.
## ۳. معماری
### ۳.۱ ماژول و درایورها
ماژول جدید `apps/api/src/modules/accounting-sync/` با همان الگوی درایور پیامک (`apps/api/src/modules/sms/drivers/` — یک فایل برای هر درایور + یک رجیستری):
- `providers/accounting-provider.ts` — اینترفیس مشترک: `health()`, `listCustomerGroups()`, `findCustomersByPhone()`, `listCustomers(cursor)`, `createCustomer()`, `updateCustomer()`, `getBalances(codes[])`, `getStatement(code, from, to, page)`, `listKarSakhte()`, `getKarSakhteStock(ref)`, `getKarSakhteMovement(from, to)`, `createKarSakhte()` (اختیاری — `capabilities` اعلام می‌کند)، `postOrderDocument(order)`, `inquireDocument(ref)`, `deleteDocument(ref)`.
- `providers/tahesab.provider.ts` ، `providers/zarrin.provider.ts` ، `providers/index.ts` (رجیستری).
- `accounting-http.ts` — کلاینت HTTP با timeout (`AbortController`، مثل `deliverViaTrez`)، **TLS همیشه روشن** با امکان دادن گواهی CA اختصاصی برای نصب‌های self-signed (نه `withoutVerifying` سیستم قدیم — SEC-17). پاک‌سازی پاسخ خراب ته‌حساب (BOM/کاراکتر کنترلی) مثل `TahesabController::test` قدیم.
- `jalali.ts` — تبدیل تاریخ با `Intl` (`-u-ca-persian`)، بدون وابستگی جدید؛ ته‌حساب سال/ماه/روز شمسی جدا می‌خواهد.
- `accounting-config.service.ts`, `accounting-sync.service.ts` (منطق همگام‌سازی)، `accounting-outbox.worker.ts`، `accounting-admin.controller.ts`.
### ۳.۲ تنظیمات (ادمین، نه env)
یک ردیف `Setting` با کلید `accounting.config`: `provider: 'none' | 'tahesab' | 'zarrin'`، اتصال هر درایور (آدرس، `DBname`، توکن، CA)، نگاشت‌ها، کلید روشن/خاموش هر قابلیت و بازه‌های زمان‌بندی.
- **راز (توکن)** write-only: هیچ API آن را برنمی‌گرداند (فقط `••••` + «تنظیم شده»)، با کلید env `ACCOUNTING_SECRET_KEY` (خوانده‌شده با `envOr` طبق D25) رمز می‌شود.
- **حفره‌ی موجود را می‌بندیم:** `GET/PUT/DELETE const` در [admin-misc.service.ts](apps/api/src/modules/admin-misc/admin-misc.service.ts) همه‌ی ردیف‌های `Setting` را به هر کسی با `config.statics` می‌دهد؛ کلیدهای `accounting.*` از این مسیرها حذف/رد می‌شوند.
- تعویض درایور: صف کارهای درایور قبلی متوقف (نه حذف) می‌شود؛ لینک‌ها per-provider هستند، پس برگشت به درایور قبلی بی‌خطر است. ادمین هنگام تعویض هشدار می‌بیند.
### ۳.۳ تغییرات اسکیما (`apps/api/prisma/schema.prisma` + migration)
- `AccountingLink` — `provider`, `entityType (CUSTOMER|PRODUCT)`, `entityId`, `externalCode`, `externalName`, `snapshot Json`, `syncedAt`؛ یکتا روی `(provider, entityType, entityId)` و `(provider, entityType, externalCode)`.
- `AccountingOutbox` — `provider`, `kind (CUSTOMER_PUSH|ORDER_DOCUMENT|ORDER_DOCUMENT_DELETE|PRODUCT_PUSH)`, `entityId`, `status (PENDING|SENT|FAILED|NEEDS_REVIEW|PAUSED)`, `attempts`, `nextAttemptAt`, `lastError`, `externalRef`, `progress Json` (برای سند چندردیفی زرین)، `request/response` خلاصه **بدون توکن**.
- `Customer`: `accountingGoldBalance`, `accountingRialBalance` (Decimal)، `accountingBalanceAt`.
- `Product`: `accountingStockWeight`, `accountingStockCount` (Decimal?)، `accountingStockAt`.
### ۳.۴ صف خروجی (Outbox) و زمان‌بند
- رویدادها **داخل همان تراکنش** کار اصلی در `AccountingOutbox` نوشته می‌شوند (مثلاً سند سفارش در همان تراکنشی که `postOrderDebt` اجرا می‌شود) → هیچ رویدادی گم نمی‌شود و قطعی حسابداری سفارش را متوقف نمی‌کند.
- Worker درون‌پردازه‌ای با `setInterval` + `pg_try_advisory_lock` (چند نمونه‌ی API هم‌زمان اجرا نکنند) — بدون وابستگی جدید. retry با backoff نمایی؛ بعد از N بار → `FAILED` و نمایش در ادمین.
- **ابهام timeout:** ته‌حساب → قبل از تلاش دوباره `DoNewSanadInquiry-By-Factor-Code`؛ زرین (بدون کلید ضد تکرار) → `NEEDS_REVIEW` به‌جای ارسال کور دوباره.
- کارهای pull دوره‌ای (مانده، کارساخته، مشتری) همین worker با بازه‌های قابل تنظیم.
## ۴. قابلیت‌ها
### F1 — مشتری
- **Pull (حسابداری → اپ):** حساب‌های گروه‌های انتخاب‌شده در ادمین (مثلاً «مشتري» GID 13 در ته‌حساب، «مشتری (خریدار)» در زرین) خوانده می‌شوند؛ موبایل نرمال می‌شود (`faToEn` + تبدیل `912…`→`0912…`).
- موبایل در اپ هست → لینک.
- نیست → **مشتری اپ ساخته می‌شود**: `nationalCode` = کد ملی حسابداری اگر معتبر و آزاد بود، وگرنه **خود موبایل** (همان fallback موجود در `customer-auth.service.ts:258` و `admin-customers.service.ts`)؛ `passwordHash: ''` (ورود با OTP)؛ `UNVERIFIED`، سقف اعتبار ۰ (بقیه مال اپ است).
- حساب بدون موبایل، یا چند حساب با یک موبایل → لیست «مغایرت» در ادمین، بدون اقدام خودکار.
- **Push (اپ → حسابداری):** وقتی ادمین مشتری را VERIFIED می‌کند و لینک ندارد → اول جستجو با موبایل (لینک اگر پیدا شد)، وگرنه ساخت (`DoNewMoshtari` / `addAccount`) در گروه انتخاب‌شده. ویرایش نام/تلفن/آدرس مشتری لینک‌شده → `DoEditMoshtari` / `edithesab`. همه از طریق Outbox.
- ثبت‌نام با موبایلی که قبلاً از حسابداری وارد شده → همان ردیف (موبایل یکتاست)، بدون حساب تکراری.
### F2 — مانده
- دسته‌ای و دوره‌ای (پیش‌فرض ۱۰ دقیقه): ته‌حساب `GetMandeHesabByCode` با تکه‌های ۱۰۰تایی کدها؛ زرین `listMandeHesabha` یک‌جا. ذخیره در `Customer.accountingGold/RialBalance`.
- تازه‌سازی تکی وقتی مشتری داشبورد را باز می‌کند و مانده قدیمی‌تر از X ثانیه است (timeout ۳ ثانیه، در خطا همان مقدار ذخیره‌شده).
- **قرارداد علامت:** ته‌حساب منفی = بدهکار (کد قدیم `* -1` می‌کرد)؛ به قرارداد اپ (مثبت = بدهکار) تبدیل می‌شود. روی سرور تست تأیید می‌شود.
- نمایش: `balances()` در [customer-report.service.ts](apps/api/src/modules/customer-report/customer-report.service.ts) وقتی اتصال روشن است مانده‌ی حسابداری + زمان آخرین سینک را برمی‌گرداند (همان شکل `{arz, mandeh}` → اپ‌ها بدون تغییر). پنل ادمین هر دو مانده را کنار هم نشان می‌دهد.
- **سقف اعتبار دست نمی‌خورد:** چک در [orders.service.ts:88-92](apps/api/src/modules/orders/orders.service.ts#L88-L92) همچنان با `goldDebtGrams` داخلی.
### F3 — سند سفارش (کارساخته)
- ماشه: شاخه‌ی `DELIVERED_TO_AGENT && !order.deliveredAt` در `updateStatus` ([orders.service.ts:154-157](apps/api/src/modules/orders/orders.service.ts#L154-L157)) — همان تراکنش `postOrderDebt` — یک ردیف `ORDER_DOCUMENT` در Outbox. پیش‌سفارش تبدیل‌شده هم چون Order می‌شود از همین مسیر می‌رود.
- همه‌ی اسناد **موقت** ثبت می‌شوند تا حسابدار نهایی کند.
- **ته‌حساب:** هر قلم یک ردیف `DoNewSanadVKHKar` (خروج کارساخته، `IsVoroodOrKhorooj=1`)، `MultiRadif=1`، `Radif_Number` ۱..n، `Factor_Code` قطعی = `GB-ORD-<order.id>` (≥۲۰ کاراکتر → ضد تکرار)، `Vazn`=وزن راش×تعداد، `Ayar`=750 (قابل تنظیم)، `Count`، `Name_Kar`=نام کار لینک‌شده، درصد اجرت مؤثر (اجرت − تخفیف)، `PoolSang`=هزینه‌ی ریالی ثابت.
- **زرین:** `foroushSakhte` با `bonak=t`, `pd=-1`, `movaghat=1`؛ قلم اول با `idHesab` → `idFactor`، اقلام بعد با `idFactor`؛ `idSakhte`=کارساخته‌ی لینک‌شده، `vazn`، `darsad`=اجرت مؤثر، `vaznSang/mablaghSang`. پیشرفت هر قلم در `progress` ذخیره می‌شود تا شکست وسط کار از همان‌جا ادامه یابد.
- **پذیرش:** بعد از ثبت، تغییر مانده‌ی وزنی حسابداری باید دقیقاً برابر `order.totalGoldWeightGrams` باشد (این‌که اجرت در کدام فیلد برود — `Darsad_Talayee` یا وزن محاسبه‌شده — با همین معیار روی سرور تست تعیین می‌شود).
- لغو سفارش بعد از ارسال سند → `ORDER_DOCUMENT_DELETE` (`DoDeleteSanad` / `hazfRadifSanad kol=1`) فقط اگر سند هنوز موقت است؛ وگرنه `NEEDS_REVIEW`.
- محصولِ بدون لینک موقع سند: چون طبق F4 هر محصول لینک دارد، این حالت استثناست → `NEEDS_REVIEW` با پیام روشن.
### F4 — کارساخته ↔ محصول
- **Pull (حسابداری → اپ)** دوره‌ای (پیش‌فرض ۳۰ دقیقه چون `listKarSakhte` کند است): کارساخته‌ی جدید → `Product` جدید (عنوان = نام، `productCode` = کد حسابداری، دسته از نگاشت «گروه ساخته → دسته‌بندی» در ادمین) بدون عکس و بدون واریانت → در اپ دیده نمی‌شود تا ادمین عکس و واریانت (وزن/تعداد) بگذارد. درصد فروش حسابداری فقط پیش‌فرض واریانت جدید.
- محصول لینک‌شده: **عنوان و کد** از حسابداری به‌روز می‌شوند و در فرم ادمین قفل‌اند؛ عکس، توضیحات، واریانت، دسته و اجرت آزاد.
- **Push (ادمین → حسابداری):**
- زرین: محصول جدید → `addFelezsakhteModel` (`noeKar` و `gorooheKar` از نگاشت ادمین) → لینک `idKar`. چون زرین ویرایش ندارد، عنوان بعد از لینک فقط از حسابداری عوض می‌شود.
- ته‌حساب: متد ساخت ندارد → `Name_Kar` = عنوان محصول؛ کار با اولین سند در ته‌حساب پدید می‌آید و از آن به بعد در `DoListNameKarsakhte` دیده و لینک می‌شود. (محدودیت API، نه انتخاب ما.)
- **موجودی:** `mojoodiKala` / `GetMojoodiKarSakhte` → `Product.accountingStock*`؛ ستون در لیست محصولات ادمین. اگر ≤ ۰ → محصول در اپ ناموجود (واریانت‌های پیش‌سفارش مستثنا) — هم در کاتالوگ هم در `createOrder` اعمال می‌شود. تعداد واریانت‌ها همچنان در ادمین.
- **گردش کارساخته:** صفحه‌ی گزارش در ادمین (بازه‌ی تاریخ) — زرین `infogardeshsakhte`؛ ته‌حساب از `DoListAsnad` اگر روی سرور تست فیلتر نوع سند جواب داد، وگرنه در این صفحه «پشتیبانی نمی‌شود».
### F5 — قانون عکس
- [catalog.service.ts](apps/api/src/modules/catalog/catalog.service.ts) (تنها مسیر لیست محصول برای مشتری) فیلتر `images: { some: {} }` می‌گیرد؛ `createOrder` واریانتِ محصول بی‌عکس را رد می‌کند.
- قبل از انتشار، تعداد محصولات فعلی بی‌عکس که پنهان می‌شوند گزارش می‌شود.
### F6 — گزارش از حسابداری
- وقتی اتصال روشن است، `summary`/`detail` در `customer-report.service.ts` از درایور خوانده می‌شود و به همان شکل `henza_pwa/src/type/accounting.ts` نگاشت می‌شود (اپ‌ها بدون تغییر):
- ته‌حساب `DoListAsnad` (`Count_Last=-1` برای مانده‌ی ردیف‌به‌ردیف) → `tahesab_T`=`TahesabVazni`، `tahesab_P`=`TahesabMali`، `factor_Code`، `sh_factor`، `tarikh`…
- زرین `parvande` → وزن/پول دریافت و پرداخت؛ مانده‌ی جاری از ردیف «مانده از قبل» جمع زده می‌شود.
- **SEC-16 حفظ می‌شود:** جزئیات سند فقط اگر کد حساب سند = کد لینک‌شده‌ی همین مشتری (از توکن).
- ستون‌هایی که REP-01 خالی گذاشته بود (نام کارساخته، جنس فلز…) حالا منبع دارند.
- اتصال خاموش یا خطا → همان گزارش دفتر داخلی فعلی (با برچسب منبع).
## ۵. پنل ادمین
- صفحه‌ی جدید `/config/manage/accounting` «اتصال حسابداری»، مجوز جدید `config.accounting` (در `apps/api/src/modules/auth/acl.constants.ts`، `apps/admin-panel/src/views/user/permissionConst.ts`، `layouts/navMenuItems.ts`، `router/admin.ts`)، با الگوی Ant Design صفحه‌ی `views/manage/wallet.vue`:
1. **اتصال:** سوییچ «بدون اتصال / ته‌حساب / زرین»، فیلدهای همان درایور، دکمه‌ی «تست اتصال» (`CheckHealth` / `version`).
2. **نگاشت:** گروه‌های مشتری برای ورود، گروه مشتری جدید، گروه ساخته ↔ دسته‌بندی، نوع کارساخته‌ی جدید (زرین)، عیار پیش‌فرض.
3. **قابلیت‌ها و زمان‌بندی:** روشن/خاموش هر قابلیت و بازه‌ها + دکمه‌های «سینک الان».
4. **لاگ:** جدول Outbox با فیلتر وضعیت، تلاش دوباره، «حل‌شده»، مشاهده‌ی درخواست/پاسخ (بدون توکن).
5. **مغایرت‌ها:** موبایل تکراری/خالی، کارساخته‌ی بی‌دسته، سند `NEEDS_REVIEW`.
- صفحه‌ی مشتری: کد حسابداری + هر دو مانده + «لینک دستی با کد».
- لیست/فرم محصول: کد حسابداری، موجودی حسابداری، قفل عنوان/کد، نشان «بدون عکس — در اپ پنهان».
## ۶. فازها
| فاز | محتوا |
|---|---|
| P0 | مستندات (این طرح در `docs/`، D27، به‌روزرسانی REP-01/SEC-17)، migration، تنظیمات + راز + بستن حفره‌ی `const`، اینترفیس درایور، کلاینت HTTP، Outbox/worker، تب «اتصال» + تست اتصال |
| P1 | F1 مشتری |
| P2 | F2 مانده + F6 گزارش |
| P3 | F4 کارساخته/محصول + F5 قانون عکس |
| P4 | F3 سند سفارش |
هر دو درایور در هر فاز ساخته می‌شوند؛ ته‌حساب روی سرور تست عمومی تأیید می‌شود، زرین با fixtureهای داکیومنت و برای نصب واقعی با دوره‌ی آزمایشی سند موقت (تصمیم ۱۱).
## ۷. فایل‌های اصلی
- جدید: `apps/api/src/modules/accounting-sync/**`، `apps/admin-panel/src/views/manage/accounting.vue`، migration جدید Prisma.
- تغییر: `apps/api/prisma/schema.prisma`، `apps/api/src/app.module.ts`، `modules/orders/orders.service.ts` (Outbox + قفل ناموجود)، `modules/admin-customers/admin-customers.service.ts` (هوک VERIFIED/ویرایش، لینک دستی)، `modules/customer-report/customer-report.service.ts`، `modules/catalog/catalog.service.ts`، `modules/admin-products/admin-products.service.ts` (push + قفل فیلد)، `modules/admin-misc/admin-misc.service.ts` (حفاظت `accounting.*`)، `modules/auth/acl.constants.ts`، فایل‌های router/nav/permission پنل ادمین، صفحه‌های مشتری و محصول ادمین.
- مستندات/استقرار: `docs/parity/DECISIONS.md`، `ISSUES.md`، `DELIVERY.md`، `.env.example`، `docker-compose.yml` (`ACCOUNTING_SECRET_KEY`).
## ۸. آزمون
- **واحد (Jest، کنار `sms.service.spec.ts`):** هر درایور با `fetch` ماک‌شده و پاسخ‌های نمونه‌ی خود داکیومنت‌ها: ترتیب آرگومان‌های ته‌حساب، هدرها، تاریخ شمسی، علامت مانده، `Factor_Code`، ادامه‌ی سند چندردیفی زرین، رفتار timeout/500/JSON خراب.
- **Outbox:** نوشتن در همان تراکنش، retry/backoff، inquiry پیش از تکرار (ته‌حساب)، `NEEDS_REVIEW` (زرین)، توقف هنگام تعویض درایور، قفل advisory.
- **یکپارچه با سرور تست ته‌حساب** (اسکریپت در `apps/api/scripts/`، کنار `auth-smoke.sh`): ساخت مشتری → خواندن مانده → سند موقت `VKHKar` → inquiry → بررسی تغییر مانده = وزن سفارش → حذف سند.
- **e2e محلی:** سفارش آزمایشی تا «تحویل مسئول فروش» → ردیف Outbox → `SENT`؛ سقف اعتبار همچنان با دفتر داخلی؛ محصول بی‌عکس در `catalog/products` نیست؛ `GET const` کلید `accounting.config` را برنمی‌گرداند و توکن در هیچ پاسخ/لاگی نیست.
- `pnpm test`، `pnpm build`، lint، و یک دور دستی در پنل ادمین (اتصال، تست، سینک الان، لاگ).
## ۹. مواردی که روی سرور تست بسته می‌شوند (سؤال از شما نیست)
- علامت و واحد مانده‌ی وزنی (گرم ۷۵۰؟) در هر دو.
- فیلد درست اجرت در `VKHKar` (`Darsad_Talayee` یا وزن محاسبه‌شده) با معیار پذیرش F3.
- قالب تاریخ هر API زرین (بعضی میلادی، بعضی شمسی‌اند).
- شناسه‌های `fkNoe`/`noeKar` زرین روی نصب واقعی.
## ۱۰. وضعیت اجرا (2026-10-05)
| فاز | وضعیت |
|---|---|
| P0 | ✅ اسکیما + migration، تنظیمات با توکن رمزشده و write-only، بستن مسیرهای `const`، اینترفیس درایور، کلاینت HTTP، Outbox + worker با lease، صفحه‌ی «اتصال حسابداری» |
| P1 | ✅ مشتری: ورود/لینک با موبایل، ساخت در حسابداری پس از VERIFIED، ویرایش، لیست مغایرت |
| P2 | ✅ مانده (دوره‌ای + تازه‌سازی ۳ ثانیه‌ای در `/customer/me`) و گزارش از حسابداری با بازگشت به دفتر داخلی |
| P3 | ✅ کارساخته ↔ محصول، قفل عنوان/کد، ستون موجودی، قفل ناموجود، قانون عکس |
| P4 | ✅ سند موقت در «تحویل مسئول فروش»، حذف با لغو (فقط سند موقت) |
**آزمون:** ۴۳ تست واحد درایورها/ابزارها (کل API: ۱۸۰ تست سبز)، و `apps/api/scripts/test-accounting-tahesab.ts` روی **سرور تست واقعی ته‌حساب** و یک دیتابیس دورریختنی: ۲۹/۲۹ — از جمله لینک دوباره‌ی همان حساب با موبایل در اجرای دوم، و سندی که دقیقاً به اندازه‌ی وزن سفارش جابه‌جا می‌کند. سند آزمایشی در پایان حذف می‌شود.
### نتیجه‌ی موارد بخش ۹ (اندازه‌گیری روی سرور تست ته‌حساب)
- **علامت مانده:** «خروج» مانده‌ی وزنی را کم می‌کند → منفی = بدهکار. در درایور برعکس می‌شود.
- **اجرت:** اثر سند روی مانده = وزن × عیار ÷ ۷۵۰؛ `Darsad_Talayee` و `Ojrat` فقط ذخیره می‌شوند و اثری ندارند. پس اجرت به‌صورت **افزایش عیار** فرستاده می‌شود (۷۵۰ با ۱۰٪ → ۸۲۵) و وزن فیزیکی دست نمی‌خورد.
- **سنگ:** `PoolSang` روی مانده‌ی ریالی اثر ندارد؛ هزینه‌ی ثابت ریالی در `MablaghKol` می‌رود.
- **سند موقت** تا نهایی‌شدن روی مانده اثر ندارد (ACC-02).
- **ضد تکرار:** `Factor_Code` کوچک‌حرف ذخیره می‌شود؛ ارسال دوباره‌ی همان ردیف خطای duplicate می‌دهد → تلاش دوباره امن است. استعلام سند چندردیفی `Duplicate` برمی‌گرداند (= پیدا شد).
- **زرین:** هنوز روی نصب واقعی آزموده نشده (ACC-01).
### یافته‌های خارج از دامنه (در ISSUES)
- **ACC-03 (High):** ثبت دوباره‌ی بدهی سفارش اگر دو بار به «تحویل مسئول فروش» برود — ناحیه‌ی محافظت‌شده، اصلاح نشد.
- **ACC-04:** `runMigrations` روی دیتابیس کاملاً خالی به‌خاطر ترتیب نام `0002_admin_schema` می‌شکند.
## ۱۱. همگام‌سازی لحظه‌ای (حکم مالک، ۱۴۰۵/۰۷/۱۳ — جایگزین «سینک دوره‌ای»)
مالک: «این باید ریل‌تایم باشد، نه با تأخیر یا سینک دستی». هیچ‌کدام از دو API وب‌هوک ندارند، پس تأخیر با سه لایه به چند ثانیه می‌رسد:
| مسیر | قبل | حالا |
|---|---|---|
| اپ ← حسابداری (سند سفارش، مشتری تأییدشده/ویرایش‌شده) | منتظر tick ۳۰ ثانیه‌ای | بعد از commit همان تراکنش (≈۰٫۷ ثانیه) صف ارسال را بیدار می‌کند؛ حلقه‌ی سریع هر ۲ ثانیه هم صف را می‌خواند |
| حسابداری ← اپ (مانده، موجودی کارساخته) | ۱۰ و ۳۰ دقیقه | **فید تغییرات** هر ۵ ثانیه (ثابت در کد؛ تنظیم ادمینش به خواست مالک حذف شد)؛ فقط حساب‌ها/کارساخته‌های تغییرکرده دوباره خوانده می‌شوند |
| مانده‌ای که مشتری می‌بیند | تازه‌سازی اگر قدیمی‌تر از ۶۰ ثانیه | اگر قدیمی‌تر از ۵ ثانیه، همان لحظه از حسابداری (مهلت ۳ ثانیه؛ در خطا آخرین مقدار) |
| موجودی هنگام **ثبت سفارش** | مقدار ذخیره‌شده | مستقیم از حسابداری (مهلت ۲٫۵ ثانیه)؛ **اگر پاسخ ندهد، سفارش با آخرین موجودی معلوم ثبت می‌شود** و تغییر بعدی اصلاحش می‌کند (حکم مالک) |
**فید تغییرات** (`getChanges` در هر درایور؛ مکان‌نما در حافظه، اولین poll فقط مبدأ را می‌گیرد):
- **ته‌حساب:** `DoListUpdatedAsnad(200)` (≈۴۰۰ms). سند «جدید» = `ID` بزرگ‌تر از مکان‌نما یا `ZamanEdit` دیرتر؛ از هر سند `MCode` (حساب) و برای اسناد کارساخته `No_Kar` (نام کار). روی سرور تست واقعی ثابت شد: سند ثبت‌شد و در poll بعدی (۰٫۷ ثانیه) حساب و کارساخته‌اش گزارش شد.
- **زرین:** `parLastTenRadif` بدون حساب (تازه‌ترین ردیف‌های روزنامه، `idRadif` صعودی) — **هنوز روی نصب واقعی دیده نشده (ACC-01)**؛ نام کارساخته‌ی تغییرکرده را نمی‌دهد، پس موجودی در زرین فقط با بازه‌ی کارساخته و خواندن لحظه‌ی سفارش تازه می‌شود.
- **چیزی که فید نمی‌بیند** (حذف سند/حساب، مشتری جدید بی‌سند، ویرایش مشخصات مشتری): حلقه‌ی ۳۰ ثانیه‌ای قبلی با بازه‌های دقیقه‌ای به‌عنوان **شبکه‌ی اطمینان** می‌ماند. اگر پنجره‌ی فید سرریز شود (همه‌ی ردیف‌ها جدید) یا حسابی/کاری لینک‌نشده دیده شود، همان لحظه کار کامل مربوط (مانده/کارساخته/مشتری، مشتری حداکثر دقیقه‌ای یک بار) اجرا می‌شود.
- حلقه‌ی سریع جدا از حلقه‌ی کند است؛ import طولانی مشتریان آن را نگه نمی‌دارد.
محدودیت: ته‌حساب سند **موقت** را روی مانده اعمال نمی‌کند (ACC-02)؛ لحظه‌ای بودن یعنی «تغییر ثبت‌شده در حسابداری چند ثانیه بعد در اپ است»، نه اینکه سند موقت مانده را عوض کند.
## ۱۲. دکمه‌های سینک، مانده‌ی پروفایل، قانون زمان ثبت سند (حکم مالک، ۱۴۰۵/۰۷/۱۳)
حکم‌ها: دکمه‌ها و مانده‌ی پروفایل **مستقل از کلیدهای قابلیت** کار می‌کنند (فقط اتصال برقرار باشد)؛ قانون سند در همان صفحه‌ی «اتصال حسابداری».
| جا | رفتار |
|---|---|
| «لیست مشتریان عمده» | دکمه‌ی «سینک با …» (فقط وقتی اتصال برقرار است): `POST admin/customers-omde/accounting-sync` (مجوز همان لیست) — ورود/لینک مشتریان (اگر گروه انتخاب شده باشد) + مانده‌ی همه؛ منتظر می‌ماند و نتیجه‌ی هر بخش را نشان می‌دهد |
| «لیست محصولات» | دکمه‌ی مشابه: `POST admin/products/accounting-sync` (مجوز `product.list`) — کارساخته‌ها و موجودی |
| پروفایل اپ (هر دو اپ) | هر بار باز شدن صفحه یک درخواست جدا `GET customer/me/accounting-balance` مانده‌ی **همان** مشتری را همان لحظه از حسابداری می‌خواند و در بخش «مانده‌ی حساب» (طلا و ریال، بدهکار/بستانکار) نشان می‌دهد؛ اگر حسابداری پاسخ ندهد «آخرین مانده‌ی ثبت‌شده» |
| ثبت سند سفارش | قانون `documents.triggerStatus` در تب «قابلیت‌ها و زمان‌بندی»، کنار کلید ثبت سند: سند **موقت (پیش‌فاکتور)** وقتی سفارش به این وضعیت **یا بعد از آن** برسد ثبت می‌شود (ثبت سفارش، تأیید مدیر، تأمین، آماده‌سازی، تحویل مسئول فروش [پیش‌فرض = رفتار قبلی]، تحویل مشتری). ارسال بلافاصله پس از commit |
جزئیات قانون سند: `SUSPENDED` هیچ‌وقت ماشه نیست؛ برگشت وضعیت به زیر قانون (برداشتن وضعیت در ادمین) یا لغو یا **حذف سفارش از ادمین** سند موقت را پس می‌گیرد (حذف فقط اگر هنوز موقت است). در ته‌حساب رسیدن دوباره به قانون سند تازه می‌فرستد (ترتیب صف: اول حذف)؛ در زرین هرگز دو بار (کلید ضد تکرار ندارد). کار حذف سند دیگر به ردیف سفارش نیاز ندارد (کد حساب از درخواست خود سند خوانده می‌شود). ثبت بدهی دفتر داخلی (`postOrderDebt`) دست نخورد.
## ۱۳. زرین روی نصب واقعی (۱۴۰۵/۰۷/۲۰ — برای بررسی‌های بعدی)
نصب مالک: زرین نسخه‌ی **۲۲۸**، حدود ۸۷۰ حساب، ۶۵۸ کارساخته. آدرس و توکن نزد مالک است و در مخزن نوشته نمی‌شود.
### تست دوباره
اسکریپت [apps/api/scripts/test-accounting-zarrin.ts](../apps/api/scripts/test-accounting-zarrin.ts) خودِ درایور را روی نصب واقعی اجرا می‌کند. دیتابیس اپ را دست نمی‌زند.
```
ZARRIN_URL=http://<host>:<port>/ZarrinService/accounting ZARRIN_TOKEN=<token> ZARRIN_ACCOUNT=499 \
npx ts-node --transpile-only scripts/test-accounting-zarrin.ts # فقط خواندن (۱۳ بررسی)
# + ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953 # همراه نوشتن (۲۰ بررسی)
```
- **حساب آزمایشی ۹۵۳** «تست اپ گلدبانک» (موبایل 09990001111، گروه «متفرقه») با تأیید مالک برای همین تست ساخته شد. نوشتن **فقط روی همین حساب** مجاز است. زرین API حذف حساب و حذف کارساخته ندارد، پس اسکریپت هیچ‌وقت حساب یا کارساخته نمی‌سازد.
- درخواست‌ها با فاصله فرستاده شوند: سرور یک بار وقتی پشت سر هم درخواست گرفت، اتصال را قبول نکرد.
- نتیجه‌ی آخرین اجرا (۱۴۰۵/۰۷/۲۰): ۲۰ از ۲۰.
### رفتارهایی که در داکیومنت زرین نیست
| موضوع | واقعیت | در درایور |
|---|---|---|
| علامت مانده | منفی = بدهکار | پیش‌فرض صفحه‌ی اتصال درست است |
| سند موقت | **همان لحظه روی مانده اثر دارد** (برخلاف ته‌حساب) | — ؛ قانون ثبت سند را با این در نظر بگیرید |
| موقت بودن سند API | در `shomareSanad` = «موقت»؛ سندی که در خود زرین باز مانده: `noeFactor` = «فاکتور موقت» | هر دو خوانده می‌شود |
| `listKarSakhte` | صفحه‌های ۱۰۰تایی با `pageNum` | همه‌ی صفحه‌ها خوانده می‌شود |
| `listhesabha` | اسم‌ها «نام \* نام‌خانوادگی» | «\*» حذف می‌شود |
| `parvande` | ردیف اول «مانده از قبل» مانده نیست | کنار گذاشته می‌شود؛ مانده‌ی جاری از مانده‌ی واقعی (`listMandeHesabha`) به عقب حساب می‌شود |
| `parLastTenRadif.mandeG` | مانده‌ی **فعلی** حساب روی همه‌ی ردیف‌ها، نه مانده‌ی هر ردیف | برای مقایسه‌ی ردیف‌به‌ردیف قابل استفاده نیست |
| `goroohSakhte` | جواب درست با کد HTTP 400 | پذیرفته می‌شود |
| `infogardeshsakhte` | JSON دست‌نویس معیوب، کند (۵ تا ۳۳ ثانیه)، گاهی خطای ۵۰۰ از خود زرین | تعمیر JSON، مهلت ۹۰ ثانیه |
| اتصال‌های keep-alive | سرور اتصال بیکار را می‌بندد و درخواست بعدی `socket hang up` می‌گیرد | یک بار تکرار روی اتصال تازه |
| `addAccount` با موبایل تکراری | حساب نمی‌سازد | درایور قبل از ساخت با موبایل جستجو می‌کند |
### تست‌نشده
- ساخت کارساخته از ادمین (`addFelezsakhteModel`): مالک نخواست کارساخته‌ی آزمایشی دائمی ساخته شود.
- سنگ: هزینه‌ی سنگ در `mablagh` فرستاده می‌شود؛ با سندی که سنگ دارد بررسی نشده.
### امنیت
نصب مالک روی **http بدون رمزنگاری** و روی اینترنت باز است، با توکن ساده. توصیه: توکن قوی و https یا VPN.
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
# جابه‌جایی مشکی و زرشکی در اپ مشتری (user-app-v2)
**وضعیت:** تأیید شد و انجام شد (2026-10-14). زرشکی دقیق برند: `#7C0035`. خروج ← مشکی؛ کارت‌های بزرگ (موجودی، دعوت، نوار نرخ) ← خودِ زرشکی برند `#7C0035` در هر دو حالت روشن و تیره (به درخواست صاحب کار، به‌جای زرشکی تیره).
**تاریخ:** ۱۴۰۵/۰۷/۲۲ (2026-10-14)
## هدف
- رنگ اصلی اپ از **مشکی** به **زرشکی** تغییر کند و مشکی نقش فرعی داشته باشد.
- مشکی کمتر دیده شود و زرشکی در اولویت باشد.
- **ظاهر و ساختار صفحه‌ها تغییر نمی‌کند**؛ فقط رنگ همان دکمه‌ها و کارت‌های فعلی عوض می‌شود.
## خارج از این کار (دست نمی‌خورد)
- **رنگ متن‌ها**: متن‌ها همان مشکی/خاکستری فعلی می‌مانند. اگر متن زرشکی شود، خواندنش سخت می‌شود.
- **سرمه‌ای و برنزیِ باقی‌مانده از اپ قدیمی**: این‌ها شامل نوار وضعیت گوشی، نوار نرخ طلا، اسپینر، `SheetArt`، `ActingAsBar` و پنجرهٔ به‌روزرسانی هستند و اگر خواستید، بعداً جداگانه انجام می‌شوند.
- اپ قدیمی `user-app` و پنل مدیریت.
## وضعیت فعلی
در این اپ، **«مشکیِ پُر»** (`@ink`) رنگ اصلی است و **زرشکی** (`@wine`) فقط برای تأکید، حالت فشرده، نشان‌ها و دکمهٔ خروج استفاده می‌شود.
## تغییرات
### ۱) هر جا که پس‌زمینه مشکیِ پُر است ← زرشکی (متن روی آن سفید)
این‌ها دکمه‌های اصلی، گزینه‌های انتخاب‌شده و کلیدها هستند:
| بخش | فایل |
|---|---|
| دکمه‌های سبد خرید (۴ مورد) | `views/CartView.vue` |
| دکمهٔ ورود | `views/user/LoginView.vue` |
| دکمهٔ ثبت‌نام | `views/user/RegisterView.vue` |
| گزینهٔ انتخاب‌شدهٔ وزن/سایز و دکمهٔ صفحهٔ محصول | `components/product/SingleProduct.vue` |
| برچسب روی کارت محصول | `components/product/ProductCard.vue` |
| چیپ‌های جستجو و فیلتر | `views/search/SearchView.vue`، `components/search/Filters.vue` |
| فیلترهای سفارش‌ها | `views/user/OrdersView.vue` |
| کلید روشن/خاموش | `components/ui/ToggleSwitch.vue` |
| بازشدن منوی کشویی | `components/ui/DropDown.vue` |
| دکمهٔ اصلی پنجره‌های پایین صفحه | `components/ui/IslandSheet.vue` |
| علاقه‌مندی‌ها، صفحهٔ تنظیمات ظاهر | `FavoritesView.vue`، `AppearanceView.vue` |
| دکمه‌های بخش کارشناس | `expert/ChildOrdersView.vue`، `expert/CustomerChoiceView.vue` |
حاشیهٔ گزینهٔ انتخاب‌شده (در تنظیمات ظاهر) و حاشیهٔ خانهٔ کد تأیید (OTP) در هنگام تایپ هم از مشکی به زرشکی تغییر می‌کنند.
### ۲) جاهایی که زرشکی قبلاً «رنگ دوم» بوده ← زرشکی تیره
برخی جاها زرشکی برای این بوده که از مشکیِ اصلی متمایز شوند؛ مثلاً چیپ فیلترِ فعال در جستجو و مرحلهٔ فعال سفارش. اکنون که رنگ اصلی زرشکی شده، این موارد **زرشکی تیره** (`@wineDeep`) می‌شوند تا تفاوت حفظ شود و مشکی هم وارد کار نشود.
### ۳) کارت‌های تیرهٔ بزرگ ← زرشکی تیره
کارت موجودی و دعوت به ورود در داشبورد، و نوار نرخ در صفحهٔ اصلی، اکنون مشکی هستند. با تغییر **یک مقدار** (`heroSurface`)، هر سه زرشکی تیره می‌شوند و گرادیان کارت موجودی از زرشکی تیره به زرشکی می‌رود.
### ۴) حالت تیره (Dark mode)
در حالت تیره همان قاعده برقرار است: دکمه‌ها با زرشکیِ روشن‌ترِ حالت تیره نمایش داده می‌شوند و متن روی آن‌ها سفید است. کارت‌های بزرگ هم زرشکی خیلی تیره می‌شوند، نه خاکستری.
## سؤال‌ها از شما
۱. **دکمهٔ «خروج»** اکنون زرشکی است تا از بقیه جدا باشد. اگر همه‌چیز زرشکی شود، این دکمه دیگر متمایز نیست.
- **پیشنهاد من:** خروج **مشکی** شود (عملاً جای دو رنگ عوض می‌شود). این یکی از معدود جاهایی است که مشکی می‌ماند.
- گزینهٔ دیگر: قرمز (رنگ خطا).
۲. **کارت‌های بزرگ داشبورد** (بند ۳): زرشکی تیره شوند یا مشکی بمانند؟
- **پیشنهاد من:** زرشکی تیره، چون خواستید مشکی کمتر دیده شود. نتیجه این است که صفحهٔ داشبورد به‌وضوح زرشکی دیده می‌شود.
## بعد از انجام
- بیلد اپ و بررسی چشمی صفحه‌های اصلی، سبد خرید، محصول، جستجو، ورود و داشبورد، در هر دو حالت روشن و تیره.
- یک خط یادداشت انتشار برای کاربر: «اپ: رنگ اصلی برنامه زرشکی شد».
+39
View File
@@ -0,0 +1,39 @@
# برند پروژه (هدنا گلد) — چک‌لیست تغییر نام
نام برند **هدنا گلد / Hedena Gold** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
## ۱. جاهایی که باید عوض شوند (فقط همین‌ها)
| چه چیزی | کجا | توضیح |
|---|---|---|
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین و اپ مشتری از همین یک فایل می‌خوانند. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «هدنا گلد» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). |
| لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است تا دامنهٔ جدید ست شود. |
| لوگو | فایل‌های تصویر با نام ثابت `brand-*` | فقط خود تصویر را با همان نام جایگزین کنید. |
### فایل‌های لوگو
- `apps/user-app/public/img/brand-logo.png` و `.webp`
- `apps/user-app/src/assets/img/brand-logo.png`
- `apps/admin-panel/src/assets/images/logo/brand-icon.png` و `.webp`، `brand-horizontal.png`
- آیکون‌های PWA و favicon: `apps/*/public/icon-192.png`، `icon-512.png`، `favicon.*`
- اسکریپت: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py)
### بعد از تغییر
1. `dist` فرانت‌ها و `apps/api/release` را دوباره بسازید.
2. اگر در دیتابیس برندی با عنوان قدیمی هست، از پنل ادمین ویرایشش کنید.
## ۲. عمداً خارج از گیت / رفرنس محلی
| مورد | توضیح |
|---|---|
| پوشه‌های `henza_admin`، `henza_pwa`، `henza_server` | فقط رفرنس محلی؛ در `.gitignore` هستند و به ریپو نمی‌روند |
| پوشهٔ `docs/` parity و سوابق | سابقهٔ کار؛ متن برنامه نیست |
| آدرس‌های `henzagold.com` در کامنت‌های قدیمی | اجرا نمی‌شوند |
## ۳. بررسی سریع
```bash
grep -rn "زرتاج\|Zartaj\|منتزر\|gold-bank\|goldbank" apps/*/src brand.ts package.json
```
+155
View File
@@ -0,0 +1,155 @@
# طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد)
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12) — **طرح؛ منتظر تأیید**
> **مالک:** «دیتاها در اپ واقعی تا ۱۶ هزار رکورد می‌رود و گرفتن همه یک‌جا اشتباه
> است، حتماً باید پیجینیشن داشته باشد.»
موافقم. با این عدد، وضعیت فعلی قابل دفاع نیست. این سند می‌گوید دقیقاً چه چیزی
شکسته است، چرا کار از «اضافه کردن `take`/`skip`» بزرگ‌تر است، و با چه ترتیبی
بی‌ریسک انجام می‌شود.
---
## ۱. وضعیت امروز (بررسی‌شده در کد، نه حدس)
| لایه | واقعیت | مدرک |
|---|---|---|
| مسیر API | `GET /catalog/products` **هیچ پارامتری نمی‌پذیرد** | `catalog.controller.ts:10-12` |
| کوئری | یک `findMany` بدون `take`/`skip`، همراه `variants`+`images`+`category`+`brand`+`collections` | `catalog.service.ts:19-42` |
| کش سرور | کل خروجی، ۳۰ ثانیه در حافظه، با هر نوشتن باطل | `catalog-cache.service.ts` |
| «صفحه‌بندی» اپ | `PAGE_SIZE = 20` فقط یک **پنجره رندر** روی آرایه‌ی کامل است: `slice(0, visibleCount)` | `SearchView.vue:204-206`، `NewestView.vue:55-57` |
| در هر اسکرول | **صفر درخواست**، ۲۰ کارت بیشتر رندر می‌شود | همان |
**ایندکس‌ها — یافته‌ی مهم:** مدل `Product` **هیچ ایندکسی ندارد** (نه
`isActive`، نه `categoryId`، نه `brandId`، نه `createdAt`) و `ProductVariant`
ایندکسی روی `productId` ندارد. با ۱۶ هزار رکورد، صفحه‌بندی بدون ایندکس همان
کندی را می‌دهد؛ پس ایندکس **بخشی از این کار است**، نه یک بهبود جداگانه.
## ۲. چرا کار از «اضافه کردن take/skip» بزرگ‌تر است
هفت مصرف‌کننده روی فرض «کل کاتالوگ در حافظه است» نوشته شده‌اند. سه‌تا بحرانی‌اند
— هر سه کل لیست را می‌گیرند تا **یک چیز** پیدا کنند:
| جا | الان چه می‌کند | با ۱۶ هزار رکورد |
|---|---|---|
| `useProductDetail` | کل کاتالوگ را می‌گیرد تا **یک محصول** را `find` کند | بازکردن هر محصول = دانلود کل کاتالوگ |
| `cartStore.init` | کل کاتالوگ برای همگام‌سازی **۲-۳ قلم سبد** | هر بار باز شدن اپ |
| `configStore.init` | کل کاتالوگ فقط برای **لیست دسته‌ها** | صفحه خانه |
| `SearchView` | متن/دسته/برند/بازه وزن را روی کلاینت فیلتر می‌کند | فیلتر روی داده‌ی ناقص = نتیجه غلط |
| `Filters.vue` | گزینه‌های فیلتر را از کل لیست مشتق می‌کند | گزینه‌ها ناقص می‌شوند |
| `FavoritesView` | علاقه‌مندی‌ها را با `find` در کل لیست پیدا می‌کند | علاقه‌مندیِ خارج از صفحه دیده نمی‌شود |
| `useOrderItemProduct` | عنوان محصولِ آیتم سفارش را از کل لیست می‌خواند | سفارش قدیمی بی‌عنوان می‌شود |
**نتیجه:** اگر فقط `take`/`skip` به مسیر اضافه شود، جستجو و فیلتر **بی‌صدا غلط**
می‌شوند (روی ۲۰ رکورد اول فیلتر می‌کنند) و این بدتر از کندی است. پس هر
مصرف‌کننده باید مسیر مخصوص خودش را بگیرد.
## ۳. طراحی پیشنهادی
### (الف) چهار مسیر، به‌جای یک مسیر
| مسیر | برای چه | چرا جدا |
|---|---|---|
| `GET /catalog/products?page=&limit=&search=&categoryId=&brandId=&minGram=&maxGram=&sort=` | لیست و جستجو | **فیلتر و صفحه‌بندی سمت سرور**؛ تنها راه درست بودن نتیجه |
| `GET /catalog/products/:id` | صفحه محصول | یک رکورد، نه ۱۶ هزار |
| `GET /catalog/facets` | گزینه‌های فیلتر (دسته‌ها، برندها، بازه وزن) | کوچک، `distinct`/`aggregate`؛ جایگزین مشتق‌کردن از کل لیست |
| `POST /catalog/products/by-ids` | سبد، علاقه‌مندی، آیتم‌های سفارش | چند شناسه مشخص در یک درخواست |
شکل پاسخ لیست: `{ rows, total, page, limit }`. **عمداً** از `TableService`
استفاده نمی‌کنم: آن برای قرارداد XTable پنل ادمین است و شکل پاسخش
(`{rows, query:{...}}`) برای اپ بی‌مورد پیچیده است.
### (ب) سازگاری عقب‌رو — مهم
مسیر فعلی **نباید بشکند**: پنل ادمین، اسکریپت‌های smoke و نسخه‌های نصب‌شده‌ی اپ
از آن استفاده می‌کنند.
- بدون `page`/`limit` ⇒ **رفتار امروز** (کل لیست). پس هیچ مصرف‌کننده‌ای نمی‌شکند.
- با `page`/`limit` ⇒ پاسخ صفحه‌بندی‌شده.
- در لاگ، درخواست بدون `page` روی کاتالوگ بزرگ **هشدار** می‌دهد تا بفهمیم چه
کسی هنوز قدیمی است.
### (ج) کش سرور — بازطراحی
کش فعلی **یک** خروجی را نگه می‌دارد (`value: {at, data}`). با پارامترها این
نادرست می‌شود: صفحهٔ ۲ جواب صفحهٔ ۱ را می‌گیرد.
پیشنهاد: کلید کش = رشته‌ی پارامترهای نرمال‌شده، با سقف تعداد کلید (مثلاً ۵۰،
LRU) تا حافظه بی‌نهایت رشد نکند. باطل‌سازی موجود (middleware پریزما) **همه** را
با هم پاک می‌کند — که درست است، چون هر نوشتنی می‌تواند هر صفحه‌ای را عوض کند.
⚠️ سقف کلید لازم است: بدون آن، هر ترکیب فیلترِ کاربران یک ردیف کش می‌سازد و
حافظه‌ی سرور به‌تدریج پر می‌شود.
### (د) ایندکس‌ها (مهاجرت)
```
Product: @@index([isActive, createdAt]) ← لیست و مرتب‌سازی پیش‌فرض
@@index([categoryId])
@@index([brandId])
ProductVariant: @@index([productId]) ← الان نیست؛ هر include یک اسکن
ProductImage: @@index([productId])
```
جستجوی متنی: `contains` فارسی روی ۱۶ هزار ردیف با ایندکس معمولی سریع نمی‌شود.
پیشنهاد **فعلی**: همان `contains` با ایندکس بالا (برای ۱۶ هزار قابل قبول است).
اگر کند بود، مرحله‌ی بعد `pg_trgm` است — ولی **اول اندازه‌گیری، بعد اضافه کردن**.
### (ه) سمت اپ
| کار | تغییر |
|---|---|
| `catalogStore` | از «یک آرایه‌ی کامل» به «صفحات + `total`»؛ `load()` صفحه می‌گیرد، `loadMore()` صفحه بعد |
| `SearchView` / `NewestView` | `IntersectionObserver` به‌جای زیاد کردن `visibleCount`، **درخواست صفحه بعد** می‌زند؛ فیلتر/جستجو با debounce به سرور می‌رود و از صفحه ۱ شروع می‌کند |
| `Filters.vue` | گزینه‌ها از `/catalog/facets` |
| `useProductDetail` | `GET /catalog/products/:id` |
| `cartStore.init` | `by-ids` با شناسه‌های سبد |
| `FavoritesView` | `by-ids` با شناسه‌های علاقه‌مندی |
| `useOrderItemProduct` | `by-ids`، یا بهتر: عنوان از خود `OrderItem` snapshot (اگر دارد) |
| `configStore` | دسته‌ها از `/catalog/facets` |
**اندازه صفحه:** همان **۲۰** که الان پنجره‌ی رندر است، تا تجربه عوض نشود.
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | وضعیت |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
### یادداشت مرحله ۳ (انجام‌شده)
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را
با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش
قبلی که ردیف را خالی می‌گذاشت.
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
صریحاً کاتالوگ را بار می‌کند.
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
## ۵. دو چیز که باید بدانی
**۱. مرجع صفحه‌بندی سرور داشت.** `ApiMobileController` برای لیست محصولات
صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار
**بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت
است.
**۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق
کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست»
که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
تنها جایی است که تست واحد کفایت نمی‌کند.
---
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر
بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری،
مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).
+291
View File
@@ -0,0 +1,291 @@
# تاریخچه‌ی کامل پورت — henza_admin → gold-banking-monorepo
> این سند خلاصه‌ی کامل و دقیق تمام کارهایی است که تا این لحظه روی پروژه انجام شده،
> از شروع پروژه تا آخرین commit. برای جزئیات ریزتر batch به batch به
> [`ADMIN-PORT-PLAN.md`](./ADMIN-PORT-PLAN.md) مراجعه کنید — آن سند مرجع فنی
> زنده است، این سند خلاصه‌ی روایی و تاریخی.
**تاریخ نگارش:** ۱۳ شهریور ۱۴۰۵ (۲۰۲۶-۰۹-۱۳)
---
## ۱. هدف پروژه
پورت کامل و بدون تغییر پنل ادمین legacy جواهرات/طلا (`henza_admin`، Vue 2 +
Vuesax + قالب Vuexy) به `apps/admin-panel` در مونوریپوی
`gold-banking-monorepo` (Vue 3 + Vite + Pinia + ant-design-vue)، همراه با
ساخت بک‌اند NestJS متناظر برای هر بخش.
### قوانین صریح کارفرما (که در تمام مسیر رعایت شدند)
- «همه رو با دقت و بدون دست خوردن میخوام» — پورت وفادار، بدون تغییر منطق
- باگ‌های legacy **نباید ساکت اصلاح شوند** — باید عیناً پورت و **جداگانه گزارش**
شوند
- حفظ دقیق قرارداد اعداد فارسی/لاتین و رفتار RTL همان‌طور که در legacy بود
- **`henza_server/` و `henza_pwa/` (و بعداً `henza_admin/`) فقط خواندنی —
هرگز تغییر داده نشوند**
- اول پلن، بعد اجرا batch به batch
- پشتیبانی از هر دو حالت ادمین و شعبه (`isBranch()`)
- ACL «دقیقا پایبند به ساختار» — وفاداری کامل به مدل دانه‌ریز
`permission(rid, ut)` در legacy
- لاگین واقعی بک‌اند، نه mock
- حذف ~۳۹۴ فایل دمو الگوی Vuexy
- «بک‌اند همراه هر بخش» — هر batch با endpoint های خودش می‌آید
- «REST تمیز + لایه‌ی نگاشت» در فرانت‌اند
---
## ۲. مراحل اصلی پروژه (Phase 0 تا ممیزی نهایی)
### Phase 0-2: زیرساخت
- راه‌اندازی مونوریپو با pnpm workspaces + Turborepo
- طراحی schema اولیه‌ی Prisma (اکنون **۳۵ مدل**)
- ساخت لایه‌ی auth واقعی: `@nestjs/jwt@12` + `@nestjs/passport@10.0.3`
(pin‌شده برای سازگاری با NestJS 10) + bcryptjs
- تبدیل مکانیکی روترهای legacy (`router/admin.js` + `router/branch.js`) به
TypeScript، با حفظ ۱۰۰٪ مسیرها، نام‌ها، `meta.role` و breadcrumb ها
- تبدیل مکانیکی `permissionConst.ts` — تمام ۱۸۱ برچسب فارسی بایت‌به‌بایت
یکسان (با diff تأیید شد)
### Batch های ۳ تا ۱۵: پورت view به view
پورت کامل **۶۹ view** به همراه بک‌اند متناظر هر batch. جزئیات کامل هر batch
(فایل‌ها، تصمیم‌ها، مشکلات) در `ADMIN-PORT-PLAN.md` ثبت شده.
### تصمیم‌های کلیدی کارفرما در طول مسیر
1. **کامپوننت مشترک به‌جای کپی فایل‌به‌فایل** برای ۵ لیست عمده‌ی تقریباً
یکسان و ۴ fork از XTable
2. **توکن‌های طراحی از خودِ legacy ادمین** (`secondary #836447`، فونت
`p-yek`) — نه هماهنگ با اپلیکیشن کاربر که قبلاً ساخته شده بود
### ممیزی نهایی (Phase 16)
پس از تکمیل ۶۹/۶۹ view، یک ممیزی جامع اجرا شد که یافته‌های معماری مهمی را
آشکار کرد:
- «فاکتور» در legacy در واقع تخصیص موجودی شعبه است، نه فاکتور فروش
- ردیف‌های «صندوق» در legacy همان `LedgerEntry` هستند
- هر دو داشبورد عمدتاً کامنت‌شده‌ی دمو Vuexy بودند
- ApexCharts import شده ولی هرگز در markup زنده استفاده نشده
- **۳۶ باگ/کوییرک legacy** ثبت و مستند شد
- **۶ رفتار مخرب داده‌ی legacy عمداً پورت نشدند** (حذف سند تاییدشده، ویرایش
مبلغ تاییدشده، حذف دسته‌بندی دارای وابسته، ایجاد چرخه در دسته‌بندی، یتیم‌کردن
ردیابی ممیزی) — هرکدام با پیام ۴۰۰ فارسی جایگزین شد
---
## ۳. بستن ۷ شکاف schema (جلسه‌ی اخیر)
پس از ممیزی نهایی، ۷ شکاف باقی‌مانده در schema شناسایی و به ترتیب بسته شد.
برای هر شکاف، منطق مستقیماً از **سرور legacy** (`henza_server`) خوانده شد،
نه فقط از view ها — این روش یک فرض غلط قبلی را هم آشکار کرد.
| # | شکاف | راه‌حل |
|---|---|---|
| ۱ | تفکیک بنکدار/مغازه‌دار | enum `CustomerKind` با **۵** مقدار (`Customer::$TYPES` سرور) |
| ۲ | تفکیک سفارش تک/عمده | enum `OrderChannel` (`RETAIL` \| `WHOLESALE`) |
| ۳ | ستون بایگانی مجزا | `Customer.isArchived` |
| ۴ | دو سوییچ مجزای مشتری | `Customer.canPlaceOrders` جدا از `isActive` |
| ۵ | ستون‌های تجاری مشتری | `phone`, `address`, `storeName`, `pasazhName`, `zone`, `memberCode` |
| ۶ | endpoint آپلود فایل | مدل `UploadedFile` + `POST admin/files/create` + سرو استاتیک `/uploads/` |
| ۷ | آواتار کارشناس فروش | `Customer.avatarFileId` (رابطه‌ی نام‌دار `CustomerAvatar` به `UploadedFile`) |
### یافته‌های مهم از خواندن مستقیم سرور legacy
**منبع:** `AdminController::getBanker/getShopkeeper/getCustomer` و
`MajorCustomerController::indexArchive/indexVerify` در `henza_server`.
- `banker` (بنکدار) → `type=4` · `shopkeeper` (حضوری) → `type=3` **فقط** ·
لیست اصلی عمده (اپلیکیشن) → `type=2`
- **اصلاح یک فرض قبلی:** پیش‌تر نوشته شده بود که لیست اصلی عمده «بدون فیلتر»
است. این درست نبود؛ خوانده و تصحیح شد.
- هر سه لیست نوع، فیلتر `is_archive=0` هم دارند — یعنی بایگانی‌کردن یک مشتری
او را از **همه‌ی** لیست‌های نوع حذف می‌کند، نه فقط از یکی.
- `Customer::$TYPES` سرور یک مقدار پنجم دارد (`۵ سرپرست کارگاه`) که فرم
`bulkCustomer.vue` هرگز نشان نمی‌داد. enum جدید هر پنج مقدار را دارد، اما
فرم پورت‌شده عمداً همان چهارتای legacy را نشان می‌دهد (برای وفاداری کامل
به رفتار دیده‌شده در UI).
- سفارش تک و عمده در legacy در **دو جدول کاملاً جدا** بودند (`orders` و
`orders_orders`)؛ هیچ‌کدام از دو کنترلر فیلتر نوع نداشتند چون **خود جدول**
تفکیک‌کننده بود، پس دو لیست هرگز هم‌پوشانی نداشتند. `Order.channel` همان
نقش را در یک مدل واحد بازی می‌کند.
- آواتار در legacy یک **FK** بود (`image_id`) نه یک URL ساده، و در پاسخ به‌شکل
تودرتوی `image: { id, image }` برمی‌گشت — هر دو نیمه‌ی این قرارداد عیناً
حفظ شد.
### دو رفتار legacy که عمداً پورت **نشدند**
۱. **آپلود بدون احراز هویت.** در legacy، `file/create` هیچ middleware نداشت
و گروه `auth:api` هم در `routes/admin.php:12` کامنت بود — یعنی آپلود
ناشناس می‌پذیرفت. این یک باگ امنیتی بود، نه قابلیت؛ در پورت، ادمینِ
واردشده الزامی است. rid اختصاصی هم ساخته نشد چون واژگان ACL موجود در
legacy ورودی media/file ندارد.
۲. **ارسال پیامک درون `toggleActive`.** legacy هنگام فعال‌سازی مستقیم SMS
می‌فرستاد. این به ماژول اعلان‌ها موکول شد (درگاه پیامک هنوز واقعاً وصل
نیست، طبق تصمیم قبلی).
۳. **باگ در بارگذاری آواتار.** مسیر لود legacy در
`ReagentCustomer.vue:280` بدون محافظ به `data.image.id` دست می‌زد، پس باز
کردن یک کارشناس فروش بدون عکس throw می‌کرد (که خودِ `catch` می‌بلعید و
فرم نیمه‌پر می‌ماند). در پورت این خواندن guard شده است.
### تأیید اجرایی این بخش
```
لیست‌ها: عمده(t2)=۱ · بنکدار(t4)=۲ · حضوری(t3)=۲ · بایگانی=۱ · تاییدنشده=۲
سوییچ‌ها: toggle-sts فقط sts را جابه‌جا کرد؛ toggle-active فقط active
بایگانی: ردیف از لیست نوع خارج و به بایگانی رفت، بدون دست‌خوردن active
سفارش: خرده‌فروشی=۳ · عمده=۱
آپلود: ۲۰۰ + فایل از URL خودش بایت‌به‌بایت برگشت؛ mime نامعتبر/حجم
زیاد/بدون فایل → ۴۰۰ فارسی؛ درخواست ناشناس → ۴۰۱
آواتار: آپلود → ساخت با image_id → خواندن image:{id,image} → پیش‌نمایش
۲۰۰ → حذف → هر دو null؛ مشتری بدون آواتار → null (نه خطا)
ممیزی: ۵۹/۵۹ endpoint خواندنی = ۲۰۰ · ACL ۱۴۴/۱۴۴ رید · ۱۵۴ مسیر ادمین
بیلد: api ✓ · admin ✓ · branch ✓ · هر دو typecheck ✓
```
تست‌های نوشتن فقط روی مشتری‌های تستی‌ای انجام شد که در همان جلسه ساخته و
بعد حذف شدند — داده‌ی seed اصلی هرگز دست‌خورد نکرد.
---
## ۴. لاگین تستی حالت توسعه (جلسه‌ی جاری)
طبق درخواست صریح، شماره‌ی **۰۹۱۲۸۰۸۱۳۹۲** برای ورود آزمایشی در **هر دو**
اپلیکیشن مجاز شد — فقط برای محیط توسعه:
- **پنل ادمین:** یک `AdminUser` با نقش super admin (`userTypeId: 85`) ساخته
شد؛ رمز عبور دقیقاً برابر خودِ شماره تلفن است.
- **اپلیکیشن کاربر (user-app):** همان شماره به‌عنوان یک `Customer` تایید‌شده
(`VERIFIED`) اضافه شد. جریان OTP در `LoginView.vue` از قبل stub بود
(هر کد ۴ رقمی برای یک مشتریِ واقعیِ منطبق پذیرفته می‌شود)، پس این مشتری
به‌محض ورود شماره در گام اول شناسایی و قابل ورود است.
- هر دو ردیف داخل خودِ `prisma/seed.ts` با کامنت **DEV-ONLY** مشخص شده‌اند
و هرگز در استقرار واقعی اجرا نمی‌شوند — این فایل فقط با اجرای دستیِ
`npx prisma db seed` روی دیتابیس محلی اجرا می‌شود.
**✅ فعال و تأییدشده.** `npx prisma db seed` اجرا شد و هر دو ردیف در
دیتابیس محلی ساخته شدند. لاگین به‌صورت زنده تست شد:
```
POST /api/admin/auth/login { phone: '09128081392', password: '09128081392' }
→ 200، super admin، ۱۴۴ rid، JWT صادر شد
GET /api/customers
→ مشتری «کاربر تست توسعه» با mobile=09128081392 در لیست موجود و قابل
شناسایی توسط گام اول OTP در LoginView.vue
```
---
## ۵. نقد پورت و رفع مشکلات پنل ادمین
کارفرما گزارش داد که پنل ادمین «هم دیزاین هم کارکرد» مشکل دارد و خواست که
**خودم پورت خودم را نقد کنم**. دو ممیزی مستقل (ظاهر + کارکرد) اجرا شد و
یافته‌های کلیدی‌شان را با خواندن مستقیم فایل‌ها تأیید کردم.
### سه اشتباه اصلی که مرتکب شده بودم
**۱. فرض کردم کلاس‌های Tailwind کار می‌کنند.** legacy یک بیلد کامل Tailwind
داشت (`tailwind.config.js`، ~۲۸KB) با مقیاس سفارشی `mb-base: 2.2rem`. من
`tailwindcss` را در `apps/admin-panel` نصب نکردم و `postcss.config` هم
نگذاشتم، ولی ~۱۸۵ کلاس Tailwind در قالب‌ها باقی ماند. بعد به‌جای رفع ریشه‌ای،
همان چند کلاس را **۱۲۴ بار در `<style scoped>` فایل‌ها کپی کردم** — و در این
کپی‌کاری `mb-base` را `1.5rem` نوشتم (۳۲٪ تنگ‌تر از legacy).
**۲. مرز CSS scoped را نادیده گرفتم.** ۱۷ مورد `icon-class="w-5 h-5 text-primary"`
به `TooltipBtn` پاس داده می‌شد، ولی استایل scoped والد هرگز به فرزند نمی‌رسد و
هیچ `:deep()` در پروژه نبود. `h-5` هم اصلاً هیچ‌جا تعریف نشده بود. یعنی آن
۱۷ آیکون **بی‌رنگ و بی‌اندازه** رندر می‌شدند.
**۳. لایه‌ی نگاشت را ناقص گذاشتم.** ۱۸ مسیر فرانت به بک‌اند نمی‌رسید — در حالی
که **مسیر بک‌اند هر ۱۸ مورد از قبل وجود داشت**. فقط ورودی مپ را ننوشته بودم.
### بدترین مورد: تخریب داده
در `bulkCustomer.vue` (و `ReagentCustomer.vue`) هنگام ویرایش، `Object.assign`
فقط ۹ فیلد را برمی‌گرداند و `store_name`/`pasazh_name`/`mcode`/`phone` را جا
می‌انداخت؛ آن‌ها `''` می‌ماندند، فرم کل `...form` را spread می‌کرد، و بک‌اند
`if (data.x !== undefined)` دارد — پس **هر بار ذخیره‌ی یک مشتری موجود، این
چهار ستون را پاک می‌کرد**.
### آنچه انجام شد
| مرحله | کار |
|---|---|
| ۱ | رفع تخریب داده: پرکردن کامل فرم روی ویرایش + ارسال **تفاضلی** (فقط فیلدهای تغییرکرده) در هر دو فرم |
| ۲ | ۹ الگو + ۲ ورودی دقیق به `endpoints.ts` → هر ۱۸ endpoint مرده زنده شد |
| ۳ | `assets/styles/utilities.less` سراسری با **مقادیر legacy**؛ حذف جراحی ۱۲۴ کپی تکراری از ۳۹ فایل |
| ۴ | `<a-config-provider :locale="faIR" direction="rtl">` در `App.vue` |
| ۵ | `reagent_id` و `password` (با bcrypt) در بک‌اند ذخیره می‌شوند |
| ۶ | دکمه‌ی «افزودن کاربر»، رفع تایپوی `color-success`، صادق‌سازی `mainLayoutType` |
### نکات فنی مهم
- **گریدها عمداً محلی ماندند.** مقادیر `minmax` در `.grid-2/3/4` بین فایل‌ها
فرق دارد (۲۰۰ تا ۳۲۰ پیکسل)؛ یکسان‌سازی‌شان چیدمان ~۲۶ فرم را بی‌صدا عوض
می‌کرد. فقط قواعدی حذف شدند که بدنه‌شان **دقیقاً** با نسخه‌ی سراسری یکی بود.
- **سه قاعده RTL-flipped هستند و همان‌طور ماندند:** `.ml-1`/`.ml-3` در پورت
`margin-right` می‌دهند و `.mr-4` می‌دهد `margin-left` — چون پنل RTL است.
در فایل سراسری هم عیناً حفظ شد و کامنت‌گذاری شد که «اصلاح» نشود.
- **`percent` عمداً دست‌نخورده ماند.** `Customer` اصلاً ستون `percent` ندارد؛
این عدد از `AdminUser.wagePercent` کارشناس معرف می‌آید. یعنی ویرایشش از فرم
مشتری، کمیسیون آن کارشناس را برای **همه‌ی** مشتریانش عوض می‌کرد. نیاز به
تصمیم شما دارد.
- **چیدمان افقی حالت شعبه:** `themeConfig` مقدار `horizontal` می‌داد ولی
`Main.vue` همیشه منوی عمودی رندر می‌کرد. کامپوننت‌های `HorizontalNavMenu`
(۴ فایل) هرگز پورت نشده‌اند، پس به‌جای ساختن بی‌سروصدای آن‌ها، نوع به
`'vertical'` محدود شد تا کد ادعای دروغ نکند. پورت چیدمان افقی کار جداست.
### تأیید اجرایی
```
داده‌رفتگی: ویرایش فقط-نام → ۰ فیلد از دست رفت (قبلاً هر ۴ پاک می‌شد)
Stage 5: reagent ذخیره شد · password یک hash واقعی bcrypt شد
endpointها: هر ۱۰ شکل بازیابی‌شده → ۲۰۰ (قبلاً ۴۰۴)
typecheck: admin ✓ · api ✓
بیلد: admin ✓ · branch ✓
```
> تست‌های نوشتن فقط روی مشتری‌ای انجام شد که خودم ساختم و بعد حذف کردم.
---
## ۶. ابعاد نهایی کد (تا این لحظه)
| معیار | مقدار |
|---|---|
| فایل‌های `.vue` پورت‌شده (views) | ۸۰ |
| ماژول‌های بک‌اند NestJS | ۲۵ |
| مدل‌های Prisma | ۳۵ |
| ورودی‌های دقیق لایه‌ی نگاشت (endpoints.ts) | ۷۷ + ۲۶ الگو |
| باگ/کوییرک legacy ثبت‌شده | ۳۶ |
| مسیرهای ادمین نگاشت‌شده | ۱۵۴ |
| پوشش ACL (rid) | ۱۴۴ / ۱۴۴ |
| کاهش حجم با کامپوننت مشترک (۵ لیست عمده + ۲ جزئیات مشتری + ۲ فرم تخفیف) | ۴٬۶۷۶ خط → ۱٬۱۳۹ خط |
---
## ۷. آنچه این تاریخچه **نمی‌تواند** تأیید کند
⚠️ **ظاهر رندرشده در مرورگر — مهم‌تر از همیشه.** هیچ view ای چشمی دیده نشده؛
همه‌ی تأییدها API و بیلد و typecheck بوده. دو تغییر اخیر بیشترین ریسک بصری را
دارند و **حتماً** باید چشمی دیده شوند:
- **`direction="rtl"` روی ConfigProvider.** `ant.less` دستی برای ant **۱.x**
نوشته شده؛ حالا ant ۳ هم RTL خودش را اعمال می‌کند. احتمال تداخل یا
دوبار‌اعمال‌شدن هست و بیلد آن را نمی‌گیرد.
- **`mb-base` از `1.5rem` به `2.2rem` رفت** (مقدار واقعی legacy). فاصله‌ی
بین کارت‌ها در ۱۳ view عمداً بازتر می‌شود — این تغییر خواسته‌شده است، نه باگ.
به‌علاوه آواتار، اسلایدر، و فرم دسته‌بندی که uploader واقعی گرفتند.
⚠️ **مسیرهای نوشتن با نقش‌های کم‌دسترسی.** دود‌تست فقط روی مسیرهای خواندنی
انجام شد تا داده خراب نشود؛ مسیرهای نوشتن در همان batch خودشان تست شدند اما
نه به‌صورت ماتریسی روی همه‌ی نقش‌ها.
⚠️ **درگاه پیامک واقعی وصل نیست** — پیام ثبت می‌شود اما ارسال نمی‌شود.
---
## مرجع
برای جزئیات فنی کامل هر batch — شامل کد دقیق تغییرات، دلایل هر تصمیم
معماری، و متن کامل دیالوگ تصمیم‌گیری — به
[`docs/ADMIN-PORT-PLAN.md`](./ADMIN-PORT-PLAN.md) مراجعه کنید.
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
# طرح — بازگرداندن «ورود کارشناس به حساب مشتری» (act-as)
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12)
## وضعیت: ✅ **انجام شد** — تصمیم مالک ۲۰۲۶/۱۰/۱۲
> «نه دقیقا همون چیزی که بود در ساختار مرجع همون رو بیار ولی با ساختار درستی که
> خودت میدونی»
یعنی: **دامنه کامل مرجع**، با سازوکار امنِ بند ۳. پس **بند ۴ (مرزها) حذف شد** —
هیچ مسیری در حالت act-as بسته نیست، آپلود مدارک و logout هم باز است، چون مرجع
هم interceptor سراسری داشت و همه‌چیز از طرف مشتری می‌رفت.
بقیه طرح عیناً اجرا شد. سه چیز که در طرح نبود و در حین کار پیدا شد (هر سه رفع
شد): پاک‌سازی `queryCache`/علاقه‌مندی/اجرت کش‌شده در جابجایی، و هدر در مسیر
آپلود که `request()` را دور می‌زند. جزئیات: `ISSUES.md` EXP-01/02/03 و D29.
---
### متن اصلی طرح (برای سابقه)
> **خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و
> از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت
> اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید… همان قابلیت هارو میخوام،
> حالا هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره.»
**همان قابلیت، بدون سه عیبِ روش قدیم.** تصمیم‌های فنی زیر با من است؛ هرجا چیزی
از مرجع **کم** شده باشد، صریح نوشته‌ام.
---
## ۱. مرجع چه می‌کرد (بررسی‌شده، نه حدس)
| مرحله | کد مرجع |
|---|---|
| لیست مشتریان | `POST /my-child` → `ApiMobileController.php:185-196`، فیلتر `reagent_id = auth()->id()` |
| **توکن مشتری ساخته می‌شد** | `:197-201` — اگر `api_token` مشتری خالی بود، `Str::random(60)` می‌ساخت و **ذخیره** می‌کرد |
| کلیک روی کارت | `customerChoice.vue:155` → `changeUser()` → `myStore.toggleActiveUser(user)` |
| ذخیره توکن | `myDataStore.ts:163` → `localStorage.setItem('activeChildToken', value.api_token)` |
| اعمال روی همه درخواست‌ها | `tokenManage.ts:8` `getUserToken()` = توکن مشتری **یا** توکن خودش → `plugins/axios.ts:18` روی **هر** درخواست |
| خروج از حساب مشتری | `Dashboard.vue:784` → پاک کردن `activeChildToken` |
یعنی دامنه مرجع: **هر کاری که مشتری می‌توانست**، چون interceptor سراسری بود.
## ۲. سه عیبی که تکرار نمی‌کنم
۱. **توکن دائمی مشتری در دست کارشناس.** `api_token` انقضا نداشت و در
`localStorage` می‌ماند. کارشناسی که فردا قطع همکاری می‌کرد، توکن مشتریانش را
داشت و همچنان کار می‌کرد.
۲. **هیچ ردی نمی‌ماند.** درخواست عیناً از طرف مشتری می‌آمد، پس در سرور معلوم
نبود این سفارش را مشتری ثبت کرده یا کارشناس. برای مالی، این یعنی غیرقابل
حسابرسی.
۳. **بررسی مالکیت فقط یک‌بار.** توکن در لحظه گرفتن صادر می‌شد؛ بعد از آن اگر
مشتری به کارشناس دیگری منتقل می‌شد، توکن قدیمی همچنان کار می‌کرد.
## ۳. طراحی پیشنهادی
**هسته: توکنِ مشتری صادر نمی‌شود. کارشناس با توکن خودش درخواست می‌دهد و
می‌گوید «از طرف مشتری X».**
### چرا این انتخاب (و نه گزینه‌های دیگر)
`CustomerJwtStrategy.validate()` در تارگت همیشه یک `AuthenticatedCustomer`
می‌سازد که `id` آن **یک Customer id** است، و **هر ۱۷ مسیر مشتری** از همان
استفاده می‌کنند. پس اگر جابجایی را همان‌جا اعمال کنم، همه آن مسیرها **بدون یک خط
تغییر** از دید مشتری کار می‌کنند — همان «همه‌چیز را از دید او می‌بیند» که خواستی،
با کمترین سطح تماس با کد.
| گزینه | نتیجه | رأی |
|---|---|---|
| صدور توکن مشتری (روش مرجع) | ساده، ولی هر سه عیب بالا | ❌ |
| پارامتر `customerId` روی هر مسیر | باید ۱۷ مسیر و همه سرویس‌ها عوض شوند؛ فراموش کردن یکی = حفره | ❌ |
| **هدر `X-Act-As-Customer` + اعمال در strategy** | یک نقطه تغییر، هر ۱۷ مسیر خودکار، قابل حسابرسی | ✅ |
### سمت سرور
**۱. هدر درخواست.** کارشناس توکن خودش را می‌فرستد به‌علاوه
`X-Act-As-Customer: <customerId>`.
**۲. اعمال در `validateExpert`** (تنها نقطه تغییر منطق دسترسی):
```
اگر هدر بود و principal کارشناس بود:
مشتری را بخوان: id = هدر، referredByAgentId = admin.id، isActive، !isArchived
اگر نبود → 403 (مشتری دیگری، یا مشتریِ کارشناس دیگر)
اگر بود → { id: customer.id, ← از دید مشتری
actingAsCustomerId: customer.id,
adminUserId: admin.id, ← هویت واقعی حفظ می‌شود
type: 'sales-expert' }
```
نکات مهم این طراحی:
- **بررسی مالکیت در هر درخواست** انجام می‌شود، نه یک‌بار. مشتری که به کارشناس
دیگری منتقل شود، دسترسی **در همان لحظه** قطع می‌شود. (عیب ۳ رفع شد.)
- کارشناس غیرفعال/تنزل‌یافته همان‌جا ۴۰۱ می‌گیرد، چون `validateExpert` از قبل
`isActive` و `role` را هر بار می‌خواند. (عیب ۱ رفع شد.)
- `adminUserId` حفظ می‌شود ⇒ هویت واقعی هرگز گم نمی‌شود. (عیب ۲ رفع شد.)
- توکن **عوض نمی‌شود**؛ پس چیزی در `localStorage` نمی‌نشیند که بعداً سوءاستفاده
شود. بستن اپ = پایان حالت act-as.
**۳. هزینه پرفورمنس (خواسته‌ات).** یک `findUnique` اضافه روی `Customer` با
`where: { id }` — کلید اصلی، پس index-only. **فقط** وقتی هدر وجود دارد؛ یعنی
درخواست‌های مشتری عادی و کارشناس در حالت معمولی **صفر هزینه اضافه** دارند. در
حالت act-as هم جایگزین کوئریِ `tradingCustomer` می‌شود، نه اضافه بر آن — پس
تعداد کوئری ثابت می‌ماند.
**۴. ⛔ این بند اجرا نشد.** مالک دامنه کامل مرجع را خواست، پس هیچ‌کدام از این
مرزها گذاشته نشد — همه مسیرها در حالت act-as باز است:
| مسیر | در حالت act-as | چرا |
|---|---|---|
| `POST /orders` | ✅ مجاز | خواسته اصلی |
| مشاهده: `me`، `me/orders`، `me/ledger-entries`، `me/report*`، `accounting-balance`، `wage-quote` | ✅ مجاز | «وضعیت آن‌ها را ببیند» |
| علاقه‌مندی‌ها | ✅ مجاز | بی‌خطر، و بخشی از «از دید او» |
| `POST /customer/me/documents` و `complete-profile` | ❌ **۴۰۳** | آپلود مدارک هویتی به‌جای مشتری. کارشناس مدرکِ مشتری را ندارد و این کار جعل هویت است، نه خدمت. در پنل ادمین انجام می‌شود. |
| `POST /auth/logout` | ❌ **۴۰۳** | نشست مشتری را نباید کارشناس ببندد |
| مسیرهای `expert/*` | ❌ **۴۰۳** | در حالت act-as بی‌معناست؛ از ابهام جلوگیری می‌کند |
**۵. ثبت در سفارش.** `OrdersService.createOrder` از قبل `placedBy` دارد. سفارشِ
act-as با `placedBy: 'operator'` ثبت می‌شود — یعنی قیمت را سرور تحمیل نمی‌کند و
عدد کارشناس می‌ماند؟ **نه**: برای این حالت `placedBy: 'customer'` می‌گذارم تا
قیمت‌گذاری سرور (MON-08) اعمال شود، چون این سفارشِ مشتری است نه اپراتورِ پنل.
علاوه بر آن `salesAgentId` از قبل خودکار از `customer.referredByAgentId` پر
می‌شود — که همان کارشناس است. **ردّ حسابرسی** هم در لاگ ثبت می‌شود:
`expert <adminUserId> acting as customer <id>`.
**۶. پیامک.** مشتری پیامک «سفارش شما ثبت شد» را **می‌گیرد** (مثل مرجع). دلیل:
روی حساب و بدهی طلای او نشسته و باید بداند. کارشناس هم پیامک ارجاع‌دهنده را
می‌گیرد، چون `referredByAgentId` خودش است — رفتار موجود، تغییری لازم نیست.
### سمت اپ (هر دو اپ)
**۱. وضعیت act-as** در `sessionStore` (نه `localStorage`): `actingAs = {id, name}`.
چون در `sessionStorage`/حافظه است، بستن تب پایانش می‌دهد — برعکس مرجع.
**۲. کلاینت API**: اگر `actingAs` ست بود، هدر `X-Act-As-Customer` به همه
درخواست‌ها اضافه شود (یک نقطه، در `request()` کلاینت).
**۳. `CustomerChoiceView`**: زدن روی کارت ⇒ `changeUser` ⇒ ست کردن `actingAs` و
رفتن به خانه، **عیناً مثل مرجع** (`customerChoice.vue:155-167`).
**۴. ⚠️ نوار هشدار همیشگی** — چیزی که مرجع نداشت و از نظر من ضروری است: یک نوار
ثابت بالای صفحه «در حال مشاهده به‌عنوان **فلانی** — خروج». بدون این، کارشناس
نمی‌فهمد در حساب کیست و ممکن است سفارش را روی حساب اشتباهی ثبت کند. این ریسک
واقعیِ روش مرجع بود.
**۵. پاک کردن سبد در جابجایی.** سبد در `localStorage` است؛ اگر پاک نشود، کالاهای
سبدِ کارشناس وارد سفارش مشتری می‌شود. در جابجایی (ورود و خروج) سبد خالی می‌شود.
## ۴. تست‌ها
| تست | می‌سنجد |
|---|---|
| کارشناس + هدر مشتریِ خودش | داده‌های آن مشتری برمی‌گردد |
| کارشناس + هدر **مشتریِ کارشناس دیگر** | ۴۰۳ |
| کارشناس + هدر مشتری غیرفعال/آرشیو | ۴۰۳ |
| **مشتری عادی** + هدر | هدر **نادیده** گرفته می‌شود (نه ۴۰۳ خاموش؛ اصلاً اثر ندارد) |
| کارشناس غیرفعال‌شده + هدر معتبر | ۴۰۱ فوری |
| سفارش در حالت act-as | `customerId` = مشتری، `salesAgentId` = کارشناس، قیمت از سرور |
| مسیرهای ممنوع (مدارک، logout) | ۴۰۳ |
| بدون هدر | رفتار امروز، بدون تغییر (regression) |
## ۵. چه چیزی عمداً مثل مرجع نیست
| مرجع | اینجا | دلیل |
|---|---|---|
| توکن دائمی مشتری به کارشناس داده می‌شد | هیچ توکنی صادر نمی‌شود | سرقت نشست؛ بعد از قطع همکاری هم کار می‌کرد |
| `api_token` در `localStorage` | وضعیت در حافظه نشست | بستن اپ = پایان |
| مالکیت یک‌بار بررسی می‌شد | هر درخواست | انتقال مشتری فوراً اثر می‌کند |
| ~~کارشناس همه‌کاره بود~~ | ~~مدارک/logout ممنوع~~ | **اجرا نشد** — مالک دامنه کامل مرجع را خواست؛ کارشناس همه‌کاره است |
| هیچ ردی نبود | لاگ + `salesAgentId` | حسابرسی مالی |
| هیچ نشانه‌ای در UI نبود | نوار همیشگی | سفارش روی حساب اشتباه |
## ۶. برآورد
| بخش | کار |
|---|---|
| سرور | `app-principal.ts` (+`actingAsCustomerId`)، `customer-jwt.strategy.ts` (منطق)، یک گارد برای مسیرهای ممنوع، لاگ در `orders.service` |
| اپ‌ها (×۲) | `sessionStore`، `api/client.ts` (هدر)، `CustomerChoiceView` (changeUser)، نوار act-as، پاک کردن سبد |
| تست | ۸ مورد بالا در `apps/api` |
ترتیب اجرا: سرور + تست‌ها اول (قابل اثبات)، بعد اپ‌ها.
---
**منتظر تأیید.** اگر با مرزهای بند ۴ (ممنوع بودن آپلود مدارک و logout) موافق
نیستی بگو — بقیه طرح تصمیم فنی است و خودم جلو می‌برم.
+61
View File
@@ -0,0 +1,61 @@
# بررسی راه‌اندازی سرور با دیتابیس خالی
> وضعیت: **بررسی فقط خواندنی** انجام شد. هیچ کدی تغییر نکرده و هیچ داده‌ای نوشته یا منتقل نشده است.
> تاریخ: ۲۰۲۶-۱۰-۱۲
## هدف
روی سرور تولید فقط **یک** کاربر super admin باشد و هیچ داده‌ی دیگری (مشتری، محصول، سفارش، بدهی، نقش اضافه، حساب تست) وارد نشود. دیتابیس henza قدیمی در این مرحله منتقل نمی‌شود.
## یافته‌ها
### ۱. حساب ثابت در کد (مهم‌ترین مورد)
- در [auth.service.ts:21](../apps/api/src/modules/auth/auth.service.ts#L21) شماره `09128081392` ثابت شده و در [خط ۴۹–۵۳](../apps/api/src/modules/auth/auth.service.ts#L49) اگر شماره همین باشد و رمز هم برابر خود شماره باشد، `ensureStaticAdmin` اجرا می‌شود.
- [خط ۲۳۰–۲۴۵](../apps/api/src/modules/auth/auth.service.ts#L230) یک `SUPER_ADMIN` با نام «مدیر موقت» می‌سازد یا فعال می‌کند.
- این شرط محیطی ندارد، پس روی سرور تولید هم فعال است.
- همین شماره در `seed.ts` به‌عنوان «کاربر تست توسعه» هم آمده است.
### ۲. داده‌ی demo در seed
- [seed.ts](../apps/api/prisma/seed.ts) بخش demo را فقط وقتی اجرا می‌کند که `NODE_ENV` برابر `production` نباشد یا `SEED_DEMO_DATA=true` باشد.
- بخش demo شامل شهرها، شعبه‌ها، کارگاه، ادمین‌های تست، مشتری، محصول، سفارش و بدهی است.
- [bootstrap.ts](../apps/api/src/prisma/bootstrap.ts) در اولین boot، اگر `RUN_SEED=false` نباشد، seed را اجرا می‌کند.
### ۳. نقش‌ها و permissionها
- `seed.ts` همیشه کاربرتایپ‌های ۸۵ تا ۸۹ و permissionهایشان را می‌سازد.
- گاردها فقط بر اساس rid کار می‌کنند و برای `SUPER_ADMIN` بای‌پس ندارند ([guards.ts:17](../apps/api/src/modules/auth/guards.ts#L17)). پس نقش ۸۵ با تمام دسترسی‌ها **لازم** است و نقش‌های ۸۶ تا ۸۹ **لازم نیستند**.
### ۴. migrationهای تغییردهنده‌ی داده
روی دیتابیس خالی بدون اثر هستند (بر اساس خواندن SQL، هنوز روی دیتابیس اجرا نشده‌اند):
| migration | کار | روی دیتابیس خالی |
|---|---|---|
| `20260922120000_customer_name_parts` | ستون و trigger نام | فقط ساختار |
| `20260922180000_sales_expert_profile_and_admin_name_parts` | `INSERT` از `AdminUser` | no-op |
| `20261005120000_accounting_integration` | permission فقط اگر نقش ۸۵ باشد | no-op |
| `20261008090000_customer_identity_cleanup` | `UPDATE` روی `Customer` و `AdminUser` | no-op |
| `20261008120000_order_sales_agent` | `UPDATE` روی `Order` | no-op |
| `20261008150000_wage_formula_permission` | permission فقط اگر نقش ۸۵ باشد | no-op |
| `20261008160000_dashboard_permission_merge` | `INSERT` و `DELETE` روی permission | no-op |
| `20261008170000_city_tree` | ساخت استان از ردیف‌های موجود | no-op |
### ۵. شماره‌های ثابت در سرویس حسابداری
`09121234567` و `09891212345` فقط مثال در docstring و تابع نرمال‌سازی هستند ([accounting-provider.ts:272](../apps/api/src/modules/accounting-sync/providers/accounting-provider.ts#L272)، [tahesab.provider.ts:224](../apps/api/src/modules/accounting-sync/providers/tahesab.provider.ts#L224)). داده نیستند.
## کارهای پیشنهادی (هنوز انجام نشده)
1. seed فقط نقش ۸۵ با `ALL_RIDS` بسازد؛ نقش‌های ۸۶ تا ۸۹ حذف شوند.
2. حساب ثابت `09128081392` و `ensureStaticAdmin` از `auth.service.ts` حذف شود.
3. روی سرور: `NODE_ENV=production` و `RUN_SEED=false`، و `SEED_DEMO_DATA` ست نشود.
4. یک اسکریپت یک‌بار اجرا برای ساخت super admin: شماره و نام و رمز را کاربر وارد می‌کند؛ داخل کد یا seed ذخیره نمی‌شود.
5. روی یک دیتابیس خالی تست شود: migrationها از صفر اجرا شوند و فقط یک کاربر و نقش ۸۵ باقی بماند.
## تصمیم‌های باز
- شماره و نام super admin واقعی.
- نقش‌های ۸۶ تا ۸۹ حذف شوند یا بمانند؟
- حذف حساب ثابت از کد تأیید شود.
## بررسی‌نشده
- سطر به سطر بررسی نشده که migrationها روی دیتابیس واقعی خالی هیچ خطایی ندهند. این را باید با یک اجرای تست روی دیتابیس خالی تأیید کرد.
- ساختار `docs/SERVER-SETUP-CHECKLIST.md` برای تنظیم سرور هنوز مرور نشده است.
+357
View File
@@ -0,0 +1,357 @@
# پلن رفتن به تولید — ارتقای پشته، جداسازی انتشار، جابه‌جایی از henza
سند برنامه‌ریزی، نوشته‌شده در ۲۰۲۶-۱۰-۰۴. **هیچ قدمی هنوز اجرا نشده.**
> این سند طوری نوشته شده که **روی سیستم دیگری، بدون گفتگوی قبلی، قابل ادامه باشد.** از §۰ شروع کنید.
>
> نسخه‌ها و تاریخ‌های این سند مال روز نوشتن‌اند. قبل از اجرای هر قدم، با
> `node scripts/check-majors.mjs` دوباره بسنجید.
---
## خلاصه
- **هدف:** سیستم زنده‌ی henza که الان زیر بار است، روی پشته‌ی جدید برود — **با همان ظاهر برای کاربر**، سریع‌تر و امن‌تر.
- **تصمیم مخزن:** پروژه‌ی جدید ساخته نمی‌شود. همین مونوریپو «نسخه‌ی بعدی تولید» است و امنیت تولید با **جداسازی انتشار** تأمین می‌شود (§۱).
- **سرعت از بک‌اند می‌آید، نه از بازنویسی ظاهر.** اپ کاربر قدیم همین حالا Vue 3 است (§۲).
- **بزرگ‌ترین ریسک داده است، نه کد.** ابزار انتقال داده از دیتابیس henza به Postgres هنوز وجود ندارد (§۷).
### ترتیب اجرا
| # | قدم | بخش | وابسته به |
|---|---|---|---|
| 1 | راه‌اندازی روی هر سیستم + commit و push اسناد | §۰ | — |
| 2 | اندازه‌گیری مبنا روی سیستم زنده | §۳ | — |
| 3 | ارتقای پشته | §۵ | — |
| 4 | جداسازی انتشار | §۶ | — |
| 5 | پورت اپ کلاسیک | `docs/PWA-PORT-PLAN.md` | تصمیم‌های مالک در آن سند |
| 6 | ابزار مهاجرت داده و تطبیق | §۷ | — (موازی با ۵ قابل انجام است) |
| 7 | اجرای موازی روی staging | §۸ | ۵ و ۶ |
| 8 | بستن مسدودکننده‌ها | §۹ | — |
| 9 | روز جابه‌جایی | §۱۰ | همه |
---
## §۰ — ادامه روی سیستم دیگر
### چه چیزی با git منتقل می‌شود و چه چیزی نه
| چیز | در git مونوریپو؟ | روی سیستم جدید |
|---|---|---|
| کد مونوریپو و `docs/` | بله، **بعد از commit و push** | clone از `http://get.feep.ir/s.esmaeili/gold-bank.git` |
| همین سند، `docs/PWA-PORT-PLAN.md` و `scripts/check-majors.mjs` | **امروز نه** — هنوز commit نشده‌اند | قبل از ترک این سیستم، commit و push |
| `henza_pwa`، `henza_server`، `henza_admin` | **نه** — هر کدام مخزن git جدای خودش است و مونوریپو آن‌ها را track نمی‌کند | جدا clone و روی commit پین‌شده checkout (جدول بعد) |
| `.env` | نه — ignore شده، و باید همین‌طور بماند | از `.env.example` بسازید؛ secret ها را از جای امن بیاورید |
| `CLAUDE.md` | نه — در ریشه‌ی workspace قبلی (`E:\gold bank`) بوده و اسناد parity به §2، §3، §4 و §6 آن ارجاع می‌دهند | اگر نسخه‌اش را دارید به مخزن اضافه کنید؛ بازسازی قواعدش در `docs/PWA-PORT-PLAN.md` آمده |
| skill `reference-parity-audit` | نه — روی ماشین قبلی | اگر دارید منتقل کنید؛ وگرنه D12 و همین اسناد روش را می‌گویند |
| حافظه‌ی Claude | نه — محلی است | همین اسناد جایش را می‌گیرند |
### مخزن‌های مرجع — commit های پین‌شده
| مخزن | remote | commit | نکته |
|---|---|---|---|
| `henza_pwa` | `http://get.feep.ir/henzagold/henza_pwa.git` | `b171fff` (2026-09-02) | مرجع پورت کلاسیک |
| `henza_server` | `http://get.feep.ir/henzagold/henza_server.git` | `37c7d7a` (2026-09-17) | روی سیستم فعلی سه فایل حذف‌شده‌ی commit‌نشده دارد (`resources/js/app.js`، `resources/js/bootstrap.js`، `webpack.mix.js`). این‌ها اسکلت Laravel Mix‌اند و به API ربطی ندارند؛ clone تازه آن‌ها را دارد و در parity فرقی ایجاد نمی‌کند |
| `henza_admin` | `http://get.feep.ir/henzagold/henza_admin.git` | `1b67448` (2026-08-11) | — |
```bash
git clone http://get.feep.ir/s.esmaeili/gold-bank.git
cd gold-bank
git checkout release/delivery # یا هر شاخه‌ای که این اسناد رویش commit شده‌اند
git clone http://get.feep.ir/henzagold/henza_pwa.git && git -C henza_pwa checkout b171fff
git clone http://get.feep.ir/henzagold/henza_server.git && git -C henza_server checkout 37c7d7a
git clone http://get.feep.ir/henzagold/henza_admin.git && git -C henza_admin checkout 1b67448
cp .env.example .env # بعد secret ها را پر کنید
pnpm install --frozen-lockfile
```
### دو تله
1. **پوشه‌های `henza_*` در `.gitignore` نیستند.** یک `git add .` یا `git add -A` آن‌ها را به‌صورت «embedded repository» اضافه می‌کند، و clone روی سیستم دیگر به‌جایشان پوشه‌ی خالی می‌گیرد. یا این سه نام به `.gitignore` اضافه شوند، یا همیشه فایل‌ها با نام add شوند.
2. **remote ها `http://` هستند، نه `https://`.** نام کاربری و رمز git بدون رمزنگاری منتقل می‌شوند.
### ابزار روی سیستم جدید
- **Node 24.** سیستم فعلی 24.21 دارد؛ `engines` در `package.json` می‌گوید `>=20`؛ ایمیج Docker API روی 20 است (§۵.۱).
- **pnpm 9** (`packageManager: pnpm@9.0.0`).
- **build روی خود ماشین انجام می‌شود، نه داخل Docker** (DEPLOY-03). Dockerfile ها فقط خروجی ساخته‌شده (`release/` و `dist/`) را کپی می‌کنند، پس Node و سیستم‌عامل ماشین build روی خروجی اثر دارند. قبل از `docker compose build` همیشه `pnpm build:release` اجرا شود.
- **ترتیب خواندن:** همین سند ← `docs/parity/PROGRESS.md` ← `docs/parity/DECISIONS.md` ← `docs/PWA-PORT-PLAN.md`.
---
## §۱ — تصمیم مخزن: پروژه‌ی جدید نه
**وضعیت:** پیشنهاد (۲۰۲۶-۱۰-۰۴)؛ مالک خواست نوشته شود.
1. **اپ‌ها و API با هم تکامل پیدا می‌کنند.** اپ کلاسیک برای ۱۸ endpoint تازه به `apps/api` وابسته است، و ادمین و اپ‌ها یک دیتابیس و یک schema دارند. جدا کردن اپ و API در دو مخزن یعنی هر تغییر دو PR، و ناهمخوانی نسخه‌ی کلاینت و سرور.
2. **امنیت تولید از جداسازی انتشار می‌آید، نه جداسازی مخزن.** هر اپ همین حالا Dockerfile و سرویس جدا دارد. فقط `build:release` همه را با هم می‌سازد، پس خرابی یک اپ آزمایشی انتشار تولید را هم می‌خواباند. راه‌حل در §۶.
3. **تاریخچه و قواعد اینجاست.** تاریخچه‌ی git و کل `docs/parity` (تصمیم‌ها، PROTECTED، ISSUES) در همین مخزن است؛ مخزن تازه یا گمشان می‌کند یا دو نسخه می‌سازد.
**چه وقت پروژه‌ی جدید درست است:** اگر `user-app` و `user-app-v2` قرار است محصول، برند یا تیم دیگری شوند. اگر قرار است هرگز منتشر نشوند، بایگانی‌شان کنید (یک tag، و خروج از انتشار) — باز هم بدون مخزن جدید. حذفشان طبق D18 و `PROTECTED.md` تأیید صریح مالک می‌خواهد.
---
## §۲ — واقعیت درباره‌ی «سرعت»
| بخش | در تولید امروز | وضعیت | جایگزین در مونوریپو |
|---|---|---|---|
| سرور | Laravel 8 + PHP `^7.3` (`henza_server/composer.json`) | پشتیبانی امنیتی Laravel 8 در ژانویه‌ی ۲۰۲۳ و PHP 7.x در ۲۰۲۲ تمام شده | `apps/api` — NestJS + Prisma + Postgres |
| ادمین | Vue 2.6 + Vuex 3 + Vuesax + Tailwind 1 + vue-cli 3 (`henza_admin/package.json`) | Vue 2 پایان ۲۰۲۳ تمام شد | `apps/admin-panel` — Vue 3 |
| اپ کاربر | Vue 3 + Vite + Tailwind 3 — lockfile آن Vue 3.5.40 دارد و هر ۲۳ route آن lazy است | **پشته به‌روز است** | `apps/user-app-classic` (`docs/PWA-PORT-PLAN.md`) |
پس بردن اپ کاربر روی «Vue 3، Tailwind، Pinia» به‌خودی‌خود سریعش نمی‌کند. اهرم‌های واقعی سرعت در آن:
1. **کل CSS ant-design** با `import 'ant-design-vue/dist/antd.less'` (`henza_pwa/src/main.ts:7`) → فقط استایل کامپوننت‌های استفاده‌شده.
2. **کل CSS vant، دو بار:** `vant/lib/index.css` هم در `main.ts:10` و هم در `main.ts:11`.
3. **درخواست‌های تکراری:** `/price` از دو مسیر جدا (`store/store.ts:114` و `Dashboard.vue:634`)؛ `/front` دو بار (`home/index.vue:184` و `Mixins/getProducts.ts:46`).
4. **بدون صفحه‌بندی:** `/get-orders` و `/get-my-findoc` کل داده را برمی‌گردانند.
5. **گزارش حساب** از یک سیستم حسابداری بیرونی proxy می‌شد (D24)؛ در هدف از دفتر داخلی خوانده می‌شود.
همه‌ی این‌ها زیر کاپوت‌اند و ظاهر را عوض نمی‌کنند، پس با قرارداد «دقیقاً مثل مرجع» در `docs/PWA-PORT-PLAN.md` سازگارند.
---
## §۳ — اندازه‌گیری مبنا (قدم ۲)
بدون عدد «قبل»، معلوم نیست کندی کجاست و بعداً هم نمی‌شود نشان داد سریع‌تر شده. **روی تولید آزمون بار (load test) اجرا نشود.**
| لایه | ابزار | چه چیزی ثبت شود |
|---|---|---|
| اپ — مهمان | `npx lighthouse https://app.henzagold.com/home --only-categories=performance --output=json --output-path=baseline-home.json` (پیش‌فرض Lighthouse شبیه‌سازی موبایل است)؛ همین برای `/search` و یک `/product/<id>` | LCP، TBT، حجم JS و CSS منتقل‌شده، تعداد درخواست‌ها |
| اپ — واردشده | Chrome DevTools ← Performance و Network، روی یک گوشی متوسط واقعی | زمان تا نمایش میز کار؛ تعداد و زمان درخواست‌های شروع |
| سرور | لاگ nginx با `$request_time` یا لاگ Laravel، در یک روز کاری | p50 و p95 برای `/front`، `/get-products`، `/me-data`، `/get-orders`، `/get-my-findoc`، `/price`، `/fin_tahesab/*`، `/set-order` |
| دیتابیس | slow query log (مثلاً بالای 0.5 ثانیه)، یک روز | ده کوئری کندتر |
| سنجه | مبنا (henza) | بعد از جابه‌جایی |
|---|---|---|
| LCP خانه (موبایل) | | |
| حجم JS + CSS خانه | | |
| تعداد درخواست تا میز کار | | |
| p95 `/front` | | |
| p95 `/get-products` | | |
| p95 `/set-order` | | |
---
## §۴ — استاندارد نسخه‌ها
«دو نسخه عقب‌تر» به‌عنوان قاعده‌ی ثابت جواب نمی‌دهد: دو نسخه قبل از vue-router فعلی، router مخصوص Vue 2 است که مرده؛ و دو نسخه قبل از NestJS فعلی، همان نسخه‌ای است که الان دارید و از ژانویه patch نگرفته.
**قاعده:** جدیدترین major که حدود ۶ ماه از انتشارش گذشته، هنوز patch می‌گیرد و وابستگی‌ها پشتیبانی‌اش می‌کنند. **دو استثنا:**
- **ابزارهای زمان build** (TypeScript، vue-tsc، Vite) روی سرور اجرا نمی‌شوند؛ patch نگرفتنشان ریسک امنیتی زمان اجرا ندارد.
- **کتابخانه‌هایی که ظاهر را می‌سازند** مستقل از سنشان ثابت می‌مانند (جدول دوم).
داده‌ی npm در ۲۰۲۶-۱۰-۰۴ — با `node scripts/check-majors.mjs` تازه کنید:
| بسته | جدیدترین | پیشنهاد | الان در مونوریپو | خط فعلی آخرین بار کی patch شد | نوع |
|---|---|---|---|---|---|
| Node | 26 (هنوز LTS نیست) | **24 LTS** | Docker API: **20** | Node 20 از ۲۰۲۶-۰۴-۳۰ پشتیبانی ندارد | زمان اجرا |
| NestJS | 12.1.2 (یک‌ماهه) | **11.x** — فعال | `^10.4.0` | 10.4.22 در ۲۰۲۶-۰۱-۱۰ | زمان اجرا |
| Prisma | 8 (RC) / 7.10.0 | **6.19.x** حالا؛ 7 بعد از تولید | `^5.19.0` | 5.22.0 در ۲۰۲۴-۱۱-۰۵ | زمان اجرا |
| ایمیج nginx | `latest` | **tag ثابت** | `latest` | — | زمان اجرا |
| Vue | 3.5.43 | **3.5.x** | `^3.5.0` | فعال | کلاینت |
| vue-router | 5.3.1 | **4.6.4** (آخرین 4.x) | `^4.4.0` | 4.6.4 در ۲۰۲۵-۱۲-۱۱ | کلاینت |
| Pinia | 4.0.3 (سه‌ماهه) | **2.3.1** (آخرین 2.x) | `^2.2.0` | 2.3.1 در ۲۰۲۵-۰۱-۲۰ | کلاینت |
| Vite | 8.3.2 | **7.3.x** | `^5.4.0` | 5.4.21 در ۲۰۲۵-۱۰-۲۰ | build |
| @vitejs/plugin-vue | 6.0.9 | **6.0.x** | `^5.1.0` | 5.2.4 در ۲۰۲۵-۰۵-۰۹ | build |
| vite-plugin-pwa | 2.0.0 (یک‌روزه) | **1.3.x** | `^0.20.5` | 0.21.2 در ۲۰۲۵-۰۳-۲۱ | build |
| TypeScript | 7.0.2 (سه‌ماهه، کامپایلر تازه) | **5.9.3** | `^5.5.0` | 5.9.3 در ۲۰۲۵-۰۹-۳۰ | build |
| vue-tsc | 3.3.12 | **3.x** | `^2.1.0` | 2.2.12 در ۲۰۲۵-۰۷-۰۲ | build |
**چرا Pinia 3 نه:** خط 3 هم ۳۳۳ روز است release نداشته، و برای پروژه‌ی Vue 3 چیزی بیشتر از 2 نمی‌دهد. تنها خط فعال 4 است که هنوز سه ماه بیشتر عمر ندارد — بعداً.
**چرا Prisma 6 با اینکه ۱۸۶ روز release نداشته:** پله‌ی کوچک‌تر و کم‌ریسک‌تر از 5 است (§۵.۷)؛ مقصد بلندمدت 7 است که بعد از تولید می‌آید.
سازگاری این ترکیب بررسی شد: `@vitejs/plugin-vue@6` با Vite 5 تا 8، `vite-plugin-pwa@1.3` با Vite 3 تا 8، `vue-tsc@3` با TypeScript ≥ 5.0، Vite 7 با Node ≥ 20.19، و NestJS 11 با Node ≥ 20 جور است.
### کتابخانه‌هایی که به‌خاطر ظاهر ثابت می‌مانند
| بسته | نسخه | چرا |
|---|---|---|
| `ant-design-vue` | 3.2.20 برای اپ‌های کاربر؛ 4.2.6 برای ادمین | v4 استایل را به CSS-in-JS برده. **هیچ‌کدام از دو خط از نوامبر ۲۰۲۴ release نداشته‌اند** — ریسک بلندمدتی که به‌خاطر ظاهر پذیرفته می‌شود |
| `tailwindcss` | 3.0.18 | Tailwind 4 بازنویسی است و پیش‌فرض‌هایش ظاهر را عوض می‌کند |
| `vant` | 4.10.x (اپ کلاسیک دقیقاً 4.10.0) | — |
| `swiper` | 7.4.1 | — |
| `@vueuse/core` | 9.13.0 | مرجع با همین نسخه است؛ ارتقا رفتار `useTransition` و `useInfiniteScroll` را عوض می‌کند و تست جدا می‌خواهد |
**پین:** نسخه‌ها بدون `^` و `~`، lockfile commit‌شده، و `pnpm install --frozen-lockfile` روی ماشین build.
---
## §۵ — قدم‌های ارتقا (قدم ۳)
**قواعد همه‌ی زیرقدم‌ها:**
- هر زیرقدم یک commit یا PR جدا روی شاخه‌ی خودش (`chore/upgrade-*`)، با تست کامل، **بدون کار فیچر قاطی**.
- `admin-panel`، `user-app` و `user-app-v2` در `PROTECTED.md` هستند: ارتقای ابزارشان **تأیید صریح مالک** و اسکرین‌شات قبل و بعد از صفحه‌های اصلی می‌خواهد. هیچ تغییر ظاهری یا رفتاری مجاز نیست.
- برای دیتابیس، روند D14 و D15 برقرار است: هرگز `migrate reset` و هرگز دست زدن به `_prisma_migrations`.
ترتیب از کم‌ریسک به پرریسک است، به‌جز Node که اول می‌آید چون Vite 7 به Node ≥ 20.19 نیاز دارد و Node 20 تمام شده.
### ۵.۱ — Node 24
- **تغییر:** `apps/api/Dockerfile` از `node:20-bookworm` به `node:24-bookworm`؛ `engines.node` به `">=24 <25"`؛ فایل `.nvmrc` با مقدار `24`.
- **ممکن است بشکند:** ریسک پایین — `apps/api/package.json` هیچ وابستگی native ندارد (بررسی شد).
- **سنجش:** `pnpm build:release`؛ بالا آمدن API در Docker؛ `apps/api/scripts/auth-smoke.sh` (۴۶ assertion)؛ `pnpm test` در api.
### ۵.۲ — پین ایمیج nginx
- **تغییر:** نسخه‌ی واقعی `latest` فعلی را پیدا کنید (`docker run --rm docker.arvancloud.ir/nginx:latest nginx -v`) و در سه Dockerfile همان tag را بنویسید.
- **رفتار:** بی‌تغییر؛ فقط از این به بعد هر build همان nginx را می‌گیرد.
### ۵.۳ — پین دقیق و patch های داخل همان خط
- **تغییر:** حذف `^` و `~` از وابستگی‌های همه‌ی `package.json` ها؛ بالا بردن به آخرین patch در همان major: Vue 3.5.43، vue-router 4.6.4، Pinia 2.3.1، TypeScript 5.9.3؛ `pnpm install --frozen-lockfile` روی ماشین build و در CI.
- **سنجش:** روی یک clone تازه، `pnpm install --frozen-lockfile` بدون تغییر lockfile تمام شود؛ typecheck و build همه‌ی اپ‌ها.
### ۵.۴ — vue-tsc 3
- **ممکن است بشکند:** vue-tsc 3 خطاهای تایپی تازه در template ها پیدا می‌کند. همه درست شوند، نه خاموش.
- **سنجش:** typecheck تمیز در `admin-panel`، `user-app` و `user-app-v2`.
### ۵.۵ — Vite 7، plugin-vue 6، vite-plugin-pwa 1.3
- **مهم‌ترین تله:** طبق راهنمای مهاجرت Vite 7، پیش‌فرض `build.target` از `'modules'` به `'baseline-widely-available'` عوض شده — یعنی مرورگرهای جدیدتر (حدود Chrome 107 و Safari 16). **هیچ‌کدام از `vite.config.ts` ها `build.target` ندارند** (بررسی شد)، پس گوشی‌های قدیمی‌تر کاربران بی‌صدا از کار می‌افتند. ← `build.target` صریحاً روی مقدار قبلی، `['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']`، گذاشته شود، مگر مالک بر اساس آمار گوشی‌های کاربران تصمیم دیگری بگیرد. قبل از اجرا با راهنمای رسمی تطبیق داده شود.
- `vite-plugin-pwa@1.3` به `workbox-build` و `workbox-window` ^7.4.1 نیاز دارد.
- **سنجش:** build هر سه اپ؛ سناریوی دود (ورود، کاتالوگ، سبد)؛ نصب PWA و جریان به‌روزرسانی؛ اسکرین‌شات قبل و بعد.
### ۵.۶ — NestJS 11 (همه‌ی بسته‌های `@nestjs/*` با هم)
- **ممکن است بشکند:** NestJS 11 روی Express 5 است. route با wildcard یا `forRoutes('*')` در کد نیست (بررسی شد) — رایج‌ترین شکست صدق نمی‌کند. ولی parser پیش‌فرض query در Express 5 «simple» شده؛ هر endpoint که query تودرتو می‌خواند باید چک شود. با راهنمای مهاجرت رسمی تطبیق داده شود.
- **سنجش:** jest کامل؛ `auth-smoke.sh`؛ تست‌های توکن متقابل (T1).
### ۵.۷ — Prisma 6.19
- **ممکن است بشکند:** مهم‌ترین شکست‌های Prisma 6 اینجا صدق نمی‌کنند (بررسی شد): در migration ها رابطه‌ی many-to-many ضمنی نیست، و فیلد `Bytes` و `NotFoundError` استفاده نشده‌اند.
- **سنجش:** بعد از ارتقا، `prisma migrate diff --from-schema-datasource --to-schema-datamodel --script` باید «empty migration» بدهد؛ `prisma migrate status`؛ jest؛ دود.
### ۵.۸ — بعداً، نه قبل از تولید
Prisma 7، Pinia 4، vue-router 5، Vite 8، TypeScript 6 و 7، NestJS 12 — هر کدام وقتی پخته شد، جدا و با تست. **Tailwind 4 و ant-design-vue 4 هرگز برای اپ‌های کاربر**، چون ظاهر عوض می‌شود.
---
## §۶ — جداسازی انتشار (قدم ۴)
- **مجموعه‌ی تولید:** `apps/api`، `apps/admin-panel`، و `apps/user-app-classic` وقتی آماده شد. **آزمایشی:** `apps/user-app`، `apps/user-app-v2`.
- **اسکریپت تازه‌ی `build:prod`** فقط برای مجموعه‌ی تولید. `build:release` فعلی دست نمی‌خورد، چون `DELIVERY.md` به آن تکیه دارد.
- **`docker-compose.yml`:** سرویس‌های آزمایشی زیر `profiles: ["experimental"]` بروند، تا `docker compose up` فقط تولید را بالا بیاورد و آزمایشی‌ها با `--profile experimental` بیایند. `DELIVERY.md` هم‌زمان به‌روز شود.
- **شاخه‌ها:** `main` همیشه آماده‌ی تولید؛ کار روی شاخه‌های `chore/upgrade-*` و `feature/classic-*`؛ شاخه‌ی انتشار از `main` بریده شود. امروز همه‌چیز روی `release/delivery` است.
- **DEPLOY-03 — build داخل Docker با multi-stage:** حالا که کار ممکن است بین ماشین‌ها جابه‌جا شود، اولویتش بالاتر است. خروجی نباید به Node و سیستم‌عامل ماشین build وابسته باشد.
---
## §۷ — مهاجرت داده و تطبیق (قدم ۶) — بزرگ‌ترین شکاف
**واقعیت:** ابزار انتقال داده از دیتابیس henza به Postgres نه در مخزن هست و نه در اسناد (جست‌وجوی ۲۰۲۶-۱۰-۰۴). موتور دیتابیس henza هم از مخزن معلوم نیست — `config/database.php` پیش‌فرض `'localhost'` دارد و `.env.example` تنظیم DB ندارد. احتمالاً MySQL است؛ از سرور تولید تأیید شود.
**ویژگی‌های اجباری ابزار:**
- تکرارپذیر و idempotent؛ همیشه روی **کپی** داده‌ی تولید.
- برای هر موجودیت یک جدول نگاشت `legacyId ← id جدید` نگه دارد. لازم است برای:
- تطبیق؛
- ترتیب ۹ دسته‌ی خانه که با id عددی هاردکد شده (Q5 در `docs/PWA-PORT-PLAN.md`)؛
- لینک‌های قدیمی محصول (`/product/123`) که کاربران فرستاده‌اند — بدون نگاشت، به خانه redirect می‌شوند؛
- سبدهای باز `appCart` روی گوشی‌ها.
- یک گزارش تطبیق بسازد (جدول دوم).
| henza | مقصد | نکته |
|---|---|---|
| مشتری (`customers`) | `Customer` | نام ← `firstName`/`lastName` (D0)؛ `type` ← `CustomerKind`؛ `percent` ← `makingChargeOffsetPercent` (D9)؛ `gram_level` ← `goldCreditLimitGrams`؛ `remain` ← `goldDebtGrams`؛ `rial_remain` ← `fiatDebtRial` **با بررسی واحد** (نقص #19 در `docs/PWA-PORT-PLAN.md`) |
| کارشناس فروش (`customers.type = 1`) | `AdminUser` با نقش `SALES_EXPERT` + `SalesExpertProfile` | D17 و D21 |
| مشتری تک (`SingleCustomer`/`login`) | `Customer` با `kind = RETAIL` | D13 — داده حفظ شود، حتی اگر خارج از کار است |
| کاربران ادمین (`users`) و `permission` | `AdminUser` + `UserType` + `Permission` | — |
| محصول، تنوع، تصویر، دسته، برند، کالکشن | مدل‌های هم‌نام | **فایل‌های تصویر هم کپی شوند** (`UPLOAD_DIR`) |
| سفارش‌ها — عمده و تک، دو جدول جدا در henza | `Order` + `OrderItem` + `OrderStatusEvent` | `sts` عددی ← `OrderStatus` |
| سفارش ساخت | `PreOrder` | — |
| اسناد مالی (`cash`) | `LedgerEntry` | فقط تأییدشده‌ها در موجودی حساب می‌شوند (D24) |
| کیف پول و cashback | مدل‌های موجود | D13 — حفظ، بدون کار |
| علاقه‌مندی، تخفیف و استثنا، اعلان، شهر، تنظیمات | مدل‌های موجود یا تازه | علاقه‌مندی جدول تازه می‌خواهد (`docs/PWA-PORT-PLAN.md`) |
| بررسی تطبیق | آستانه |
|---|---|
| تعداد مشتری، کارشناس و کاربر ادمین | دقیقاً برابر |
| بدهی طلای هر مشتری (`remain` ↔ `goldDebtGrams`) | **صفر اختلاف** |
| بدهی ریالی هر مشتری | **صفر اختلاف**، بعد از تبدیل واحد |
| سقف اعتبار و آفست اجرت هر مشتری | صفر اختلاف |
| تعداد سفارش به تفکیک وضعیت؛ جمع وزن سفارش‌های باز | برابر |
| تعداد و جمع اسناد تأییدشده و تأییدنشده | برابر |
| تعداد محصول، تنوع و تصویر؛ فایل تصویر گم‌شده | برابر؛ صفر |
**شماره‌ی تکراری بین مشتری و کارشناس** قبل از مهاجرت فهرست شود. یکتایی شماره بین `Customer` و `AdminUser` امروز اجرا نمی‌شود — هر چهار بررسی تداخل فقط `adminUser` را کوئری می‌کنند — و در `ISSUES.md` هم ثبت نشده. قاعده‌ی مالک در D21: «کارشناس برنده است».
---
## §۸ — اجرای موازی روی staging (قدم ۷)
- یک دامنه‌ی staging؛ هر شب داده‌ی تولید با ابزار §۷ منتقل و تطبیق شود.
- **پیامک در staging خاموش، یا فقط به شماره‌ی تست.** داده‌ی واقعی یعنی شماره‌ی واقعی مشتری‌ها.
- کاربران داخلی جریان‌های واقعی را روی staging امتحان کنند.
- **معیار آمادگی:** مثلاً سه شب پیاپی تطبیق تمیز، و هیچ باگ مسدودکننده‌ی باز.
---
## §۹ — مسدودکننده‌های قبل از تولید (قدم ۸)
| مورد | منبع |
|---|---|
| مقدار جدا برای `JWT_CUSTOMER_SECRET` (REL-03) | `DELIVERY.md` §A1 |
| حذف `OTP_MASTER_CODE` (REL-01) | §A2 |
| `JWT_SECRET` واقعی | §A3 |
| اعتبارنامه‌ی SMS (REL-02) | §A4 |
| چرخاندن اعتبارنامه‌های نشت‌کرده: پنل SMS (SEC-12)، توکن‌های PayPing و Tahesab و pepper کیف پول (SEC-06). **هم‌زمان با سیستم قدیم**، چون همان حساب‌های زنده‌اند؛ چرخاندن یک‌طرفه پیامک سیستم قدیم را قطع می‌کند | §B |
| MON-08 — سرور اجرت و تخفیف را از کلاینت نپذیرد. وضعیت فعلی چک شود | `docs/parity/ISSUES.md` |
| MON-05 — اجرای `canPlaceOrders` و `verificationStep` | `docs/parity/ISSUES.md` |
| یکتایی شماره بین `Customer` و `AdminUser` | §۷ |
---
## §۱۰ — روز جابه‌جایی (قدم ۹)
### شرط‌های go/no-go
- همه‌ی موارد §۹ بسته‌اند.
- معیار آمادگی §۸ برقرار است.
- برگشت (rollback) یک بار تمرین شده.
- به کاربران اطلاع داده شده: زمان قطعی؛ یک بار ورود دوباره با OTP، چون توکن‌های قدیمی باطل می‌شوند؛ خالی شدن سبدهای باز، مگر با نگاشت §۷ منتقل شوند.
### گام‌ها
1. سیستم قدیم در حالت نگهداری؛ نوشتن قفل.
2. مهاجرت نهایی و گزارش تطبیق ← **تصمیم go/no-go.**
3. سوئیچ مسیرها روی **همان دامنه‌ها**:
- service worker جدید در همان مسیر `/sw.js`، تا جای قبلی را بگیرد.
- نسخه‌ی اپ کلاسیک بالاتر از `1.0.7` باشد، تا دیالوگ مرجع «نسخه جدید هنزا منتشر شد!» یک بار برای همه بیاید و اپ تازه شود (`henza_pwa/src/App.vue:56-74`).
4. اولین باز شدن بعد از سوئیچ ممکن است پوسته‌ی کش‌شده‌ی قدیمی را نشان دهد که به API قدیم (`henzagold.com/oapi/api/`) درخواست می‌فرستد. آن مسیر تا مدتی پاسخ تمیز (مثلاً 503) بدهد، نه timeout.
5. آزمون دود: ورود با OTP؛ موجودی میز کار برابر گزارش تطبیق؛ ثبت سفارش روی یک مشتری تست؛ ورود ادمین و دیدن همان سفارش؛ رسیدن پیامک.
6. باز کردن برای همه.
### برگشت
- سیستم قدیم چند روز (مثلاً ۱۴ روز) فقط‌خواندنی بماند؛ پشتیبان کامل از هر دو دیتابیس.
- **مهلت تصمیم برگشت از قبل تعیین شود** (مثلاً ۲ ساعت بعد از باز کردن). بعد از آن، سفارش‌ها و اسناد تازه فقط در سیستم جدیدند و برگشت یعنی انتقال دستی آن‌ها به دیتابیس قدیم.
- **برگشت سمت گوشی‌ها فوری نیست:** service worker جدید روی گوشی‌ها کش شده. برای برگرداندن، نسخه‌ی قدیم باید با شماره‌ی نسخه‌ی بالاتر دوباره منتشر شود.
---
## §۱۱ — بعد از جابه‌جایی
- مقایسه با مبنای §۳ و ثبت نتیجه در جدول همان بخش.
- پایش خطا و زمان پاسخ در هفته‌ی اول.
- بعد: ارتقاهای §۵.۸؛ وصل کردن `user-app` و `user-app-v2` به قابلیت‌های تازه‌ی بک‌اند (تنش ۱ در `docs/PWA-PORT-PLAN.md`).
---
# وضعیت اجرا
| # | قدم | وضعیت |
|---|---|---|
| 1 | راه‌اندازی روی هر سیستم + commit و push اسناد | ⬜ |
| 2 | اندازه‌گیری مبنا | ⬜ |
| 3 | ارتقای پشته (۵.۱ تا ۵.۷) | ⬜ |
| 4 | جداسازی انتشار | ⬜ |
| 5 | پورت اپ کلاسیک | ⬜ منتظر تصمیم‌های مالک در `docs/PWA-PORT-PLAN.md` |
| 6 | ابزار مهاجرت داده و تطبیق | ⬜ |
| 7 | اجرای موازی روی staging | ⬜ |
| 8 | بستن مسدودکننده‌ها | ⬜ |
| 9 | روز جابه‌جایی | ⬜ |
+149
View File
@@ -0,0 +1,149 @@
# برنامه‌ی رسیدگی به فهرست مشکلات ثبت‌شده
**تاریخ:** ۱۷ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۰۸)
**منبع:** [`docs/parity/ISSUES.md`](./parity/ISSUES.md)
**روش کار:** هر مورد بازِ آن فهرست با کد امروز دوباره سنجیده شد. بعضی در این
فاصله درست شده‌اند ولی فهرست به‌روز نشده بود.
> تا تأیید کارفرما هیچ کدی نوشته نمی‌شود. موارد «حساس» (دفتر بدهی، حذف داده)
> جداگانه هم تأیید می‌خواهند.
---
## الف) درست شده‌اند، فقط باید در فهرست بسته شوند
| شناسه | چه بود | وضعیت امروز |
|---|---|---|
| MON-01 / V2-01 | اپ نسخه‌ی ۲ سقف موجودی نداشت؛ می‌شد بیشتر از موجودی سفارش داد | سقف در هر دو اپ هست |
| V2-02 / APP-02 | اسلایدر تنظیم‌شده در پنل در اپ نسخه‌ی ۲ نمی‌آمد | اپ نسخه‌ی ۲ آن را می‌خواند |
| MON-03 | عدد مثقال (۴٫۳۳۱۸۰۲) اعشاری معمولی بود و نگرانیِ گرد شدن داشت | سنجیده شد: هنگام محاسبه دقیقاً به همان رقم تبدیل می‌شود و **هیچ خطایی نمی‌سازد** |
| PERS-03 | ستونی برای تاریخ تولد مشتری نبود | ستونش با انتقال داده اضافه شد؛ **فقط فیلد فرم مانده** (پایین، بند ج) |
**پیشنهاد:** همین‌ها را در فهرست ببندم. کد عوض نمی‌شود.
---
## ب) قبل از راه‌اندازی واقعی — حتماً
### ۱. علاقه‌مندی‌ها در اپ ذخیره نمی‌شوند (تازه پیدا شد — مرتبط با انتقال)
**مشکل:**
- اپ علاقه‌مندی‌ها را فقط در حافظه‌ی گوشی نگه می‌دارد، پس با بستن اپ پاک
می‌شوند.
- **۸۷۴ علاقه‌مندیِ منتقل‌شده از سیستم قدیم در دیتابیس هست، ولی اپ هیچ‌وقت آن‌ها
را نشان نمی‌دهد.**
- سیستم قدیم علاقه‌مندی را روی سرور ذخیره می‌کرد.
**پیشنهاد:** سه مسیر ساده در سرور اضافه شود (فهرست، افزودن، حذف) و هر دو اپ به
آن وصل شوند.
### ۲. تنظیمات امنیتی سرور (REL-01، REL-03، SEC-04، SEC-05)
**این‌ها کد نیستند؛ تنظیم سرور هستند:**
- **کد ثابت ورود آزمایشی (REL-01):** حذف شود. تا وقتی هست، هر کسی با آن کد
وارد هر حسابی می‌شود.
- **کلید رمز توکن مشتری‌ها (REL-03):** از کلید مدیرها جدا شود.
- **کلید پیش‌فرض توکن (SEC-04):** اگر کلید تنظیم نشده باشد، سرور با یک کلید
پیش‌فرضِ معلوم بالا می‌آید.
**پیشنهاد برای SEC-04:** در محیط اصلی، بدون کلید اصلاً بالا نیاید. این تغییر
کوچک است.
**دو مورد دیگر:**
- **دسترسی از هر دامنه (CORS):** هر سایتی می‌تواند به سرور درخواست بفرستد.
باید فقط به دامنه‌های پنل و اپ محدود شود.
- **حساب آزمایشی (SEC-05):** دیتای اولیه یک حساب مدیر ارشد آزمایشی می‌سازد که
رمزش همان شماره است. باید فقط در محیط توسعه ساخته شود.
### ۳. رمزهای لو رفته‌ی سیستم قدیم (SEC-06، SEC-12، SEC-17) — کارِ خود کارفرما
رمز پنل پیامک، توکن درگاه پرداخت و توکن ته‌حساب در کد سیستم قدیم نوشته شده‌اند.
باید **عوض شوند**. این کار از دست من برنمی‌آید.
### ۴. کالاهای بدون عکس (ACC-05)
کالای بدون عکس در اپ نمایش داده نمی‌شود و قابل سفارش نیست.
**پیشنهاد:** بعد از انتقال داده یک شمارش بگیرم که چند کالا عکس ندارند.
---
## ج) درستی پول و سفارش — مهم
### ۵. بدهی دوبار ثبت می‌شود (ACC-03 = MON-04) — 🔴 مهم‌ترین مورد پولی
**مشکل:** اگر سفارشی دوبار به وضعیت «تحویل به کارشناس» برود (مثلاً با برگرداندن
وضعیت و جلو بردن دوباره)، **بدهی طلا و ریالِ مشتری دوبار ثبت می‌شود.**
**پیشنهاد:** پیش از ثبت بدهی، بررسی شود که برای همین سفارش قبلاً بدهی ثبت شده
یا نه.
**وضعیت:** دفتر بدهی ناحیه‌ی حفاظت‌شده است، پس **تأیید جداگانه‌ی شما لازم است.**
### ۶. مشتری اجرت سفارش را خودش تعیین می‌کند (MON-08)
**مشکل:**
- درصد اجرت، تخفیف و هزینه‌ی ریالی را **اپ** به سرور می‌فرستد و سرور همان را
قبول می‌کند.
- کسی که درخواست را دستکاری کند، می‌تواند با اجرت صفر سفارش بدهد.
- سیستم قدیم هم همین‌طور بود (حتی بدتر).
**پیشنهاد:** سرور این اعداد را خودش از کالا و مشتری حساب کند و عدد اپ را
نادیده بگیرد. **این رفتار را عوض می‌کند، پس تأیید شما لازم است.**
### ۷. «معلق کردن سفارش» و «احراز هویت» جلوی سفارش را نمی‌گیرند (MON-05)
**مشکل:** مدیر می‌تواند ثبت سفارش یک مشتری را معلق کند، ولی سرور موقع سفارش
آن را بررسی نمی‌کند.
**وضعیت سیستم قدیم:** آن هم در سرور بررسی نمی‌کرد. اپ قدیم را باید ببینم که
آن‌جا جلوی کار را می‌گرفت یا نه.
**پیشنهاد:** مشتریِ «عدم سفارش» نتواند سفارش ثبت کند. تأیید شما لازم است.
---
## د) متوسط — بعد از موارد بالا
| شناسه | مشکل به زبان ساده | پیشنهاد |
|---|---|---|
| PERS-03 | فیلد تاریخ تولد در فرم مشتری نیست (۵۴۱ تاریخ تولد واقعی منتقل شده) | فیلد شمسی به فرم اضافه شود |
| SEC-07 | ذخیره‌ی دسترسی‌ها هر متنی را قبول می‌کند، حتی دسترسی بی‌معنی | فقط دسترسی‌های معتبر پذیرفته شود |
| VAL-02 | بیشتر فرم‌های پنل در سرور بررسی ورودی ندارند | به‌تدریج برای فرم‌های ثبت و ویرایش اضافه شود |
| MON-06 | تنظیمات کیف پول، اسلایدر و تخفیف خودکار در نصب تازه مقدار اولیه ندارند | مقدار پیش‌فرض ساخته شود |
| PERM-01 | دو دسترسی قدیمیِ داشبورد در دیتابیس هست که در صفحه‌ی دسترسی‌ها دیده نمی‌شود | حذفشان **حذف داده است و تأیید می‌خواهد** |
| PERS-02 | شهرها در قدیم استان ← شهر بودند؛ در جدید فهرست ساده‌اند | تصمیم با شما؛ فعلاً کار می‌کند |
| PERS-01 | در قدیم موبایل مشتری بعد از ثبت عوض نمی‌شد؛ در جدید می‌شود | قبلاً تصمیم گرفته شد که بماند؛ فقط یادآوری |
| REL-02 | اگر پیامک خراب باشد سرور فقط در گزارش داخلی می‌نویسد | یک صفحه‌ی «وضعیت سلامت» که خرابی پیامک را نشان دهد |
| STUB-05 | شماره‌ی مدیرهایی که پیامک سفارش می‌گیرند | از تنظیمات خوانده شود (بخشی‌اش انجام شده؛ بررسی می‌کنم) |
| V2-03 | اپ نسخه‌ی ۲ عکس هر مدل (واریانت) را نشان نمی‌دهد | — |
| V2-05 | اپ نسخه‌ی ۲ برچسب موجودی روی مدل‌ها ندارد | هر دو باید با اپ نسخه‌ی ۱ یکسان شوند (فقط ظاهر باید فرق کند) |
| VAL-03 | اپ وقتی خطا می‌گیرد پیام درستی به کاربر نشان نمی‌دهد | — |
| ACC-01 | اتصال زرین روی نصب واقعی امتحان نشده | وقتی نصب واقعی داشتید |
| ACC-04 | روی دیتابیس کاملاً خالی، ترتیب تغییرات دیتابیس به هم می‌خورد | نام یک فایل عوض شود، بدون اثر روی دیتابیس‌های موجود |
---
## ه) کم‌اهمیت / تمیزکاری
| شناسه | مشکل | پیشنهاد |
|---|---|---|
| DEAD-01..03 | کد و پوشه‌ی بلااستفاده در اپ‌ها و ریشه‌ی پروژه | حذف، با تأیید |
| PERF-01 | فهرست کارشناسان برای هر ردیف یک پرس‌وجو جدا می‌زند | الان با ۵۱ کارشناس مشکلی ندارد |
| REP-02 | گزارش مشتری برای هر بار نمایش کل تاریخچه را حساب می‌کند | الان مشکلی ندارد |
| DEPLOY-03 | قبل از ساخت نسخه‌ی سرور باید خروجی تازه گرفته شود؛ وگرنه نسخه‌ی کهنه ساخته می‌شود | در چک‌لیست انتشار هست؛ بعداً خودکار شود |
---
## ترتیب پیشنهادی کار
| ترتیب | کار | نیاز به تأیید جداگانه |
|---|---|---|
| ۱ | بستن موارد درست‌شده در فهرست (بند الف) | — |
| ۲ | **علاقه‌مندی‌ها روی سرور** (بند ۱) | — |
| ۳ | **بدهی دوباره** (بند ۵) | ✅ ناحیه‌ی حفاظت‌شده |
| ۴ | امنیت سرور (بند ۲) | — |
| ۵ | اجرت از سرور (بند ۶) و جلوگیری از سفارشِ معلق (بند ۷) | ✅ تغییر رفتار |
| ۶ | فیلد تاریخ تولد (PERS-03) و بررسی دسترسی‌ها (SEC-07) | — |
| ۷ | بقیه‌ی بند «د» و «ه» به ترتیب | حذف‌ها ✅ |
+157
View File
@@ -0,0 +1,157 @@
# طرح فاز ۲ — قلم ۲ (D3): سه رفتار جامانده در اپ‌ها
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12)
## وضعیت — تصمیم مالک گرفته شد (۲۰۲۶/۱۰/۱۲)
> «الف و ب مهم پس به ترتیب برو جلو — ج اصلا اولیوت نداره جزو اخرین چیز هایی هست
> که شاید درستش کنیم»
| قلم | تصمیم | وضعیت |
|---|---|---|
| **الف** — پیام مهمان | انجام شود | ✅ **انجام شد** — گزینه ب (پیام + حفظ بازگشت به مقصد) در هر دو اپ |
| **ب** — گارد `isLoaded` | انجام شود، طبق پیشنهاد پورت **نشود** | ✅ **ثبت شد** به‌عنوان «عمداً تکرار نشد — D3/ب» در `configStore.ts` هر دو اپ |
| **ج** — آمار بازدید | **آخرین اولویت، شاید هرگز** | ⏸️ معوق — بخش «شکاف ۳» پایین به‌عنوان طرحِ آماده نگه داشته شد |
---
---
## چه چیزی قبلاً انجام شده (بررسی شد، ادعا نیست)
D3 ده کار بود. هفت‌تایش انجام شده است:
| کار | وضعیت | مدرک |
|---|---|---|
| ۵ صفحه `/report`، `/report/detail`، `/ChildOrder`، `/customerChoice`، `/complete_profile` | ✅ در **هر دو** اپ | `apps/user-app/src/router/index.ts` و همان فایل در `user-app-v2` — هر پنج مسیر ثبت شده با `meta.layout='FullPage'` |
| گارد `sts_verify_step_two` روی تکمیل پروفایل | ✅ | `router/index.ts` گارد `requiresIncompleteProfile`؛ از سرور می‌پرسد نه از حافظه، پس پروفایلی که روی دستگاه دیگر کامل شده هم محترم است |
| سقف موجودی سبد v2 (`capQty`/`maxQty`) | ✅ یکسان در هر دو اپ | `stores/cartStore.ts:80,88` در هر دو |
**سه کار باقی است** و هر سه را بررسی و در مرجع تأیید کردم.
---
## شکاف ۱ — رفتار مهمان: الان به صفحه ورود پرتاب می‌شود
**مرجع** (`henza_pwa/src/router/index.ts:253-261`): کاربر وارد‌نشده که یکی از پنج
صفحه شخصی را باز کند، یک پیام «ابتدا وارد شوید» می‌بیند و **به خانه** می‌رود.
```ts
if (to.name === 'ordersList' || ... || to.name === 'completeProfile') {
showNotify('ابتدا وارد شوید');
return next({name: 'home'})
} else return next()
```
**تارگت** (`apps/user-app/src/router/index.ts:139-141`): به `/login` می‌رود با
`query.redirect`، و **هیچ پیامی نشان نمی‌دهد**.
```ts
if (to.meta.requiresAuth && !session.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
```
**چرا مهم است:** کاربر مهمان که روی «سفارش‌های من» می‌زند، بی‌هیچ توضیحی در صفحه
ورود می‌افتد و نمی‌داند چه شد. رفتار مرجع صریح‌تر است و D3 هم همان را خواسته.
### ⚠️ یک تضاد که باید خودت تصمیم بگیری
رفتار فعلی تارگت از یک جهت **بهتر** است: `query.redirect` دارد، یعنی بعد از
ورود، کاربر به همان صفحه‌ای که می‌خواست برمی‌گردد. رفتار مرجع این را ندارد —
کاربر به خانه می‌رود و باید دوباره دستی به آن صفحه برود.
سه گزینه:
| گزینه | نتیجه |
|---|---|
| **الف) عیناً مرجع** | toast + خانه. `query.redirect` از بین می‌رود. تطبیق کامل با D3. |
| **ب) بهترین هر دو** (پیشنهاد من) | toast «ابتدا وارد شوید» نشان داده شود، **ولی** مقصد همان صفحه ورود با `redirect` بماند. کاربر هم می‌فهمد چه شد، هم بعد از ورود سرِ جایش برمی‌گردد. در ماتریس به‌عنوان «عمداً تکرار نشد» ثبت می‌شود. |
| **ج) دست نزن** | شکاف باز می‌ماند و در ماتریس ثبت می‌شود. |
هزینه هر سه یکی است (چند خط). تفاوت فقط در تجربه کاربر است.
---
## شکاف ۲ — گارد `isLoaded`
**مرجع** (`router/index.ts:263-266`): کاربر وارد‌شده‌ای که داده‌های اولیه‌اش
(`memory.state.isLoaded`) هنوز نیامده، به هر صفحه‌ای جز `home` برود به خانه
برگردانده می‌شود — مگر آن مسیر `meta.skipLoadedCheck` داشته باشد.
**تارگت**: پورت نشده، و در کد **صریحاً نوشته شده چرا**:
```ts
// apps/user-app/src/stores/configStore.ts:19
// Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
```
**نظر من: این یکی را نباید پورت کنیم، و پیشنهادم «نه» است.**
دلیل: `isLoaded` در مرجع پرچمِ «یک درخواست بزرگ اولیه نشست» بود که تارگت ندارد —
تارگت هر صفحه داده‌ی خودش را می‌گیرد. اگر این گارد را اضافه کنیم، چیزی به کاربر
اضافه نمی‌شود و فقط یک پرش ناخواسته به خانه ساخته می‌شود: مثلاً کاربری که لینک
محصول را از تلگرام باز می‌کند، به‌جای محصول در خانه می‌افتد. این دقیقاً مصداق
«باگ مرجع را پورت نکن» (D4) است.
اگر موافقی، به‌عنوان **«عمداً تکرار نشد — D4»** ثبت می‌شود و D3 بسته می‌شود.
اگر مخالفی، پورت می‌کنم.
---
## شکاف ۳ — آمار بازدید صفحه‌ها (`log/create` و `log/update`)
**مرجع** دو مسیر دارد و زمان ماندن کاربر در هر صفحه را اندازه می‌گیرد:
| | مرجع |
|---|---|
| ورود به صفحه | `POST /log/create/{title}` → یک ردیف با `start_at`، `ip`، `title`، `customer_id` (اگر وارد شده باشد) می‌سازد و `id` را برمی‌گرداند |
| خروج از صفحه | `GET /log/update/{id}` → `end_at` و `time_present` را پر می‌کند |
| صفحه محصول | `data: {product_id}` هم می‌فرستد تا بازدید هر محصول جدا شمرده شود |
| مستثنا | `login` و `userPage::hasSubRoute` |
مرجع: `henza_server/app/Http/Controllers/Api/LogController.php`،
`henza_pwa/src/router/customAnalytics.ts`.
**تارگت**: نه مسیر، نه جدول، نه فراخوانی. کامل غایب.
این بزرگ‌ترین قلم از سه‌تاست — جدول، مدل Prisma، مهاجرت، دو مسیر API، یک
composable در هر اپ، و صفحه‌های گزارش ادمین (`queryTitle`، `queryAllCustomer`،
`queryCustomer`، `ago`) که آمار را نشان می‌دهند.
### ⚠️ دو ایراد مرجع که عیناً پورت نمی‌کنم
۱. **مسیر بی‌محافظ است.** در مرجع `log/create` احراز هویت ندارد و هر عنوانی را
می‌پذیرد، پس هر کسی از بیرون می‌تواند جدول را با ردیف‌های جعلی پر کند و آمار
را بی‌معنا یا دیسک را پر کند. پیشنهاد: عنوان فقط از یک فهرست بسته (نام
مسیرهای واقعی) پذیرفته شود و نرخ درخواست محدود شود.
۲. **`GET` که داده را عوض می‌کند.** `log/update` یک `GET` است که ردیف را
می‌نویسد. پیشنهاد: `POST` باشد. (اپ‌های تارگت تازه نوشته می‌شوند، پس سازگاری
عقب‌رو لازم نیست.)
### ⚠️ و یک پرسش که فقط تو می‌توانی جواب بدهی
این قابلیت **رفتار هر مشتری را با IP ثبت می‌کند** — کدام صفحه‌ها را دید و چند
ثانیه ماند. فنی‌اش ساده است، ولی این داده‌ی شخصی است و مرجع هیچ‌وقت آن را پاک
نمی‌کرد (جدول بی‌نهایت رشد می‌کند).
سه پرسش:
| پرسش | پیشنهاد من |
|---|---|
| این آمار را واقعاً لازم داری، یا در سیستم قدیم هم عملاً نگاهش نمی‌کردی؟ | اگر نگاهش نمی‌کردی، نسازیم. ساده‌ترین قابلیت، قابلیتی است که وجود ندارد. |
| اگر لازم است، چند وقت نگه داشته شود؟ | ۹۰ روز، و قدیمی‌ترها خودکار پاک شوند. |
| IP هم ذخیره شود؟ | نه. برای «کدام صفحه چقدر دیده شد» لازم نیست و حساس‌ترین بخش داده است. |
---
## جمع‌بندی و پیشنهاد ترتیب کار
| # | قلم | پیشنهاد من | هزینه |
|---|---|---|---|
| ۱ | toast مهمان | **گزینه ب** — هم پیام، هم بازگشت به صفحه مقصد | کم (نیم ساعت، هر دو اپ) |
| ۲ | گارد `isLoaded` | **پورت نشود** — «عمداً تکرار نشد — D4» | صفر |
| ۳ | آمار بازدید | **اول جواب بده لازمش داری یا نه.** اگر بله: با عنوان بسته، `POST`، بدون IP، نگه‌داری ۹۰ روزه | متوسط تا زیاد (جدول + مهاجرت + ۲ مسیر + ۲ اپ + صفحه گزارش ادمین) |
اگر بگویی «قلم ۱ و ۲ را انجام بده، ۳ را فعلاً نه»، D3 با یک ردیف «عمداً تکرار
نشد» برای آمار بسته می‌شود و می‌رویم سراغ قلم بعدی فاز ۲.
+155
View File
@@ -0,0 +1,155 @@
# ماژول محصول (Product) — ادمین پنل
مستند تعریف محصول، تنوع (Variant) و ثبت اطلاعات در ادمین پنل، منطبق بر پیاده‌سازی فعلی.
مسیرهای مرتبط:
- فرم ثبت/ویرایش: [AddProduct.vue](../apps/admin-panel/src/views/apps/product/AddProduct.vue) (هم برای ایجاد هم ویرایش استفاده می‌شود؛ [EditProduct.vue](../apps/admin-panel/src/views/apps/product/EditProduct.vue) صرفاً یک wrapper است)
- لیست محصولات: [ProductList.vue](../apps/admin-panel/src/views/apps/product/ProductList.vue) و مدل جدول [productListModel.ts](../apps/admin-panel/src/views/apps/product/productListModel.ts)
- بک‌اند: [admin-products.controller.ts](../apps/api/src/modules/admin-products/admin-products.controller.ts) و [admin-products.service.ts](../apps/api/src/modules/admin-products/admin-products.service.ts)
- اسکیمای دیتابیس: [schema.prisma](../apps/api/prisma/schema.prisma) (مدل‌های `Product`, `ProductVariant`, `ProductImage`)
---
## ۱. تعریف محصول (Product)
هر محصول یک رکورد پایه دارد که در کارت «اطلاعات پایه» و «تصاویر محصول» فرم پر می‌شود.
### ۱.۱ اطلاعات پایه
| فیلد فرم (فارسی) | نام در فرم/پی‌لود | ستون در دیتابیس | نوع | الزامی | توضیح |
|---|---|---|---|---|---|
| کد محصول | `product_code` | `Product.productCode` | string، یکتا (`@unique`) | خیر | SKU نمایشی؛ در فرم اعتبارسنجی نمی‌شود ولی در دیتابیس یکتا است |
| نام محصول | `title` | `Product.title` | string | بله | تنها فیلد با اعتبارسنجی سمت فرم (`err.title`) |
| نوع نمایش | `show_type` | `Product.showType` | int، پیش‌فرض `3` | خیر | مقادیر: `1` سایت، `2` گوشی، `3` سایت و گوشی |
| دسته‌بندی | `category_id` | `Product.categoryId` (FK به `Category`) | string، اختیاری | خیر | از `GET category` پر می‌شود؛ عنوان‌ها با نوع فروشگاه (`type_str`) پسوند می‌خورند |
| برند | `brand_id` | `Product.brandId` (FK به `Brand`) | string، اختیاری | خیر | از `GET brand` |
| سینی‌ها (گروه‌بندی) | `ciny_ids` | جدول واسط `CollectionProduct` → مدل `Collection` | آرایه از id | خیر | چند‌انتخابی؛ معادل مدرن فیچر قدیمی «Ciny»، صرفاً یک تگ‌گذاری/گروه‌بندی محصول است، نه چیزی مرتبط با قیمت لحظه‌ای طلا |
| شرح محصول (سایت) | `body_html` | `Product.description` | text | خیر | |
| نمایش محصول | `is_active` | `Product.isActive` | boolean، پیش‌فرض `true` | خیر | فعال/غیرفعال بودن محصول در فروشگاه |
فیلدهای سیستمی که در فرم دیده نمی‌شوند: `id` (cuid)، `createdAt`.
> فیلدهای نسخه قدیمی که عمداً حذف شده‌اند (چون ستون معادل در اسکیمای جدید ندارند): `delivery_type`, `message_long_delivery`, `offer_day`, `has_warranty`, `is_exist`, `tags`، و فیلدهای رنگ/اجرت ریالی در تنوع. طبق کامنت بالای فایل فرم، این تصمیم آگاهانه بوده — داده جمع‌آوری و بی‌صدا دور ریخته نمی‌شود.
### ۱.۲ تصاویر محصول
محصول به ازای هر تصویر یک رکورد `ProductImage` دارد که با فیلد `role` مشخص می‌شود کدام یک از ۴ اسلات قدیمی است:
| بخش فرم (فارسی) | `ProductImageRole` | تعداد | فیلد پی‌لود |
|---|---|---|---|
| تصویر شاخص | `FEATURED` | تک | `indicator_id` |
| تصویر شاخص موبایل | `FEATURED_MOBILE` | تک | `mobile_pic` |
| گالری تصاویر سایت | `GALLERY` (پیش‌فرض) | چندتایی | `images` |
| گالری تصاویر موبایل | `GALLERY_MOBILE` | چندتایی | `images_mobile` |
آپلود از طریق کامپوننت `ImageUploadInput` و اندپوینت عمومی `file/create` انجام می‌شود (همان اندپوینتی که برای آپلود دسته‌بندی/اسلایدر/آواتار هم استفاده می‌شود). فیلد `imageBody` (کپشن هر تصویر گالری در نسخه قدیمی) معادلی در دیتابیس ندارد و حذف شده.
---
## ۲. کارکرد واریانت (تنوع محصول)
هر محصول می‌تواند چند «تنوع» (`ProductVariant`) داشته باشد — مثلاً وزن‌های مختلف یک مدل طلا. مدیریت واریانت‌ها **درون همان فرم محصول** و به‌صورت یک جدول این‌لاین با مودال افزودن/ویرایش انجام می‌شود؛ واریانت‌ها تا زمان «ثبت اطلاعات» فقط در حافظه (state) نگه داشته می‌شوند و همراه با کل محصول به سرور ارسال می‌گردند.
### ۲.۱ فیلدهای واریانت
| عنوان در مودال | نام فیلد | ستون دیتابیس | نوع | توضیح |
|---|---|---|---|---|
| عنوان | `title` | `ProductVariant.title` | string، الزامی | تنها فیلد با اعتبارسنجی (`عنوان تنوع الزامی است`) |
| وزن | `grams` | `ProductVariant.rashWeightGrams` | Decimal(14,4) | وزن پایه/خام پیش از احتساب اجرت («rash weight») |
| اجرت درصدی | `percentage_pay` | `ProductVariant.defaultWagePercent` | Decimal(6,3) | درصد اجرت پیش‌فرض روی وزن پایه |
| تعداد | `qty` | `ProductVariant.qty` | int، پیش‌فرض `0` | موجودی انبار |
| فعال | `is_active` | `ProductVariant.isActive` | boolean، پیش‌فرض `true` | |
| پیش سفارش | `is_pre_order` | `ProductVariant.isPreOrder` | boolean، پیش‌فرض `false` | اگر `true` باشد، حتی با موجودی صفر قابل سفارش است |
فیلدهای دیگر مدل `ProductVariant` که در فرم نیستند: `sku` (یکتا)، `id`. فیلد رنگ و اجرت ریالی نسخه قدیمی حذف شده‌اند (بدون ستون معادل).
> قاعده‌ی سبد خرید: اگر `qty = 0` و `isPreOrder = false` باشد، واریانت قابل افزودن به سبد نیست (منطق در `apps/admin-panel/src/stores/cart.ts`).
### ۲.۲ رفتار UI جدول تنوع‌ها
- ستون‌ها: عنوان، وزن، اجرت درصدی، تعداد، فعال (تگ سبز/قرمز)، پیش سفارش (تگ آبی/خالی)، عملیات.
- **افزودن تنوع**: دکمه بالای جدول → باز کردن مودال خالی با کلید موقت `__key` (برای ردیابی ردیف پیش از ذخیره در سرور).
- **ویرایش تنوع**: آیکون ویرایش هر ردیف → پر کردن مودال با مقادیر همان ردیف.
- **حذف تنوع**: آیکون حذف → صرفاً از آرایه‌ی حافظه حذف می‌شود؛ حذف واقعی از دیتابیس زمانی رخ می‌دهد که فرم اصلی «ثبت اطلاعات» شود.
- کل بخش تنوع‌ها پشت پرمیشن است (زیر بخش «ثبت اطلاعات» دقیق‌تر شرح داده شده).
---
## ۳. ثبت اطلاعات
### ۳.۱ فرم ایجاد/ویرایش
- یک فرم واحد (`AddProduct.vue`) هم برای ایجاد و هم ویرایش به کار می‌رود؛ تشخیص حالت از روی وجود `route.params.id`.
- **اعتبارسنجی سمت کلاینت**: فقط «نام محصول» (`title`) الزامی است؛ در سطح واریانت فقط «عنوان» الزامی است. سایر فیلدها اختیاری‌اند.
- **دکمه ارسال**: «ثبت اطلاعات» → `handleSubmit`.
### ۳.۲ اندپوینت‌ها
| حالت | متد | مسیر |
|---|---|---|
| ایجاد محصول جدید | `POST` | `product/create` |
| ویرایش محصول موجود | `PUT` | `product/update/:id` |
| نمایش یک محصول (پر کردن فرم ویرایش) | `GET` | `product/show/:id` |
| لیست برندها | `GET` | `brand` |
| لیست دسته‌بندی‌ها | `POST` | `category` |
| لیست سینی‌ها/کالکشن‌ها | `GET` | `admin/products/collections` |
### ۳.۳ ساختار پی‌لود ارسالی
```json
{
"title": "...",
"product_code": "...",
"show_type": 3,
"category_id": "...",
"brand_id": "...",
"ciny_ids": ["..."],
"body_html": "...",
"is_active": true,
"indicator_id": "url|null",
"mobile_pic": "url|null",
"cats": ["category_id"],
"images": ["url", "..."],
"images_mobile": ["url", "..."],
"variants": [
{
"id": "فقط در ویرایش واریانت موجود",
"title": "...",
"grams": 0,
"percentage_pay": 0,
"qty": 0,
"is_active": true,
"is_pre_order": false
}
]
}
```
نکات مهم:
- `cats` مشتق از `category_id` است (آرایه‌ای تک‌عضوی یا خالی) — یک artifact از انتقال ساختار قدیمی چند-دسته‌ای.
- `images` معادل گالری سایت و `images_mobile` معادل گالری موبایل است؛ تصاویر شاخص جدا (`indicator_id`, `mobile_pic`) ارسال می‌شوند.
- کل آرایه `variants` هر بار با کل درخواست ارسال می‌شود (نه اندپوینت جداگانه)؛ بک‌اند مسئول تطبیق/ایجاد/به‌روزرسانی ردیف‌ها بر اساس وجود یا نبود `id` است.
### ۳.۴ بازخورد به کاربر
- موفق: پیام `اطلاعات با موفقیت به ثبت رسید.` و ریدایرکت به `/apps/product/list`.
- خطا: پیام خطای برگشتی از سرور (`e.response.data.message`) یا پیام پیش‌فرض `خطا در ثبت اطلاعات.`
- در حین ارسال، کل فرم پشت `a-spin` (لودینگ) قفل می‌شود.
### ۳.۵ پرمیشن‌های مرتبط با ثبت/مدیریت واریانت
| کلید پرمیشن | کنترل می‌کند |
|---|---|
| `product.list.variants` | نمایش کل بخش «تنوع‌ها» در فرم محصول |
| `product.list.variants.view` | مشاهده (تعریف‌شده در `permissionConst.ts`؛ در UI فعلی فرم جداگانه چک نشده) |
| `product.list.variants.add` | نمایش دکمه «افزودن تنوع» |
| `product.list.variants.edit` | نمایش آیکون ویرایش هر ردیف تنوع |
| `product.list.variants.remove` | نمایش آیکون حذف هر ردیف تنوع |
ثبت خودِ محصول (فرم اصلی) پرمیشن جداگانه‌ای در این فایل چک نمی‌کند — دسترسی به صفحه از طریق روتینگ/منو کنترل می‌شود.
---
## ۴. لیست محصولات (خلاصه، برای زمینه)
جدول لیست (`admin/products/list`) این ستون‌ها را نشان می‌دهد: کد، تصویر محصول، نام محصول، برند، دسته‌بندی، کد محصول، نوع نمایش (با فیلتر انتخابی روی مقادیر ۱/۲/۳)، عملیات. تعریف کامل در [productListModel.ts](../apps/admin-panel/src/views/apps/product/productListModel.ts).
+538
View File
@@ -0,0 +1,538 @@
# پلن پورت اپ کاربر کلاسیک — henza_pwa → apps/user-app-classic
سند برنامه‌ریزی. **قبل از تأیید شما هیچ کدی نوشته نمی‌شود.**
> **مرجع پین‌شده:** `henza_pwa` در کامیت `b171fff` (2026-09-02، «fee in bonakdar removed»).
> هر ارجاع `path:line` در این سند به همین نسخه است و نسبت به `henza_pwa/` نوشته شده.
> منبع اصلی شواهد: `docs/parity/_inv-ref-pwa.md` (سیاهه‌ی کامل مرجع) به‌علاوه‌ی
> راستی‌آزمایی مستقیم روی کد. اگر مرجع عوض شد، این سند باید دوباره راستی‌آزمایی شود.
---
## خواسته
1. یک اپ کاربر **سوم** در مونوریپو، کنار `user-app` و `user-app-v2`.
2. طراحی آن = **طراحی قبلی همین سیستم**، یعنی `henza_pwa`.
3. همه‌ی صفحه‌ها، کارکردها، عملیات، انیمیشن‌ها و ناوبری **دقیقاً مثل مرجع**.
4. با **تکنولوژی جدید و کد بهتر**.
5. **هیچ چیزی از بین نرود.**
---
## قرارداد «دقیقاً مثل مرجع»
| لایه | قاعده |
|---|---|
| **آنچه کاربر می‌بیند و انجام می‌دهد** | **عیناً مرجع.** چیدمان و ابعاد، رنگ‌ها، فونت، آیکون‌ها، متن‌های فارسی (بایت‌به‌بایت — حتی فاصله‌ی دوتایی بعد از «لطفا» و غلط املایی «کارکتر»)، ارقام، ترتیب و برچسب تب‌ها، پیام‌های اعتبارسنجی، رنگ و مدت toast ها، پارامترهای انیمیشن، مسیرها و نام route ها، رفتار guard ها، محاسبه‌ها و گرد کردن‌ها. |
| **زیر کاپوت** | **بازنویسی‌شده و بهتر.** TypeScript با `strict`، Pinia، composable ها، یک لایه‌ی HTTP تایپ‌شده، تنظیمات از env، حذف کد مرده و تکراری، تست. |
| **کوییرک بصری بی‌خطر** | **عیناً تکرار می‌شود** — مثلاً notify ورود که حتی برای موفقیت قرمز است، یا تشدید سرگردان در «ّکاربر فعال شود؟». |
| **استثنا** | **فقط خرابی‌های کارکردی مرجع، طبق D4.** هر کدام شماره دارد، در §تنش ۴ و §چیزی که پورت نمی‌شود فهرست شده، و هیچ‌کدام بی‌صدا نیست. |
«هیچ چیزی از بین نرود» دو معنا دارد و هر دو تعهد این پلن است:
1. **هیچ صفحه، کارکرد، انیمیشن یا متنی از مرجع جا نمی‌ماند** — با سرشماری خودکار اثبات می‌شود (§معیار تمام شد).
2. **هیچ چیز موجود در مونوریپو آسیب نمی‌بیند** — `user-app`، `user-app-v2` و `admin-panel` دست نمی‌خورند؛ تغییرات `apps/api` فقط افزایشی است.
---
## قواعد حاکم (ارث‌برده از پروژه)
1. `henza_pwa/`، `henza_server/` و `henza_admin/` **فقط خواندنی‌اند.** برای اجرای مرجع یک کپی بیرون از آن‌ها ساخته می‌شود؛ حتی `node_modules` هم داخلشان نصب نمی‌شود.
2. **D4** — رفتار تکرار می‌شود، خرابی نه. هر عدم‌تکرار عمدی در ماتریس: «عمداً تکرار نشد — ISSUE #n».
3. **D2** — چیزی که مرجع ندارد ساخته نمی‌شود. یعنی اپ کلاسیک صفحه‌ی اعلان‌ها، تنظیمات ظاهر و تب پنجم ناوبری را **ندارد**، چون مرجع ندارد.
4. **PROTECTED.md** — `user-app` و `user-app-v2` دست نمی‌خورند. حتی بازساختاردهی (مثلاً بیرون کشیدن کد مشترک) تأیید جدا می‌خواهد.
5. **D13** — سطوح فروشگاه تک‌فروشی (کیف پول، cashback، قیمت محصول طبق D16) دست نمی‌خورند.
6. **D12** — ماتریس فیلد به فیلد، و تست رفت‌وبرگشت برای هر موجودیتی که endpoint نوشتنی تازه می‌گیرد.
7. **D18** — تأیید دائمی برای شکاف‌های بی‌ابهام؛ توقف فقط برای موارد فهرست D18.
8. **T1 / D21** — مشتری همیشه از توکن، هرگز از بدنه یا URL؛ ادعای `typ`؛ تست توکن متقابل.
9. **D25** — خواندن env فقط با `envOr` / `envOrNull`.
10. **D15** — تست‌هایی که ورودی فارسی دارند، ورودی را از فایل UTF-8 می‌خوانند.
> ⚠️ **`CLAUDE.md` در این مخزن نیست.** اسناد parity مدام به §2، §3، §4 و §6 آن ارجاع می‌دهند،
> ولی فایل در ریشه‌ی workspace قبلی بوده. قواعدش از روی همین ارجاع‌ها بازسازی شده:
> §2 منطق اپ‌های کاربر باید یکسان باشد و فقط طراحی فرق کند؛ §3 باگ مرجع تکرار و ثبت شود،
> مگر حفره‌ی امنیتی در جریان پول — آنجا توقف و سؤال؛ §4 PROTECTED فقط افزایشی؛
> §6 هر حذف داده در CHANGELOG ثبت شود.
> **پیشنهاد: نسخه‌ی اصلی را به مخزن برگردانید** تا متن اصلی جای این بازسازی را بگیرد.
---
## دامنه‌ی واقعی کار (اعداد راستی‌آزمایی‌شده)
| سنجه | عدد | نحوه‌ی راستی‌آزمایی |
|---|---|---|
| route | **22** (۲۱ واقعی + catch-all) | `_inv-ref-pwa.md` §3 |
| فایل `.vue` غیر vendored | **77** فایل، **12,445** خط | `find src -name '*.vue'` بدون دو پوشه‌ی DatePicker |
| فایل‌های `views/` | 34 — سه‌تا مرده: `login.vue` (کل فایل کامنت)، `user/src/components/report.vue` (صفحه‌ی دیباگ)، `NotificationList.vue` (سوار نشده) | §6.1 |
| کامپوننت اپ | 40 | §9 |
| store | ۹ singleton دست‌ساز + ۱ Pinia | §7 |
| endpoint یکتا | **44** در ۵۸ محل فراخوانی → **۳۹ ردیف کارکردی زنده** بعد از کنار گذاشتن مرده‌ها | §2.4 + §نقشه‌ی endpoint در همین سند |
| پوشش API هدف از آن ۳۹ ردیف | ✅ **12** · 🟡 **9** · ❌ **18** | §نقشه‌ی endpoint |
| `@keyframes` | 8 | grep |
| محل `<transition>` | ۷ زنده + ۲ کامنت‌شده (از جمله **انتقال بین صفحه‌ها**) | grep |
| GSAP / `useTransition` | ۱ / ۱ — هر دو در ناوبری پایین | `navigation.vue:83,110` |
| overlay | ۲۰ `Drawer`، ۳ `Modal`، ۲ `van-action-sheet` | grep |
| asset | ۲۴ svg، ۹ png، ۱ jpg، ۱ ico، فونت Yekan Bakh FaNum (۸ وزن × ۳ فرمت)، `icons.json` | `find src/assets public` |
| نقص شناخته‌شده‌ی مرجع | 20 | §13 |
---
## «تکنولوژی جدید» — چه چیزی عوض می‌شود و چه چیزی نه
### کتابخانه‌های UI روی نسخه‌ی lockfile مرجع قفل می‌شوند
**یافته:** `yarn.lock` مرجع خودش Vue **3.5.40** را resolve کرده. پس «تکنولوژی جدید» در
کتابخانه‌های UI معنایی ندارد — و نباید داشته باشد، چون ظاهر پیکسلی دقیقاً به همین نسخه‌ها
وابسته است. مثلاً ant-design-vue 4 استایل را به CSS-in-JS برده و ظاهر عوض می‌شود.
| بسته | نسخه‌ی دقیق (بدون `^`) |
|---|---|
| `ant-design-vue` | 3.2.20 |
| `vant` | 4.10.0 — v1/v2 روی 4.10.2 هستند؛ اپ کلاسیک نسخه‌ی مرجع را می‌گیرد |
| `swiper` | 7.4.1 |
| `gsap` | 3.15.0 |
| `tailwindcss` | 3.0.18 |
| `@vueuse/core` | 9.13.0 |
| `moment-jalaali` | 0.10.4 |
| `@lottiefiles/dotlottie-vue` | 0.4.18 |
| `vue3-lazyload` | 0.2.5-beta |
| `vue` / `vue-router` | 3.5.x / 4.x — همان خانواده‌ی lockfile |
**ارقام فارسی از فونت می‌آیند، نه از تبدیل.** مرجع هیچ تبدیل رقمی ندارد (§8.1)؛ ارقام
به‌خاطر فونت FaNum فارسی دیده می‌شوند. اپ کلاسیک هم تبدیل رقم اضافه نمی‌کند.
### آنچه واقعاً نو می‌شود
| مرجع | اپ کلاسیک | چرا |
|---|---|---|
| Vite 3 + plugin-vue 2، TS 4.4 بدون strict، vue-tsc 0.29 | Vite 5، TS 5 با `strict`، vue-tsc 2 — همان زنجیره‌ی v1/v2 | ابزار نگهداری‌شده؛ خطا در زمان build |
| axios 0.25 با سه instance؛ interceptor خطا promise را برنمی‌گرداند؛ response interceptor ندارد | یک client تایپ‌شده؛ توکن در هر درخواست؛ رفتار 401 مرجع (`myStore.init()`) به‌صورت سراسری | نقص #1 و #2 |
| ۹ singleton کلاسی دست‌ساز | Pinia — **با همان شکل state و همان getter های readonly** | یکدستی با v1/v2 |
| Mixin ها (`getProducts`، `product`، `favorite`، ...) | composable های تایپ‌شده با تست واحد | قابل تست |
| URL و کلید هاردکد در ۵ فایل | متغیرهای `VITE_*` | پیکربندی محیط‌ها |
| دو درخت vendored از DatePicker (fork از ant-design-vue) | `generatePicker` عمومی ant-design-vue 3 با config جلالی — **به شرط اثبات ظاهر یکسان در Phase 0** | حذف هزاران خط کد شخص ثالث |
| ۵ کتابخانه‌ی تاریخ | `moment-jalaali` برای فرمت‌ها (خروجی یکسان) + dayjs/jalaliday فقط برای picker | کمتر، با تست خروجی |
| ۴ کامپوننت تقریباً یکسان `ScrollFetch*` | یک کامپوننت با prop | تکرار |
| `addTransaction.vue` و `addFindoc.vue` تکراری | یک فرم، دو پوسته (صفحه و drawer) | تکرار |
| کد مرده (§چیزی که پورت نمی‌شود) | پورت نمی‌شود | — |
| PWA در `mode: "development"` | production | نقص #17 |
| بدون تست | تست واحد (adapter، formatter، validator، موتور تخفیف) + e2e و مقایسه‌ی تصویری با Playwright | اثبات «دقیقاً» |
---
## تنش‌هایی که قبل از شروع باید تصمیمشان را بدانید
### تنش ۱ — ۱۸ endpoint مرجع در API هدف نیست
از ۳۹ ردیف زنده، **۱۸ تا در هدف معادل ندارند و ۹ تا ناقص‌اند** (§نقشه‌ی endpoint). بدون آن‌ها
«دقیقاً مثل مرجع» ممکن نیست. وضعیت امروز v1/v2 این را نشان می‌دهد:
- ثبت‌نام **هیچ چیزی ذخیره نمی‌کند** — کامنت خود `RegisterView.vue:17` این را می‌گوید.
- علاقه‌مندی‌ها **فقط محلی‌اند** (`favoritesStore.ts:1-3`).
- ثبت سند، سفارش ساخت، مدیریت مشتری توسط کارشناس، ویرایش سفارش مشتری، آنالیتیکس و فاکتور اصلاً وجود ندارند.
ساختن این‌ها در `apps/api` افزایشی است (route و جدول/ستون جدید). **ولی با `CLAUDE.md` §2
تعارض دارد:** منطق اپ‌های کاربر باید یکسان باشد. به محض اینکه اپ کلاسیک ثبت‌نام و
علاقه‌مندی واقعی داشته باشد، v1 و v2 عقب می‌افتند.
**پیشنهاد:** بک‌اند **یک بار**، افزایشی و تست‌شده ساخته شود. وصل کردن v1/v2 به آن یک کار
جدا و ثبت‌شده باشد — نه بخشی از این پروژه. **تصمیم شما لازم است** که §2 شامل اپ کلاسیک
هم می‌شود یا نه.
### تنش ۲ — «انتخاب مشتری»: کار کردن به‌جای مشتری
**در مرجع:** کارشناس فروش (type 1) روی یک مشتری می‌زند → `toggleActiveUser` توکن خود آن
مشتری را به‌عنوان `activeChildToken` ذخیره می‌کند، و این توکن **روی همه‌ی درخواست‌ها
اولویت دارد** (`store/tokenManage.ts:8-10`، `store/myDataStore.ts:159-169`). از آن لحظه کل اپ
به‌جای مشتری کار می‌کند: میز کار موجودی مشتری را نشان می‌دهد، سفارش سبد روی حساب مشتری
با `from_parent: true` ثبت می‌شود تا پیامک به کارشناس برسد (`views/cart.vue:162-164`)،
و دکمه به «خروج از حساب مشتری» تبدیل می‌شود.
**در هدف:** D21 **سازوکار** را رد کرد — ساختن توکن مشتری برای کارشناس یک ابزار ربودن نشست
است. `CustomerChoiceView` در v1 با لمس کارت فقط یک drawer جزئیات باز می‌کند
(`openDetail`). پس **خود این قابلیت** امروز در هدف وجود ندارد.
**پیشنهاد:** همان قابلیت، با سازوکار امن:
- توکن خود کارشناس + هدر `X-Acting-As: <customerId>`.
- سرور در **هر درخواست** چک می‌کند `referredByAgentId` آن مشتری = همین کارشناس.
- موضوع همه‌ی route های مشتری = آن مشتری.
- سفارش با پرچم `from_parent` ثبت می‌شود.
- هر تعویض در لاگ ثبت می‌شود.
- **هیچ توکنی ساخته نمی‌شود** و سمت کلاینت فقط شناسه‌ی مشتری انتخاب‌شده نگه داشته می‌شود.
**تأیید شما لازم است**، چون امنیت و جریان پول است (`CLAUDE.md` §3). این کار ریسک REL-03 را هم
بالاتر می‌برد — §پیش‌نیازها.
### تنش ۳ — نقش کارگاه (type 5) در اپ
**در مرجع:** سرپرست کارگاه یک ردیف مشتری با type 5 است. کاشی «سفارش ساخت» برایش مستقیم به
صفحه‌ی وضعیت می‌رود و دکمه‌های «تایید سفارش» و «ارسال سفارش» را می‌بیند
(`makeOrderStatus.vue:68-75`).
**در هدف دو نامزد هست:**
- `AdminUser` با نقش `WORKSHOP_SUPERVISOR` — `Workshop.supervisorId` به اینجا اشاره می‌کند و `PreOrder.workshopId` به `Workshop`.
- `Customer` با `kind = WORKSHOP_SUPERVISOR` — این مقدار در `CustomerKind` هست و برخلاف `SALES_AGENT` منسوخ اعلام نشده.
D21 فقط به `SALES_EXPERT` اجازه‌ی ورود به اپ‌ها را داده.
**پیشنهاد:** `AdminUser`، چون مسیر داده‌ای تمیز همین است. الگوی D21 به `WORKSHOP_SUPERVISOR`
گسترش پیدا کند، با دامنه‌ی محدود به `PreOrder` های کارگاه خودش. **تصمیم شما لازم است**،
از جمله اینکه مشتری با `kind = WORKSHOP_SUPERVISOR` در این صورت چه نقشی دارد.
### تنش ۴ — خرابی‌های مرجع که به منطق کسب‌وکار می‌خورند
D18 برای این‌ها توقف می‌خواهد (مورد ۴). شماره‌ها از `_inv-ref-pwa.md` §13 است.
| # | خرابی در مرجع | اثر برای کاربر | پیشنهاد من |
|---|---|---|---|
| 4 | دکمه‌ی ثبت در صفحه‌ی `addTransaction` بیرون از `<Form>` است و `html-type="submit"` ندارد (`addTransaction.vue:84-97`) | ثبت سند از **صفحه** کار نمی‌کند؛ فقط از drawer | درست شود — یک فرم مشترک، هر دو پوسته کار کنند |
| 5 | کاشی «سفارش مشتریان» تابع را صدا نمی‌زند (`Dashboard.vue:119`) | کارشناس از میز کار به آن صفحه نمی‌رسد | درست شود — D3 خود صفحه را خواسته |
| 6 | کادرهای ته‌حساب طلایی و ریالی اندیس‌ها و formatter ها را جابه‌جا خوانده‌اند (`Dashboard.vue:182-192`) | موجودی طلا و ریال در کادر اشتباه و با قالب اشتباه | هر کادر مقدار درست را با قالب متناسب با برچسبش نشان دهد |
| 8 | دکمه‌ی به‌روزرسانی قیمت، فهرستی را تازه می‌کند که نمایش داده نمی‌شود (`store.ts:107` در برابر `Dashboard.vue:632`) | به‌روزرسانی بی‌اثر | **قبلاً تصمیم گرفته شده (D4):** فهرست دیده‌شده تازه شود |
| 9 | «ارسال سفارش» همان `{sts: 2}` «تایید سفارش» را می‌فرستد (`makeOrderStatus.vue:212,234`) | ارسال در سرور ثبت نمی‌شود | تایید → «تایید کارگاه»، ارسال → «ساخته شده آماده ارسال». مقدار دقیق `PreOrderStatus` را شما تعیین کنید |
| 12 | radio «حواله به» **اندیس** آرایه را می‌فرستد نه متن مقصد (`addTransaction.vue:50`). سرور مرجع کل بدنه را بی‌تغییر ذخیره می‌کرد (`ApiMobileController::setFindoc`) | مقصد حواله به‌صورت عدد ذخیره می‌شد | سرور اندیس را به متن مقصد نگاشت کند؛ UI بی‌تغییر |
| 14 | در `editUser` سه آپلودر مدرک شناسه را دور می‌ریزند و دو تا یک `fileList` مشترک دارند (`editUser.vue:65,69`) | آپلود «موفق» دیده می‌شود ولی چیزی ذخیره نمی‌شود | **عیناً تکرار شود و ثبت شود** — وصل کردنش داده‌ی احراز را از صفحه‌ای عوض می‌کند که مرجع هرگز اجازه نداده بود |
| 15 | شرط infinite scroll گزارش `tableData.value.count` را می‌خواند که هرگز مقدار نمی‌گیرد (`report/index.vue:234`) | فقط صفحه‌ی اول گزارش دیده می‌شود | درست شود — endpoint های D24 صفحه‌بندی دارند |
| 19 | پسوند ` تومان ` روی فیلدی به نام `rial_remain` (`filters/index.ts:13`)؛ ستون هدف `fiatDebtRial` به ریال است | اگر واحدها نخوانند، بدهی ده برابر کم یا زیاد خوانده می‌شود | اول در `henza_server` واحد واقعی `rial_remain` معلوم شود؛ بعد adapter **همان عدد و برچسبی** را نشان دهد که مرجع برای همان بدهی نشان می‌داد |
| — | `singleProduct` دکمه‌ی back را می‌رباید: `history.pushState` + `window.onpopstate = () => history.go(1)`، سراسری و هرگز برداشته نمی‌شود (`singleProduct.vue:376-379`) | بعد از دیدن اولین محصول، back مرورگر یا گوشی **در کل اپ** از کار می‌افتد | محدود به خود صفحه‌ی محصول — نیت ظاهری کد همین است، نشتش به کل اپ خرابی است |
### تنش ۵ — سرور هدف از مرجع سخت‌گیرتر است
سرور مرجع تقریباً هر چیزی را قبول می‌کرد. سرور هدف بیشتر رد می‌کند: قاعده‌ی موجودی و
پیش‌سفارش (PROTECTED §C.1)، MON-05 وقتی اجرا شود، DTO های اعتبارسنجی، قیدهای یکتایی.
ولی UI مرجع در بیشتر خطاها **هیچ چیزی نشان نمی‌دهد** — فقط `console.log(e)`، مثلاً در
`Register.vue:340-342`، `cart.vue:189-191` و ثبت سند. پس درخواستی که مرجع بی‌صدا قبول می‌کرد،
در اپ کلاسیک بی‌صدا شکست می‌خورد و کاربر گیج می‌شود.
| مورد | پیشنهاد من |
|---|---|
| **سقف موجودی:** سبد مرجع سقف ندارد؛ قاعده‌ی موجودی هدف (`capQty`/`maxQty` در v1) وجود دارد | سقف **بی‌صدا** در دکمه‌ی `+` اعمال شود — بدون هیچ عنصر یا متن تازه |
| **خطاهای بی‌صدا** | هر جا مرجع بی‌صدا بوده بی‌صدا بماند، **مگر** جایی که سرور هدف ردی می‌کند که مرجع هرگز نداشت. آنجا نزدیک‌ترین پیام **موجود** مرجع نشان داده شود (مثلاً «خطا در ثبت سفارش» از `makeOrder.vue:229`). هیچ متن تازه‌ای ساخته نمی‌شود |
### تصمیم‌های کوچک‌تر
| # | سؤال | پیشنهاد من |
|---|---|---|
| Q1 | نام، مسیر و پورت اپ | `apps/user-app-classic`، پورت **5183** (`USER_APP_CLASSIC_PORT`)، سرویس docker با همین نام. «classic» و نه «v3»، چون v3 «جدیدترین» را القا می‌کند در حالی که این قدیمی‌ترین طراحی است |
| Q2 | کد مشترک بین سه اپ | فعلاً داخل خود اپ در `src/core/` (api، adapter، قواعد دامنه)، با مرز تمیز تا بعداً قابل استخراج باشد. پکیج مشترک یعنی بازساختاردهی v1/v2 → PROTECTED → تصمیم جدا |
| Q3 | فیلتر رنگ در جست‌وجو — هدف اصلاً داده‌ی رنگ ندارد (`Product` و `ProductVariant` فقط `brand` دارند) | مدل افزایشی `Color` و ارتباطش با variant؛ ویرایشش در پنل ادمین کار جداگانه است. تا آن موقع فهرست خالی — همان چیزی که مرجع با داده‌ی خالی نشان می‌داد |
| Q4 | تابلوی قیمت میز کار: مرجع ۱۴ ردیف دارد (۸ طلا و سکه + ۶ ارز و انس، `store.ts:118-119`)؛ هدف فقط نرخ طلا به ازای عیار | ردیف‌هایی که هدف داده‌ی واقعی برایشان دارد نمایش داده شوند؛ بقیه حذف (قاعده‌ی صداقت، مثل REP-01) تا منبع قیمت تأیید شود. منبع خارجی = تصمیم شما |
| Q5 | ترتیب دسته‌های خانه: آرایه‌ی هاردکد `[37, 45, 31, 32, 42, 26, 36, 35, 34]` با شناسه‌ی عددی legacy (`home/index.vue:189-204`)؛ شناسه‌ی `Category` در هدف cuid است و نه legacyId دارد نه ستون ترتیب | ستون افزایشی ترتیب روی `Category` و پر کردنش از نگاشت مهاجرت. پر کردن = تغییر داده‌ی موجود → D18 توقف #5 → تأیید لازم |
| Q6 | آنالیتیکس مهمان: مرجع برای مهمان هم لاگ می‌فرستد؛ `CustomerActivityLog.customerId` اجباری است و ستون پایان ندارد | فقط کاربر واردشده لاگ شود؛ ستون افزایشی `endedAt` برای معادل `log/update` |
| Q7 | اعلان‌ها، FCM و پرکردن خودکار OTP — هر سه در مرجع سوار نیستند یا غیرفعال‌اند | ساخته نمی‌شوند — دقیقاً مثل مرجع |
| Q8 | اجرای مرجع برای مقایسه‌ی تصویری | کپی در `.parity/ref-pwa/` (gitignored) و رهگیری شبکه با Playwright؛ خود مرجع دست نمی‌خورد |
| Q9 | وضعیت D19 (حالت تحویل) | این پلن فرض می‌کند D19 تمام شده و D18 برقرار است |
---
## پیش‌نیازهای باز از کارهای قبلی
| مورد | چرا این اپ به آن نیاز دارد | Batch |
|---|---|---|
| **D9 قدم ۵** — جابه‌جایی `filterPercentPay` به اندازه‌ی آفست مشتری | اسلایدر اجرت در فیلتر جست‌وجو | B |
| **شکاف یکتایی شماره بین `Customer` و `AdminUser`** — هر چهار بررسی تداخل فقط `adminUser` را چک می‌کنند؛ در `ISSUES.md` ثبت نشده | ثبت‌نام و «افزودن کاربر» هر دو مشتری می‌سازند | A، F |
| **MON-08** — سرور اجرت و تخفیف را خودش بسازد | ثبت سفارش از سبد | E |
| **MON-05** — اجرای `canPlaceOrders` و `verificationStep` | مرجع فقط در کلاینت چک می‌کند (`sts_verify_step_two === 2`) | E |
| **REL-03** — مقدار جدای `JWT_CUSTOMER_SECRET` | کار به‌جای مشتری (تنش ۲) ریسک آن را بیشتر می‌کند | قبل از F |
---
## نقشه‌ی endpoint ها — مرجع ↔ هدف
✅ موجود (فقط adapter شکل) · 🟡 ناقص · ❌ وجود ندارد
| # | مرجع | هدف امروز | وضعیت | Batch |
|---|---|---|---|---|
| 1 | `POST /web/auth` | `POST /customer/auth/request-otp` | ✅ | A |
| 2 | `POST /user-verfiy` | `POST /customer/auth/verify-otp` | ✅ — رد ورود برای `sts_verify_step_one == 3` بررسی شود | A |
| 3 | `POST /major/register` | — | ❌ | A |
| 4 | `POST /major/complete` | `POST /customer/me/complete-profile` | ✅ (D22) | A |
| 5 | `POST file/create` | `POST /customer/me/documents` | ✅ با `purpose` | A، D، G |
| 6 | `POST /me-data` | `GET /customer/me` | 🟡 هیچ‌کدام از `filterBrand`، `filterColor`، `filterGram`، `filterPercentPay`، `favorits`، `to`، `reagent`، `grams_sell`، `mcode` را ندارد | Phase 2 |
| 7 | `POST /front` | `GET /catalog/products` | 🟡 دسته‌ها، `more` و `ciny` آماده نیستند | B |
| 8 | `POST /get-products` (Query) | `GET /catalog/products` | 🟡 فیلتر و صفحه‌بندی سمت سرور ندارد (STUB-03) | B |
| 9 | `POST /get-products` (`products_id`) | `GET /catalog/products` | 🟡 | B، E |
| 10 | `GET /ciny/{cat}` | داخل catalog (`collections`) | 🟡 | B |
| 11 | `GET /product/{id}` | — فقط فهرست | 🟡 | B |
| 12 | `GET /config` | `GET /home/slider` | 🟡 `about` ندارد | B |
| 13 | `GET /promotion` | — | ❌ | B |
| 14 | `GET /ignore` | — | ❌ | B |
| 15 | `GET /price` | `GET /pricing/latest` | 🟡 فقط طلا به ازای عیار (Q4) | C |
| 16 | `GET /tahesab/me` | `GET /customer/me/report/balances` | ✅ (D24) | C |
| 17 | `POST /add-to-fav` | — | ❌ (v1/v2 محلی) | D |
| 18 | `POST /get-favorites` | — | ❌ | D |
| 19 | `POST /get-orders` | `GET /customer/me/orders` | ✅ فیلتر `sts` در کلاینت | D |
| 20 | `POST /update-profile` | — | ❌ | D |
| 21 | `POST /get-my-findoc` | `GET /customer/me/ledger-entries` | 🟡 شکل متفاوت | D |
| 22 | `POST /set-findoc` | — | ❌ | D |
| 23 | `GET /fin_tahesab/table` | `GET /customer/me/report` | ✅ (D24) | D |
| 24 | `GET /fin_tahesab/my_detail/{offset}` | `GET /customer/me/report/detail` | ✅ | D |
| 25 | `GET /fin_tahesab/detail/{code}` | `GET /customer/me/report/:id` | ✅ | D |
| 26 | `report/order?order_id` — فاکتور با `window.open` | — | ❌ | D |
| 27 | `report/doc?doc_id` — فایل سند | — | ❌ | D |
| 28 | `POST /set-order` | `POST /orders` | ✅ (MON-08) | E |
| 29 | `POST /my-child` | `POST /expert/my-customers` | ✅ | F |
| 30 | `POST /customer/toggle-active` | — | ❌ | F |
| 31 | `POST /customer/create` | — | ❌ | F |
| 32 | `POST /customer/update` | — | ❌ | F |
| 33 | `GET admin/major/list_type` | — | ❌ از `CustomerKind` ساخته می‌شود | F |
| 34 | `POST /order/child` | `POST /expert/child-orders` | ✅ | F |
| 35 | `PUT /order/child/{id}` | — | ❌ | F |
| 36 | `POST /create-custom-order` | — مدل `PreOrder` فقط در ادمین | ❌ | G |
| 37 | `GET /get-custom-orders` | — | ❌ | G |
| 38 | `POST /update-custom-order/{id}` | — | ❌ | G |
| 39 | `POST /log/create/{key}` + `GET /log/update/{id}` | — | ❌ | G |
**ساخته نمی‌شوند (در مرجع مرده‌اند):** `POST /hesab_detail/table` (هرگز صدا زده نمی‌شود)،
تکرار `GET /ignore` در `getCustomerListType`، تکرار مرده‌ی `toggle-active` در Dashboard،
`POST /cities/list` (داده گرفته می‌شود ولی UI آبشاری کامنت است و فیلد شهر متن آزاد است —
`Register.vue:58-64`)، و سه endpoint اعلان (کامپوننتشان سوار نیست).
---
## مشخصات حس و حرکت — انیمیشن‌ها و ناوبری
این بخش همان چیزی است که یک پورت سرسری از دست می‌دهد. هر ردیف در Phase 0 با ضبط frame
اثبات می‌شود.
### انیمیشن‌ها
| # | انیمیشن | پارامتر دقیق | محرک | شاهد |
|---|---|---|---|---|
| 1 | فرورفتگی (notch) ناوبری پایین | `gsap.to('.from-path', {duration: .3, attr: {d: draw()}})` | تغییر تب | `components/ui/navigation.vue:50-86` |
| 2 | جابه‌جایی آیکون فعال | `useTransition` با `TransitionPresets.easeInOutCubic`، ‏150ms | تغییر تب | `navigation.vue:110-117` |
| 3 | **shutter هدر** | `@keyframes shutter` در 1s با `cubic-bezier(0.33, 1, 0.68, 1)`؛ `max-height` از 0 تا 120px و `opacity` از 0 تا 1 | **هر تغییر route**: `afterEach` پرچم را false و بعد از 100ms دوباره true می‌کند | `components/appHeader.vue:1-45`، `router/index.ts:273-286` |
| 4 | فاصله‌ی محتوا از هدر | `margin-top = headerHeight` وقتی `transitionEnded` | همراه ۳ | `components/content.vue:2` |
| 5 | **انتقال بین صفحه‌ها** | **ندارد.** `<transition>` در `App.vue:6,17` کامنت است. CSS `slideDown` (`App.vue:151-166`) و `setPgaTransition` زنده‌اند ولی به هیچ چیز وصل نیستند | — | **اضافه نمی‌شود** |
| 6 | باز شدن بخش فیلتر | `@keyframes drop` با `mode="out-in" appear` | باز و بسته کردن | `components/dropDown.vue:11,40` |
| 7 | تعویض تصویر گالری | `slideUp` با `mode="out-in"` | تغییر تصویر یا تنوع | `views/singleProduct.vue:28`، `components/slider/slider.vue:22` |
| 8 | اسپینر | `@keyframes rotate` و `rotateOpacity` (سه حلقه) | بارگذاری | `components/spinner.vue:104,115` |
| 9 | ریل سفارش ساخت | `@keyframes gradientMove` و `colorChange` روی رابط مرحله‌ی جاری | نمایش وضعیت | `makeOrderStatus.vue:283,295` |
| 10 | دیالوگ‌ها و notify | قواعد `animation:` | باز شدن | `DialogVant.vue`، `DialogVantOneButton.vue`، `NotifyVant.vue` |
| 11 | فهرست اسناد | `TransitionGroup name="list"` + کوییرک `testBind` (#13) | بارگذاری فهرست | `transactions.vue:17,177-205` |
| 12 | notify قدیمی | `@keyframes notify-in` | — | `customNotification.vue:47`، `Notification.vue:81` — **اول بررسی شود سوار هستند یا نه** |
| 13 | اسلایدر خانه | Swiper با `loop` و `autoplay` | خودکار | `slider/homeSlider.vue:7,10` |
| 14 | اسلایدر محصول | Swiper با ماژول `Zoom`، بدون loop | لمس | `slider/slider.vue:43-45` |
| 15 | overlay ها | انیمیشن خود کتابخانه برای ۲۰ Drawer، ۳ Modal و ۲ action-sheet | — | با قفل نسخه‌ها تضمین می‌شود |
| 16 | دیالوگ نسخه‌ی جدید | Lottie | تغییر `version` | `App.vue:22-38` |
### زمان‌بندی‌ها
| چه چیزی | مقدار | شاهد |
|---|---|---|
| دیالوگ تشویق به ورود در خانه | 5 ثانیه، **در هر بازدید** — گارد «فقط یک بار» کامنت شده | `home/index.vue:210-216` |
| شمارش معکوس OTP | 120 ثانیه، با صفر پیشرو زیر 10 | `Login.vue:122-131,187-189` |
| toast ها | 4000ms | `Dashboard.vue:269-284` و بقیه |
| debounce جست‌وجو | 700ms | `components/search/SearchInput.vue` |
| بازگرداندن پرچم انتقال هدر | 100ms | `router/index.ts:273-286` |
### ناوبری
- **۲۲ route با همان path، name و meta**، از جمله حروف و نام‌های عجیب: `/ChildOrder`، `/customerChoice`، `/addTransaction`، `/makeOrder_status`، `/complete_profile`، `userPage::hasSubRoute`. خانه `/home` است و `/` به آن redirect می‌شود.
- **`beforeEach`** همان شش قدم `_inv-ref-pwa.md` §5.1، با تصمیم‌های D3:
- مهمان روی پنج route محافظت‌شده → toast «ابتدا وارد شوید» + خانه.
- کاربر واردشده روی `/login` یا `/` → خانه.
- تله‌ی cold-boot (`isLoaded`) — D3 گفته تکرار شود.
- `completeProfile` بسته است وقتی `sts_verify_step_two ∈ {1, 2}`.
- route هایی که مرجع در guard ندارد (`addTransaction`، `makeOrderList`، `makeOrderStatus`، `addUser`، `editUser`، `ChildOrder`، `customerChoice`، و `report`/`reportDetail` که خودشان redirect می‌کنند) در اپ کلاسیک هم در guard نیستند؛ سرور هدف به‌هرحال 401 می‌دهد.
- **ناوبری پایین:** چهار تب به ترتیب index — کاربر (۱)، سبد (۲)، جست‌وجو (۳)، خانه (۴)؛ پیش‌فرض فعال ۴. فقط روی همین چهار route دیده می‌شود، و وقتی پنل فیلتر باز است پنهان می‌شود.
- **`keep-alive` + `suspense` با کلید `route.name`** — هر صفحه‌ی دیده‌شده mount می‌ماند (مثلاً نتایج جست‌وجو بعد از برگشت حفظ می‌شوند). این بخشی از حس ناوبری مرجع است و حفظ می‌شود.
- `scrollBehavior`: بازگرداندن `savedPosition`، وگرنه بالای صفحه.
- layout `FullPage` (بدون ناوبری پایین) برای همان ۸ route مرجع؛ بقیه `Master`.
- ربودن دکمه‌ی back در صفحه‌ی محصول — تنش ۴.
---
## ساختار فازها
### Phase 0 — تصمیم‌ها و زیرساخت اثبات (بدون کد اپ)
1. پاسخ شما به تنش‌های ۱ تا ۵ و Q1 تا Q9.
2. **اجرای مرجع:** کپی `henza_pwa@b171fff` در `.parity/ref-pwa/` (gitignored)، `yarn install --frozen-lockfile`، اجرا روی پورت 3030. اگر toolchain قدیمی روی Node فعلی بالا نیاید → ریسک R1.
3. **یک مجموعه‌داده با دو خروجی:** JSON به شکل API مرجع (برای رهگیری درخواست‌های `https://henzagold.com/oapi/**` در Playwright) و seed دیتابیس برای API هدف. یک منبع، دو projection — تا هر دو اپ دقیقاً یک داده را نشان دهند.
4. **پرسوناها:** مهمان؛ مشتری تأییدنشده؛ مشتری تأییدشده (`sts_verify_step_two = 2`)؛ بنکدار (type 4)؛ کارشناس فروش (type 1)، و کارشناس در حال کار به‌جای مشتری؛ کارگاه (type 5).
5. **مقایسه‌ی تصویری:** Playwright روی یک ماشین و یک مرورگر برای هر دو اپ؛ viewport های 390×844 و 768×1024 (کانتینر مرجع `max-w-screen-md` است)؛ نواحی پویا مثل تاریخ و تایمر ماسک می‌شوند. آستانه‌ی قبول همین‌جا توافق می‌شود.
6. **بررسی انیمیشن:** ضبط frame در زمان‌های ثابت پس از محرک (مثلاً 0، 150، 300 و 1000ms) برای هر ردیف جدول انیمیشن.
7. **spike تقویم جلالی:** `generatePicker` عمومی + config جلالی کنار fork مرجع. اگر مقایسه‌ی تصویری صفر نشد، fork به‌صورت vendored نگه داشته می‌شود.
8. ساخت `docs/parity/03-PWA-CLASSIC-MATRIX.md` — فیلد به فیلد طبق D12.
🛑 **Gate — توقف و تأیید شما.**
### Phase 1 — پایه‌ی `apps/user-app-classic`
- اسکلت Vite 5 + Vue 3.5 + TS strict + Pinia + vue-router، با نسخه‌های قفل‌شده‌ی بالا.
- **asset ها بایت‌به‌بایت با checksum:** فونت‌ها، `icons.json` و `Icon.vue`، تصاویر `assets/img` و empty، favicon، آیکون‌های manifest، فایل Lottie، `whatsapp.png`.
- `themeConfig/colors.json`، متغیرهای less، `tailwind.config.js` و `postcss-rtl` — همان مقادیر.
- `index.html` همان: عنوان «طلای منتزر»، `theme-color` ‏`#1C2D56`، قفل zoom، دکمه‌ی شناور واتس‌اپ (شماره از env، با مقدار پیش‌فرض مرجع). `bale.png` در `public/` هست ولی در `index.html` استفاده نمی‌شود — بررسی می‌شود.
- PWA: `injectManifest`، `sw.ts` فقط precache، manifest همان، **mode production**.
- زیرساخت: پورت 5183، `Dockerfile` و nginx به الگوی v1، سرویس compose، اضافه شدن به `build:release`، به‌روزرسانی `DELIVERY.md`.
### Phase 2 — لایه‌ی core
- **client HTTP:** base از env، توکن در هر درخواست؛ 401 سراسری = همان کار `myStore.init()` مرجع (پاک کردن `appToken`، `appCart` و کلید acting-as، رفتن به خانه).
- **adapter ها — قلب کار:** DTO هدف → شکل‌هایی که view های مرجع انتظار دارند (`MyData`، `Product`، `Variant`، `cartItem`، سفارش با `sts` عددی، ردیف سند، ردیف گزارش). view ها با همان نام فیلدهای مرجع bind می‌شوند. **قاعده‌ی صداقت:** چیزی که هدف ندارد `null` می‌شود و ردیفش پنهان — هرگز مقدار ساختگی.
- **نگاشت enum ها:**
- `CustomerKind` → `type` مرجع (۲، ۳، ۴، ۵؛ و ۱ برای کارشناس).
- `OrderStatus` هدف (۹ وضعیت) → `sts` مرجع (۸ تب).
- `PreOrderStatus` → رشته‌های فارسی `sts_str_fa`.
- `LedgerSourceType` → نوع سند ۱ تا ۴ و ۹.
- **بک‌اند پایه:** معادل کامل `POST /me-data` (ردیف ۶ نقشه) — همه‌ی صفحه‌ها به آن وابسته‌اند.
- **store های Pinia با همان state مرجع:** base، me (از همین حالا با جای `_activeUser` برای تنش ۲)، cart (`useStorage('appCart')` با همان کلید و شکل)، nav، query، token، transition، windowData، report.
- **composable ها:** `getProducts` با فرمول دقیق صفحه‌بندی `floor(count/limit) - 1`؛ موتور تخفیف (۴ نوع، نوع ۳ خاموش، بیشترین `discount_percent`)؛ `getWage`؛ `getHasDiscountWage`؛ علاقه‌مندی خوش‌بینانه.
- **formatter ها با همان خروجی:** ` تومان ` با فاصله‌ها، truncate (نه round) در `useTowDecimal`، حذف علامت در `useFormat`. **validator ها با همان پیام‌ها**، ولی بدون پرچم `g` (نقص #18).
- تست واحد برای همه‌ی این‌ها، با نمونه‌های مرجع.
### Phase 3 — پوسته، ناوبری، انیمیشن
- `App.vue` با `keep-alive`/`suspense`، دو layout، چک نسخه + دیالوگ Lottie، به‌روزرسانی بی‌صدای service worker.
- router با ۲۲ route و guard ها؛ hook آنالیتیکس (وصلش در Batch G).
- ناوبری پایین با GSAP، `appHeader` با shutter، `content` با margin.
- کامپوننت‌های پایه: `DialogVant`، `DialogVantOneButton`، `NotifyVant`، `spinner`، `empty`، `WorkActionSheet`، `container`، `ScrollFetch`، `SearchInput`، `otpInput`، `SingleImageUploader`، `InputDatePicker`.
- هر ردیف جدول انیمیشن با harness فاز ۰ اثبات می‌شود.
### Batch A — ورود، ثبت‌نام، تکمیل پروفایل · حجم بک‌اند: کم
- **صفحه‌ها:** `LoginRegister/index.vue`، `Login.vue`، `Register.vue`، `completeProfile.vue`، `input/otpInput.vue`، `input/SingleOtpInput.vue`.
- **بک‌اند:**
- معادل `POST /major/register` — مشتری تازه با `verificationStep = UNVERIFIED`، با بررسی یکتایی شماره در **هر دو** جدول.
- رد ورود برای `sts_verify_step_one == 3` با پیام «مجوز ورود ندارید» — نگاشتش به وضعیت هدف بررسی می‌شود.
- تکمیل پروفایل و آپلود مدارک: موجود (D22).
- **کوییرک‌هایی که حفظ می‌شوند:** OTP چهاررقمی (APP-01)؛ رنگ notify همیشه قرمز `#DC2626`، حتی برای موفقیت؛ برچسب دکمه «دریافت رمز عبور» / «دریافت مجدد رمز عبور»؛ دیالوگ موفقیت ثبت‌نام که با کلیک بیرون بسته نمی‌شود؛ فیلد شهر متن آزاد؛ تلفن فروشگاه بدون الگو.
### Batch B — کاتالوگ · حجم بک‌اند: زیاد
- **صفحه‌ها:** `home/index.vue`، `search/searchPage.vue`، `search/collection/index.vue` و `CollectionCard.vue`، `newest.vue`، `singleProduct.vue` (صفحه و حالت drawer)، `components/product/*`، `components/search/*`، `components/slider/*`، `viewAll.vue`.
- **بک‌اند:**
- معادل `POST /front`: دسته‌ها با `total`، `icon` و `image`؛ محصولات جدید؛ `more`؛ `ciny`.
- معادل `POST /get-products`: همه‌ی کلیدهای `Query` (`colors`، `brands`، `limit_percent`، `limit_gram`، `has_discount`، `text`، `cats`، `ciny_id`، `page`، `limit`) با پاسخ `{rows, count, limit}`، و حالت `products_id` برای rehydrate سبد. سمت سرور، چون صفحه‌بندی مرجع سمت سرور است.
- معادل `GET /product/{id}` و `GET /ciny/{cat}`.
- معادل `GET /config` (`about` + `main_slider`).
- معادل `GET /promotion` و `GET /ignore` برای کاربر واردشده.
- **کوییرک‌ها:** ترتیب ۹ دسته (Q5)؛ دیالوگ ورود در هر بازدید بعد از ۵ ثانیه؛ ارتفاع اسلایدر `min(width, 768) / 2.2125`؛ تنوع پیش‌فرض = کمترین `percentage_pay` با fallback «بدون تنوع»؛ تخفیف نوع ۳ خاموش؛ «افزودن به سبد» فقط با `sts_verify_step_two === 2`؛ نمونه‌ی تصادفی ۶ کالکشن؛ برچسب «N فیلتر» / «اعمال فیلتر»؛ بازه فقط وقتی «فعال» حساب می‌شود که هر دو سرش عوض شده باشد؛ بنکدار اسلایدر اجرت را نمی‌بیند.
- **وابسته به:** D9 قدم ۵، Q3، Q5، تصمیم دکمه‌ی back.
### Batch C — میز کار (`/user`) · حجم بک‌اند: کم تا متوسط
- **صفحه‌ها:** `views/user/index.vue`، `Dashboard.vue`، `about.vue`.
- **بک‌اند:** تابلوی قیمت (Q4)؛ `grams_sell` برای کارشناس.
- **کوییرک‌ها:** «بدهکار گرمی» / «بستانکار گرمی» با قدر مطلق؛ «فروش گرمی» فقط برای کارشناس؛ کاشی‌ها برای مهمان → دیالوگ ورود؛ دیالوگ «ّکاربر فعال شود؟» با همان تشدید سرگردان؛ دیالوگ خروج؛ متن‌های toast قیمت؛ سرتیتر تاریخ و ساعت قیمت که همیشه خالی است (#7 — کوییرک بصری).
- **تصمیم‌ها:** #5، #6، #8، #19؛ تنش ۲ برای دکمه‌ی «انتخاب مشتری» / «خروج از حساب مشتری».
### Batch D — سفارش‌ها، علاقه‌مندی‌ها، پروفایل، اسناد، گزارش · حجم بک‌اند: زیاد
- **صفحه‌ها:** `Orders.vue`، `product/order.vue`، `product/orderItem.vue`، `favorits.vue`، `profile.vue`، `editUser.vue`، `transactions.vue`، `addTransaction.vue`، `addFindoc.vue`، `report/index.vue`، `report/detail.vue`، `Accounting.vue`، `AccountingDetail.vue`.
- **بک‌اند:**
- علاقه‌مندی: جدول افزایشی، toggle و list.
- ویرایش پروفایل: **فقط فیلدهای فرم** (whitelist). مرجع کل بدنه را روی ردیف مشتری می‌نوشت (`ApiMobileController::updateProfile` — همان خانواده‌ی SEC-14). ستون‌های افزایشی `birthDate` و `marriageDate` (PERS-03).
- اسناد: فهرست به شکل مرجع از `LedgerEntry`. ثبت سند به‌صورت ورودی **تأییدنشده**، با نرمال‌سازی آب‌شده `value × ayar / 750` (همان `REPAYMENT_MELTED_GOLD`). پیامک به مدیران از env، به‌جای دو شماره‌ی هاردکد مرجع (الگوی SEC-15). مرجع اینجا هم کل بدنه را ذخیره می‌کرد.
- فاکتور سفارش و فایل سند (معادل `report/order` و `report/doc`).
- گزارش: موجود (D24).
- **کوییرک‌ها:** ۸ تب وضعیت با همان رنگ‌ها (۰ و ۲ بدون رنگ)؛ ریل چهارمرحله‌ای با `current = 1` پیش‌فرض؛ جست‌وجوی دقیق شماره در کلاینت؛ مودال تمام‌صفحه؛ باز شدن فاکتور در پنجره‌ای به نام `_bank`؛ **دو دکمه‌ی شناور هم‌پوشان** در اسناد، با همان ترتیب لایه‌ها؛ رنگ و آیکون ردیف برای `type == 9`؛ چهار تب فرم سند و متن ثابت تب «پولی»؛ دکمه‌ی «افزودن کاربر» که هنگام کار به‌جای مشتری پنهان است (#11).
- **تصمیم‌ها:** #4، #12، #14، #15.
### Batch E — سبد و ثبت سفارش · حجم بک‌اند: متوسط
- **صفحه‌ها:** `cart.vue`، `product/cartItem.vue`.
- **بک‌اند:** MON-08؛ MON-05؛ پرچم `from_parent` (تنش ۲).
- **کوییرک‌ها:** کلید `appCart` و شکلش؛ حذف بی‌صدای ردیف‌هایی که محصولشان دیگر نیست؛ وقتی تعداد ۱ است، منفی به سطل زباله تبدیل می‌شود؛ drawer یادداشت 280px با textarea پنج‌خطی؛ جمع **فقط وزن** با `.toFixed(2)`؛ دیالوگ تأیید؛ صفحه‌ی موفقیت با شماره‌ی سفارش؛ بنکدار اجرت را نمی‌بیند.
- **تصمیم‌ها:** تنش ۵.
### Batch F — کارشناس فروش · حجم بک‌اند: زیاد
- **صفحه‌ها:** `customerChoice.vue`، `addUser.vue`، `ChildOrders.vue`، و بخش‌های کارشناس در Dashboard و profile.
- **بک‌اند:**
- کار به‌جای مشتری (تنش ۲).
- ساخت، ویرایش و فعال/غیرفعال کردن مشتری توسط کارشناس، با دامنه‌ی `referredByAgentId` و یکتایی شماره در هر دو جدول.
- فهرست نوع مشتری (معادل `admin/major/list_type`) از `CustomerKind`.
- ویرایش سفارش مشتری و ثبت نهایی (معادل `PUT /order/child/{id}`).
- **کوییرک‌ها:** پیش‌پر کردن از `sessionStorage.editCustomer`؛ برگشت به `customerChoice` یا `profile` بر اساس `query.from`؛ ۲۲ منطقه‌ی تهران؛ بنکدار → `sts = -1`؛ toast شماره‌ی تکراری با قرمز `#b10f0f`؛ جست‌وجوی نام در کلاینت؛ ویرایش وزن خام (بدون اجرت)؛ دو متن تأیید.
### Batch G — سفارش ساخت + آنالیتیکس · حجم بک‌اند: متوسط
- **صفحه‌ها:** `makeOrder.vue`، `makeOrderStatus.vue`، کاشی سفارش ساخت در Dashboard، `router/customAnalytics.ts`.
- **بک‌اند:**
- ثبت و فهرست سفارش ساخت مشتری روی مدل موجود `PreOrder` (`colorName`، `sizeSpec`، `minaColor`، `stoneColor`، `description`، تصویر) + ستون افزایشی تعداد — فیلد `sum` مرجع در `PreOrder` نیست.
- تأیید و ارسال توسط کارگاه (تنش ۳، #9).
- آنالیتیکس روی `CustomerActivityLog` + ستون افزایشی `endedAt` (Q6).
- **کوییرک‌ها:** نگاشت ۵ مرحله از رشته‌ی فارسی `sts_str_fa` — سرور باید **همان رشته‌ها** را بدهد؛ `confirmedOrders` فقط در حافظه است و با reload از دست می‌رود؛ کاشی برای type 5 مستقیم و برای بقیه مودال دوگزینه‌ای؛ اسپینری که به‌خاطر سایه‌انداختن متغیر `loading` هرگز دیده نمی‌شود.
### Phase نهایی — ممیزی و تحویل
- همه‌ی ردیف‌های ماتریس: ✅، یا ➖ با شماره‌ی ISSUE، یا ⏸️.
- گزارش مقایسه‌ی تصویری همه‌ی صفحه‌ها × پرسوناها × viewport ها.
- سرشماری‌ها (§معیار تمام شد).
- typecheck، lint، build، `docker compose`، `build:release`.
- به‌روزرسانی `DELIVERY.md` و `PROGRESS.md`.
---
## روش کار هر Batch
1. خواندن **خط‌به‌خط** فایل‌های مرجع آن batch — نه فقط inventory.
2. ردیف‌های ماتریس، فیلد به فیلد.
3. شکاف‌های بک‌اند: endpoint افزایشی + تست (رفت‌وبرگشت، توکن متقابل، IDOR).
4. پورت view ها: template و style تا جای ممکن عین مرجع؛ script بازنویسی‌شده.
5. مقایسه‌ی تصویری و انیمیشن در برابر مرجع.
6. typecheck، lint، build.
7. گزارش کوتاه: چه پورت شد، چه عمداً نشد (با شماره)، چه باگ تازه‌ای در مرجع پیدا شد.
تحت D18 بین batch ها توقف نمی‌شود؛ فقط برای موارد توقف D18 و gate فاز ۰.
---
## معیار «تمام شد» — اثبات خودکار اینکه چیزی از دست نرفت
| سرشماری | چطور | قبول |
|---|---|---|
| route | استخراج path، name و meta از هر دو router | ۲۲ از ۲۲، یکسان |
| رشته‌ها | استخراج هر literal فارسی از فایل‌های زنده‌ی مرجع (template و script) | همه در اپ کلاسیک موجود؛ هر غایب یا در §چیزی که پورت نمی‌شود هست یا خطاست |
| asset | checksum | همه یکسان |
| endpoint | هر ردیف نقشه | ✅ یا تصمیم ثبت‌شده |
| تصویر | مقایسه‌ی هر صفحه × پرسونا × viewport | زیر آستانه‌ی توافق‌شده در فاز ۰ |
| انیمیشن | frame های زمان ثابت | منطبق |
| ماتریس | — | بدون ردیف باز |
| کیفیت | typecheck strict، lint، build، compose | سبز |
---
## چیزی که پورت نمی‌شود (و چرا)
هر مورد «کد مرده» قبل از کنار گذاشتن با جست‌وجوی importer تأیید می‌شود.
| مورد | چرا | شاهد |
|---|---|---|
| `src/views/login.vue` | کل فایل کامنت است | `login.vue:1,201` |
| `user/src/components/report.vue` | صفحه‌ی دیباگ، سوار نشده | `_inv-ref-pwa.md` §6.1 |
| `NotificationList.vue` و سه endpoint اعلان | در مرجع سوار نیست | `home/index.vue:5` |
| FCM (`Mixins/fireBase.js`) و `firebase-messaging-sw.js` | در مرجع غیرفعال؛ کلید API هاردکد | `App.vue:105` |
| پرکردن خودکار OTP (Web OTP API) | غیرفعال | `App.vue:100` |
| `POST /cities/list` | UI آبشاری کامنت است | `Register.vue:58-64` |
| `cax`، `noneAuthUrl`، `canNavigate`، `getHomeRouteForLoggedInUser`، `getCustomerListType`، `loadingStore`، `filters sample.js`، `Date.vue`، `UploadPic.vue`، `updateProfile` بی‌مصرف در `makeOrder.vue`، `logOut` دوم در `user/index.vue` | کد مرده | `_inv-ref-pwa.md` §2.2، §5.1، §8.6، §9 |
| دو fetch تکراری در `singleProduct` | یک fetch؛ نتیجه‌ی دیده‌شده یکسان | #20 |
| `wage_weight` و `weight` ثابت در payload سفارش | سرور نادیده می‌گیرد | DEAD-06 |
| حلقه‌ی `setInterval(reload)` | یک reload | #10 |
| کلید analytics به شکل `undefined` | کلید از نام route | #16 |
| PWA در dev mode | production | #17 |
---
## ریسک‌ها
| # | ریسک | اثر | کاهش |
|---|---|---|---|
| R1 | مرجع روی Node فعلی بالا نیاید (Vite 3، plugin-vue 2، وابستگی `webpack 5.0.0`) | بدون مقایسه‌ی تصویری، «دقیقاً» فقط با خواندن کد ادعا می‌شود | Node قدیمی‌تر فقط در همان کپی؛ اگر نشد، اعلام صریح سطح اطمینان پایین‌تر |
| R2 | تفاوت داده بین دو API | adapter وسوسه می‌شود چیزی جعل کند | قاعده‌ی صداقت؛ تست adapter با fixture |
| R3 | تقویم جلالی | ظاهر متفاوت | spike فاز ۰؛ fallback به fork |
| R4 | حجم بک‌اند | بیشتر کار در `apps/api` است، نه در UI | batch بندی بر اساس وابستگی |
| R5 | رندر فونت بین ماشین‌ها | تفاوت کاذب در مقایسه‌ی تصویری | هر دو اپ روی یک ماشین و یک مرورگر |
| R6 | `keep-alive` | state کهنه بعد از برگشت — که رفتار مرجع است | حفظ، با تست سناریوی برگشت |
| R7 | v1/v2 عقب می‌افتند (`CLAUDE.md` §2) | ناسازگاری منطقی بین اپ‌ها | تنش ۱ |
| R8 | شناسه‌های عددی دسته‌ها | ترتیب اشتباه دسته‌ها در خانه | Q5 |
---
# وضعیت اجرا
هنوز شروع نشده. **قدم بعدی:** پاسخ شما به تنش‌های ۱ تا ۵ و Q1 تا Q9؛ بعد Phase 0.
+52
View File
@@ -0,0 +1,52 @@
# نسخه و پیام «چه چیزی تغییر کرد»
هر بار که پنل یا اپ‌ها ساخته می‌شوند، شماره‌ی نسخه **خودش بالا می‌رود**؛ شماره همان
تعداد کامیت‌های شاخه است و فایلی برای دستی بالا بردن ندارد.
بعد از انتشار، هر کسی که برنامه را باز دارد حداکثر تا ۵ دقیقه، یا همان لحظه‌ای که
دوباره به برنامه برمی‌گردد، پنجره‌ی «نسخه‌ی تازه آماده است» را می‌بیند:
- فهرست تغییراتِ **همان بخش** نشان داده می‌شود: اپ مشتری تغییرات اپ را می‌بیند و
پنل تغییرات پنل را.
- پنجره بسته نمی‌شود؛ تنها راه، دکمه‌ی «به‌روزرسانی» است.
- با زدن دکمه، حافظه‌ی نهان برنامه پاک و صفحه دوباره بار می‌شود.
- ورود کاربر، سبد خرید و تنظیمات **پاک نمی‌شوند**.
## متن تغییرات از کجا می‌آید؟
از پیام کامیت‌ها. متن فنی و انگلیسی **هیچ‌وقت** به کاربر نشان داده نمی‌شود؛ فقط
خط‌هایی که با یکی از این برچسب‌ها شروع شوند:
| برچسب | کجا نشان داده می‌شود |
|---|---|
| `اپ:` | هر دو اپ مشتری |
| `اپ۱:` | فقط اپ نسخه‌ی ۱ |
| `اپ۲:` | فقط اپ نسخه‌ی ۲ |
| `پنل:` | پنل مدیریت |
نمونه‌ی پیام کامیت:
```
fix(cart): keep the cart when the price refreshes
اپ: سبد خرید با به‌روز شدن قیمت دیگر خالی نمی‌شود.
پنل: در فهرست سفارش‌ها، تاریخ ثبت دقیق نشان داده می‌شود.
```
### چطور بنویسیم
- برای **کاربر** بنویسید، نه برنامه‌نویس: «چه چیزی برای او فرق کرده».
- یک خط کوتاه برای هر تغییر.
- کامیتی که کاربر چیزی از آن نمی‌بیند (تمیزکاری، تست، تغییر داخلی) **برچسب نمی‌خواهد**.
- اگر در یک انتشار هیچ خطِ برچسب‌داری نباشد، پنجره فقط «بهبودها و رفع اشکال‌ها»
را نشان می‌دهد.
## از نظر فنی
- `scripts/app-version.mjs` موقع ساخت، شماره و یادداشت‌ها را درمی‌آورد و
`version.json` را کنار برنامه می‌گذارد.
- `useAppUpdate.ts` در هر برنامه آن فایل را بدون حافظه‌ی نهان می‌خواند و با شماره‌ی
خودش مقایسه می‌کند.
- تنظیم nginx هر سه برنامه: `index.html`، `sw.js` و `version.json` هرگز در مرورگر
نگه داشته نمی‌شوند. پیش‌تر `sw.js` شش ماه نگه داشته می‌شد و می‌توانست مرورگر را
روی نسخه‌ی قدیمی نگه دارد.
+95
View File
@@ -0,0 +1,95 @@
# فهرست تنظیمات سرور اصلی
برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد.
اگر موارد زیر درست نباشند، سرور باز هم روشن می‌شود ولی در گزارش سرور فهرست
کامل ایرادها با یک هشدار بزرگ چاپ می‌شود و برای هر مورد جایگزین امن به کار
می‌رود:
- کلیدهای `JWT_SECRET` و `JWT_CUSTOMER_SECRET`: یک کلید تصادفی یک بار ساخته و در
جدول `Setting` نگه داشته می‌شود، پس با ریستارت عوض نمی‌شود.
- `OTP_MASTER_CODE`: بی‌اثر می‌شود و کد ثابت کار نمی‌کند.
- `CORS_ORIGINS`: همه‌ی نشانی‌ها مجازند.
باز هم بهتر است هر سه را درست تنظیم کنید و یک بار دوباره راه‌اندازی کنید.
این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ
سرور همیشه در این حالت است.
---
## ۱. حذف کد ثابت ورود آزمایشی
| متغیر | مقدار درست |
|---|---|
| `OTP_MASTER_CODE` | **خالی** یا کلاً حذف‌شده |
تا وقتی این کد هست، هر کسی که آن را بداند وارد حساب **هر** مشتری می‌شود.
> سرور **آزمایشی**ای که عمداً این کد را لازم دارد، باید کنارش
> `TEST_SERVER_ALLOW_MASTER_CODE=yes` بگذارد. این را **هرگز** روی سرور اصلی
> نگذارید.
## ۲. کلید جدا برای مشتری‌ها
| متغیر | مقدار درست |
|---|---|
| `JWT_SECRET` | یک رشته‌ی تصادفی، دست‌کم ۳۲ نویسه (کلید ورود مدیرها) |
| `JWT_CUSTOMER_SECRET` | یک رشته‌ی تصادفیِ **دیگر**، دست‌کم ۳۲ نویسه (کلید ورود مشتری‌ها و کارشناس‌ها) |
دو مقدار **نباید یکی باشند**. ساختن مقدار تصادفی، روی هر سیستمی که Node دارد:
```
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
دو بار اجرا کنید و هر خروجی را برای یکی از دو متغیر بگذارید.
⚠ **عوض کردن این کلیدها همه را از حساب بیرون می‌کند.** مدیرها و مشتری‌ها
یک بار دوباره وارد می‌شوند. اطلاعات کسی از بین نمی‌رود.
⚠ اگر `ACCOUNTING_SECRET_KEY` خالی باشد، رمز ذخیره‌شده‌ی نرم‌افزار حسابداری از
`JWT_SECRET` ساخته می‌شود. با عوض شدن `JWT_SECRET` باید یک بار دوباره در
«تنظیمات حسابداری» پنل وارد شوید. بهتر است `ACCOUNTING_SECRET_KEY` هم یک مقدار
تصادفیِ جدا بگیرد و دیگر عوض نشود.
## ۳. نشانی‌های مجاز
| متغیر | مقدار درست |
|---|---|
| `CORS_ORIGINS` | نشانی کامل پنل و اپ‌ها، با ویرگول جدا |
نمونه:
```
CORS_ORIGINS=https://adm.henzagold.ir,https://app.henzagold.ir,https://app2.henzagold.ir
```
- هر نشانی با `https://` شروع شود و **مسیر نداشته باشد**؛ مثلاً `/admin` در
آخرش نیاید.
- هر دامنه‌ای که پنل یا یکی از دو اپ از آن باز می‌شود باید در فهرست باشد؛
وگرنه آن صفحه نمی‌تواند با سرور حرف بزند.
- `*` پذیرفته نمی‌شود.
## ۴. پیامک: نشانی سرور
| متغیر | مقدار درست |
|---|---|
| `SMS_RELAY_HOST` | **نشانی IP عمومی همین سرور** |
سرویس پیامک، سرور فرستنده را با این مقدار می‌شناسد. اگر خالی باشد، برنامه خودش
نشانی عمومی سرور را پیدا می‌کند؛ ولی نوشتن آن مطمئن‌ترین راه است. داخل داکر،
نشانیِ خودِ برنامه یک نشانی داخلی (`172.x`) است و سرویس پیامک آن را نمی‌شناسد.
بعد از راه‌اندازی، در گزارش سرور خطی مثل `SMS relay host: …` نشانی‌ای را که
فرستاده می‌شود نشان می‌دهد. اگر هشدار «private address» دیدید، این متغیر را پر کنید.
---
## بعد از تنظیم
1. سرور را دوباره راه‌اندازی کنید.
2. در گزارش سرور باید `Nest application successfully started` دیده شود.
اگر سرور بالا نیامد، فهرست ایرادها همان‌جا چاپ شده است.
3. پنل مدیریت و هر دو اپ را باز کنید و یک بار وارد شوید.
4. ورود مشتری با پیامک را امتحان کنید. کد ثابت دیگر نباید کار کند.
+92
View File
@@ -0,0 +1,92 @@
# طرح افزودن درگاه پیامکی جدید (Relay / `createsms`)
> وضعیت: **اجرا شد** (2026-10-05) — تصمیم ثبت‌شده: D26.
> تاریخ: 2026-10-05
## ۱. کد ورودی چه می‌کند
کد PHP ارسالی (Laravel) یک پیامک را به یک سرویس واسط (relay) می‌فرستد:
| مورد | مقدار |
|---|---|
| متد / آدرس | `POST http://185.231.112.101/api/createsms` (HTTP ساده، بدون TLS) |
| بدنه | JSON |
| هدر احراز هویت | `secret_key: <کلید>` |
| timeout | ۱۵ ثانیه |
| فیلد `phone` | شماره گیرنده |
| فیلد `text` | متن پیام، `trim()` شده |
| فیلد `host` | `Controller::Config('montek')` و اگر خالی بود IP خود سرور (`gethostbyname(gethostname())`) — یعنی شناسه‌ی «این مشتری/سرور» نزد سرویس واسط |
| فیلد `platform` | اگر `$sendFromKave` درست باشد `"kave"` (کاوه‌نگار)، وگرنه `null` (خط پیش‌فرض سرویس واسط) |
| `CURLOPT_IPRESOLVE = V4` | فقط اجبار IPv4؛ چون آدرس خودش IP است در عمل اثری ندارد |
این کد در `henza_server` وجود ندارد (جستجو شد)، پس از سیستم دیگری آمده و مرجع parity ندارد.
## ۲. کجا قرار می‌گیرد
ماژول پیامک همین الان در [apps/api/src/modules/sms/sms.service.ts](../apps/api/src/modules/sms/sms.service.ts) هست و همه‌ی مصرف‌کننده‌ها (OTP ورود، پیامک خوش‌آمد، اعلان تکمیل پروفایل، …) فقط `SmsService.send()` را صدا می‌زنند. پس:
- **هیچ مصرف‌کننده‌ای تغییر نمی‌کند.** فقط یک «درایور» دوم کنار Trez اضافه می‌شود و با `SMS_PROVIDER=relay` انتخاب می‌شود.
- قوانین فعلی حفظ می‌شود: `send()` هرگز throw نمی‌کند (D20)، هر پیام در `SmsMessage` ثبت می‌شود، متن OTP ذخیره نمی‌شود (SEC-11)، نبود تنظیمات فقط هشدار می‌دهد و سرور بالا می‌آید.
## ۳. تغییرات پیشنهادی
### ۳.۱ کد — `sms.service.ts`
1. `smsConfig()` متغیرهای جدید را هم می‌خواند (جدول ۳.۲).
2. `smsConfigured()` برای `relay`: کافی است `SMS_RELAY_SECRET` و `SMS_RELAY_HOST` پر باشند.
3. `assertSmsConfigured()` مقدار `relay` را می‌شناسد و متغیرهای خالی را با نام گزارش می‌کند (بدون throw).
4. متد جدید `deliverViaRelay(mobile, text, platform)`:
- `fetch` با `Content-Type: application/json` و هدر `secret_key`، بدنه‌ی `{ phone, host, text, platform }`، timeout ۱۵ ثانیه با `AbortController` — هم‌شکل `deliverViaTrez`.
- موفقیت = پاسخ HTTP 2xx (تا وقتی قالب پاسخ سرویس واسط مشخص شود — سؤال ۴).
5. `SmsSendOptions` یک گزینه‌ی اختیاری `platform?: 'kave'` می‌گیرد؛ درایور Trez آن را نادیده می‌گیرد.
6. اجبار IPv4 پیاده نمی‌شود چون آدرس IP است؛ اگر بعداً دامنه شد، با `dns.lookup` با `family: 4` اضافه می‌شود.
### ۳.۲ متغیرهای محیطی جدید
| متغیر | اجباری | توضیح |
|---|---|---|
| `SMS_PROVIDER` | — | مقدار جدید `relay` |
| `SMS_RELAY_URL` | خیر | پیش‌فرض `http://185.231.112.101/api/createsms` |
| `SMS_RELAY_SECRET` | **بله** | مقدار هدر `secret_key` — **فقط در env، هرگز در کد یا git** |
| `SMS_RELAY_HOST` | **بله** | مقدار فیلد `host` (معادل `Config('montek')`) |
| `SMS_RELAY_KAVE_FOR` | خیر | کدام پیام‌ها از کاوه‌نگار بروند — بسته به پاسخ سؤال ۲ |
### ۳.۳ مستندات و استقرار
- `.env.example` (ریشه)، `apps/api/.env.example`، `docker-compose.yml`، `DELIVERY.md` (بخش SMS و گام ۴): افزودن متغیرهای بالا.
- `docs/parity/DECISIONS.md`: ثبت یک تصمیم جدید (D26) برای درایور relay.
### ۳.۴ تست — `sms.service.spec.ts`
- تنظیمات: پیش‌فرض URL، `smsConfigured` با/بدون secret و host، هشدار با نام متغیرهای خالی.
- ارسال با `fetch` ماک‌شده: آدرس، هدر `secret_key`، بدنه‌ی JSON، `trim` متن، `platform` درست؛ و این‌که خطای شبکه/timeout/HTTP 500 به `{ ok: false }` تبدیل شود و throw نشود.
## ۴. هشدار امنیتی ⚠
1. **کلید `secret_key` لو رفته فرض شود.** در متن پیام چت آمده و احتمالاً در git پروژه‌ی PHP هم هست (مشابه SEC-12). باید از صاحب سرویس واسط کلید جدید گرفت و فقط در env سرور گذاشت. این کلید در کد این مخزن نوشته نمی‌شود.
2. **ارتباط HTTP ساده است**؛ کلید و متن پیام (از جمله کد OTP) رمزنشده از اینترنت عبور می‌کنند. اگر سرویس واسط HTTPS دارد از آن استفاده شود.
## ۵. سؤال‌ها (با پیشنهاد)
1. **جایگزین یا کنار Trez؟** پیشنهاد: کنار Trez بماند و با `SMS_PROVIDER=relay` انتخاب شود؛ پیش‌فرض همان `trez` بماند تا رفتار فعلی عوض نشود. یا پیش‌فرض `relay` شود؟
2. **`sendFromKave` کی درست است؟** در کد اصلی این متغیر از کجا مقدار می‌گیرد؟ پیشنهاد: فقط OTP ورود از کاوه‌نگار برود (معمولاً برای سرعت/تحویل بهتر) و بقیه از خط پیش‌فرض — قابل تنظیم با env.
3. **مقدار `host` (`montek`) چیست؟** پیشنهاد: اجباری در env. fallback به IP خود سرور پیاده نمی‌شود، چون داخل Docker، IP کانتینر (مثلاً `172.x`) برمی‌گردد که برای سرویس واسط بی‌معناست.
4. **پاسخ سرویس واسط چه شکلی است؟** اگر در خطا هم 200 برمی‌گرداند (مثلاً `{"status": false}`)، نمونه‌ی پاسخ موفق و ناموفق لازم است تا `ok` درست تشخیص داده شود. پیشنهاد فعلی: فقط وضعیت HTTP.
5. **HTTPS دارد؟** (بند ۴.۲)
## ۷. پاسخ‌های مالک (2026-10-05)
| سؤال | پاسخ | اثر روی پیاده‌سازی |
|---|---|---|
| کلید | لو نرفته؛ تأیید دومرحله‌ای جای دیگری هست؛ داخل پروژه قرار بگیرد | کلید به‌عنوان مقدار پیش‌فرض در کد می‌آید و `SMS_RELAY_SECRET` فقط آن را بازنویسی می‌کند. بند ۴.۱ لغو شد. |
| ۱. جایگزین Trez؟ | پیش‌فرض `relay` شود؛ Trez حذف نشود؛ ممکن است بعداً سرویس‌های بیشتری بیاید | سرویس به ساختار «درایور» بازنویسی می‌شود: یک اینترفیس `SmsDriver` و هر سرویس یک فایل جدا (`drivers/relay.driver.ts`، `drivers/trez.driver.ts`). برای اضافه کردن سرویس بعدی فقط یک فایل و یک خط ثبت لازم است. پیش‌فرض `SMS_PROVIDER` برابر `relay` می‌شود. |
| ۳. `host` | IP سرور درست است | `SMS_RELAY_HOST` اختیاری است. اگر خالی باشد، مثل PHP اولین IPv4 غیرداخلی سرور فرستاده می‌شود. |
| ۴. پاسخ | با تشخیص خودم | `ok` فقط وقتی است که HTTP 2xx باشد و بدنه‌ی JSON هم نشانه‌ی شکست صریح نداشته باشد (`status: false`، `success: false`، `error`). در غیر این صورت `ok: false` + لاگ؛ هیچ‌وقت throw نمی‌شود. |
| ۵. HTTPS | دارد | بررسی شد: روی `https://185.231.112.101` گواهی `*.montpar.ir` نصب است که **از 2026-08-22 منقضی شده** و روی IP هم معتبر نیست ← سؤال ب |
### سؤال‌های باز
- **الف) `sendFromKave`:** کدام پیام‌ها از کاوه‌نگار بروند؟ پیشنهاد: فقط OTP ورود.
- **ب) آدرس HTTPS:** نام دامنه‌ی سرویس (زیرمجموعه‌ای از `montpar.ir`) چیست؟ گواهی هم باید تمدید شود. تا آن موقع پیش‌فرض همان `http://185.231.112.101/api/createsms` می‌ماند و با `SMS_RELAY_URL` عوض می‌شود.
## ۶. وضعیت اجرا
- [x] تأیید طرح و پاسخ سؤال‌ها (الف: همه‌ی پیام‌ها از کاوه‌نگار؛ ب: همان IP و HTTP درست است)
- [x] کد + تست (`apps/api/src/modules/sms/drivers/`، ۱۳۵ تست سبز)
- [x] env / compose / DELIVERY / DECISIONS (D26) / ISSUES (REL-02)
+199
View File
@@ -0,0 +1,199 @@
# نحوه ارتباط gold-bank با سامانه هویت متمرکز (IAM)
این سند **مسیر داده** را توضیح می‌دهد: چه کسی، چه زمانی، چه چیزی را به کجا
می‌فرستد و اگر آن طرف جواب نداد چه اتفاقی می‌افتد.
برای پیکربندی و فعال‌سازی: [`iam-sso.md`](iam-sso.md) ·
مرجع تصویری اندپوینت‌های IAM: `http://<آدرس IAM>/docs`
---
## ۱. خلاصه در یک تصویر
```
پنل ادمین (Vue) apps/api (NestJS) Central IAM
─────────────── ───────────────── ───────────
فرم ورودِ همیشگی
(موبایل + رمز)
│ POST /api/admin/auth/login
├──────────────────────────────▶
│ AuthService.verifyPassword
│ │ POST /api/v1/auth/login
│ │ + client_id/secret + client_ip
│ ├────────────────────────────────▶
│ │◀────────────────────────────────┤
│ │ SUCCESS (sub، sid) │ 401 │ خطا
│ │
│ AdminSession: ثبت jti + sid + sub
│◀─────────────────────────────┤ همان پاسخ همیشگی (api_token, roles)
```
سه نکته که کل طراحی را توضیح می‌دهد:
1. **مرورگر ادمین هرگز به IAM نمی‌رود.** نه ریدایرکت، نه دکمه SSO، نه صفحه جدید.
2. **قالب پاسخ ورود تغییر نکرده است** (`api_token`, `roles`, …)، پس پنل تفاوتی نمی‌بیند.
3. **نگاشت کاربر با `mobile` انجام می‌شود.** هیچ ستونی به `AdminUser` اضافه نشده؛
شناسه‌های IAM روی جدول **جدید** `AdminSession` می‌نشینند.
> **اپ کاربر (`bnk.montzar.ir`) هم متصل است**، ولی از مسیر دیگری: ورودش فقط با کد
> یکبارمصرف است و رمزی ندارد که به IAM فرستاده شود. کد را همین سامانه تولید،
> ارسال و بررسی می‌کند و سپس نتیجه را به IAM اعلام می‌کند
> (`/api/v1/auth/login/delegated-otp`) تا نشست مرکزی، لاگ امنیتی و خروج سراسری
> شامل آن ورود هم بشود. توکن اپ هم مثل توکن پنل `jti` می‌گیرد و در جدول
> `AppSession` ثبت می‌شود، پس قابل ابطال است.
---
## ۲. کانال‌های ارتباطی (همه چیزی که رد و بدل می‌شود)
| # | جهت | مسیر | چه زمانی | چه چیزی می‌رود | اگر خطا بدهد |
| --- | --- | --- | --- | --- | --- |
| ۱ | ما → IAM | `POST /api/v1/auth/login` | هر ورود ادمین | موبایل، رمز، `client_id`، `client_secret`، `client_ip` | طبق `IAM_LOGIN_DELEGATION` (پیش‌فرض: `bcrypt` محلی) |
| ۱.۵ | ما → IAM | `POST /api/v1/auth/login/delegated-otp` | **ورود اپ کاربر** (`/auth/verify-otp`) — این ورود رمز ندارد | موبایل، اعتبارنامه کلاینت، `client_ip`، شماره ماسک‌شده | کاربر آنجا نیست یا سرویس down ⇒ ورود محلی ادامه؛ قفل/غیرفعال/2FA ⇒ ورود رد می‌شود |
| ۲ | ما → IAM | `GET /.well-known/jwks.json` | تأیید توکن خروج سراسری (کش ۲۴ ساعته، `kid` ناشناس ⇒ یک‌بار refetch) | — | اعلان خروج پذیرفته نمی‌شود (۲۰۰ + لاگ) |
| ۳ | **IAM → ما** | `POST /api/iam/backchannel-logout` | خروج سراسری یا ابطال نشست توسط ادمینِ IAM | `logout_token` امضاشده (RS256) | همیشه ۲۰۰؛ دلیل رد فقط در لاگ |
فقط همین سه کانال. جریان مرورگری OAuth در این سامانه استفاده نمی‌شود، چون قرار
است رابط کاربری ورود دست‌نخورده بماند.
---
## ۳. جریان ۱ — ورود ادمین
```
POST /api/admin/auth/login { phone, password }
│
├─ AdminUser با این موبایل هست و فعال است؟ (نه ⇒ ۴۰۱ مبهم، مثل قبل)
│
├─ AuthService.verifyPassword
│ ├─ IAM خاموش ⇒ یک bcrypt.compare (دقیقاً رفتار قبلی، بدون هیچ درخواست شبکه)
│ ├─ IAM تأیید کرد ⇒ { ok: true, identity: { sub, sessionId } }
│ ├─ IAM رد کرد (کاربر آنجا نیست/رمز فرق دارد)
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱
│ ├─ حساب در IAM قفل است ⇒ **رد قطعی** (قفل IAM دور زده نمی‌شود)
│ ├─ IAM در دسترس نیست / ۴۲۹
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱ با پیام روشن
│ └─ حساب دو مرحله‌ای است ⇒ پیام روشن (این فرم مرحله کد ندارد)
│
├─ jti تازه (فقط وقتی ردیابی نشست روشن است)
├─ امضای همان JWT همیشگی { sub, mobile, jti? }
├─ AdminSession: { jti, adminUserId, iamSessionId, iamSub, ip, userAgent }
│
└─ پاسخ: همان قالب قبلی
```
**چرا `client_ip` حیاتی است:** محدودیت نرخ IAM روی IP اعمال می‌شود. بدون IP کاربر
نهایی، همه ورودها از IP همین سرور دیده می‌شوند و بعد از چند تلاش ناموفق، **کل**
پنل ادمین ۴۲۹ می‌گیرد. اگر API پشت پراکسی است، `trust proxy` را تنظیم کنید وگرنه
`@Ip()` همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
---
## ۴. جریان ۲ — اعتبارسنجی هر درخواست (نشست قابل ابطال)
```
هر درخواست با Bearer توکن
│
├─ JwtStrategy: امضا و انقضا (مثل قبل)
├─ توکن از نوع اپ مشتری نباشد (typ)
│
├─ AdminSessionService.isActive(jti)
│ ├─ ردیابی خاموش ⇒ true، بدون هیچ کوئری (هزینه دقیقاً مثل قبل)
│ ├─ توکن بدون jti (صادرشده پیش از فعال‌سازی) ⇒ true تا انقضا
│ ├─ ردیف AdminSession باطل شده یا وجود ندارد ⇒ ۴۰۱
│ └─ سالم ⇒ ادامه
│
└─ AdminUser + مجوزها از دیتابیس (مثل قبل، تا تغییر دسترسی فوری اثر کند)
```
`AUTH_SESSION_TRACKING`: خالی = همراه `IAM_ENABLED` · `true` = روشن بدون IAM ·
`false` = خاموش (ابطال مرکزی دیگر به اینجا نمی‌رسد).
---
## ۵. جریان ۳ — خروج سراسری (تنها مسیر ورودی از IAM)
```
IAM: خروج کاربر یا «بستن همه نشست‌ها» توسط ادمین
│ logout_token امضاشده با RS256 (application/x-www-form-urlencoded)
▼
POST /api/iam/backchannel-logout ← بدون احراز هویت، عمداً
│
├─ IamLogoutService.verify
│ ├─ alg باید RS256 باشد (alg=none رد می‌شود)
│ ├─ امضا با کلید عمومی JWKS
│ ├─ iss برابر issuer پیکربندی‌شده
│ ├─ aud شامل client_id ما
│ ├─ events شامل رخداد back-channel logout (تا ID Token جای آن نرود)
│ ├─ nonce نباید وجود داشته باشد
│ └─ exp/iat با تحمل ۶۰ ثانیه انحراف ساعت
│
└─ AdminSessionService
├─ sid دارد ⇒ همان نشست باطل می‌شود
└─ فقط sub دارد ⇒ همه نشست‌های آن هویت
⇒ درخواست بعدی با آن توکن ۴۰۱ می‌گیرد
```
پاسخ **همیشه ۲۰۰** است (حتی برای توکن نامعتبر) تا IdP بی‌نهایت تلاش مجدد نکند؛
دلیل رد شدن در لاگ سرویس می‌آید.
آدرسی که باید در IAM برای این کلاینت ثبت شود:
```
backchannel_logout_uri = https://<آدرس این API>/api/iam/backchannel-logout
```
ابطال محلی هم به همین مسیر وصل است: غیرفعال کردن حساب یا تغییر رمز یک ادمین
(`AdminUsersService.update`) نشست‌های او را می‌بندد.
---
## ۶. نگاشت هویت‌ها
| در IAM | در gold-bank | قاعده |
| --- | --- | --- |
| موبایل | `AdminUser.mobile` | تنها معیار نگاشت کاربر — هیچ ستون جدیدی روی این جدول نیست |
| `sub` | `AdminSession.iamSub` | برای خروج سراسریِ «همه نشست‌های این هویت» |
| `sid` | `AdminSession.iamSessionId` | برای ابطال دقیقاً همان نشست |
| نقش‌ها | `UserType` / `Permission` محلی | از IAM **خوانده نمی‌شود**؛ دسترسی‌ها مال همین سامانه است |
**چرا شناسه‌های IAM روی جدول جدید نشستند و نه روی `AdminUser`:** تا فعال کردن این
قابلیت هیچ تغییری در جدول‌های موجود لازم نداشته باشد و برگرداندنش هم به همان
سادگی باشد. اگر روزی فیلد دیگری از IAM لازم شد، همین الگو ادامه یابد.
---
## ۷. رفتار در خطا (خلاصه تصمیم‌ها)
| وضعیت | `fallback` (پیش‌فرض) | `exclusive` |
| --- | --- | --- |
| IAM خاموش/ناقص | یک bcrypt، بدون شبکه — رفتار کاملاً قبلی | همان |
| IAM در دسترس نیست | bcrypt محلی؛ ورود ادمین‌ها قطع نمی‌شود | ۴۰۱ «سرویس هویت در دسترس نیست» |
| کاربر در IAM نیست | bcrypt محلی (مناسب مهاجرت تدریجی) | ۴۰۱ |
| رمز در IAM غلط است | bcrypt محلی | ۴۰۱ |
| حساب در IAM قفل است | **۴۰۱** — قفل IAM دور زده نمی‌شود | ۴۰۱ |
| حساب دو مرحله‌ای است | ۴۰۱ با پیام روشن (این فرم مرحله کد ندارد) | همان |
| ثبت `AdminSession` ناموفق | ورود انجام می‌شود (فقط هشدار لاگ) — بدترین حالت: نشستی که زودتر قابل ابطال نیست | همان |
| توکن خروج نامعتبر | ۲۰۰ + هشدار لاگ؛ هیچ نشستی بسته نمی‌شود | همان |
---
## ۸. بررسی سریع اتصال
```bash
cd apps/api
npx jest src/modules/iam src/modules/auth src/modules/sessions # ۶۱ تست
npx prisma migrate deploy # جدول AdminSession
```
مشکلات پرتکرار:
| نشانه | علت |
| --- | --- |
| بعد از چند ورود همه ۴۲۹ می‌گیرند | `client_ip` نمی‌رسد؛ `trust proxy` تنظیم نشده |
| خروج سراسری بی‌اثر است | `backchannel_logout_uri` ثبت نشده، یا `AUTH_SESSION_TRACKING=false` |
| همه ادمین‌ها ۴۰۱ می‌گیرند | جدول `AdminSession` پاک شده — ردیف نبود یعنی «باطل» |
| `unsupported logout token algorithm` در لاگ | توکن با RS256 امضا نشده (تلاش جعل یا پیکربندی اشتباه IdP) |
| حساب دو مرحله‌ای وارد نمی‌شود | این پنل مرحله «ورود کد» ندارد — بخش ۷ سند `iam-sso.md` |
+234
View File
@@ -0,0 +1,234 @@
# اتصال gold-bank به سامانه هویت متمرکز (Central IAM)
> **نحوه ارتباط (مسیر داده، اندپوینت‌ها، رفتار در خطا):** [`iam-connection.md`](iam-connection.md)
> **مرجع تصویری API سامانه هویت:** `<آدرس IAM>/docs`
این سند توضیح می‌دهد ورود ادمین چطور می‌تواند به‌جای رمز محلی، از سامانه هویت
سازمانی اعتبارسنجی شود — **بدون هیچ تغییری در رابط کاربری**.
---
## ۱. خلاصه تصمیم
| موضوع | تصمیم |
| --- | --- |
| رابط کاربری | فرم ورود همان است. با IAM روشن، یک پرش بی‌صدای `prompt=none` و صفحه `/auth/iam/callback` اضافه شده تا ورود مشترک با همکار کار کند |
| جای اتصال | فقط همان یک نقطه‌ای که رمز بررسی می‌شد (`AuthService.login`) |
| پیش‌فرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز |
| در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمین‌ها قطع نمی‌شود |
| تغییر دیتابیس | دو جدول **جدید** (`AdminSession`، `AppSession`) برای ابطال نشست؛ هیچ جدول موجودی تغییر نکرده |
| ورود مشتری (OTP پیامکی) | کد همچنان اینجا تولید و بررسی می‌شود؛ نتیجه به IAM اعلام می‌گردد تا نشست و خروج سراسری مرکزی شود |
جریان ورود ادمین پس از فعال شدن:
```
پنل ادمین ──POST /api/admin/auth/login──▶ API (NestJS)
(همان فرم و همان بدنه) │
├─ IAM روشن؟ ──خیر──▶ bcrypt محلی (رفتار فعلی)
│
└─بله─▶ POST {IAM}/api/v1/auth/login
│
┌─────────────────┴─────────────────┐
تأیید شد رد/خطا/در دسترس نبود
│ │
صدور همان JWT محلی fallback ⇒ bcrypt محلی
exclusive ⇒ رد ورود
```
پاسخ اندپوینت در هر دو حالت **دقیقاً یکسان** است (`api_token`, `roles`, …)،
بنابراین پنل ادمین هیچ تفاوتی نمی‌بیند.
---
## ۲. فایل‌ها
| مسیر | نقش |
| --- | --- |
| `apps/api/src/modules/iam/iam.service.ts` | ارتباط با IAM و نگاشت خطاها |
| `apps/api/src/modules/iam/iam-logout.service.ts` | تأیید توکن خروج سراسری با JWKS (بدون وابستگی جدید) |
| `apps/api/src/modules/iam/iam.controller.ts` | دریافت اعلان خروج سراسری |
| `apps/api/src/modules/iam/iam.module.ts` | ماژول Global (تا زمان روشن شدن، بی‌اثر) |
| `apps/api/src/modules/sessions/admin-session.service.ts` | نشست‌های قابل ابطال (jti) |
| `apps/api/src/modules/sessions/admin-session.module.ts` | ماژول Global نشست‌ها |
| `prisma/migrations/20260925120000_admin_session/` | جدول `AdminSession` |
| `apps/api/src/modules/**/*.spec.ts` | تست هر چهار سرویس بالا |
تغییر روی کد موجود، حداقلی است:
- `auth.service.ts` — یک `bcrypt.compare` به متد `verifyPassword` منتقل شد و توکن
صادرشده (در صورت روشن بودن ردیابی نشست) ثبت می‌شود
- `auth.controller.ts` — افزودن `@Ip()` و `user-agent`
- `jwt.strategy.ts` — یک بررسی «نشست باطل نشده باشد» (در حالت خاموش، بدون کوئری)
- `admin-users.service.ts` — غیرفعال کردن حساب یا تغییر رمز، نشست‌ها را می‌بندد
- `app.module.ts` — ثبت `IamModule` و `AdminSessionModule`
- `prisma/schema.prisma` — فقط مدل جدید `AdminSession` (+ رابطه روی `AdminUser`)
---
## ۳. فعال‌سازی
### گام ۱ — ثبت این سامانه به‌عنوان کلاینت در IAM
باید کلاینت از نوع `confidential` و **`is_trusted: true`** باشد؛ فقط چنین
کلاینتی اجازه «ورود واگذارشده» دارد.
```bash
curl -X POST https://auth.example.com/api/v1/admin/clients \
-H "Authorization: Bearer $IAM_ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{
"client_id": "goldbank_api",
"client_name": "سامانه بانک طلا",
"client_type": "confidential",
"redirect_uris": ["https://goldbank.example.com/unused"],
"allowed_scopes": ["openid", "profile", "email"],
"is_trusted": true
}'
```
> `redirect_uris` آدرس callback فرانت است و باید دقیقاً با صفحه‌ای که مرورگر
> باز می‌کند یکی باشد. برای اپ کاربر و پنل ادمین، و برای دولوپ و پروداکشن،
> هر کدام یک خط جدا. جزئیات در بخش «ورود مشترک با همکار».
### گام ۲ — تنظیم `.env`
```dotenv
IAM_ENABLED=true
IAM_ISSUER=https://auth.example.com
IAM_CLIENT_ID=goldbank_api
IAM_CLIENT_SECRET=<راز دریافتی>
IAM_TENANT_SLUG=montzar
IAM_LOGIN_DELEGATION=fallback
```
همین. سرویس را ری‌استارت کنید؛ هیچ مایگریشنی لازم نیست.
### گام ۳ — بازگشت به عقب
`IAM_ENABLED=false` و ری‌استارت. تمام.
---
## ۴. حالت‌های `IAM_LOGIN_DELEGATION`
| مقدار | رفتار |
| --- | --- |
| `off` | IAM اصلاً در ورود دخالت نمی‌کند |
| `fallback` (پیش‌فرض) | اول IAM؛ اگر کاربر آنجا نبود یا سرویس در دسترس نبود، رمز محلی بررسی می‌شود — مناسب مهاجرت تدریجی |
| `exclusive` | فقط IAM؛ رمز محلی پذیرفته نمی‌شود و خرابی IAM یعنی توقف ورود |
**پیشنهاد مهاجرت:** با `fallback` شروع کنید، بعد از انتقال همه ادمین‌ها به IAM
و اطمینان از پایداری، به `exclusive` بروید.
---
## ۵. نکات عملیاتی
### محدودیت نرخ
IAM روی «IP» و «هویت» محدودیت نرخ دارد. چون درخواست‌ها از سرور API می‌آیند،
IP کاربر نهایی با فیلد `client_ip` فرستاده می‌شود؛ در غیر این صورت پس از چند
ورود، کل پنل ادمین ۴۲۹ می‌گرفت.
اگر API پشت پراکسی است، مطمئن شوید Nest `trust proxy` دارد وگرنه `@Ip()`
همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
### احراز دو مرحله‌ای
فرم ورود این پنل فقط «موبایل + رمز» است و **هیچ مرحله‌ی ورود کدی ندارد**؛ پس جایی
برای گرفتن کد عامل دوم وجود ندارد و این مسیر نمی‌تواند آن را تکمیل کند. ورود چنین
حسابی با پیام روشن رد می‌شود (نه خطای مرموز). سه گزینه:
1. برای حساب‌هایی که از این پنل وارد می‌شوند 2FA را در IAM فعال نکنید (ساده‌ترین)؛
2. مرحله‌ی «ورود کد» را به همین فرم اضافه کنید؛ آن‌وقت می‌توان مثل سامانه «همکار»
کد را واسطه کرد یا با کانال خودِ سامانه فرستاد
(`/api/v1/auth/mfa/verify` و `/api/v1/auth/mfa/delegate` در IAM آماده‌اند)؛
3. یا جریان مرورگری SSO را برای این پنل به کار بگیرید که خودش صفحه کد را می‌دهد.
> در سامانه «همکار» این محدودیت وجود ندارد، چون ورود کاربر عادی آنجا از قبل مرحله‌ی
> «ورود کد» (پیامک) دارد و همان مرحله برای عامل دوم استفاده می‌شود.
### ابطال نشست
توکن ادمین خودبسنده و ۱۲ ساعته است، پس تا پیش از این **هیچ‌چیز** نمی‌توانست نشست را
زودتر تمام کند: نه غیرفعال کردن حساب، نه خروج سراسری در IAM. حالا هر توکن صادرشده
یک `jti` دارد که در جدول `AdminSession` ثبت می‌شود و درخواست بعدیِ نشستِ باطل‌شده
۴۰۱ می‌گیرد.
```
IAM: ابطال نشست / خروج سراسری
│ logout_token (RS256، امضاشده)
▼
POST /api/iam/backchannel-logout ← این آدرس را در IAM به‌عنوان
│ backchannel_logout_uri کلاینت ثبت کنید
├─ تأیید امضا با JWKS، بررسی iss/aud/events و نبودن nonce
└─ ابطال نشست‌های متناظر (sid، یا همه نشست‌های sub)
│
▼
درخواست بعدی با آن توکن ⇒ ۴۰۱
```
کلید تنظیم: `AUTH_SESSION_TRACKING`
| مقدار | رفتار |
| --- | --- |
| خالی (پیش‌فرض) | همراه `IAM_ENABLED` روشن/خاموش می‌شود — تا ابطال مرکزی بی‌اثر نماند |
| `true` | روشن، حتی بدون IAM (ابطال محلی: غیرفعال کردن حساب، تغییر رمز) |
| `false` | خاموش؛ دقیقاً رفتار قبلی (بدون `jti`، بدون کوئری اضافه) و ابطال مرکزی بی‌اثر |
نکته‌های عملی:
- توکن‌هایی که **پیش از** روشن کردن این قابلیت صادر شده‌اند `jti` ندارند و تا انقضا
معتبر می‌مانند؛ یعنی روشن کردن این کلید کسی را یک‌باره بیرون نمی‌اندازد.
- ردیف نشستِ گم‌شده «باطل» تلقی می‌شود، پس پاک کردن جدول یعنی خروج همه.
- اندپوینت خروج سراسری عمداً بدون احراز هویت است (اعتبارش از امضای توکن می‌آید) و
همیشه ۲۰۰ برمی‌گرداند تا IdP بی‌نهایت تلاش مجدد نکند؛ دلیل رد شدن در لاگ می‌آید.
- تنها تغییر شِما یک جدول **جدید** است؛ هیچ جدول موجودی ستون نگرفته. مایگریشن:
`pnpm --filter @gold-bank/api exec prisma migrate deploy`.
### لاگ امنیتی
هر ورود واگذارشده در IAM با IP واقعی کاربر و `client_id` این سامانه ثبت
می‌شود، پس گزارش‌های امنیتی متمرکز کامل باقی می‌مانند.
---
## ۶. ورود مشترک با همکار
کوکی نشست روی خود IAM است (`iam.montest.ir`) و بین دامنه‌ها مشترک نمی‌شود.
ورود در یک سامانه فقط وقتی سامانهٔ دیگر را هم باز می‌کند که:
1. ورود محلی بلیت مرورگر گرفته باشد و فرانت به `iamBootstrapUrl` رفته باشد.
2. سامانهٔ دوم بدون توکن محلی، `prompt=none` بزند و callback **همان صفحه** باشد.
3. همان شماره موبایل در سامانهٔ دوم از قبل حساب داشته باشد. IAM حساب gold-bank
یا همکار را نمی‌سازد.
کلاینت gold-bank جدا از `hamkar_server` است (`goldbank_api`). آدرس‌ها عیناً،
هر کدام یک خط در فرم IAM و با کاما در env:
| نقش | callback (آدرس‌های بازگشت) | بعد از خروج |
| --- | --- | --- |
| اپ کاربر | `IAM_REDIRECT_URI` و `IAM_REDIRECT_URIS` | `IAM_POST_LOGOUT_REDIRECT_URI` و `IAM_ALLOWED_RETURN_URLS` |
| پنل ادمین | `IAM_ADMIN_REDIRECT_URI` و `IAM_ADMIN_REDIRECT_URIS` | `IAM_ADMIN_POST_LOGOUT_REDIRECT_URI` و `IAM_ADMIN_ALLOWED_RETURN_URLS` |
فرانت آدرس خودش را می‌فرستد (`origin` + `/auth/iam/callback`؛ پنل شعبه پیشوند
`/branch` دارد). اگر آن رشته در فهرست نباشد، بررسی بی‌صدا انجام نمی‌شود و
صفحهٔ لاگین محلی می‌ماند. خروج هم اگر مقصدش ثبت نشده باشد به IAM نمی‌رود.
وقتی `IAM_ENABLED` خاموش است، `sso/start` مقدار `enabled: false` می‌دهد و
`sso/logout-url` مقدار `logout_url: null`. هیچ پیام خطایی برای خروج لازم نیست.
---
## ۷. تست
```bash
cd apps/api
pnpm test # ۱۱۵ تست، بدون دیتابیس و بدون شبکه
pnpm test -- iam-logout.service # تأیید توکن خروج سراسری با کلید RSA واقعی
pnpm test -- admin-session # نشست‌های قابل ابطال
```
تست‌ها بدون دیتابیس و بدون شبکه اجرا می‌شوند و مهم‌ترین چیزی که تضمین می‌کنند
این است: **با خاموش بودن قابلیت، هیچ درخواستی به IAM نمی‌رود و ورود دقیقاً مثل
قبل کار می‌کند**؛ و با روشن بودن، خرابی IAM ورود ادمین را قطع نمی‌کند.
+181
View File
@@ -0,0 +1,181 @@
-- ============================================================================
-- پیش‌بررسی دوم — سه سؤال باز
-- ============================================================================
--
-- ⚠️ مثل فایل اول: فقط SELECT COUNT / SUM. صفر INSERT/UPDATE/DELETE.
-- هیچ ستون متنی در خروجی نیست.
--
-- اجرا:
-- mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck-2.sql > out2.txt
--
-- مرجع: docs/DATA-MIGRATION-MAP.md §۱۰.۴ · §۱۰.۶
-- ============================================================================
-- ---------------------------------------------------------------------------
-- 🔴 سؤال ۱ (مسدودکننده) — مبلغ سند مالی در کدام ستون است؟
--
-- در خروجی اول، SUM(cashes.remain) و SUM(cashes.rial_remain) هر دو NULL
-- برگشتند — یعنی در هر ۶۴۸ ردیف NULL هستند. پس مبلغ جای دیگری است.
-- بدون جواب این، LedgerEntry.amount خالی پر می‌شود و کل دفتر مالی
-- بی‌مبلغ منتقل می‌شود.
-- ---------------------------------------------------------------------------
SELECT '=== 1. WHERE IS THE LEDGER AMOUNT? ===' AS section;
-- هر ستون عددی cashes: چند ردیف مقدار غیرNULL و غیرصفر دارند؟
SELECT 'input' AS col, COUNT(input) AS not_null, SUM(input <> 0) AS nonzero FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'output', COUNT(output), SUM(output <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'remain', COUNT(remain), SUM(remain <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_input', COUNT(rial_input), SUM(rial_input <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_output', COUNT(rial_output), SUM(rial_output <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'rial_remain', COUNT(rial_remain), SUM(rial_remain <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'value', COUNT(value), SUM(value <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'original_value', COUNT(original_value), SUM(original_value <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'balance', COUNT(balance), SUM(balance <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'price', COUNT(price), SUM(price <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'price_per_gram', COUNT(price_per_gram), SUM(price_per_gram <> 0) FROM cashes WHERE deleted_at IS NULL
UNION ALL SELECT 'discount', COUNT(discount), SUM(discount <> 0) FROM cashes WHERE deleted_at IS NULL;
-- جمع کل هر ستونی که مقدار دارد → مبنای تطبیق برای ابزار
SELECT
CAST(SUM(input) AS CHAR) AS sum_input,
CAST(SUM(output) AS CHAR) AS sum_output,
CAST(SUM(value) AS CHAR) AS sum_value,
CAST(SUM(original_value) AS CHAR) AS sum_original_value,
CAST(SUM(rial_input) AS CHAR) AS sum_rial_input,
CAST(SUM(rial_output) AS CHAR) AS sum_rial_output,
CAST(SUM(price) AS CHAR) AS sum_price
FROM cashes WHERE deleted_at IS NULL;
-- به تفکیک نوع سند: کدام نوع از کدام ستون استفاده می‌کند؟
-- (type: 9=سفارش 1=آب‌شده 2=حواله 3=چک 4=پولی)
SELECT type AS cash_type,
COUNT(*) AS n,
SUM(input IS NOT NULL AND input <> 0) AS has_input,
SUM(output IS NOT NULL AND output <> 0) AS has_output,
SUM(value IS NOT NULL AND value <> 0) AS has_value,
SUM(rial_input IS NOT NULL AND rial_input <> 0) AS has_rial_in,
SUM(rial_output IS NOT NULL AND rial_output <> 0) AS has_rial_out,
SUM(price IS NOT NULL AND price <> 0) AS has_price
FROM cashes WHERE deleted_at IS NULL
GROUP BY type ORDER BY type;
-- آیا اصلاً ردیفی هست که هیچ مبلغی نداشته باشد؟
SELECT 'rows_with_no_amount_at_all' AS metric, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL
AND COALESCE(input,0)=0 AND COALESCE(output,0)=0
AND COALESCE(value,0)=0 AND COALESCE(rial_input,0)=0
AND COALESCE(rial_output,0)=0 AND COALESCE(price,0)=0;
-- ---------------------------------------------------------------------------
-- 🔴 سؤال ۲ — موبایل تکراری: آیا قاعده‌ی «مسئول فروش برنده» کافی است؟
--
-- ۲۷ گروه تکراری وجود دارد. اگر در یک گروه هر دو ردیف مشتری عادی باشند
-- (type != 1)، آن قاعده جواب نمی‌دهد و قاعده‌ی پشتیبان لازم است.
-- ---------------------------------------------------------------------------
SELECT '=== 2. DUPLICATE MOBILE — is the "sales expert wins" rule enough? ===' AS section;
-- در هر گروه تکراری، چند ردیف type=1 (کارشناس) هست؟
-- experts_in_group = 1 → قاعده جواب می‌دهد
-- experts_in_group = 0 → هیچ کارشناسی نیست، قاعده بی‌اثر → پشتیبان لازم
-- experts_in_group > 1 → چند کارشناس با یک شماره، قاعده مبهم
SELECT experts_in_group, COUNT(*) AS n_groups, SUM(rows_in_group) AS n_rows
FROM (
SELECT mobile,
COUNT(*) AS rows_in_group,
SUM(type = 1) AS experts_in_group
FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) g
GROUP BY experts_in_group ORDER BY experts_in_group;
-- آیا ردیف‌های تکراری updated_at متفاوت دارند؟ (قاعده‌ی پشتیبان پیشنهادی)
-- اگر n_ties > 0 یعنی updated_at هم تفکیک نمی‌کند
SELECT 'groups_where_updated_at_ties' AS metric, COUNT(*) AS n
FROM (
SELECT mobile, COUNT(*) AS c, COUNT(DISTINCT updated_at) AS d
FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1 AND COUNT(DISTINCT updated_at) < COUNT(*)
) t;
-- آیا ردیف‌های تکراری بدهی دارند؟ ادغامشان یعنی جمع‌کردن بدهی
SELECT 'dup_groups_with_debt' AS metric, COUNT(*) AS n
FROM (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile
HAVING COUNT(*) > 1 AND (SUM(COALESCE(remain,0)) <> 0 OR SUM(COALESCE(rial_remain,0)) <> 0)
) t;
-- آیا ردیف‌های تکراری سفارش دارند؟
SELECT 'dup_rows_with_orders' AS metric, COUNT(DISTINCT c.id) AS n
FROM customers c
JOIN orders o ON o.customer_id = c.id AND o.deleted_at IS NULL
WHERE c.deleted_at IS NULL AND c.mobile IN (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
);
-- ---------------------------------------------------------------------------
-- سؤال ۳ — نگاشت تصویر محصول: سه ستون → چهار نقش ProductImageRole
--
-- مرجع: §۹ — file_id / mobile_pic_id / indicator_id_id
-- ---------------------------------------------------------------------------
SELECT '=== 3. PRODUCT IMAGE ROLES ===' AS section;
SELECT 'file_id (FEATURED?)' AS col, COUNT(file_id) AS not_null FROM product WHERE deleted_at IS NULL
UNION ALL SELECT 'mobile_pic_id (FEATURED_MOBILE?)', COUNT(mobile_pic_id) FROM product WHERE deleted_at IS NULL
UNION ALL SELECT 'indicator_id_id (?)', COUNT(indicator_id_id) FROM product WHERE deleted_at IS NULL;
-- products_productimage همان GALLERY است؟ چند تصویر per محصول؟
SELECT 'gallery_rows' AS metric, COUNT(*) AS n FROM products_productimage WHERE deleted_at IS NULL
UNION ALL SELECT 'gallery_distinct_products', COUNT(DISTINCT product_id) FROM products_productimage WHERE deleted_at IS NULL
UNION ALL SELECT 'gallery_orphan_image', SUM(i.id IS NULL)
FROM products_productimage pi LEFT JOIN image i ON i.id = pi.image_id WHERE pi.deleted_at IS NULL
UNION ALL SELECT 'gallery_orphan_product', SUM(p.id IS NULL)
FROM products_productimage pi LEFT JOIN product p ON p.id = pi.product_id WHERE pi.deleted_at IS NULL;
-- ستون w در products_productimage — آیا نقش را تفکیک می‌کند؟
SELECT 'prod_image.w' AS col, COALESCE(w,'(null)') AS val, COUNT(*) AS n
FROM products_productimage WHERE deleted_at IS NULL GROUP BY w ORDER BY n DESC LIMIT 10;
-- یتیم‌های تصویر محصول (file_id → image)
SELECT 'product.file_id orphan' AS ref, COUNT(*) AS orphans
FROM product p LEFT JOIN image i ON i.id = p.file_id
WHERE p.deleted_at IS NULL AND p.file_id IS NOT NULL AND i.id IS NULL
UNION ALL
SELECT 'product.mobile_pic_id orphan', COUNT(*)
FROM product p LEFT JOIN image i ON i.id = p.mobile_pic_id
WHERE p.deleted_at IS NULL AND p.mobile_pic_id IS NOT NULL AND i.id IS NULL;
-- ---------------------------------------------------------------------------
-- سؤال ۴ — کارشناس فروش (type=1): ۵۶ ردیف که به AdminUser می‌روند
-- مرجع: §۵.۷ · D17
-- ---------------------------------------------------------------------------
SELECT '=== 4. SALES EXPERTS (type=1 -> AdminUser) ===' AS section;
SELECT 'experts_total' AS metric, COUNT(*) AS n FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_password', SUM(password IS NOT NULL AND password <> '') FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_ncode', SUM(ncode IS NOT NULL AND ncode <> '') FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_city', SUM(city_id IS NOT NULL) FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_with_percent', SUM(percent IS NOT NULL AND percent <> 0) FROM customers WHERE deleted_at IS NULL AND type = 1
UNION ALL SELECT 'experts_referenced_as_reagent', COUNT(DISTINCT reagent_id) FROM customers WHERE deleted_at IS NULL AND reagent_id IS NOT NULL;
-- 🔴 حیاتی: آیا reagent_id واقعاً به customers اشاره می‌کند؟ (§۵.۷)
-- اگر orphan_in_customers صفر یا کم باشد → تأیید می‌شود
SELECT 'reagent_id orphan vs customers' AS ref, COUNT(*) AS orphans
FROM customers c LEFT JOIN customers r ON r.id = c.reagent_id
WHERE c.deleted_at IS NULL AND c.reagent_id IS NOT NULL AND r.id IS NULL;
-- آیا همه‌ی reagent_id ها به ردیف type=1 اشاره می‌کنند؟
SELECT 'reagent points to non-expert' AS ref, COUNT(*) AS n
FROM customers c JOIN customers r ON r.id = c.reagent_id
WHERE c.deleted_at IS NULL AND r.type <> 1;
SELECT '=== END ===' AS section;
+65
View File
@@ -0,0 +1,65 @@
-- ============================================================================
-- پیش‌بررسی سوم — فقط دو سؤال باقی‌مانده
-- ============================================================================
--
-- ⚠️ فقط 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_set,
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
-- The two columns have different collations (unicode_ci vs general_ci), so
-- both sides are pinned to one; without it MySQL refuses the comparison.
JOIN cities ci ON TRIM(ci.title) COLLATE utf8mb4_general_ci = TRIM(c.new_city) COLLATE utf8mb4_general_ci
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;
+321
View File
@@ -0,0 +1,321 @@
-- ============================================================================
-- پیش‌بررسی مهاجرت — فقط شمارش، صفر داده
-- ============================================================================
--
-- روی دیتابیس MySQL قدیمی (اسکیمای `oapi`) اجرا شود.
--
-- ⚠️ این فایل فقط SELECT COUNT / SUM / MIN / MAX دارد.
-- هیچ INSERT / UPDATE / DELETE / DROP / ALTER در آن نیست.
-- هیچ ستون متنی (نام، موبایل، کد ملی، آدرس) در خروجی ظاهر نمی‌شود.
--
-- اجرا:
-- mysql -u USER -p -h HOST -P 3305 oapi < migration-precheck.sql > out.txt
--
-- سپس محتوای out.txt کپی شود. خروجی فقط عدد است.
--
-- مرجع: docs/DATA-MIGRATION-MAP.md §۳ · §۵.۳ · §۵.۵ · §۵.۶
-- ============================================================================
-- ---------------------------------------------------------------------------
-- بخش ۱ — حجم کار: تعداد ردیف زنده و حذف‌شده‌ی نرم
-- مرجع: §۵.۶ — ردیف حذف‌شده نباید منتقل شود
-- ---------------------------------------------------------------------------
SELECT '=== 1. ROW COUNTS ===' AS section;
SELECT 'customers' AS tbl, COUNT(*) AS live, SUM(deleted_at IS NOT NULL) AS soft_deleted FROM customers
UNION ALL SELECT 'users', COUNT(*), SUM(deleted_at IS NOT NULL) FROM users
UNION ALL SELECT 'orders', COUNT(*), SUM(deleted_at IS NOT NULL) FROM orders
UNION ALL SELECT 'order_items', COUNT(*), SUM(deleted_at IS NOT NULL) FROM order_items
UNION ALL SELECT 'cashes', COUNT(*), SUM(deleted_at IS NOT NULL) FROM cashes
UNION ALL SELECT 'product', COUNT(*), SUM(deleted_at IS NOT NULL) FROM product
UNION ALL SELECT 'variant', COUNT(*), SUM(deleted_at IS NOT NULL) FROM products_productvarient
UNION ALL SELECT 'prod_image', COUNT(*), SUM(deleted_at IS NOT NULL) FROM products_productimage
UNION ALL SELECT 'category', COUNT(*), SUM(deleted_at IS NOT NULL) FROM category_collection
UNION ALL SELECT 'image', COUNT(*), SUM(deleted_at IS NOT NULL) FROM image
UNION ALL SELECT 'cities', COUNT(*), SUM(deleted_at IS NOT NULL) FROM cities
UNION ALL SELECT 'branches', COUNT(*), SUM(deleted_at IS NOT NULL) FROM branches
UNION ALL SELECT 'workshops', COUNT(*), SUM(deleted_at IS NOT NULL) FROM workshops
UNION ALL SELECT 'custom_orders', COUNT(*), SUM(deleted_at IS NOT NULL) FROM custom_orders
UNION ALL SELECT 'comments', COUNT(*), SUM(deleted_at IS NOT NULL) FROM comments
UNION ALL SELECT 'messages', COUNT(*), 0 FROM messages
UNION ALL SELECT 'notify', COUNT(*), SUM(deleted_at IS NOT NULL) FROM notify
UNION ALL SELECT 'fav', COUNT(*), 0 FROM fav
UNION ALL SELECT 'permission', COUNT(*), 0 FROM permission
UNION ALL SELECT 'config_statics', COUNT(*), SUM(deleted_at IS NOT NULL) FROM config_statics;
-- ---------------------------------------------------------------------------
-- بخش ۲ — 🔴 یکتایی: آیا @unique در Prisma می‌شکند؟
-- مرجع: §۳ — مبدأ هیچ unique index ندارد
-- هر عدد غیرصفر یعنی مهاجرت در میانه متوقف می‌شود
-- ---------------------------------------------------------------------------
SELECT '=== 2. UNIQUENESS (any nonzero = migration breaks) ===' AS section;
-- چند *گروه* موبایل تکراری، و چند ردیف درگیرند
SELECT 'dup_mobile_groups' AS metric, COUNT(*) AS n FROM (
SELECT mobile FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d
UNION ALL
SELECT 'dup_mobile_rows', COALESCE(SUM(c),0) FROM (
SELECT COUNT(*) AS c FROM customers
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d2
UNION ALL
SELECT 'dup_ncode_groups', COUNT(*) FROM (
SELECT ncode FROM customers
WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode <> ''
GROUP BY ncode HAVING COUNT(*) > 1
) d3
UNION ALL
SELECT 'dup_ncode_rows', COALESCE(SUM(c),0) FROM (
SELECT COUNT(*) AS c FROM customers
WHERE deleted_at IS NULL AND ncode IS NOT NULL AND ncode <> ''
GROUP BY ncode HAVING COUNT(*) > 1
) d4;
-- تهی‌بودن: Prisma هر دو را NOT NULL می‌خواهد
SELECT 'empty_mobile' AS metric, SUM(mobile IS NULL OR mobile = '') AS n FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'empty_ncode', SUM(ncode IS NULL OR ncode = '') FROM customers WHERE deleted_at IS NULL;
-- تداخل شماره بین مشتری و کاربر ادمین (D21: «کارشناس برنده است»)
SELECT 'mobile_collision_customer_vs_user' AS metric, COUNT(*) AS n
FROM customers c JOIN users u ON u.mobile = c.mobile
WHERE c.deleted_at IS NULL AND u.deleted_at IS NULL
AND c.mobile IS NOT NULL AND c.mobile <> '';
-- یکتایی در جدول users هم بررسی شود
SELECT 'dup_user_mobile_groups' AS metric, COUNT(*) AS n FROM (
SELECT mobile FROM users
WHERE deleted_at IS NULL AND mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile HAVING COUNT(*) > 1
) d;
-- ---------------------------------------------------------------------------
-- بخش ۳ — 🔴 نام: آیا D0 درست است؟
-- مرجع: §۲ — fname/lname از قبل وجود دارند
-- ---------------------------------------------------------------------------
SELECT '=== 3. NAME PARTS (does `name` agree with fname+lname?) ===' AS section;
SELECT
SUM(fname IS NULL OR fname = '') AS empty_fname,
SUM(lname IS NULL OR lname = '') AS empty_lname,
SUM(name IS NULL OR name = '') AS empty_name,
-- ردیف‌هایی که name با اجزا نمی‌خواند → trigger مقصد name را بازنویسی می‌کند
SUM(TRIM(COALESCE(name,'')) <> TRIM(CONCAT(COALESCE(fname,''),' ',COALESCE(lname,'')))) AS name_mismatch,
-- اجزا تهی ولی name پر → تنها حالتی که تجزیه لازم است
SUM((fname IS NULL OR fname = '') AND (lname IS NULL OR lname = '')
AND name IS NOT NULL AND name <> '') AS parts_empty_name_set
FROM customers WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۴ — enum ها: آیا مقداری بی‌نگاشت می‌ماند؟
-- مرجع: §۵.۳ (type) · §۵.۵ (verify) · §۷.۳ (sts)
-- ---------------------------------------------------------------------------
SELECT '=== 4. ENUMS ===' AS section;
-- type: 1..5 نگاشت دارند. NULL یا خارج از بازه → بی‌صدا APP_SHOPKEEPER می‌شود
SELECT 'customers.type' AS col, CAST(type AS CHAR) AS val, COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL GROUP BY type ORDER BY type;
-- sts و active: دو سوییچ مجزا
SELECT 'customers.sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL GROUP BY sts
UNION ALL
SELECT 'customers.active', CAST(active AS CHAR), COUNT(*)
FROM customers WHERE deleted_at IS NULL GROUP BY active
UNION ALL
SELECT 'customers.is_archive', CAST(is_archive AS CHAR), COUNT(*)
FROM customers WHERE deleted_at IS NULL GROUP BY is_archive;
-- دو ستون تأیید → یک enum چهارحالته. حالت (0,1) در مقصد نماینده ندارد
SELECT 'verify_step_combo' AS col,
CONCAT(COALESCE(CAST(sts_verify_step_one AS CHAR),'NULL'), ',',
COALESCE(CAST(sts_verify_step_two AS CHAR),'NULL')) AS val,
COUNT(*) AS n
FROM customers WHERE deleted_at IS NULL
GROUP BY sts_verify_step_one, sts_verify_step_two;
-- وضعیت سفارش عمده: 0..6 نگاشت دارند
SELECT 'orders.sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM orders WHERE deleted_at IS NULL GROUP BY sts ORDER BY sts;
-- اسناد مالی: type → LedgerSourceType (9,1,2,3,4)، confirmed حیاتی است (D24)
SELECT 'cashes.type' AS col, CAST(type AS CHAR) AS val, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL GROUP BY type ORDER BY type;
SELECT 'cashes.confirmed' AS col, CAST(confirmed AS CHAR) AS val, COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL GROUP BY confirmed;
-- ---------------------------------------------------------------------------
-- بخش ۵ — 🔴 پول و وزن: مبنای تطبیق + تأیید اینکه آستانه لازم است
-- مرجع: §۴ — ۱۱۰ ستون double، «صفر اختلاف» دست‌نیافتنی است
-- ---------------------------------------------------------------------------
SELECT '=== 5. MONEY / WEIGHT TOTALS (migration must match these) ===' AS section;
SELECT
COUNT(*) AS n_customers,
CAST(SUM(remain) AS CHAR) AS sum_gold_debt,
CAST(SUM(rial_remain) AS CHAR) AS sum_rial_debt,
CAST(SUM(gram_level) AS CHAR) AS sum_credit_limit,
SUM(remain <> 0) AS n_nonzero_gold,
SUM(rial_remain <> 0) AS n_nonzero_rial,
SUM(remain < 0) AS n_negative_gold,
SUM(rial_remain < 0) AS n_negative_rial
FROM customers WHERE deleted_at IS NULL;
-- 🔴 شاهدِ مشکل double: چند ردیف مقداری دارند که در Decimal(14,4) جا نمی‌شود؟
-- عدد غیرصفر = گِردکردن رخ می‌دهد = «صفر اختلاف» ناممکن است
SELECT 'gold_needs_rounding' AS metric, SUM(remain <> ROUND(remain, 4)) AS n
FROM customers WHERE deleted_at IS NULL
UNION ALL
SELECT 'credit_needs_rounding', SUM(gram_level <> ROUND(gram_level, 4))
FROM customers WHERE deleted_at IS NULL
UNION ALL
SELECT 'order_weight_needs_rounding', SUM(w <> ROUND(w, 4))
FROM orders WHERE deleted_at IS NULL
UNION ALL
SELECT 'cash_amount_needs_rounding', SUM(remain <> ROUND(remain, 4))
FROM cashes WHERE deleted_at IS NULL;
-- فقط اسناد تأییدشده در موجودی حساب می‌شوند (D24)
SELECT 'confirmed_only' AS scope,
CAST(SUM(remain) AS CHAR) AS sum_gold,
CAST(SUM(rial_remain) AS CHAR) AS sum_rial,
COUNT(*) AS n
FROM cashes WHERE deleted_at IS NULL AND confirmed = 1;
-- ---------------------------------------------------------------------------
-- بخش ۶ — یکپارچگی ارجاعی (مبدأ صفر FK دارد — §۱۱)
-- ارجاع یتیم = ردیفی که مهاجرت نمی‌تواند وصل کند
-- ---------------------------------------------------------------------------
SELECT '=== 6. ORPHAN REFERENCES (no FKs exist in source) ===' AS section;
SELECT 'customer.city_id -> cities' AS ref, COUNT(*) AS orphans
FROM customers c LEFT JOIN cities ci ON ci.id = c.city_id
WHERE c.deleted_at IS NULL AND c.city_id IS NOT NULL AND ci.id IS NULL
UNION ALL
SELECT 'customer.reagent_id -> users', COUNT(*)
FROM customers c LEFT JOIN users u ON u.id = c.reagent_id
WHERE c.deleted_at IS NULL AND c.reagent_id IS NOT NULL AND u.id IS NULL
UNION ALL
SELECT 'customer.image_id -> image', COUNT(*)
FROM customers c LEFT JOIN image i ON i.id = c.image_id
WHERE c.deleted_at IS NULL AND c.image_id IS NOT NULL AND i.id IS NULL
UNION ALL
SELECT 'order.customer_id -> customers', COUNT(*)
FROM orders o LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.deleted_at IS NULL AND o.customer_id IS NOT NULL AND c.id IS NULL
UNION ALL
SELECT 'order_items.order_id -> orders', COUNT(*)
FROM order_items oi LEFT JOIN orders o ON o.id = oi.order_id
WHERE oi.deleted_at IS NULL AND oi.order_id IS NOT NULL AND o.id IS NULL
UNION ALL
SELECT 'order_items.variant_id -> variant', COUNT(*)
FROM order_items oi LEFT JOIN products_productvarient v ON v.id = oi.variant_id
WHERE oi.deleted_at IS NULL AND oi.variant_id IS NOT NULL AND v.id IS NULL
UNION ALL
SELECT 'cashes.customer_id -> customers', COUNT(*)
FROM cashes ca LEFT JOIN customers c ON c.id = ca.customer_id
WHERE ca.deleted_at IS NULL AND ca.customer_id IS NOT NULL AND c.id IS NULL
UNION ALL
SELECT 'variant.product_id_id -> product', COUNT(*)
FROM products_productvarient v LEFT JOIN product p ON p.id = v.product_id_id
WHERE v.deleted_at IS NULL AND v.product_id_id IS NOT NULL AND p.id IS NULL
UNION ALL
SELECT 'fav.customer_id -> customers', COUNT(*)
FROM fav f LEFT JOIN customers c ON c.id = f.customer_id
WHERE c.id IS NULL
UNION ALL
SELECT 'fav.product_id -> product', COUNT(*)
FROM fav f LEFT JOIN product p ON p.id = f.product_id
WHERE p.id IS NULL;
-- 🔴 fav هیچ PK ندارد → ردیف تکراری ممکن است (§۱۰)
SELECT 'fav_duplicate_pairs' AS metric, COUNT(*) AS n FROM (
SELECT customer_id, product_id FROM fav
GROUP BY customer_id, product_id HAVING COUNT(*) > 1
) d;
-- permission هم PK ندارد
SELECT 'permission_duplicate_rows' AS metric, COUNT(*) AS n FROM (
SELECT rid, ut FROM permission GROUP BY rid, ut HAVING COUNT(*) > 1
) d;
-- ---------------------------------------------------------------------------
-- بخش ۷ — ستون‌های مبهم: نوع واقعی داده
-- مرجع: §۸ — ayar متنی یا عددی؟ تاریخ جلالی یا میلادی؟
-- ---------------------------------------------------------------------------
SELECT '=== 7. AMBIGUOUS COLUMNS ===' AS section;
-- ayar در مبدأ varchar(20) است ولی مقصد Int? — مقدار غیرعددی می‌شکند
SELECT 'ayar_total' AS metric, COUNT(*) AS n FROM cashes WHERE deleted_at IS NULL AND ayar IS NOT NULL AND ayar <> ''
UNION ALL
SELECT 'ayar_non_numeric', COUNT(*) FROM cashes
WHERE deleted_at IS NULL AND ayar IS NOT NULL AND ayar <> '' AND ayar NOT REGEXP '^[0-9]+$';
-- recive_date / proceed_at در مبدأ varchar هستند — جلالی یا میلادی؟
-- طول رشته و اینکه با 13 یا 14 شروع می‌شود، نوع تقویم را لو می‌دهد
SELECT 'recive_date_set' AS metric, COUNT(*) AS n FROM cashes WHERE deleted_at IS NULL AND recive_date IS NOT NULL AND recive_date <> ''
UNION ALL
SELECT 'recive_date_starts_13x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '13%'
UNION ALL
SELECT 'recive_date_starts_14x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '14%'
UNION ALL
SELECT 'recive_date_starts_20x', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND recive_date LIKE '20%'
UNION ALL
SELECT 'proceed_at_set', COUNT(*) FROM cashes WHERE deleted_at IS NULL AND proceed_at IS NOT NULL AND proceed_at <> '';
-- ستون‌های داده‌ی غیرقابل‌بازسازی (§۵.۲ گروه ج) — چند ردیف واقعاً مقدار دارند؟
SELECT 'has_birth_date' AS metric, COUNT(*) AS n FROM customers WHERE deleted_at IS NULL AND birth_date IS NOT NULL
UNION ALL SELECT 'has_marriage_date', COUNT(*) FROM customers WHERE deleted_at IS NULL AND marriage_date IS NOT NULL
UNION ALL SELECT 'has_token_mobile', COUNT(*) FROM customers WHERE deleted_at IS NULL AND token_mobile IS NOT NULL AND token_mobile <> ''
UNION ALL SELECT 'has_new_city', COUNT(*) FROM customers WHERE deleted_at IS NULL AND new_city IS NOT NULL AND new_city <> '';
-- فرمت رمز عبور: bcrypt با $2y$ شروع می‌شود. اگر نه، hash دیگری است
SELECT 'pw_bcrypt' AS metric, SUM(password LIKE '$2%') AS n FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'pw_other', SUM(password IS NOT NULL AND password <> '' AND password NOT LIKE '$2%') FROM customers WHERE deleted_at IS NULL
UNION ALL SELECT 'pw_empty', SUM(password IS NULL OR password = '') FROM customers WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۸ — جداول پشتیبان: آیا ردیفی دارند که `customers` ندارد؟
-- مرجع: §۱.۱ — قبل از نادیده‌گرفتن باید شمرده شوند
-- ---------------------------------------------------------------------------
SELECT '=== 8. BACKUP TABLES (safe to ignore?) ===' AS section;
SELECT 'customers' AS tbl, COUNT(*) AS n, CAST(MAX(id) AS CHAR) AS max_id FROM customers
UNION ALL SELECT 'customers_copy1', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_copy1
UNION ALL SELECT 'customers_copy2', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_copy2
UNION ALL SELECT 'customers_test', COUNT(*), CAST(MAX(id) AS CHAR) FROM customers_test;
-- شناسه‌هایی که در کپی هستند ولی در جدول اصلی نیستند → یعنی ردیف حذف شده
SELECT 'ids_only_in_copy1' AS metric, COUNT(*) AS n
FROM customers_copy1 k LEFT JOIN customers c ON c.id = k.id WHERE c.id IS NULL
UNION ALL
SELECT 'ids_only_in_copy2', COUNT(*)
FROM customers_copy2 k LEFT JOIN customers c ON c.id = k.id WHERE c.id IS NULL;
-- ---------------------------------------------------------------------------
-- بخش ۹ — تک‌فروشی (به تأخیر افتاده، §۷.۲) — فقط حجم، برای برنامه‌ریزی آینده
-- ---------------------------------------------------------------------------
SELECT '=== 9. RETAIL (deferred — size only) ===' AS section;
SELECT 'orders_orders' AS tbl, COUNT(*) AS live, SUM(deleted_at IS NOT NULL) AS soft_deleted FROM orders_orders
UNION ALL SELECT 'orders_orderitem', COUNT(*), SUM(deleted_at IS NOT NULL) FROM orders_orderitem
UNION ALL SELECT 'login', COUNT(*), SUM(deleted_at IS NOT NULL) FROM login;
-- 🔴 sts=7 در خرده «تحویل مشتری» است ولی در عمده «کنسل». چند ردیف درگیرند؟
SELECT 'retail_sts' AS col, CAST(sts AS CHAR) AS val, COUNT(*) AS n
FROM orders_orders WHERE deleted_at IS NULL GROUP BY sts ORDER BY sts;
SELECT '=== END ===' AS section;
+304
View File
@@ -0,0 +1,304 @@
# 00 — INVENTORY & MAPPING (Phase 0)
Read-only inventory of all four reference/target pairs, plus the mapping tables
that drive Phase 1 and Phase 2.
**Method:** seven parallel read-only subagents, one per project, each citing
`path:line`. Their raw output lives in the `_inv-*.md` files next to this one;
this document is the consolidated map. Claims marked ✔ below were additionally
re-verified by the main session by reading the cited lines directly.
| Detail inventory | Project | Lines |
|---|---|---|
| `_inv-ref-server.md` | `henza_server` (reference backend) | 1313 |
| `_inv-tgt-api.md` | `apps/api` (target backend) | 1179 |
| `_inv-ref-admin.md` | `henza_admin` (reference admin) | 1705 |
| `_inv-tgt-admin.md` | `apps/admin-panel` (target admin) | 1261 |
| `_inv-ref-pwa.md` | `henza_pwa` (reference user app) | 1178 |
| `_inv-tgt-userapp.md` | `apps/user-app` (target v1) | 612 |
| `_inv-tgt-userappv2.md` | `apps/user-app-v2` (target v2) | 700 |
---
## 0. The single most important finding — the domain is NOT gold banking
The kickoff brief asks to audit "gold price, gram/weight, fees, commissions,
spread, wallet/balance, buy/sell orders, deposit/withdraw, transfers, KYC levels,
daily limits, payment callbacks, live price sockets". **Most of that does not
exist in the reference**, so it cannot be a parity gap.
`henza_server` + `henza_pwa` are a **gold-jewellery B2B wholesale + B2C
e-commerce** system (Henza Gold): a catalogue of physical SKUs priced from a gold
rate, a cart, orders, and a gram-denominated customer ledger.
Verified absences in the reference user app (`_inv-ref-pwa.md` §6.2):
| Brief's flow | Reality in `henza_pwa` |
|---|---|
| buy gold | ABSENT — nearest is add-to-cart for a physical jewellery SKU |
| sell gold | ABSENT |
| wallet | ABSENT — a read-only balance strip on the dashboard |
| deposit | ABSENT — nearest is submitting a *findoc* of type چک/حواله/آب‌شده |
| withdraw | ABSENT |
| transfer | ABSENT — findoc type 2 (`حواله`) only records a transfer note |
| KYC levels | PARTIAL — two flat flags, no levels |
| addresses | ABSENT — one free-text field on the user |
| tickets / support | ABSENT — a static WhatsApp FAB (`index.html:59-61`) |
| daily/min/max limits | only one exists: `gram_level` (`ApiMobileController.php:528-758`) |
| live price socket | ABSENT — `BROADCAST_DRIVER=null`, zero `ShouldBroadcast` events ✔ |
| payment callback | route `site/transaction/verify` is **not registered** anywhere |
| cron | `Console/Kernel::schedule()` is **empty** |
**Consequence for the plan:** the parity matrices will cover what the reference
actually has — catalogue, pricing formula, cart/orders, ledger ("اسناد"), customer
management, ACL — and will not manufacture rows for gold-banking features. Where
the *target* has something bank-like, it is an intentional addition, not a gap.
---
## 1. Stack mapping
| Layer | Reference | Target | Note |
|---|---|---|---|
| Backend | Laravel/PHP, 46 controllers, 57 models | NestJS 10 + Prisma 5, 25 modules, 29 models | total rewrite; port behaviour only |
| Admin | Vue 2 + Vuesax/Vuexy template, Vuex | Vue 3.5 + ant-design-vue 4.2.6, Pinia | total rewrite |
| User app | Vue 3 + Vite + vant + ant-design-vue 3 | Vue 3 + Vite, two design versions | closest pair |
| Money types | PHP floats throughout | `Prisma.Decimal`, **zero `Float` in schema** ✔ | target is *stricter* — keep it |
---
## 2. Backend mapping — `henza_server` ⇄ `apps/api`
### 2.1 Scale
| Metric | `henza_server` | `apps/api` |
|---|---|---|
| Routes / endpoints | **412** (admin 249, api 70, customer 48, site 30, web 15) — corrected, see below | **179** |
| Controllers | 46 | 25 |
| Models | 57 files (34 migrated tables + ~28 legacy Django-era tables) | 29 Prisma models |
| Enums | PHP static arrays | 14 Prisma enums |
| DTO / validation | **zero FormRequests**, all inline, several endpoints unvalidated | 8 DTO classes; most admin bodies untyped `Record<string, unknown>` |
| Seeders | 1, **empty** | `prisma/seed.ts` — user types, 144 rids, admins |
The 412 → 179 drop is expected, not automatically a gap: the reference counts
`Route::resource` expansions, ~28 dead legacy tables, a `site.php` storefront and
a `web.php` that the monorepo replaces with its own apps. **Which of the in-scope
routes are genuinely unmapped is Phase 1 / Phase 2 matrix work**, per route group.
**⚠️ Count correction (2026-09-22).** The **369** reported throughout Phase 0 is
**wrong**; the real total is **412**. Recounting the
live routes (excluding the 6 commented-out ones, expanding `Route::resource` ×7
and `Route::match(['get','post'])` ×2) gives **412**. The error was in
`admin.php`: §3.6 of `_inv-ref-server.md` recorded 117 explicit routes where the
file actually has **151**, so admin is **249**, not 215. Verified with
`grep -E "^\s*Route::(get|post|…)" | grep -v "^\s*//"` per file.
| File | Explicit | `match` ×2 | `resource` ×7 | Total |
|---|---|---|---|---|
| `admin.php` | 151 | 0 | 14 → 98 | **249** |
| `api.php` | 61 | 1 → 2 | 1 → 7 | **70** |
| `customer.php` | 18 | 1 → 2 | 4 → 28 | **48** |
| `site.php` | 30 | 0 | 0 | **30** |
| `web.php` | 13 | 1 → 2 | 0 | **15** |
| **Total** | | | | **412** |
**Scope split (D13, 2026-09-22).** The retail storefront is preserved but out of
scope, so the 412 divide as follows:
| Group | Endpoints | Scope |
|---|---|---|
| Admin panel — B2B | 218 | ✅ Phase 1 |
| B2B mobile app (`api.php`) | 70 | ✅ Phase 2 |
| Retail storefront — customer API (`customer.php`) | 48 | ⏸️ preserved, out of scope |
| Retail storefront — public site API (`site.php`) | 30 | ⏸️ preserved, out of scope |
| Retail storefront — admin management (wallet 6, `single` 15, `comment` 9, `get-analytics-tak` 1) | 31 | ⏸️ preserved, out of scope |
| `web.php` dev/ops closures, price cron, redirects | 15 | ⏸️ out of scope (dead/dev) |
| **Total** | **412** | **288 in scope · 124 out** |
**109 of the 412 are retail storefront**; 15 more are `web.php` dev/ops. Full
classification with evidence: `DECISIONS.md` § D13.
### 2.2 Core business logic — where each rule lives
| Rule | Reference | Target |
|---|---|---|
| **Gold price formula** ✔ | `app/Models/ProductVariant.php:183-190` | `apps/api/src/modules/...` (Phase 1 to map) |
| Price source | `app/Models/WebServiceGold.php` — `getPriceGold18Ayar()` divides by 10 (rial→toman) | `pricing` module |
| Order pipeline | `app/Http/Controllers/Controller.php` — `createSingleCustomerOrders`, `afterCreateOrder`, cashback at sts 6 | `orders.service.ts`, `admin-orders.service.ts` |
| Wholesale order + the only limit | `ApiMobileController.php:528-758` (`gram_level`) | `orders.service.ts` |
| Retail checkout | `Customer/OrderController.php:68-168` | `orders.service.ts` |
| Ledger / wallet | `packages/rooyesh/wallet/` — hash chain, pepper `"yhoenszeaf"` | `accounting/ledger.service.ts` (dual-currency, `Prisma.Decimal`) |
| Cashback | `Adm/AdminController.php:985-1013` (`chargeWalletAfterOrder`, in grams) | `admin-customers.service.ts` |
| Order statuses | **two divergent enums**: `Order.php:66` (0–6) and `SingleCustomerOrders.php:67` (1–7) | `OrderStatus` + `PreOrderStatus` |
| SMS / OTP | `app/Models/Sms.php` | **absent** — DB persistence only |
#### The gold price formula, verified ✔
`henza_server/app/Models/ProductVariant.php:183-190`:
```php
$price = goldPrice + (goldPrice * ((percentage_pay + product_price_extra_percent) / 100));
$price += $price * 0.07; // 7% margin
$temp = $price - goldPrice; // non-metal portion only
$temp += $temp * 0.09; // 9% VAT on that portion only
$price = goldPrice + $temp;
$price *= grams;
return floor($price / 1000) * 1000; // round DOWN to the nearest 1000
```
Three superseded variants sit commented out above it — they apply VAT to the
whole price, which is **wrong** and must not be ported. `getPriceWithDiscountAttribute`
(`:255-280`) is the same formula with `0.07 - discount_percent/100` and a trailing
`price -= price * (discount_price/100)`.
This formula, its ordering, and the `floor(/1000)*1000` are **row #1 of the Phase 1
matrix** — it is the money rule the whole catalogue depends on.
### 2.3 Auth & permissions
| Aspect | Reference | Target |
|---|---|---|
| Admin route protection | **NONE** ✔ — `auth:api` commented at `routes/admin.php:12` and `:272`, so all ~215 admin routes are public | global `JwtAuthGuard` + `PermissionsGuard`; 15 routes opt out with `@Public()` ✔ |
| Permission model | `Permission(rid, ut)` rows returned at login (`AdminController:74`), enforced **client-side only** — no Gates/Policies/Spatie | `acl.constants.ts` — 144 rids + `expandWithParents()`, enforced **server-side** |
| Permission catalogue source | not derivable from the server; lives in the admin at `views/user/permissionConst.js` (**172 declared keys**) | `acl.constants.ts` (144 rids) + `permissionConst.ts` (**179 keys, 174 unique**) |
| Parent expansion | `store/users/index.js:3-21` `checkParent()` — `order.list.sts.remove` implies `order.list.sts`, `order.list`, `order` | `expandWithParents()` — same semantics |
| Token refresh | machinery exists but is **unreachable** (`main.js:22-23` commented) | none |
**The target is deliberately stricter than the reference on auth.** That is a
prior, documented decision (`docs/CHANGELOG-PORT-HISTORY.md`), not a parity gap —
and reverting it would reopen a critical hole. Logged as `SEC-01`/`SEC-02`.
**This needs your confirmation (see Q1).**
---
## 3. Admin mapping — `henza_admin` ⇄ `apps/admin-panel`
| Metric | `henza_admin` | `apps/admin-panel` |
|---|---|---|
| `.vue` under `views/` | **541** — but only ~69 reachable from the live router; the rest are Vuexy template demos | **91** `.vue` + 19 `.ts` table models = 110 files |
| Routes | admin build + branch build (two route trees) | 80 admin-mode + 15 branch-mode records |
| Permission keys | 172 in `permissionConst.js` | 179 (174 unique) in `permissionConst.ts` |
| Rids enforced by `meta.role` | ~58 | ~58 |
| Button-level `$hasPermission` | catalogued in `_inv-ref-admin.md` §5.3 | 73 distinct rids via `hasPermission()` |
| HTTP | `src/axios.js`, **no response interceptor**, no working refresh | `src/api/client.ts` + `endpoints.ts` legacy→REST map (~100 exact + 34 regex), 401 → clear token + redirect |
| Table system | `src/components/x-table/` (4 forked copies) | one `XTable`, POST `{search, filters, page, limit, order, select, titles, report_title}` → `{rows, query}` |
| Digit/price utils | **none exist** (`_inv-ref-admin.md` Appendix B #17) | none, except inside `DateInput.vue` |
**Route-tree note:** `src/router/{apps,category,customer,dashboard,full,manage,order,product,temp}.js`
exist in the reference but are **not imported** — only `admin.js` and `branch.js`
are (`src/router.js:6,8`). Nine dead route files. They must not be mistaken for
missing pages in Phase 1.
### 3.1 Mapping status
A route-by-route and action-by-action mapping is **Phase 1's deliverable**
(`01-ADMIN-MATRIX.md`). What Phase 0 establishes is the denominator: ~69 live
reference views against 91 target views, with the reference's 172-key permission
tree against the target's 174-unique-key tree.
---
## 4. User-app mapping — `henza_pwa` ⇄ `user-app` + `user-app-v2`
### 4.1 Routes
| Reference route | name | `user-app` / `user-app-v2` | Status |
|---|---|---|---|
| `/home` | home | `/home` → `HomeView.vue` | ✅ |
| `/collection/:id?` | collection | same | ✅ |
| `/search/:id?` | search | `/search` — **no `:id?` param** | ⚠️ different |
| `/product/:id` | singleProduct | `components/product/SingleProduct.vue` | ✅ |
| `/newest` | newestProduct | same | ✅ |
| `/user` | userPage::hasSubRoute | `DashboardView.vue` | ✅ |
| `/orders` | ordersList | same | ✅ |
| `/transactions` | transactionsList | same | ✅ |
| `/profile` | profileData | same | ✅ |
| `/favorites` | favoritesList | same | ✅ |
| `/cart` | cart | same | ✅ |
| `/login` | login | `LoginRegisterView.vue` | ✅ |
| **`/report`** | report | — | ❌ missing |
| **`/report/detail`** | reportDetail | — | ❌ missing |
| **`/ChildOrder`** | ChildOrder (sales-rep child orders) | — | ❌ missing |
| **`/customerChoice`** | customerChoice (impersonation) | — | ❌ missing |
| **`/complete_profile`** | completeProfile (document upload / KYC) | — | ❌ missing |
| — | — | **`/notifications`** | ➕ extra in target |
Verified by reading `henza_pwa/src/router/index.ts:98-215` against
`apps/user-app/src/router/index.ts:18-45` ✔. The two target routers are
**byte-identical** (`diff` returns nothing) ✔ — good for the v1/v2 parity rule.
### 4.2 Guard differences ✔
| Behaviour | `henza_pwa/src/router/index.ts` | target |
|---|---|---|
| unauth hitting a gated route | `showNotify('ابتدا وارد شوید')` then redirect to **`home`** (`:250-254`) | redirect to **`/login`** with `?redirect=`, no toast |
| gated route list | ordersList, transactionsList, profileData, favoritesList, **completeProfile** | `meta.requiresAuth` on 4 (no completeProfile — the route doesn't exist) |
| logged-in hitting `/login` | → home (`:256`) | → home ✅ |
| `!isLoaded && to.name !== 'home'` | → home (`:258`) | **absent** |
| `sts_verify_step_two ∈ {1,2}` blocks completeProfile | `:260` | **absent** |
| route analytics | `useCustomAnalytics()` → `POST /log/create/{key}`, `GET /log/update/{id}` (`customAnalytics.ts:25,32`) | **absent entirely** |
### 4.3 The v1 ⇄ v2 rule
`CLAUDE.md` §2: the only allowed difference is visual design. Five **logic**
differences found, all with v2 behind v1 — these are parity gaps *inside* the
target, independent of the reference:
| # | Gap in `user-app-v2` | Evidence |
|---|---|---|
| 1 | **No cart stock ceiling** — no `capQty`/`maxQty`; a user can order more units than exist | v1 `stores/cartStore.ts:62,65`, `components/product/CartItem.vue:82-83`; zero hits in v2 ✔ |
| 2 | No `getHomeSlider()` / `GET /home/slider` admin banner | `diff` of the two `api/client.ts` ✔ |
| 3 | No per-variant images — v1 has `ProductImageSummary{id,url,sortOrder,role}` + `imageId`/`image`; v2's `images` is `Array<{url}>` | same diff ✔ |
| 4 | No orderable-first variant defaulting | v1 `composables/useProduct.ts:26` |
| 5 | No variant-chip preview cap / per-chip stock badges | v1 `components/product/SingleProduct.vue:388-392` |
Logged as `MON-01` and `V2-01..05` in `ISSUES.md`; they become Phase 2 matrix rows.
### 4.4 Live price — reference vs target ✔
Neither side has a socket. But the reference has a **two-path bug** worth knowing
before porting: `baseStore.getPriceList()` (`store.ts:107-156`) and
`Dashboard.getListPrice()` (`Dashboard.vue:632-660`) both do a one-shot
`GET /price`; the visible grid comes from the second, while the manual refresh
button re-runs the first — **so the refresh button does not update the list the
user is looking at**. Also `prices.date`/`prices.time` assignments are commented
out (`store.ts:145-147`), so the dashboard's date/time header is permanently blank.
Target: one-shot `GET /pricing/latest?karatPerMille=750` on mount + pull-to-refresh,
in `HomeView` and `DashboardView` only.
---
## 5. What Phase 0 did NOT determine (goes to Phase 1 / Phase 2)
- Route-by-route mapping of the reference's 412 backend routes (288 in scope after D13) onto the target's 179.
- Field-by-field comparison of request/response shapes per endpoint.
- Per-view form/validation/table comparison for the ~69 live admin views.
- Whether the target's gold-price implementation matches `ProductVariant.php:183-190`
exactly, including the `floor(/1000)*1000`. **This is Phase 1 row #1.**
- Whether the 174 target permission keys are a superset/subset of the reference's 172.
---
## 6. Open questions — need your answer before Phase 1
See the gate message; recorded here so they survive a context reset.
- **Q1 — Auth on admin routes.** The reference leaves all ~215 admin routes
unauthenticated (`routes/admin.php:12` commented). The target enforces JWT +
per-rid permissions. Strict parity would mean *removing* auth. Confirm the
target's stricter behaviour stands as an accepted deviation.
- **Q2 — Scope of the "gold banking" checklist.** Confirm we audit the domain the
reference actually implements (§0) rather than manufacturing rows for
buy/sell/deposit/withdraw/KYC-levels/sockets that exist nowhere.
- **Q3 — The 5 missing user-app routes** (`/report`, `/report/detail`,
`/ChildOrder`, `/customerChoice`, `/complete_profile`). These are real reference
features absent from both target apps. Building them means ~5 screens × 2 designs
plus backend. In scope for Phase 2, or deferred?
- **Q4 — Reference bugs.** 20 defects found in `henza_pwa` and 18 in `henza_admin`
(listed in the `_inv-*` appendices). `CLAUDE.md` §3 says replicate + log. Confirm
we do **not** replicate the data-destroying / clearly-broken ones (e.g. the
refresh button that updates an invisible list), and log them instead.
- **Q5 — Stray folder** `gold-banking-monorepo/user-app-v2/` (repo root, outside
`apps/`): only `package.json` + an outdated `vite.config.ts`, ignored by
pnpm-workspace. Delete, or leave?
+483
View File
@@ -0,0 +1,483 @@
# 01 — ADMIN PARITY MATRIX (Phase 1)
Branch: `parity/phase-1-admin`, cut from `parity/security-hotfix` so the security
work is preserved (owner's instruction).
Method: the `reference-parity-audit` skill, **rule C0 — field-by-field, never
page-by-page** (D12). Every row cites `path:line` on both sides.
Status legend: `✅ match` · `❌ missing` · `⚠️ different` · `🛡️ protected` ·
`➕ extra in target` · `❓ unknown` · `⏸️ preserved — out of current scope` (D13)
---
## §0 — Scope of this phase
From the 412 reference endpoints, D13 leaves **288 in scope**; Phase 1 covers the
**218 admin (B2B)** ones plus the admin panel screens that call them. The 31
storefront-management admin routes are listed `⏸️` and get no gap rows.
### Reachable screens
The reference ships 11 router files but **only two are imported**
(`henza_admin/src/router.js:6,8` → `router/admin.js` and `router/branch.js`);
the other nine are dead (Phase 0 finding, confirmed). Reachable routes:
| | Reference | Target |
|---|---|---|
| `router/admin` | 67 | 69 |
| `router/branch` | 17 | 17 |
| **Total** | **84** | **86** |
Route-level mapping is complete: **all 67 reference admin routes exist in the
target at the same paths** (`henza_admin/src/router/admin.js` ↔
`apps/admin-panel/src/router/admin.ts`). Two target-only routes:
| Target route | Component | Status |
|---|---|---|
| `/reagent/add` | `views/apps/customer/EditReagent.vue` | ➕ extra — the reference had no add-agent route (agents were added through the shared customer form); harmless |
| `/reagent/view/:id` | `views/customer/reagentList/AgentDetail.vue` | ➕ extra — a read-only agent detail screen the reference lacks |
---
## §1 — Permission (rid) mapping — D1
D1 asked for a complete "reference admin operation ↔ target rid" matrix and an
explanation of the **172 vs 174** unique-key difference.
### The real counts
The Phase 0 figure was approximate. Counted from the authoritative catalogues
(`henza_admin/src/views/user/permissionConst.js` ↔
`apps/admin-panel/src/views/user/permissionConst.ts`):
| | Reference | Target |
|---|---|---|
| Tree entries | 181 | 179 |
| **Unique keys** | **176** | **174** |
| Duplicate keys | 5 | 5 (the same 5) |
The 5 duplicates are identical on both sides — `persons.list.client.single.view`,
`product.list.view`, `order.list.view`, `order.tamin.view`, `cities.list.view` —
each appearing twice in the tree because it is reachable from two parents. The
target reproduces this faithfully.
### The entire 176 → 174 difference is the dashboard, and nothing else
| Key | Reference | Target | Status |
|---|---|---|---|
| `dashboard` (parent) | present, `permissionConst.js:3` | absent | ⚠️ see below |
| `dashboard.omde` (آمار عمده فروشی) | `permissionConst.js:7` | absent from the UI tree | ⚠️ |
| `dashboard.tak` (آمار تک فروشی) | `permissionConst.js:11` | absent from the UI tree | ⏸️ storefront (D13) |
| `dashboard.view` | — | `permissionConst.ts` | ➕ replaces the three above |
**176 − 3 + 1 = 174.** Every other one of the 173 shared keys matches exactly.
**Cause:** the admin **Dashboard is a PROTECTED zone** (`PROTECTED.md` §A) — the
monorepo replaced the reference's two separate dashboards (wholesale stats and
retail stats) with one redesigned dashboard, so it needs one permission instead
of two.
**Verdict: ✅ accepted, not a gap.** Collapsing the three keys into one follows
directly from the protected redesign. Reversing it would mean restoring the
two-dashboard split, which is exactly what PROTECTED forbids.
### ⚠️ But there IS a real defect here — PERM-01
The **database** carries all three rids:
```
SELECT rid FROM permission GROUP BY rid -> 178 distinct
dashboard rids present: dashboard.omde, dashboard.tak, dashboard.view
```
So `dashboard.omde` and `dashboard.tak` are **grantable in data but invisible in
the permission screen**. An operator cannot see, grant or revoke them, yet a row
may already hold them, and nothing reads them any more. Two consequences:
1. Permissions exist that no UI can manage — they can only be changed by editing
the database directly.
2. The DB has **178** distinct rids against the UI's **174**, so the two
disagree about what the permission set even is.
Logged as **PERM-01**. The fix is additive and safe: stop seeding the two dead
rids, and clean up any rows that hold them — **not** done yet, because deleting
permission rows is a data deletion and needs the owner's approval (and a
CHANGELOG entry per the standing rule).
### Route-guard rids
`meta.role` on each route is the rid the guard checks
(`henza_admin/src/router.js:193-195` ↔ the target's guard).
| | Reference | Target |
|---|---|---|
| Route entries carrying a role | 77 | 79 |
| **Unique rids used by routes** | **57** | **57** |
Differences — the same dashboard story, plus one:
| Rid | Where | Status |
|---|---|---|
| `dashboard.tak` → `dashboard.view` | ref `router/admin.js:13` | ✅ protected-zone consequence |
| `dashboard.omde` → `dashboard.view` | ref `router/admin.js:31` | ✅ protected-zone consequence |
| `persons.list.sellers.view` | target only, on `/reagent/view/:id` | ➕ guards the new agent-detail route with an existing catalogue rid — correct, no new permission invented |
**Verdict: ✅ the route-guard layer is at parity.** Every reference route's rid is
either identical or maps onto the protected dashboard consolidation.
---
## §2 — Reference admin operation ↔ target rid
The reference leaves all its admin routes unauthenticated
(`henza_server/routes/admin.php:12,272`); the target enforces JWT + rid on every
one. **D1 accepted this as a deliberate deviation** — the requirement was to
prove no operation is left without an equivalent permission.
Per-module mapping of the reference's admin controller operations onto the
target's rid, grouped by the module of `apps/api/src/modules/`:
Counted mechanically from the target's `@RequirePermission` decorators and the
permission tree, 2026-10-12 (§3–§9 are all complete, so both sides have been
read):
| | Count |
|---|---|
| Reference admin routes (`routes/admin.php`) | 154 |
| Target admin routes carrying a rid | 163 |
| Distinct rids enforced by those routes | 68 |
| Keys in the permission tree (`permissionConst.ts`) | 176 |
Per module, against the reference controller it ports:
| Target module | Reference controller(s) | Routes | Distinct rids |
|---|---|---|---|
| `accounting` | `WalletController`, `CashController` | 5 | `wallet.list`, `wallet.charge`, `document.create`, `document.list.confirm` |
| `accounting-sync` | — target-only (D27) | 2 | `persons.list.client.omde`, `product.list` |
| `admin-branches` | `BranchController` | 6 | `branch.list`, `branch.create` |
| `admin-categories` | `CategoryController` | 11 | `collection.list`, `collection.create`, `collection.list.edit` |
| `admin-comments` | `CommentController` | 6 | `comment` |
| `admin-customers` | `AdminController`, `MajorCustomerController`, `SingleCustomerController` | 23 | 10 `persons.*` + `wallet` |
| `admin-dashboard` | `DashboardController` | 5 | `dashboard.view`, `report.order.item`, `report.product` |
| `admin-documents` | `CashController`, `WalletController` | 13 | `document.*`, `config.wallet`, `wallet` |
| `admin-invoices` | `CinyController` | 6 | `invoice.list`, `invoice.create` |
| `admin-misc` | `CitiesController`, `SmsController`, `NotifyController`, `ConstController`, `BrandController` | 27 | 16 (`cities.*`, `sms.*`, `notify.*`, `config.statics*`, `statics`, `newsletters`) |
| `admin-orders` | `OrderController`, `MajorOrderController` | 9 | `order.list`, `order.create`, `order.omde`, `order.tamin` |
| `admin-preorders` | `OrderController` (tamin) | 5 | `order.makeOrder` |
| `admin-products` | `ProductController` | 15 | `product.*`, `order.create` |
| `admin-promotions` | `PromotionController`, `IgnoreController` | 12 | `offers.*`, `offersIgnore` |
| `admin-report` | `ReportController` | 1 | `report.order.item` |
| `admin-users` | `UserController`, `PermissionController` | 11 | `user.*` (7) |
| `admin-workshops` | `WorkshopController` | 4 | `workshop.list`, `workshop.create` |
| `orders` | `ApiMobileController::setOrder` | 2 | `order.list`, `order.list.sts.submit` |
**D1's requirement — "no operation left without an equivalent permission" — is
met:** every one of the 163 rid-guarded routes names a rid, and 66 of the 68
resolve to a key the permission screen can grant.
### The 2 exceptions — PERM-02
Two enforced rids are **absent from the permission tree**, so a route checks them
but no operator can grant them from the UI:
| rid | Enforced by | Effect |
|---|---|---|
| `newsletters` | `admin-misc` (subscriber list) | only a role that already holds it (from the seed) can read the newsletter list |
| `persons.list.sellers.view` | `admin-customers` (`GET /reagent/view/:id`, the ➕ target-only agent-detail screen) | the new screen cannot be granted to a new role |
This is **inherited from the reference, not introduced here** — the header of
`permissionConst.ts` already recorded four such rids. Two of the four
(`buy.order.list`, `persons.create.customer.tak`) now have no route at all, so
they are dead on both sides and need no action.
Logged as **PERM-02**. Additive fix (add the two keys to the tree); it touches
the permission vocabulary, so it waits for the owner like PERM-01.
---
## §3+ — Field-by-field sections
- §3 Price formula and product/variant — ⏸️ stopped by D16
- §4 Orders and the `gram_level` limit — ✅ done
- §5 Financial documents (اسناد) — ✅ at parity
- §6 Customers (اشخاص) — ✅ done in Phase 1(a)
- §7 Users, permissions, cities, branches, workshops — ✅ at parity
- §8 Promotions, SMS, notifications, newsletters, statics — ✅ at parity
- §9 Reports — ✅ at parity (Excel export built)
---
## Issues raised by this section
| ID | Severity | Summary |
|---|---|---|
| PERM-01 | Medium | `dashboard.omde` / `dashboard.tak` are seeded into `permission` but absent from the permission UI, so they are grantable yet unmanageable; DB has 178 distinct rids vs the UI's 174. |
---
## §3 — Price formula and product pricing — ⏸️ STOPPED BY D16
> **Owner ruling, 2026-09-22.** Computing and showing `price` /
> `price_with_discount` is a **retail-storefront** concern; the B2B app works in
> **making charge (اجرت)** only. PRICE-01 is therefore ⏸️ under D13 and is NOT
> worked on. `variant-price.ts` and its 11 tests are **kept but unused**;
> nothing needed reverting because the function was never wired in.
>
> **Making charge stays fully in scope** — `percentage_pay`,
> `makingChargeOffsetPercent`, and wage display on variants and order lines.
>
> The findings below are retained as evidence for whenever the storefront is
> taken up.
### PRICE-01 — the target computes no product price at all — ⏸️ out of scope
| Dimension | Reference | Target | Status |
|---|---|---|---|
| Formula implemented | `app/Models/ProductVariant.php:183-190` (`getPriceAttribute`) | `apps/api/src/modules/pricing/variant-price.ts` — ported today, **verbatim + 11 hand-checked tests** | ⚠️ exists but unused |
| Formula called anywhere | on every variant, via `$appends = ['price', …]` (`ProductVariant.php:115-119`) | **never** — `grep computeVariantPrice` outside its own file and spec returns nothing | ❌ missing |
| Gold price injected | `AdminController.php:97-98` sets `ProductVariant::$goldPrice = WebServiceGold::getPriceGold18Ayar()` before listing | no equivalent | ❌ missing |
| `price` in the variant payload | appended automatically | `variantRow()` (`admin-products.service.ts:188-208`) returns **no `price`** | ❌ missing |
| `price_with_discount` | appended (`ProductVariant.php:118`) | absent | ❌ missing |
| Displayed in the product list | `henza_admin/src/views/apps/product/ProductList.vue:283,287,301` — `{{ item.price }}` in three places | no price column | ❌ missing |
| Extra percent source | `ConfigStatic['product_price_extra_percent']`, default 7 (`ProductVariant.php:50`) | the ported function defaults to 7 but nothing reads the config row | ❌ missing |
**Impact (historical).** Every admin screen that would show a product price shows
nothing. Under D16 this is storefront behaviour, so it is not a Phase 1 gap.
**What is already done:** the formula itself is ported and proven — the live
version at `:183-190`, not the three superseded variants above it that apply the
9% to the whole price (a test asserts the difference: 39,986,000 vs 42,686,000).
**What remains (only if the storefront is ever taken up):** read
`product_price_extra_percent` from settings, fetch the
current gold price, call `computeVariantPrice`, and surface `price` /
`price_with_discount` in `variantRow()` and the product list column.
### PRICE-02 — `rial_pay` (flat rial charge per variant) ⚠️
`ProductVariant.php:163` adds `$this->rial_pay` in the **superseded** formula
only; the live formula at `:183-190` does not. The target notes the column is
"returned as null rather than faked" (`admin-products.service.ts:206-208`).
**Matches the live reference** — no action. Recorded so a later reader does not
"restore" it from the commented-out code.
---
## §4 — Orders, the credit limit, and debt recognition
Contrary to the Phase 0 expectation, this area is in **good** shape.
| Rule | Reference | Target | Status |
|---|---|---|---|
| Credit-limit check | `ApiMobileController.php:568` — `($allGram + $remain) > $gramLevel` | `orders.service.ts:88-92` — `totalGoldWeightGrams.add(goldDebtGrams).gt(goldCreditLimitGrams)` | ✅ match, including the strict `>` |
| Over-limit outcome | order still created, `sts = 0` (معلق) | `OrderStatus.SUSPENDED` (`orders.service.ts:92`) | ✅ match |
| Debt recognition point | `sts > 4`, first true at `sts = 5` | `DELIVERED_TO_AGENT`, documented at `admin-orders.service.ts:14` and mapped `5 → DELIVERED_TO_AGENT` at `:359` | ✅ match |
| Removing that status | legacy allowed it, losing the posted debt | deliberately refused (`admin-orders.service.ts:382-390`) | ➕ stricter — a data-loss guard, keep (D4) |
| Suspended-order SMS | `ApiMobileController.php:754` | `orders.service.ts` `notifyOrderPlaced` | ✅ fixed 2026-10-12 |
| Order-placed SMS to the customer | `:757` | same | ✅ fixed 2026-10-12 |
| Order-placed SMS to the admins | `:739-743` (4 hardcoded numbers) | same, via `ORDER_ADMIN_MOBILES` | ✅ fixed 2026-10-12 |
| Order-placed SMS to the sales expert | `:762-764` | `notifySalesAgent` | ✅ fixed 2026-10-12 |
| Order-**edited** SMS to the admins | `:734-737` | none | «عمداً تکرار نشد — SMS-03» — no target path lets a customer edit a placed order |
| `Notify::store` push beside each SMS | `:755,758,765` | none | ⏸️ PUSH-01, deferred to the PWA port |
The one **money hole** here is already logged and scheduled: the server accepts
client-supplied `wagePercent` / `discountPercent` / `flatRialCharge`
(`orders.service.ts:40-52`) — **MON-08**, fixed as Phase 2 item #1 per the
owner's ruling, since `henza_pwa` computes rather than collects them.
---
## Issues raised by §3–§4
| ID | Severity | Summary |
|---|---|---|
| PRICE-01 | **Critical** | The price formula is ported and tested but **wired to nothing**: no gold price is fetched, `variantRow()` returns no `price`/`price_with_discount`, and the product list has no price column. The reference shows a price on every variant. |
| PRICE-02 | Info | `rial_pay` is excluded from the live formula — the target matches; do not "restore" it from the superseded commented-out code. |
---
## §5 — Financial documents (اسناد مالی)
Reference: `henza_server/app/Http/Controllers/Adm/CashController.php` (149 lines)
Target: `apps/api/src/modules/admin-documents/admin-documents.service.ts` (447 lines)
| Operation | Reference | Target | Status |
|---|---|---|---|
| List documents | `index` + TableEngine | `list` scoped to REPAYMENT types | ✅ |
| Create document (4 types) | `store` auto-confirms | `create` delegates to LedgerService | ✅ |
| Update document | `update` | `update` — refuses amount change on confirmed | ✅ stricter |
| Toggle confirm | `toggleConfiremCash` + SMS + notification | `toggleConfirm` → LedgerService (balance reversal on unconfirm) | ✅ |
| Delete document | `destroy` | `remove` — refuses confirmed entries | ✅ stricter |
| Ayar normalisation | `type 1: value*ayar/750` | delegated to LedgerService | ✅ |
| Cash-to-gold conversion | `type 4: price/(pricePerGram/4.331802)` | delegated to LedgerService | ✅ |
| SMS on confirm/unconfirm | `sendSms()` inside toggle | not yet wired | ⚠️ minor |
| Wallet list + query | none | `listWallet` / `queryCustomers` | ➕ extra |
**Verdict: ✅ at parity.** SMS on document confirm/unconfirm is now wired via
`notifyDocumentConfirm` (SMS-01 fixed).
---
## §6 — Customers (اشخاص)
Completed in Phase 1(a). No gaps remain.
---
## §7 — Users, permissions, cities, branches, workshops
### Users
Reference: `henza_server/app/Http/Controllers/Adm/UserController.php` (99 lines)
Target: `apps/api/src/modules/admin-users/admin-users.service.ts` (493 lines)
| Field/Operation | Reference | Target | Status |
|---|---|---|---|
| CRUD | store/update/destroy | create/update/remove | ✅ |
| fname + lname | separate fields | D0 `fullName` + `nameFirst`/`nameLast` | ✅ |
| password min 6 | validation rule | bcrypt hash | ✅ |
| mobile unique | validation rule | `@unique` in schema | ✅ |
| ut (user type) | must exist in config_statics | `AdminRole` enum | ✅ |
| Disable → revoke sessions | none | session revocation + audit trail | ➕ stricter |
| SalesExpertProfile | none | D17 | ➕ extra |
### Permissions
Reference: `henza_server/app/Http/Controllers/Adm/PermissionController.php` (89 lines)
Target: `apps/api/src/modules/admin-users/admin-users.service.ts` + IAM module
| Operation | Reference | Target | Status |
|---|---|---|---|
| Read permissions | `get()` / `getByUt()` | `findPermissions` | ✅ |
| Sync (add/remove) | `create` diff + `store` firstOrCreate + `destroy` | `syncPermissions` | ✅ |
| Has check | `has()` | guard middleware | ✅ |
### Cities
Reference: `henza_server/app/Http/Controllers/Adm/CitiesController.php` (77 lines)
Target: `apps/api/src/modules/admin-misc/admin-misc.service.ts` + `admin-cities.controller.ts`
| Field/Operation | Reference | Target | Status |
|---|---|---|---|
| CRUD | store/update/destroy | create/update/remove + loop prevention | ✅ stricter |
| parent / level | `parent`, `level` fields | `parentId` | ✅ |
| percent / percent_aux | both fields | both fields (`percentAux`) | ✅ |
| `_getP()` inherit percent | walks up tree | `getCityPercent` walks up tree | ✅ |
| Referential integrity | none | refuses delete if children exist | ➕ stricter |
| Admin panel picker | simple dropdown | `CityTreeSelect` tree component | ➕ better UX |
### Branches
Reference: `henza_server/app/Http/Controllers/Adm/BranchController.php`
Target: `apps/api/src/modules/admin-branches/admin-branches.service.ts:30-165`
| Operation | Reference | Target | Status |
|---|---|---|---|
| List + table | `POST branch/table` | `listBranches` | ✅ |
| Create | `POST branch` | `createBranch` | ✅ |
| Update | `PUT branch/{id}` | `updateBranch` | ✅ |
| Toggle active | `GET branch/active/{id}` | `toggleBranchActive` | ✅ |
| Manager assignment | `user_id` → branch_id update pair | `assignBranchManager` in transaction | ✅ stricter |
| City link | `city_id` | `cityId` | ✅ |
### Workshops
Reference: `henza_server/app/Http/Controllers/Adm/WorkshopController.php`
Target: `apps/api/src/modules/admin-branches/admin-branches.service.ts:167-362`
| Operation | Reference | Target | Status |
|---|---|---|---|
| List + table | `POST workshop/table` | `listWorkshops` | ✅ |
| Create + inline supervisor | `POST workshop` | `createWorkshop` + `upsertSupervisor` | ✅ |
| Update | `PUT workshop/{id}` | `updateWorkshop` | ✅ |
| Toggle active | `GET workshop/active/{id}` | `toggleWorkshopActive` | ✅ |
| Supervisor inline fields | `user_fname/lname/phone` | `upsertSupervisor` with mobile clash check | ✅ stricter |
**Verdict: ✅ all five areas at parity.** No gaps.
---
## §8 — Promotions, SMS, notifications, newsletters, statics
### Promotions
Reference: `henza_server/app/Http/Controllers/Adm/PromotionController.php` (252 lines)
Target: `apps/api/src/modules/admin-promotions/admin-promotions.service.ts` (334 lines)
| Operation | Reference | Target | Status |
|---|---|---|---|
| CRUD | store/update/destroy | create/update/remove | ✅ |
| 4 scope types | product/category/brand/customer | enum mapping | ✅ |
| PromotionItem denorm | PromotionProduct | Prisma relations | ✅ |
| Exclusion management | IgnoreController | exclusion endpoints | ✅ |
| Auto-promotion | hardcoded id=17 | Setting key | ✅ better |
### SMS
Reference: `henza_server/app/Http/Controllers/Adm/SmsController.php` (154 lines)
Target: `apps/api/src/modules/admin-misc/admin-misc.service.ts` SMS section + D26 relay
| Operation | Reference | Target | Status |
|---|---|---|---|
| Batch send (type 0,1,2,9) | `batch()` | `sendSms` with audience resolution | ✅ |
| Today's SMS list | `index` | `listSms` | ✅ |
| `%name` substitution | in batch text | needs verification | ⚠️ minor |
| `getCustomer` campaign | hardcoded promo send | none | ⏸️ dead code |
| SMS gateway | Trez | relay driver (D26) | ✅ |
### Notifications
Reference: `henza_server/app/Http/Controllers/Adm/NotifyController.php` (100 lines)
Target: `apps/api/src/modules/admin-misc/admin-misc.controller.ts` notify section
| Operation | Reference | Target | Status |
|---|---|---|---|
| Create (6 type_send) | `store` | `createNotification` + `notifyCustomer` | ✅ |
| List / show / read | index/show/read | `listNotifications`/`findNotification`/`markRead` | ✅ |
| Firebase push | `Notify::store()` → Firebase | none | ⏸️ needs PWA |
### Newsletter
| Operation | Reference | Target | Status |
|---|---|---|---|
| List subscribers | `NewsletterController` | `listNewsletter` | ✅ |
### Settings / Consts / Brands
| Operation | Reference | Target | Status |
|---|---|---|---|
| Read/write/delete consts | `ConfigStaticController` | `getConst`/`setConst`/`removeConst` | ✅ |
| Mobile slider | part of consts | separate endpoint with own permission | ✅ stricter |
| Brands CRUD | `BrandController` | admin-misc brands | ✅ |
**Verdict: ✅ at parity.** Firebase push is out of scope (needs PWA port).
---
## §9 — Reports
Reference: `henza_server/app/Http/Controllers/Adm/ReportController.php` (239 lines)
Target: no admin report module exists.
| Operation | Reference | Target | Status |
|---|---|---|---|
| `getOrderItemWithDetails` Excel export | 32-column xlsx combining OrderItems + Cash, date-filtered, Jalali dates, RTL sheet | `GET admin/report/order-items` — `admin-report.service.ts` | ✅ fixed |
**Verdict: ✅ at parity.** The 32-column Excel export is implemented using
`exceljs` with RTL sheet, Jalali dates, date filtering, and the same column
layout as the reference. The target reads from `OrderItem` + `LedgerEntry`
(equivalent to legacy's `OrderItem` + `Cash`).
---
## Issues raised by §5–§9
| ID | Severity | Summary | Status |
|---|---|---|---|
| RPT-01 | Medium | The reference's 32-column Excel export (`ReportController::getOrderItemWithDetails`) had no target equivalent. | ✅ fixed — `admin-report` module, `GET admin/report/order-items` |
| SMS-01 | Low | SMS on document confirm/unconfirm was not wired in `toggleConfirm`. | ✅ fixed — `admin-documents.service.ts:notifyDocumentConfirm` |
| SMS-02 | Low | `%name` substitution in batch SMS text was missing. | ✅ fixed — `admin-misc.service.ts:sendSms` + `customerNamesByMobile` |
| SMS-03 | Medium | **None of the four order-placement SMS messages was wired** — `OrdersService` did not even inject `SmsService`. The matrix had logged only the suspended-order one. | ✅ fixed 2026-10-12 — `notifyOrderPlaced` + `notifySalesAgent`, 6 tests. The «ویرایش شد» variant is deliberately not ported (no such path). |
| PERM-02 | Medium | `newsletters` and `persons.list.sellers.view` are enforced by routes but absent from the permission tree, so they cannot be granted from the UI. Inherited from the reference. | awaiting owner (additive) |
| PUSH-01 | Low | Firebase push notifications not implemented — deferred to PWA port phase. | ⏸️ deferred |
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
# D16 بخش‌های (b) و (c) — خلاصه gap‌ها
> این سند خلاصه بررسی field-by-field بخش‌های باقیمانده فاز ۱ هست.
> طبق D16: «قبل از fix، خلاصه gap به صاحب‌کار نشون داده بشه.»
> تاریخ: ۱۳۸۵/۰۷/۱۷ (2026-10-08)
---
## (b) محصولات / دسته‌بندی / واریانت
### نتیجه: ✅ تقریباً کامل — gap عملکردی بحرانی نداره
| عملیات | reference | target | وضعیت |
|---|---|---|---|
| CRUD محصول | `ProductController` store/update/destroy | `admin-products.service.ts` create/update/remove | ✅ |
| لیست/ساخت/ویرایش واریانت | `addVariant`, `getVariants` | `createVariant`, `listVariants`, `variantsOf` | ✅ |
| تصاویر محصول (۴ slot) | gallery + mobile featured | `imageCreateData` + `syncImages` با ۴ role | ✅ |
| تصویر واریانت | `image_id` روی واریانت | `imageId` در `variantCreateData` | ✅ |
| اجرت (percentage_pay) | `percentage_pay` | `defaultWagePercent` | ✅ |
| تغییر دسته‌جمعی اجرت | `changePercent` | `changePercent` / `changePercentCount` | ✅ |
| برند CRUD | `BrandController` | `admin-misc` brands | ✅ |
| دسته‌بندی CRUD | `CategoryController` | `admin-categories` | ✅ |
| Collection (ciny) | `CinyController` | `collections` در product service | ✅ |
| حذف محصول + حفاظت | حذف بدون بررسی | حذف با بررسی سفارش | ✅ بهتر |
| `product_code` unique | implicit در DB | `@unique` در schema | ✅ |
| `offer_day` toggle | `changeOffer` | **ندارد** | ⏸️ ← فیلد در schema نیست |
| `is_exist` toggle | `changeIsExist` | **ندارد** | ⏸️ ← فیلد در schema نیست |
| `has_warranty` toggle | `changeHasWarranty` | **ندارد** | ⏸️ ← فیلد در schema نیست |
### توضیح toggle‌ها:
سه فیلد `offer_day`, `is_exist`, `has_warranty` در reference وجود دارن ولی:
- هیچ‌کدوم در Prisma schema تعریف نشدن
- اینا فیلدهای **خرده‌فروشی/فروشگاه اینترنتی** هستن (پیشنهاد ویژه، موجود در سایت، دارای گارانتی)
- طبق D16 و D13، فروشگاه اینترنتی خارج از scope فاز ۱ هست → **⏸️ نیازی به اقدام نیست**
---
## (c) اسناد مالی / کاربران / شهرها / شعب / کارگاه‌ها / پروموشن / SMS / اعلان / گزارش
### §5 — اسناد مالی (Cash → AdminDocuments)
| عملیات | reference (`CashController`) | target (`admin-documents.service.ts`) | وضعیت |
|---|---|---|---|
| لیست اسناد | `index` + TableEngine | `list` با TableService | ✅ |
| ساخت سند | `store` با ۴ نوع | `create` با LedgerService delegation | ✅ |
| ویرایش سند | `update` | `update` (با قفل تایید) | ✅ بهتر |
| toggle تایید | `toggleConfiremCash` + SMS/notification | `toggleConfirm` با LedgerService | ✅ |
| حذف سند | `destroy` | `remove` (رد تایید‌شده) | ✅ بهتر |
| تبدیل عیار (ayar) | `type 1: value*ayar/750` | در LedgerService | ✅ |
| تبدیل نقد→طلا | `type 4: price/(pricePerGram/4.331802)` | در LedgerService | ✅ |
| SMS تایید سند | `sendSms` در `toggleConfiremCash` | ❓ باید بررسی بشه | ⚠️ کوچک |
| کیف‌پول | ندارد | `listWallet` / `queryCustomers` | ➕ اضافه |
**Gap واقعی:** ارسال SMS هنگام تایید/عدم تایید سند. بررسی لازم — ممکنه از طریق D26 relay انجام بشه.
### §6 — مشتریان (Customers)
**قبلاً انجام شده** — بخش (a) فاز ۱. Gap ندارد.
### §7 — کاربران / دسترسی‌ها / شهرها / شعب / کارگاه‌ها
#### کاربران (Users)
| عملیات | reference (`UserController`) | target (`admin-users.service.ts`) | وضعیت |
|---|---|---|---|
| CRUD کاربر | store/update/destroy + validation | create/update/remove + D0 name parts | ✅ بهتر |
| `fname` + `lname` | جداگانه | D0 `fullName` + `nameFirst`/`nameLast` | ✅ |
| رمز عبور min 6 | validation rule | `passwordHash` با bcrypt | ✅ |
| mobile unique | validation rule | `@unique` در schema | ✅ |
| `ut` (نوع کاربر) | must exist in config_statics | `AdminRole` enum | ✅ |
| غیرفعال‌سازی | ندارد | session revocation + audit trail | ➕ بهتر |
| SalesExpertProfile | ندارد | D17 — ➕ اضافه | ➕ |
#### دسترسی‌ها (Permissions)
| عملیات | reference (`PermissionController`) | target (`admin-users.service.ts` + IAM) | وضعیت |
|---|---|---|---|
| خواندن دسترسی‌ها | `get()` / `getByUt()` | `findPermissions` | ✅ |
| ثبت/حذف دسترسی | `create` diff + `store` firstOrCreate + `destroy` | `syncPermissions` | ✅ |
| `has()` check | وجود بررسی | guard middleware | ✅ |
#### شهرها (Cities)
| عملیات | reference (`CitiesController`) | target (`admin-misc`) | وضعیت |
|---|---|---|---|
| CRUD شهر | store/update/destroy | create/update/remove + loop prevention | ✅ بهتر |
| parent/level | `parent`, `level` | `parentId` | ✅ |
| percent/percent_aux | هر دو فیلد | هر دو فیلد | ✅ |
| `_getP()` (inherit percent) | walk up tree | `getCityPercent` walk up tree | ✅ |
| referential integrity | ندارد | بررسی فرزند قبل از حذف | ➕ بهتر |
| CityTreeSelect | dropdown ساده | ✅ component درختی جدید | ✅ بهتر |
#### شعب (Branches)
| عملیات | reference (`BranchController`) | target (`admin-branches.service.ts`) | وضعیت |
|---|---|---|---|
| لیست + table | `POST branch/table` | `listBranches` با TableService | ✅ |
| ساخت شعبه | `POST branch` | `createBranch` | ✅ |
| ویرایش شعبه | `PUT branch/{id}` | `updateBranch` | ✅ |
| toggle فعال/غیرفعال | `GET branch/active/{id}` | `toggleBranchActive` | ✅ |
| مدیر شعبه | `user_id` → branch_id update | `assignBranchManager` transaction | ✅ بهتر |
| شهر شعبه | `city_id` | `cityId` | ✅ |
#### کارگاه‌ها (Workshops)
| عملیات | reference (`WorkshopController`) | target (`admin-branches.service.ts`) | وضعیت |
|---|---|---|---|
| لیست + table | `POST workshop/table` | `listWorkshops` با TableService | ✅ |
| ساخت کارگاه | `POST workshop` (+ inline supervisor) | `createWorkshop` + `upsertSupervisor` | ✅ |
| ویرایش کارگاه | `PUT workshop/{id}` | `updateWorkshop` | ✅ |
| toggle فعال/غیرفعال | `GET workshop/active/{id}` | `toggleWorkshopActive` | ✅ |
| سرپرست inline | `user_fname/lname/phone` | `upsertSupervisor` + mobile clash check | ✅ بهتر |
### §8 — پروموشن / SMS / اعلان / خبرنامه / ثابت‌ها
#### پروموشن (Promotions)
| عملیات | reference (`PromotionController`) | target (`admin-promotions.service.ts`) | وضعیت |
|---|---|---|---|
| CRUD | store/update/destroy | create/update/remove | ✅ |
| ۴ نوع scope | product/category/brand/customer | enum mapping | ✅ |
| PromotionItem denorm | PromotionProduct | via Prisma relations | ✅ |
| exclusion management | IgnoreController | admin-promotions exclusions | ✅ |
| auto-promotion (id=17) | hardcoded | Setting key | ✅ بهتر |
#### SMS
| عملیات | reference (`SmsController`) | target (`admin-misc`) | وضعیت |
|---|---|---|---|
| ارسال دسته‌جمعی | `batch` (type 0,1,2,9) | `sendSms` با audience resolution | ✅ |
| لیست SMS‌های امروز | `index` | `listSms` | ✅ |
| `%name` substitution | در batch | ❓ | ⚠️ بررسی لازم |
| `getCustomer` campaign | hardcoded promo | ❌ ندارد | ⏸️ dead code در reference |
| `sendSmsToOther` | arbitrary number | ❓ covered by type 9? | ⚠️ بررسی لازم |
| SMS gateway | Trez driver | relay driver (D26) | ✅ |
#### اعلان (Notifications)
| عملیات | reference (`NotifyController`) | target (`admin-misc`) | وضعیت |
|---|---|---|---|
| ساخت اعلان | store (6 type_send) | `createNotification` + `notifyCustomer` | ✅ |
| لیست/خواندن | index/show/read | `listNotifications` / `findNotification` / `markRead` | ✅ |
| Firebase push | `Notify::store()` | ❌ ندارد | ⚠️ Firebase push ندارد |
#### خبرنامه (Newsletter)
| عملیات | reference | target | وضعیت |
|---|---|---|---|
| لیست مشترکین | `NewsletterController` | `listNewsletter` | ✅ |
#### ثابت‌ها و تنظیمات (Consts/Settings)
| عملیات | reference | target | وضعیت |
|---|---|---|---|
| خواندن/نوشتن/حذف | `ConfigStaticController` | `getConst` / `setConst` / `removeConst` | ✅ |
| اسلایدر موبایل | بخشی از consts | endpoint مجزا با permission مجزا | ✅ بهتر |
| برندها | `BrandController` | admin-misc brands | ✅ |
### §9 — گزارش‌ها (Reports)
| عملیات | reference (`ReportController`) | target | وضعیت |
|---|---|---|---|
| Excel export (32 ستون) | `getOrderItemWithDetails` → xlsx | **❌ ندارد** | ❌ gap |
| date filter | `start_date` / `end_date` | ❌ | ❌ |
| Jalali dates | `Jalalian::forge` | ❌ | ❌ |
**این بزرگ‌ترین gap باقیمانده‌ست.** یه Excel export که ۳۲ ستون از OrderItem + Cash ترکیب می‌کنه. ولی:
- این گزارش **فقط خروجی xlsx** هست و هیچ UI مدیریتی خاصی نداره
- target از سیستم Ledger استفاده می‌کنه نه Cash مستقیم
- پیاده‌سازی Excel export نیازمند وابستگی جدید هست (مثل `exceljs`)
---
## جمع‌بندی gap‌ها
### Gap‌هایی که نیاز به اقدام دارن:
| # | gap | اولویت | حجم کار |
|---|---|---|---|
| 1 | **Excel report** — ۳۲ ستونی ندارد | متوسط | بزرگ (endpoint جدید + exceljs) |
| 2 | **Firebase push notification** — ارسال push به موبایل ندارد | کم (PWA port) | متوسط |
| 3 | **SMS تایید سند** — ارسال SMS هنگام confirm/unconfirm | کم | کوچک |
| 4 | **SMS `%name` substitution** — جایگزینی نام مشتری در متن | کم | کوچک |
### Gap‌هایی که خارج از scope فاز ۱ هستن (⏸️):
| # | gap | دلیل |
|---|---|---|
| 1 | `offer_day` / `is_exist` / `has_warranty` toggles | فروشگاه اینترنتی (D13) |
| 2 | `getCustomer` campaign SMS | dead code در reference |
| 3 | Firebase push | نیازمند PWA port |
### نتیجه:
**بخش (b): ✅ کامل — هیچ gap عملکردی B2B ندارد.**
**بخش (c): ✅ تقریباً کامل** — فقط Excel report واقعاً ندارد.
بقیه gap‌ها (SMS confirm، Firebase) یا خارج از scope هستن یا خیلی کوچک.
---
## اقدامات انجام شده (2026-10-08):
۱. ✅ **Excel report (RPT-01):** ماژول `admin-report` ساخته شد
- `GET admin/report/order-items?start_date=...&end_date=...`
- ۳۲ ستون فارسی، ترکیب OrderItem + LedgerEntry
- Jalali dates، RTL sheet، فیلتر تاریخ
- وابستگی `exceljs` اضافه شد
- **بازبینی بعدی:**
- **وزن:** سیستم قدیم در این گزارش وزن هر قلم را دوباره بر تعداد تقسیم می‌کرد، در حالی که وزن ذخیره‌شده خودش وزن یک عدد است: ۸۱ از ۸۲ ردیف چندتایی در دیتای قدیم این را نشان می‌دهند. در نتیجه ستون «بدهکار» کمتر از واقع بود. این خطا تکرار نشد.
- **«درصد»:** مثل سیستم قدیم، اجرت منهای تخفیف است.
- **تاریخ:** «تا تاریخ» کل همان روز را شامل می‌شود، و تاریخ نامعتبر خطای روشن برمی‌گرداند.
- **دکمه:** «دریافت فایل اکسل» در صفحه‌ی گزارش تجمیعی گذاشته شد.
۲. ✅ **SMS تایید سند (SMS-01):** `notifyDocumentConfirm` در `admin-documents.service.ts`
- تایید: «سند مالی شما توسط مدیر سیستم تایید شد.»
- رد: «سند مالی شما توسط مدیر سیستم رد شد.»
۳. ✅ **SMS `%name` (SMS-02):** جایگزینی نام در `sendSms` در `admin-misc.service.ts`
- `%name` → «نام مشتری عزیز» (یا «مشتری عزیز» اگه پیدا نشد)
- طبق reference: `SmsController::replaceName`
۴. ⏸️ **Firebase push (PUSH-01):** به فاز PWA موکول شد.
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
# ISSUES — logged, NOT fixed during parity
Anything found during the parity audit that is a bug, risk, smell or improvement
opportunity goes here. **Do not fix these while parity work is in progress** (see
`CLAUDE.md` §3). They are worked through in Phase 3, only when the user asks.
Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` · `Low`
> Paths are relative to the workspace root (`gold bank/`).
> "REF" = reference project (read-only). "TGT" = `gold-banking-monorepo`.
> Items marked **[replicated]** exist in the reference and were faithfully ported —
> fixing them is a product decision, not a parity fix.
## Security & auth
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| SEC-01 | Critical | REF `henza_server/routes/admin.php:12,272` | The whole admin route file is **unauthenticated** — the `Route::middleware('auth:api')->group(...)` wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. `wallet/charge`, `users`, `roles`). | Verified by reading both lines. | n/a in reference (read-only). Drives TGT decision below. |
| SEC-02 | ~~Critical~~ **FIXED — hotfix stage 3** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | ✅ All closed across stages 1-3. Ledger + order-status behind admin rids; customer routes behind the customer JWT; `POST /orders` takes the customer from the token and ignores the body. Only `catalog/products`, `pricing/latest`, `home/slider` and `uploads/:filename` remain public, all by design. |
| SEC-03 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle. Verified end-to-end by `auth-smoke.sh`. ⚠️ **"no enumeration" no longer holds at step 1** — owner ruling 2026-10-12 (see AUTH-01 below) makes `requestOtp` answer an unregistered number with 404, because the opaque answer was unusable in support. Step 2 (`verifyOtp`) is still single-message for every failure reason, and the per-IP send throttle still bounds any enumeration attempt. |
| SEC-08 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/api/src/modules/customers/customers.service.ts:6-23,44-50` + `customers.controller.ts:10` (`@Public()`) | **Full customer book exposed to anonymous callers.** Login downloads `GET /customers` and matches the phone client-side, so the endpoint returns **every** active customer with `fullName`, `mobile`, **`nationalCode`**, `goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, and the URLs of **national-ID / business-licence / shop scans** (and `GET uploads/:filename` is public too, so those URLs resolve). `passwordHash` is correctly excluded. Not possible in the reference, where `userVerfiy` returned **one** customer after the code matched. | Read `customers.service.ts:5-23`; `DECISIONS.md` D8 | ✅ Closed. The whole controller is deleted — no route accepts a customer id any more. Reads moved to token-derived `/customer/me*`. Stage 1 had only narrowed the fields, which left the ids and the `:id` routes exposed. |
| SEC-09 | ~~Critical~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7` | `@Public()` on the whole accounting controller exposes **5 routes, 4 of them writes**: reconcile a balance, record a gold repayment, record a rial repayment, confirm a ledger entry. Faithful to the reference (whose admin routes were all unauthenticated) but contradicts the confirmed decision D1. | `DECISIONS.md` §"@Public audit" group A | ✅ Done: rids `wallet.list`, `wallet.charge`, `document.create` ×2, `document.list.confirm`. Verified 401/403/pass-through by `apps/api/scripts/auth-smoke.sh`. |
| SEC-10 | ~~High~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/orders/orders.controller.ts:22` | `PATCH orders/:id/status` is public **and** its DTO accepts a client-supplied `actorAdminUserId`, so an anonymous caller can move an order's status and attribute it to any admin. In the reference this was an admin operation. | `DECISIONS.md` §"@Public audit" B3 | ✅ Done: `order.list.sts.submit`. Had no caller — the admin panel uses `admin/orders/status`. |
| SEC-04 | ~~High~~ **FIXED 2026-10-08** — production (`NODE_ENV=production`) refuses to boot without `JWT_SECRET`; CORS reads `CORS_ORIGINS` (exact origins, comma-separated), blank = allow-all with a boot warning in production. Verified: boot refused with a blank key in production only; a listed origin gets `Access-Control-Allow-Origin`, an unlisted one does not. | TGT `apps/api` (two files) | `JWT_SECRET` falls back to `'dev-only-insecure-secret'` and is absent from `.env.example`. `enableCors()` is wide open. | Reported by api inventory; see `_inv-tgt-api.md` §11. | Require the env var at boot; restrict CORS origins. |
| SEC-05 | ~~High~~ **FIXED 2026-10-08** — worse than logged: the app runs the seed once on a fresh database at boot (`bootstrap.ts`), and the WHOLE seed was demo data (sample customers, products, orders, posted debt, and admin accounts with known passwords). Now only roles + permissions run in production; the rest needs `SEED_DEMO_DATA=true`. ⚠ Any deployment that already booted on an empty database has these demo rows and must be checked. | TGT `apps/api/prisma/seed.ts` | Seed creates a SUPER_ADMIN (`09128081392`) whose **password equals the phone number**, holding all 144 rids. Marked DEV-ONLY in comments but lives in the normal seed. | `docs/CHANGELOG-PORT-HISTORY.md` §4 documents this as intentional for dev. | Gate behind an explicit env flag so it can never run outside dev. |
| SEC-06 | High | REF `henza_server` | Hardcoded secrets committed to the repo: SMS panel password, PayPing bearer token, Tahesab bearer + IP allowlist, wallet hash pepper (`"yhoenszeaf"`). | See `_inv-ref-server.md` §11.4. | Rotate all of them before any deployment that reuses these credentials. |
| SEC-07 | Medium | TGT `apps/api/src/modules/admin-customers/admin-customers.service.ts` | `setRoles` stores arbitrary strings as rids with no validation against `ALL_RIDS`. | api inventory §9. | Validate against the rid catalogue. ✅ **FIXED 2026-10-08:** `setRoles` stores only rids in ALL_RIDS or their tree parents; anything else is dropped with a warning log, so a tick on a legacy-only tree leaf (e.g. notify.create.single.*, which no route checks) cannot fail the save. Test: admin-users.roles.spec.ts. |
## Customer / person module (found during the user-requested deep check)
> These are **parity defects being fixed**, not deferred items — see
> `DECISIONS.md` D0, D9, D10, D11. Listed here so the trail is complete.
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| CUST-01 | Critical | TGT `apps/api/src/modules/admin-customers/admin-customers.service.ts:231-233` | **First and last name collapse.** The reference stores `fname`/`lname` as two columns; the target merged them into `fullName` and returns `fname: c.fullName, lname: ''`. Opening any edit form puts the whole name in the first-name box and blanks the surname; saving then persists that. `singleCustomer.vue` tries to compensate with `split(' ')`, which mis-splits multi-part names. | REF `database/migrations/2020_10_01_120430_customers_table.php:18-19` (two columns) vs TGT `:231-233`. Reported by the user from live use. | **FIXING — D0** |
| CUST-02 | Critical | TGT same file `:245`, `:600-658` | **`percent` read from the wrong entity and never written.** Target returns `c.referredByAgent?.wagePercent`; the reference reads the customer's **own** `percent` column. `update()` ignores `data.percent` entirely, so editing the field does nothing. The create-time default (3) and the admin-path `required` rule are both missing, as is the app-side `filterPercentPay` offset. | REF `database/migrations/2020_10_19_153228_customer_add.php:32`; `AdminController.php:338,354`; `ApiMobileController.php:391-396,76-77`. Full trace in `DECISIONS.md` §percent. | **FIXING — D9** |
| CUST-03 | High | TGT `apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue:97-110` | **Sales-expert form is missing 5 reference fields:** `ncode`, `phone`, `city_id`, `gram_level`, `reagent_id`. The reference form had 15; the target has 8. Likely because `AdminUser` lacks those columns. | REF `henza_admin/src/views/apps/customer/ReagentCustomer.vue` (15 `v-decorator` fields) vs TGT form object. | **INVESTIGATING — D10** |
| CUST-04 | Low | TGT `apps/admin-panel/src/views/apps/customer/singleCustomer.vue:91` | Still spreads the whole form on save while the other two customer forms send a diff. Harmless today (4 fields, all repopulated) but the same shape that caused the earlier data loss. | read at `:80-106` | **FIXING — D11** |
| CUST-05 | Low | TGT `apps/admin-panel/src/views/apps/customer/bulkCustomer.vue` | Diff-send on edit is a **deliberate deviation** from the reference, which posts all 15 fields on every save. Recorded so it is not "fixed" back. | `DECISIONS.md` D11 | accepted deviation |
## Money, stock & data integrity
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| MON-01 | ~~Critical~~ **FIXED (verified 2026-10-08)** | TGT `apps/user-app-v2/src/stores/cartStore.ts` | v2 has **no `capQty` / `maxQty` stock ceiling**, which `user-app` does have (`apps/user-app/src/stores/cartStore.ts:62,65`; `components/product/CartItem.vue:82-83`). A v2 user can order more units than exist. | Verified by grep across both apps — zero hits in v2. | Port the ceiling to v2 (this is also a **Phase 2 parity gap**, row to be added to `02-APPS-MATRIX.md`). |
| MON-02 | High | REF `henza_server` wallet package | The wallet `balance` column **mixes grams and toman in one field**, and money operations are not wrapped in DB transactions. | `_inv-ref-server.md` §7. | Separate the units; wrap mutations in transactions. |
| MON-03 | ~~High~~ **NO EFFECT (verified 2026-10-08)** — `Prisma.Decimal(4.331802)` converts through the number's shortest decimal string, i.e. exactly `4.331802`; nothing is pre-rounded | TGT `apps/api/src/modules/accounting/ledger.service.ts:189` | `GRAMS_PER_MESGHAL = 4.331802` is a JS **float** used inside an otherwise-`Prisma.Decimal` CASH repayment formula, so the divisor is pre-rounded before the Decimal division. | Read at `ledger.service.ts:182-190`; the source comment already flags the formula for stakeholder review. | Make the constant a `Decimal` (or a string literal passed to `Decimal`). |
| MON-04 | ~~High~~ **FIXED 2026-10-08 — see ACC-03** | TGT `apps/api/src/modules/orders/orders.service.ts` | Debt is recognised at `DELIVERED_TO_AGENT`, but the idempotency guard checks `deliveredAt`, which is only set by `DELIVERED_TO_CUSTOMER` — so the debt trigger is not reliably idempotent. There is also **no status-transition validation** anywhere. | api inventory §8. | Guard on the same field the trigger fires on; add an explicit transition table. |
| MON-05 | ~~Medium~~ **FIXED 2026-10-08 (owner option 2)** — `canPlaceOrders` now blocks orders placed from the app (403); an operator can still order for the customer. Legacy never read `customers.sts` (313 active customers carry «عدم سفارش», mostly the sign-up default), so the import sets everyone to allowed; only a deliberate suspension after go-live blocks. `verificationStep` is still not enforced, as in legacy. | TGT `apps/api` | `canPlaceOrders` and `verificationStep` are stored and toggled by the admin panel but **never enforced** on any order path. | api inventory §8. | Enforce at order creation, or remove the toggles. |
| MON-06 | Medium | TGT `apps/api` | `Setting` rows are never seeded — `wallet.config`, `wallet.birthday`, `promotion.auto`, `mobile_main_slider` only materialise on first write, so a fresh install has undefined behaviour until an admin saves each one. | api inventory §7. | Seed the defaults. |
| MON-07 | Medium | REF `henza_server/app/Models/ProductVariant.php:183-190` vs `SingleCustomerOrders.php:67`, `Order.php:66` | Two **divergent order-status enums** coexist (0–6 and 1–7) for what is conceptually one flow. | Read both models. | Unify, or document the mapping explicitly. |
## Validation & contracts
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| VAL-01 | High | REF `henza_server` | **Zero FormRequests**; all validation is inline, and `setOrder`, wallet `charge`, `setConst` and file upload have **no validation at all**. The wallet package's own validator is dead code (it calls `failed()` instead of `fails()`). | `_inv-ref-server.md` §12. | n/a in reference; informs how much validation the target must add. |
| VAL-02 | High | TGT `apps/api` | Only **8 DTO classes** exist; most admin request bodies are untyped `Record<string, unknown>`, so class-validator never runs on them. | api inventory §5. | Add DTOs for the admin write paths. |
| VAL-03 | Medium | TGT both user apps | HTTP client is a bare `fetch` wrapper: **no interceptors, no Authorization header, no refresh, no 401 handling, no error toasts**. Non-2xx throws a generic `Error`; call sites mostly `console.log(e)`. Order-submit failure is **silent to the user**. | `apps/user-app/src/api/client.ts`, `apps/user-app-v2/src/api/client.ts:3`. | Add a shared client with error surfacing; compare against `henza_pwa/src/service/*` in Phase 2. |
## Parity-relevant divergence between user-app and user-app-v2
> `CLAUDE.md` §2: the only allowed difference is visual design. These are logic
> differences, so they are **Phase 2 parity gaps** as well as issues.
| ID | Severity | Where | Description | Evidence |
|---|---|---|---|---|
| V2-01 | ~~Critical~~ **FIXED (verified 2026-10-08)** | v2 missing stock ceiling | see MON-01 | grep |
| V2-02 | ~~Medium~~ **FIXED (verified 2026-10-08 — v2 calls `getHomeSlider`)** | v2 missing `getHomeSlider()` / `GET /home/slider` admin banner | v1 `api/client.ts:152-170` defines `AdminSlide` + `getHomeSlider`; v2 has neither | `diff apps/user-app/src/api/client.ts apps/user-app-v2/src/api/client.ts` |
| V2-03 | Medium | v2 missing per-variant images | v1 defines `ProductImageSummary` (`id/url/sortOrder/role`) and `imageId`/`image` on the variant; v2's `images` is just `Array<{url:string}>` | same diff |
| V2-04 | Medium | v2 missing orderable-first variant defaulting | v1 `composables/useProduct.ts:26` picks the first orderable variant; v2 has no equivalent | grep |
| V2-05 | Low | v2 missing variant-chip preview cap + per-chip stock badges | v1 `components/product/SingleProduct.vue:388-392` | grep |
## Dead code & maintenance
| ID | Severity | Where | Description |
|---|---|---|---|
| DEAD-01 | Low | TGT `apps/user-app` | 8 components with no importers, `loadingStore`, `api.getBalance` (zero call sites), unused `gsap` dependency. ✅ **FIXED 2026-10-08** (owner approved): removed 8 unreachable components + loadingStore (found by walking imports from main.ts; sw.ts kept — vite-plugin-pwa entry), the unused validators/price helpers, and the gsap + @ant-design/icons-vue dependencies. getBalance was already gone. |
| DEAD-02 | Low | TGT `apps/user-app-v2` | 11 dead components, `loadingStore`, `getBalance`, `priceUnit`/`priceUnitRialLess`, 12 of 15 validators, unused `gsap` + `@ant-design/icons-vue` deps. ✅ **FIXED 2026-10-08** (owner approved): same for v2: 11 unreachable components + loadingStore, unused validators/price helpers, gsap + @ant-design/icons-vue. |
| DEAD-03 | Low | TGT `gold-banking-monorepo/user-app-v2/` (repo root, outside `apps/`) | Stray leftover folder containing only `package.json` and an **older** `vite.config.ts` (hardcoded port 5179, hardcoded proxy, imports a `themeConfig` that doesn't exist there). Not matched by `pnpm-workspace.yaml`, so pnpm ignores it. Left in place — deleting it needs user approval. ✅ **FIXED 2026-10-08** (owner approved): stray root `user-app-v2/` deleted; nothing referenced it. |
| DEAD-04 | Low | REF `henza_server/app/Console/Kernel.php` | `schedule()` is **empty** — no cron. Price refresh / expiry / birthday logic must be driven by OS cron or the `GET /` web route. |
| DEAD-05 | Low | REF `henza_server` | PayPing `returnUrl` points at `site/transaction/verify`, which is **not registered in any route file** — the payment callback is unreachable. |
## Known stubs in the target (not bugs, but not parity either)
| ID | Where | Description |
|---|---|---|
| STUB-01 | TGT both user apps | Registration persists nothing; favourites are in-memory only (lost on reload); notifications is a static empty state; home slides are 3 hardcoded placeholders in v2. |
| STUB-02 | TGT `apps/api` | No OTP, no SMS provider, no payment gateway/callbacks, no cron, no websockets. SMS and notifications are DB-persistence only. |
| STUB-03 | TGT both user apps | All list filtering and "pagination" is **client-side** over one flat `GET /catalog/products`; no server `page`/`limit` params. "Pagination" is a 20-item IntersectionObserver window. |
| STUB-04 | TGT both user apps | Live price is **pull-on-mount only** (`GET /pricing/latest?karatPerMille=750`) — no socket, no SSE, no polling interval. `quotedAt` renders as a Jalali **date only**, so a stale rate looks current. |
## Found during the stage-3 OTP audit
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| SEC-11 | High | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` (deliver) | The OTP message text, **code included**, was persisted to `SmsMessage.body`, which the admin SMS grid lists and full-text searches (`admin-misc.service.ts:135-136`). Any operator holding the `sms` rid could read live codes — defeating the bcrypt hashing of `codeHash`. | Read both files; grid `allowed` includes `body`. | ✅ Fixed in stage 3: the persisted row is a redacted audit line. |
| MON-08 | ~~Medium~~ **FIXED 2026-10-08 (owner-approved)** — app orders are priced by the server (`pricing/wage-formula.ts`, legacy by default: variant % + customer % − largest active product/category/brand promotion; rial charge 0) and the app's wage/discount/rial are ignored; the apps show the same figure from `POST customer/me/wage-quote`. Operators keep their manual numbers. Found on the way: the apps never added the customer's own percent (usually 3) nor any promotion, so the new app undercharged vs legacy. The formula is editable on the new «اجرت» admin screen (`config.wage`) with a live server-computed sample and approve-then-save. Verified end to end: 9+3=12; promotions 2,1 + expired 5 → 10; a tampered order (wage 0, discount 50, rial 999) was charged 10.5% and 0 rial under an approved +0.5 formula. | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. |
## Found while classifying scope for D13
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| DOC-01 | Medium | DOC `docs/parity/_inv-ref-server.md:588` (§3.6) | The Phase 0 inventory undercounted `routes/admin.php`: it recorded **117** explicit routes where the file has **151** live ones (6 more are commented out). Every downstream total inherited the error, so the reference endpoint count was reported as **369** when it is **412** (admin 249, not 215). No code is affected — only the planning figures. | `grep -E "^\s*Route::(get\|post\|put\|patch\|delete\|any)" routes/admin.php \| grep -v "^\s*//" \| wc -l` → 151; resources 14×7=98; 151+98=249 | ✅ Corrected in `00-INVENTORY.md` §2.1 and `DECISIONS.md` §D13. `_inv-ref-server.md` left as the raw subagent record. |
| DOC-02 | Low | REF `routes/admin.php:38` | `GET admin/get-analytics-tak` reads `SingleCustomerOrders` (`Adm/AdminController.php:557-579`), so it is a **retail-storefront** endpoint despite its neutral name. A keyword scan for `wallet\|single\|comment` misses it. Noted so the D13 scope split stays correct — anything else named neutrally but querying `SingleCustomerOrders`, `Fav`, `Address`, `Comment` or `Transaction` is storefront too. | `AdminController.php:563` — `SingleCustomerOrders::select(...)` | ✅ Classified as ⏸️ storefront in D13 (the 31st admin storefront route). |
## Found during the SMS / MON-08 investigation (2026-09-22)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| SEC-12 | **Critical** | REF `henza_server/app/Models/Sms.php:73-77,101-105` | Live SMS-panel production credentials (username, plaintext password, sender line) are **committed to git** in the reference, duplicated across two methods. An older provider's credentials sit commented out beside them. Anyone with repo access can send SMS on the owner's account at their cost. | Read the file. | Not ported — the target will read them from env only. **The owner should rotate these credentials**; they must be assumed compromised. |
| STUB-05 | Medium | REF `ApiMobileController.php:154-158,601-605`; `Adm/OrderController.php:54-55`; `Customer/OrderController.php:141-145` | Admin notification recipients are **hardcoded mobile numbers** scattered across controllers (six distinct numbers). Staff changes require a code deploy, and an ex-employee keeps receiving order data. | Read the files. | ✅ **FIXED** (2026-10-12, commit `83e5d28`) — ported as two comma-separated env vars, `PROFILE_ADMIN_MOBILES` and `ORDER_ADMIN_MOBILES`, both defaulting to empty. No literal number is in the target's git history. |
| DEAD-06 | Low | REF `henza_pwa/src/views/cart.vue:181-182` | The cart sends `wage_weight: 5.7856000000000005` and `weight: 6.303744000000001` as **literal constants**, each with a `// how to get?!` comment. The server ignores both: it recomputes weight from `rash_weight`, and the `wage_weight` use is commented out (`ApiMobileController.php:549-551`). | Read both files. | Dead payload fields — do not port. |
## Found while applying the D0 / percent migrations (2026-09-22)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| DEAD-07 | Low | TGT DB table `_goldbank_migrations` + `apps/api/src/prisma/migrate.ts` | **Re-assessed 2026-09-22 — not dead code.** It is the tracking table of the app's OWN migration runner (`src/prisma/migrate.ts:5`), which exists because the Docker deploy image is copy-only and ships no Prisma CLI, so `migrate deploy` cannot run there (`migrate.ts:7-13`). So the project has TWO ledgers on purpose: `_prisma_migrations` (CLI, used in development) and `_goldbank_migrations` (runtime runner, used in the container). `migrate diff` proposed `DROP TABLE` only because the table was not declared in the schema; the runner already refused to execute such a statement (`migrate.ts:190-196`). | Read `src/prisma/migrate.ts`. | ✅ Resolved: declared as an `@@ignore`d model, so drift is now **zero**. Table and all rows kept. |
## Found in the Phase 1 admin matrix (2026-09-22)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| PRICE-01 | ⏸️ out of scope (was Critical) | TGT `apps/api/src/modules/admin-products/admin-products.service.ts:188-208`; `apps/admin-panel/src/views/apps/product/ProductList.vue` | **No product price is computed or shown anywhere in the admin panel.** The reference appends `price` and `price_with_discount` to every variant (`ProductVariant.php:115-119`), sets the live gold rate before listing (`AdminController.php:97-98`), and renders `{{ item.price }}` in three places in its product list (`ProductList.vue:283,287,301`). The target's `variantRow()` returns neither field, nothing fetches a gold price for the admin list, and `computeVariantPrice()` — ported and unit-tested today — is called from nowhere. | `grep computeVariantPrice src/ --include=*.ts` matches only its own file and spec. | ⏸️ **Stopped by D16** — price display is retail-storefront, out of scope. `variant-price.ts` and its 11 tests are KEPT but unused. Nothing needed reverting: the function was never called. Making-charge fields stay in scope. |
| PRICE-02 | Info | REF `app/Models/ProductVariant.php:163` vs TGT `admin-products.service.ts:206-208` | `rial_pay` (a flat rial charge per variant) appears only in the **superseded, commented-out** formula, not the live one at `:183-190`. The target returns null rather than faking it, which matches the live reference. Recorded so nobody "restores" it from the dead code. | Read both. | ✅ No action — matches. |
| PERM-01 | Medium | TGT `prisma/seed.ts`; `apps/admin-panel/src/views/user/permissionConst.ts` | `dashboard.omde` and `dashboard.tak` are seeded into the `permission` table but are **absent from the permission UI tree**, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses `dashboard.view`). The DB holds **178** distinct rids against the UI's **174**. | `SELECT rid FROM permission GROUP BY rid` → 178, including all three dashboard rids. | ✅ **FIXED 2026-10-08** (owner approved the deletion). Migration `20261008160000_dashboard_permission_merge` gives `dashboard.view` to every type holding `dashboard` / `dashboard.omde` / `dashboard.tak`, then deletes those rows. **Also found:** the import dropped these three as unknown rids, so legacy types 102, 104, 105, 108 and 135 lost the dashboard — `people.ts` RENAMED_RIDS now maps them to `dashboard.view`. The other 45 legacy rids the target lacks are tree parents or rids no legacy code reads. |
## Found while closing Phase 1 (2026-10-12)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| SMS-03 | Medium | TGT `apps/api/src/modules/orders/orders.service.ts` | **No order-placement SMS at all.** The reference texts four audiences when an order is placed (`ApiMobileController.php:733-766`): the admins, the customer — with a distinct text when the order was suspended over the gram ceiling — and the customer's referring sales expert. `OrdersService` did not inject `SmsService`, so a customer placing an order was told nothing. The matrix had logged only the suspended-order message, understating the gap. | Read both sides; `grep SmsService` showed six modules wired and `orders` absent. | ✅ **FIXED 2026-10-12** — `notifyOrderPlaced` + `notifySalesAgent`, sent after the transaction commits, fire-and-forget per D20, admin numbers from `ORDER_ADMIN_MOBILES`. 6 tests. The «ویرایش شد» variant is «عمداً تکرار نشد» — no target path edits a placed order. |
| PERM-02 | Medium | TGT `apps/admin-panel/src/views/user/permissionConst.ts` | Two rids are **enforced by routes but absent from the permission tree**, so an operator cannot grant them: `newsletters` (newsletter subscriber list) and `persons.list.sellers.view` (the ➕ target-only agent-detail screen). Only a role that already holds them from the seed can use those routes. Inherited from the reference — that file's own header recorded four such rids; the other two (`buy.order.list`, `persons.create.customer.tak`) now have no route on either side and are dead. | Mechanical comparison of 68 enforced rids against 176 tree keys, `01-ADMIN-MATRIX.md` §2. | **awaiting owner** — the fix is additive (add the two keys to the tree) but it changes the permission vocabulary, so it waits like PERM-01 did. |
## Delivery-mode items (D19/D20, 2026-09-22)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. 🔒 **ENFORCED 2026-10-08:** a production API refuses to start while OTP_MASTER_CODE is set, unless TEST_SERVER_ALLOW_MASTER_CODE=yes marks a test server (common/production-checks.ts). Removing it from the live environment is still the operator's step — docs/SERVER-SETUP-CHECKLIST.md. |
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. |
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | ✅ **FIXED 2026-10-08** (owner: as legacy). `City.parentId` (self relation, Restrict) replaces `province`; migration `20261008170000_city_tree` turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (`CityTreeSelect.vue`: top level with children not selectable); city form/grid have «والد». **Also restored:** legacy `_getP` — a city without its own percent takes its ancestor's (`getCityPercent`); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused. |
| **PERS-03** | Low — **column added 2026-10-07 (`Customer.birthDate`, 541 legacy dates imported); form field still missing** | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. ✅ **FIXED 2026-10-08:** Jalali «تاریخ تولد» (DateInput) on the wholesale (`birth_date`) and retail (`birth_day`) forms; create/update accept either name, refuse impossible/future dates, '' clears; customer rows return both names. |
| **PERF-01** | Low | TGT `admin-customers.service.ts` `listReagents()` | The per-agent gold-sold total runs **one `order.aggregate` per row** inside `Promise.all` — an N+1 on every page of the sales-expert grid. Harmless at the current agent count. | `listReagents()` `rows.map(async ...)` | Replace with a single `groupBy` over the referring agent. |
## Apps — found during the delivery smoke test (2026-09-23)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **APP-01** | Medium (constraint, handled) | TGT `apps/user-app/src/views/user/LoginView.vue:43`, `user-app-v2` same, `components/input/OtpInput.vue:43` | Both apps render the login OTP as **four single-character inputs** and submit only on `@on-complete`, i.e. at exactly 4 digits. The API accepts any length, so an `OTP_MASTER_CODE` longer than 4 **works over HTTP but cannot be typed into either app**. The real OTP is 4 digits, so the box itself is correct. | `OtpInput.vue:43` `otp.join('').length === numInputs`; `Math.floor(1000 + random*9000)` in `customer-auth.service.ts` | Handled without touching the shipped login UI: `main.ts` warns at boot when the code is not 4 digits, and `.env.example` states the rule. |
| **APP-02** | ~~Medium~~ **FIXED (verified 2026-10-08)** | TGT `apps/user-app-v2/src/api/client.ts` (no `getHomeSlider`) vs `apps/user-app/src/api/client.ts:228` | `user-app` calls `GET /home/slider` and prefers admin-configured slides, falling back to category-derived ones (`HomeView.vue:258`). `user-app-v2` has no such call at all and always derives its hero from categories, so **slides configured in the admin panel never appear in v2**. | `grep getHomeSlider` finds it only in user-app | Add `getHomeSlider` to v2's client and let `HeroCarousel` prefer admin slides. Deliberately deferred: v2's home is its own design (PROTECTED) and rewiring its hero during a delivery risks the page being demonstrated. |
| **DEPLOY-01** | ~~High~~ **FIXED** | TGT `docker-compose.yml` | The `api` service passed only `DATABASE_URL` and `PORT`, so **every auth and SMS variable was silently ignored in Docker** — `JWT_SECRET` would fall back to the development default, and `OTP_MASTER_CODE` would never reach the container, so the delivery login would not have worked in a deployed environment. | `docker compose config` before/after | ✅ All auth, SMS and master-OTP variables are now passed through, each defaulting to empty (which the code treats as "unset"). |
| **TEST-01** | Low (fixed) | TGT `apps/api/scripts/test-sales-expert-roundtrip.cjs:10` | The script had `localhost:3077` hardcoded — a port from an earlier dev session — so it could only ever connect by accident. It failed with ECONNREFUSED on this branch. | Run against the standard port | ✅ Now defaults to `localhost:3000` (`API_PORT`) and accepts an override argument. Re-ran: 33/33. |
## complete_profile — found while porting the page (2026-09-23)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. 🔒 **ENFORCED 2026-10-08:** production refuses to start without JWT_CUSTOMER_SECRET, or with it equal to JWT_SECRET or under 32 characters. Setting it is the operator's step — docs/SERVER-SETUP-CHECKLIST.md. |
| **SEC-14** | High (reference bug, NOT ported) | REF `Major/CustomerController.php:87` | `complete()` did `$customer->update($request->all())` — it mass-assigned **the entire request body** onto the customer row. The app could therefore set its own `gram_level` (credit limit), `percent` (making charge), `remain` (gold debt), `active` or even `mobile` by adding fields to the profile-completion POST. A privilege-escalation and money hole reachable from any signed-in customer. | `$input = $request->all(); ... $customer->update($input);` | **Not ported.** The target reads a three-field whitelist (`CompleteProfileDto`) and writes only the three document URLs plus the verification step. Asserted by `test-complete-profile.cjs` §7, which posts credit-limit/debt/mobile overrides and checks the row is unchanged. |
| **SEC-15** | Low (reference bug, NOT ported) | REF `Major/CustomerController.php:110-112` | `sendSmsToAdmin()` hardcoded four real personal mobile numbers in the source. | `$admin_mobile = ['0990…','0913…','0912…','0919…'];` | **Not ported.** The target reads `PROFILE_ADMIN_MOBILES` (comma-separated, blank = notify nobody), so no personal number is committed to this repository. |
| **APP-03** | Low (reference bug, NOT ported) | REF `completeProfile.vue:175-181` | `refresh()` called `window.setInterval(() => window.location.reload(), 1000)` — an **unterminated** timer that reloaded the whole page every second forever, not a one-off refresh. | `completeProfile.vue:178-180` | **Not ported.** The target navigates home once after the success dialog. |
| **UPL-01** | Medium (gap, fixed) | TGT `admin-uploads.controller.ts` | The reference let the app upload documents through the same `file/create` the admin panel used, inside its `auth:api-customer` group (`routes/api.php:95`). The target's copy of that route is **admin-guarded**, which no app token can pass — so without a customer-facing upload route, complete_profile could not have worked at all. | `AdminUploadsController` has no `@CustomerAuth()` route | ✅ Added `POST customer/me/documents`, reusing `AdminUploadsService` unchanged so the size limit, MIME whitelist and stored-name scheme are identical on both paths. The uploader is recorded as `null` rather than as an admin, since that column is an AdminUser FK. |
## report — found while porting the page (2026-09-23)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **SEC-16** | High (reference bug, NOT ported) | REF `Customer/TahesabController.php::detail()` | The document-detail endpoint took the factor code straight from the URL and performed **no ownership check at all** — the method never consults `auth()`. Any signed-in customer could read any other customer's accounting document by guessing or enumerating a code. | `public function detail(Request $request, $id)` → proxies `GetHesabDetailByFactorCode?Factor_Code=$id` with no customer scope | **Not ported.** In the target the id is only a filter on top of a `customerId` scope taken from the token, so another customer's id returns nothing. Asserted in both directions by `test-report.cjs` §6. |
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. **Update 2026-10-05 (D27):** with the accounting integration on, «گزارش» reads the accounting statement and `nO_kar` / `jens_felez` are filled from it. |
| **REP-02** | Low (performance) | TGT `customer-report.service.ts` `summary()` | The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. | `summary()` `findMany` without `take` | If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time. |
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. **Update 2026-10-05 (D27):** a Tahesab integration now exists; it keeps TLS verification on (a custom CA can be supplied), stores the token encrypted and write-only, and never logs it. |
## Found by the pre-delivery Docker Compose run (2026-09-23)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **DEPLOY-02** | ~~**Critical**~~ **FIXED** | TGT `auth.module.ts`, `customer-auth.service.ts`, `jwt.strategy.ts`, `main.ts`, `upload-path.ts`, `sms.service.ts` | **Admin login returned 500 for every user in Docker.** `docker-compose.yml` passes each optional variable as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code read them with `process.env.X ?? 'default'`, which only falls back on `undefined` — so the empty string won and `jsonwebtoken` threw `"expiresIn" should be a number of seconds or string representing a timespan` on **every** sign. Invisible to every suite that ran against a local dev process, where the variable is genuinely absent. The same pattern also meant a blank `UPLOAD_DIR` would have served static files from the **filesystem root**, and a blank `PORT` would have bound a random ephemeral port (`Number('') === 0`), making the container unreachable. | Container log: `ERROR [ExceptionsHandler] "expiresIn" should be a number of seconds or string representing a timespan` | ✅ Added `src/common/env.ts` (`envOr` / `envOrNull`) which treats blank as unset, and applied it to every auth, port, upload and SMS read. Pinned by `src/common/env.spec.ts` (9 tests), which reproduces the original throw against the real `jsonwebtoken` and asserts the reader can no longer produce it. Re-verified end to end in compose: **32/32**. |
| **DEPLOY-03** | Medium (process) | TGT `apps/api/Dockerfile` | The image is `COPY release/ ./` — it ships a **pre-built bundle** produced on the developer machine by `pnpm build:release`, not a build performed inside the image. So `docker compose build` happily produces an image from a **stale** `release/app.js` and reports success. At the start of this run that bundle was four days old, and the rebuilt container was missing every route added since. | `apps/api/release/app.js` dated Sep 19 while the source was Sep 23; the container mapped none of the new routes | **`pnpm build:release` MUST be run before `docker compose build`.** Noted in DELIVERY.md's deploy section. A longer-term fix is to build inside the Dockerfile, or to have the build fail when `release/` is older than `src/`. |
## Accounting integration — found while building D27 (2026-10-05)
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **ACC-01** | Medium (unverified) | TGT `accounting-sync/providers/zarrin.provider.ts` | The Zarrin driver follows the published docs only — there is no Zarrin installation to test on. Open points: which sign of `listMandeHesabha` means "owes" (a setting, default negative), whether stones belong in `mablagh` or `mablaghSang` on `foroushSakhte` (sent in `mablagh`), and the exact response envelopes. | Owner: «نه، فعلاً نداریم» (no test install). Unit tests use the docs' sample outputs. | Before real use: a trial on temporary documents on a real install (owner's decision 11), check one balance and one document against the Zarrin UI. **Update 2026-10-12 — reads verified on the owner's install (v228, 870 accounts, 656 items):** negative balance = owes (default is right); a «فاکتور موقت» moves the balance at once (unlike Tahesab, ACC-02); fixed: `listKarSakhte` pages by 100 via `pageNum` (only 100 of 656 were read), `goroohSakhte` answers HTTP 400 with a good body (was treated as missing), the statement now skips «مانده از قبل» and is anchored on the live balance (its last row now equals Zarrin's balance; ranges agree on shared rows), temporary rows flagged, `infogardeshsakhte`'s hand-printed JSON repaired (+90 s timeout; Zarrin itself sometimes answers 500), document delete searches every ledger page, and a request on a kept-alive connection the server already closed is retried once on a fresh one ("socket hang up"). **Writes verified 2026-10-12** on owner-approved test account 953 «تست اپ گلدبانک» (09990001111, group متفرقه — Zarrin has no API to delete it, so it stays): addAccount, searchAccount finds it, edithesab keeps the group; foroushSakhte (bonak, 2 lines × 1 g, 10 %, movaghat=1) moved the balance by exactly 2.2 g = our acceptance rule, stock fell 1 g per item; hazfRadifSanad (kol=1) removed every row, balance and stock back to start. Fixed from that test: a movaghat=1 document is marked in `shomareSanad` («موقت»), not `noeFactor`; listhesabha names are «first * last» (all 873) — the «*» is now dropped. addFelezsakhteModel NOT tested (owner: no permanent test item). Security: the install is plain http on the internet with token `123456`. |
| **ACC-02** | Low (by design) | TGT D27 | Order documents are sent **temporary** (owner ruling), and a temporary Tahesab document does **not** move the balance until the accountant finalises it. Until then the balance the customer sees from the accounting software does not include those orders. | Measured on the Tahesab test server: balance delta 0 for a temporary `VKHKar`, −Vazn×Ayar/750 once final. | Accountant finalises documents promptly; the report marks temporary rows «موقت — هنوز در مانده اثر ندارد». |
| **ACC-03** | ~~**High**~~ **FIXED 2026-10-08 (owner-approved)** — `LedgerService.syncOrderDebt`: on every status change the order's ORDER_STOCK entries are brought to what legacy's recomputed `sts > 4` gives (full weight + rial while DELIVERED_TO_AGENT/CUSTOMER, else 0), posting only the difference as a confirmed DEBIT/CREDIT. Also fixes: straight-to-DELIVERED_TO_CUSTOMER owed nothing; rollback/cancel left the debt. Removing the «تحویل به نماینده» status is allowed again, as in legacy. Existing data: 0 orders out of line in dev and the imported copy. | TGT `orders/orders.service.ts` `updateStatus()` | The debt guard is `DELIVERED_TO_AGENT && !order.deliveredAt`, but `deliveredAt` is only set at `DELIVERED_TO_CUSTOMER`. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt **twice**. | Read at `orders.service.ts:135-160`. The new accounting hook is not affected (it dedupes on the existing document row). | Guard on an existing `ORDER_STOCK` ledger entry for the order instead of `deliveredAt`; needs owner approval (PROTECTED area). |
| **ACC-04** | Low (pre-existing) | TGT `src/prisma/migrate.ts` | On a completely empty database `runMigrations` fails: `0002_admin_schema.sql` sorts before `20260910113720_first` and alters tables that do not exist yet. Existing databases are unaffected. | Reproduced while preparing the D27 throwaway test database (`relation "City" does not exist`). | Rename to sort after `first`, with a `LEGACY_NAME_ALIASES` entry so applied databases do not re-run it. |
| **ACC-05** | Low (behaviour change) | TGT `catalog.service.ts` | D27 rule: a product with **no image** is no longer listed in the app nor orderable — for every product, not only those from the accounting software. | Dev DB on 2026-10-05: 0 of 5 active products affected. | Before release run `select count(*) from "Product" p where p."isActive" and not exists (select 1 from "ProductImage" i where i."productId" = p.id);` on production and photograph those first. |
| **ACC-06** | Medium (fixed 2026-10-05) | TGT `accounting-sync/accounting-http.ts` | A real Tahesab install reached by IP could not connect. Tahesab serves its own self-signed certificate issued for `localhost` / `127.0.0.1`, so verification failed (`DEPTH_ZERO_SELF_SIGNED_CERT`), and pasting it as a CA still failed the name check (`ERR_TLS_CERT_ALTNAME_INVALID`). «تست اتصال» also tested only *saved* settings. | Owner's server (`https://<ip>:4040`, DBname `tahesabDB5`): token and DB were fine (raw `CheckHealth` = OK). The same server also answers to the default `TahesabDB` — a different database (40 customer groups instead of 19). | Fixed: a pasted certificate is now also a **pin** — exactly that certificate is accepted under any name; the chain check stays on, any other certificate is still name-checked, and verification is never switched off. New «دریافت گواهی از سرور» button shows the SHA-256 fingerprint to compare before pinning; «تست اتصال» uses the form as typed. Verified against the owner's server; `accounting-http.spec.ts`. Admins must check the DB name in the test result. |
| **ACC-07** | Medium (fixed 2026-10-05) | TGT `accounting-sync` (config, worker, admin) | "Connected but nothing syncs": the switch was saved on «زرین» while only Tahesab had an address/token. The worker found no usable provider and did nothing, with no message; «سینک الان» answered "started". Separately, an empty «گروه‌های مشتری برای ورود» meant *all groups* — on the owner's server that includes صندوق, کارمندان, سرمایه‌گذار, آبشده, کلاهبرداران… which would have become app customers. | Owner's Tahesab: 228 accounts in 15 groups, 23 کارساخته; read-only provider calls (groups, customers, balances, items, stock) all fine. | Fixed: saving a provider without address/token is refused; the screen shows why nothing syncs (`inactiveReason`); «سینک الان» refuses with the reason (or "feature off"). Customer pull now refuses to run with no groups selected; creating a customer with no «گروه مشتری جدید» goes to NEEDS_REVIEW instead of an ungrouped account. |
| **ACC-08** | Medium (done 2026-10-05) | TGT `accounting-sync` (worker, providers, sync, catalog) | Owner: sync must be real-time, not delayed or manual. It was: outbox tick 30 s, balances every 10 min, products every 30 min, stock read from the stored copy at order time. | Neither Tahesab nor Zarrin has webhooks. Tahesab `DoListUpdatedAsnad` lists new/edited documents (≈400 ms); proven on the public test server (document posted → account+item seen in the next poll, 0.7 s). Zarrin `parLastTenRadif` is unverified (ACC-01). | Done: fast loop (2 s) for outbox + change poll every 5 s (fixed; the admin setting was removed at the owner's request); `kickSoon()` right after a hook enqueues; targeted balance/stock refresh; order-time live stock with 2.5 s timeout, falling back to the last known stock (owner's ruling); balance freshness 5 s. Interval jobs stay as the safety net (deletions, edits, new customers). `getChanges` tests added (55 accounting tests). See plan §11. |
| **ACC-09** | Medium (done 2026-10-05) | TGT `accounting-sync`, `orders`, `admin-orders`, admin lists, both apps' profile | Owner: sync also on demand — a button on the wholesale-customer list and the product list; the app profile reads its own balance live on every visit; an optional rule for the order status that posts the temporary document (پیش‌فاکتور). | Owner: buttons/profile work whenever connected, regardless of the feature switches; the rule lives on «اتصال حسابداری». Admin order delete and status rollback could leave a temporary document behind once it can be posted before the debt. | Done: `AccountingQuickController` (`admin/accounting-status`, `admin/customers-omde/accounting-sync`, `admin/products/accounting-sync`, `customer/me/accounting-balance`), `documents.triggerStatus` (default DELIVERED_TO_AGENT), `orderStatusReached` hook from createOrder/updateStatus/rollback, withdrawal on delete/rollback, delete job independent of the order row. 8 hook tests. Plan §12. |
## Found during the legacy data migration (2026-10-08)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **FAV-01** | High (data invisible) | TGT both apps `stores/favoritesStore.ts`; no API route | Favourites were **memory-only** in both apps (lost on reload), and the API had no favourites route — so the 874 favourites imported from legacy `major_favorites` into `CustomerFavorite` were never shown. Legacy kept them on the server (`POST add-to-fav`, `POST get-favorites`, `routes/api.php:84-85`). | Store header said "local-only … until that backend support exists". | ✅ Fixed 2026-10-08: `GET customer/me/favorites`, `POST customer/me/favorites/toggle` (customer from the token); both apps load on sign-in, clear on sign-out, toggle optimistically and roll back on failure. Verified on the imported data: a customer with 116 legacy favourites sees all 116. |
## Phase 2 — item #1, MON-08 close-out (2026-10-12)
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **MON-08** | **High (money)** | REF `ApiMobileController.php:536-541`, `henza_pwa/src/views/cart.vue:170-179` vs TGT `apps/api/src/modules/orders/orders.service.ts` | The reference computed a customer's gold debt straight from `percent_pay`, `discount` and `rash_weight` **as sent in the request**, with nothing re-read from the database — so a crafted order could set its own making charge, its own discount and its own weight, i.e. decide how much gold the customer owed. | DECISIONS §"Report — MON-08"; the PHP block is quoted there in full. | ✅ **FIXED.** All four steps of the owner's ruling are in place. (1) An app order is priced by `WageService.quote` — `variantPercent + customerPercent − promotions` through the approved formula (`pricing/wage-formula.ts`, legacy's by default), read from the DB. (2) The client's figures are ignored and a mismatch is now logged (`warnOnPriceMismatch`, added 2026-10-12) — the same pattern as the `customerId` warning. (3) The comparison test `"gives an honest app exactly the total it sent"` proves an ordinary customer's total is unchanged. (4) The formula, its ordering and its rounding are untouched (D4). An operator ordering from the panel still keeps the numbers they typed, as legacy's order builder did. 16 tests on this service. |
**Accepted deviation, re-confirmed:** the reference also trusted the client's
`rash_weight`; the target reads the weight from the variant row. Per the owner's
ruling the target's behaviour stays — identical whenever the client sends the
true weight (which the reference app always does, `cart.vue:178`), different
only for a forged request, which is the point.
## Phase 2 — item #2, D3 close-out (2026-10-12)
Seven of D3's ten items were already built — all five screens exist in **both**
apps, as do the `sts_verify_step_two` guard and v2's cart stock ceiling
(verified in the code, not taken from a checkmark). Three were outstanding:
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **APP-03** | Medium (UX) | REF `henza_pwa/src/router/index.ts:253-261` vs TGT `apps/user-app/src/router/index.ts`, `user-app-v2` same | A guest tapping a personal page (orders, transactions, profile, favourites, complete-profile) was redirected **in silence**, so they landed on the login box with nothing saying why. Legacy showed «ابتدا وارد شوید». | Read both guards; legacy calls `showNotify('ابتدا وارد شوید')` then `next({name:'home'})`. | ✅ **FIXED 2026-10-12** — `toast.info('ابتدا وارد شوید')` in both apps' `beforeEach`. **«عمداً تکرار نشد — D3/الف»** for the destination: legacy sent the guest to `home` and lost where they were going; the owner chose to keep the target's `query.redirect`, so signing in returns them to the page they asked for. Also applied via `meta.requiresAuth` rather than legacy's hand-kept list of five names, which additionally covers the two sales-expert pages. Only reached when the IAM is off (the default) or unreachable — silent SSO leaves the page before this runs, and a toast on a document being replaced could not be read. |
| **APP-04** | Low | REF `henza_pwa/src/router/index.ts:263-266` vs TGT both apps `stores/configStore.ts` | Legacy's `isLoaded` router guard sent a signed-in user to `home` from any route without `meta.skipLoadedCheck` until its one big bootstrap request had landed. | Read both; the target's store header already said `isLoaded` was not ported. | ⏹️ **«عمداً تکرار نشد — D3/ب»** (owner, 2026-10-12). The target has no bootstrap request — every page fetches its own data — so the flag would guard nothing and only add a bounce: opening a shared product link would land on home instead of the product. Porting it would port the breakage, not the behaviour (D4). Recorded in both apps' `configStore.ts` so a later reader finds the reasoning where they would look for the flag. |
| **ANLY-01** | Low | REF `henza_server/app/Http/Controllers/Api/LogController.php`, `henza_pwa/src/router/customAnalytics.ts`; routes `api.php:57-58` | Per-page dwell-time analytics: `POST /log/create/{title}` opened a row (`start_at`, `ip`, `title`, `customer_id`, optional `product_id`), `GET /log/update/{id}` closed it; four admin report endpoints aggregated it. Entirely absent from the target — no table, no routes, no calls. | Read the controller, the composable and the routes. | ⏸️ **DEFERRED — lowest priority, may never be built** (owner, 2026-10-12: «ج اصلا اولیوت نداره جزو اخرین چیز هایی هست که شاید درستش کنیم»). The full design is parked in `docs/PHASE2-D3-PLAN.md` §"شکاف ۳", including two reference defects **not** to copy if it is ever built: `log/create` required no auth and accepted any title (anyone could fill the table from outside), and `log/update` was a `GET` that wrote. Open owner questions there too: retention (suggested 90 days — legacy never deleted) and whether to store the IP at all (suggested no). |
## App login — owner ruling 2026-10-12 (says what went wrong)
Reported by the owner: «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد
میبردیش توی صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد
یا نه واقعا نیست».
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **AUTH-01** | **High (support/UX)** | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` `requestOtp`, both apps' `LoginView.vue` | Step 1 returned `{ sent: true }` for a number no customer holds, so the app advanced to the code box and the person waited for an SMS that was never sent. Three very different causes were indistinguishable on screen: the SMS is late, the gateway is down, or the number was never registered — the only one the person can act on. | Read the guard (the opaque `return { sent: true }` with the enumeration note) and the apps' catch, which showed a generic «خطا در ارسال کد» for anything that was not a 429. | ✅ **FIXED.** 404 + «این شماره در سیستم ثبت نشده است…»; both apps now surface the server's Persian message for 404 as they already did for 429. **This restores the reference's behaviour** (`firstOrFail()`, `ApiMobileController::userAuth:43` → 404) — the opaque response was the deviation, not this. Enumeration stays bounded by `assertSendAllowed` (per-mobile and per-IP throttles), which the reference did not have, and step 2 is still a single message for every failure reason. |
| **AUTH-02** | **High (support/UX)** | TGT same file, `deliver` | A failed SMS send **also** returned success. `SmsService` never throws — a dead gateway is a log line (D20) — so an outage produced the identical "waiting at the code box" dead end, with nothing visible server-side either unless someone read the logs. | `SmsService.deliver` returns `{ ok: false, … }` and `requestOtp` ignored the result. | ✅ **FIXED.** A failed send is now 503 + «ارسال پیام کوتاه در حال حاضر ممکن نیست…». D20 protects an *operation* from a dead gateway (an order must still be placed); in a login the SMS **is** the operation, so there is nothing to protect by staying quiet. The code row is left in place (harmless, expires on its own). The local-dev path is preserved: when `SmsService` reports its own `sms-not-configured` outside production the code is logged and the login completes. |
| **AUTH-03** | Medium (wrong message) | TGT same file, `resolveExpertTradingAccount` | Found while fixing AUTH-01. Three states return no trading account for a sales expert who **does** exist (linked customer missing/deactivated/archived, or no `SalesExpertProfile`). Previously all three fell into the opaque success; with AUTH-01 they would have reported «این شماره ثبت نشده است» — a false statement that would send support hunting a registration problem that is not there. | Read the method: two `return null` paths plus the archived/inactive one. | ✅ **FIXED** in the same pass: 403 «حساب شما غیرفعال است…» and «حساب شما کامل نیست…», each distinct from the unknown-number message. The missing-profile case is now logged at `error`, not `warn` — it means something bypassed both paths that create a profile (D17), i.e. a server-side defect. |
| **DOC-01** | Low (misleading) | TGT both apps `src/views/user/LoginView.vue` header comment | The file header still said the OTP flow was **stubbed** — "the SMS-send step is stubbed — no SMS provider exists", "Step 2 (OTP code) is NOT verified — any 4-digit code is accepted" — and that step 1 matched against `GET /api/customers` **in the client**, which was the login leak (D8). All three had been untrue for weeks. | Read the comment against the code beside it. | ✅ **FIXED 2026-10-12** — corrected in both apps rather than deleted, with a note that the stale text actually misled a reader during this session. |
**Test gap this exposed.** `requestOtp` had **no test coverage at all**: the whole
28-test customer-auth suite stayed green through a change that altered its
response for every unregistered number. `request-otp.spec.ts` now covers 9 cases
— the happy path, the unknown number, Persian-digit input, a refused gateway, the
dev no-gateway fallback, that the fallback does **not** apply in production, and
the two sales-expert states.
## Sales expert — «ورود به حساب مشتری» restored (2026-10-12)
Reported by the owner: «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش
بشه و از جانب اون ها ثبت سفارش کنه … انگار با اکانت اون ها لاگین کرده … و این
خیلی کاربردی بود ولی الان نیست».
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **EXP-01** | High (missing capability) | REF `henza_pwa` `customerChoice.vue:155-167` → `myDataStore.ts:159-169` → `tokenManage.ts:8` → `plugins/axios.ts:18`; REF server `ApiMobileController.php:197-201`. TGT both apps + `customer-jwt.strategy.ts` | A sales expert could open any of their referred customers' accounts and use the whole app as them — browse, see balance and debt, place orders. The reference did it by **minting and handing over the customer's own permanent `api_token`**, which its axios interceptor then used for every request. The target listed the customers but tapping one only opened a detail sheet; an expert's order always landed on their own trading account. | Read all five reference files and the target's view header, which recorded the omission (D21). | ✅ **RESTORED 2026-10-12 (D29)** with the reference's full scope — every customer route, including documents — but a different mechanism: `X-Act-As-Customer` alongside the expert's **own** token, resolved once in `CustomerJwtStrategy.validateExpert`, which is why all 17 customer routes work unchanged. Ownership (`referredByAgentId`) is re-checked **on every request** in the same query as the id, so another expert's customer and a non-existent id both answer 403. 11 tests. |
| **EXP-02** | Medium (found while building EXP-01) | TGT both apps `stores/queryCache.ts`, `favoritesStore.ts`, `wageQuoteStore.ts` | All three drop their per-customer data when `session.token` changes — but act-as changes **whose** data they hold *without* changing the token. An expert entering a customer's account would have seen their own profile, orders, balance, ledger, favourites and **making-charge quotes**. | Read each watcher; each one's own comment stated the rule it was about to break. | ✅ Fixed with EXP-01: each watcher now also keys on `actingAs.id`. The wage-quote one is the money case — making charge is per customer, so a stale quote would have shown a figure that was not the one the order was priced at. |
| **EXP-03** | Medium (same) | TGT both apps `api/client.ts` `uploadDocument` | The multipart upload bypasses `request()` and builds its own headers, so the act-as header had to be repeated there. Without it an expert uploading a customer's documents would have filed them against their **own** account, silently and with no error. | `grep Authorization` on the client returns two call sites, not one. | ✅ Both call sites send the header. |
**Deliberately different from the reference** (all in D29): no customer token is
ever minted; the act-as target lives in `sessionStorage`, so closing the tab ends
it, where the reference's `api_token` sat in `localStorage` forever and outlived
the expert's employment; `adminUserId` survives the switch so an order placed
this way is attributable (the reference's requests were indistinguishable from
the customer's own); a permanent «در حال مشاهده به‌عنوان …» bar with an exit
button, which the reference had no equivalent of — there, impersonation was
invisible and the only way out was a logout that also ended the expert's own
session; and the cart is cleared in both directions.
## Apps — navigation and read-freshness audit (2026-10-12)
Owner report: «من مثلا از صفحه پروفایل وارد صفحه لاگین میشم بک میزنم میره به صفحه
اصلی در صورتی که باید بیاد توی صفحه پروفایل … این یک نمونه بود، تمام ساختار این
مدلی رو برسی کن». Audited both, found four navigation defects and three
read-freshness ones.
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **NAV-01** | Medium (UX, pervasive) | TGT both apps `components/ui/PageBar.vue` + 14 views | The back arrow pushed a **fixed route name** (`backTo`) instead of going back, so it ignored where the person came from. The reported case: the guest dashboard → «ورود / ثبت‌نام» → back landed on **home**, not the dashboard they came from, because `LoginView` declared `back-to="home"`. Of 15 `PageBar` uses only `SearchView` used real history. Inherited from the reference, which did the same in every component's back arrow (`$router.push({name:'userPage::hasSubRoute'})`). | Read `PageBar.onBack` and grepped all `back-to=` uses. | ✅ **FIXED.** `backTo` is now a **fallback**, used only when there is nothing to go back to; otherwise `router.back()`. The 14 declarations are kept and unchanged — they are exactly the right fallback for a direct link or refresh. |
| **NAV-02** | Medium | TGT both apps `stores/navStore.ts` (new), `router/index.ts` | NAV-01 needs to know whether back lands inside the app. `history.length` cannot answer it: it counts entries from before the app was opened, so a link opened in a fresh tab would look like it had somewhere to go back to and the back button would leave the app. | — | ✅ `navStore.entryIndex` records the history position of the app's first navigation (`afterEach`), and `canGoBack` compares `history.state.position` against it. Direction-proof, unlike a counter, which only grows and would be wrong after one back-and-forward. Verified vue-router writes `position` on every entry (`vue-router.mjs:97,109,145`). |
| **NAV-03** | **High (loses the user's place)** | TGT both apps `views/user/LoginView.vue` | After a successful sign-in the app `push`ed the destination, leaving the **login screen in history**. One tap of back returned to the login box, which the guard then bounced to home — so back after signing in lost the person's place entirely. The same file already honoured `?redirect=`, so sign-in and back disagreed about where the person belonged. | Read `sendToken`'s navigation against the `to.name === 'login'` guard. | ✅ `router.replace`. |
| **NAV-04** | Medium | TGT both apps `main.ts` (401 handler), `views/user/CompleteProfileView.vue` | Two more `push`es where the page being left is no longer reachable: the 401 handler left the page whose request just failed in history (back → it → bounced to login again), and complete-profile left a form that the guard redirects home once the documents are in, so back appeared to do nothing. | Grepped every `router.push` in both apps and checked each against the guards. | ✅ Both are `router.replace`. `CartView`'s post-order `push` to the orders list was **left as-is** — back lands on the emptied cart, which is a valid screen, not a dead end. |
| **READ-01** | **High (stale money data)** | TGT both apps `views/user/OrdersView.vue` | The view is in MasterLayout's keep-alive list but only loaded in `onMounted`, so **the second and every later visit made no request at all** — and even the first used the cache. An order's status changes on the admin side, which nothing in the app invalidates, so the list could be stale for the whole session. | `KEEP_ALIVE = ['HomeView','SearchView','OrdersView','DashboardView']` vs the view's single `onMounted`. | ✅ `onMounted` + `onActivated`, both `force`, per the owner's ruling. |
| **READ-02** | Medium | TGT both apps `views/user/TransactionsView.vue`, `HomeView.vue`, `user/DashboardView.vue` | Ledger entries were read without `force`, so after the first visit the cached list was shown indefinitely — and they are written by the **accounting** side, which this app can never know about. The gold price was only forced by pull-to-refresh, on two screens that show it prominently; `HomeView` is kept alive, so it was fetched once per app launch. | Read each call site. | ✅ All forced on entry. `DashboardView.loadDashboard`'s `force` parameter is gone: with both of its reads always fresh it controlled nothing. |
| **READ-03** | — (deliberate) | TGT both apps `stores/catalogStore.ts` | The catalogue is **not** re-read on entry. | — | Intentional, per the owner's own reasoning («سربار برای سرور نداره»): it is the entire active product list in one response, it changes when an admin edits it rather than minute to minute, and pull-to-refresh and placing an order already invalidate it. Re-fetching it on every tab switch is the needless load the ruling is meant to avoid. |
**On the cache layer itself** (the owner also asked whether its structure is
sound). It is: concurrent callers share one in-flight promise, and a response
that arrives after an `invalidate` is handed to its caller but **not** stored, so
a write never loses a race against a read in progress. Both are easy to get
wrong and both are right here. Its one real gap was that nothing ever went
stale — no TTL, and `force` used only by pull-to-refresh — which READ-01/02 fix
at the point that matters: opening the screen. The policy is now written at the
top of `queryCache.ts` so the next person does not re-introduce a TTL.
## Super-admin-only features — found while building D30 (2026-10-14)
| ID | Severity | Where | Description | Status |
|---|---|---|---|---|
| **PROMO-TAK-01** | Medium | TGT `admin-promotions.service.ts:147`, `PromotionForm.vue:67` | `type_customer` (1 = تک, 2 = عمده) is not stored; it is derived from the scope — CUSTOMER scope gives 1, any other gives 2. The «تک» form hides the customer scope, so everything it creates comes back as عمده, while the عمده form *can* pick the customer scope and produce a «تک» row. The API also never checks the `.tak` rids and deletes on `offers.list.edit`. Consequence for D30: retail promotions can only be restricted in the panel, not in the API. | Open — logged, not fixed |
| **CINY-01** | Low | TGT `admin-products.service.ts` `update()` | `create()` saves `ciny_ids` (trays), `update()` ignores them, so changing a product's trays on edit is silently lost. | Open — logged, not fixed |
+127
View File
@@ -0,0 +1,127 @@
# Migration state — investigated before writing D0 / percent
Requested by the user before any new migration is written. All findings are from
**read-only** queries; nothing was reset and `_prisma_migrations` was not touched.
## The five questions
### 1. Is `20260917120000_customer_name_parts_and_percent` recorded as applied?
**Yes — but as a no-op.**
```
migration_name 20260917120000_customer_name_parts_and_percent
finished_at 2026-09-18T16:03:33.956Z
rolled_back_at null
applied_steps_count 0 <-- zero steps ran
```
`finished_at` is set and `rolled_back_at` is null, so **Prisma considers it
applied and will never re-run it**. `applied_steps_count = 0` says no SQL
statement was ever executed by it.
### 2. What is in its SQL file?
**Comments only — no executable statement.** The file is a placeholder that
explains itself: it says the columns were added "directly against the live DB"
and that it was recreated as an empty no-op so `prisma migrate status` would
stop erroring on a missing directory.
### 3. Does `schema.prisma` declare `firstName` / `lastName` / `productPercent`?
**No.** `grep` over `prisma/schema.prisma` finds none of the three. The
`Customer` model has `fullName` and nothing else name-related.
### 4. Do the columns exist in the database?
**No.** Querying `information_schema.columns` for `Customer` and those names
returns only `fullName`:
```
firstName absent
lastName absent
productPercent absent
makingChargeOffsetPercent absent
fullName present
```
**So the placeholder file's own comment is wrong.** It claims the columns "already
exist live"; they do not, at least not in this database (`gold_banking` on
localhost). Whatever database that comment described, it was not this one.
**Consequence for the runtime:** none, and this is the good news. Prisma Client
generates from `schema.prisma`, and the schema does not mention these fields
either. Schema and database agree — both lack the columns. There is **no drift
that could make Prisma Client throw at runtime**. The only thing that is wrong is
the placeholder's comment.
### 5. `prisma migrate status` (read-only)
```
9 migrations found in prisma/migrations
Following migration have not yet been applied:
20260922000000_customer_otp
```
Note on that line: the OTP table **does exist** — stage 2 created it with
`prisma db execute`, which runs SQL without writing a ledger row. So the schema
is correct but the ledger entry is missing, and `migrate deploy` would try to
run it again. The SQL is written with `CREATE TABLE IF NOT EXISTS`, the indexes
with `IF NOT EXISTS`, and the FK inside a `DO $$ ... EXCEPTION WHEN
duplicate_object` block, so a re-run is harmless. Still worth resolving cleanly —
see the recommendation.
### 6. Where did the file come from?
```
b80fa9b Redesign product detail page and fix icon/cart reactivity bugs
```
A single commit, and the message has nothing to do with migrations — the
placeholder was swept in as collateral to an unrelated frontend change.
---
## Recommendation
**The placeholder is recorded as applied, so per the user's rule it must not be
edited.** It stays exactly as it is.
1. **Leave `20260917120000_...` untouched.** Editing a migration Prisma has
recorded would make its checksum mismatch and break `migrate status` for
everyone. Its only sin is a misleading comment, and a follow-up migration
makes that harmless.
2. **Write the D0 and `percent` work as a new, additive migration** with a fresh
timestamp. It adds the columns the placeholder never added. Written to be
idempotent (`IF NOT EXISTS`) so it is safe on a database where someone *did*
apply the columns by hand.
3. **Resolve the OTP ledger entry** with
`npx prisma migrate resolve --applied 20260922000000_customer_otp`.
That writes the ledger row without running SQL, which is exactly right here
since the table already exists. **Needs the user's approval before running**,
as it writes to `_prisma_migrations`.
4. **Never** `migrate reset`, and never edit `_prisma_migrations` by hand.
---
## Naming: one field, not two
The user asked for a single final name. **`productPercent` is dropped;
`makingChargeOffsetPercent` is the name.**
Reason: the reference has **three unrelated things** all called `percent`, and a
vague name is how they got confused in the first place (the target currently
reads the wrong one — CUST-02):
| Reference `percent` | Where | What it is |
|---|---|---|
| `customers.percent` | `customer_add.php:32` | a **per-customer making-charge offset**, used only to shift catalogue filter bounds (`ApiMobileController.php:391-396,76-77`) |
| `ConfigStatic['percent']` | `AdminController.php:903,994` | the **wallet cashback rate**: `total_weight × percent/100` |
| `order_item.percent` | `MajorOrderController.php:220` | the **per-line making charge** on a wholesale order |
`productPercent` would read as "a percent belonging to a product", which is the
third meaning, not the first. `makingChargeOffsetPercent` says which of the
three it is. It is mapped back to the legacy key `percent` in `toRow()`, so the
admin panel's field name does not change.
`productPercent` is not created, so there is no duplicate field.
+330
View File
@@ -0,0 +1,330 @@
# PROGRESS — Reference Parity Project
> Read this file FIRST at the start of every session and after every `/compact`.
> Rules live in `CLAUDE.md` (workspace root). Method lives in the `reference-parity-audit` skill.
> Neither is in this repository — see `docs/GO-LIVE-PLAN.md` §0.
## New workstreams (2026-10-04) — plans only, nothing executed
| Plan | What | Waiting on |
|---|---|---|
| `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.
## Current state
| Field | Value |
|---|---|
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, app-login error reporting (**AUTH-01/02/03**, **DOC-01**), **«ورود به حساب مشتری» restored** (**EXP-01/02/03**, D29 — supersedes D21's omission), and the apps' back-navigation + read-freshness audit (**NAV-01…04**, **READ-01…03**). **308/308** jest tests; both apps `vue-tsc` clean. |
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
| **Last updated** | 2026-10-12 |
### Phase 1 close-out (2026-10-12)
The two items that were still genuinely unfinished:
| Item | Result |
|---|---|
| §2 — the D1 rid matrix (stood as *(pending)* all phase) | ✅ filled: 163 rid-guarded routes, 68 distinct rids, 176 tree keys, mapped per module. 66/68 grantable from the UI; the 2 that are not are **PERM-02**. |
| Order-placement SMS | ✅ **SMS-03** — the matrix had logged one missing message; in fact all four were missing and `OrdersService` had no `SmsService`. Fixed with 6 tests. |
**What is NOT done, by decision rather than by omission:**
| | Why |
|---|---|
| §3 — product price display (PRICE-01) | ⏸️ retail storefront, out of scope by **D13/D16**. `variant-price.ts` + 11 tests kept but unused. |
| Firebase push beside each SMS (PUSH-01) | ⏸️ deferred to the PWA port. |
| «سفارش ویرایش شد» admin SMS | «عمداً تکرار نشد» (D4) — no target path edits a placed order. |
| PERM-02 (2 ungrantable rids) | additive, changes the permission vocabulary → awaits the owner, as PERM-01 did. |
| `02-APPS-MATRIX.md` | Phase 2's file; not created yet. |
⚠️ **Not verified in a browser.** Every ✅ above rests on reading both sides,
unit tests and smoke scripts. The prior port's note still stands: no admin view
has been visually checked in a browser. A full click-through is the honest
remaining risk on Phase 1.
### Security hotfix — final result
| Route | Was | Now |
|---|---|---|
| `GET accounting/customers/:id/balance` | public | admin, rid `wallet.list` |
| `POST accounting/customers/:id/reconcile` | public **write** | admin, rid `wallet.charge` |
| `POST accounting/repayments/gold` | public **write** | admin, rid `document.create` |
| `POST accounting/repayments/rial` | public **write** | admin, rid `document.create` |
| `POST accounting/entries/:id/confirm` | public **write** | admin, rid `document.list.confirm` |
| `PATCH orders/:id/status` | public **write** | admin, rid `order.list.sts.submit` |
| `GET customers` | every customer, 18 fields | **route deleted** |
| `GET customers/:id` | public | **route deleted** → `GET customer/me` (token) |
| `GET customers/:id/orders` | public | **route deleted** → `GET customer/me/orders` (token) |
| `GET customers/:id/ledger-entries` | public | **route deleted** → `GET customer/me/ledger-entries` (token) |
| — | — | **new** `POST customer/auth/request-otp` · `verify-otp` |
Still public by design: `catalog/products`, `pricing/latest`, `home/slider`,
`uploads/:filename` (ruling T2), and `POST /orders` + `GET /orders/:id` — the
last two are logged as SEC-02/B1-B2 and want a customer-token pass of their own.
**Tests**
- `apps/api/scripts/auth-smoke.sh` (live server): **46 passed, 0 failed**.
Run it with `API=http://localhost:3000/api CODE_LOG=/path/to/api.log`.
- `pnpm test` in `apps/api` (jest, newly installed): **7 passed**.
- Typecheck: api ✅ · user-app ✅ · user-app-v2 ✅. Both apps build ✅.
### ⚠️ Correction worth remembering
Stage 1 reported the customer-book leak as "mitigated" after narrowing the
`GET /customers` field list. That was wrong: the response still returned every
customer **id**, and the three `/customers/:id*` routes were still public, so the
full data was still two requests away. The user caught it. Stage 2 deleted the
whole id-bearing surface instead. **Narrowing a response is not closing a leak
while the id and the id-addressed routes remain.**
## ⚠️ Read `DECISIONS.md` before doing anything
All user rulings live there (D0–D17). Highlights that change how work is done:
- **D12 — the matrix is FIELD-BY-FIELD, not page-by-page**, and a 4-step
round-trip test per entity is mandatory. Both rules were added to the
`reference-parity-audit` skill (Step C rule C0, Step E rule E0).
- **D0** — split `fullName` into `firstName`/`lastName` via an additive migration;
**show the migration and get approval before running it**.
- **D4** — never port broken behaviour, but match calculations/rounding/messages
exactly; annotate each non-replication «عمداً تکرار نشد — ISSUE #n».
- **D2** — target-only features get status ➕ and are left alone.
- **D13** — the **retail storefront** (48 `customer.php` + 30 `site.php` + 31
storefront-management admin routes = **109 of the reference's 412 endpoints**)
is **preserved but out of scope**: status ⏸️, no gap row, no work row, nothing
deleted or disabled. **288 endpoints stay in scope** (218 admin + 70 B2B app).
Note: the reference total is **412, not 369** — `admin.php` was undercounted
in Phase 0; corrected in `00-INVENTORY.md` §2.1.
Ask before changing shared surfaces (product, variant, price, order, customer).
## Phase checklist
- [x] P0.1 — All 3 reference folders readable (`henza_server`, `henza_admin`, `henza_pwa`)
- [x] P0.2 — Branch `parity/phase-0` + `docs/parity/` created
- [x] P0.3 — Inventory all 7 projects → `_inv-*.md` (8111 lines total) → consolidated in `00-INVENTORY.md`
- [x] P0.4 — Mapping tables, missing list, extra list (`00-INVENTORY.md` §2–4)
- [x] P0.5 — Draft `PROTECTED.md`
- [x] **P0.6 — GATE passed** — `PROTECTED.md` confirmed, D0–D12 recorded
- [x] P0.7 — Urgent traces delivered: `percent`, `@Public` list, login leak
- [x] P0.8 — Skill updated with rules C0 (field-by-field) and E0 (round-trip test)
- [x] **P1.0 — approved and DONE:** 5 ledger routes protected, `percent` column added, D0 migration applied, SMS provider implemented
- [x] Phase 1 — Admin parity (`01-ADMIN-MATRIX.md`) — all in-scope sections closed 2026-10-12
- [ ] **GATE** ← HERE — asked 2026-10-12: "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?"
- [ ] Phase 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps)
- [ ] Phase 3 — Work through `ISSUES.md` (only when user asks)
## Answered — see `DECISIONS.md` for the full text
| # | Ruling |
|---|---|
| D0 | Split `fullName` → `firstName`/`lastName`, additive migration, backfill, sync, report multi-token rows. Approval needed before running. |
| D1 | Target's stricter auth stands. Build the "reference operation ↔ rid" matrix; explain 172 vs 174. |
| D2 | Audit only what the reference has. Target-only features → ➕, untouched. |
| D3 | Build the 5 missing screens in Phase 2, both apps, plus analytics, `isLoaded`/`sts_verify_step_two` guards, guest toast+home, and v2's cart ceiling. |
| D4 | Don't port breakage; do match calculations/rounding/messages. Unit-test the price formula. |
| D5 | Leave the stray folder; delete last, own commit. |
| D6 | **OTP must verify** — parity gap, fix it (plus expiry + attempt limits, which exceed the reference). |
| D7 | `@Public` ledger routes: list delivered, awaiting approval to protect. |
| D8 | Login leak: **target**, full customer book incl. national IDs and debts. |
| D9 | `percent`: trace delivered — **the target's implementation is wrong**. |
| D10 | Sales expert: keep `AdminUser`; investigate the 5 missing fields; propose `SalesExpertProfile`. |
| D11 | `singleCustomer.vue` → diff-send. |
| D12 | **Field-by-field matrix + mandatory round-trip test.** Added to the skill. |
| D13 | **Retail storefront (wallet, cashback, `ConfigStatic['percent']`) preserved but OUT OF SCOPE.** Nothing deleted or disabled; no parity work; matrices list it ⏸️ with no gap/work row; ask before touching shared surfaces. |
| D14 | **`migrate dev` in development, `migrate deploy` in production and nowhere else.** Never `migrate reset`; never edit `_prisma_migrations`. On drift + a reset offer: **stop and report**. |
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
| D29 | **«ورود به حساب مشتری» restored** (2026-10-12) — a sales expert can work inside any of their referred customers' accounts with the reference's **full** scope (every customer route, documents included). Mechanism deliberately not the reference's: no customer token is minted; `X-Act-As-Customer` rides the expert's own token and is authorised in `CustomerJwtStrategy.validateExpert`, re-checking `referredByAgentId` on **every** request. **Supersedes D21's "deliberately not ported".** |
| D28 | **App login step 1 must say what went wrong** (2026-10-12): an unregistered number → 404, a failed SMS send → 503, an unusable sales-expert account → 403, each with its own Persian message. Restores the reference's `firstOrFail()`. **Trades away part of the anti-enumeration protection, deliberately** — the per-mobile/per-IP send throttles still bound it and `verifyOtp` stays single-message. See D28 for the full reasoning and why D20 is not violated. |
## Open — awaiting user approval
Nothing. The 5 ledger routes, the `percent` column and the D0 migration are all
done and verified (see CHANGELOG 2026-09-22).
## Key context (survives context loss)
- **Domain correction:** the reference is a **gold-jewellery B2B/B2C e-commerce**
system, not a gold bank. No buy/sell order book, no spread, no deposit/withdraw,
no P2P transfer, no KYC levels, no daily limits (only `gram_level`), no payment
callback route, no cron, no websockets. See `00-INVENTORY.md` §0.
- **The gold price formula** (Phase 1 row #1) —
`henza_server/app/Models/ProductVariant.php:183-190`: `+extra%` → `+7%` margin →
`+9%` VAT **on the non-metal portion only** → `×grams` → `floor(/1000)*1000`.
Three superseded commented-out variants above it apply VAT to the whole price —
do **not** port those.
- **A large prior port already happened** — see `docs/ADMIN-PORT-PLAN.md` and
`docs/CHANGELOG-PORT-HISTORY.md` (80 views, 25 Nest modules, 29 Prisma models,
154 admin routes, 144/144 rids). Prior port notes **no view was ever visually
verified in a browser**.
- **Stack changed across the port:** Laravel→NestJS/Prisma, Vue2/Vuesax→Vue3/antd.
Port behaviour, not code style.
- **Reference scale is inflated:** 541 admin `.vue` files but only ~69 reachable
(rest are Vuexy template demos); 9 reference router files are dead (not imported);
~28 legacy Django-era DB tables have no migrations.
- **v1 ⇄ v2 logic gaps** (5, all v2 behind v1) are parity work, not design:
`ISSUES.md` MON-01, V2-01..05.
## Inventory files (Phase 0 working files)
| File | Scope | Lines |
|---|---|---|
| `_inv-ref-server.md` | henza_server | 1313 |
| `_inv-tgt-api.md` | apps/api | 1179 |
| `_inv-ref-admin.md` | henza_admin | 1705 |
| `_inv-tgt-admin.md` | apps/admin-panel | 1261 |
| `_inv-ref-pwa.md` | henza_pwa | 1178 |
| `_inv-tgt-userapp.md` | apps/user-app | 612 |
| `_inv-tgt-userappv2.md` | apps/user-app-v2 | 700 |
Consolidated into `00-INVENTORY.md`. Issues found: `ISSUES.md` (26 entries).
## Standing rules added 2026-09-22
1. **Migrations run only via the standard Prisma workflow** (`migrate dev` in
development). **`prisma db execute` is not used for schema changes.**
2. **Every data deletion is logged in `CHANGELOG.md`** — including throwaway
rows created during a session.
3. **D13** — the retail storefront is preserved but out of scope; see
`DECISIONS.md` §D13 and `PROTECTED.md` §H.
## Phase 2 — item #1 (MON-08) — ✅ DONE 2026-10-12
All four steps of the ruling are in place; see `ISSUES.md` §"Phase 2 — item #1".
Steps 1, 3 and 4 had landed with the wage-formula work; **step 2 — logging the
client figures that get discarded — was added 2026-10-12**, along with the
comparison test the ruling asked for («تست مقایسه‌ای»), which step 3 had not
actually had: the suite only covered a tampered request, not an honest one.
`orders.service.spec.ts` is now 16 tests.
An operator ordering from the panel still keeps the numbers they typed — only an
app order is re-priced by the server.
## Phase 2 — item #2 (D3) — ✅ DONE 2026-10-12
D3 was ten items, not one. **Seven were already built** — all five screens
(`/report`, `/report/detail`, `/ChildOrder`, `/customerChoice`,
`/complete_profile`) exist in **both** apps, as do the `sts_verify_step_two`
guard and v2's cart stock ceiling. Checked in the code, not read off a
checkmark.
| Outstanding item | Result |
|---|---|
| guest toast «ابتدا وارد شوید» | ✅ both apps (**APP-03**). The destination stays the target's `/login?redirect=…`, better than legacy's `home` — owner's call, logged as «عمداً تکرار نشد — D3/الف». |
| `isLoaded` router guard | ⏹️ **not ported** (**APP-04**) — guards a bootstrap request the target does not have; would only bounce a shared product link to `home` (D4). Owner agreed. |
| route analytics | ⏸️ **deferred, lowest priority** (**ANLY-01**) — design parked in `docs/PHASE2-D3-PLAN.md`, with the two reference defects not to copy and the two retention/privacy questions still open. |
⚠️ **Phase 2 is running without its matrix.** `02-APPS-MATRIX.md` still does not
exist, so "what else do the apps miss" rests on the earlier survey rather than a
field-by-field comparison (D12). That is the next item.
The original requirement, for the record:
Before anything else in Phase 2: the server must derive `wagePercent`,
`discountPercent` and `flatRialCharge` from the DB and **ignore** the client's
values, because `henza_pwa` computes them rather than asking the user
(`cart.vue:170-179`, `Mixins/product/index.ts:69-75`). A comparison test must
show the total is unchanged for an ordinary customer. Depends on
`makingChargeOffsetPercent` existing. Full reasoning: `DECISIONS.md`
§"Report — MON-08".
## Done 2026-09-22 (was: waiting on the owner)
| # | Item | What is ready |
|---|---|---|
| 1 | Run the **D0** and **percent** migrations | ✅ **Applied** with `migrate deploy` (`migrate dev` is interactive-only and this shell is not a TTY). Drift checked read-only first — the only difference is the pre-existing `_goldbank_migrations` table, which was **NOT** dropped (DEAD-07). Service layer, all three forms, trigger test and the full round-trip test done. |
| 2 | Implement the **SMS provider** (Trez) | ✅ `SmsModule`/`SmsService` live, OTP wired, text byte-identical, boot check fails in production on a missing key, `.env.example` has names only. The 12 B2B templates land per flow in their own phase. **SEC-12: the owner is rotating the credentials and setting them in `.env`.** |
## ⚠️ Standing rules added 2026-09-22 (D14)
1. **`migrate dev` in development, `migrate deploy` in production and nowhere
else.** Never `migrate reset`, never edit `_prisma_migrations` by hand. If
drift is reported and a reset is offered: **stop and report**, do not accept.
`prisma db execute` is no longer used for schema changes.
2. **Every data deletion is logged in `CHANGELOG.md`**, including throwaway rows
a session creates itself.
## ⚠️ `customer_sync_full_name` is a DATABASE TRIGGER
Prisma knows nothing about it, so writing `Customer` can store something
different from what you wrote:
| Write | Result |
|---|---|
| the parts only | `fullName` is rebuilt |
| `fullName` only | the parts are re-derived (first token / the rest) |
| **both at once** | **THE PARTS WIN**; the submitted `fullName` is discarded |
Re-read after writing if you need the stored value. See `DECISIONS.md` §D14 and
the doc comment on the `Customer` model in `schema.prisma`.
## ⚠️ Two migration ledgers, on purpose (D15)
| Ledger | Written by | Used |
|---|---|---|
| `_prisma_migrations` | the Prisma CLI | development |
| `_goldbank_migrations` | `apps/api/src/prisma/migrate.ts` | the deployed container, at boot |
The Docker deploy image is copy-only and ships **no Prisma CLI**, so the app
applies migrations itself at startup. **Every new migration must be safe under
both paths** — keep the SQL idempotent (`IF NOT EXISTS`, `CREATE OR REPLACE`).
## ⚠️ Persian test input comes from a FILE (D15)
The Windows shell replaces Persian characters with `?`. A test that passes its
data inline can "pass" while storing `????`. Write fixtures in the test file or
a UTF-8 JSON file and send bodies with `curl --data-binary @file`. Console
output of Persian is never evidence — compare values in Node.
## ⚠️ Phase 1 work order (D16 — supersedes the earlier ordering)
**(a) Persons (اشخاص)** ← current
1. The 5 missing sales-expert fields (`ncode`, `phone`, `city_id`, `gram_level`,
`reagent_id`): **report first** where the reference uses each for `type=1`,
then add accordingly (proposal: `SalesExpertProfile` related to `AdminUser`,
D10). Unused fields are listed for the owner, not added speculatively.
2. `singleCustomer` → diff-send (D11).
3. Field-by-field audit of every Persons form and grid (retail, wholesale,
sales expert) per D12, each with its round-trip test.
**(b) Products, categories, variants, admin order detail** — deep field-by-field.
The variant image picker is PROTECTED (§B) — additive only. Making charge on
every variant and order line must display and persist exactly as the reference.
**(c) The rest, in order:** financial documents → users & permissions → admin SMS
templates → promotions / notifications / cities / branches / workshops → reports.
**(d)** Full admin test pass → Phase 1 report → gate question → **STOP and wait**.
### Method per section (D16)
Implement the gaps first, then field-by-field test that same section. **Show the
owner a short gap summary BEFORE fixing each section.**
## ✅ SEC-02 — verified complete (re-tested 2026-09-22)
| Check | Result |
|---|---|
| `POST /orders` without a token | 401 (was `@Public`) |
| Body `customerId: "customer-2"` with a `customer-dev-test` token | order landed on **customer-dev-test**; spoof blocked and warn-logged |
| `GET /orders/:id` own order | 200 |
| `GET /orders/:id` another customer's order | **404**, not 403 — ids stay unenumerable |
| `GET /orders/:id` unknown id | 404, identical response |
| `GET /orders/admin/:id` with a customer token | 401 |
**9/9 passed** live, plus **32/32** in `auth-smoke.sh`. The server derives the
customer from the token only (`orders.controller.ts:26-29`,
`orders.service.ts` `createOrder(dto, customerId)`).
Client-supplied `wagePercent` / `discountPercent` / `flatRialCharge` remain —
that is **MON-08**, scheduled as Phase 2 item #1 per the owner's ruling.
+276
View File
@@ -0,0 +1,276 @@
# PROTECTED — intentional monorepo additions (additive-only)
**CONFIRMED by the user at the Phase 0 gate (2026-09-21).**
Rulings are recorded in `DECISIONS.md` (D0–D12).
Items here are intentional changes in `gold-banking-monorepo`. Per `CLAUDE.md` §4
they must **not** be removed, restyled, restructured, or have their behaviour
changed. You may only **ADD** what the reference has and they lack, in a way that
doesn't break them. Any fix that touches a protected item needs explicit approval
with a diff plan first.
Legend: **[brief]** = named as protected in the kickoff brief · **[found]** =
discovered in Phase 0, proposed for protection · **[?]** = needs your ruling.
---
## A. Admin panel — Dashboard · **[brief]**
**Location:** `apps/admin-panel/src/views/Dashboard.vue` — one file serving both
`/dashboard/ecommerce` and `/dashboard/analytics`. KPI chrome lives in
`components/statistics-cards/StatisticsCardLine.vue`.
**Gate:** rid `dashboard.view`; lacking it redirects to `my-data` (not a 403 page).
**Data:** a single `GET admin/dashboard/overview` on mount, then client-side sorts
(`reagent` desc by `grams_sell`; `lastOrder` desc by `w`).
Contents, in render order:
1. 4 KPI tiles — مشتریان جدید (نفر) · طلای فروش‌رفته (گرم) · تعداد سفارشات · نرخ هر گرم طلای ۱۸ عیار
2. 2 KPI tiles — سفارشات تحویل‌شده · مجموع بدهی طلای مشتریان (گرم)
3. Area chart «روند سفارش‌ها» — 2 series (تک / عمده); x-labels rotated **-45°** with
`rotateAlways` + `hideOverlappingLabels` (a deliberate overlap fix — do not "tidy" it)
4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک
5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش)
6. Vertical bar «محصولات پرفروش» with an `a-empty` fallback
7. Sales summary «میزان فروش» — 3 equal cells incl. a computed average
8. Table «سفارشات هفته جاری» — `pageSize` 10, `sts_str` as a coloured `a-tag`, `caj` via `jdate`
**Chrome (owner-approved 2026-10-14):** panel-unified look — plain layout background,
simple page header (no brand badge), bordered ant cards, muted period labels (not gold
pills), flat KPI tiles (soft navy icon well, no lift hover / side accent bar), equal
sales-summary cells. Chart series palette stays `#1c2d56 / #28c76f / #ff9f43 / #ea5455`.
**No filters, no date range, no export, no refresh** — that is the intended design, not a gap.
Spec: `docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md`.
> Why this matters for parity: the reference's two dashboards
> (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier
> port to be **mostly commented-out Vuexy demo markup**, with ApexCharts imported
> but never used in live markup (`docs/CHANGELOG-PORT-HISTORY.md`). So there is
> essentially nothing to port *into* this dashboard. Visual chrome may change only
> with explicit owner approval (this section).
---
## B. Admin panel — the extra option in product Variants · **[brief]**
**Location:** `apps/admin-panel/src/views/apps/product/AddProduct.vue:151-270,
315-361, 519-568` (Create/Edit Product).
**Gate:** the card is gated `product.list.variants`; add/edit/remove on
`.add` / `.edit` / `.remove`.
Variant table columns: تصویر | عنوان | وزن | اجرت درصدی | تعداد | فعال (tag) |
« پیش سفارش» (tag) | عملیات.
**The protected extra field is `تصویر تنوع` (variant image).** It is a **picker,
not an uploader**: a 64×64 flex grid over the product's already-saved gallery
(`data.gallery`, each `{id, url, role, sortOrder}`), whose first tile is a
`بدون تصویر` option and whose selected tile gets a blue `.is-on` border.
`pickVariantImage()` sets `image_id` (posted) plus `image_url` (thumbnail only,
stripped before POST).
It is populated **only in edit mode** — on create the gallery is empty and an
`a-alert` shows a context-dependent message (`ابتدا محصول را ذخیره کنید…` vs
`برای این محصول تصویری ثبت نشده است.`).
Variants are edited **purely in memory** (row key `__key = v-<ts>-<rand>`) and
saved with the product via `PUT product/update/{id}` / `POST product/create` under
a `variants[]` array — there is **no per-variant endpoint**. Only validation is
`عنوان تنوع الزامی است`; delete has **no confirm dialog**.
> Related, and NOT protected by itself: the same `imageId`/`image` contract on the
> **user-app** side. `user-app` consumes it; `user-app-v2` does **not** (gap V2-03).
> Closing that gap in v2 is additive and does not touch this admin code.
---
## C. User apps — new designs and new features · **[brief]**
Both `apps/user-app` and `apps/user-app-v2` keep their own visual design. The
following were identified in Phase 0 as design/feature additions with no
`henza_pwa` equivalent.
### C.1 Shared by both apps
| Item | Location |
|---|---|
| `/notifications` route + screen (self-documented "no legacy equivalent") | `views/user/NotificationsView.vue` |
| `ui/PageBar.vue` replacing the legacy `AppHeader` | `components/ui/` |
| Redesigned CSS bottom nav (5 tabs; legacy was a 4-tab GSAP notch) | `layout/` |
| `GallerySlider.vue` rewrite + teleported full-screen image preview | `components/product/` |
| Variant chips with per-variant images, stock badges, «نمایش همه» | `components/product/SingleProduct.vue` |
| The whole stock / pre-order rule (`qty`, `isPreOrder`, `capQty`, `maxQty`) | `stores/cartStore.ts`, `composables/useProduct.ts` — **v1 only; porting to v2 is gap MON-01** |
| vant pull-to-refresh on 5 screens | various views |
| IntersectionObserver incremental rendering (20-item window) | list views |
| Ledger-based «اسناد» screen | `views/user/TransactionsView.vue` |
| Order-detail drawer + 4-step tracker + `useOrderItemProduct` join | `views/user/OrdersView.vue` |
| 9-chip `OrderStatus` enum | `types/` |
| Dotlottie update dialog | app shell |
| New API contracts: `GET /home/slider`, `GoldPriceQuote`, `productCode`, `collections[]`, `gold-bank:*` session keys | `api/client.ts` |
### C.2 `user-app` (design v1) only
Home family: `components/home/RateStrip.vue`, `ShowcaseSlider.vue`,
`ShowcaseCard.vue`, `CategoryRail.vue`, `SectionHead.vue`, `AccountStrip.vue`;
admin banner via `GET /home/slider` + `AdminSlide`; the «برگزیده منتزر» and
«تازه‌ها» home sections; trust-badge rows.
### C.3 `user-app-v2` (design v2) only
`components/home/HeroCarousel.vue`, `ProductCarousel.vue`, `CategoryGrid.vue`,
`ProductPanel.vue`; the IRANSans FaNum font family (12 files,
`src/assets/styles/fonts/iransans/`); redesigned HomeView structure; redesigned
DashboardView (hero card + 4 settings rows, replacing card + tile grid); the
staggered first-paint reveal system (module-scope `hasRevealed`) in
HomeView/CartView/DashboardView; the `compact` RateStrip pill variant;
SingleProduct's scroll-driven solid header + category deep-link pill; the
pill-shaped SearchInput.
> **Important boundary.** C.2 and C.3 are *design* differences and stay separate.
> The five items in `ISSUES.md` §V2-01..05 are *logic* differences and are **not**
> protected — `CLAUDE.md` §2 requires logic to be identical across both apps.
---
## D. Backend — stricter auth than the reference · **CONFIRMED (D1)**
**Protected.** The user ruled the target's stricter auth stands as an accepted
deviation. Additional obligation from D1: build a full
"reference admin operation ↔ target rid" matrix so no operation lacks a
permission, and explain the 172-vs-174 key difference.
⚠️ **Not yet satisfied:** 5 ledger-write routes are still `@Public()`
(`DECISIONS.md` §"@Public audit" group A). They contradict D1 and are awaiting
the user's go-ahead to be protected.
The reference leaves every admin route unauthenticated (`henza_server/routes/admin.php:12`
and `:272` — the `auth:api` group wrapper is commented out at both ends, verified).
`apps/api` instead applies a global `JwtAuthGuard` + `PermissionsGuard` with
144 server-enforced rids, and only 15 routes opt out via `@Public()`.
Strict parity would mean **removing** authentication from ~215 admin endpoints.
`CLAUDE.md` §3 already carves out exactly this case ("if matching the reference
would cause … a clear security hole in money flows → STOP and ask").
**Proposed:** protect the target's stricter auth as an accepted deviation; log the
reference behaviour as `SEC-01` and the residual `@Public()` routes as `SEC-02`.
---
## E. Backend — Decimal money discipline · **CONFIRMED**
The reference uses PHP floats throughout. `apps/api` has **zero `Float` columns**
in `prisma/schema.prisma`; ledger and order math run on `Prisma.Decimal`.
Reverting to floats for "parity" would introduce rounding error into money and
gram values. **Proposed:** protect the Decimal discipline. The one leak
(`GRAMS_PER_MESGHAL` as a JS float, `ledger.service.ts:189`) is logged as `MON-03`,
to be fixed in Phase 3 — not now.
Note this does **not** exempt the target from matching the reference's *arithmetic*,
including the `floor(price/1000)*1000` rounding in `ProductVariant.php:183-190`.
Same result, better representation.
---
## F. Prior deliberate non-ports · **CONFIRMED (D4)**
Per D4: broken/destructive reference behaviour is not replicated, only logged —
but **business behaviour, calculations, rounding and messages must match the
reference exactly**, and every deliberate non-replication carries a matrix note
«عمداً تکرار نشد — ISSUE #n».
Recorded in `docs/CHANGELOG-PORT-HISTORY.md` as intentionally **not** ported:
1. Unauthenticated file upload (`file/create` had no middleware in the reference)
2. SMS sent inside `toggleActive`
3. Six destructive data behaviours — deleting a confirmed document, editing a
confirmed amount, deleting a category that has dependents, creating a category
cycle, orphaning the audit trail — each replaced with a Persian 400 message
4. The reference's avatar-load crash (`ReagentCustomer.vue:280` dereferenced
`data.image.id` unguarded)
5. The branch-mode horizontal layout (`HorizontalNavMenu`, 4 files never ported;
`mainLayoutType` narrowed to `'vertical'`)
Items 1–4 are security/data-loss carve-outs consistent with `CLAUDE.md` §3 and are
**proposed for protection**. **Item 5 is different** — it is a missing feature, not
a safety carve-out, and Phase 1 should treat it as a real gap unless you say
otherwise.
---
---
## G. Target-only capabilities · **CONFIRMED (D2) — status ➕, do not touch**
Per D2, features that exist only in the target are listed with status **➕** and
left alone. Ask before touching any of them.
| Capability | Where | Note |
|---|---|---|
| Dual-currency ledger (`LedgerEntry`, gold grams + rial) | `apps/api/src/modules/accounting/` | replaces the reference's single mixed-unit `balance` column |
| Gram / rial debt split (`goldDebtGrams`, `fiatDebtRial`) | `prisma/schema.prisma` | reference mixed both in one field |
| `Prisma.Decimal` money types throughout | schema + services | see §E |
| Server-enforced rid permissions | `auth/acl.constants.ts` | see §D |
| `UploadedFile` model + authenticated upload | `admin-uploads/` | reference upload was anonymous |
| Status-label / transition helpers | `admin-orders.service.ts` | reference had two divergent enums |
This list grows as Phase 1 and Phase 2 identify more. It is **not** a to-do list —
it is a keep-out list.
---
## Summary — confirmed status
| Section | Item | Status |
|---|---|---|
| A | Admin Dashboard | ✅ protected (brief) |
| B | Product Variants image picker | ✅ protected (brief) |
| C | User-app designs + new features | ✅ protected (brief), **excluding** the 5 v1↔v2 logic gaps (D3) |
| D | Stricter admin auth in `apps/api` | ✅ protected (D1) — 5 ledger routes still open, pending approval |
| E | Decimal money types | ✅ protected |
| F.1–4 | Prior security / data-loss non-ports | ✅ protected (D4) |
| F.5 | Branch horizontal layout | ❌ **a real gap**, not protected |
| G | Target-only capabilities | ✅ ➕ leave untouched (D2) |
### Explicitly NOT protected (parity work, per `DECISIONS.md`)
- The `fullName` collapse — **being reversed** under D0
- `percent` read from the referring agent — **wrong**, being corrected under D9
- The 5 v1↔v2 logic gaps — D3
- Unverified OTP — D6
- The public customer list — D8
- `singleCustomer.vue`'s full-form send — D11
---
## H — Retail storefront (D13) — preserved, out of current scope
Owner's ruling of 2026-09-22. The reference also ran a **retail storefront**
(B2C single-customer shop). The **wallet**, **cashback** and
`ConfigStatic['percent']` belong to it.
**Keep-out rule — stronger than "protected":** these are neither fixed nor
touched. Nothing is deleted or disabled on either side; no parity work is done
on them; they appear in the matrices only with status **⏸️ حفظ‌شده — خارج از
کار فعلی**, with no gap row and no work row.
| Surface | Reference | Endpoints |
|---|---|---|
| Customer API (prefix `cu`) | `routes/customer.php` | 48 |
| Public site API (prefix `site`) | `routes/site.php` | 30 |
| Wallet / cashback admin | `admin.php:186-191`, `Adm\WalletController` | 6 |
| Retail-customer admin | `admin.php:35,59,60,194-199` + `single` resource | 15 |
| Retail analytics | `admin.php:38` `get-analytics-tak` → `SingleCustomerOrders` | 1 |
| Product comments admin | `admin.php:150-152` + `comment` resource | 9 |
| **Total** | | **109** |
Whatever of this already exists in `gold-banking-monorepo` stays exactly as it
is — it is not restyled, not refactored, not removed.
**Shared-surface warning.** Product, variant, price, order and customer are
shared between the B2B business and the storefront. Before changing any of
them, check whether the change reaches the storefront and **ask the owner
first** (D13 rule 4). When in doubt whether something is storefront-only or
shared, **list it and ask — never guess** (D13 rule 5).
Full reasoning and the endpoint classification: `DECISIONS.md` § D13.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
# Inventory — apps/user-app (target user app v1)
All `path:line` references are relative to `apps/user-app/`. Scope: `src/**`, root config. `node_modules`/`dist` excluded.
---
## 1. Stack & versions
Source: `package.json:1-38`
| Field | Value | Line |
|---|---|---|
| name / version / private | `user-app` / `0.0.1` / true | `package.json:2-4` |
| scripts | `dev: vite`, `build: vue-tsc -b && vite build`, `lint: eslint "src/**/*.{ts,vue}"` | `package.json:6-8` |
### Dependencies (`package.json:10-24`)
| Package | Version | Line |
|---|---|---|
| @ant-design/icons-vue | ^6.1.0 | `package.json:11` |
| @gold-bank/shared-types | workspace:* | `package.json:12` |
| @vueuse/core | ^9.13.0 | `package.json:13` |
| ant-design-vue | ^3.2.20 | `package.json:14` |
| gsap | ^3.9.1 | `package.json:15` |
| moment-jalaali | ^0.10.0 | `package.json:16` |
| pinia | ^2.2.0 | `package.json:17` |
| swiper | ^7.4.1 | `package.json:18` |
| vant | ^4.0.3 | `package.json:19` |
| vue | ^3.5.0 | `package.json:20` |
| vue-router | ^4.4.0 | `package.json:21` |
| vue3-lazyload | ^0.2.5-beta | `package.json:22` |
| @lottiefiles/dotlottie-vue | ^0.4.15 | `package.json:23` |
### devDependencies (`package.json:25-37`)
| Package | Version | Line |
|---|---|---|
| @vitejs/plugin-vue | ^5.1.0 | `package.json:26` |
| autoprefixer | ^10.4.2 | `package.json:27` |
| less | ^4.1.2 | `package.json:28` |
| postcss | ^8.4.5 | `package.json:29` |
| tailwindcss | 3.0.18 (pinned) | `package.json:30` |
| typescript | ^5.5.0 | `package.json:31` |
| vite | ^5.4.0 | `package.json:32` |
| vue-tsc | ^2.1.0 | `package.json:33` |
| vite-plugin-pwa | ^0.20.5 | `package.json:34` |
| workbox-precaching / workbox-window | ^7.1.0 | `package.json:35-36` |
Notes: `gsap` is declared but **unused** in `src/**` (the GSAP notch nav was replaced — `src/components/ui/Navigation.vue:1-10`). `swiper` is used only by `src/components/slider/HomeSlider.vue:37` and `src/components/slider/Slider.vue:42`, both of which have **no importers** (dead components).
---
## 2. HTTP layer
Single file: `src/api/client.ts`. **No axios, no interceptors, no token attach, no refresh, no 401 handling, no error toasts.**
| Aspect | Value | Line |
|---|---|---|
| Client | bare `fetch` wrapper `request<T>()` | `src/api/client.ts:5-15` |
| Base URL | const `BASE = '/api'` (relative; dev-proxied) | `src/api/client.ts:3` |
| Default headers | `{'Content-Type':'application/json'}` only | `src/api/client.ts:7` |
| Auth header | **none** — no Authorization/Bearer anywhere in the app | `src/api/client.ts:5-15` |
| Refresh token | **none** | — |
| 401 handling | **none** — any non-2xx throws `Error("API <path> failed (<status>): <body>")` | `src/api/client.ts:10-13` |
| Error toast | **none** at the client layer; each caller does `console.log(e)` or a silent `catch` | e.g. `src/views/HomeView.vue:248`, `src/stores/cartStore.ts:52` |
| Credentials / cookies | not set | — |
| Retry / timeout / abort | none | — |
### Every API function
| Function | Method | URL | Params / payload | Returns | Line |
|---|---|---|---|---|---|
| `api.listProducts()` | GET | `/api/catalog/products` | none | `ProductSummary[]` | `src/api/client.ts:160` |
| `api.listCustomers()` | GET | `/api/customers` | none | `CustomerSummary[]` | `src/api/client.ts:161` |
| `api.getCustomer(id)` | GET | `/api/customers/{id}` | path `id` | `CustomerDetail` | `src/api/client.ts:162` |
| `api.getBalance(customerId)` | GET | `/api/accounting/customers/{customerId}/balance` | path `customerId` | `CustomerBalanceDto` | `src/api/client.ts:163` |
| `api.getLedgerEntries(customerId)` | GET | `/api/customers/{customerId}/ledger-entries` | path `customerId` | `LedgerEntrySummary[]` | `src/api/client.ts:164` |
| `api.createOrder(dto)` | POST | `/api/orders` | body `CreateOrderDto` = `{customerId, items:[{productVariantId, quantity, wagePercent}]}` | `OrderSummary` | `src/api/client.ts:165`; payload built at `src/views/CartView.vue:198-208` |
| `api.getCustomerOrders(customerId)` | GET | `/api/customers/{customerId}/orders` | path `customerId` | `OrderDetail[]` | `src/api/client.ts:166` |
| `api.getLatestGoldPrice(karatPerMille=750)` | GET | `/api/pricing/latest?karatPerMille={n}` | query `karatPerMille`, default **750** | `GoldPriceQuote` | `src/api/client.ts:167` |
| `api.getHomeSlider()` | GET | `/api/home/slider` | none (public) | `AdminSlide[]` | `src/api/client.ts:170` |
**Total: 9 API functions.** `api.getBalance` is declared but has **zero call sites** in `src/**`.
### DTO / response interfaces (`src/api/client.ts`)
| Interface | Key fields | Line |
|---|---|---|
| `ProductImageSummary` | `id, url, sortOrder, role: 'FEATURED'\|'FEATURED_MOBILE'\|'GALLERY'\|'GALLERY_MOBILE'` | `17-22` |
| `ProductVariantSummary` | `id, title, rashWeightGrams, defaultWagePercent, qty?, isPreOrder?, imageId?, image?` | `24-41` |
| `CategorySummary` | `id, title, parentId` | `43-47` |
| `BrandSummary` | `id, title` | `49-52` |
| `CollectionSummary` | `id, title, description, isActive` | `54-59` |
| `ProductSummary` | `id, title, description, productCode?, variants[], images[], category, brand, collections[]` | `61-75` |
| `LedgerEntrySummary` | `id, currency, direction, sourceType, amount, confirmed, createdAt, note` | `77-86` |
| `CustomerSummary` | `id, fullName, mobile, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, isActive, isArchived` | `88-97` |
| `OrderSummary` | `id, orderNumber, status, totalGoldWeightGrams, isSuspendedOverCreditLimit` | `99-105` |
| `OrderItemDetail` | `id, productVariantId, quantity, rashWeightGramsSnapshot, wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge` | `107-116` |
| `OrderDetail` | `id, orderNumber, status, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, deliveredAt, createdAt, items[]` | `118-129` |
| `CustomerDetail` | `id, fullName, mobile, nationalCode, cityId, goldCreditLimitGrams, goldDebtGrams, fiatDebtRial, verificationStep, createdAt` | `131-142` |
| `GoldPriceQuote` | `id, karatPerMille, pricePerGramRial, source, quotedAt` | `144-150` |
| `AdminSlide` | `image, link` | `152-157` |
---
## 3. Router — every route
Source: `src/router/index.ts:16-45`. History: `createWebHistory()` (`:9`). `scrollBehavior`: restore `savedPosition` on back/forward, else `{top:0}` (`:12-15`).
| path | name | component file | meta |
|---|---|---|---|
| `/home` | `home` | `src/views/HomeView.vue` | — (Master layout, no guard) — `:18` |
| `/product/:id` | `singleProduct` | `src/components/product/SingleProduct.vue` (`props:true`) | `layout: 'FullPage'` — `:19-25` |
| `/newest` | `newestProduct` | `src/views/NewestView.vue` | `layout: 'FullPage'` — `:26` |
| `/cart` | `cart` | `src/views/CartView.vue` | — (**no auth guard**; login enforced in-view) — `:27` |
| `/login` | `login` | `src/views/user/LoginRegisterView.vue` | `layout: 'FullPage'` — `:28` |
| `/user` | `userPage::hasSubRoute` | `src/views/user/DashboardView.vue` | — (**no guard**; view renders a signed-out invite) — `:29` |
| `/orders` | `ordersList` | `src/views/user/OrdersView.vue` | `requiresAuth: true` — `:30` |
| `/transactions` | `transactionsList` | `src/views/user/TransactionsView.vue` | `requiresAuth: true` — `:31-36` |
| `/profile` | `profileData` | `src/views/user/ProfileView.vue` | `requiresAuth: true` — `:37` |
| `/favorites` | `favoritesList` | `src/views/user/FavoritesView.vue` | `requiresAuth: true` — `:38` |
| `/notifications` | `notifications` | `src/views/user/NotificationsView.vue` | — (no guard) — `:39` |
| `/search` | `search` | `src/views/search/SearchView.vue` | — `:40` |
| `/collection/:id?` | `collection` | `src/views/search/CollectionView.vue` | — (optional param; title via `?page=`) — `:41` |
| `/` | — | redirect → `{name:'home'}` | `:43` |
| `/:pathMatch(.*)*` | — | redirect → `{name:'home'}` | `:44` |
**13 named routes + 2 redirects.** No `meta.title` and **no `meta.level` / level requirement anywhere** — there is no KYC/level gating in the router.
---
## 4. Navigation
Bottom tab bar: `src/components/ui/Navigation.vue`; item list lives in `src/stores/navStore.ts:25-31`.
| index | label | name | path | icon |
|---|---|---|---|---|
| 1 | خانه | `home` | `/home` | `vuesax-linear:home-2` — `navStore.ts:26` |
| 2 | جستجو | `search` | `/search` | `vuesax-linear:search-normal-1` — `navStore.ts:27` |
| 3 | سبد خرید | `cart` | `/cart` | `vuesax-linear:bag-happy` — `navStore.ts:28` |
| 4 | سفارشات | `ordersList` | `/orders` | `vuesax-linear:receipt-text` — `navStore.ts:29` |
| 5 | پروفایل | `userPage::hasSubRoute` | `/user` | `vuesax-linear:user` — `navStore.ts:30` |
- Gating: **none per tab** — every tab is a plain `router-link` (`Navigation.vue:19-28`). `/orders` is gated by the router's `requiresAuth` and bounces to `/login`.
- Visibility: bar is `v-show="nav.showNav"` (`Navigation.vue:12`). `router.afterEach` sets `showNav=true` + `setActiveLink(index)` only for the five tab routes, else `false` (`src/router/index.ts:95-101`).
- Active indicator: CSS transform `translateX(-(activeLink-1)*100%)`, negative because the app is RTL (`Navigation.vue:40-44`).
- SearchView hides the bar while its filter sheet is open (`src/views/search/SearchView.vue:228`) and restores it in `onBeforeRouteLeave` (`:262-264`).
- Other nav surfaces: home bar cart/search links `src/views/HomeView.vue:25-34`; dashboard 4-tile grid `src/views/user/DashboardView.vue:184-200`; `AccountStrip` 3 action buttons (`src/components/home/AccountStrip.vue:32-43`) — **AccountStrip has no importer** (dead).
---
## 5. Guards
| Guard | Rule | Line |
|---|---|---|
| `router.beforeEach` — already-logged-in | `to.name === 'login' && session.customerId` → redirect `{name:'home'}` | `src/router/index.ts:56-58` |
| `router.beforeEach` — auth | `to.meta.requiresAuth && !session.customerId` → `{name:'login', query:{redirect: to.fullPath}}` | `src/router/index.ts:59-61` |
| `router.beforeEach` — transition | `to.name==='singleProduct'` → `nav.setPgaTransition('slideDown')`, else `undefined` | `src/router/index.ts:64-65` |
| `router.afterEach` | resets transition flags (100 ms cycle, inert — no consumer), sets active tab + nav visibility | `src/router/index.ts:80-102` |
| `onBeforeRouteLeave` (SearchView) | restores nav visibility if the filter sheet is open | `src/views/search/SearchView.vue:262-264` |
| In-view login gate (Cart) | `submitOrder()` → if `!session.customerId` open `loginNotify` dialog instead of posting | `src/views/CartView.vue:191-195` |
| In-view login gate (Dashboard) | `goToDetails()` → if `!session.customerId` open `data.loginNotify` dialog | `src/views/user/DashboardView.vue:191-195` |
| In-view login gate (product) | add-to-cart: `isUserLoggedIn() ? addToCart() : (loginNotify = true)` | `src/components/product/SingleProduct.vue:227`; helper `src/utils/checkAccess.ts:4-6` (`localStorage.getItem('appToken')`) |
| Post-login redirect | `route.query.redirect` honoured, else `{name:'home'}` | `src/views/user/LoginView.vue:161-162` |
**KYC / verification-level guard: does not exist.** `verificationStep` is read-only display text in `src/views/user/ProfileView.vue:32,54` with labels at `:75-80`. No route, button or flow is gated on it. Credit-limit (`goldCreditLimitGrams`, `isSuspendedOverCreditLimit`) is likewise never enforced client-side.
---
## 6. Views — all 14 files under `src/views` (recursive)
| File | Purpose |
|---|---|
| `src/views/HomeView.vue` | Redesigned shop-first home: brand bar, live rate strip, hero slider, category rail, featured grid, newest reel, trust badges. |
| `src/views/NewestView.vue` | "جدیدترین محصولات" grid with incremental reveal. |
| `src/views/CartView.vue` | Basket → per-item note → confirm → POST order → success step. |
| `src/views/search/SearchView.vue` | Search + category chips + filter sheet + collection tiles + result grid. |
| `src/views/search/CollectionView.vue` | Products of one collection (client-side filter by route param). |
| `src/views/user/LoginRegisterView.vue` | Toggle shell between LoginView and RegisterView. |
| `src/views/user/LoginView.vue` | Mobile → OTP login (OTP verification stubbed). |
| `src/views/user/RegisterView.vue` | 7-field registration form (submit stubbed, no endpoint). |
| `src/views/user/DashboardView.vue` | "میز کار": balance card, 4-tile access grid, rate strip, logout dialog. |
| `src/views/user/OrdersView.vue` | Order list, 9 status chips, number search, detail drawer with 4-step tracker. |
| `src/views/user/TransactionsView.vue` | "اسناد" ledger list + detail drawer. |
| `src/views/user/ProfileView.vue` | Read-only identity card (4 fields + verification seal). |
| `src/views/user/FavoritesView.vue` | Local favourites filtered against the catalogue. |
| `src/views/user/NotificationsView.vue` | Static empty state only — no data source. |
### 6.1 `HomeView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` GET `/api/catalog/products` + `memory.init()` (same endpoint) in `Promise.all` | `243-249` |
| API | `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750`; on throw → `goldPrice=null` | `251-255` |
| API | `api.getHomeSlider()` GET `/api/home/slider`; on throw → `adminSlides=null` | `257-263` |
| Derived data | `featured = products[0]`; `gridProducts = slice(1,5)`; `newestProducts = slice(5,13)` | `160-162` |
| Hero source | admin slides when non-empty (filtered by `s.image`), else first 3 categories with rotating copy `SLIDE_COPY` (`مجموعه منتزر`/`دیدن مجموعه`, `انتخاب سردبیر`/`مشاهده`, `پیشنهاد این هفته`/`بررسی کنید`) | `170-202` |
| Slide click | absolute `http(s)` link → `window.open(link,'_blank','noopener')`; other link → `router.push(link)`; else set `filters.query.cats=[categoryId]` → `/search` | `219-235` |
| Category click | `filters.query.cats=[cat.id]` → `router.push({name:'search'})` | `214-217` |
| Buttons | cart icon (badge = `cart.items.length`), search icon, `همه محصولات`→`/search`, `مشاهده همه`→`/newest`, ShowcaseCard→drawer | `25-34, 61, 75, 63-66` |
| Pull-to-refresh | vant `PullRefresh` → `loadData()` | `38-42, 266-269` |
| Empty state | `v-if="!loading && !products.length"` → "هنوز محصولی در مجموعه ثبت نشده است." | `88-91` |
| Conditional render | hero only if `slides.length`; rail only if `memory.cats.length`; featured section if `featured`; newest section if `newestProducts.length` | `46, 48, 56, 70` |
| Detail | ant `Drawer` bottom, `height=100vh`, hosts `SingleProduct` | `110-119` |
| Not present | no price on any card, no form, no validation, no pagination params | file header `13-19` |
### 6.2 `CartView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `cart.init()` on mount + on pull-refresh → `api.listProducts()` (re-sync/prune) | `224-226, 219-222`; store `src/stores/cartStore.ts:39-55` |
| API | `api.createOrder({customerId: session.customerId, items:[{productVariantId: item.vid, quantity: item.qty, wagePercent: parseFloat(variant.defaultWagePercent) \|\| 0}]})` POST `/api/orders` | `198-208` |
| Steps | `step===1` basket, `step===2` success | `35, 66` |
| Calculation shown | total weight only: `cart.total()` = `Σ parseFloat(rashWeightGrams)*qty`, `.toFixed(2)`, unit گرم. **No rial total.** | `86-89`; `src/stores/cartStore.ts:124-131` |
| Form | note textarea, `rows=4`, placeholder "مثلاً سایز یا جزئیات سفارش"; **no validation rules** | `107-112` |
| Note submit | `cart.updateFeild(note, tempRowIndex, tempRow, 'note')`, closes drawer, clears model | `176-181` |
| Confirm dialog | `DialogVant` "آیا مطمئن به ثبت سفارش هستید ؟" — "بله"→`submitOrder`, "بی خیال"→close; `timeOut:-1`, `closeOnClickOverlay` | `116-125` |
| Login dialog | "برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید." — "ورود/ثبت نام"→`/login`, "بیخیال"→close | `127-138` |
| Loading | `Spinner :spinning="cartData.loadingPage"` tip "درحال دریافت اطلاعات"; `loadingPage` true during POST | `32, 213, 216` |
| Submit bar | rendered only when `cart.items.length > 0 && step === 1`; button is **not** disabled while posting (only the Spinner overlays) | `82, 91` |
| Success | shows `cartData.orderID` (= `order.orderNumber`), `cart.clear()`, CTA "فهرست سفارش‌ها" → `/orders` then `step=1` | `209-211, 185-188` |
| Error | `catch { console.log(e) }` — **no user-visible error message on order failure** | `212-214` |
| Empty | "سبد خرید شما خالی است." + CTA "دیدن محصولات" → `/home` | `56-62` |
### 6.3 `search/SearchView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount and on pull-refresh | `206-213, 215-218, 266` |
| Filters (all client-side) | `text` → `p.title.includes(text)`; `cats` → `cats.includes(p.category.id)`; `brands` → `brands.includes(p.brand.id)`; `limit_gram` (len 2) → any variant weight within `[min,max]` | `153-167` |
| Pagination | **client-side window only**, `PAGE_SIZE = 20`, grown by `IntersectionObserver` with `rootMargin:'400px'`; reset to 20 on any filter change. No server page/limit params. | `174-199` |
| Chips | "فیلتر" (shows `${filterKeys.length} فیلتر` when active), "همه" (clears cats), one chip per available category | `23-50` |
| Collections | tiles → `router.push({path:'/collection/'+id, query:{page:title}})` | `71-82` |
| Header | `PageBar` title flips to "فیلترها" while the sheet is open; back button closes the sheet instead of navigating | `17, 241-248` |
| Nav side-effect | opening the sheet hides the tab bar; closing / leaving restores it | `226-237, 262-264` |
| Empty | "محصولی با این مشخصات پیدا نشد." when `!hasMore && !loadingPage` | `103-106` |
| Result count | SectionHead subtitle `${filteredProducts.length} محصول` | `84-88` |
### 6.4 `search/CollectionView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount | `58-65` |
| Filter | `p.collections.some(c => c.collectionId === route.params.id)` — client-side | `54-56` |
| Title | `route.query.page` else fallback `'مجموعه'` | `52` |
| Empty | "در این مجموعه محصولی ثبت نشده است." when `!loadingPage` | `26-29` |
| Pagination | none | — |
### 6.5 `NewestView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `useProducts().getProducts(loadingPage, false)` → `api.listProducts()` | `59-61`; `src/composables/useProducts.ts:24-35` |
| Pagination | `PAGE_SIZE = 20` window; `pull()` grows the window first, only then calls `getProducts(loadingPull, true)` — which is a **no-op** (`isPull` returns early) | `55-71`; `useProducts.ts:25` |
| Empty | `ScrollFetchSearch` renders `EmptyState` when `productsData.flag` (= list length < 1) | `src/components/ui/ScrollFetchSearch.vue:19`; `useProducts.ts:29` |
### 6.6 `user/LoginView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | Step 1: `api.listCustomers()` GET `/api/customers`; match `c.mobile === formModel.username && c.isActive && !c.isArchived` | `131-133` |
| API | Step 2: **no request** — OTP not verified; `session.login(matchedCustomer.id, matchedCustomer.mobile)` | `153-160` |
| Field | `username` / "تلفن همراه", `<Input type="number">` | `62-63` |
| Validation | `required('تلفن همراه')` → " لطفا تلفن همراه را وارد کنید "; `mobile('تلفن همراه')` pattern `/^(\+98\|0098\|98\|0)?9\d{9}$/g` → " مقدار تلفن همراه نا معتبر است " | `61`; `src/utils/validator.ts:5-8,23,3` |
| Error message | not-found → NotifyVant red (`#DC2626`) "اطلاعات کاربری در سیستم وجود ندارد." (2000 ms) | `22, 103, 135-137` |
| OTP | `OtpInput` `:num-inputs="4"`, `inputType="tel"`, auto-focus, `@on-complete="sendToken"` | `40-48` |
| Timer | `timer = 120` s on step 2, 1 s countdown; label "امکان دریافت مجدد رمز در {counterZeroShow(timer)} ثانیه دیگر" (zero-padded < 10), else "برای دریافت رمز عبور مجدد تلاش کنید" | `112-122, 50-53, 170` |
| Button | label `body.step===1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'`; `:disabled="body.loading \|\| (timer > 0 && body.step === 2)"` | `66-73, 172` |
| Success | push `route.query.redirect` or `/home`, then notify "با موفقیت وارد شدید" | `161-164` |
| Switch | "ثبت نام کنید" → `emit('register', true)` | `77-80` |
| Digit conversion | **none** — no Persian→English digit normalisation on the mobile input | — |
### 6.7 `user/RegisterView.vue`
| Field | Label | Control | Validation | Line |
|---|---|---|---|---|
| `fname` | نام | Input | `required('نام')` | `37-39` |
| `lname` | نام خانوادگی | Input | `required('نام خانوادگی')` | `40-42` |
| `mobile` | شماره موبایل | Input `type=number` | `mobile()` + `required()` | `43-49` |
| `store_name` | نام فروشگاه | Input | `required('نام فروشگاه')` | `50-52` |
| `phone` | تلفن فروشگاه | Input `type=number` | `required('تلفن فروشگاه')` only (no `phone()` pattern) | `53-55` |
| `address` | آدرس پستی | Textarea rows=3 | `required('آدرس')` | `56-58` |
| `city` | شهر | Input (plain text, no picker) | `required('شهر')` | `59-61` |
| Aspect | Detail | Line |
|---|---|---|
| API | **none — stubbed.** `register()` only sets `registeredInfo = true` | `117-125` |
| Submit | button "ثبت اطلاعات", `type=submit`, no disabled state | `63` |
| Success dialog | "ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید." + "متوجه شدم" → `/home`; `closeOnClickOverlay=false` | `74-86` |
| Loading | `Spinner :spinning="profileData.loading"` tip "در حال ثبت اطلاعات" | `25` |
### 6.8 `user/DashboardView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomer(session.customerId)` GET `/api/customers/{id}` (only when signed in) | `208-211` |
| API | `api.getLatestGoldPrice()`; on throw → null | `212-216` |
| Calculations | `isDebtor = parseFloat(goldDebtGrams) > 0`; `grams = Math.abs(parseFloat(goldDebtGrams))`; label `بدهکار گرمی`/`بستانکار گرمی`; seal `بدهکار`/`بستانکار`; rial row `price(customer.fiatDebtRial)` + "ریال" | `176-180, 58-71` |
| Access tiles | id 0 سفارشات→`ordersList`, 1 اسناد→`transactionsList`, 2 پروفایل→`profileData`, 3 علاقمندی→`favoritesList`; all `disabled:false` | `184-200` |
| Gate | any tile while signed out → `data.loginNotify` dialog (not navigation) | `191-195` |
| Logout dialog | "آیا می خواهید از برنامه خارج شوید؟" → "بله" = `session.logout()` + push `/home`; "بی خیال" | `123-132, 202-206` |
| Login dialog | long invite copy → "ورود/ثبت نام" / "بیخیال" | `134-148` |
| Signed-out block | invite card with CTA "ورود / ثبت‌نام" | `82-93` |
| Pull-to-refresh | `PullRefresh` → `loadDashboard()` | `51, 219-222` |
| Error | `api.getCustomer` is **not** wrapped in try/catch — a failure rejects `loadDashboard()` | `209-211` |
### 6.9 `user/OrdersView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomerOrders(session.customerId)` GET `/api/customers/{id}/orders` | `196-204` |
| Filter chips (9) | `ALL` همه, `SUSPENDED` معلق, `PLACED` ثبت سفارش, `ADMIN_APPROVED` تایید مدیر سیستم, `SOURCING` درحال تامین, `PREPARING` درحال آماده سازی, `DELIVERED_TO_AGENT` تحویل مسئول فروش, `DELIVERED_TO_CUSTOMER` تحویل مشتری, `CANCELLED` لغو شده | `167-177` |
| Search | `SearchInput` placeholder "جستجوی شماره سفارش" → `o.orderNumber.includes(term)` | `32, 188` |
| Filtering | client-side (status + number). **No server-side query params, no pagination.** | `185-190` |
| Detail drawer | 4-step tracker `ORDER_STEPS`, headline (orderNumber + coloured status), specs: items count "آیتم", `parseFloat(totalGoldWeightGrams).toFixed(2)` گرم, `jdate(createdAt)`; items via `OrderItemCard` | `86-138` |
| Status colours | from `ORDER_STATUS_COLORS` (inline `background`/`color` styles) | `108-113, 210` |
| Empty | "سفارشی با این مشخصات پیدا نشد." when `!loading` | `59-62` |
| Pull-to-refresh | yes → `loadOrders()` | `51, 218-221` |
### 6.10 `user/TransactionsView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getLedgerEntries(session.customerId)` GET `/api/customers/{id}/ledger-entries` | `142-150` |
| Source labels | `ORDER_STOCK` سفارش, `ORDER_PRE_ORDER` سفارش ساخت, `REPAYMENT_MELTED_GOLD` طلای آب‌شده, `REPAYMENT_TRANSFER` حواله, `REPAYMENT_CHEQUE` چک, `REPAYMENT_CASH` پولی, `WALLET_BONUS` پاداش, `ADJUSTMENT` اصلاحیه | `112-121` |
| Direction | `isCredit = direction === 'CREDIT'` → green/in arrow-down; else red/out arrow-up | `132, 31-33` |
| Amount format | `currency === 'GOLD_GRAM' ? "{amount} گرم" : "{price(amount)} ریال"` | `134-135` |
| Confirm state | `confirmed ? 'تایید شده' : 'در انتظار تایید'` | `47-49` |
| Detail drawer | height 420px, 5 rows: نوع سند / مبلغ / وضعیت / تاریخ / توضیحات (`note \|\| '—'`) | `63-97` |
| Empty | "سندی برای حساب شما ثبت نشده است." | `56-59` |
| Filters / pagination | none | — |
### 6.11 `user/ProfileView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomer(session.customerId)` (skipped entirely when signed out) | `88-96` |
| Fields (read-only) | نام و نام خانوادگی, تلفن همراه, کد ملی, وضعیت احراز هویت | `40-56` |
| Verification labels | `UNVERIFIED` احراز نشده, `STEP_ONE_SUBMITTED` مرحله اول ثبت شده, `STEP_TWO_SUBMITTED` مرحله دوم ثبت شده, `VERIFIED` تایید شده | `75-80` |
| Seal class | `is-ok` when `verificationStep === 'VERIFIED'`, else `is-pending` | `31-33, 86` |
| Empty | "اطلاعات حساب در دسترس نیست." when `!loading` | `59-62` |
| Editing / uploads | **none** — no edit form, no document upload, no bank fields | header `6-17` |
### 6.12 `user/FavoritesView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount | `55-62` |
| Filter | `allProducts.filter(p => favorites.isFavorite(p.id))` — local store, **not persisted** | `53`; `src/stores/favoritesStore.ts:9-21` |
| Count | SectionHead subtitle `${favoriteProducts.length} محصول` | `19` |
| Empty | "هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید." + CTA "دیدن محصولات" → `/home` | `25-31` |
### 6.13 `user/NotificationsView.vue`
Static: `PageBar "اعلان‌ها" back-to="home"` + empty state "اعلانی برای نمایش وجود ندارد." — no API, no store, no props (`:9-17`).
### 6.14 `user/LoginRegisterView.vue`
Shell: `showRegister` ref toggled by the `register` event from either child (`:7-13`).
---
## 7. Pinia stores
| Store | id | State | Actions | Persistence |
|---|---|---|---|---|
| `src/stores/cartStore.ts` | `cart` | `items: CartItem[] {pid, vid, product, qty, note?}` (`:8-14,32-34`) | `init()` (GET `/catalog/products`, prune dead lines `:39-55`), `addToCart` (refuses non-orderable `:56-72`), `capQty` (`:79-84`), `maxQty` (`:87-93`), `remove`, `updateFeild` (clamps `qty` `:101-114`), `find_index`, `isInCart`, `variant`, `total()` (`:124-131`), `clear()` | **localStorage key `appCart`**, JSON, written by `persist()` on every mutation (`:16-29`) |
| `src/stores/session.ts` | `session` | `customerId`, `mobile` (hydrated from localStorage `:11-12`) | `setCustomerId`, `login(customerId, mobile)`, `logout()` | localStorage keys **`gold-bank:customerId`**, **`gold-bank:mobile`**, **`appToken`** (= customerId, legacy compat) (`:20-32`) |
| `src/stores/configStore.ts` | `config` | `config.main_slider: Slide[]` (3 hardcoded placeholder slides `:39-58`), `cats: HomeCategory[]`, `loaded` | `setCats`, `init()` — derives distinct categories from `api.listProducts()` (`:73-91`) | none |
| `src/stores/favoritesStore.ts` | `favorites` | `ids: string[]` | `toggle(id)`, `isFavorite(id)` | **none — in-memory only**, lost on reload (`:1-21`) |
| `src/stores/filtersStore.ts` | `filters` | `query: {colors[], brands[], ciny_id[], limit_percent[], limit_gram[], has_discount, text?, cats?, page:0}` (`:9-31`) | `reset()` | none |
| `src/stores/navStore.ts` | `nav` | `navBarRoutes[5]`, `activeLink:1`, `showNav:true`, `pageTransition:'right'` | `setNavVisibility`, `setActiveLink`, `setPgaTransition` | none |
| `src/stores/loadingStore.ts` | `loading` | `loading:false` | `setLoading` | none — **no importers (dead)** |
| `src/stores/transitionStore.ts` | `transition` | `transitionFlag`, `transitionEnded`, `headerHeight` | `setTransitionFlag`, `setTransitionEnded`, `setHeaderHeight` | none; `transitionFlag` is inert (`src/router/index.ts:72-79`) |
| `src/stores/windowDataStore.ts` | `windowData` | `width:0`, `pullingDown:false` | `setWidth`, `setPullingDown` | none |
Unused filter fields (declared, never applied): `colors`, `limit_percent`, `ciny_id`, `has_discount`, `page` (`src/stores/filtersStore.ts:1-6`).
---
## 8. Shared utils
### `src/utils/filters.ts` — formatters
| Function | Behaviour | Line |
|---|---|---|
| `price(val)` | thousands separator via `replace(/\B(?=(\d{3})+(?!\d))/g, ',')`; returns `val` unchanged when falsy (so `0` / `''` pass through) | `7-12` |
| `priceUnit(val)` | same separator + `" تومان "` suffix | `14-19` |
| `priceUnitRialLess(val)` | same separator + trailing space | `21-26` |
| `jdate(val)` | `moment(val).format('jYYYY/jMM/jDD')` — parses ISO 8601 | `28-36` |
**Digits: Western only.** File header explicitly forbids swapping to `fa-IR` (`:1-4`). There is **no Persian↔English digit converter anywhere in `src/**`**, and **no gram/weight formatter** — weights are printed raw or via ad-hoc `parseFloat(x).toFixed(2)` (`src/views/user/OrdersView.vue:123`, `src/components/product/OrderCard.vue:35`) or `Number(x)` (`src/components/product/SingleProduct.vue:140`).
### `src/utils/validator.ts` — rules (ant-design-vue rule objects)
| Rule | Pattern / constraint | Message | Line |
|---|---|---|---|
| `message(field)` | — | `" مقدار {field} نا معتبر است "` | `3` |
| `mobile` | `/^(\+98\|0098\|98\|0)?9\d{9}$/g` | `message` | `5-8` |
| `phone` | `/^((\+98\|0098\|98\|0)\d1)?\d{8}$/g` | `message` | `9-12` |
| `fa` | `/^([؀-ۿﮊپچگ‌ ])+$/` | `" مقدار {field} باید فارسی باشد "` | `13-16` |
| `mail` | `type:'email'` | `message` | `17` |
| `url` | `type:'url'` | `message` | `18` |
| `username` | `/^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim` | `" مقدار {field} باید شامل حروف و اعداد انگلیسی و _ باشد "` | `19-22` |
| `required` | `required:true` | `" لطفا {field} را وارد کنید "` | `23` |
| `sheba` | `/^IR\d{24}$/gim` | `message` | `24` |
| `card` | `/^\d{16}$/gim` | `message` | `25` |
| `zip` | `/^\d{10}$/gim` | `message` | `26` |
| `num` | `/^\d*$/gim` | `message` | `27` |
| `len(field,len)` | `len` | `" مقدار {field} باید {len} کارکتر باشد "` | `28` |
| `max(field,max)` | `max` | `" مقدار {field} باید کمتر از {max} کارکتر باشد "` | `29` |
| `min(field,min)` | `min` | `" مقدار {field} باید بیشتر از {min} کارکتر باشد "` | `30` |
Only `required` and `mobile` have call sites (LoginView, RegisterView).
### `src/utils/orderStatus.ts` — enums (exact values)
| Const | Content | Line |
|---|---|---|
| `ORDER_STATUS_LABELS` | `SUSPENDED:'معلق'`, `PLACED:'ثبت سفارش'`, `ADMIN_APPROVED:'تایید مدیر سیستم'`, `SOURCING:'درحال تامین'`, `PREPARING:'درحال آماده سازی'`, `DELIVERED_TO_AGENT:'تحویل مسئول فروش'`, `DELIVERED_TO_CUSTOMER:'تحویل مشتری'`, `CANCELLED:'لغو شده'` | `8-17` |
| `ORDER_STATUS_COLORS` | `PLACED {#3B82F6,#EFF6FF}`, `SOURCING {#EAB308,#FEFCE8}`, `PREPARING {#F97316,#FFF7ED}`, `DELIVERED_TO_AGENT {#06B6D4,#ECFEFF}`, `DELIVERED_TO_CUSTOMER {#22C55E,#F0FDF4}` — SUSPENDED/ADMIN_APPROVED/CANCELLED deliberately uncoloured | `23-29` |
| `ORDER_STEPS` | `['ثبت سفارش','آماده سازی','تحویل مشتری','لغو شده']` | `35` |
| `orderStepIndex(status)` | PLACED/ADMIN_APPROVED/SUSPENDED→0; SOURCING/PREPARING→1; DELIVERED_TO_AGENT/DELIVERED_TO_CUSTOMER→2; CANCELLED→3; default 0 | `37-54` |
### Other constants
| Const | Value | Line |
|---|---|---|
| `VERIFICATION_LABELS` | UNVERIFIED/STEP_ONE_SUBMITTED/STEP_TWO_SUBMITTED/VERIFIED → احراز نشده / مرحله اول ثبت شده / مرحله دوم ثبت شده / تایید شده | `src/views/user/ProfileView.vue:75-80` |
| `SOURCE_LABELS` | ledger source types (see §6.10) | `src/views/user/TransactionsView.vue:112-121` |
| `isUserLoggedIn()` | `localStorage.getItem('appToken')` | `src/utils/checkAccess.ts:4-6` |
| Theme colours | `primary #1C2D56`, `secondary #8D6A46`, `danger #E31A1A`, `ink #14161A`, `wine #7A1B33`, `canvas #FFFFFF`, `hairline #ECEDEF` (+ 30 more) | `themeConfig/colors.json:1-38` |
| Theme vars | `prefix: 'montzar'`, `font-size 14px`, `border-radius-base 5px`, `height-lg 42px`, `checkbox-size 20px` | `themeConfig/themeConfig.ts:15-43` |
---
## 9. Components (21 files + 1 icons.json)
| File | Purpose / API |
|---|---|
| `src/components/icon/Icon.vue` | Inline-SVG icon from `icons.json` map; props `icon, color?, size?`; `tempIcon` is a computed (bug-fix noted `:19-34`), `applySize()` sets width/height after `nextTick` (`:36-47`). |
| `src/components/input/OtpInput.vue` | **OTP input.** Props `defaultFocus, numInputs=4, separator, inputClasses, inputType, shouldAutoFocus=false, placeholder, disabled`; emits `on-change`, `on-complete`. Keycodes BACKSPACE 8 / LEFT 37 / RIGHT 39 / DELETE 46 (`:6-9`); paste handling `:67-83`; completion when joined length === numInputs (`:42-47`). |
| `src/components/input/SingleOtpInput.vue` | One OTP cell: `maxlength=1`, `pattern="[0-9]"`, `min=0 max=9`, default `inputType='tel'`; only numeric keycodes + `[8,9,13,37,39,46,86]` pass (`:62-75`); focus selects (`:79-82`). |
| `src/components/search/SearchInput.vue` | Debounced search field, `useDebounce(value, 700)` (`:42`); emits `search` on enter, icon click and debounce; clear button. **Known bug preserved:** watcher tests `if (value)` (the ref object, always truthy) not `value.value` — clear icon never auto-hides (`:8-11, 43-47`). |
| `src/components/search/Filters.vue` | Filter sheet. Brand toggle-chips derived from products (`:95-101`); gram range via ant `Slider` `range reverse :step="0.01"`, bounds `[floor(min), ceil(max)]` of all `rashWeightGrams`, fallback `[0,100]` (`:103-107`), seeded once when `limit_gram` empty (`:114-122`). Buttons "پاک کردن فیلتر‌ها" (`resetFilters:true`) / "اعمال فیلتر" (`:75-77, 131-150`). Dropped: colour filter, wage-percent range, discount toggle (`:18-25`). |
| `src/components/ui/DropDown.vue` | Accordion, props `text?`, local `show` ref (`:24-30`). |
| `src/components/ui/Navigation.vue` | Bottom tab bar (§4). |
| `src/components/ui/PageBar.vue` | Sticky bar; props `title`, `back=true`, `backTo?`; slots `trailing`, `below`; `onBack` = `router.push({name: backTo})` else `router.back()` (`:34-50`). |
| `src/components/ui/Container.vue` | `<main class="px-[16px] max-w-screen-md m-auto ...">`; registered globally as `container` (`src/main.ts:31`). |
| `src/components/ui/ContentWrapper.vue` | margin-top from `transitionStore.headerHeight` — **no importers**. |
| `src/components/ui/ScrollFetch.vue` | Infinite scroll via `useInfiniteScroll(distance:50)`, default EmptyState — **no importers**. |
| `src/components/ui/ScrollFetchSearch.vue` | Same, `favoritEmpty.svg` image; used by `NewestView.vue:41`. |
| `src/components/ui/ScrollFetchAfterSearch.vue` | Same, `ordersEmpty.svg` — **no importers**. |
| `src/components/Spinner.vue` | ant `Spin` wrapper; props `tip, size='large', cls, delay, spinning, indicator, fixed=true`; brand-logo indicator by default. |
| `src/components/DialogVant.vue` | vant Dialog, 2-column button grid; slots `img/title/button`; **`show` is not a declared prop** — works via attribute fallthrough (`:1-10`). |
| `src/components/DialogVantOneButton.vue` | Single-action variant; used by `src/App.vue:22`. |
| `src/components/NotifyVant.vue` | vant Notify toast; props `modelValue, timeOut, text?, color?`; auto-hides after `timeOut` unless `-1` (`:34-44`). |
| `src/components/EmptyState.vue` | ant `Empty description="موردی یافت نشد"`, optional image. |
| `src/components/ViewAll.vue` | "مشاهده همه" router-link — **no importers**. |
| `src/components/product/SingleProduct.vue` | Product detail (route + drawer). See §6 notes below. |
| `src/components/product/ProductCard.vue` | Catalogue card: image (v-lazy), fav toggle, `ناموجود` / `پیش‌سفارش` flag, title, `{min_gram} گرم`, `اجرت {wage}٪`; opens its own 100vh Drawer with `SingleProduct` (`:53-62`). |
| `src/components/product/CartItem.vue` | Cart line: qty stepper (`+` disabled when `!canIncrement`; at qty 1 the `-` becomes a trash/remove), note button toggling "توضیحات"/"ویرایش توضیحات" (`:32-63, 82-91`). |
| `src/components/product/OrderCard.vue` | Order row: coloured mark + status pill, `{items.length} آیتم`, `toFixed(2) گرم`, `jdate(createdAt)`, "جزئیات" (`:15-48`). |
| `src/components/product/OrderItemCard.vue` | Order line: image via `useOrderItemProduct` lookup, `{quantity} عدد`, `{computedWeightGrams} گرم`, `{wagePercentSnapshot} ٪ اجرت` (`:30-43`). |
| `src/components/product/GallerySlider.vue` | Pointer-Events swipe gallery; `v-model:index`; props `slides, alt, fit='cover', showCounter=false`; emits `tap`; `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6`; track pinned `direction: ltr` for RTL correctness (`:74-192`). |
| `src/components/product/Category.vue` | Legacy category tile with `id === '45'` special-case — **no importers** (`:1-45`). |
| `src/components/home/RateStrip.vue` | **Price ticker.** Props `quote: GoldPriceQuote\|null`; label "نرخ طلای ۱۸ عیار"; value `price(pricePerGramRial.split('.')[0])` + "ریال / گرم"; `jdate(quotedAt)`; placeholder "در انتظار اعلام نرخ" when null (`:6-33`). |
| `src/components/home/AccountStrip.vue` | Balance strip with 3 actions — **no importers** (dead). |
| `src/components/home/CategoryRail.vue` | Horizontal category chips; emits `select`. |
| `src/components/home/ShowcaseCard.vue` | Editorial card; props `value, wide?, badge?`; fav toggle; weight + wage; no price (`:10-48`). |
| `src/components/home/ShowcaseSlider.vue` | Dependency-free hero slider; autoplay 6000 ms (`:81-84`), pointer drag, `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6` (`:102-103`), dots; `ShowcaseSlide {key, eyebrow, title, subtitle, cta, image?, categoryId?, link?}` (`:55-66`). |
| `src/components/home/SectionHead.vue` | Title + subtitle + optional action button. |
| `src/components/slider/HomeSlider.vue` | Swiper banner + popup drawer; slide types `popup`/`link`/`discount`; preserved bug: sets `query.discount` instead of `has_discount` (`:83-92`) — **no importers**. |
| `src/components/slider/Slider.vue` | Swiper gallery + zoom drawer + GSAP-era SVG curve maths; preserved bug: `showPopup(item)` called with one arg so `index` is always undefined (`:1-9, 60-67`) — **no importers**. |
**Missing component categories (do not exist in this app):** numeric/price input with thousands-separator masking, amount/gram input, date picker, file uploader, image uploader.
### Composables
| File | Purpose | Key detail |
|---|---|---|
| `src/composables/useProduct.ts` | Adapts `ProductSummary` → card contract | `isVariantOrderable = qty>0 \|\| isPreOrder` (`:25-27`); `getCheapestVariant` = lowest `defaultWagePercent` among orderable, fallback all (`:29-40`); `hasDiscountWage` always null (`:56`); `has_warranty` always false (`:64`) |
| `src/composables/useProductDetail.ts` | "Detail" fetch = full list + client-side `find` (no single-product endpoint) | `:16-29` |
| `src/composables/useProducts.ts` | List + fake pagination; `totalPages` always 0; `getProducts(loading, isPull=true)` returns immediately | `:24-35` |
| `src/composables/useOrderItemProduct.ts` | Module-level cached `Map<variantId, {productTitle, variantTitle, imageUrl}>` built from `listProducts()` | `:14-31` |
| `src/composables/useRefresh.ts` | Auto-applies a waiting service worker | `:7-17` |
### Layouts
| File | Behaviour |
|---|---|
| `src/layout/MasterLayout.vue` | `<router-view/>` + `<Navigation/>` + a primary-coloured overscroll strip; tracks width via `useResizeObserver` → `windowDataStore`. Known issue: it renders its own `router-view`, so App.vue's keep-alive/suspense wrapper is bypassed (`:3-7`). |
| `src/layout/FullPageLayout.vue` | Bare `<router-view/>` — selected by `meta.layout === 'FullPage'` (`src/App.vue:57-62`). |
---
## 10. Live price / sockets / polling
| Aspect | Finding |
|---|---|
| Mechanism | **Pull-on-mount only.** `api.getLatestGoldPrice()` (GET `/api/pricing/latest?karatPerMille=750`) is called once per view mount. |
| Call sites | `src/views/HomeView.vue:252` (+ pull-to-refresh `:266-269`); `src/views/user/DashboardView.vue:213` (+ pull-to-refresh `:219-222`). |
| WebSocket / SSE | **none** — no `WebSocket`, `socket.io`, `EventSource` anywhere in `src/**`. |
| Polling | **none** — no `setInterval` refresh for price. The only `setInterval` is the hero autoplay (`src/components/home/ShowcaseSlider.vue:81`); the only price-adjacent `setTimeout` is the OTP countdown (`src/views/user/LoginView.vue:115`). |
| Display | `src/components/home/RateStrip.vue:13-19` — integer part only (`split('.')[0]`), thousands-separated, `ریال / گرم`, with Jalali `quotedAt`. |
| Karat | fixed 750 (18k) default; no karat selector anywhere. |
| Multi-row ticker | explicitly dropped (`src/views/user/DashboardView.vue:28-31`). |
---
## 11. Env / config / PWA
| Item | Value | Line |
|---|---|---|
| `envDir` | repo root (`../..`) | `vite.config.ts:9,15` |
| `USER_APP_PORT` | 5181 (fallback in config), declared in root `.env` | `vite.config.ts:11`; `.env:26` |
| `API_PORT` | 3000 | `vite.config.ts:12`; `.env:24` |
| Dev server | `host 0.0.0.0`, `strictPort: true` | `vite.config.ts:44-49` |
| Proxy | `/api` → `http://localhost:3000`, `/uploads` → same | `vite.config.ts:50-53` |
| `import.meta.env.*` | **no `VITE_*` variables are read anywhere in `src/**`** | — |
| Less vars | `modifyVars: $themeLessVars`, `javascriptEnabled: true` | `vite.config.ts:55-62` |
| PWA strategy | `VitePWA` `injectManifest`, `srcDir: 'src'`, `filename: 'sw.ts'`, `base '/'`, `includeAssets ['favicon.ico']`, `devOptions.enabled true` | `vite.config.ts:20-27` |
| PWA manifest | name/short_name/description `Montzar`; `theme_color`/`background_color` = `$themeLessVars.primary` (`#1C2D56`); `start_url '/'`; `display 'standalone'`; icons 192 + 512 + 512-maskable | `vite.config.ts:28-41` |
| Service worker | `precacheAndRoute(self.__WB_MANIFEST)` + `SKIP_WAITING` message handler | `src/sw.ts:9-14` |
| Auto-update | `useRefresh()` applies a waiting SW immediately | `src/composables/useRefresh.ts:8-16` |
| Version dialog | localStorage key `version` vs `package.json` version → "نسخه جدید منتزر منتشر شد!" + "به روز رسانی" (updateSW + reload) | `src/App.vue:66-81, 33-38` |
| `public/manifest.json` | `{ "gcm_sender_id": "464066939468" }` only (legacy FCM leftover; not linked from `index.html`) | `public/manifest.json:1-3` |
| `index.html` | title `طلای منتزر`, `theme-color #1C2D56`, `maximum-scale=1.0, user-scalable=no`, apple web-app meta | `index.html:6-11` |
| localStorage keys (all) | `appCart`, `gold-bank:customerId`, `gold-bank:mobile`, `appToken`, `version` | `cartStore.ts:16`; `session.ts:22-25`; `App.vue:69` |
| Global registrations | Pinia, router, `vue3-lazyload` (loading+error placeholder = `montzar-logo.png`), global `container` component | `src/main.ts:22-31` |
| Style imports | `tailwind.css` → `ant-design-vue/dist/antd.less` → `style.less` → `vant/lib/index.css` | `src/main.ts:15-18` |
| Docker | `Dockerfile`, `nginx.conf`, `.dockerignore` present at app root | — |
---
## 12. CANDIDATE PROTECTED — new features / new design not in a 2023-era reference PWA
These are deliberate additions or redesigns in the target. Treat as protected unless explicitly told otherwise.
### 12.1 Entirely new pages / routes
| Item | Path(s) | Evidence |
|---|---|---|
| **Notifications page** | route `/notifications` name `notifications`; `src/views/user/NotificationsView.vue` | `src/router/index.ts:39`; view header states "New nav destination (bottom nav redesign) — no legacy equivalent" (`NotificationsView.vue:1-6`) |
### 12.2 New / redesigned widgets and sections
| Item | Path | Evidence |
|---|---|---|
| **Live 18k rate strip** (`RateStrip`) on home + dashboard | `src/components/home/RateStrip.vue`; used `src/views/HomeView.vue:44`, `src/views/user/DashboardView.vue:118` | Replaces legacy's multi-row `GET /price` ticker (`DashboardView.vue:28-31`) |
| **Editorial hero slider** (dependency-free, pointer-drag, 6 s autoplay) | `src/components/home/ShowcaseSlider.vue` | Replaces the Swiper `HomeSlider` (now unused) — `ShowcaseSlider.vue:1-13` |
| **Admin-managed home banner** consuming `GET /home/slider` | `src/api/client.ts:168-170`; `src/views/HomeView.vue:155, 176-201, 257-263` | New endpoint + new fallback-to-category hero logic |
| **ShowcaseCard / ShowcaseSlider / CategoryRail / SectionHead / AccountStrip** — whole `components/home/` family | `src/components/home/*` | New design-system components; `ShowcaseCard.vue:1-9` |
| **"برگزیده منتزر" featured section** (wide hero card + 2-col grid) | `src/views/HomeView.vue:56-68` | New home section |
| **"تازه‌ها" horizontal reel** on home | `src/views/HomeView.vue:70-86` | New home section |
| **Trust badges row** (خرید امن / اصالت کالا / ارسال بیمه‌شده) | `src/views/HomeView.vue:93-106`; also on product detail `src/components/product/SingleProduct.vue:193-196` | New widget |
| **`PageBar`** — shared translucent sticky bar replacing the legacy dark AppHeader | `src/components/ui/PageBar.vue:1-9` | Whole-app chrome redesign; AppHeader deleted (`src/router/index.ts:74-79`) |
| **Redesigned 5-tab bottom nav** with CSS sliding indicator (replaces 4-tab GSAP SVG notch) | `src/components/ui/Navigation.vue:1-10`; `src/stores/navStore.ts:25-31` | Explicit "Redesigned bottom nav — replaces the old GSAP SVG-notch bar" |
| **`GallerySlider`** — Pointer-Events gallery rewrite with LTR-pinned track + full-screen teleported preview with thumbnails | `src/components/product/GallerySlider.vue:1-47`; preview `src/components/product/SingleProduct.vue:239-274` | From-scratch rewrite, not a port |
| **Variant chips with per-variant images + stock badges + "نمایش همه" collapse** (`VARIANT_PREVIEW_COUNT = 6`) | `src/components/product/SingleProduct.vue:105-154, 370-374, 383-393` | New variant-picker design |
| **Stock / pre-order model in UI** (`qty`, `isPreOrder`, `isVariantOrderable`, cart `capQty`/`maxQty` clamping, disabled `+`) | `src/composables/useProduct.ts:25-27,57-62`; `src/stores/cartStore.ts:56-93`; `src/components/product/CartItem.vue:82-88`; `src/components/product/SingleProduct.vue:466-480` | New business rule not in a 2023 reference |
| **Pull-to-refresh** (vant `PullRefresh`) on Home, Cart, Search, Orders, Dashboard | `HomeView.vue:38-42`; `CartView.vue:35-39`; `SearchView.vue:65-69`; `OrdersView.vue:51`; `DashboardView.vue:51` | New interaction |
| **IntersectionObserver incremental rendering** (`PAGE_SIZE = 20`) replacing server pagination | `src/views/search/SearchView.vue:169-204`; `src/views/NewestView.vue:49-71` | New rendering strategy |
| **Ledger-based "اسناد" screen** (LedgerEntry + CREDIT/DEBIT + confirmed state + detail drawer) | `src/views/user/TransactionsView.vue:1-19, 112-140` | Replaces legacy Tahesab/Cash documents |
| **Order detail as bottom Drawer with 4-step tracker + `OrderItemCard` lookup join** | `src/views/user/OrdersView.vue:67-141`; `src/composables/useOrderItemProduct.ts` | Redesigned flow (was a full-screen ant Modal) |
| **Order status chips using the new `OrderStatus` enum (9 chips)** | `src/views/user/OrdersView.vue:167-177`; `src/utils/orderStatus.ts:8-17` | New enum, new labels |
| **Dotlottie "new version" update dialog** | `src/App.vue:22-39, 46` (`@lottiefiles/dotlottie-vue`, remote lottie.host asset) | New dependency + new UX |
| **Balance card with بدهکار/بستانکار seal** on dashboard | `src/views/user/DashboardView.vue:55-78, 176-180` | Redesigned; also duplicated in unused `AccountStrip.vue` |
| **Brand-logo Spinner indicator** | `src/components/Spinner.vue:5` | New brand chrome |
### 12.3 New/changed data contracts worth protecting
| Item | Path |
|---|---|
| `GET /home/slider` + `AdminSlide` type | `src/api/client.ts:152-157, 168-170` |
| `ProductVariantSummary.qty / isPreOrder / imageId / image` | `src/api/client.ts:29-40` |
| `ProductSummary.productCode / collections[]` | `src/api/client.ts:65-74` |
| `GoldPriceQuote` (`karatPerMille`, `pricePerGramRial`, `quotedAt`) | `src/api/client.ts:144-150` |
| `session` localStorage schema `gold-bank:*` alongside legacy `appToken` | `src/stores/session.ts:11-32` |
### 12.4 Deliberate regressions / stubs (documented in-file — verify before "fixing")
| Item | Path |
|---|---|
| OTP code is **not** verified; any 4 digits log in as the matched customer | `src/views/user/LoginView.vue:152-160` |
| Registration posts nothing (no create-customer endpoint) | `src/views/user/RegisterView.vue:117-125` |
| Favourites are in-memory only, no backend | `src/stores/favoritesStore.ts:1-5` |
| Placeholder home slides in `configStore` (used only if `HomeSlider` were mounted — it isn't) | `src/stores/configStore.ts:39-58` |
| `SearchInput` clear-icon truthiness bug preserved | `src/components/search/SearchInput.vue:8-11` |
| `HomeSlider` `query.discount` vs `has_discount` bug preserved | `src/components/slider/HomeSlider.vue:85-90` |
| `Slider.vue` `showPopup` index bug preserved | `src/components/slider/Slider.vue:2-9` |
| `MasterLayout` bypasses App.vue's keep-alive/suspense | `src/layout/MasterLayout.vue:3-7` |
| `DialogVant` / `DialogVantOneButton` rely on attribute fallthrough for `show` | `src/components/DialogVant.vue:2-9` |
### 12.5 Dead code (no importers) — candidates for removal, not parity gaps
`src/components/home/AccountStrip.vue`, `src/components/product/Category.vue`, `src/components/slider/HomeSlider.vue`, `src/components/slider/Slider.vue`, `src/components/ui/ContentWrapper.vue`, `src/components/ui/ScrollFetch.vue`, `src/components/ui/ScrollFetchAfterSearch.vue`, `src/components/ViewAll.vue`, `src/stores/loadingStore.ts`, `api.getBalance` (`src/api/client.ts:163`), `gsap` dependency (`package.json:15`).
---
## Summary counts
| Metric | Count |
|---|---|
| Named routes | 13 (+2 redirects) |
| View files under `src/views` | 14 |
| API functions | 9 (1 uncalled) |
| Pinia stores | 9 (1 unused) |
| Reusable components | 34 `.vue` under `src/components` (8 with no importers) |
| Composables | 5 |
| Util modules | 4 |
| Persisted localStorage keys | 5 |
+700
View File
@@ -0,0 +1,700 @@
# Inventory — apps/user-app-v2 (target user app v2)
Read-only evidence inventory. All `path:line` references are relative to `apps/user-app-v2/`.
Scope: every file under `src/`, plus root config. `node_modules`, `dist` ignored.
Totals: **15 route entries** (13 named + 2 redirects) · **14 view files** · **8 API functions** · **9 Pinia stores** · **41 components** · **6 composables**.
---
## 1. Stack & versions
Source: `package.json:1-38`.
| Field | Value | Line |
|---|---|---|
| name / version / private | `user-app-v2` / `0.0.1` / true | `package.json:2-4` |
| scripts | `dev: vite` · `build: vue-tsc -b && vite build` · `lint: eslint "src/**/*.{ts,vue}"` | `package.json:6-8` |
| vue | `^3.5.0` | `package.json:20` |
| vue-router | `^4.4.0` | `package.json:21` |
| pinia | `^2.2.0` | `package.json:17` |
| ant-design-vue | `^3.2.20` | `package.json:14` |
| vant | `^4.0.3` | `package.json:19` |
| swiper | `^7.4.1` | `package.json:18` |
| gsap | `^3.9.1` (declared; **no import anywhere in `src/`** — dead dep) | `package.json:15` |
| moment-jalaali | `^0.10.0` | `package.json:16` |
| @vueuse/core | `^9.13.0` | `package.json:13` |
| vue3-lazyload | `^0.2.5-beta` | `package.json:22` |
| @lottiefiles/dotlottie-vue | `^0.4.15` | `package.json:23` |
| @ant-design/icons-vue | `^6.1.0` (declared; unused in `src/` — app uses own `Icon.vue`) | `package.json:11` |
| @gold-bank/shared-types | `workspace:*` | `package.json:12` |
| vite / plugin-vue | `^5.4.0` / `^5.1.0` | `package.json:32`, `:26` |
| vite-plugin-pwa | `^0.20.5` | `package.json:34` |
| workbox-precaching / -window | `^7.1.0` / `^7.1.0` | `package.json:35-36` |
| tailwindcss | pinned exact `3.0.18` (no caret) | `package.json:30` |
| less / postcss / autoprefixer | `^4.1.2` / `^8.4.5` / `^10.4.2` | `package.json:28`, `:29`, `:27` |
| typescript / vue-tsc | `^5.5.0` / `^2.1.0` | `package.json:31`, `:33` |
### 1a. Difference vs sibling `apps/user-app`
`apps/user-app/package.json` is **byte-identical** to this one (same deps, same versions, same scripts; only `"name"` differs). The divergence is entirely in `src/`, not in dependencies.
Notable: there is **no test runner, no eslint dependency** (the `lint` script invokes `eslint` which is not a declared dep), and **no axios** — HTTP is raw `fetch` (`src/api/client.ts:6`).
TS config: `tsconfig.json:1-14` extends `../../tsconfig.base.json`; `target ES2020`, `module ESNext`, `moduleResolution bundler`, `noEmit: true`, `include: ["src"]`. Ambient types at `src/env.d.ts:1-9` (`vite/client`, `vite-plugin-pwa/client`, `vite-plugin-pwa/vue`, `*.vue` shim), plus `src/types/moment-jalaali-shim.d.ts` and `src/types/swiper-shim.d.ts`.
---
## 2. HTTP layer
Single file: `src/api/client.ts` (146 lines). No axios, no service-per-domain split.
| Aspect | Reality | Evidence |
|---|---|---|
| Base URL | Hardcoded constant `const BASE = '/api'` — relative, resolved by the Vite dev proxy or nginx | `src/api/client.ts:3` |
| Transport | Native `fetch`, wrapped in `request<T>()` | `src/api/client.ts:5-15` |
| Default headers | `{'Content-Type': 'application/json'}`, spread-overridable by caller `options` | `src/api/client.ts:7-8` |
| **Request interceptor** | **NONE** | — |
| **Response interceptor** | **NONE** | — |
| **Auth token attach** | **NONE.** No `Authorization` header is ever sent. `customerId` is passed as a URL path segment / body field instead | `src/api/client.ts:6-9` |
| **Token refresh** | **NONE.** No refresh logic exists anywhere | — |
| **401 handling** | **NONE.** `!res.ok` throws a generic `Error` regardless of status; no redirect to login on 401 | `src/api/client.ts:10-13` |
| **Error toast** | **NONE at the HTTP layer.** Errors bubble as thrown `Error`; every call site either `catch{console.log(e)}` or lets it escape | `src/api/client.ts:12` |
| Error message shape | `` `API ${path} failed (${res.status}): ${body}` `` where `body = await res.text()` | `src/api/client.ts:12` |
| Credentials / cookies | Not set (`credentials` never passed) → browser default `same-origin` | `src/api/client.ts:6-9` |
| Timeout / retry / abort | None | — |
### 2a. Every API function
All exported on the `api` object at `src/api/client.ts:137-146`.
| # | Function | Method | URL | Params / payload | Return type | Line |
|---|---|---|---|---|---|---|
| 1 | `listProducts()` | GET | `/api/catalog/products` | none | `ProductSummary[]` | `:138` |
| 2 | `listCustomers()` | GET | `/api/customers` | none | `CustomerSummary[]` | `:139` |
| 3 | `getCustomer(id)` | GET | `/api/customers/{id}` | path `id: string` | `CustomerDetail` | `:140` |
| 4 | `getBalance(customerId)` | GET | `/api/accounting/customers/{customerId}/balance` | path `customerId: string` | `CustomerBalanceDto` | `:141` |
| 5 | `getLedgerEntries(customerId)` | GET | `/api/customers/{customerId}/ledger-entries` | path `customerId: string` | `LedgerEntrySummary[]` | `:142` |
| 6 | `createOrder(dto)` | **POST** | `/api/orders` | JSON body `CreateOrderDto` = `{customerId, items:[{productVariantId, quantity, wagePercent}]}` | `OrderSummary` | `:143` |
| 7 | `getCustomerOrders(customerId)` | GET | `/api/customers/{customerId}/orders` | path `customerId: string` | `OrderDetail[]` | `:144` |
| 8 | `getLatestGoldPrice(karatPerMille = 750)` | GET | `/api/pricing/latest?karatPerMille={n}` | query `karatPerMille`, **default 750** | `GoldPriceQuote` | `:145` |
**`getBalance` (#4) is declared but never called anywhere in `src/`** — dead. Balance is read from `getCustomer().goldDebtGrams` / `.fiatDebtRial` instead (`src/views/user/DashboardView.vue:24-28`).
### 2b. Response DTO shapes (declared locally, not from shared-types)
| Interface | Fields | Line |
|---|---|---|
| `ProductVariantSummary` | `id, title, rashWeightGrams, defaultWagePercent, qty?, isPreOrder?` | `:17-28` |
| `CategorySummary` | `id, title, parentId` | `:30-34` |
| `BrandSummary` | `id, title` | `:36-39` |
| `CollectionSummary` | `id, title, description, isActive` | `:41-46` |
| `ProductSummary` | `id, title, description, productCode?, variants[], images: Array<{url}>, category, brand, collections[]` | `:48-60` |
| `LedgerEntrySummary` | `id, currency, direction, sourceType, amount, confirmed, createdAt, note` | `:62-71` |
| `CustomerSummary` | `id, fullName, mobile, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, isActive, isArchived` | `:73-82` |
| `OrderSummary` | `id, orderNumber, status, totalGoldWeightGrams, isSuspendedOverCreditLimit` | `:84-90` |
| `OrderItemDetail` | `id, productVariantId, quantity, rashWeightGramsSnapshot, wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge` | `:92-101` |
| `OrderDetail` | `id, orderNumber, status, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, deliveredAt, createdAt, items[]` | `:103-114` |
| `CustomerDetail` | `id, fullName, mobile, nationalCode, cityId, goldCreditLimitGrams, goldDebtGrams, fiatDebtRial, verificationStep, createdAt` | `:116-127` |
| `GoldPriceQuote` | `id, karatPerMille, pricePerGramRial, source, quotedAt` | `:129-135` |
---
## 3. Router — every route
Source: `src/router/index.ts:8-46`. History mode: `createWebHistory()` (`:9`). Lazy `() => import(...)` for every component.
| path | name | component file | meta |
|---|---|---|---|
| `/home` | `home` | `src/views/HomeView.vue` | *(none)* → MasterLayout, no guard | `:18` |
| `/product/:id` | `singleProduct` | `src/components/product/SingleProduct.vue` (`props: true`) | `layout: 'FullPage'` | `:20-25` |
| `/newest` | `newestProduct` | `src/views/NewestView.vue` | `layout: 'FullPage'` | `:26` |
| `/cart` | `cart` | `src/views/CartView.vue` | *(none)* → MasterLayout, **no auth guard** | `:27` |
| `/login` | `login` | `src/views/user/LoginRegisterView.vue` | `layout: 'FullPage'` | `:28` |
| `/user` | `userPage::hasSubRoute` | `src/views/user/DashboardView.vue` | *(none)* → **no auth guard** (renders a signed-out invite state) | `:29` |
| `/orders` | `ordersList` | `src/views/user/OrdersView.vue` | `requiresAuth: true` | `:30` |
| `/transactions` | `transactionsList` | `src/views/user/TransactionsView.vue` | `requiresAuth: true` | `:31-36` |
| `/profile` | `profileData` | `src/views/user/ProfileView.vue` | `requiresAuth: true` | `:37` |
| `/favorites` | `favoritesList` | `src/views/user/FavoritesView.vue` | `requiresAuth: true` | `:38` |
| `/notifications` | `notifications` | `src/views/user/NotificationsView.vue` | *(none)* — **no auth guard** | `:39` |
| `/search` | `search` | `src/views/search/SearchView.vue` | *(none)* | `:40` |
| `/collection/:id?` | `collection` | `src/views/search/CollectionView.vue` | *(none)*; reads `?page=<title>` query for the bar title | `:41` |
| `/` | — | *redirect* → `{name:'home'}` | — | `:43` |
| `/:pathMatch(.*)*` | — | *catch-all redirect* → `{name:'home'}` | — | `:44` |
**No route declares a `title` meta and no route declares a level/KYC requirement.** The only meta keys in use are `layout` and `requiresAuth`.
`scrollBehavior` (`:12-15`): returns `savedPosition` on back/forward, else `{top: 0}`.
---
## 4. Navigation (bottom tab bar)
Items are defined as store state at `src/stores/navStore.ts:25-31`, rendered by `src/components/ui/Navigation.vue:11-31`.
| index | label | route name | path | icon | gating |
|---|---|---|---|---|---|
| 1 | خانه | `home` | `/home` | `vuesax-linear:home-2` | none |
| 2 | جستجو | `search` | `/search` | `vuesax-linear:search-normal-1` | none |
| 3 | سبد خرید | `cart` | `/cart` | `vuesax-linear:bag-happy` | none |
| 4 | سفارشات | `ordersList` | `/orders` | `vuesax-linear:receipt-text` | **`requiresAuth`** → tapping while signed out redirects to `/login?redirect=/orders` |
| 5 | پروفایل | `userPage::hasSubRoute` | `/user` | `vuesax-linear:user` | none (Dashboard shows a signed-out invite) |
Mechanics:
- The tab list is **not filtered by auth** — all five always render (`Navigation.vue:20-28`); gating happens only in the router guard.
- Visibility: `v-show="nav.showNav"` (`Navigation.vue:12`). `showNav` is written by `router.afterEach` (`src/router/index.ts:95-101`): visible only when the destination route name is one of the five `navBarRoutes`, otherwise hidden.
- Active indicator: pure CSS transform `translateX(-(activeLink-1)*100%)` — negative because the app is RTL (`Navigation.vue:40-44`).
- **Stale comment**: `Navigation.vue:1-10` documents "5 tabs (Home, Search, Likes, Notifications, Profile)" which does not match the actual store list (Home, Search, Cart, Orders, Profile). Cosmetic only.
- Dashboard also offers 4 in-page nav rows → `ordersList` / `transactionsList` / `profileData` / `favoritesList` via `goToDetails(id)` (`src/views/user/DashboardView.vue` script `:32-41`).
---
## 5. Guards
All guard logic lives in `src/router/index.ts`. **There is no KYC guard, no level/tier guard, and no role guard.**
| Guard | Rule | Line |
|---|---|---|
| `beforeEach` — already-logged-in | `to.name === 'login' && session.customerId` → redirect `{name:'home'}` | `:56-58` |
| `beforeEach` — auth required | `to.meta.requiresAuth && !session.customerId` → redirect `{name:'login', query:{redirect: to.fullPath}}` | `:59-61` |
| `beforeEach` — page transition | `to.name === 'singleProduct'` → `nav.setPgaTransition('slideDown')`, else `undefined` | `:64-65` |
| `afterEach` — transition flag | `setTransitionFlag(false)`, `setTransitionEnded(false)`, then `setTransitionFlag(true)` after 100ms. File comment `:73-79` states this half is **inert** (AppHeader was deleted, nothing reads `transitionFlag`) | `:84-88` |
| `afterEach` — tab bar | `setActiveLink(index)` + `setNavVisibility(true)` when the destination is a nav route; `setNavVisibility(false)` otherwise | `:95-101` |
Redirect consumption: `LoginView.vue` reads `route.query.redirect` and pushes it after a successful OTP step, falling back to `{name:'home'}` (`src/views/user/LoginView.vue:161-162`).
Auth source of truth: `useSessionStore().customerId`, hydrated from `localStorage['gold-bank:customerId']` at store creation (`src/stores/session.ts:11`). A separate legacy-compat key `appToken` is written on login (`session.ts:25`) and read by `isUserLoggedIn()` (`src/utils/checkAccess.ts:5`), which is used only by SingleProduct's add-to-cart gate (`SingleProduct.vue` template `:237`).
**Gap**: KYC/verification exists in the data (`CustomerDetail.verificationStep`, labels at `src/views/user/ProfileView.vue:57-62`) but is **displayed only** — nothing is gated on it.
---
## 6. Views — every file under `src/views` (recursive)
| # | File | Purpose (one line) |
|---|---|---|
| 1 | `src/views/HomeView.vue` (348 L) | Home tab: brand bar + live 18k rate, hero coverflow of categories, category grid, "محصولات جدید" carousel + vertical list, product drawer. |
| 2 | `src/views/CartView.vue` (490 L) | Basket: item rows, per-item note sheet, total weight, confirm dialog, POST order, success step. |
| 3 | `src/views/NewestView.vue` (120 L) | Full "جدیدترین محصولات" grid with client-side incremental windowing. |
| 4 | `src/views/search/SearchView.vue` (389 L) | Search tab: text search, category chips, filter sheet, infinite-window results grid. |
| 5 | `src/views/search/CollectionView.vue` (100 L) | Products belonging to one collection id, filtered client-side. |
| 6 | `src/views/user/DashboardView.vue` (568 L) | "میز کار": balance hero (grams + rial debt), 4 settings rows, live rate row, logout/login dialogs. |
| 7 | `src/views/user/LoginRegisterView.vue` (14 L) | Thin switcher between `LoginView` and `RegisterView` on a local `showRegister` flag. |
| 8 | `src/views/user/LoginView.vue` (318 L) | 2-step mobile → OTP login (OTP verification is stubbed). |
| 9 | `src/views/user/RegisterView.vue` (218 L) | 7-field store-registration form; **submit is stubbed — persists nothing**. |
| 10 | `src/views/user/OrdersView.vue` (484 L) | Order list with status chips + order-number search, detail drawer with 4-step tracker. |
| 11 | `src/views/user/TransactionsView.vue` (342 L) | "اسناد" ledger-entry list with credit/debit marks and a detail drawer. |
| 12 | `src/views/user/ProfileView.vue` (216 L) | Read-only account profile card (name, mobile, national code, verification step). |
| 13 | `src/views/user/FavoritesView.vue` (110 L) | Grid of favourited products (favourites are in-memory only). |
| 14 | `src/views/user/NotificationsView.vue` (51 L) | **Static empty-state stub only** — no data source, no API call, no state. |
### 6.1 `HomeView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.listProducts()` GET `/api/catalog/products` and `memory.init()` (which itself calls `listProducts`) in `Promise.all` — **the catalogue is fetched twice per load** (`HomeView.vue:196`). Then `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750` in a separate try (`:203`). |
| Error state | Catalogue failure: `catch{console.log(e)}` — silent, list stays empty (`:198-200`). Price failure: `goldPrice = null` → RateStrip prints "در انتظار اعلام نرخ" (`:204-206`; `RateStrip.vue:12`). |
| Computed slices | `reelProducts = products.slice(0, REEL_LIMIT=6)`; `listProducts = products.slice(0, LIST_LIMIT=4)` (`:149-154`). |
| Hero slides | `memory.cats.slice(0,5)` mapped to `{key,title,image,categoryId}`; image = first product image in that category (`:159-171`). |
| Conditional rendering | Hero `v-if="slides.length"` (`:37`); categories `v-if="memory.cats.length"` (`:45`); products section `v-if="newestProducts.length"` (`:50`); empty state `v-if="!loading && !products.length"` → box icon + "هنوز محصولی در مجموعه ثبت نشده است." (`:73-76`). |
| Pull-to-refresh | vant `PullRefresh` wrapping the body; `onRefresh()` re-runs `loadData()` (`:30`, `:209-212`). |
| Reveal choreography | `--reveal-delay` CSS var, `REVEAL_STEP_MS = 70`, module-scope `hasRevealed` so it plays once per app load, not per tab switch (`:114`, `:136-145`, `:214-220`). |
| Navigation out | Category/slide tap → seeds `filters.query.cats=[id]` then `router.push({name:'search'})` (`:178-188`). "See all" → `{name:'newestProduct'}` (`:190-192`). |
| Product open | Opens an ant `Drawer` (bottom, `height="100vh"`) containing `SingleProduct` — **not** a route push (`:80-89`, `:173-176`). |
| Buttons disabled/loading | None on this page. |
| Confirm dialogs | None. |
| Prices | **No rial price is printed for any product** — deliberate (`:12-17`). Only weight (گرم) and wage (٪). |
### 6.2 `CartView.vue`
| Aspect | Detail |
|---|---|
| API calls | On mount and on pull-refresh: `cart.init()` → `api.listProducts()` GET `/api/catalog/products` to re-sync lines and drop stale ones (`CartView.vue:228`, `:243`; `src/stores/cartStore.ts:39-55`). On submit: `api.createOrder(...)` **POST `/api/orders`**. |
| POST payload | `{ customerId: session.customerId, items: cart.items.map(i => ({ productVariantId: i.vid, quantity: i.qty, wagePercent: parseFloat(variant.defaultWagePercent) || 0 })) }` (script `:220-231` / tail `:1-11`). The per-item **`note` is NOT sent** — it is stored locally only. |
| Auth gate | `submitOrder()` first checks `session.customerId`; if absent, closes the confirm dialog and opens `loginNotify` (script `:214-217`). |
| Login dialog | Text: "برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید." Buttons: "ورود/ثبت نام" → `{name:'login'}`, "بیخیال" → close (`:129-140`). |
| Confirm dialog | `DialogVant`, title "آیا مطمئن به ثبت سفارش هستید ؟", buttons "بله" → `submitOrder()`, "بی خیال" → close (`:118-127`). |
| Trigger | The sticky "ثبت نهایی سفارش" button only sets `cartData.notify = true` — it does not submit directly (`:93`). |
| Loading | `Spinner :spinning="cartData.loadingPage"` tip "درحال دریافت اطلاعات" (`:32`); `loadingPage` is set true during submit, false in `finally` (script `:219`, `:237`). **The submit button itself is never `disabled`** — only masked by the spinner overlay. |
| Success behaviour | `cartData.orderID = order.orderNumber`; `cartData.step = 2`; `cart.clear()` (script `:232-234`). Step 2 panel shows tick icon, "سفارش شما ثبت شد", the order number, hint "برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید." and a CTA → `router.push({name:'ordersList'})` then resets `step = 1` (`:68-79`, script `:207-210`). |
| Error handling | `catch{console.log(e)}` — **a failed POST shows the user nothing**; the cart is not cleared and the view silently stays on step 1 (script `:235-236`). |
| Note sheet | Bottom `Drawer` height `300px`, title "توضیح برای {product.title}", a `Textarea :rows="4"` placeholder "مثلاً سایز یا جزئیات سفارش", submit "ثبت توضیحات". **No validation rules on the note field** (`:98-116`). Saved via `cart.updateFeild(note, index, row, 'note')` (script `:198-203`). |
| Totals | `cart.total()` = `Σ parseFloat(variant.rashWeightGrams) * qty`, `.toFixed(2)`, labelled "جمع وزن … گرم" (`:86-92`; `cartStore.ts:88-95`). **No rial total.** |
| Sticky bar visibility | `v-if="cart.items.length > 0 && cartData.step === 1"` (`:84`). |
| Empty state | Bag icon, "سبد خرید شما خالی است.", CTA "دیدن محصولات" → `{name:'home'}` (`:58-64`). |
| Pagination / filters | None. |
### 6.3 `SearchView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.listProducts()` GET `/api/catalog/products` on mount and on pull-refresh (`SearchView.vue:178-190`, `:238`). **The whole catalogue is fetched once; all filtering is client-side.** |
| Text filter | `p.title.includes(q.text)` — substring, case-sensitive, **no digit normalisation, no trimming** (`:128`). |
| Category filter | `q.cats.includes(p.category.id)` (`:129`). |
| Brand filter | `q.brands.includes(p.brand.id)` (`:130`). |
| Gram range filter | Applies only when `q.limit_gram.length === 2`: keeps products with **any** variant whose `rashWeightGrams` falls inclusively inside `[min,max]` (`:131-137`). |
| Unused filter fields | `colors`, `limit_percent`, `ciny_id`, `has_discount`, `page` are declared in `FiltersQuery` but never read by this filter chain (`src/stores/filtersStore.ts:9-19`). |
| Pagination | **No server params.** Client windowing: `PAGE_SIZE = 20`, `visibleCount` grows by 20 when an `IntersectionObserver` sentinel (`rootMargin: '400px'`) enters view (`:146-166`). Window resets to 20 on any filter change (`:169-171`). |
| Chips | "همه" (clears `cats`) + one chip per distinct category derived from the fetched catalogue (`:19-36`, `:109-115`). Filter chip shows `${filterKeys.length} فیلتر` when active, else "فیلتر" (`:10-17`). |
| Empty state | `v-if="!filteredProducts.length && !loadingPage"` → search icon + "محصولی با این مشخصات پیدا نشد." Explicitly NOT chained to the sentinel (commented fix at `:70-74`). |
| Error state | `loadProducts()` has `try/finally` with **no `catch`** — a network failure rejects unhandled (`:178-185`). |
| Filter sheet side-effects | Opening hides the tab bar (`nav.setNavVisibility(false)`, `:198-201`); closing restores it; `onBeforeRouteLeave` restores it too, to fix a leak where navigating away with the sheet open left the app with no tab bar (`:222-236`). |
| Bar back button | While the sheet is open, the PageBar back button closes the sheet instead of leaving the route, via a capture-phase click handler (`:213-220`). |
| `availableCollections` | Computed at `:117-123` but **never used in the template** — dead. |
### 6.4 `views/user/LoginView.vue`
| Aspect | Detail |
|---|---|
| Step 1 API | `api.listCustomers()` GET `/api/customers`, then client-side match `c.mobile === formModel.username && c.isActive && !c.isArchived` (`LoginView.vue:131-133`). **The entire customer list is downloaded to the browser to check one phone number.** |
| Step 2 API | **None.** `sendToken(_otpCode)` ignores the code entirely and calls `session.login(matchedCustomer.id, matchedCustomer.mobile)` — any 4 digits log you in (`:153-160`, documented at `:14-16`). |
| Form field | `username` — label "تلفن همراه", `<Input type="number">` (`:63`). |
| Validation rules | `required('تلفن همراه')` → message `" لطفا تلفن همراه را وارد کنید "` (`utils/validator.ts:23`); `mobile('تلفن همراه')` → pattern `/^(\+98|0098|98|0)?9\d{9}$/g`, message `" مقدار تلفن همراه نا معتبر است "` (`validator.ts:3-8`). Rules array at `LoginView.vue:61`. |
| Not-found message | Notify toast, red (`background-color: #DC2626`), text "اطلاعات کاربری در سیستم وجود ندارد." auto-dismiss 2000 ms (`:22`, `:103`, `:135-137`). |
| OTP input | `OtpInput` `:num-inputs="4"`, `inputType="tel"`, `should-auto-focus`, `@on-complete="sendToken"` (`:40-48`). |
| Resend timer | `timer = 120` seconds on entering step 2, decremented by a self-rescheduling `setTimeout` (`:112-122`, `:140-141`). Display: "امکان دریافت مجدد رمز در {mm} ثانیه دیگر" with `counterZeroShow` zero-padding under 10 (`:50-53`, `:170`); else "برای دریافت رمز عبور مجدد تلاش کنید". |
| Button | Label `body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'` (`:172`). **Disabled when** `body.loading || (timer > 0 && body.step === 2)` (`:70`). Loading class `is-loading` (`:69`). |
| Success | `router.push(route.query.redirect ?? {name:'home'})`, then toast "با موفقیت وارد شدید" (`:161-164`). |
| Digit conversion / masks | **None.** Persian digits typed into the mobile field are not converted; no separators, no input mask. |
| Switch to register | "ثبت نام کنید" → `emit('register', true)` (`:79`). |
### 6.5 `views/user/RegisterView.vue`
| Field (`name`) | Label | Control | Rules | Line |
|---|---|---|---|---|
| `fname` | نام | `Input` | `required('نام')` | `:37-39` |
| `lname` | نام خانوادگی | `Input` | `required('نام خانوادگی')` | `:40-42` |
| `mobile` | شماره موبایل | `Input type="number"` | `[mobile('شماره موبایل'), required('شماره موبایل')]` | `:43-49` |
| `store_name` | نام فروشگاه | `Input` | `required('نام فروشگاه')` | `:50-52` |
| `phone` | تلفن فروشگاه | `Input type="number"` | `[required('تلفن فروشگاه')]` — **`phone()` pattern validator is NOT applied** | `:53-55` |
| `address` | آدرس پستی | `Textarea :rows="3"` | `required('آدرس')` (note: label says "آدرس پستی", message says "آدرس") | `:56-58` |
| `city` | شهر | `Input` (plain text) | `required('شهر')` | `:59-61` |
- Submit button "ثبت اطلاعات", `type="submit"`, **never disabled** (`:63`).
- `register()` sets `profileData.loading` true, then just sets `registeredInfo = true`. **No API call — nothing is persisted** (`:117-125`, documented at `:15-19`).
- Success dialog: `closeOnClickOverlay: false`, image `registerDelay.svg`, text "ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.", button "متوجه شدم" → `{name:'home'}` (`:74-86`).
- Spinner tip "در حال ثبت اطلاعات" (`:25`).
- Dropped from legacy (documented `:5-13`): the country/province/city cascading picker, `WorkActionSheet`, `POST /cities/list`.
### 6.6 `views/user/DashboardView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.getCustomer(session.customerId)` GET `/api/customers/{id}` — **only when signed in** (script `:50-52`). Then `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750`, `catch → null` (script `:53-57`). |
| Calculations | `isDebtor = parseFloat(goldDebtGrams) > 0`; `grams = Math.abs(parseFloat(goldDebtGrams))`; `remainText = isDebtor ? 'بدهکار' : 'بستانکار'` (script `:24-28`). Rial debt printed via `price(customer.fiatDebtRial)` (thousands separators) + " ریال" (template `:32`). |
| Signed-in hero | Full name, `session.mobile`, a seal badge بدهکار/بستانکار, the gram figure + unit, the rial-debt row (template `:14-35`). |
| Signed-out state | Invite card: "حساب گرمی شما" / "برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید." / CTA "ورود / ثبت‌نام" → `{name:'login'}` (template `:38-47`). |
| Nav rows | 4 rows, each `button` + chevron: سفارشات / اسناد / پروفایل / علاقمندی → `goToDetails(0..3)` (template `:52-94`). Each row checks `session.customerId` first; if absent it opens `data.loginNotify` instead of navigating (script `:32-41`). |
| Logout | Exit icon in the bar, only `v-if="session.customerId"` (template `:52-60` of the header block). Opens `data.notify` dialog "آیا می خواهید از برنامه خارج شوید؟" → "بله" runs `session.logout()` + push `{name:'home'}`; "بی خیال" closes (template `:102-111`, script `:43-47`). |
| Login-nudge dialog | Long text "برای دسترسی به تمامی امکانات نرم افزار منتزر…" → "ورود/ثبت نام" / "بیخیال" (template `:113-127`). |
| Live rate | `<RateStrip :quote="goldPrice" />` — full-width card variant (template `:98`). |
| Pull-to-refresh | vant `PullRefresh` → `onRefresh()` re-runs `loadDashboard()` (template `:5`, script `:60-63`). |
| Error state | `loadDashboard` has **no try/catch around `getCustomer`** — a failed customer fetch rejects unhandled and the reveal animation never fires (script `:49-52`). |
| Explicitly dropped | Multi-role/sub-account switching, "سفارش مشتریان", "سفارش ساخت" (PreOrder), external Tahesab report, multi-row world-price ticker — all documented at `:9-27`. |
### 6.7 `views/user/OrdersView.vue`
| Aspect | Detail |
|---|---|
| API call | `api.getCustomerOrders(session.customerId)` GET `/api/customers/{id}/orders`; early-returns when signed out (script `:52-60`). |
| Filters | 9 status chips built from `ORDER_STATUS_LABELS`: `ALL` ("همه"), `SUSPENDED`, `PLACED`, `ADMIN_APPROVED`, `SOURCING`, `PREPARING`, `DELIVERED_TO_AGENT`, `DELIVERED_TO_CUSTOMER`, `CANCELLED` (script `:23-33`). **All client-side** — no query params sent. |
| Search | `SearchInput` placeholder "جستجوی شماره سفارش"; filter is `o.orderNumber.includes(searchTerm)` — substring, no digit normalisation (script `:44`). |
| Pagination | **None** — every order renders. |
| Empty state | `v-else-if="!loading"` → box icon + "سفارشی با این مشخصات پیدا نشد." (template `:38-41`). |
| Loading | `Spinner :spinning="loading"` tip "درحال دریافت اطلاعات" (template `:29`). |
| Pull-to-refresh | Yes, `onRefresh()` (template `:30`, script `:74-77`). |
| Detail drawer | Bottom `Drawer height="100vh"`. Contains a 4-step tracker (`ORDER_STEPS` = ثبت سفارش / آماده سازی / تحویل مشتری / لغو شده) with `is-done` for `index <= currentStep` and `is-active` for equality (template `:66-78`); headline facts — order number, status pill coloured from `ORDER_STATUS_COLORS`; specs — "تعداد کالاها" `items.length` آیتم, "وزن سفارش" `parseFloat(totalGoldWeightGrams).toFixed(2)` گرم, "تاریخ سفارش" `jdate(createdAt)` (template `:81-109`); items rendered via `OrderItemCard` (template `:112-117`). |
| Error state | `try/finally` with no `catch` (script `:55-59`). |
### 6.8 `views/user/TransactionsView.vue`
| Aspect | Detail |
|---|---|
| API call | `api.getLedgerEntries(session.customerId)` GET `/api/customers/{id}/ledger-entries` on mount; early-returns when signed out (script `:42-50`). |
| Source labels | Local map at script `:12-21`: `ORDER_STOCK`→سفارش, `ORDER_PRE_ORDER`→سفارش ساخت, `REPAYMENT_MELTED_GOLD`→طلای آب‌شده, `REPAYMENT_TRANSFER`→حواله, `REPAYMENT_CHEQUE`→چک, `REPAYMENT_CASH`→پولی, `WALLET_BONUS`→پاداش, `ADJUSTMENT`→اصلاحیه. Fallback: raw `sourceType`. |
| Credit/debit | `isCredit = direction === 'CREDIT'` → down-arrow + `is-in`; else up-arrow + `is-out` (template `:12-13`, script `:32`). |
| Amount format | `currency === 'GOLD_GRAM' ? \`${amount} گرم\` : \`${price(amount)} ریال\`` — **grams are printed raw without separators; rial gets thousands separators** (script `:34-35`). |
| Date | `jdate(createdAt)` → Jalali `jYYYY/jMM/jDD`, Western digits (template `:27`). |
| Confirmed state | "تایید شده" / "در انتظار تایید" with `is-pending` class (template `:28-30`). |
| Header count | `SectionHead title="گردش حساب"` subtitle `${entries.length} سند` (template `:8`). |
| Filters / pagination | **None.** |
| Detail | Tapping a row opens a Drawer via `openEntry` (script `:37-40`). |
### 6.9 `views/user/ProfileView.vue`
- API: `api.getCustomer(session.customerId)` GET `/api/customers/{id}`, on mount only, early-return when signed out (`:70-78`).
- **Read-only** — no form, no inputs, no save, no PATCH/PUT anywhere.
- Fields rendered: نام و نام خانوادگی, تلفن همراه, کد ملی, وضعیت احراز هویت (`:21-38`).
- `VERIFICATION_LABELS` (`:57-62`): `UNVERIFIED`→احراز نشده, `STEP_ONE_SUBMITTED`→مرحله اول ثبت شده, `STEP_TWO_SUBMITTED`→مرحله دوم ثبت شده, `VERIFIED`→تایید شده. Fallback: raw value.
- `isVerified = verificationStep === 'VERIFIED'` drives an `is-ok`/`is-pending` seal (`:13-15`, `:68`).
- Empty state `v-else-if="!loading"`: "اطلاعات حساب در دسترس نیست." (`:41-44`).
- `try/finally`, no `catch` (`:72-77`).
### 6.10 `views/user/FavoritesView.vue`
- API: `api.listProducts()` GET `/api/catalog/products` then filtered by `favorites.isFavorite(p.id)` (`:42`, `:44-51`).
- Favourites are **in-memory Pinia only, not persisted and not server-backed** (`src/stores/favoritesStore.ts:1-5`) → the list is empty after every reload.
- Header: `SectionHead "ذخیره‌شده‌ها"` subtitle `${n} محصول` (`:8`).
- Empty state: heart icon, "هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید.", CTA "دیدن محصولات" → `{name:'home'}` (`:14-20`).
- No filters, no pagination.
### 6.11 `views/user/NotificationsView.vue`
Entire file is 51 lines: a `PageBar title="اعلان‌ها" back-to="home"` and a hardcoded empty state "اعلانی برای نمایش وجود ندارد." (`:1-17`). **No script state, no API, no store.** Route `/notifications` is reachable but no UI links to it.
### 6.12 `views/NewestView.vue`
- Data via `useProducts()` → `api.listProducts()` (`src/composables/useProducts.ts:24-35`).
- `getProducts(loading, isPull)` **returns immediately when `isPull` is true** — so `pull()` never re-fetches (`useProducts.ts:25`). `totalPages` is permanently `0` (`:19`).
- Client windowing: `PAGE_SIZE = 20`, `visibleCount` grows on `@fetch` from `ScrollFetchSearch`; only falls through to `getProducts(loadingPull, true)` (a no-op) once everything fetched is visible (`NewestView.vue:54-56`, `:62-70`).
- `productsData.flag` = `productList.length < 1`, passed to `ScrollFetchSearch` as the empty flag (`useProducts.ts:30`).
- Empty state: box icon + "هنوز محصولی در مجموعه ثبت نشده است." (`:23-26`).
- Errors: `catch{console.log(e)}` inside the composable (`useProducts.ts:31-33`).
### 6.13 `views/search/CollectionView.vue`
- API: `api.listProducts()` GET `/api/catalog/products`, then `p.collections.some(c => c.collectionId === route.params.id)` (`:41-43`, `:45-52`).
- Title: `route.query.page` as the bar title, fallback "مجموعه" (`:39`).
- Empty state: "در این مجموعه محصولی ثبت نشده است." (`:13-16`).
- No pagination, no filters, no `catch`.
---
## 7. Stores (Pinia) — 9 stores
| Store (`defineStore` id) | File | State | Actions | Endpoints | Persistence |
|---|---|---|---|---|---|
| `cart` | `src/stores/cartStore.ts` | `items: CartItem[]` (`{pid, vid, product, qty, note?}`), hydrated from `localStorage['appCart']` | `init`, `addToCart`, `remove`, `updateFeild`, `find_index`, `isInCart`, `variant`, `total`, `clear` (`:36-99`) | `init()` → GET `/api/catalog/products` (`:41`) | **`localStorage['appCart']`**, written by `persist()` after every mutation (`:16`, `:27-29`) |
| `config` | `src/stores/configStore.ts` | `config.main_slider: Slide[]` (3 hardcoded placeholder slides), `cats: HomeCategory[]`, `loaded: boolean` (`:61-65`) | `setCats`, `init` (`:66-91`) | `init()` → GET `/api/catalog/products`, derives distinct categories (`:75-86`) | None |
| `favorites` | `src/stores/favoritesStore.ts` | `ids: string[]` | `toggle`, `isFavorite` (`:12-20`) | **None — no favourites endpoint exists** (`:1-5`) | **None** (lost on reload) |
| `filters` | `src/stores/filtersStore.ts` | `query: FiltersQuery` = `{colors[], brands[], ciny_id[], limit_percent[], limit_gram[], has_discount, text?, cats?, page}` (`:9-31`) | `reset()` (`:36-38`) | None | None |
| `loading` | `src/stores/loadingStore.ts` | `loading: boolean` | `setLoading` | None | None. **Never imported anywhere in `src/` — dead store.** |
| `nav` | `src/stores/navStore.ts` | `navBarRoutes: NavItem[5]`, `activeLink = 1`, `showNav = true`, `pageTransition = 'right'` (`:24-35`) | `setNavVisibility`, `setActiveLink`, `setPgaTransition` (`:36-46`) | None | None |
| `session` | `src/stores/session.ts` | `customerId`, `mobile` — both hydrated from localStorage at creation (`:11-12`) | `setCustomerId`, `login`, `logout` (`:14-33`) | None (login is client-side matching in `LoginView`) | **`localStorage`**: `gold-bank:customerId`, `gold-bank:mobile`, and legacy-compat `appToken` (= customerId) (`:21-25`, cleared at `:29-32`) |
| `transition` | `src/stores/transitionStore.ts` | `transitionFlag`, `transitionEnded`, `headerHeight` | `setTransitionFlag`, `setTransitionEnded`, `setHeaderHeight` | None | None. Written by `router.afterEach`, but `transitionFlag`/`transitionEnded` are **read by nothing** (router comment `src/router/index.ts:73-79`); `headerHeight` is read only by `ContentWrapper.vue` and the three `ScrollFetch*` wrappers. |
| `windowData` | `src/stores/windowDataStore.ts` | `width`, `pullingDown` | `setWidth`, `setPullingDown` | None | None. `width` written by `MasterLayout` resize observer (`:35-44`), read only by `slider/Slider.vue:37-48`. `pullingDown` is written by nothing. |
Other localStorage keys: `version` (app-version check, `src/App.vue:69-74`).
---
## 8. Shared utils
### 8.1 `src/utils/filters.ts` — formatters
| Function | Behaviour | Exact output | Line |
|---|---|---|---|
| `price(val)` | Thousands separators via `val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')`. Falsy input (incl. `0`, `''`) returned as-is | `1234567` → `1,234,567` | `:7-12` |
| `priceUnit(val)` | Same separators + `" تومان "` suffix. Guard is `['string','number'].includes(typeof val)` so **`0` IS formatted here** (unlike `price`) | `1000` → `1,000 تومان ` | `:14-19` |
| `priceUnitRialLess(val)` | Same separators + a trailing space, no unit | `1000` → `1,000 ` | `:21-26` |
| `jdate(val)` | `moment(val).format('jYYYY/jMM/jDD')`; parses ISO 8601 natively | `2025-03-21T…` → `1404/01/01` | `:28-36` |
**Critical characteristics:**
- **Jalali dates render with WESTERN digits**, not Persian numerals — explicitly preserved, with a warning not to switch to `Intl` `fa-IR` (`:1-4`).
- There is **no Persian→English digit conversion utility anywhere in the app** (grep for `۰-۹`/`٠-٩` conversion: none). Numeric inputs accept whatever the keyboard produces and are passed straight through.
- There is **no gram/weight formatter**. Weights are printed with ad-hoc `.toFixed(2)` (`cartStore.ts:94`; `OrdersView.vue` template `:102`) or raw (`TransactionsView.vue` script `:35`; `SingleProduct.vue` template `:135`).
- There is **no input mask / separator-on-type component** anywhere. `price()` is display-only.
- `priceUnit` and `priceUnitRialLess` are **exported but never imported** — dead.
### 8.2 `src/utils/validator.ts` — validators (ant-design-vue rule objects)
| Export | Rule | Message | Line |
|---|---|---|---|
| `message(field)` | — | `` ` مقدار ${field} نا معتبر است ` `` | `:3` |
| `mobile(field)` | `/^(\+98\|0098\|98\|0)?9\d{9}$/g` | `message(field)` | `:5-8` |
| `phone(field)` | `/^((\+98\|0098\|98\|0)\d1)?\d{8}$/g` | `message(field)` | `:9-12` |
| `fa(field)` | `/^([؀-ۿﮊپچگ‌ ])+$/` | `` ` مقدار ${field} باید فارسی باشد ` `` | `:13-16` |
| `mail(field)` | `{type:'email'}` | `message(field)` | `:17` |
| `url(field)` | `{type:'url'}` | `message(field)` | `:18` |
| `username(field)` | `/^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim` | `` ` مقدار ${field} باید شامل حروف و اعداد انگلیسی و _ باشد ` `` | `:19-22` |
| `required(field)` | `{required:true}` | `` ` لطفا ${field} را وارد کنید ` `` (note the double space) | `:23` |
| `sheba(field)` | `/^IR\d{24}$/gim` | `message(field)` | `:24` |
| `card(field)` | `/^\d{16}$/gim` | `message(field)` | `:25` |
| `zip(field)` | `/^\d{10}$/gim` | `message(field)` | `:26` |
| `num(field)` | `/^\d*$/gim` | `message(field)` | `:27` |
| `len(field, len)` | `{len}` | `` ` مقدار ${field} باید ${len} کارکتر باشد ` `` | `:28` |
| `max(field, max)` | `{max}` | `` ` مقدار ${field} باید کمتر از ${max} کارکتر باشد ` `` | `:29` |
| `min(field, min)` | `{min}` | `` ` مقدار ${field} باید بیشتر از ${min} کارکتر باشد ` `` | `:30` |
Only `required` and `mobile` are actually used (LoginView, RegisterView). **There is no national-code (کد ملی) validator**, no IBAN use, no card use.
Note: the `/g` and `/gim` flags on these patterns are a latent statefulness bug with `RegExp.lastIndex` if a rule object is reused across `.test()` calls — ported from legacy as-is.
### 8.3 `src/utils/orderStatus.ts` — constants/enums with exact values
`ORDER_STATUS_LABELS` (`:8-17`):
| Key | Label |
|---|---|
| `SUSPENDED` | معلق |
| `PLACED` | ثبت سفارش |
| `ADMIN_APPROVED` | تایید مدیر سیستم |
| `SOURCING` | درحال تامین |
| `PREPARING` | درحال آماده سازی |
| `DELIVERED_TO_AGENT` | تحویل مسئول فروش |
| `DELIVERED_TO_CUSTOMER` | تحویل مشتری |
| `CANCELLED` | لغو شده |
`ORDER_STATUS_COLORS` (`:23-29`) — only 5 of 8 statuses are coloured; `SUSPENDED`, `ADMIN_APPROVED`, `CANCELLED` are deliberately uncoloured (`:20-22`):
| Key | font | background |
|---|---|---|
| `PLACED` | `#3B82F6` | `#EFF6FF` |
| `SOURCING` | `#EAB308` | `#FEFCE8` |
| `PREPARING` | `#F97316` | `#FFF7ED` |
| `DELIVERED_TO_AGENT` | `#06B6D4` | `#ECFEFF` |
| `DELIVERED_TO_CUSTOMER` | `#22C55E` | `#F0FDF4` |
`ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده']` (`:35`).
`orderStepIndex(status)` (`:37-54`): `PLACED|ADMIN_APPROVED|SUSPENDED` → `0`; `SOURCING|PREPARING` → `1`; `DELIVERED_TO_AGENT|DELIVERED_TO_CUSTOMER` → `2`; `CANCELLED` → `3`; default `0`.
### 8.4 Other constant sets (declared inside views, not centralised)
- `VERIFICATION_LABELS` — `src/views/user/ProfileView.vue:57-62` (see §6.9).
- `SOURCE_LABELS` — `src/views/user/TransactionsView.vue` script `:12-21` (see §6.8).
- Order filter tabs — `src/views/user/OrdersView.vue` script `:23-33`.
- Nav items — `src/stores/navStore.ts:25-31`.
- Colour tokens — `themeConfig/colors.json` (see §11).
### 8.5 `src/utils/checkAccess.ts`
`isUserLoggedIn()` → `localStorage.getItem('appToken')` (`:4-6`). The file header claims "always returns falsy for now" — **stale**, since `session.login()` writes `appToken` (`src/stores/session.ts:25`).
---
## 9. Components (41 files)
### 9.1 Input components
| Component | Purpose | Key details |
|---|---|---|
| `src/components/input/OtpInput.vue` (138 L) | N-box OTP container | Props `numInputs`, `inputType`, `inputClasses`, `separator`, `shouldAutoFocus`, `defaultFocus`, `placeholder`, `disabled`; emits `on-complete`. Handles change/keydown/paste/focus/blur across boxes (`:4-21`). |
| `src/components/input/SingleOtpInput.vue` (117 L) | One OTP box | `min="0" max="9" maxlength="1" pattern="[0-9]"` (`:6-11`). Truncates to 1 char (`:53-59`). Keydown whitelist: numerics 48-57 / 96-105 plus `[8,9,13,37,39,46,86]` (backspace, tab, enter, ←, →, delete, `v`) — everything else `preventDefault` (`:61-74`). `input.select()` on focus (`:78-81`). **No Persian-digit acceptance or conversion.** |
| `src/components/search/SearchInput.vue` (108 L) | Debounced search box | Emits `search` on Enter, on icon click, and on a **700 ms** `useDebounce` watcher (`:30-35`). Clear button emits `search(undefined)` (`:24-28`). **Bug**: `if (value)` at `:32` tests the `Ref` object (always truthy) rather than `value.value`, so the clear button appears after any keystroke and never hides. |
**There is no numeric/price input component, no thousands-separator-on-type input, no uploader/file-picker component, and no date-picker component anywhere in `src/`.** All numeric entry uses bare `<Input type="number">` from ant-design-vue.
### 9.2 Price / rate
| Component | Details |
|---|---|
| `src/components/home/RateStrip.vue` (166 L) | The only price ticker. Props `{quote: GoldPriceQuote \| null, compact?: boolean}` (`:8`). Displays `price(perGram)` + "ریال / گرم" + `jdate(quotedAt)`; when `quote` is null shows "در انتظار اعلام نرخ" (`:8-14`). `perGram = quote.pricePerGramRial.split('.')[0]` — the decimal tail is **truncated, not rounded** (`:12`). Label "نرخ طلای ۱۸ عیار" is hardcoded although the karat is a parameter. `compact` variant is a pill for the home header; default is the dashboard card. |
### 9.3 Product components
| Component | Details |
|---|---|
| `src/components/product/SingleProduct.vue` (1315 L) | Product detail. Used both as a route component (`/product/:id`) and inside Drawers from HomeView/ProductCard. Data via `useProductDetail(id)`. Sticky header turns solid once scrolled past the gallery height minus 56 px (`:44-60`). Stock labels: `qty>0` → `"{qty} عدد موجود"` / `is-in`; else `isPreOrder` → "پیش‌سفارش" / `is-pre`; else "ناموجود" / `is-out` (`:139-146`). Wage label: whole → bare, fractional → `.toFixed(2)` (`:133-137`). Variant chips render when `variants.length > 1` (template `:146`). Add-to-cart button `:disabled="!is_exist"`, label `is_exist ? 'افزودن به سفارش' : 'ناموجود'`, click gated on `isUserLoggedIn()` else opens `loginNotify` (template `:232-241`). Once in cart, the CTA becomes a link "مشاهده سبد خرید" → `/cart` and a qty stepper replaces the category pill (template `:213-230`, `:243-245`). Stepper: `increment` via `cart.updateFeild(qty+1,…)`; `decrement` removes the line at qty 1 (`:162-169`). Full-screen preview teleported to `<body>` with body-scroll lock and cleanup on unmount (`:118-131`, `:199-201`). Success toast "محصول با موفقیت به سبد خرید افزوده شد", 3000 ms (template tail `:58-61`). |
| `src/components/product/ProductCard.vue` (243 L) | Grid card; opens a `SingleProduct` Drawer on click; favourite toggle; declares an unused `wageBefore` prop (`:10-13`). |
| `src/components/product/CartItem.vue` (242 L) | One basket row with the qty stepper. **`decrement()` calls `updateFeild(qty-1, …)` with no floor** (`:16-18`) — unlike SingleProduct's stepper, which removes at 1. `index` is captured **once at setup** (`:11`), so it goes stale if earlier rows are removed. |
| `src/components/product/OrderCard.vue` (163 L) | Order summary row; status label + colour; `jdate`; emits `view`. |
| `src/components/product/OrderItemCard.vue` (144 L) | One order line; resolves the product/variant title + image via `useOrderItemProduct()` (client-side join against the catalogue). |
| `src/components/product/GallerySlider.vue` (287 L) | Pointer-capture swipe gallery. `v-model:index`, props `slides`, `alt`, `fit: 'cover'\|'contain'`, `showCounter` (default **false**, to avoid overlapping the caller's own chrome — `:10-19`). `DRAG_COMMIT_RATIO = 0.18` of track width; `TAP_MAX_PX = 6` distinguishes tap from swipe and suppresses `tap` after a drag (`:52-53`). |
| `src/components/product/Category.vue` (45 L) | Category tile that writes `filters.query.cats = [id]`. **Not imported anywhere — dead.** |
### 9.4 Home components
| Component | Purpose |
|---|---|
| `src/components/home/HeroCarousel.vue` (275 L) | Coverflow hero. Autoplay default true; `SWIPE_THRESHOLD = 46`, `NEIGHBOUR_SCALE = 0.76`, `NEIGHBOUR_SHIFT = 36.6`, `NEIGHBOUR_OPACITY = 0.75` (`:29-36`). Side cards tuck *behind* the centre card. |
| `src/components/home/ProductCarousel.vue` (298 L) | Peeking product rail ending in a "see all" cell. `SWIPE_THRESHOLD = 46`, `NEIGHBOUR_SCALE = 0.78`, `NEIGHBOUR_SHIFT = 92.5`, `NEIGHBOUR_OPACITY = 0.62` (`:27-37`); cells loop (`:41-44`). |
| `src/components/home/CategoryGrid.vue` (95 L) | Category tile grid; emits `select`. |
| `src/components/home/ProductPanel.vue` (117 L) | Wide horizontal product row used in the home list. |
| `src/components/home/RateStrip.vue` | See §9.2. |
| `src/components/home/SectionHead.vue` (59 L) | Title + subtitle + optional action; used by NewestView, CollectionView, FavoritesView, TransactionsView. |
| `src/components/home/AccountStrip.vue` (229 L) | Balance strip (grams + rial). **Not imported anywhere — dead** (DashboardView inlines the equivalent markup instead). |
| `src/components/home/CategoryRail.vue` (94 L) | Horizontal category rail. **Not imported — dead** (replaced by `CategoryGrid`). |
| `src/components/home/ShowcaseCard.vue` (156 L) | Large product card. **Not imported — dead.** |
| `src/components/home/ShowcaseSlider.vue` (201 L) | Auto-advancing banner slider, 6000 ms interval (`:28-31`). **Not imported — dead** (replaced by `HeroCarousel`). |
### 9.5 UI / shared
| Component | Purpose |
|---|---|
| `src/components/ui/PageBar.vue` (117 L) | Standard top bar. Props `title`, `back` (default **true**), `backTo?`. `onBack()` pushes `{name: backTo}` when given, else `router.back()` (`:38-41`). Slots `trailing`, `below`. |
| `src/components/ui/Navigation.vue` (119 L) | Bottom tab bar — see §4. |
| `src/components/ui/Container.vue` (13 L) | Registered globally as `<container>` in `src/main.ts:28`; optional `padding` prop. |
| `src/components/ui/DropDown.vue` (97 L) | Collapsible section used by the filter sheet. |
| `src/components/ui/ScrollFetch.vue` (41 L) | `useInfiniteScroll` wrapper, `distance: 50`. **Not imported — dead.** |
| `src/components/ui/ScrollFetchSearch.vue` (50 L) | Same, used by `NewestView` only. |
| `src/components/ui/ScrollFetchAfterSearch.vue` (48 L) | Same + `isSearch` prop. **Not imported — dead.** |
| `src/components/ui/ContentWrapper.vue` (12 L) | Reads `transitionStore.headerHeight`. **Not imported — dead.** |
| `src/components/DialogVant.vue` (142 L) | Two-button vant dialog; slots `img`/`title`/`button`; props `timeOut`, `text1..3`. |
| `src/components/DialogVantOneButton.vue` (104 L) | One-button variant; used by `App.vue` for the update prompt. |
| `src/components/NotifyVant.vue` (50 L) | Toast. `v-model` + `timeOut`; `timeOut === -1` disables auto-dismiss (`:21`). |
| `src/components/Spinner.vue` (116 L) | ant `Spin` wrapper; props `tip`, `size` (default `'large'`), `cls`, `delay`, `spinning`, `indicator`, `fixed` (default **true**). |
| `src/components/EmptyState.vue` (32 L) | ant `Empty` wrapper; used by the `ScrollFetch*` family. |
| `src/components/ViewAll.vue` (19 L) | "see all" link. **Not imported — dead.** |
| `src/components/icon/Icon.vue` (54 L) | Inline-SVG icon by name from `icons.json`. `tempIcon` is a **computed** (`:30`) — the header documents a fixed bug where it was a plain ref and froze when the `icon` prop changed on a mounted instance (the cart trash/minus swap). `applySize()` sets width/height after `nextTick` (`:32-43`). |
| `src/components/icon/icons.json` | **1024 icons**, all `vuesax-linear:*`. |
| `src/components/slider/HomeSlider.vue` (146 L) | Swiper banner with popup/link/discount click types, autoplay 4000 ms (`:32`). **Not imported — dead.** Carries a documented ported bug: sets `filters.query.discount` while the real field is `has_discount`, so the discount branch never filtered anything (`:50-55`). |
| `src/components/slider/Slider.vue` (139 L) | Swiper gallery with SVG curve math off `windowDataStore.width`. **Not imported — dead.** |
### 9.6 Composables
| File | Purpose | Key facts |
|---|---|---|
| `src/composables/useProduct.ts` | Adapts `ProductSummary` to the card contract | Default variant = **cheapest by `defaultWagePercent`** (`:24-29`) with **no orderability preference**. `is_exist = qty > 0 \|\| isPreOrder` (`:46`). `hasDiscountWage` always `null`, `has_warranty` always `false` — no backend (`:40`, `:48`). `image` = `product.images[0].url` only (`:33`). |
| `src/composables/useProductDetail.ts` | Single-product fetch | **No detail endpoint exists**: fetches the whole catalogue and finds by id client-side (`:16-29`). |
| `src/composables/useProducts.ts` | Product list for NewestView | `getProducts(loading, isPull)` no-ops when `isPull` (`:25`); `totalPages` always `0`. |
| `src/composables/useOrderItemProduct.ts` | Variant-id → title/image lookup | Module-level `cache` ref, loaded once from `listProducts()` (`:14-31`) — **never invalidated** for the app's lifetime. |
| `src/composables/useRefresh.ts` | SW auto-update | `watchEffect` auto-applies a waiting SW as soon as `offlineReady \|\| needRefresh` (`:16`). |
**Bug note**: `App.vue:84` calls `useRefresh()` **inside `onMounted`**, outside the setup scope, so its `watchEffect` is not bound to the component lifecycle.
---
## 10. Live price / sockets / polling
**There is no WebSocket, no SSE, no EventSource, and no polling timer for prices anywhere in `src/`.**
| Aspect | Reality | Evidence |
|---|---|---|
| Mechanism | A plain one-shot `fetch` of `GET /api/pricing/latest?karatPerMille=750` | `src/api/client.ts:145` |
| Call sites | Exactly two: `HomeView.loadData()` and `DashboardView.loadDashboard()` | `src/views/HomeView.vue:203`; `src/views/user/DashboardView.vue` script `:54` |
| Refresh trigger | Only (a) component mount and (b) the user pulling to refresh that page. No interval, no visibility-change listener, no focus listener. | `HomeView.vue:209-216`; `DashboardView.vue` script `:60-63`, `:75-83` |
| Staleness UX | The quote's own `quotedAt` is shown as a Jalali **date only** (no time), so a several-hours-old rate looks current | `src/components/home/RateStrip.vue:14` |
| Failure | `catch → goldPrice = null` → "در انتظار اعلام نرخ" | `HomeView.vue:204-206`; `RateStrip.vue:12` |
| Karat | Always the default `750` (18k); the label "نرخ طلای ۱۸ عیار" is hardcoded | `client.ts:145`; `RateStrip.vue:5` |
The only recurring timers in the app are decorative: `HeroCarousel` autoplay, `ShowcaseSlider` 6000 ms (dead component), `HomeSlider` 4000 ms (dead component), and `LoginView`'s 120-second OTP countdown (`src/views/user/LoginView.vue:112-122`).
---
## 11. Env / config / PWA
### 11.1 Environment
**The app has no `.env` and no `.env.example` of its own.** `vite.config.ts:9-12` loads the **monorepo-root** `.env` via `loadEnv('development', rootDir, '')` and sets `envDir: rootDir` (`:15`).
| Key | Read by | Default | Evidence |
|---|---|---|---|
| `USER_APP_V2_PORT` | dev-server port | `5182` | `vite.config.ts:11`; root `.env:27` = `5182` |
| `API_PORT` | proxy target | `3000` | `vite.config.ts:12`; root `.env:24` = `3000` |
**No `VITE_*` variable is referenced anywhere in `src/`** — there is no runtime-configurable API base URL. The base is the hardcoded `'/api'` (`src/api/client.ts:3`).
Dev proxy (`vite.config.ts:50-53`): `/api` and `/uploads` → `http://localhost:{apiPort}`. `strictPort: true` (`:49`) — fails loudly rather than moving ports. `host: '0.0.0.0'` (`:45`).
Production (`nginx.conf`): `/api/uploads/`, `/api/`, `/uploads/` all proxy to `http://goldbank-api:3000` with a Docker-DNS `resolver 127.0.0.11 valid=10s` to survive api-container restarts (`:11`, `:13-44`); SPA fallback `try_files $uri $uri/ /index.html` (`:46-48`); static assets `expires 6M` (`:50-54`). `Dockerfile:1-7`: `nginx:latest` from `docker.arvancloud.ir`, copies `dist`, exposes 80.
### 11.2 PWA
`VitePWA` config at `vite.config.ts:20-42`:
| Setting | Value |
|---|---|
| strategy | `injectManifest`, `srcDir: 'src'`, `filename: 'sw.ts'` |
| base | `/` |
| includeAssets | `['favicon.ico']` |
| devOptions | `{enabled: true, type: 'module'}` — **the SW runs in dev too** |
| manifest name / short_name / description | `Montzar` / `Montzar` / `Montzar` |
| theme_color / background_color | `$themeLessVars.primary` = `#1C2D56` |
| start_url / display | `/` / `standalone` |
| icons | `icon-192.png` 192², `/icon-512.png` 512², `icon-512.png` 512² `purpose: 'any maskable'` |
`src/sw.ts:1-14`: listens for `{type:'SKIP_WAITING'}` → `self.skipWaiting()`, then `precacheAndRoute(self.__WB_MANIFEST)`. Nothing else — **no runtime caching rules, no API caching, no offline fallback page**.
`public/manifest.json` contains only `{"gcm_sender_id": "464066939468"}` (`:1-3`) — a legacy FCM artifact; it is **not** the PWA manifest (that one is generated by the plugin). Push/Firebase is not initialised (`src/App.vue:4-6`).
App-version update flow (`src/App.vue:66-86`): compares `localStorage['version']` against `package.json` `version`; on mismatch shows a Lottie dialog "نسخه جدید منتزر منتشر شد!" with an "به روز رسانی" button that stores the new version, calls `updateServiceWorker()` and `window.location.reload()`. **Since `package.json` version is a static `0.0.1`, this dialog can only fire if the version is bumped at release time.**
### 11.3 Theme
- `themeConfig/themeConfig.ts:15-43` — ant-design-vue Less vars: `primary-color: secondary (#8D6A46)`, `link-color`/`heading-color: primary (#1C2D56)`, `error-color: #E31A1A`, `border-radius-base: 5px`, `height-lg: 42px`, `transition-duration: .5s`. Font scale from `curve.font = 14`.
- `themeConfig/colors.json` — 37 tokens. Legacy brand: `primary #1C2D56`, `secondary #8D6A46`, `danger #E31A1A`. **Redesign palette** (added on top of the legacy set): `ink #14161A`, `inkSoft #22262E`, `inkMuted #5B616E`, `inkFaint #8A909C`, `wine #7A1B33`, `wineDeep #5E1427`, `wineSoft #A33C55`, `wineWash #FBF2F4`, `canvas #FFFFFF`, `canvasSoft #FAFAFA`, `hairline #ECEDEF`, `hairlineSoft #F4F5F6` (`:26-37`).
- `tailwind.config.js:8-16` — content `./index.html`, `./src/**/*.{html,js,ts,vue}`; `darkMode: 'media'` (never used); colors = tailwind defaults merged with `colors.json`.
- `index.html:8-13` — `theme-color` / `background-color` / body background hardcoded `#1C2D56`; title "طلای منتزر"; viewport locked `maximum-scale=1.0, user-scalable=no`.
- `src/App.vue:94-104` — `#app { direction: rtl !important; user-select: none; color: @primary }`.
- Fonts: `src/assets/styles/fonts.less` + two families on disk — **IRANSans FaNum (6 weights, woff+woff2)** and **Yekan Bakh FaNum (7 weights, eot/ttf/woff)**. IRANSans is present in `user-app-v2` only.
---
## 12. Candidate PROTECTED items — new features / new design
These are items where `apps/user-app-v2` **diverges from the sibling `apps/user-app`** by adding something that does not exist there, or by a deliberate redesign. Verified by a full file-tree and per-file diff of the two apps.
### 12.1 New component files (exist ONLY in user-app-v2)
| Path | What it is |
|---|---|
| `src/components/home/HeroCarousel.vue` (275 L) | **New** coverflow hero with pointer-drag, wrap-around stack geometry and autoplay. Replaces `ShowcaseSlider` (which still exists but is now dead here). |
| `src/components/home/ProductCarousel.vue` (298 L) | **New** peeking 3-up product rail whose final cell is a "see all" CTA. No equivalent in the sibling app. |
| `src/components/home/CategoryGrid.vue` (95 L) | **New** category tile grid. Replaces `CategoryRail` (now dead here). |
| `src/components/home/ProductPanel.vue` (117 L) | **New** wide horizontal product row for the home list. |
### 12.2 New font family (only in user-app-v2)
`src/assets/styles/fonts/iransans/` — 12 files, IRANSans FaNum in 6 weights (woff + woff2). Not present in the sibling app.
### 12.3 Redesigned flows / sections
| Item | Path | Nature of the change |
|---|---|---|
| **Home page structure** | `src/views/HomeView.vue:1-20`, `:37-76` | Fully restructured: `HeroCarousel` + `CategoryGrid` + `ProductCarousel` + `ProductPanel` list, replacing the sibling's `ShowcaseSlider` + `CategoryRail` + featured/grid/newest three-band layout. New slice constants `REEL_LIMIT=6`, `LIST_LIMIT=4` (`:149-150`). |
| **First-paint reveal choreography** | `HomeView.vue:114`, `:136-145`, `:214-220`; `CartView.vue:165`, `:174-185`, `:227-245`; `DashboardView.vue` script `:65-83` | A `--reveal-delay` staggered entrance driven by a **module-scope `hasRevealed` flag** so it plays once per app load, not per tab switch. **Exists only in v2** — the sibling has no reveal system on any of the three screens. |
| **Dashboard layout** | `src/views/user/DashboardView.vue:29-45`, template `:14-98` | Second visual pass: one full hero card (balance + identity + rial-debt row) plus four wide settings-style rows with chevrons, replacing the sibling's "card + `SectionHead` + 4-cell tile grid". The sibling's `list[]` tile-config array is gone. `remainText` shortened from "بدهکار گرمی"/"بستانکار گرمی" → "بدهکار"/"بستانکار" (script `:25`). |
| **Compact RateStrip variant** | `src/components/home/RateStrip.vue:8`, `:47-103` | New `compact` prop: a navy-tinted pill for the home header (`fade(@primary, 6%)` background, navy border) next to the logo. The sibling has only the dark full-width card. Used at `HomeView.vue:26`. |
| **SingleProduct sticky solid header** | `src/components/product/SingleProduct.vue:39-60`, template `:42-68` | New scroll-driven header that turns solid once the sheet has covered the photo, measured off the gallery's own height minus `HEADER_SOLID_OFFSET_PX = 56`. Not in the sibling. |
| **SingleProduct category deep-link pill** | `SingleProduct.vue:22`, `:96`, `:185-190`, template `:227-230` | New: when the item is not yet in the cart, the action bar's secondary pill deep-links to search filtered by this product's category (seeds `filtersStore` then pushes `{name:'search'}`). Not in the sibling. |
| **Pill-shaped search field** | `src/components/search/SearchInput.vue:64-75` | Restyled: `height 48px`, `border-radius 999px`, `background @canvas` (outlined pill on white) vs the sibling's 44 px / 14 px-radius filled `@hairlineSoft` box. |
| **Lazy-load placeholder** | `src/main.ts:7`, `:24-27` | Uses `Vector.svg` as the vue3-lazyload loading/error mark; the sibling uses `montzar-logo.png` with an explicit TODO to swap it back. v2 is the intended end-state here. |
### 12.4 IMPORTANT — regressions where v2 is BEHIND the sibling
These are **not** protected new features; they are capabilities the sibling `apps/user-app` has that v2 lacks. Flagged here so a parity pass does not mistake them for intentional v2 design.
| Missing in v2 | Present in sibling | Evidence |
|---|---|---|
| **Admin-managed home banner** | `getHomeSlider()` GET `/home/slider` + `AdminSlide` interface + `adminSlides` state + absolute-URL vs in-app-path slide link handling in HomeView | v2 `src/api/client.ts:137-146` has 8 functions; sibling has 9 (`apps/user-app/src/api/client.ts:170`). v2 `HomeView.vue` has no `adminSlides`. |
| **Stock ceiling on cart quantity** | `capQty()`, `maxQty()`, an orderability guard in `addToCart()` that refuses out-of-stock non-pre-order variants, and clamping inside `updateFeild(...,'qty')` | v2 `src/stores/cartStore.ts:56-78` has none of these; a user can increment a cart line past available stock. |
| **Per-variant images** | `ProductImageSummary` (with `id`, `sortOrder`, `role: FEATURED\|FEATURED_MOBILE\|GALLERY\|GALLERY_MOBILE`), `ProductVariantSummary.imageId`/`.image`, images returned sorted by `sortOrder`, `useProduct.image` preferring the variant's own photo, a `watch` that scrolls the gallery to the selected variant's image | v2 `src/api/client.ts:56` types images as bare `Array<{url}>`; `src/composables/useProduct.ts:33` reads `images[0]` only. |
| **Orderable-first default variant** | `isVariantOrderable()` + a filtered candidate pool, so the page does not open on a disabled button | v2 `src/composables/useProduct.ts:24-29` picks the cheapest variant regardless of stock. |
| **Variant chip preview cap + per-chip stock** | `VARIANT_PREVIEW_COUNT = 6` with a "نمایش همه" toggle, per-chip thumbnails, per-chip موجود/پیش‌سفارش/ناموجود badges | v2 `SingleProduct.vue` template `:146-157` renders every variant as a plain text chip. |
### 12.5 Stubbed / non-functional surfaces (candidate gaps, documented in-code)
| Surface | Status | Evidence |
|---|---|---|
| OTP verification | Any 4 digits log in; the code is never checked | `src/views/user/LoginView.vue:153-160` |
| Registration | Submits nothing; no create-customer endpoint | `src/views/user/RegisterView.vue:117-125` |
| Favourites | In-memory only; lost on reload; no endpoint | `src/stores/favoritesStore.ts:1-5` |
| Notifications | Static empty state, no data source | `src/views/user/NotificationsView.vue:1-17` |
| Home banner slides | 3 hardcoded placeholders from local artwork | `src/stores/configStore.ts:39-58` |
| Profile editing | Read-only; no write endpoint used | `src/views/user/ProfileView.vue` |
| Promotions / discounts | `hasDiscountWage` always `null`; no data | `src/composables/useProduct.ts:40` |
| Server-side pagination | No endpoint pages; all windowing is client-side | `src/composables/useProducts.ts:25`; `SearchView.vue:146-166` |
| `getBalance()` API fn | Declared, never called | `src/api/client.ts:141` |
### 12.6 Dead code inventory (not imported anywhere in `src/`)
Components: `home/AccountStrip.vue`, `home/CategoryRail.vue`, `home/ShowcaseCard.vue`, `home/ShowcaseSlider.vue`, `product/Category.vue`, `slider/HomeSlider.vue`, `slider/Slider.vue`, `ui/ScrollFetch.vue`, `ui/ScrollFetchAfterSearch.vue`, `ui/ContentWrapper.vue`, `ViewAll.vue`.
Stores: `loadingStore.ts`. Utils: `priceUnit`, `priceUnitRialLess`, and 12 of 15 validators. API: `getBalance`. Deps: `gsap`, `@ant-design/icons-vue`. Computed: `SearchView.availableCollections` (`:117-123`).
---
## 13. The stray folder at the monorepo root
**`E:\gold bank\gold-banking-monorepo\user-app-v2\` — it exists.** It is a **stray partial copy, not a build output and not a workspace package.**
Contents — exactly two files, nothing else (no `src/`, no `index.html`, no `dist/`, no `node_modules/`):
| File | Size | mtime | What it is |
|---|---|---|---|
| `user-app-v2/package.json` | 966 B | 2025-09-18 12:54 | **Byte-identical** to `apps/user-app-v2/package.json`. |
| `user-app-v2/vite.config.ts` | 1553 B | 2025-09-18 12:54 | An **older revision** of `apps/user-app-v2/vite.config.ts`. |
The vite config differs from the live one in three ways:
1. **Hardcoded `port: 5179`** (`user-app-v2/vite.config.ts:37`) vs. the live config reading `USER_APP_V2_PORT` from the root `.env` (`= 5182`).
2. **No `strictPort`**, so it would silently drift to another port.
3. **Hardcoded proxy targets** `http://localhost:3000` (`:39-40`) vs. the live `http://localhost:${apiPort}`, and **no `envDir`/`loadEnv`** block at all.
It also imports `./themeConfig/themeConfig` (`:4`) — a path that **does not exist** at the root, so this config could not even be loaded from where it sits.
Why it is inert:
- `pnpm-workspace.yaml` declares packages as `apps/*` and `packages/*` only — the root `user-app-v2/` is **not** in any workspace glob, so pnpm ignores it.
- The root `package.json` `build:release` script filters `--filter user-app-v2`, which resolves to the workspace package at `apps/user-app-v2` (same `"name"`), not this folder.
- Nothing references it; it has no entry point.
**Conclusion: a leftover from the 2025-09-18 move of the app into `apps/`. Safe to delete, but NOT modified by this inventory as instructed.** Its only residual risk is confusion — a developer opening `user-app-v2/vite.config.ts` from the root would read stale port 5179 and stale hardcoded proxy targets.
@@ -0,0 +1,43 @@
# Admin dashboard visual unify — design spec
**Date:** 2026-10-14
**Status:** Approved (owner)
**Scope:** Visual chrome only on the admin dashboard
## Goal
Make the admin dashboard look like the rest of the admin panel: calm, bordered cards, brand navy, even spacing. Remove marketing-style gradients, gold chips, lift hover, and multi-colour KPI accents that made the page feel scattered.
## Decisions
| Decision | Choice |
|---|---|
| Content / API | Unchanged — same fields, sorts, charts, `GET dashboard/overview` |
| Approach | Unify with panel chrome (not a new IA, not polish-only) |
| Filters / refresh / export | Out of scope (PROTECTED §A) |
## Visual rules
1. **Page background** — no radial gold/navy wash; no negative margin on the dashboard container.
2. **Header** — title «نمای کلی» + muted subtitle «آمار ۳۰ روز گذشته · به‌روز از سرور»; remove «منتزر» badge.
3. **Chart/table cards** — light border / default ant Card look; drop heavy multi-layer shadows.
4. **Period labels** — muted text next to the title (not gold pills).
5. **KPI cards** — flat; soft grey icon well + `#1c2d56` icon/value; no side accent bar; no translateY hover.
6. **Sales summary** — three equal cells; no gold accent on the average.
7. **Spacing** — ~16px gap between blocks.
8. **Chart series colours** — keep `#1c2d56 / #28c76f / #ff9f43 / #ea5455` and −45° x-label rotation.
## Files
- `apps/admin-panel/src/views/Dashboard.vue`
- `apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue`
- `docs/parity/PROTECTED.md` §A (document chrome change)
- `docs/parity/CHANGELOG.md`
## States
Loading (`a-spin`), empty product chart (`a-empty`), tables, and fetch error handling stay as today.
## Done when
Same stats and charts render; page reads as part of the admin shell (no gradient / gold chip / card lift); desktop and mobile grids still work.