22 KiB
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) —b372d5e7f6c97b8cbe79ae7c2f1ccef7components/pe/PeDetailTabs.tsx(3610 dòng) —27ec74d8cd75024a4472304018e5e05fpages/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:257evaluation.workflow.nextPhases.filter(p => p !== Phase.TuChoi)— BE là single source of truth (:255ghi 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:
:476if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null - disable khi không đến lượt:
:479const isDisabled = blockedByV2Level:103const blockedByV2Level = isV2Pending && !actorInV2Level:102const isV2Pending = !!evaluation.currentApproval:99-100const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)):61const 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=:124isEditablePhase(evaluation.phase)→fe-user/src/types/purchaseEvaluation.ts:65(DangSoanThao ∥ TraLai).- Nút thu hẹp hơn nữa: CHỈ
DangSoanThao(Nháp).TraLaibị 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-698api.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ật — menuKeys.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:167Actions = ["Read", "Create", "Update", "Delete"]Permission.cs:8-11— 4 cộtCanRead/CanCreate/CanUpdate/CanDeletemenuKeys.ts:72(fe-user)export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'- Ma trận admin enumerate bảng
MenuItems(PermissionFeatures.cs:20 db.MenuItems) ⇒ ô tickDeletechoPe_DuyetNcc_Pendingđã hiện sẵn trong UI và ghi được rowPermissions.
🔴 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_* có 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ônaction="Delete"fe-user/src/pages/master/DepartmentsPage.tsx:76— mirror phía user- Cơ chế:
usePermission.ts:15-26can(menuKey, action)→findNode(menu, key)trên cây menu từAuthContext→ đọcnode.canDelete.PermissionGuard.tsx:12-15= wrapperif (!can(...)) return fallback. 🔴 Vùng PE (components/pe/*,pages/pe/*) — 0 hitusePermission|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:
fe-user/src/components/pe/PeWorkflowPanel.tsx~:458-506— thêm nút Xóa cạnh khốinext.map(KHÔNG nhét vào map).fe-admin/src/components/pe/PeWorkflowPanel.tsx— y hệt (byte-identical, sửa 1 bên là sót — PH-01).- Bọc
<PermissionGuard menuKey={MenuKeys.PurchaseEvaluations} action="Delete">theo khuôn PH-13 (importusePermission/PermissionGuard— hiện PE chưa import). - Prop
onDeletekhông cần thêm — đã truyền sẵn ởPurchaseEvaluationsListPage.tsx:573+:668(PH-10); nhưng nó đang gắn vàoPeDetailTabs, KHÔNG vàoPeWorkflowPanel⇒ phải (a) nâng mutationdellên rồi truyền prop mới choPeWorkflowPanel, 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.Deleted → entry.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 →
TuChoiở cả 4 policy (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) ⇒nextPhaseshế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:257hiện tại),dialog/isCancelgiữ 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.tsx → 0 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à:
MenuKeys.cs— factory +All(:147-165) ⇐ nếu quên, policy không được đăng ký (PH-12).DbInitializer.SeedMenuTreeAsync(:1870-1877) + 2 chỗ liệt kê key (:2092-2096,:2492-2496).Layout.tsxregexpeMatchcả 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.docs/STATUS.mdrowMenu keys+ rowPolicies(derived|All| × |Actions|, B1 canonical). ⇒ [SUY LUẬN] Đường rẻ nhất & ít rủi ro nhất vẫn là dùng keyPurchaseEvaluationssẵn có + actionDelete(PH-12) — 0 key mới ⇒ nhánh 2 không phải mở.