16 KiB
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ọifile:linedướ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+CurrentApprovalLevelOrderkhớpApproverUserId(khuônApproveV2Asyncđã 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):
MenuKeys.cs:134-141— 2 factory const mớiDbInitializer.cs:1874-1877— 2 dòngtree.AddDbInitializer.cs:1917-1923— entrylabelBackfillcho renameDbInitializer.cs:2092-2096VÀ: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ệchLayout.tsxregex —fe-user:120vàfe-admin:107docs/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ờ
includeDeletedvà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, defaultfalse) — 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— injectICurrentUser - 5. Nới allow-list: thêm nhánh
ChoDuyetkèm điều kiện (đúng lượt ∧ F6).DangSoanThaogiữ nguyên - 6. Ghi changelog
ChangelogAction.Deletetheo khuônLogTransitionAsync(+ chỗ chứa lý do) - 7.
[Authorize(Policy = "PurchaseEvaluations.Delete")]lên action Delete — 🔴 KHÔNG dùngPe_*(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.
DbInitializertree:1874-1877— 2 dòngtree.Add(× số type code) - 11.
DbInitializerlabelBackfill— entry renameDuyệt→Đang duyệt🔴 bước này KHÔNG được bỏ, sửatree.Addlà vô tác dụng - 12.
DbInitializer:2092-2096VÀ: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ạnhnext.map, gate E4 - 15.
PurchaseEvaluationsListPage.tsx— bỏ chặnreadOnlycho riêng nút Xóa (dâyonDeleteđã sẵn) - 16.
Layout.tsxregex — nới cho 2 key mới, và vá lệchfe-admin:107thiếuWfView - 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.tsxvẫ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.Deletegọi thẳng endpoint ⇒ 403 (không chỉ ẩn nút — gotcha #82) - 26. Changelog có bản ghi
Deletesau khi xóa
Docs
- 27.
docs/STATUS.md— Mig mới, Menu keys, Policies - 28.
docs/gotchas.md— bẫylabelBackfill(sửatree.Addvô tác dụng) + bẫyPe_*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.