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
+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.