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.
24 KiB
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:
- Permissions exist that no UI can manage — they can only be changed by editing the database directly.
- 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_levellimit — ✅ 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_discountis 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.tsand 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 |