# SPEC — PE: xóa phiếu ở màn DUYỆT + restructure menu

> run `2026-07-27-S155-pe-delete-approver` · S2 (lead tổng hợp từ 3 Invest) · trạng thái: **CHỜ REVIEW**
> Nguồn: `sub-invest-be-1.md` (24 phát hiện) · `sub-invest-fe-2.md` (18) · `sub-invest-menu-3.md` (21)
> Mọi `file:line` dưới đây đã được lead verify lại độc lập ở các mốc chịu lực.

---

## 1. Vì sao làm — đây là lỗi SỐ LIỆU, không phải thiếu tiện nghi

Lời user (Tra Sol, UAT prod, verbatim):
> *"Vì mấy bạn bắt sai cái gói thầu"* → *"nên phải cho quyền xóa cái phiếu đó luôn"*
> *"chứ lỡ bấm sai cái gói thầu, quay lại không được, **phải xóa thì nó mới ko có lũy kế lên**"*

**Bằng chứng mã cho từng vế:**

| Vế user nói | Bằng chứng | File |
|---|---|---|
| "đang ăn lũy kế" | `PrevSubmitted` gom `Phase == ChoDuyet \|\| DaDuyet`; `PendingSelected` gom ChoDuyet-có-winner ⇒ ăn ở **CẢ HAI** dòng | `PeBudgetAccumulator.cs:53-59` · `:114-126` |
| "quay lại không được" | `TraLai` rơi khỏi `ComputeAsync` nhưng **rơi VÀO cả 2 dòng** `ComputePendingAsync` ⇒ Trả lại chỉ **dời** số, không triệt tiêu | `PeBudgetAccumulator.cs:104-126` |
| không xóa được | allow-list xóa = `{DangSoanThao, TuChoi}`; phiếu `ChoDuyet` → **409 Conflict** | `PurchaseEvaluationFeatures.cs:1404-1406` |

**Nguồn gốc lỗ hổng (khảo cổ, cả 2 sub độc lập xác nhận):** commit `6db195d` (Fri 12/06/2026 14:30, S60) gỡ hành động **Từ chối** theo UAT — *"anh Kiệt 14:14: Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi"*. Guard còn sống tự khuyên đường thoát:
> *"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**."* (`PurchaseEvaluationWorkflowService.cs:101-106`)

Cả hai cửa đều **không dùng được** cho phiếu ChoDuyet ⇒ S60 bịt cửa cuối cùng. Đây khớp lời owner *"hôm trước có cái chỗ cho hủy / mọi người nói là ko cần cái đó / giờ lại cần"*.
🔸 Hệ quả phụ: `TuChoi` trong allow-list xóa nay là **nhánh chết** (guard S60 chặn cả Admin) ⇒ allow-list thực tế chỉ còn `{DangSoanThao}`.

---

## 2. Quyết định OWNER (đã chốt — không bàn lại)

| # | Quyết định | Nguồn |
|---|---|---|
| 1 | Xóa được khi **đến lượt duyệt** của người đó | anh, 2026-07-27 |
| 2 | **Xóa MỀM** | anh |
| 3 | Phiếu **đang duyệt VẪN xóa được** (không thêm rào cho phiếu đã có lượt duyệt) — xóa ⇒ xuống mục "Đã xóa" ⇒ **hết ăn lũy kế** | anh |
| 4 | **Menu:** `Duyệt` → **"Đang duyệt"**; thêm **"Đã duyệt"**, **"Đã xóa"** | anh (ảnh, ghi chú đỏ) |
| 5 | Quyền xóa gắn vào **cờ `AllowApproverDelete` per-Cấp-duyệt** (admin tick trong Designer) | anh, AskUser |
| 6 | Màn "Đã xóa" **CHỈ XEM**, chưa làm khôi phục | anh, AskUser |

🔴 **Khai thẳng cho người đọc sau:** quyết định (5) **đổi nghĩa** so với chữ đầu tiên của anh (*"người đứng đầu phòng"*). Lý do: `Department.ManagerUserId` tuy có tên đúng nhưng **0/10 phòng có dữ liệu** và **cả 2 FE đang hardcode `managerUserId: null`** trong payload sửa ⇒ field tự xóa trắng mỗi lần sửa phòng ban. Anh chọn cờ per-Cấp với trade-off đó đã ghi rõ. **Không phải hiểu nhầm.**

---

## 3. Bốn hạng mục

### A. Cờ **F6 `AllowApproverDelete`** — 1 migration

Nhà: `ApprovalWorkflowLevel` (**không** phải workflow-level — Mig 28 từng để ở đó rồi **DROP ở Mig 29**, chuyển xuống slot Cấp).
Khuôn có sẵn, đã lặp: F1 4 mode `AllowReturn*` · F2 `AllowApproverSkipToFinal` (Mig 31) · F3 `AllowApproverEditDetails` · F4 `AllowApproverEditBudget` (Mig 30) · F5 `AllowApproverFinalize` (Mig 58).
⇒ **F6 = 1 `AddColumn` bit, default `false`, no new table.** Nhịp y hệt F4/F5.

### B. Nới guard xóa + đóng lỗ authz

**B1 — Guard phase.** `DeletePurchaseEvaluationCommandHandler` (`PurchaseEvaluationFeatures.cs:1392-1411`): allow-list `{DangSoanThao, TuChoi}` → **thêm nhánh `ChoDuyet`**, nhưng nhánh này **kèm điều kiện người**, khác 2 nhánh cũ.

**B2 — 🔴 Điều kiện người (HIỆN KHÔNG CÓ).** Handler **không inject `ICurrentUser`** ⇒ hiện **không kiểm ai đang xóa**. Với `ChoDuyet` phải thoả **CẢ HAI**:
- đang là **lượt duyệt của actor** — dùng `CurrentWorkflowStepIndex` + `CurrentApprovalLevelOrder` khớp `ApproverUserId` (khuôn `ApproveV2Async` đã có)
- 🔴 **`matchingLevel.AllowApproverDelete == true` — PER-ROW, KHÔNG per-Cấp** *(owner chốt sau review H2)*

> 🔴 **Viết chính xác thế này, đừng diễn giải lại** *(vá H2 — repo đang dùng CẢ HAI cách đọc cho cờ anh em F5, nên 2 người cài đặt sẽ ra 2 kết quả từ cùng một câu chữ)*:
> - ✅ **DÙNG** khuôn per-ROW: `matchingLevel.AllowApproverFinalize` (`PurchaseEvaluationWorkflowService.cs:859`)
> - ❌ **KHÔNG dùng** khuôn per-CẤP: `g.Any(l => l.AllowApproverFinalize)` (`PurchaseEvaluationFeatures.cs:1182`)
>
> **Vì sao per-ROW:** 1 row = **1 người** (`ApproverUserId` là field của row); "Cấp" chỉ là **tập row cùng `Order`**. Đọc per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xóa được** — ảnh prod cho thấy Cấp 2 Phòng Cung ứng có **3 người**, tức quyền huỷ chứng-từ tài chính rộng gấp 3. Owner chọn per-ROW để giữ sát ý ban đầu *"người đứng đầu phòng"* = **1 người, admin chỉ đích danh**.

Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ luồng đang chạy).

**B3 — 🔴 Authz API.** Action `Delete` (`PurchaseEvaluationsController.cs:146-151`) hiện **chỉ `[Authorize]` trần** ⇒ **mọi user đăng nhập gọi được**, rào duy nhất là phase. Nới phase mà không thêm rào = **mở toang**.
⚠️ **Bẫy:** `MenuKeys.All` **KHÔNG chứa `Pe_*`** (verify: `MenuKeys.cs:147-165`), mà `Program.cs:82-89` chỉ đăng ký policy cho key trong `All` ⇒ `[Authorize(Policy="Pe_….Delete")]` trỏ **policy không tồn tại** → hỏng lúc chạy.
✅ **Dùng `[Authorize(Policy = "PurchaseEvaluations.Delete")]`** (root, có trong `All`); `GetMyMenuTreeQuery:66-72` cho `Pe_*` **kế thừa cờ CRUD từ root**. Ô tick `Delete` **đã hiện sẵn** trong ma trận. ⇒ **0 key mới, 0 migration quyền.**

**B4 — Ghi vết.** `ChangelogAction.Delete = 3` **đã tồn tại**, đã dùng ở 5 chỗ PE ⇒ không cần migration. Handler xóa hiện **không ghi changelog** → thêm, theo khuôn `LogTransitionAsync` (`PurchaseEvaluationWorkflowService.cs:1158-1185`, 12 call-site).
🔸 Vết `DeletedBy` do `AuditingInterceptor` set là **ai + lúc nào**, **không có LÝ DO** — changelog là chỗ chứa lý do.

### C. Menu restructure

🔴 **Menu = SEED DB** (bảng `MenuItems`), **không** hard-code FE. FE fetch `/api/menus/me`, chỉ map key→route.

| Việc | Cách | Bẫy |
|---|---|---|
| `Duyệt` → **"Đang duyệt"** | thêm entry vào `labelBackfill` (`DbInitializer.cs:1917-1923`) | 🔴 **Sửa chữ trong `tree.Add(...)` KHÔNG có tác dụng** — upsert `:1893-1903` gặp key đã tồn tại thì **chỉ update `Order`** rồi `continue`, **không đụng Label** |
| **"Đã duyệt"** (mới) | key mới → nhánh INSERT chạy bình thường | trỏ URL `?type=1&phase=7` |
| **"Đã xóa"** (mới) | key mới | cần endpoint mới, xem D |

**6 chỗ phải chạm** (không phải 4-place mirror — xác nhận dứt khoát: thêm nút/mục **không** kích luật 4-place):
1. `MenuKeys.cs:134-141` — 2 factory const mới
2. `DbInitializer.cs:1874-1877` — 2 dòng `tree.Add`
3. `DbInitializer.cs:1917-1923` — entry `labelBackfill` cho rename
4. `DbInitializer.cs:2092-2096` **VÀ** `:2492-2496` — 🔴 **2 danh sách permission SONG SONG** cùng liệt kê đích danh 5 key PE; quên 1 bên = quyền lệch
5. `Layout.tsx` regex — `fe-user:120` **và** `fe-admin:107`
6. `docs/STATUS.md` — row Menu keys + Policies

🔸 **Nhân bản:** menu sinh trong `foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)` ⇒ mỗi mục mới **× số loại phiếu** (`DuyetNcc`, `DuyetNccPhuongAn`), không phải 2 dòng mà 2 × N.
🔸 **Không cần EF migration** (không đổi schema) nhưng **cần restart API** để `DbInitializer` chạy.
🔸 **Nhặt dọc đường:** regex `fe-admin/Layout.tsx:107` **thiếu `WfView`** so với `fe-user:120` — lệch có sẵn, nằm đúng vùng đang sửa ⇒ vá luôn.

### D. Màn "Đã xóa" — việc thật duy nhất

**D1 — Vì sao khó.** `PurchaseEvaluationConfiguration.cs:84` `HasQueryFilter(x => !x.IsDeleted)`. Chính filter này cho ta **miễn phí** quyết định (3) của anh (xóa ⇒ tự rớt khỏi **cả 4** phép cộng lũy kế). Nhưng nó cũng làm phiếu xóa **vô hình với mọi query** ⇒ không liệt kê được.

**D2 — Số đo** (2 sub verify độc lập, khớp): `IgnoreQueryFilters` = **0 hit** `src/Backend` · `HasQueryFilter(` = **12** · **15 hit trong `tests/`** ⇒ cú pháp đã quen trong repo, chỉ là lần đầu ở tầng production.

**D3 — 🔴 Ranh giới an toàn (CẤM vi phạm).** `IgnoreQueryFilters()` là toán tử trên **một `IQueryable` cụ thể**; `PeBudgetAccumulator` dựng `peers` **mới tinh** ở cả 2 hàm (`:49`, `:100`) ⇒ không rò **nếu** giữ 3 điều cấm:
- ❌ **KHÔNG** đặt vào helper dùng chung trả `IQueryable`
- ❌ **KHÔNG** sửa `PurchaseEvaluationConfiguration.cs:84`
- ❌ **KHÔNG** tái dùng đường snapshot/finalize
✅ **Hẹp nhất = handler + endpoint RIÊNG.** **KHÔNG** thêm cờ `includeDeleted` vào query list hiện có (cờ sẽ lan).

**D4 — Lịch sử đọc được miễn phí.** `PurchaseEvaluationChangelog : BaseEntity` (**không** có `IsDeleted`), query lọc **chỉ theo `PurchaseEvaluationId`**, **không join** sang PE ⇒ global filter không chạm (verify `PurchaseEvaluationFeatures.cs:1424-1425`). Thứ bị chôn là **bundle chi tiết phiếu** (`GetPurchaseEvaluationQuery`) ⇒ nếu muốn **mở** phiếu đã xóa thì đó là chỗ `IgnoreQueryFilters` **thứ 2**, cân nhắc riêng.

**D5 — Không khôi phục** (owner chốt (6)). Ghi rõ trong spec để người sau không tự thêm: nếu sau này làm restore thì **phải chốt phiếu về phase nào** — về `ChoDuyet` là **ăn lũy kế lại ngay**, đúng thứ vừa đi chữa.

**D6 — 0 tiền lệ thùng rác** toàn repo. Hit "khôi phục" duy nhất là **chuỗi thông báo** `EmployeeFeatures.cs:162` — chữ có, chức năng không (ghost-wire). ⇒ đây là màn đầu tiên loại này, đáng test riêng.

### E. FE — nút Xóa ở màn duyệt

**E1 — Dây đã nối sẵn.** Prop `onDelete` **đã truyền** vào **cả 2 call-site** màn Duyệt kèm mutation thật + invalidate (`PurchaseEvaluationsListPage.tsx:570-575` inline · `:665-670` focus overlay = đúng màn trong ảnh). Nút không hiện chỉ vì điều kiện render đòi `mode === 'workspace' && canEditPhase && !readOnly`, mà màn duyệt có `mode='detail'`, `readOnly=true`, `canEditPhase=false`.

**E2 — Chỗ đặt.** `PeWorkflowPanel.tsx:458-506`. 🔴 Đặt **CẠNH** vòng `next.map`, **KHÔNG** nhét vào map — map sinh nút theo **phase kế tiếp**, mà Xóa **không phải một phase**.

**E3 — 🔴 Mirror 2 app.** `PeWorkflowPanel.tsx` **byte-identical** giữa `fe-user` và `fe-admin` (md5 `02671be6334110028c55fe82f5e70dee`, 942 dòng). **Sửa 1 bên là sót.** Đây là luật thay thế cho 4-place.

**E4 — Gate hiển thị.** `!readOnly` ∧ `actorInV2Level` ∧ `phase === ChoDuyet` ∧ **cờ F6 của Cấp hiện tại**. Vùng PE hiện **0-hit** `usePermission|PermissionGuard`; khuôn để bắt chước: `fe-admin/.../DepartmentsPage.tsx:101` `action="Delete"`.
⚠️ Gate này là **display-layer**. Tầng API (B3) **độc lập** — ẩn nút **không** đóng API (gotcha #82 / S118).

---

## 4. ✅ CHECKLIST THỰC THI

> 🔴 **THỨ TỰ THI CÔNG CỨNG** *(vá M7 — văn xuôi §F.0 và checklist đá nhau: F.0 nói "A-E không chạy nếu thiếu F" nhưng checklist lại xếp A-E trước)*:
> **`1-2` (schema F6) → `F-1…F-4` + `F-1a` (lệnh Update + validator + authz) → `3-8` → `9-13` (menu/seed) → `14-20` (FE) → `21-26` + `F-12…F-16` (test) → `27-28` (docs)**
> Làm top-down theo số thứ tự sẽ dựng đúng thứ tự mà chính spec cảnh báo là hỏng.

### Backend
- [ ] **1.** `ApprovalWorkflowLevel` + `AllowApproverDelete` (bit, default `false`) — F6, khuôn F4/F5
- [ ] **2.** Migration `AddPeAllowApproverDelete` — **3-file rule** (Migration + Designer + ModelSnapshot), no new table
- [ ] **3.** DTO + Designer admin: tick F6 **per-NGƯỜI** (mirror chỗ tick F5). 🔴 **KHÔNG làm mục này một mình — nó là F-11 ở độ sâu nông** *(vá M8)*: F-11 liệt **5 dây FE** + cảnh báo **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình, rất khó bắt). **Làm thẳng theo F-11**, bỏ qua mục 3 này
- [ ] **4.** `DeletePurchaseEvaluationCommandHandler` — inject `ICurrentUser`
- [ ] **5.** Nới allow-list: thêm nhánh `ChoDuyet` **kèm** điều kiện (đúng lượt ∧ F6). `DangSoanThao` giữ nguyên
- [ ] **6.** Ghi changelog `ChangelogAction.Delete` theo khuôn `LogTransitionAsync` (+ chỗ chứa **lý do**)
- [ ] **7.** 🔴 **TÁCH 2 ĐƯỜNG XÓA** *(owner chốt sau review H1 — đo thật trên DB Dev: **11/13 role có `CanDelete=0`**, chỉ `Admin` + `DeptManager` = 1; nguồn `DbInitializer.cs:2515` `canDelete = roleName == AppRoles.DeptManager`)*:
  - [ ] **7a. Đường CŨ — xóa NHÁP: GIỮ NGUYÊN, KHÔNG gắn policy.** Rào vẫn là **phase** (`{DangSoanThao}`) + là người soạn. 🔴 Gắn policy vào đây = **role `Drafter` mất quyền xóa nháp của chính mình** (nút `PeDetailTabs.tsx:445` + `:460` đang chạy được **nhờ** endpoint không có policy) = **regression trên chức năng đang sống**
  - [ ] **7b. Đường MỚI — xóa KHI ĐANG DUYỆT: command + endpoint RIÊNG**, mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]`. Rào = policy **∧** đúng lượt **∧** cờ F6
  - [ ] **7c.** Cấp `CanDelete` cho tập role đích (approver thật — ví dụ `Procurement` trong ảnh UAT đang = 0 ⇒ **403 dù đúng lượt và đúng cờ**). Ghi rõ đường cấp: tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path (⚠️ khối `DbInitializer.cs:2120-2126` **skip-if-exists** `:2118` và **không bao giờ chạm `CanDelete`** ⇒ sửa seeder **KHÔNG** vá được prod)
  - [ ] **7d.** Test **chiều thuận**: đủ quyền + đúng lượt + đúng cờ ⇒ **204**. (Test 25 chỉ kiểm chiều 403 nên nó **xanh trong cả 2 kịch bản hỏng** ở trên — mù đúng chỗ cần thấy)
  > ✅ **Vì sao cách này không đụng `MenuKeys.All`:** đường mới tái dùng policy **root** `PurchaseEvaluations.Delete` (đã có trong `All`), đường cũ không cần policy ⇒ **0 key mới** ⇒ 2 số `Menu keys=54` / `Policies=216` ở `STATUS.md:450-451` **giữ nguyên**, khớp mục 27.
  > 🔴 Tầng policy **KHÔNG kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác**. Cơ chế kế thừa `GetMyMenuTreeQuery:66-72` chỉ là **display-layer** — không cấp quyền API.
- [ ] **8.** Endpoint + handler **RIÊNG** list "Đã xóa" — tuân D3 **VÀ 2 ràng buộc bắt buộc dưới đây** *(vá H3 sau review)*:
  - [ ] **8a.** 🔴 `IgnoreQueryFilters().Where(x => x.IsDeleted)` — `IgnoreQueryFilters()` **GỠ** filter chứ **không ĐẢO** nó; thiếu `.Where` ⇒ màn "Đã xóa" liệt kê **toàn bộ phiếu sống lẫn chết**. Test 22 soi accumulator nên **không bắt được** lỗi này
  - [ ] **8b.** 🔴 Tái lập **IDOR-scope**: sao khối `PurchaseEvaluationFeatures.cs:596-617` (non-admin chỉ thấy phiếu khi là Drafter ∥ phase ∈ `eligiblePhases` theo role ∥ là approver của workflow đã pin). Handler MỚI **mặc định không có rào nào** ⇒ thiếu bước này = mọi user đọc được **toàn bộ phiếu đã xoá của mọi dự án** kèm `BudgetPeriodAmount` + tên gói thầu + NCC
  - [ ] **8c.** Test: (a) list "Đã xóa" **không chứa** phiếu sống · (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác
  > 🔴 D3 chống rò **sang accumulator**; 8a/8b chống rò **ra người dùng**. Hai trục khác nhau, đừng gộp.

### Menu / seed
- [ ] **9.** `MenuKeys.cs` — 2 factory const mới (Approved / Deleted)
- [ ] **10.** `DbInitializer` tree `:1874-1877` — 2 dòng `tree.Add` (× số type code)
- [ ] **11.** `DbInitializer` `labelBackfill` — entry rename `Duyệt` → `Đang duyệt` 🔴 **bước này KHÔNG được bỏ**, sửa `tree.Add` là vô tác dụng
- [ ] **12.** `DbInitializer:2092-2096` **VÀ** `:2492-2496` — thêm key vào **CẢ HAI** danh sách permission
- [ ] **13.** Restart API để seed chạy (không cần EF migration cho menu)

### Frontend (🔴 mọi mục = **× 2 app**, giữ byte-identical)
- [ ] **14.** 🔴 **MỘT chỗ đặt nút DUY NHẤT** = `PeWorkflowPanel.tsx` cạnh `next.map`, gate E4. **Phải THÊM prop + mutation mới** — xem 14a *(vá M5)*
  - [ ] **14a.** 🔴 **§E1 bản đầu SAI, đã đính chính:** `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}` (`fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`) — **KHÔNG có `onDelete`**, không có mutation xoá. `onDelete` được truyền cho **`PeDetailTabs`**, **không phải** cho panel (`PurchaseEvaluationsListPage.tsx:570-575` / `:665-670` — số dòng đúng, **component sai**). ⇒ **"Dây đã nối sẵn" KHÔNG đúng cho chỗ đặt nút.** Phải nâng mutation `del` lên rồi truyền prop mới cho panel, hoặc tạo mutation riêng trong panel
- [ ] ~~**15.**~~ 🚫 **GỠ** *(vá M5)* — mục cũ bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"*, nhưng nút bị chặn đó nằm trong **`PeDetailTabs`** với gate **4 điều kiện** (`mode === 'workspace' && canEditPhase && !readOnly` `:445` **+** `phase === DangSoanThao` `:460`), mà màn Duyệt có `mode='detail'`, `canEditPhase=false`. "Bỏ chặn readOnly" **không đủ**, và giữ cả 14 lẫn 15 sẽ đẻ ra **2 nút Xóa** hoặc **1 nút không nối dây**. Nút nháp trong `PeDetailTabs` **giữ nguyên không đụng** (đường 7a)
- [ ] **16.** `Layout.tsx` regex — nới cho 2 key mới, **và** vá lệch `fe-admin:107` thiếu `WfView`
- [ ] **17.** Mục "Đã duyệt" trỏ `?type=1&phase=7` — **0 route mới, 0 page mới**
- [ ] **18.** Trang "Đã xóa" — gọi endpoint mục 8, chỉ-xem, **không** nút khôi phục
- [ ] **19.** Xác minh md5 `PeWorkflowPanel.tsx` **vẫn khớp** giữa 2 app sau khi sửa
- [ ] **20.** `npm build` × 2 app

### Test (test-before cho phần financial — luật dự án)
- [ ] **21.** 🔴 Xóa phiếu `ChoDuyet` ⇒ **4 assert CÓ HƯỚNG, không phải "cả 4 cùng giảm"** *(vá M4 — phát biểu cũ SAI số học)*:
  - `PrevSubmitted` **↓** (`PeBudgetAccumulator.cs:53-59` — gom `ChoDuyet || DaDuyet`)
  - `PendingSelected` **↓** (`:114-117` — loại `{Nháp, DaDuyet, TuChoi}` + có winner)
  - `PrevSelected` **KHÔNG ĐỔI** (`:61-62` đòi `Phase == DaDuyet`)
  - `PendingSubmitted` **KHÔNG ĐỔI** (`:107` **loại tường minh** `ChoDuyet`)
  - `PriorPes` **mất đúng 1 phần tử** (`:128-133` — output thứ 5 spec bản đầu quên)
  > Viết assert "giảm" cho cả 4 ⇒ test **không thể xanh**. Viết cho 2 rồi quên 2 kia ⇒ **mất chốt hồi quy**.
- [ ] **22.** 🔴 Endpoint "Đã xóa" **KHÔNG** làm phiếu xóa lọt lại `PeBudgetAccumulator` (anti-regression cho D3)
- [ ] **23.** Cấp **không** có F6 ⇒ xóa **bị từ chối** (403/409), kể cả khi đúng lượt
- [ ] **24.** Đúng F6 nhưng **không đúng lượt** ⇒ từ chối
- [ ] **25.** API-authz: user thiếu `PurchaseEvaluations.Delete` gọi thẳng endpoint ⇒ **403** (không chỉ ẩn nút — gotcha #82)
- [ ] **26.** Changelog có bản ghi `Delete` sau khi xóa

### Docs
- [ ] **27.** `docs/STATUS.md` — **CHỈ row Mig mới** *(vá M6)*. 🔴 **KHÔNG đụng row `Policies` (216) và `Menu keys` (54)** — key mới là `Pe_*` sinh qua **factory**, **không** vào `MenuKeys.All` ⇒ cả 2 số **giữ nguyên**. Sửa chúng = ghi số sai vào đúng chỗ canonical mà `governance-detectors.ps1` đang soi (`STATUS.md:450-451`, `Policies` là **DERIVED** = `|All| × |Actions|`)
- [ ] **28.** `docs/gotchas.md` — bẫy `labelBackfill` (sửa `tree.Add` vô tác dụng) + bẫy `Pe_*` không có policy

---

## 5. ⚠️ NỢ ĐÃ BIẾT — khai, không vá lượt này

| # | Nợ | Vì sao hoãn |
|---|---|---|
| N1 | **Rác bảng con:** soft-delete đổi state `Deleted→Modified` ⇒ **6 FK Cascade không bao giờ chạy**; 6/8 bảng con là `BaseEntity` **không có `IsDeleted`** ⇒ ở lại DB trỏ về phiếu vô hình | Sub grep **38 read-site**: tất cả join từ `db.PurchaseEvaluations` ⇒ **chưa lộ số**. Rủi ro là report tương lai |
| N2 | **Snapshot `budgetFrozen`** (Mig 67): xóa phiếu X sau khi X đã vào snapshot của phiếu Y đã duyệt ⇒ số của Y **không đổi** | Đúng ý đồ "đóng băng = record-of-decision". Cần owner chốt nếu muốn re-compute |
| N3 | `Department.ManagerUserId` **0/10 dữ liệu** + **2 FE hardcode `null`** xóa trắng field mỗi lần sửa phòng ban | Bug có thật, nhưng quyết định (5) đã đi đường khác ⇒ tách việc |
| N4 | `TuChoi` trong allow-list xóa là **nhánh chết** (guard S60 chặn cả Admin) | Dọn khi nào owner quyết số phận nút Từ chối |

---

## 6. NGOÀI PHẠM VI
Khôi phục phiếu (owner chốt (6)) · mở lại **chi tiết** phiếu đã xóa (D4 — cần `IgnoreQueryFilters` thứ 2) · khôi phục nút Từ chối · vá N1–N4.

---

# PHẦN II — Hạng mục F: lệnh **UPDATE quy trình duyệt** (yêu cầu owner (7))

> Nguồn: `sub-invest-wfver-4.md` (25 phát hiện). Lead verify độc lập F1/F3/F7/F14/F17.

## F.0 🔴 QUAN HỆ PHỤ THUỘC — đọc trước tiên

**Hạng mục A-E (quyền xóa) KHÔNG CHẠY nếu thiếu F.** Không phải "làm cùng thì tiện" mà là **phụ thuộc cứng**. 4 mắt xích:

1. POST tạo `new ApprovalWorkflow{...}` (`ApprovalWorkflowV2AdminFeatures.cs:336`) → `BaseEntity` sinh `Guid.NewGuid()` ⇒ **Id MỚI**; Steps/Levels cũng row mới.
2. Phiếu đọc workflow **theo Id đã pin**: `PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId` — **tham chiếu SỐNG, không snapshot**.
3. Bản cũ chỉ bị `IsActive=false` (`:334`) ⇒ **vẫn resolve được** ⇒ phiếu chạy tiếp trên cấu hình **CŨ**, im lặng, không lỗi.
4. **Không có đường re-pin:** `PE.ApprovalWorkflowId` chỉ ghi ở `PurchaseEvaluationFeatures.cs:149` (create) / `:291` (update), mà update chặn `:249-251` *"Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại"*. `UPDATE…ApprovalWorkflowId` trong Migrations = **0 hit**.

⇒ Tick `AllowApproverDelete` hôm nay ⇒ sinh v(n+1) ⇒ **phiếu đang treo vẫn đọc v(n)** ⇒ **không có cờ** ⇒ quyền xóa **không tới đúng phiếu cần xóa**.
⇒ Đường vòng duy nhất (Trả lại → gửi lại) chạy quy trình **lại từ đầu** và **vẫn ăn lũy kế** ⇒ **không giải được ca UAT gốc**.

## F.1 Hiện trạng: KHÔNG có lệnh Update nào

Mặt admin V2 chỉ 4 việc; **không** Update (trừ bật/tắt `IsUserSelectable`). Mọi POST **tự tăng** `Version = MAX(Version per Code)+1` (`:325-328`), client không truyền được, **kèm tác dụng phụ hạ `IsActive`** mọi bản cùng `ApplicableType` (`:331-334`).
⇒ (7) = **XÂY MỚI một lệnh chưa từng tồn tại**, không phải nới rào.

FE: Designer chỉ ở **`fe-admin/…/ApprovalWorkflowsV2Page.tsx`** (fe-user `WorkflowMatrixViewPage.tsx` chỉ-xem). **Không có nút "Sửa"** — chỉ **"Nhân bản"** (`:479`) mở dialog *"Tạo quy trình mới"* (`:638`), toast *"Version cũ đã archive"* (`:611`). Admin **có** thấy Version + trạng thái.
🔴 **0 cảnh báo "đang được dùng", 0 disable khi `IsActive`.** Designer HĐ **V1 CÓ** badge *"N HĐ còn chạy"* — **V2 không port**.

## F.2 🔴 QUY TẮC PHÂN LOẠI (rút gọn, kiểm được bằng máy)

**AN TOÀN ⟺ thoả CẢ HAI phép thử:**

> **(T1) Khung không đổi** — tập `{Step.Order}` và tập `{Level.Order}` giữ nguyên.
> **(T2) Luật chơi không đổi** — không đụng trường quyết định **đường đi** của phiếu. Hiện tập này = `{CeoApprovalThreshold}`.

🔴 **KHAI GIỚI HẠN (vá M1 — đừng đọc quy tắc mạnh hơn nó thật):** (T1) chỉ bảo đảm **con trỏ không dịch** ⇒ phiếu không kẹt. Nó **KHÔNG** bảo đảm **ngữ nghĩa** và **hiển thị** không đổi. Ba phản ví dụ đều giữ nguyên cả 2 tập Order mà vẫn hỏng: cờ F5 đổi điểm kết thúc · fallback `.First()` gắn chữ ký sang người khác · thêm người vào Cấp đã qua thì hiện "Done" dù chưa ký. Vì vậy bảng dưới có hạng **⚠️ NỬA**, và (T2) tồn tại.

Lý do **(T1)**: con trỏ phiếu là **LAI** — `CurrentWorkflowStepIndex` dùng **INDEX** vào list đã sort (`:686`); `CurrentApprovalLevelOrder` so **GIÁ TRỊ Order** (`:694`). Giữ nguyên 2 tập Order ⇒ **không con trỏ nào dịch**.

🔴 Lý do **(T2)** — **owner chốt 2026-07-27, và nó SỬA quy tắc chứ không chỉ điền một ô.** Bản đầu của quy tắc chỉ có (T1), nên `CeoApprovalThreshold` (0 đụng Order) **tự động rơi vào AN TOÀN**. Owner xếp nó **PHÁ VỠ** ⇒ lộ ra rằng "không dịch con trỏ" **chưa đủ** để gọi là an toàn: một thay đổi có thể **không làm kẹt phiếu nào** mà vẫn **đổi luật chơi giữa chừng** (phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO"). Đó là an toàn **kỹ thuật** nhưng không an toàn **về niềm tin** — người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó.
⇒ **Bài học mang theo:** thêm trường mới vào workflow phải hỏi **cả hai** câu, không chỉ câu con trỏ. Trường nào lái đường đi của phiếu thì thuộc (T2) dù không đụng Order.

### Bảng 13 thao tác

| # | Thao tác | Hạng | Lý do (mã) |
|---|---|---|---|
| 1 | **Thêm người vào Cấp có sẵn** (row trùng `Order`) | ✅ AN TOÀN | `levelGroups` giữ nguyên khoá; người mới duyệt được ngay, người cũ không mất gì (`:689-695`, `:702`) |
| 2a | **Cờ QUYỀN-TẠI-CHỖ**: F1 4 mode `AllowReturn*` · F3 `AllowApproverEditDetails` · F4 `AllowApproverEditBudget` · **F6 `AllowApproverDelete`** | ✅ AN TOÀN | chỉ đổi **người đó được làm gì tại chỗ họ đứng**; 0 đụng Order, 0 đổi đường đi phiếu. 🔴 F6 **buộc** phải ở hạng này — nếu không thì §F.0 vô nghiệm |
| 2b | **Cờ ĐỔI-ĐƯỜNG-ĐI**: **F5 `AllowApproverFinalize`** | ❌ **PHÁ VỠ** *(owner chốt sau review M1)* | Qua (T1) nhưng **trượt (T2)** — đổi **ĐIỂM KẾT THÚC**: bật ⇒ người duyệt tại cấp đó **kết thúc luôn** `Phase = DaDuyet` (`:866`) kèm **đóng băng số tiền** `ApplyBudgetSnapshotOnFinalizeAsync` (`:865`) ⇒ phiếu đang chờ **không lên Ban Giám đốc nữa**. Nặng hơn `CeoApprovalThreshold` (cái kia còn cần CCM chủ động tích `:881-901`, F5 đọc thẳng `:859`) |
| 3 | **Đổi tên hiển thị** | ✅ AN TOÀN | display-only |
| 3b | Đổi `ApprovalWorkflow.Code` | ❌ PHÁ VỠ *(vá M2)* | vỡ chuỗi version — `nextVersion = MAX(Version) WHERE Code = …` (`:325-328`) ⇒ bản kế tiếp tụt về v1 hoặc đụng version cũ |
| 3c | Đổi `ApplicableType` | ❌ PHÁ VỠ *(vá M2)* | phiếu đang pin bỗng chạy workflow **module khác** (khớp loại **chỉ kiểm lúc pin** `PurchaseEvaluationFeatures.cs:258-265`, không re-validate) + vỡ bất-biến "1 active mỗi type" (`:331-334`) |
| 3d | **Thêm người vào Cấp ĐÃ QUA** | ⚠️ NỬA *(vá M1c)* | `ComputeLevelStatus` thuần con-trỏ (`:1135-1148` `stepIdx0 < currentIdx → "Done"`) ⇒ người mới render **"Done"** dù **chưa từng ký**, không có `LevelOpinion` nào. Phiếu `DaDuyet` còn tuyệt đối hơn (`:1137`) |
| 3e | **Thêm/bớt row cùng Cấp khi có khả năng Admin-duyệt-thay** | ⚠️ NỬA *(vá M1b)* | `matchingLevel` fallback `pendingLevelGroup.First()` (`:733-734`); row cùng `Order` **không có tie-breaker** ⇒ thứ tự do DB trả ⇒ "row đầu tiên" đổi ⇒ chữ ký admin gắn **sang Level row người khác** + UPSERT `LevelOpinion` khoá `(PE, LevelId)` ghi nhầm row |
| 4 | **Bớt người khỏi Cấp** | ⚠️ NỬA | con trỏ không dịch, NHƯNG người bị gỡ đang chờ → **403** in **GUID trần** (`:705`); row đã ký → **FK `Restrict` chặn**; Cấp rỗng người ⇒ **phiếu KẸT** |
| 5 | **Đổi `ApproverUserId`** | ⚠️ NỬA | như #4 **+** chữ ký cũ (`LevelOpinion` khoá theo `LevelId`) **đổi nghĩa IM LẶNG** — ý kiến người A hoá thành của người B |
| 6 | Thêm Cấp (`Order` mới) | ❌ PHÁ VỠ | đổi `maxLevelOrder`, chèn chặng |
| 7 | Xoá Cấp | ❌ PHÁ VỠ | `pendingLevelGroup` null → `ConflictException` `:695` ⇒ **phiếu KẸT** |
| 8 | Đổi `Order` Cấp | ❌ PHÁ VỠ | tương đương xoá+thêm với con trỏ |
| 9 | Thêm Bước | ❌ PHÁ VỠ | `steps[currentIdx]` trỏ **sang bước khác, ÂM THẦM** (`:686`) |
| 10 | Xoá Bước | ❌ PHÁ VỠ | số bước < `currentIdx` → `ConflictException` `:683` ⇒ **phiếu KẸT** |
| 11 | Đổi `Order` Bước | ❌ PHÁ VỠ | như #9 |
| 12 | Đổi `CeoApprovalThreshold` | ❌ **PHÁ VỠ** *(owner chốt)* | Qua **(T1)** nhưng **trượt (T2)** — đổi luật chơi giữa chừng: phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO". Người đã duyệt thì duyệt theo ngưỡng họ **nhìn thấy lúc đó** |
| 13 | Đổi `Step.DepartmentId` | ✅ **AN TOÀN** *(owner chốt)* | BE **không** dùng field này để khớp người duyệt (chỉ hint) ⇒ 0 phiếu kẹt. 🔸 Kèm việc: Designer FE **ép nhập** (`:573-575`) trong khi BE coi là optional — lệch sẵn có, xem F-17 |

🔴 **4 read-site con trỏ, mỗi cái vỡ MỘT KIỂU:**

| Read-site | Kiểu vỡ |
|---|---|
| `ResolveV2InboxIdsAsync` — hộp thư "Chờ duyệt" (`PurchaseEvaluationFeatures.cs:842-849`) | 🔴 **IM LẶNG**: `idx >= steps.Count → continue` ⇒ phiếu **biến mất khỏi màn Duyệt**, không lỗi |
| Flow-tree detail (`:1135-1191`) | tô sai Done/Current/Pending |
| Banner "Đến lượt bạn" (`:1223-1243`) | banner rỗng ⇒ FE mất gate `blockedByV2Level` |
| Badge "Kết thúc trước CEO" (`:672-677`) | giả định ẩn **`Step.Order - 1 == StepIndex`** — chỉ đúng khi Order liền mạch 1..N |

## F.3 ⚠️ BLAST RADIUS — không phải việc riêng của PE

`ApprovalWorkflow` V2 là schema **DÙNG CHUNG ≥6 module**: Proposal · Leave/OT · Travel/Vehicle · WorkflowApps · Contract (+PE). Hai con trỏ có **198 hit / 23 file**.
⇒ Test **bắt buộc phủ PE + ≥1 module Văn phòng số**.

## F.4 ✅ CHECKLIST hạng mục F

- [ ] **F-1.** `UpdateAwDefinitionCommand` mới — **CHỈ** nhận trường hạng ✅ (#1 #2 #3 #13). Trường hạng ❌ gửi lên ⇒ **từ chối kèm thông điệp bảo tạo version mới**
- [ ] **F-1a.** 🔴 **AUTHZ — KHÔNG ĐƯỢC QUÊN** *(vá H4 sau review)*: endpoint mới phải mang `[Authorize(Policy = "Workflows.Create")]`, khớp **cả 3** verb ghi hiện có của controller này (`ApprovalWorkflowsV2Controller.cs:29` POST · `:40` PATCH · `:48` DELETE); class chỉ `[Authorize]` trần (`:18`) nên **không tự bảo vệ**.
  > 🔴 Vì sao nghiêm trọng hơn endpoint thường: **cả 2 thao tác F-1 cho phép đều là hành vi CẤP QUYỀN.** (i) "thêm người vào Cấp" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708` ⇒ **duyệt được phiếu người khác**; (ii) "bật cờ `Allow*`" ⇒ gồm **chính F6** ⇒ **tự cấp quyền xoá** phiếu đang duyệt. Thiếu 1 dòng attribute = **leo thang đặc quyền cho mọi tài khoản đăng nhập**.
- [ ] **F-1b.** Test **403** cho user thường gọi endpoint Update
- [ ] **F-1c.** Ghi **vết** cho thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng quên đòi vết cho sửa quyền — bất đối xứng, review bắt)
- [ ] **F-2.** 🔴 Validator server-side thi hành **F.2 — CẢ HAI phép thử**: **(T1)** so tập `{Step.Order}` + `{Level.Order}` trước/sau · **(T2)** chặn thay đổi `CeoApprovalThreshold`. Vi phạm bất kỳ ⇒ **409**. Răng thật, KHÔNG dựa FE ẩn nút
- [ ] **F-17.** Designer FE **ép nhập** `Step.DepartmentId` (`:573-575`) trong khi BE coi là **optional** (chỉ hint) — lệch sẵn có, nay owner cho sửa field này tại chỗ nên phải chốt: nới FE cho optional, hay giữ ép. Hỏi lại nếu không rõ, ĐỪNG tự đổi
- [ ] **F-3.** 🔴 **CẤM delete-then-insert Levels** — **diff theo `Level.Id`**. Replace-all sẽ **nổ FK `Restrict`** trên workflow đang chạy + mồ côi `LevelOpinion`
- [ ] **F-4.** Update **KHÔNG** tăng `Version`, **KHÔNG** đụng `IsActive`, **KHÔNG** đổi `Id` — giữ Id là toàn bộ mục đích
- [ ] **F-5.** Hạng ⚠️ NỬA (#4 #5 #3d #3e): cho phép nhưng **cảnh báo trước** + chặn khi row đã có `LevelOpinion` (đón trước FK) + chặn khi Cấp sẽ **rỗng người**
  - [ ] **F-5a.** 🔴 Đếm `LevelOpinion` phải **LOẠI TRỪ** opinion thuộc PE đã `IsDeleted` *(vá M3)*. Lý do: xoá mềm ⇒ cascade **không chạy** (`AuditingInterceptor.cs:56-62` đổi `Deleted→Modified`) ⇒ opinion của phiếu đã xoá **ở lại DB** và **vẫn Restrict** FK. Không loại trừ ⇒ admin bị **chặn thật** khỏi việc bỏ người ra khỏi Cấp, **chỉ vì một phiếu đã bị xoá**.
  > 🔴 **Lượt này làm nợ N1 TỆ HƠN, phải khai:** *trước* đây rác chỉ sinh từ phiếu **Nháp** (hiếm có `LevelOpinion`); *sau* tính năng này mọi phiếu bị xoá đều ở **`ChoDuyet`** ⇒ **chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức ta vừa dựng luật trên đúng cái trục mà ta đang làm xấu đi.
- [ ] **F-6.** Sửa message 403 `:705` — đang in **GUID trần**, đổi sang **tên người**
- [ ] **F-7.** Designer fe-admin: thêm nút **"Sửa"** (khác "Nhân bản"), chỉ mở trường hạng ✅
- [ ] **F-8.** Port badge **"N phiếu còn chạy"** từ Designer HĐ V1 sang V2 + cảnh báo trước khi sửa
- [ ] **F-9.** `DeleteAwDefinitionCommandHandler` — thêm **usage-check** (nợ tự khai `:400-402`); hiện xoá nhầm ⇒ **FK 547 → 500**
- [ ] **F-10.** Vá comment **SAI** `ApprovalWorkflow.cs:81-82` (*"KHÔNG OR-of-many"*) — mã + CLAUDE.md đều **OR-of-N**
- [ ] **F-11.** 🔴 Tick F6 ở Designer: `fe-admin:999-1007` (dưới F5) + **5 dây** `:54 / :110 / :159 / :177 / :605` — ⚠️ **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình) + `fe-user:282` + BE `:41 / :230 / :188 / :368`

### Test hạng mục F
- [ ] **F-12.** Update hạng ✅ trên workflow **đang có phiếu chạy** ⇒ phiếu **nhận thay đổi ngay** (bài kiểm chứng F.0)
- [ ] **F-13.** Update đụng tập Order ⇒ **409**, workflow **không đổi**
- [ ] **F-14.** Bớt người mà row đã có `LevelOpinion` ⇒ chặn **lịch sự** (409), KHÔNG nổ FK 547
- [ ] **F-15.** 🔴 Tick F6 bằng Update ⇒ phiếu `ChoDuyet` **đang treo từ trước** xoá được (nối F với A-E)
- [ ] **F-16.** Regression ≥1 module Văn phòng số (Leave/OT hoặc Proposal)

## F.5 Nợ khai thêm
| # | Nợ | Ghi chú |
|---|---|---|
| N5 | Designer V2 **0 cảnh báo đang-dùng, 0 disable** khi `IsActive` — V1 có, V2 không port | F-8 vá |
| N6 | `DeleteAwDefinition` 0 usage-check ⇒ FK 547 → 500 | F-9 vá |
| N7 | Comment `ApprovalWorkflow.cs:81-82` sai (OR-of-N) | F-10 vá |
