wal: flush 20260727T1319
This commit is contained in:
@ -0,0 +1,65 @@
|
||||
# sub-d2-fe-3 — ĐỢT 2 FRONTEND, phần CHÍNH (nút Xóa màn duyệt + menu + màn "Đã xóa")
|
||||
|
||||
- **role:** implementer-frontend · **run:** `2026-07-27-S155-pe-delete-approver` · **lượt:** 3 (nối `sub-d2-fe-2.md`)
|
||||
- **scope:** `fe-admin/src/**` + `fe-user/src/**` ONLY. KHÔNG chạm `src/Backend/**`, KHÔNG chạm `tests/`.
|
||||
- **lượt trước để lại:** types ×2 app đã +`allowApproverDelete` (còn trong working tree, chưa commit).
|
||||
Mục "vá `WfView` cho fe-admin" → **lead chốt đường (c) GIỮ NGUYÊN** ⇒ bỏ hẳn khỏi phạm vi lượt này.
|
||||
|
||||
---
|
||||
|
||||
## 0. Nền đã ĐO lại lúc bắt đầu (không tin trí nhớ lượt trước)
|
||||
|
||||
| Đo | Kết quả |
|
||||
|---|---|
|
||||
| `git status` | BE lane ĐÃ LAND: `PeSoftDeleteFeatures.cs` (mới) + Controller + `MenuKeys.cs` + `DbInitializer.cs` (chưa commit) |
|
||||
| `PeWorkflowPanel.tsx` md5 ×2 app | `02671be6334110028c55fe82f5e70dee` IDENTICAL, 942 dòng |
|
||||
| `PurchaseEvaluationsListPage.tsx` md5 ×2 app | `99e38e167d2f745ea7628174f1d2d6bf` IDENTICAL, 712 dòng |
|
||||
| `Layout.tsx` | DIVERGE cố ý — admin `ca59f56c` 284L / user `cab674f4` 415L |
|
||||
| `lib/menuKeys.ts` | DIVERGE đúng 2 dòng (`Off_AttendanceReport` admin-only) |
|
||||
| `PurchaseEvaluationPhase.DaDuyet` | **= 7** ⇒ `phase=7` trong hợp-đồng route KHỚP (đo ở `types/purchaseEvaluation.ts:28`) |
|
||||
|
||||
### Hợp-đồng BE đọc TỪ MÃ (không tin bảng trong brief)
|
||||
- `[HttpDelete("{id:guid}/by-approver")]` + `[FromBody] DeleteByApproverBody? body` với `record DeleteByApproverBody(string? Reason)`
|
||||
⇒ lý do đi trong **BODY**, không phải query ⇒ FE dùng `api.delete(url, { data: { reason } })`.
|
||||
*(Lượt trước D2 định gửi CẢ query lẫn body cho chắc; nay đo được binding thật ⇒ gửi ĐÚNG 1 đường, bỏ query thừa.)*
|
||||
- `[HttpGet("deleted")]` trả `PagedResult<PurchaseEvaluationListItemDto>` — **DTO DÙNG CHUNG với list sống**.
|
||||
⇒ **KHÔNG có** `deletedAt` / `deletedBy` / `deletedByName`. Giả định D3 lượt trước (type mới 3 field optional) **SAI** ⇒ bỏ, tái dùng thẳng `PeListItem`, **0 type mới**.
|
||||
- `MenuKeys.cs` factory `Pe_{code}_Approved` · `Pe_{code}_Deleted` ĐÃ có; `DbInitializer` seed 2 mục + `labelBackfill` đổi `Duyệt`→`Đang duyệt` ĐÃ có.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔴 CHẶN THẬT — cờ F6 KHÔNG có đường ra tới FE (BE thiếu 1 member DTO)
|
||||
|
||||
Brief nói *"BE ĐÃ HIỆN THỰC XONG, lead tự verify"*. Đo bằng mã thì **verify đó sót đúng chỗ chết người** —
|
||||
lặp lại y hệt lớp lỗi đợt 1 (BE dựng, FE không nối được).
|
||||
|
||||
| Đo | Kết quả |
|
||||
|---|---|
|
||||
| `AllowApproverDelete` trong `ApprovalWorkflowOptionsDto` (`PurchaseEvaluationDtos.cs:123-131`) | ❌ **KHÔNG CÓ** — record đúng **8** member, dừng ở `AllowApproverFinalize` |
|
||||
| Chỗ dựng DTO `PurchaseEvaluationFeatures.cs:1088-1096` | ❌ truyền **8** đối số, không có `curLevel.AllowApproverDelete` |
|
||||
| `AllowApproverDelete` trong `PurchaseEvaluationFeatures.cs` (toàn file) | ❌ **0 hit** |
|
||||
| Kênh thay thế `approvalFlow.steps[].levels[]` | ❌ cũng **0 hit** — không có đường vòng nào |
|
||||
| Chỗ ĐÃ có | ✅ Domain `ApprovalWorkflow.cs:169` · Mig 68 · Designer admin · **rào BE `PeSoftDeleteFeatures.cs:114`** |
|
||||
|
||||
⇒ **Hệ quả runtime:** `evaluation.currentLevelOptions.allowApproverDelete` = `undefined` ⇒ gate `=== true` false ⇒
|
||||
**nút Xóa KHÔNG BAO GIỜ HIỆN**, mà `npm run build` ×2 vẫn XANH (type lượt trước khai `boolean` non-optional,
|
||||
TS không kiểm được payload runtime). Đúng dạng **silent drop**.
|
||||
|
||||
**BE phải vá 2 dòng (tôi KHÔNG chạm `src/Backend/**`):**
|
||||
1. `PurchaseEvaluationDtos.cs:131` — thêm member positional **thứ 9**: `bool AllowApproverDelete);`
|
||||
2. `PurchaseEvaluationFeatures.cs:1096` — thêm đối số cuối: `curLevel.AllowApproverDelete);`
|
||||
|
||||
**Tôi vẫn viết FE bám ĐÚNG tên canonical `currentLevelOptions.allowApproverDelete` và ĐI TIẾP** (không dừng lượt).
|
||||
Chọn **fail-closed** (`=== true`), KHÔNG fail-open (`!== false`): thà nút ẩn tới khi BE vá, còn hơn lòi nút
|
||||
xoá-chứng-từ-tài-chính cho mọi người rồi ăn 403 hàng loạt.
|
||||
|
||||
### 1-bis. 🔴 `actorInV2Level` KHÔNG dùng được cho rào xóa (lệch rào BE) — tôi đi CHẶT HƠN brief
|
||||
Brief ghi gate dùng `actorInV2Level`. Đo thì nó **rộng hơn BE**:
|
||||
- `PeWorkflowPanel.tsx:99-100`: `actorInV2Level = isAdmin || v2Approvers.some(...)` ⇒ **admin bypass**.
|
||||
- `PeSoftDeleteFeatures.cs:104`: `FirstOrDefault(l => l.ApproverUserId == actorId)` **KHÔNG bypass admin**, và comment
|
||||
BE nói rõ *"bê fallback `.First()` sang đây sẽ biến admin bất kỳ thành người xóa được phiếu người khác"*.
|
||||
- Tệ hơn: `PurchaseEvaluationFeatures.cs:1084-1085` khi actor không match thì **fallback row ĐẦU** của Cấp
|
||||
⇒ admin sẽ đọc cờ F6 **của người khác** ⇒ vi phạm thẳng "PER-NGƯỜI" mà owner chốt.
|
||||
|
||||
⇒ Dùng `actorIsCurrentApprover` = `!!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)`
|
||||
(tập con CHẶT của `actorInV2Level`, khớp 1-1 rào BE). Khai ở đây để reviewer không tưởng tôi đọc lệch brief.
|
||||
@ -49,3 +49,50 @@ Cơ chế triệt tiêu: `Remove` ⇒ `IsDeleted=true` ⇒ global filter `Purcha
|
||||
- T25 (403 policy) = Pattern 10 reflection, thêm vào file mới cùng chỗ (đọc attribute trên **method**, không phải class — 2 endpoint này gắn method-level).
|
||||
|
||||
<!-- flush 1 -->
|
||||
|
||||
## 3. 🔴 PHÁT HIỆN HẠ TẦNG — `AuditingInterceptor` KHÔNG wire trong test fixture
|
||||
|
||||
`TestApplicationDbContext` (`Common/SqliteDbFixture.cs:12-32`) chỉ override column type; **không** `AddInterceptors`. Prod wire ở `Infrastructure/DependencyInjection.cs:61-62`. 7 file test hiện có đã ghi chú điều này và **né** bằng cách set `IsDeleted = true` thủ công.
|
||||
|
||||
Nhưng đợt này KHÔNG né được: vật cần đo (`handler.Handle`) gọi `db.PurchaseEvaluations.Remove(pe)` và **ý nghĩa của nó do interceptor quyết định**:
|
||||
- có interceptor ⇒ Deleted→Modified + `IsDeleted=true` ⇒ **row Ở LẠI** ⇒ màn "Đã xóa" có gì để hiện.
|
||||
- không interceptor ⇒ **XÓA CỨNG** ⇒ T22b/T22c sẽ assert trên list RỖNG = đúng ở cả hai thế giới = **chính cái bẫy assert-rỗng-nghĩa lead cấm**.
|
||||
|
||||
⇒ Quyết định: dựng fixture RIÊNG trong file test (`SoftDeleteFixture`, ~55 LOC, mirror `IdentityFixture` + `.AddInterceptors(new AuditingInterceptor(currentUser, clock))`). KHÔNG sửa `IdentityFixture` dùng chung (500+ test đang dựa vào hành vi hiện tại). Đây là `tests/**` = trong phạm vi của tôi, KHÔNG chạm production.
|
||||
|
||||
### Rủi ro đã lường TRƯỚC khi chạy — cascade nuốt changelog (ảnh hưởng T26)
|
||||
`PurchaseEvaluationConfiguration.cs:78`: `HasMany(x => x.Changelogs)...OnDelete(Cascade)` + navigation 2 chiều.
|
||||
Handler làm đúng thứ tự: `Changelogs.Add(...)` **rồi** `PurchaseEvaluations.Remove(pe)`. EF mặc định `CascadeDeleteTiming.Immediate` ⇒ ngay tại `Remove()` mọi dependent ĐANG TRACKED bị cascade; dependent ở trạng thái `Added` khi bị set Deleted thì EF chuyển thành **Detached** (không phải Deleted).
|
||||
Nếu đúng vậy ⇒ **changelog bị vứt IM LẶNG ở CẢ PROD**, vì cascade xảy ra ở `Remove()` (trước), còn interceptor chỉ chạy ở `SavingChanges` (sau). T26 sẽ ĐỎ và đó là **bug thật**, không phải lỗi harness.
|
||||
Chưa kết luận — để test trả lời. Ghi trước ở đây để nếu tôi mất return thì lead vẫn có giả thuyết.
|
||||
|
||||
## 4. Thiết kế 10 ca — mỗi ca kèm CHỨNG-NHÂN (gỡ tính năng ⇒ đỏ)
|
||||
|
||||
Seed chung T21/T22: 3 peer cùng cặp (Project, WorkItem), đều TRƯỚC mốc `CurCreatedAt`:
|
||||
| Peer | Phase | BudgetPeriod | winner quote |
|
||||
|---|---|---|---|
|
||||
| `peerDaDuyet` | DaDuyet | 100tr | 90tr |
|
||||
| `peerTraLai` | TraLai | 60tr | 20tr |
|
||||
| **`target`** (bị xóa) | **ChoDuyet** | **30tr** | **50tr** |
|
||||
|
||||
Số TRƯỚC → SAU (exact, không chỉ "giảm"):
|
||||
- `PrevSubmitted` 130tr/2 → **100tr/1** (↓ đúng 30tr) — `ComputeAsync:53-59` nhận ChoDuyet
|
||||
- `PrevSelected` 90tr/1 → **90tr/1** (KHÔNG đổi, **≠ 0** nên không vacuous) — `:61-62` đòi DaDuyet
|
||||
- `PendingSubmitted` 60tr/1 → **60tr/1** (KHÔNG đổi, **≠ 0**) — `:107` loại tường minh ChoDuyet
|
||||
- `PendingSelected` 70tr/2 → **20tr/1** (↓ đúng 50tr, **≠ 0** sau khi giảm) — `:114-126` nhận ChoDuyet+winner
|
||||
- `PriorPes` 2 → **1** (mất đúng `target`, còn `peerTraLai`)
|
||||
|
||||
🔴 Chủ ý: 2 đại lượng "KHÔNG đổi" đều được giữ **khác 0 ở cả hai phía** nhờ peer DaDuyet + peer TraLai. Nếu chỉ seed mỗi phiếu bị xóa thì "không đổi" = 0→0 = vô nghĩa.
|
||||
|
||||
- **T22**: gọi handler `/deleted` (dùng `IgnoreQueryFilters`) XONG rồi tính lại accumulator ⇒ 4 số y hệt sau-xóa. Chứng-nhân: list phải THẬT SỰ trả về phiếu đã xóa (assert chứa `target`), nếu không thì "không rò rỉ" là vì list rỗng.
|
||||
- **T22b**: 1 phiếu đã xóa + **2 phiếu SỐNG** (Project thật vì handler INNER JOIN Projects) ⇒ list đúng 1 phần tử. Chứng-nhân: gỡ `.Where(x => x.IsDeleted)` ⇒ 3 phần tử ⇒ đỏ. Thêm đối chứng: 2 phiếu sống ĐANG thấy được ở list thường.
|
||||
- **T22c**: `dave` (0 role, không drafter, không approver quy trình đã pin) ⇒ 0. Chứng-nhân: **cùng seed, cùng query**, đổi mỗi actor sang drafter ⇒ 1. Nếu bỏ khối IDOR ⇒ dave thấy 1 ⇒ đỏ.
|
||||
- **T23**: alice đúng lượt, cờ **false** ⇒ Forbidden + phiếu **vẫn sống**.
|
||||
- **T24**: Cấp 1 = alice, Cấp 2 = carol (**cờ carol = true**), con trỏ ở Cấp 1, carol xóa ⇒ Forbidden "chưa tới lượt" + phiếu vẫn sống. Cờ true để ca này đo ĐÚNG biến "lượt".
|
||||
- **T24b**: Cấp 1 có **2 người**: alice(cờ TRUE) + bob(cờ FALSE), cùng đúng lượt. bob TRƯỚC ⇒ Forbidden; alice SAU ⇒ xóa được. `g.Any(...)` ⇒ bob qua ⇒ đỏ.
|
||||
- **T25**: reflection method-level `DeleteByApprover` = `PurchaseEvaluations.Delete` + tên policy có thật trong `MenuKeys.All`/`Actions` + `Delete` (nháp) **KHÔNG** policy (giữ ranh giới 2 đường).
|
||||
- **T25b**: alice đủ cả 3 ⇒ không ném + row còn + `IsDeleted=true` + `DeletedBy=alice` + biến mất khỏi query thường + hiện ở list "Đã xóa".
|
||||
- **T26**: 1 changelog `Action=Delete`, `EntityId=matchingLevel.Id`, `PhaseAtChange=ChoDuyet`, `ContextNote=lý do`; đối chứng phiếu thứ 2 xóa KHÔNG lý do ⇒ `ContextNote=null`.
|
||||
|
||||
<!-- flush 2 -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user