Files
hedena_gold/docs/parity/01-ADMIN-MATRIX.md
T
s.esmaeili ea86636027 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.
2026-10-15 17:19:10 +03:30

24 KiB
Raw Blame History

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