From a21482ecd7dbb4bd842b2eb5fc196cef31c0128f Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 27 Jul 2026 13:19:26 +0700 Subject: [PATCH] wal: flush 20260727T1319 --- .../sub-d2-fe-3.md | 65 +++++++++++++++++++ .../sub-d2-test-4.md | 47 ++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 .claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-3.md diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-3.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-3.md new file mode 100644 index 0000000..2abfe2e --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-3.md @@ -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` — **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. diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md index 9657bd5..fbdd3df 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md @@ -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). + +## 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`. + + +