wal: flush 20260727T0939
This commit is contained in:
@ -25,7 +25,10 @@ chain:
|
||||
[x] A-owner-chốt 4 ý: (1) trưởng phòng xóa khi đến lượt (2) xóa MỀM (3) phiếu đang-duyệt VẪN xóa được, xóa→mục "Đã xóa"+hết ăn lũy kế (4) 🆕 MENU: Duyệt→"Đang duyệt" +"Đã duyệt" +"Đã xóa" — verbatim ở `run.md` §OWNER CHỐT
|
||||
[!] wf: A-product PE-delete-approver — run `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 3 Invest chạy nền: be-1 (Q4-Q7 resume) · fe-2 (Q3/Q5/Q6 resume) · menu-3 (MỚI: menu+Đã-xóa+trưởng-phòng)
|
||||
[!] 🔴 A-#53 ×2: be-1 + fe-2 CẢ HAI garble return (trả lát-cắt transcript). Đĩa cứu TRỌN: be-1 14.528B Q1-Q3 · fe-2 8.029B Q1/Q2/Q4 ⇒ ghi-đĩa-trong-lúc-làm CHỨNG MINH lần nữa. Đã SendMessage-resume cả 2
|
||||
[ ] A-S2: 3 Invest về → `spec-pe-delete-approver.md` + checklist → S3 reviewer → S4 hmw
|
||||
[x] A-S2 spec v1 XONG — `spec-pe-delete-approver.md` (4 hạng mục A-E + checklist 28 mục + 4 nợ khai N1-N4)
|
||||
[!] A-yêu-cầu-(7) MỚI anh giao giữa chừng: quy trình duyệt cho **sửa TẠI CHỖ** (thêm người / chỉnh quyền) thay vì ép tạo version mới; chỉ đổi CẤU TRÚC mới bắt buộc version mới → Invest #4 `sub-invest-wfver-4.md` đang chạy
|
||||
[ ] ⚠️ mâu-thuẫn CẦN GIẢI ở (7): nếu "nhiều người 1 Cấp" = nhiều bản ghi Level cùng Order thì "thêm người" CHÍNH LÀ "thêm Level" ⇒ ranh AN-TOÀN/PHÁ-VỠ của anh có thể tự mâu thuẫn ở tầng dữ liệu. W4 của Invest #4 phải trả lời
|
||||
[ ] A-S2-bis: Invest #4 về → bổ sung hạng mục F vào spec → S3 reviewer → S4 hmw
|
||||
[ ] A-ràng-buộc CỨNG cho spec: `IgnoreQueryFilters()` chỉ mở ĐÚNG 1 endpoint list "Đã xóa" — rộng tay = phiếu xóa lọt lại `PeBudgetAccumulator` = phá đúng ý (3) của anh
|
||||
|
||||
next: [A] chờ 2 Invest về → viết file chi tiết + checklist → hỏi anh điểm quyết → reviewer → hmw. || [B] CHỜ anh 3 số (42) canonRows · (43) carry con-trỏ-vs-slug · (44) END-line-thành-luật + (E2) harvest-curator 111,1% trần.
|
||||
|
||||
@ -10,6 +10,7 @@ Read-only INTERNAL audit SOLUTION_ERP. Tools: Read/Grep/Glob/Bash + 5 RAG MCP. O
|
||||
## 📌 Recent invest (1-line digest; full detail → archive/runs/git)
|
||||
- **S155 (07-27) PE xóa-phiếu màn DUYỆT [24 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md`]:** DELETE `/api/purchase-evaluations/{id}` ĐÃ CÓ (`PurchaseEvaluationsController.cs:146`, authz = class `[Authorize]` TRẦN, 0 policy action-level toàn file) nhưng guard allow-list `{DangSoanThao,TuChoi}` (`PurchaseEvaluationFeatures.cs:1404`) chặn ChoDuyet. `Remove()`=**SOFT** gián tiếp qua `AuditingInterceptor.cs:56-62` (Deleted→Modified+IsDeleted/DeletedAt/DeletedBy) — lớp #81-EXT indirect. Lũy kế: `PeBudgetAccumulator.cs` 2 hàm/4 call-site DUY NHẤT (0 read-site khác, Reports 0-hit PE); ChoDuyet ăn số ở **2 chỗ** — `ComputeAsync:54` PrevSubmitted(ChoDuyet+DaDuyet) + `ComputePendingAsync:114` PendingSelected(∉{Nháp,DaDuyet,TuChoi}+winner); `TraLai` KHÔNG triệt tiêu; `TuChoi` rơi khỏi 100% phép cộng. Không dòng `IsDeleted` nào trong query → dựa global `HasQueryFilter` (`PurchaseEvaluationConfiguration.cs:84`), `IgnoreQueryFilters`=0-hit ⇒ soft-del TỰ rớt, không sửa accumulator. Snapshot Mig 67 KHÔNG re-compute. 🔴 Cascade 6 FK (`:75-80`) **không bao giờ chạy** vì soft ⇒ con `BaseEntity` (no IsDeleted) ở lại — rác ẩn, chưa lộ vì mọi read-site join từ PE root. **Git khảo cổ TRÚNG:** "chỗ cho hủy" = nút Từ chối, gỡ `6db195d` 12-06-2026 S60 (anh Kiệt "Duyệt hoặc Trả về thôi"); guard `PurchaseEvaluationWorkflowService.cs:101-106` còn sống + tự trỏ "Xóa phiếu khi còn Bản nháp" = **chính cái lỗ** S155 phải vá. Q7: 0 tiền lệ hủy chứng-từ in-flight; khuôn gần nhất `CancelMeetingBookingHandler` (`MeetingFeatures.cs:457`, Status=Cancelled NOT IsDeleted + owner-or-admin check). Tag `[s155, pe-delete-approver, s60-tuchoi-removal-hole, soft-delete-no-cascade]`
|
||||
- **S155 (07-27) PE xóa-phiếu FE/authz-slice [18 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md`]:** Khối HÀNH ĐỘNG = `components/pe/PeWorkflowPanel.tsx:458-506` **byte-identical 2 app** (md5 `02671be6…`, cùng PeDetailTabs/PeListPanel/ListPage) ⇒ sửa-1-bên-là-sót. Gate 3 tầng: `?pendingMe=1`→`readOnly={!pendingMe}` (`ListPage:590`/`:676`) · `next=workflow.nextPhases` BE-source (`:257`) · `blockedByV2Level` (`:99-103` isAdmin ∥ actor∈currentApproval.approvers). 🔴 Plumbing xóa ĐÃ NỐI SẴN vào màn duyệt: `onDelete={del.mutate}` truyền ở `ListPage:573`+`:668` (`api.delete('/purchase-evaluations/:id')` `:90`) nhưng nút chết vì `PeDetailTabs:445` đòi `mode==='workspace' && canEditPhase && !readOnly`; nút hiện chỉ ở `:460` `phase===DangSoanThao`. **Authz 2 tầng:** PE vùng FE = **0-hit** `usePermission|PermissionGuard` (guard chỉ sống ở Master+Users, khuôn `DepartmentsPage:101 action="Delete"`); `menuKeys.ts` KHÔNG có const `Pe_*` (chỉ root `:23`) — `Pe_*` sinh BE `MenuKeys.cs:134-141`, seed `DbInitializer:1870-1877`, FE khớp bằng regex `Layout.tsx:120` (fe-admin `:107` **LỆCH**, thiếu WfView). 🔴 `Pe_*` **KHÔNG ∈ `MenuKeys.All`** ⇒ `Program.cs:82-89` không đăng-ký policy `Pe_*.{action}` (0 dynamic provider) ⇒ dùng `[Authorize(Policy="Pe_…")]` = policy-chưa-tồn-tại; **dùng được ngay = `PurchaseEvaluations.Delete`** (root ∈ All, + `GetMyMenuTreeQuery:66-72` cho `Pe_*` KẾ THỪA cờ CRUD từ root ⇒ 0 key mới, 0 migration). Ma trận enumerate `db.MenuItems` (`PermissionFeatures.cs:20`) nên ô Delete đã hiện sẵn. Q6: thêm-nút KHÔNG kích 4-place (0 route/page/staticMap `Layout.tsx:56-106`); `Pe_*` resolve nhánh regex `:119-134` KHÔNG qua staticMap. Tag `[s155, pe-delete-fe-authz, two-app-byte-identical, pe-keys-not-in-All]`
|
||||
- **S155 (07-27) PE menu/trash/dept-head [21 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md`]:** 🔴 Menu = **SEED DB** không hard-code FE — 4 mục con `Pe_<Code>` khai `DbInitializer.cs:1874-1877` (`"Duyệt"`=`:1877`); thêm mục = restart API, **0 migration** (upsert `:1888-1914` chỉ sửa `Order`, **KHÔNG đụng Label** ⇒ rename phải qua `labelBackfill:1918-1924` HOẶC `DisplayLabel` admin-UI mà `MenuDtos.cs:14` ghi rõ **CHỈ ăn fe-user**, fe-admin luôn Label gốc). 6-chỗ-mirror (không phải 4): +2 seed permission SONG SONG `:2092-2096`/`:2492-2496`; `menuKeys.ts` FE **0-hit `Pe_*`** (sinh regex `Layout.tsx:120`, fe-admin `:107` thiếu `WfView` = lệch sẵn). Đổi KEY = mồ côi **13 row/key × 10 key** (Permissions Dev) ⇒ giữ key chỉ đổi label. **`Đã duyệt` chạy được HÔM NAY**: `phase=` đã đủ 3 tầng (`Controller:23`→`Features:565`→`:620`), DaDuyet=7, FE `:39/:75/:395` sẵn ⇒ `?type=1&phase=7`, 0 route mới. `pendingMe` KHÔNG phải param BE — FE rẽ `/inbox` (`ListPage:61-68`) rồi lọc DaGuiDuyet client-side `:182`. Tự verify: `IgnoreQueryFilters` **0** src/Backend nhưng **15 hit tests** (khuôn `Suppliers.IgnoreQueryFilters().CountAsync()` `SupplierImportV2DedupTests`); `b.HasQueryFilter(`=12. An-toàn accumulator = **cấu trúc**: `PeBudgetAccumulator.cs:42`/`:100` dựng `peers` FRESH mỗi lần ⇒ chỉ rò nếu helper-dùng-chung/sửa `:84`/tái-dùng-đường-snapshot. 🎁 Changelog `: BaseEntity` (no IsDeleted) + query lọc CHỈ `PurchaseEvaluationId` (`:1424`) ⇒ lịch sử phiếu-xóa đọc được MIỄN PHÍ; cái chôn là DETAIL bundle (chỗ `IgnoreQueryFilters` thứ 2). 0 tiền lệ thùng-rác/restore (`IsDeleted=false` 0-hit; hit "khôi phục" duy nhất = STRING `EmployeeFeatures.cs:162` = ghost-wire). 🔴 **"Trưởng phòng" CÓ TÊN, CHƯA CÓ ĐỜI**: `Department.ManagerUserId` (`Department.cs:9` cmt "TPB") = **0/10 Dev** + **2 FE hardcode `managerUserId: null` ở payload SỬA** (`DepartmentsPage.tsx:43`/`:57`) ⇒ tự-xoá sau mỗi lần sửa phòng; 5 hit BE toàn DTO-passthrough. `Level.Order`=trình-tự KHÔNG thứ-bậc (`ApprovalWorkflow.cs:86`); "Kết thúc tại Cấp 2"=`AllowApproverFinalize` F5. `PositionLevel.TruongPhong` **33/33 NULL** (V1-legacy, V2 không đọc); role `DeptManager` 8 user nhưng không nói PHÒNG. 3 lối: ManagerUserId(0-mig,vá-2-FE+nhập-tay) · cờ `AllowApproverDelete` per-Level (1 AddColumn, **khớp khuôn F2/F3/F4/F5 4× cùng bảng**) · role∧DeptId(có-data,nghĩa-lỏng). Tag `[s155, menu-seed-db, phase-param-exists, changelog-survives-softdelete, dept-head-name-only]`
|
||||
- **S153 (07-26) ctx soft-memory spec [fable-real]:** thư `0df10df4` sàn-7 → spec 9-task/24-check (`runs/2026-07-26-S153-ctx-adopt/sub-fable-invest-S153.md`). Findings đắt: SE **0 content-ts-reader sống** (session_ctx.py = git-ts+mtime; C10 tự khai thiếu nguồn LATENESS) ⇒ rào-2 canonical `_TS_KEY_RE` đặt session_ctx.py, máy-7-phép = EXTEND python (tái dụng `_SECRET_RES` 1-nguồn) KHÔNG .ps1 mới (2-sự-thật); `secrets_sweep` rglob TRỌN session-dir ⇒ `_mind` tự vào lưới @snapshot §3; `_mind` mới-nhất-ở-TRÊN ⟂ `_context` FLOW ở-CUỐI (bẫy chép-khuôn); (36)(b) máy-$0/pause + vai @2-bookend ⇒ O-1 carve-out KHỎI CẦN (pause §1 NGỪNG-spawn giữ nguyên); C11(b) filter `sub-*.md`+`^sub-<role>[-.]` ⇒ đặt tên `sub-ctx-audit-*` lọt lưới ngay (kẽ trio `harness-*-return` đã queue #8 fix); roster 21 > hmw_width 20 = owner-note; (35)-(38) vết OWNER-DELEGATED khuôn `hmw_width._ratified_by`. Tag `[s153, ctx-mind-spec, ts-reader-absent, one-source-regex]`
|
||||
- **S148 (07-23) PE tree DUP project by-space:** `FLOCK 01`/`FLOCK01` = **path-A TWO real Project rows** diff-by-space. Tree groups on `p.projectId` GUID (`PurchaseEvaluationsListPage.tsx:192-197`, display projectCode); PE→Project FK `ProjectId` (`PurchaseEvaluation.cs:15`) JOIN-resolved (`PeFeatures.cs:579/:647`) — NO denorm string. CRUD zero-trim (`ProjectFeatures.cs:80` validator, `:95/:138` exact `Code==` dupe-check). Both seeds exact-Code key emit ONLY no-space: `SeedRealMasterDataAsync:2776` (UNGATED :128) + `SeedDemoMasterDataAsync:2681` (gated :117 OFF-prod). Space=legacy/manual. Blast=3 tables ProjectId (Contracts/PeWorkItemBudgets/PurchaseEvaluations); only `PeWorkItemBudgets` UNIQUE(ProjectId,WorkItemId) filtered [IsDeleted]=0 (`PeWorkItemBudgetConfiguration.cs:27`). Direction: KEEP no-space (in seed→idempotent), RETIRE space (not-in-seed); soft-del survivor→seed RE-CREATES (#75/#76). Dev only FLOCK 01 pair (FLOCK03/CAL01 space-twins prod-only); prod UNVERIFIED (SSH dies on SQL-client, S134). Tag `[s148, pe-tree-dup-project-by-space, path-A-two-rows, no-normalize-crud]`
|
||||
- **S147 (07-23) PE attach TraLai edit-block:** FE-ONLY block, BE zero phase-guard (`PurchaseEvaluationsController.cs:15` class `[Authorize]` only; handlers check exists only `PurchaseEvaluationAttachmentFeatures.cs:66/:160`). Gate = raw `readOnly` PROP not phase (`GeneralAttachmentsSection` :3555/:3577 hide-when-readOnly); List detail HARDCODE `readOnly=true` (:574/:669) ⇒ TraLai loại khỏi SCREEN không khỏi editable-SET (workspace `PeListPanel.tsx:88` isEditablePhase GỒM TraLai). fe-admin MIRROR. BE no-guard CỐ Ý (S78). Fix FE: `attachEditable=isDrafter&&isEditablePhase`. Tag `[s147, pe-attach-tralai, fe-only-screen-gate]`
|
||||
|
||||
@ -79,3 +79,24 @@ NHƯNG cùng filter đó làm phiếu xóa **vô hình với MỌI query** ⇒ m
|
||||
- [~] S1 — 2 Invest BE/FE: đã cứu từ đĩa Q1-Q3 (BE) + Q1,Q2,Q4 (FE); **cả 2 dính #53**, đã SendMessage-resume làm nốt
|
||||
- [ ] S1-bis — Invest #3 lát cắt MỚI: menu restructure + "Đã xóa" list + "người đứng đầu phòng"
|
||||
- [ ] S2 — spec + checklist · [ ] S3 — reviewer · [ ] S4 — hmw Opus 5 MAX
|
||||
|
||||
---
|
||||
|
||||
## 🔴 OWNER CHỐT — BỔ SUNG (2026-07-27, lượt 2)
|
||||
|
||||
**(5) Quyền xóa** = cờ **`AllowApproverDelete` per-Cấp-duyệt** (AskUser). Owner bác phương án `Department.ManagerUserId`.
|
||||
🔴 KHAI: đổi nghĩa so với chữ đầu *"người đứng đầu phòng"* → thành *"cấp nào admin tick"*. Owner chọn với trade-off ghi rõ trên bàn. KHÔNG phải hiểu nhầm.
|
||||
**(6) Màn "Đã xóa" = CHỈ XEM**, chưa làm khôi phục (AskUser).
|
||||
|
||||
**(7) 🆕 QUY TRÌNH DUYỆT — sửa TẠI CHỖ vs bắt buộc TẠO MỚI** *(anh, verbatim)*:
|
||||
> *"Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt buộc tạo mới."*
|
||||
|
||||
⇒ Bài toán = **PHÂN LOẠI THAY ĐỔI** thành 2 lớp:
|
||||
- **Lớp AN TOÀN (sửa tại chỗ, giữ nguyên Version):** thêm/bớt NGƯỜI trong một Cấp · điều chỉnh QUYỀN (các cờ F1-F6)
|
||||
- **Lớp PHÁ VỠ (bắt buộc Version mới):** đổi CẤU TRÚC quy trình — thêm/bớt/đổi thứ tự Bước hoặc Cấp
|
||||
|
||||
🔗 **Đụng thẳng spec đang viết:** tick cờ **F6 `AllowApproverDelete`** = "điều chỉnh quyền" ⇒ thuộc **lớp AN TOÀN** ⇒ phải sửa được tại chỗ trên workflow đang có phiếu chạy. Nếu không thì mọi lần bật quyền xóa lại đẻ 1 version mới.
|
||||
|
||||
**Nền đã có (lead peek):** `ApprovalWorkflow.Version` (int, monotonic per `Code`) · `IsActive` · `ActivatedAt` · `IsUserSelectable`; note `ApprovalWorkflowV2AdminFeatures.cs:402` *"Sau UAT khi link với PE/Contract thật cần check usage trước khi delete"* ⇒ usage-check là việc còn treo.
|
||||
|
||||
- [ ] S1-ter — Invest #4: cơ chế versioning hiện hành + ranh AN-TOÀN/PHÁ-VỠ + phiếu đang chạy chịu ảnh hưởng gì
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
# 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.
|
||||
@ -58,4 +58,199 @@ grep -rn "Pe_DuyetNcc|Pe_\$\{" fe-user/src/lib/menuKeys.ts fe-admin/src/lib/menu
|
||||
```
|
||||
⇒ FE mirror const chỉ có key top-level; `Pe_*` sinh động qua regex trong `Layout.tsx`. **Không phải chỗ phải sửa.**
|
||||
|
||||
</content>
|
||||
### A-4 `[CODE]` Đổi tên `Duyệt` → `Đang duyệt`: **2 đường, 1 rủi ro thật**
|
||||
|
||||
**Đường rẻ nhất (0 code):** admin sửa `DisplayLabel` qua `/system/menu-visibility` (`fe-admin/src/pages/system/MenuVisibilityPage.tsx` → `PATCH` `MenusController.cs:26-32`). ⚠️ **CHỈ ăn ở fe-user** — `MenuDtos.cs:14` ghi rõ *"override label cho fe-user; fe-admin luôn render Label gốc"*; fe-user đọc `effectiveLabel()` (`Layout.tsx:161-163`), fe-admin không có hàm này.
|
||||
**Đường code:** thêm `[Pe_<code>_Pending] = "Đang duyệt"` vào `labelBackfill` (`DbInitializer.cs:1918-1924`) — vì vòng upsert `:1893-1904` **KHÔNG update Label** row đã tồn tại. Sửa mỗi `:1877` là **KHÔNG ăn trên DB đã seed**.
|
||||
|
||||
🔴 **Rủi ro nếu ĐỔI KEY (không chỉ label):** đo trên Dev DB —
|
||||
```
|
||||
sqlcmd -d SolutionErp_Dev -Q "SELECT MenuKey,COUNT(*) FROM Permissions WHERE MenuKey LIKE 'Pe[_]%' GROUP BY MenuKey"
|
||||
→ 10 key × 13 row (13 role) mỗi key, gồm Pe_DuyetNcc_Pending = 13 row
|
||||
```
|
||||
⇒ Permission row là **explicit, không phải chỉ dựa inherit**. Đổi key = 13 row/key thành mồ côi ⇒ role mất quyền (rơi về inherit từ root `PurchaseEvaluations` — có cứu, nhưng cờ CRUD thành cờ của ROOT chứ không phải cờ đã tinh chỉnh). **Khuyến nghị: GIỮ key `Pe_*_Pending`, chỉ đổi label.**
|
||||
|
||||
### A-5 `[CODE]` Cả 2 app đều có menu này — nhưng **lệch 1 nhánh**
|
||||
|
||||
fe-user `Layout.tsx:120` regex `(List|Create|Pending|WfView)` · fe-admin `Layout.tsx:107` regex `(List|Create|Pending)` — **fe-admin THIẾU `WfView`** ⇒ leaf "Luồng duyệt" resolve `null` → `MenuLeaf` drop im lặng bên admin. Thêm leaf mới phải sửa **cả 2**, và biết trước là 2 file này **đã lệch sẵn** (không byte-identical như các file PE khác).
|
||||
|
||||
---
|
||||
|
||||
## Q-B — Dựng 2 màn `Đã duyệt` + `Đã xóa`
|
||||
|
||||
### B-1 `[CODE]` Màn `Duyệt` KHÔNG lọc phase ở BE — nó gọi **endpoint khác** rồi lọc ở FE
|
||||
|
||||
`pendingMe=1` **không phải** tham số BE. FE rẽ nhánh sang endpoint inbox:
|
||||
|
||||
```ts
|
||||
// fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx:61-68
|
||||
if (pendingMe) {
|
||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||
params: { type: typeFilter ?? undefined, approvalWorkflowId: approvalWorkflowId || undefined },
|
||||
})
|
||||
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
|
||||
}
|
||||
```
|
||||
BE = `GetMyPurchaseEvaluationInboxQuery` (`PurchaseEvaluationFeatures.cs:715-771`), controller `PurchaseEvaluationsController.cs:30-35`. **Không có `Phase == ChoDuyet` đơn lẻ** — mà là 2 nhánh OR (`:762`):
|
||||
```csharp
|
||||
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
|
||||
```
|
||||
`v2InboxIds` = `ResolveV2InboxIdsAsync(userId)` (`:748`) — phiếu V2 mà actor là approver **Cấp hiện tại**.
|
||||
Lọc "Đã gửi duyệt" cứng nằm ở **FE**, sau khi nhận data:
|
||||
```ts
|
||||
// :182-183
|
||||
const filtered = pendingMe
|
||||
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
|
||||
```
|
||||
`[SUY LUẬN]` ⇒ màn `Đang duyệt` giữ nguyên 100%, chỉ đổi nhãn menu. Không đụng BE.
|
||||
|
||||
### B-2 `[CODE]` `Đã duyệt` = **ĐÃ CHẠY ĐƯỢC HÔM NAY**, 0 dòng BE, 0 route mới
|
||||
|
||||
Tham số `phase=` đã tồn tại đủ 3 tầng:
|
||||
| Tầng | file:line |
|
||||
|---|---|
|
||||
| Controller | `PurchaseEvaluationsController.cs:23` `[FromQuery] PurchaseEvaluationPhase? phase = null` |
|
||||
| Query record | `PurchaseEvaluationFeatures.cs:565` `PurchaseEvaluationPhase? Phase = null` |
|
||||
| Áp filter | `PurchaseEvaluationFeatures.cs:620` `if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase)` |
|
||||
| FE đọc URL | `PurchaseEvaluationsListPage.tsx:39` `sp.get('phase')` → `:75` truyền xuống API |
|
||||
| FE đã có dropdown | `:389-403` — option "Đã duyệt" map `String(PurchaseEvaluationPhase.DaDuyet)` (`:395`) |
|
||||
|
||||
`DaDuyet = 7` (`PurchaseEvaluationPhase.cs:23`).
|
||||
⇒ **URL `/purchase-evaluations?type=1&phase=7` chạy ngay.** Việc phải làm = 1 key menu + 1 nhánh `resolvePath` (×2 app) + 1 dòng seed. **Ô #5 bảng A-3 = 0 chỗ** (không route mới, không page mới).
|
||||
⚠️ 1 khác biệt UX: nhánh `phase` đi đường `list` (có phân trang `pageSize:50`, `readOnly={!pendingMe}` `:590`/`:676` ⇒ **detail read-only**) — khác nhánh inbox (không phân trang).
|
||||
|
||||
### B-3 `[CODE]` Xác minh 2 con số bằng lệnh của chính tôi
|
||||
|
||||
```bash
|
||||
grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs | wc -l → 0
|
||||
grep -rn "b\.HasQueryFilter(" src/Backend --include=*.cs | wc -l → 12 (20 hit thô, 8 là comment)
|
||||
grep -rn "IgnoreQueryFilters" tests --include=*.cs | wc -l → 15 ← BẤT NGỜ, xem B-4
|
||||
```
|
||||
✅ Cả 2 con số sub BE báo đều **ĐÚNG**: `IgnoreQueryFilters` = **0 hit toàn `src/Backend`**; PE global filter ở `PurchaseEvaluationConfiguration.cs:84`.
|
||||
|
||||
### B-4 `[CODE]` 🎁 Đã có **15 tiền lệ `IgnoreQueryFilters`** — nhưng ở **tests**, không phải production
|
||||
|
||||
`tests/.../Application/MasterCatalogFilteredUniqueTests.cs:71,102,134` · `PeWorkItemBudgetTests.cs:260` · `Services/SupplierExcelImportServiceTests.cs:78,174` · `Services/SupplierImportV2DedupTests.cs` (×9).
|
||||
Khuôn dùng: `await db.Suppliers.IgnoreQueryFilters().CountAsync()` — *"không có bản ghi phantom nào kể cả đã soft-delete"* (`SupplierExcelImportServiceTests.cs:78`).
|
||||
⇒ `[SUY LUẬN]` Đội đã quen cú pháp này; cái mới **chỉ là lần đầu ở tầng production**, không phải kỹ thuật lạ. Test cho màn "Đã xóa" có khuôn assert sẵn để bắt chước.
|
||||
|
||||
### B-5 `[CODE]` 🔴 Chỗ HẸP NHẤT + ranh giới an toàn (câu quan trọng nhất của Q-B)
|
||||
|
||||
**Vì sao an toàn về mặt cấu trúc:** `IgnoreQueryFilters()` là toán tử **trên MỘT `IQueryable`**, không phải cờ trên `DbContext`. Cả 2 hàm accumulator đều dựng `peers` từ `db.PurchaseEvaluations.AsNoTracking()` **mới tinh** mỗi lần gọi:
|
||||
- `PeBudgetAccumulator.cs:42-44` (`ComputeAsync`) — `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
|
||||
- `PeBudgetAccumulator.cs:100-102` (`ComputePendingAsync`) — y hệt, `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
|
||||
|
||||
⇒ **Không có đường rò** từ 1 query list sang accumulator, **trừ khi** vi phạm 1 trong 3 điều sau. Đây là ranh giới cần ghi vào checklist:
|
||||
|
||||
| 🔴 CẤM | Vì sao |
|
||||
|---|---|
|
||||
| Đặt `IgnoreQueryFilters()` trong **helper dùng chung** trả `IQueryable<PurchaseEvaluation>` mà accumulator/list cũng gọi | rò gián tiếp — biến 1 dòng thành N call-site |
|
||||
| Gỡ / sửa `HasQueryFilter` ở `PurchaseEvaluationConfiguration.cs:84` | phá toàn hệ, đúng thứ đang cho owner "xóa là hết ăn lũy kế" |
|
||||
| Bật `IgnoreQueryFilters` ở query **detail** rồi tái dùng cho snapshot/finalize | `PeBudgetAccumulator` được gọi từ cả display lẫn `ApplyBudgetSnapshotOnFinalizeAsync` (comment `PeBudgetAccumulator.cs:10-12`) — chung đường |
|
||||
|
||||
**Đề xuất hẹp nhất `[SUY LUẬN]`:** **query/handler RIÊNG** (vd `ListDeletedPurchaseEvaluationsQuery`) + endpoint riêng `GET /api/purchase-evaluations/deleted`, `IgnoreQueryFilters()` đặt **đúng 1 lần trong thân handler đó**. KHÔNG thêm cờ `includeDeleted` vào `ListPurchaseEvaluationsQuery` — vì handler đó đã có 4 filter + IDOR block (`:596-617`) + subquery projection (`:656-685`); nhét cờ vào = mọi call-site cũ đều thành đường có-thể-rò và phải review lại.
|
||||
|
||||
### B-6 `[CODE]` **KHÔNG có tiền lệ "thùng rác" nào trong repo** — nói thẳng
|
||||
|
||||
Lệnh đã chạy:
|
||||
```bash
|
||||
grep -rniE "Thùng rác|thung-rac|deleted=|showDeleted|includeDeleted" fe-user/src fe-admin/src → 0 màn thùng rác
|
||||
grep -rniE "IsDeleted\s*=\s*false" src/Backend --include=*.cs | grep -v Migrations → 0 dòng
|
||||
grep -rniE "Restore|Undelete|Khôi phục" src/Backend --include=*.cs → 1 hit DUY NHẤT, là chuỗi thông báo
|
||||
```
|
||||
Hit duy nhất: `EmployeeFeatures.cs:162` — `$"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."` ⇒ **câu chữ có, chức năng KHÔNG có** (ghost-wire loại "message nói về thứ chưa tồn tại"). Màn "Đã xóa" sẽ là **màn đầu tiên** loại này trong toàn hệ.
|
||||
|
||||
### B-7 `[CODE]` Restore: **0 cơ chế sẵn** — chỉ khảo sát, không chốt
|
||||
|
||||
Không endpoint nào set `IsDeleted=false` (lệnh B-6). Chiều ngược lại — `AuditingInterceptor.cs:56-62` chỉ chuyển `Deleted→Modified`, **không có nhánh un-delete**.
|
||||
Dữ kiện để owner quyết `[SUY LUẬN]`:
|
||||
- Restore = 1 `PATCH` set 3 field (`IsDeleted=false`, `DeletedAt=null`, `DeletedBy=null`) — **rẻ về code**;
|
||||
- nhưng phiếu restore **quay lại ăn lũy kế ngay** (accumulator không lọc gì thêm ngoài query filter) ⇒ đúng thứ owner muốn tránh sẽ quay lại. Nếu owner muốn restore thì phải nói rõ phiếu restore về **phase nào** (giữ `ChoDuyet` = ăn lũy kế lại · ép về `DangSoanThao` = không ăn theo `ComputePendingAsync:105` loại `DangSoanThao`).
|
||||
- ⚠️ `Suppliers`/`Projects`/`Departments` có UNIQUE **filtered `[IsDeleted]=0`** (Mig 47) — restore ở các bảng đó có thể đụng key trùng. **PE thì KHÔNG** (không có unique filtered trên `PurchaseEvaluations`) ⇒ riêng PE restore không vướng index.
|
||||
|
||||
### B-8 `[CODE]` 🎁 Vết xóa **KHÔNG bị chôn** — changelog sống sót, không cần `IgnoreQueryFilters`
|
||||
|
||||
`PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) — **BaseEntity, KHÔNG phải AuditableEntity** ⇒ **không có `IsDeleted`** ⇒ **không có query filter** (12 `HasQueryFilter` không có cái nào cho bảng này).
|
||||
Query lịch sử lọc **chỉ theo Id**, không join PE:
|
||||
```csharp
|
||||
// PurchaseEvaluationFeatures.cs:1424-1425
|
||||
return await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||
.Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||
```
|
||||
⇒ `ListPurchaseEvaluationChangelogsQuery` **chạy được nguyên vẹn cho phiếu đã xóa**, 0 sửa đổi.
|
||||
🔴 Cái BỊ chôn là **chỗ khác**: `GetPurchaseEvaluationQuery` (detail bundle) load PE root ⇒ dính filter ⇒ **404**. Nên: mở lại lịch sử = miễn phí; **mở lại DETAIL = phải `IgnoreQueryFilters` chỗ thứ 2**. Owner cần chốt: màn "Đã xóa" chỉ LIST (rẻ, 1 chỗ) hay LIST + mở được phiếu (2 chỗ).
|
||||
|
||||
---
|
||||
|
||||
## Q-C — "NGƯỜI ĐỨNG ĐẦU PHÒNG" map vào đâu?
|
||||
|
||||
### C-1 `[CODE]` 🎯 **CÓ TỒN TẠI** — `Department.ManagerUserId`, chú thích đúng chữ "Trưởng Phòng ban"
|
||||
|
||||
```csharp
|
||||
// src/Backend/SolutionErp.Domain/Master/Department.cs:9
|
||||
public Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
|
||||
```
|
||||
⇒ Giả thuyết **(b) ĐÚNG về schema**. Không cần migration để có field.
|
||||
|
||||
### C-2 `[CODE]` 🔴 NHƯNG field đó **RỖNG + bị FE chủ động ghi `null`** — đây là cái bẫy
|
||||
|
||||
```
|
||||
sqlcmd -d SolutionErp_Dev -Q "SELECT COUNT(*), SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) FROM Departments WHERE IsDeleted=0"
|
||||
→ TotalDept=10 · HasManager=0 (0/10)
|
||||
```
|
||||
Tệ hơn: **cả 2 app hardcode `managerUserId: null` trong payload SỬA**, nên mỗi lần ai đó sửa phòng ban là **xóa sạch** giá trị vừa nhập:
|
||||
- `fe-user/src/pages/master/DepartmentsPage.tsx:43` — `const payload = { id, code, name, managerUserId: null, note }`
|
||||
- `fe-admin/src/pages/master/DepartmentsPage.tsx:57` — `managerUserId: null,`
|
||||
BE thì ghi thẳng, không bảo vệ: `DepartmentFeatures.cs:217` `entity.ManagerUserId = request.ManagerUserId;`
|
||||
⇒ Dùng đường (b) thì **bắt buộc** vá 2 file FE trước, nếu không tính năng sẽ "tự tắt" sau lần sửa phòng ban đầu tiên. `[CHƯA XÁC MINH]` prod (SSH chết khi nạp SQL-client, S134/S148) — nhưng vì FE null-hoá nên khả năng prod cũng rỗng là cao.
|
||||
📌 0 read-site nào dùng `ManagerUserId` để phân quyền — grep toàn BE chỉ ra 5 hit, **tất cả là DTO passthrough** (`DepartmentFeatures.cs:15,126,140,144,164,173,217`).
|
||||
|
||||
### C-3 `[CODE]` Giả thuyết **(a) SAI** — `Level.Order` là *trình tự*, không phải *thứ bậc*
|
||||
|
||||
```csharp
|
||||
// ApprovalWorkflow.cs:81-88
|
||||
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. ... Sequential trong cùng bước: cấp 1 → cấp 2 → ...
|
||||
public int Order { get; set; } // 1-based trong cùng step
|
||||
```
|
||||
`Order` 1-based tăng dần = **ai duyệt trước/sau**, không mang nghĩa chức vụ. Nhiều row cùng `Order` = cùng 1 Cấp (đúng ảnh: Cấp 2 có 3 người). ⇒ "Cấp cao nhất trong Bước" **không phải khái niệm có sẵn**; muốn dùng phải TỰ định nghĩa `MAX(Order)` = trưởng phòng — `[SUY LUẬN]` đây là **suy diễn**, schema không hứa điều đó (một Bước có thể cấu hình ngược).
|
||||
Còn "Kết thúc tại Cấp 2" trong ảnh = cờ **`AllowApproverFinalize`** (`ApprovalWorkflow.cs:142`, F5 Mig 58) — là **quyền per-slot do admin tick**, KHÔNG phải dấu hiệu chức vụ.
|
||||
|
||||
### C-4 `[CODE]` Giả thuyết **(c): 2 ứng viên, 1 có data 1 không**
|
||||
|
||||
| Ứng viên | file:line | Data Dev |
|
||||
|---|---|---|
|
||||
| Role `DeptManager` | `AppRoles.cs:8` | **8 user** ✅ |
|
||||
| `PositionLevel.TruongPhong = 3` | `PositionLevel.cs:14` (chữ "TruongPhong" đúng nghĩa nhất) | **0 user — 33/33 NULL** ❌ |
|
||||
|
||||
```
|
||||
sqlcmd -d SolutionErp_Dev -Q "SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel" → NULL|33
|
||||
sqlcmd -d SolutionErp_Dev -Q "SELECT r.Name, COUNT(ur.UserId) FROM Roles r LEFT JOIN UserRoles ur ... GROUP BY r.Name"
|
||||
→ Drafter|10 · CostControl|8 · DeptManager|8 · Procurement|6 · ProjectManager|2 · Director|2 · ...
|
||||
```
|
||||
⚠️ `PositionLevel` là di sản **V1** (Mig 18/21): mọi read-site còn lại đều thuộc workflow V1 (`PeWorkflowAdminFeatures.cs:187,223,264` · `WorkflowAdminFeatures.cs:194,223,271`) + CRUD user (`UsersController.cs:81`). **`ApproveV2Async` không đọc nó.**
|
||||
⚠️ Role `DeptManager` **không nói PHÒNG NÀO** — phòng của user nằm ở `User.DepartmentId` (Dev: **31/33 có**). Nên (c) chỉ dùng được khi ghép `role DeptManager ∧ User.DepartmentId == Step.DepartmentId`. Đó là "một trưởng phòng nào đó của phòng này", không phải "ĐÚNG người đứng đầu".
|
||||
|
||||
### C-5 `[CODE]` Mảnh ghép "đến lượt họ" thì đã có sẵn và ĐÁNG TIN
|
||||
|
||||
- `Step.DepartmentId` (`ApprovalWorkflow.cs:75`) — Dev **6/6 step có DepartmentId** (`SELECT COUNT(*), SUM(CASE WHEN DepartmentId IS NOT NULL...) FROM ApprovalWorkflowSteps` → `6|6`).
|
||||
- Con trỏ hiện tại = `PE.CurrentWorkflowStepIndex` + `PE.CurrentApprovalLevelOrder`; sub FE đã đo `blockedByV2Level` (`PeWorkflowPanel.tsx:99-103`) = `isAdmin ∥ actor ∈ currentApproval.approvers`.
|
||||
⇒ Vế "đến lượt" **không phải làm mới**. Chỉ vế "người đứng đầu phòng" là thiếu.
|
||||
|
||||
### C-6 `[SUY LUẬN]` KẾT LUẬN Q-C + 3 chỗ thêm rẻ nhất (owner chọn, tôi không chốt)
|
||||
|
||||
**Kết luận:** khái niệm "người đứng đầu phòng" **có tên trong schema nhưng chưa có ĐỜI SỐNG**. `Department.ManagerUserId` là chỗ duy nhất mang đúng nghĩa — 0/10 dữ liệu, 0 read-site, và bị 2 file FE ghi đè `null`. Không có cờ nào trên `ApprovalWorkflowLevel` nói "người này là trưởng phòng". ⇒ **Viết spec được, nhưng phải chọn 1 trong 3 và trả giá tương ứng:**
|
||||
|
||||
| # | Cách | Migration? | Giá phải trả |
|
||||
|---|---|---|---|
|
||||
| **1** | Dùng `Department.ManagerUserId`, so với `Step.DepartmentId` của bước đang chờ | **KHÔNG** | phải vá 2 FE null-hoá (`DepartmentsPage.tsx:43` / `:57`) + thêm ô chọn TP + **nhập tay 10 phòng** trên prod; chưa nhập = nút không hiện với ai (fail-closed, an toàn) |
|
||||
| **2** | Thêm cờ per-slot `AllowApproverDelete` trên `ApprovalWorkflowLevel` | **CÓ** (1 AddColumn, no new table) | đắt hơn 1 migration nhưng **khớp khuôn đã lặp 4× ở đúng bảng này** (F2 Mig 31 · F3 Mig 29 · F4 Mig 30 · F5 Mig 58 — `ApprovalWorkflow.cs:104-142`); admin tick trong Designer, 0 phụ thuộc data master, đúng chỗ "đến lượt" đã có sẵn |
|
||||
| **3** | Role `DeptManager` ∧ `User.DepartmentId == Step.DepartmentId` | **KHÔNG** | có data ngay (8 user) nhưng **ngữ nghĩa lỏng**: 8 người mang role trên 10 phòng ⇒ có phòng nhiều "trưởng", có phòng không ai. Không phải "người đứng đầu" theo nghĩa owner nói |
|
||||
|
||||
`[CHƯA XÁC MINH]` — số liệu C-2/C-4 là **Dev DB**. Prod chưa đo được (SSH `vietreport-vps` đứt phiên mỗi khi nạp SQL-client, tái xác nhận S134/S148). Lệnh để implementer chạy trên prod:
|
||||
```sql
|
||||
SELECT COUNT(*) Total, SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) HasManager FROM Departments WHERE IsDeleted=0;
|
||||
SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel;
|
||||
```
|
||||
|
||||
<!-- END sub-invest-menu-3 · TOTAL=21 PHAT-HIEN -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user