Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md
2026-07-27 10:30:24 +07:00

318 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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****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`**ă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``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`**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`**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á N1N4.
---
# 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ẫ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****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ẫ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á |