Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md
2026-07-27 09:39:31 +07:00

179 lines
16 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ó)
- **Cấp đó có `AllowApproverDelete == true`**
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
### 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-Cấp (mirror chỗ tick F5 `AllowApproverFinalize`)
- [ ] **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.** `[Authorize(Policy = "PurchaseEvaluations.Delete")]` lên action Delete — 🔴 **KHÔNG** dùng `Pe_*` (policy không tồn tại)
- [ ] **8.** Endpoint + handler **RIÊNG** list "Đã xóa" với `IgnoreQueryFilters()` — tuân D3
### 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.** `PeWorkflowPanel.tsx` — nút Xóa cạnh `next.map`, gate E4
- [ ] **15.** `PurchaseEvaluationsListPage.tsx` — bỏ chặn `readOnly` cho riêng nút Xóa (dây `onDelete` đã sẵn)
- [ ] **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`**cả 4** phép cộng lũy kế giảm đúng (`PrevSubmitted`, `PrevSelected`, `PendingSubmitted`, `PendingSelected`)
- [ ] **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` — Mig mới, Menu keys, Policies
- [ ] **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.