Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md
2026-07-27 09:14:17 +07:00

22 KiB
Raw Blame History

sub-invest-fe-2 — FE + PHÂN QUYỀN: nút XÓA phiếu PE ở màn DUYỆT

run: 2026-07-27-S155-pe-delete-approver · vai: investigator-codebase (READ-only) Quy ước: [CODE] = đọc thẳng từ file · [SUY LUẬN] = diễn giải của tôi · [CHƯA XÁC MINH] = không chứng được. Ghi TRONG LÚC LÀM (bài học #53).


Q1 — Định vị component khối HÀNH ĐỘNG (✓ Duyệt / ← Trả lại)

PH-01 [CODE] Component = PeWorkflowPanel.tsx, tồn tại ở CẢ 2 APP, byte-identical

App Đường dẫn md5
fe-user D:\...\SOLUTION_ERP\fe-user\src\components\pe\PeWorkflowPanel.tsx (942 dòng) 02671be6334110028c55fe82f5e70dee
fe-admin D:\...\SOLUTION_ERP\fe-admin\src\components\pe\PeWorkflowPanel.tsx (942 dòng) 02671be6334110028c55fe82f5e70dee

SỬA 1 BÊN LÀ SÓT. Quy ước dự án (CLAUDE.md): duplicate giữa 2 app là CÓ CHỦ ĐÍCH.

Các file PE khác cũng byte-identical (cùng md5 2 app):

  • components/pe/PeListPanel.tsx (264 dòng) — b372d5e7f6c97b8cbe79ae7c2f1ccef7
  • components/pe/PeDetailTabs.tsx (3610 dòng) — 27ec74d8cd75024a4472304018e5e05f
  • pages/pe/PurchaseEvaluationsListPage.tsx (712 dòng) — 99e38e167d2f745ea7628174f1d2d6bf

PH-02 [CODE] Vị trí render 2 nút — PeWorkflowPanel.tsx:458-506 (cùng số dòng ở CẢ 2 app)

:458   {next.length > 0 && !readOnly && (
:460       <Label className="text-xs">Hành động:</Label>       ← nhãn khối "HÀNH ĐỘNG"
:462       {next.map(p => {                                     ← render 1 button / phase kế tiếp
:467         const isSendBack = (p === Phase.DangSoanThao || p === Phase.TraLai) && ...
:470         const isCancel   = p === Phase.TuChoi
:480         const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
:487-501     <button onClick={() => !isDisabled && setTarget(p)} disabled={isDisabled} .../>
:506   )}

Nút KHÔNG hardcode — sinh động từ mảng next (danh sách phase kế tiếp BE trả về). Nút Xóa mới sẽ KHÔNG lọt vào vòng next.map này (Xóa không phải 1 phase) ⇒ phải render cạnh khối, không nhét vào map. [SUY LUẬN]

PH-03 [CODE] "Từ chối" đã bị gỡ khỏi UI (nhưng logic còn) — PeWorkflowPanel.tsx:254

:254  // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi".

⇒ giải thích vì sao ảnh prod chỉ thấy 2 nút. Tiền lệ gỡ-nút-theo-UAT đã xảy ra ở đúng khối này.

PH-04 🔴 [CODE] Bằng chứng "phiếu Nháp có nút Xóa riêng" — PeWorkflowPanel.tsx:472-476

:472  // S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
:473  // người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
:474  // (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
:475  // = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
:476  if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null

⇒ comment tự khai: đường "hủy phiếu sai" hiện tại = nhờ cấp khác Từ chối (mà nút Từ chối đã bị ẩn @S60!) + Xóa chỉ có ở phiếu Nháp. Đây chính là lỗ hổng UAT anh Tra đang gặp. [SUY LUẬN]


Q2 — Điều kiện hiện 2 nút hiện tại + nút Xóa mới phải gate bằng gì

PH-05 [CODE] Chuỗi gate 3 tầng (fe-user & fe-admin giống hệt)

Tầng 1 — màn hình: PurchaseEvaluationsListPage.tsx:37 const pendingMe = sp.get('pendingMe') === '1' → truyền readOnly={!pendingMe} vào PeWorkflowPanel tại 2 call-site: :588-592 (inline panel) + :674-678 (focus overlay). ⇒ khối HÀNH ĐỘNG chỉ sống ở URL ?pendingMe=1 (= menu "Duyệt"). Ở "Danh sách" → readOnly=true → thay bằng dòng chữ :507-511.

Tầng 2 — khối: PeWorkflowPanel.tsx:458 {next.length > 0 && !readOnly && (

  • next = PeWorkflowPanel.tsx:257 evaluation.workflow.nextPhases.filter(p => p !== Phase.TuChoi)BE là single source of truth (:255 ghi rõ "BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE").

Tầng 3 — từng nút:

  • ẩn hẳn Trả lại/Từ chối khi actor == drafter: :476 if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
  • disable khi không đến lượt: :479 const isDisabled = blockedByV2Level
    • :103 const blockedByV2Level = isV2Pending && !actorInV2Level
    • :102 const isV2Pending = !!evaluation.currentApproval
    • :99-100 const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
    • :61 const isAdmin = currentUser?.roles?.includes('Admin') ?? false

PH-06 🔴 [CODE] KHÔNG có usePermission / PermissionGuard nào trong PeWorkflowPanel

Grep usePermission|PermissionGuard trong fe-{user,admin}/src/components/pe/* + pages/pe/*0 hit (xem Q3 PH-11). ⇒ gate hiện tại thuần role + workflow-position, KHÔNG đụng ma trận quyền menu. [CODE]

PH-07 [SUY LUẬN] Gate đề xuất cho nút Xóa mới (nhất quán với 2 nút kia)

Cùng vị trí PeWorkflowPanel.tsx:458-506 (đặt CẠNH map, không nhét vào map vì Xóa ≠ phase): !readOnly (chỉ màn Duyệt) actorInV2Level (đúng lượt duyệt hoặc Admin) evaluation.phase === ChoDuyet. ⚠️ Đây là gate display-layer; tầng API độc lập — xem Q3.


Q4 — PE đang xóa được ở đâu (khuôn tái dùng)

PH-08 [CODE] Nút "Xóa phiếu" hiện có — PeDetailTabs.tsx:457-472 (cả 2 app, byte-identical)

:445  {mode === 'workspace' && canEditPhase && !readOnly && (        ← gate KHỐI action-bar
:457    /* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
:458       (đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
:459       (AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */
:460    {evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (   ← gate NÚT
:463      onClick={() => { if (confirm(`Xóa phiếu "…"? … soft-delete …`)) onDelete() }}
:470      <Trash2 …/> Xóa phiếu
  • canEditPhase = :124 isEditablePhase(evaluation.phase)fe-user/src/types/purchaseEvaluation.ts:65 (DangSoanThao ∥ TraLai).
  • Nút thu hẹp hơn nữa: CHỈ DangSoanThao (Nháp). TraLai bị loại có chủ đích (:457-458).

PH-09 [CODE] API + gate quyền của đường xóa hiện tại

PurchaseEvaluationsListPage.tsx:89-90

const del = useMutation({ mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`) …
  • bản mobile fullpage :697-698 api.delete(/purchase-evaluations/${id}). FE không gate bằng permission nào cả — chỉ gate bằng phase + mode. [CODE]

PH-10 🔴🔴 [CODE] Plumbing xóa ĐÃ ĐƯỢC NỐI SẴN vào màn DUYỆT — chỉ bị điều-kiện-render chặn

cả 2 call-site của màn Duyệt, prop onDelete đã truyền mutation xóa thật:

  • PurchaseEvaluationsListPage.tsx:570-575 (inline): onDelete={() => del.mutate(detail.data!.id)} + readOnly={true}
  • PurchaseEvaluationsListPage.tsx:665-670 (focus overlay = ảnh prod): onDelete={() => del.mutate(detail.data!.id)} + readOnly={true}

Nút không hiện vì :445 yêu cầu mode === 'workspace' && canEditPhase && !readOnly mà ở đây mode = default 'detail' (:107), readOnly=true, canEditPhase=false (phase ChoDuyet). ⇒ [SUY LUẬN] chi phí wire FE rất thấp — hàm xóa + invalidate + đóng-detail đã sẵn, phần thiếu là (a) nút ở đúng chỗ approver + (b) cửa BE/authz (Q3).


Q3 — PHÂN QUYỀN 2 TẦNG (gotcha #82 / S118)

(a) PH-11 [CODE] Menu key PE thậtmenuKeys.ts chỉ có ROOT, Pe_* sinh ở BE

fe-user/src/lib/menuKeys.ts (md5 4da4405…) ⟂ fe-admin/src/lib/menuKeys.ts (md5 81fe5ad…) — 2 file KHÁC nhau (không như file PE vốn byte-identical). Cả 2 đều có:

key fe-user line fe-admin line
PurchaseEvaluations: 'PurchaseEvaluations' :23 :23
PeWorkflows: 'PeWorkflows' :24 :24
ApprovalWorkflowsV2 / AwV2_DuyetNcc / AwV2_DuyetNccPhuongAn :26-28 :26-28

🔴 KHÔNG có const Pe_* / PeWf_* nào trong menuKeys.ts của cả 2 app. Chúng được sinh động ở BE: src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:131-145

:131 PurchaseEvaluationTypeCodes = ["DuyetNcc", "DuyetNccPhuongAn"];
:134 PurchaseEvaluationGroup(code)          => $"Pe_{code}"
:135 PurchaseEvaluationList(code)           => $"Pe_{code}_List"
:136 PurchaseEvaluationCreate(code)         => $"Pe_{code}_Create"
:137 PurchaseEvaluationPending(code)        => $"Pe_{code}_Pending"   ← MÀN DUYỆT
:141 PurchaseEvaluationWorkflowView(code)   => $"Pe_{code}_WfView"
:145 PeWorkflowTypeLeaf(code)               => $"PeWf_{code}"

Seed vào bảng MenuItems: DbInitializer.cs:1870-1877 (+:2092-2096, :2492-2496). ⇒ 10 key Pe_* thật (2 typeCode × {group, _WfView, _List, _Create, _Pending}) + 2 key PeWf_*. FE chỉ khớp chúng bằng regex, không bằng const: fe-user/src/components/Layout.tsx:120 /^Pe_([^_]+)_(List|Create|Pending|WfView)$/ · fe-admin/src/components/Layout.tsx:107 /^Pe_([^_]+)_(List|Create|Pending)$/ ⚠️ 2 app LỆCH nhau (admin thiếu WfView).

(b) PH-12 🔴 [CODE] Ma trận CRUD ĐÃ CÓ Delete — và đang không ai dùng cho PE

  • MenuKeys.cs:167 Actions = ["Read", "Create", "Update", "Delete"]
  • Permission.cs:8-11 — 4 cột CanRead/CanCreate/CanUpdate/CanDelete
  • menuKeys.ts:72 (fe-user) export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'
  • Ma trận admin enumerate bảng MenuItems (PermissionFeatures.cs:20 db.MenuItems) ⇒ ô tick Delete cho Pe_DuyetNcc_Pending đã hiện sẵn trong UI và ghi được row Permissions.

🔴 BẪY 2-TẦNG chính xác nằm ở đây: Program.cs:82-89 chỉ đăng-ký policy "{menu}.{action}" cho menu ∈ MenuKeys.All. MenuKeys.All (:147-165) CHỈ có root PurchaseEvaluations (:153) + PeWorkflows (:163)KHÔNG có Pe_*/PeWf_*. Không có IAuthorizationPolicyProvider động (grep = 0 hit). ⇒ Viết [Authorize(Policy = "Pe_DuyetNcc_Pending.Delete")] sẽ trỏ vào policy CHƯA ĐĂNG KÝ (ASP.NET ném InvalidOperationException → 500, không phải 403). [CODE + SUY LUẬN về hệ quả runtime — chưa chạy thử] ⇒ Policy DÙNG ĐƯỢC NGAY hôm nay = "PurchaseEvaluations.Delete" (root ∈ All ⇒ đã đăng ký, 0 code mới).

Quà cho spec: tầng display cũng đã sẵn — GetMyMenuTreeQuery.cs:51-84 cho Pe_* kế thừa cờ CRUD từ root PurchaseEvaluations khi leaf không có row riêng (:66 if (inheritFromKey is not null && !resolved.ContainsKey(m.Key)), :72). Nghĩa là tick Delete 1 lần ở root là mọi Pe_*canDelete=true. Không cần key mới, không cần migration.

(c) PH-13 [CODE] Khuôn PermissionGuard đúng chuẩn ở module KHÁC (để bắt chước)

  • fe-admin/src/pages/master/DepartmentsPage.tsx:101 <PermissionGuard menuKey={MenuKeys.Departments} action="Delete">
  • fe-admin/src/pages/master/ProjectsPage.tsx:130 · SuppliersPage.tsx:257 — cùng khuôn action="Delete"
  • fe-user/src/pages/master/DepartmentsPage.tsx:76 — mirror phía user
  • Cơ chế: usePermission.ts:15-26 can(menuKey, action)findNode(menu, key) trên cây menu từ AuthContext → đọc node.canDelete. PermissionGuard.tsx:12-15 = wrapper if (!can(...)) return fallback. 🔴 Vùng PE (components/pe/*, pages/pe/*) — 0 hit usePermission|PermissionGuard ở CẢ 2 app (xác nhận lại PH-06). Toàn bộ 5 file dùng guard này là Master + Users, KHÔNG có PE.

(d) PH-14 🔴 CHECKLIST "phải chạm chỗ nào" để đủ CẢ HAI tầng

Tầng DISPLAY (FE) — 4 mục:

  1. fe-user/src/components/pe/PeWorkflowPanel.tsx ~:458-506 — thêm nút Xóa cạnh khối next.map (KHÔNG nhét vào map).
  2. fe-admin/src/components/pe/PeWorkflowPanel.tsxy hệt (byte-identical, sửa 1 bên là sót — PH-01).
  3. Bọc <PermissionGuard menuKey={MenuKeys.PurchaseEvaluations} action="Delete"> theo khuôn PH-13 (import usePermission/PermissionGuard — hiện PE chưa import).
  4. Prop onDelete không cần thêm — đã truyền sẵn ở PurchaseEvaluationsListPage.tsx:573 + :668 (PH-10); nhưng nó đang gắn vào PeDetailTabs, KHÔNG vào PeWorkflowPanel ⇒ phải (a) nâng mutation del lên rồi truyền prop mới cho PeWorkflowPanel, hoặc (b) tạo mutation riêng trong panel. [SUY LUẬN]

Tầng API-AUTHZ (BE) — 3 mục: 5. PurchaseEvaluationsController.cs:146 [HttpDelete("{id:guid}")] — hiện chỉ có [Authorize] trần ở class :15, 0 policy (grep Policy trong file = 0 hit). Thêm [Authorize(Policy = "PurchaseEvaluations.Delete")] trên chính action Delete (policy này đã đăng ký sẵn — PH-12). 6. Nới guard phase trong handler: PurchaseEvaluationFeatures.cs:1404-1406 hiện allow-list {DangSoanThao, TuChoi}ChoDuyet đang bị chặn 409 "Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.". Không nới thì nút FE bấm ra lỗi. (thuộc slice BE) 7. Guard "đúng lượt duyệt" ở BE (mirror actorInV2Level) — hiện KHÔNG có; nếu chỉ dựa PurchaseEvaluations.Delete thì bất kỳ ai có quyền Delete PE đều xóa được phiếu người khác đang duyệt. [SUY LUẬN — rủi ro cần spec chốt]

Seed / migration / role — 2 mục: 8. KHÔNG cần key mới, KHÔNG cần migration nếu dùng root PurchaseEvaluations (PH-12). Chỉ cần admin tick ô Delete/system/permissions cho role đích → kế thừa xuống Pe_*. 9. Nếu spec muốn key riêng (vd Pe_{code}_Pending có Delete độc lập) ⇒ phải thêm key vào MenuKeys.All (:147-165) để Program.cs đăng ký policy, + seed MenuItems, + tính lại row Policies trong docs/STATUS.md (derived |All| × |Actions|).

Phụ (từ run.md "xóa phải có vết"): 10. DeletePurchaseEvaluationCommandHandler (:1396-1411) KHÔNG ghi changelog — chỉ db.PurchaseEvaluations.Remove(entity). Soft-delete là thật: AuditingInterceptor.cs:56-59 bắt EntityState.Deletedentry.Entity.IsDeleted = true. [CODE]


Q5 — DẤU VẾT UI ĐÃ GỠ ("chỗ cho huy")

PH-15 🔴🔴 [CODE] TÌM THẤY — đường "khai tử phiếu" phía approver ĐÃ TỪNG TỒN TẠI và bị gỡ

Commit 6db195d · 2026-06-12 14:30:38 +0700 · S60

[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)

Lý do gỡ — nguyên văn UAT (PurchaseEvaluationWorkflowService.cs:94-96):

// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi."

Khớp 1-1 lời anh: "mọi người nói là ko cần cái đó".

Gỡ tới đâu (6 file, git show --stat 6db195d):

  • Domain: xoá MỌI transition → TuChoicả 4 policy (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) ⇒ nextPhases hết trả TuChoi ⇒ nút FE tự biến mất (đúng cơ chế PH-05 tầng-2).
  • Service: guard chặn targetPhase=TuChoi đứng TRƯỚC mọi branch, chặn CẢ Admin — commit message ghi "spec bỏ hẳn, không escape hatch".
  • FE ×2 app: next.filter(p !== TuChoi) (= PeWorkflowPanel.tsx:257 hiện tại), dialog/isCancel giữ dead-safe để flip lại dễ.

PH-16 🔴🔴 [CODE] Chính message của guard S60 trỏ vào ngõ cụt — đây là gốc UAT hôm nay

PurchaseEvaluationWorkflowService.cs:101-106:

"Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
"Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp."

⇒ Hệ thống tự khai lối thoát duy nhất = "Xóa phiếu khi còn Bản nháp". Nhưng phiếu PE/2026/A/046 đang ở ChoDuyet, không còn Bản nháp ⇒ chỉ dẫn này không thực hiện được. [SUY LUẬN — nhưng dựa trên 2 mảnh CODE khớp nhau: guard-message + PeDetailTabs.tsx:460 gate phase === DangSoanThao]

⚠️ Hệ quả kèm theo (surprise): allow-list xoá ở BE là {DangSoanThao, TuChoi} (PurchaseEvaluationFeatures.cs:1404-1405) — mà TuChoi không còn tới được từ S60 ⇒ nửa allow-list đã chết. [CODE] 🎁 S60 để sẵn đường lùi, ghi trong chính comment (Service:99-100): "Flip lại nếu cần: xóa guard này + restore transitions trong PurchaseEvaluationPolicy."

PH-17 [CODE] KHÔNG tìm thấy nút Xóa/Thu-hồi từng nằm trong PeWorkflowPanel (màn duyệt)

git log -S trên cả 2 app cho Trash2 · onDelete · api.delete giới hạn file PeWorkflowPanel.tsx0 commit cả 3. Nút "Xóa phiếu" chỉ từng sống ở PeDetailTabs (workspace), sinh ra ở 378c993 (2026-05-07 16:37) với ràng buộc ghi rõ trong commit body: "CHỈ Bản nháp (DangSoanThao), KHÔNG xóa Trả lại (đã có lịch sử workflow)".

Lệnh đã chạy (liệt kê đủ để tái lập):

git log --oneline -S "Xóa phiếu" -- fe-user/src/components/pe fe-user/src/pages/pe fe-admin/src/components/pe fe-admin/src/pages/pe   → 1 hit (378c993)
git log --oneline -S "Thu hồi"  -- fe-user/src fe-admin/src                                                                          → 0 hit
git log --oneline -S "Hủy phiếu" -- fe-user/src fe-admin/src                                                                         → 0 hit
git log --oneline -S "Huỷ phiếu" -- fe-user/src fe-admin/src                                                                         → 0 hit
git log --oneline -S "Từ chối"  -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx                                             → 7 hit (6db195d = gỡ)
git log --oneline -S "Trash2"|"onDelete"|"api.delete" -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx                        → 0 hit ×3
git log --oneline --format="%h %ad %s" -- fe-user/src/components/pe/PeWorkflowPanel.tsx                                              → 30 commit (đọc hết)
grep -i "từ chối|xóa" docs/changelog/sessions/2026-06-12-S60-S62-*.md                                                                → xác nhận §S60 dòng 23-27

Kết luận Q5: "chỗ cho huy" = nút Từ chối (✗ Từ chối, màu đỏ, cùng khối HÀNH ĐỘNG), gỡ 6db195d 2026-06-12, lý do = UAT anh Kiệt 14:14. Không phải nút Xóa — nút Xóa chưa bao giờ có ở màn duyệt. Ngoài ra 9c330d2 (2026-06-11, S59) trước đó đã ẩn Trả lại+Từ chối khi approver == drafter (PeWorkflowPanel.tsx:476). 🔴 Spec phải trả lời: khôi phục TuChoi (rẻ, có đường lùi sẵn) hay dựng hành-động Xóa mới? Điểm khác biệt nghiệp vụ: TuChoi = phiếu còn tồn tại (vẫn hiện ở tab "Từ chối") — chưa chắc triệt tiêu lũy kế; Xóa = IsDeleted=1 khuất khỏi mọi query. Anh Tra cần "ko có lũy kế lên" ⇒ nghiêng về Xóa. [SUY LUẬN]


Q6 — Pattern 16-bis 4-place mirror

PH-18 [CODE] Trả lời DỨT KHOÁT

Nhánh 1 — chỉ thêm nút vào màn có sẵn (kịch bản mặc định): KHÔNG kích hoạt luật 4-place. Luật 16-bis chỉ áp khi có page/route mới, đúng như comment tại fe-user/src/components/Layout.tsx:86 ("4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap") và :73-74 (thiếu staticMap → MenuLeaf … if (!path) return null → sidebar drop im lặng). Việc này: 0 page mới, 0 route App.tsx, 0 key menuKeys.ts, 0 entry staticMap (Layout.tsx:56-106). Màn đích đã có route /purchase-evaluations?pendingMe=1 (Layout.tsx:134). ⇒ Luật thay thế phải tuân = "2-app mirror" (PH-01): PeWorkflowPanel.tsx byte-identical ở fe-user + fe-admin. Sửa 1 bên = sót — nguy hiểm ngang mức quên staticMap.

Nhánh 2 — nếu Q3(b) chốt phải thêm MENU KEY MỚI: vẫn KHÔNG phải 4-place, mà là bộ mirror KHÁC. Pe_* không đi qua staticMap — chúng resolve bằng nhánh regex động Layout.tsx:119-134 (peMatch), nên "chỗ thứ 4" của 16-bis không áp dụng. Bộ phải chạm khi thêm key PE mới là:

  1. MenuKeys.cs — factory + All (:147-165) ⇐ nếu quên, policy không được đăng ký (PH-12).
  2. DbInitializer.SeedMenuTreeAsync (:1870-1877) + 2 chỗ liệt kê key (:2092-2096, :2492-2496).
  3. Layout.tsx regex peMatch cả 2 app — hiện đã LỆCH (fe-user có WfView, fe-admin không) ⇒ thêm suffix mới phải sửa cả 2.
  4. docs/STATUS.md row Menu keys + row Policies (derived |All| × |Actions|, B1 canonical). ⇒ [SUY LUẬN] Đường rẻ nhất & ít rủi ro nhất vẫn là dùng key PurchaseEvaluations sẵn có + action Delete (PH-12) — 0 key mới ⇒ nhánh 2 không phải mở.