179 lines
16 KiB
Markdown
179 lines
16 KiB
Markdown
# 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ó)
|
||
- **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` 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
|
||
|
||
### 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á N1–N4.
|