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>
484 lines
24 KiB
Markdown
484 lines
24 KiB
Markdown
# 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 |
|