Files
tehran_taj/docs/parity/01-ADMIN-MATRIX.md
s.esmaeiliandClaude Opus 5 83e5d2868b feat(orders): send the order-placement SMS and close Phase 1
The reference texted three audiences on every placed order
(henza_server Api/ApiMobileController::setOrder:733-766). None of it was
wired here — OrdersService had no SmsService at all, so a customer could
place an order and nobody was told: not the customer, not the admins, not
the referring sales expert.

Now, after the transaction commits (so a rolled-back order texts nobody)
and without ever being awaited (D20 — a dead gateway must not fail an
order):
  - every mobile in ORDER_ADMIN_MOBILES, with the customer's name and
    the rounded gold weight;
  - the customer, with distinct text for a normal and an over-limit
    suspended order;
  - the referring sales expert, looked up on AdminUser (the target's
    referring expert is an admin, not a customer as in the reference).

The reference's four hardcoded personal numbers stay out of git, behind
the same env-var pattern as PROFILE_ADMIN_MOBILES.

The «در سیستم ویرایش شد» variant was deliberately not ported: the target
has no order-edit route, so it would be dead code — «عمداً تکرار نشد».

Also fills §2 of the admin matrix, the D1 deliverable that had been
(pending) since the phase opened: 163 rid-guarded routes, 68 distinct
rids, 176 grantable tree keys, and a per-module map to the reference
controllers. Only two enforced rids are ungrantable from the permission
screen (newsletters, persons.list.sellers.view) — filed as PERM-02,
awaiting an owner ruling because it changes the permission vocabulary.

Phase 1 (admin parity) is at its gate. api: 283/283 tests, 26 suites.

پنل: پیامک ثبت سفارش برای مشتری، ادمین‌ها و کارشناس فروش فعال شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 10:24:46 +03:30

484 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |