Finished from a parallel session that expired (owner: «بساز»).
- RPT-01: GET admin/report/order-items — legacy report/order_item's
32-column Excel (order items, one row per unit, plus financial
documents), RTL, Jalali dates. The «گزارش تجمیعی» screen gets
«دریافت فایل اکسل». Review fixes:
- weight: computedWeightGrams is already one unit's weight; legacy
divided it by qty again, under-reporting «بدهکار» on multi-unit
lines. Not repeated.
- «درصد»: the final percent (wage − discount), as legacy.
- dates: the end date covers its whole day; a bad date is a 400.
- SMS-01: confirming or rejecting a financial document texts the customer,
as legacy CashController::toggleConfiremCash.
- SMS-02: bulk SMS substitutes %name per recipient and is actually sent
through the configured gateway (D26 relay), no longer only logged.
- Docs: 01-ADMIN-MATRIX and D16-BC-GAP-SUMMARY.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 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/:
| Reference operation (controller) | Target endpoint | rid |
|---|---|---|
| (pending — filled per module as each section below is audited) |
Populated as §3 onward completes, so no row is written before both sides have been read.
§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:739 |
not yet sent | ❌ — lands with the Phase 1 SMS templates |
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 |
| PUSH-01 | Low | Firebase push notifications not implemented — deferred to PWA port phase. | ⏸️ deferred |