# 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ậ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: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_*` 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ô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.tsx` — **y 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.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) ⇒ `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.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à:
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ở.

---

<!-- END sub-invest-fe-2 · TOTAL=18 PHAT-HIEN -->
