diff --git a/.claude/WAL.md b/.claude/WAL.md index 6933e0e..0f44532 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -57,7 +57,11 @@ chain: [ ] ⚠️ Deploy này mang **migration mới** `20260727033522_AddPeAllowApproverDelete` lên PROD — AddColumn bit default false, additive an-toàn, nhưng LÀ đổi schema prod thật [~] wf: A-S4-đợt1 hmw — run-id engine `wf_f1bfea65-ed9` · run-folder `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 2 task: implementer-backend (F6 schema+mig+UpdateCommand+validator T1/T2+authz+F-5a+F-6+F-10) · implementer-frontend (F-11 5 dây, bẫy :605). Chết giữa chừng ⇒ /tiep §4 relaunch ĐÃ-CẮT-GỌT, đọc sub-*.md trên đĩa trước [ ] A-S4-đợt1 wave 2 (sau khi wave 1 xanh): test-specialist F-12..F-16 + reviewer soi diff -[!] wf: **A-ĐỢT-2 W1** hmw — run-id `wf_55f6201f-a52` · anh lệnh "làm tiếp luôn" 2026-07-27 (KHÔNG chờ UAT đợt 1). 2 task SONG SONG: BE (xóa-by-approver + list /deleted + menu seed + changelog + 10 test) · FE (nút xóa panel + 3 mục menu + màn Đã xóa + md5-mirror) +[~] A-ĐỢT-2 W1 hmw XONG-MỘT-PHẦN — cả 2 sub mất return. Đo đĩa: **BE code LAND** (file mới `PeSoftDeleteFeatures.cs` + controller `:163` `by-approver` + `:174` `deleted` + `MenuKeys:145-146` + `DbInitializer`) build 0E — **NHƯNG 0 test mới** (551 y nguyên) · **FE gần như CHƯA làm** (chỉ `types/purchaseEvaluation.ts` ×2) +[x] ✅ **HỢP-ĐỒNG LIÊN-LANE GIỮ ĐƯỢC ở phía BE** — route + menu-key đúng canonical từng ký tự. FE chưa gọi (0 hit) vì chưa làm tới +[!] 🔴 **LEAD SAI, sub FE bác ĐÚNG**: lead bảo "vá lệch `fe-admin:107` thiếu `WfView`" — sub đo ra tiền-đề KHÔNG ĐỨNG (`WorkflowMatrixViewPage.tsx` CHỈ có ở fe-user; admin không page/route/types) ⇒ thêm regex một mình = **đẻ link chết** "Trang này chưa được build" = regression NHÌN THẤY ĐƯỢC, tệ hơn hiện trạng. Lead chép kết-luận của investigator đợt 1 mà không hỏi trang đích có tồn tại không. **LEAD CHỐT đường (c): GIỮ NGUYÊN**, gỡ khỏi phạm vi (admin đã có Designer sửa được, matrix chỉ-xem là thừa) +[!] wf: A-ĐỢT-2 W1-bis — 2 spawn TRỰC TIẾP song song (KHÔNG qua hmw): `implementer-frontend` làm phần chính FE (`sub-d2-fe-3.md`) · `test-specialist` viết 10 test T21-T26 (`sub-d2-test-4.md`) +[~] wf: A-ĐỢT-2 W1 hmw — run-id `wf_55f6201f-a52` · anh lệnh "làm tiếp luôn" 2026-07-27 (KHÔNG chờ UAT đợt 1). 2 task SONG SONG: BE (xóa-by-approver + list /deleted + menu seed + changelog + 10 test) · FE (nút xóa panel + 3 mục menu + màn Đã xóa + md5-mirror) [ ] 🔴 **HỢP-ĐỒNG LIÊN-LANE đã chốt TRƯỚC khi phóng** (bài học đợt 1: 2 nửa build sạch mà không nối được nhau — F3) — canonical ở `run.md` §ĐỢT 2: `DELETE /api/purchase-evaluations/{id}/by-approver` (policy `PurchaseEvaluations.Delete`) · `GET /api/purchase-evaluations/deleted` (policy `.Read`) · giữ NGUYÊN `DELETE /{id}` xóa-nháp KHÔNG policy · key `Pe_{code}_Approved` + `Pe_{code}_Deleted` (KHÔNG vào `MenuKeys.All`) · route FE `?phase=7` và `?deleted=1` [ ] A-ĐỢT-2 W2: test + reviewer → deploy (theo đúng gate đợt 1: test xanh ∧ reviewer không ĐỪNG-DEPLOY ∧ F8 `git add` đích danh) [ ] 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 diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-be-1.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-be-1.md new file mode 100644 index 0000000..20a2847 --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-be-1.md @@ -0,0 +1,32 @@ +# sub-d2-be-1 — ĐỢT 2 BACKEND (xóa-by-approver + list "Đã xóa" + menu + test) + +> run `2026-07-27-S155-pe-delete-approver` · vai `implementer-backend` · 2026-07-27 +> Luật ghi: flush TỪNG PHẦN + tự `wc -c` (run này đã mất 1 diary 0-byte + 7 return). + +## 0. Checklist tự theo dõi + +- [x] đọc `run.md` mục ĐỢT 2 (hợp-đồng liên-lane) + `spec` PHẦN I mục 4-13, 21-28 +- [x] đo nền từ ĐĨA (không tin spec): controller · handler xóa · list IDOR · MenuKeys · DbInitializer +- [ ] A. đường xóa MỚI `/by-approver` +- [ ] B. list `/deleted` +- [ ] C. menu (MenuKeys +2 factory · tree +2 · labelBackfill · 2 danh sách permission) +- [ ] D. test 21-26 + 22b/22c/25b +- [ ] build + test, dán SỐ THẬT + +## 1. Nền ĐO TỪ ĐĨA (verify lại, không chép spec) + +| Thứ | Vị trí đọc được | Khớp MEMORY-PACK? | +|---|---|---| +| Endpoint xóa CŨ | `PurchaseEvaluationsController.cs:146-151` — `[HttpDelete("{id:guid}")]`, class `[Authorize]` trần `:15` | ✅ | +| Handler xóa CŨ | `PurchaseEvaluationFeatures.cs:1396-1411` — ctor CHỈ `IApplicationDbContext`, guard allow-list `:1404-1406` | ✅ | +| Khối IDOR list | `PurchaseEvaluationFeatures.cs:596-617` (3 điều kiện: Drafter ∥ eligiblePhases ∥ V2-approver + `!= DangSoanThao`) | ✅ | +| Khuôn per-ROW | `PurchaseEvaluationWorkflowService.cs:744-745` `matchingLevel` (fallback `.First()`), guard lượt `:694-720` | ✅ (spec ghi `:859` cho F5 — đây là chỗ ĐỌC cờ) | +| `LogTransitionAsync` | `PurchaseEvaluationWorkflowService.cs:1169-1196` | ✅ | +| `MenuKeys.All` | `MenuKeys.cs:147-165` — 0 `Pe_*` (chỉ root `PurchaseEvaluations` `:153`) | ✅ | +| factory `Pe_*` | `MenuKeys.cs:134-141` (Group/List/Create/Pending/WfView) | ✅ | +| tree menu | `DbInitializer.cs:1867-1878` trong `foreach PurchaseEvaluationTypeCodes` | ✅ | +| upsert chỉ update Order | `DbInitializer.cs:1893-1905` (`continue` sau Order, KHÔNG đụng Label) | ✅ | +| `labelBackfill` | `DbInitializer.cs:1918-1924` | ✅ | +| permission seed ×2 | `DbInitializer.cs:2092-2096` (grant-all-roles) **VÀ** `:2492-2496` (PE defaults) | ✅ | + +(đang cập nhật — phần thi công ghi tiếp bên dưới) diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-2.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-2.md new file mode 100644 index 0000000..2163565 --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-fe-2.md @@ -0,0 +1,71 @@ +# sub-d2-fe-2 — ĐỢT 2 FRONTEND (nút Xóa màn duyệt + menu + màn "Đã xóa") + +- **role:** implementer-frontend · **run:** `2026-07-27-S155-pe-delete-approver` · **đợt:** 2 +- **scope:** `fe-admin/src/**` + `fe-user/src/**` ONLY. KHÔNG chạm `src/Backend/**`, KHÔNG chạm `tests/`. +- **ghi TRONG LÚC LÀM** (flush từng phần, tự `wc -c` sau mỗi flush). + +--- + +## 0. Nền đã ĐO (trước khi sửa) — số thật, không phải trí nhớ + +| Đo | Kết quả | +|---|---| +| `PeWorkflowPanel.tsx` md5 ×2 app | `02671be6334110028c55fe82f5e70dee` (IDENTICAL, 942 dòng) | +| `PurchaseEvaluationsListPage.tsx` md5 ×2 app | `99e38e167d2f745ea7628174f1d2d6bf` (IDENTICAL — **bonus**, spec không nói) | +| `usePermission.ts` md5 ×2 app | `2b210b753e8fe2704407c787cb82779d` (IDENTICAL) | +| `Layout.tsx` | **DIVERGE** admin `ca59f56c…` / user `cab674f4…` (đúng như thiết kế — 2 app khác staticMap) | +| `lib/menuKeys.ts` | DIVERGE đúng 2 dòng (`Off_AttendanceReport` admin-only) | +| `git status` lúc bắt đầu | sạch (chỉ `.session-counter.json`) ⇒ **lane BE chưa land gì** | + +## 1. 🔴 PHỤ THUỘC LIÊN-LANE — FE gọi gì, BE phải có gì + +Hợp-đồng `run.md:139-154` chốt 3 route + 2 menu-key. FE bám ĐÚNG. **NHƯNG** hợp-đồng +**không** đặt tên cho 2 thứ FE bắt buộc phải có — khai ở đây để lead verify, KHÔNG tự chế lung tung: + +### (D1) Cờ F6 per-NGƯỜI trong bundle chi tiết phiếu +- FE đọc: `evaluation.currentLevelOptions.allowApproverDelete` +- BE PHẢI thêm: `bool AllowApproverDelete` làm **member positional thứ 9** của + `ApprovalWorkflowOptionsDto` (`src/Backend/…/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs:123-131`) + + gán từ `curLevel.AllowApproverDelete` tại `PurchaseEvaluationFeatures.cs:1088-1096`. +- Vì sao chỗ này mà không phải chỗ khác: `currentLevelOptions` là kênh **DUY NHẤT** per-NGƯỜI — + nó resolve bằng `l.ApproverUserId == currentUser.UserId` (`PurchaseEvaluationFeatures.cs:1083-1085`). + Kênh còn lại `approvalFlow.steps[].levels[].allowApproverFinalize` là **OR-of-N per-CẤP** + (`:1182 g.Any(...)`) — dùng nó là vi phạm B2 (`run.md:159` per-ROW). +- **Nếu BE quên:** field về `undefined` → gate `=== true` false → **nút KHÔNG BAO GIỜ HIỆN**, + build 2 bên vẫn xanh. Đúng dạng silent-drop đợt 1. ⇒ lead phải grep xác nhận. + +### (D2) Chỗ chứa "lý do xóa" trong request +- Hợp-đồng chỉ ghi `DELETE /api/purchase-evaluations/{id}/by-approver`, **không** nói payload. +- FE gửi **CẢ HAI** đường cho tương thích mọi kiểu binding BE: + `api.delete(url, { params: { reason }, data: { reason } })` + → BE bind `[FromQuery] string? reason` ✅ · bind `[FromBody] {Reason}` ✅ · không nhận gì ✅ (bị bỏ qua). +- Chủ ý: thà thừa 1 query-param còn hơn mất vết audit vì lệch tên. Ghi rõ để reviewer không tưởng là cẩu thả. + +### (D3) DTO list "Đã xóa" +- FE khai `PeDeletedListItem = PeListItem + { deletedAt?, deletedBy?, deletedByName? }` — **tất cả optional**, + render phòng thủ (`—` khi thiếu). BE trả thêm/thiếu đều không vỡ. Tên bám `AuditableEntity` + (`src/Backend/SolutionErp.Domain/Common/AuditableEntity.cs:5-7`: `IsDeleted`/`DeletedAt`/`DeletedBy`). + +## 2. 🚩 FLAG — mục "vá `WfView` cho fe-admin" TÔI KHÔNG LÀM (tiền-đề của lệnh sai) + +Lệnh: *"vá lệch sẵn có: `fe-admin:107` THIẾU `WfView` so với `fe-user:120`"*. +Đo trước khi làm thì tiền-đề **không đứng**: + +| Bằng chứng | fe-user | fe-admin | +|---|---|---| +| Page `pages/pe/WorkflowMatrixViewPage.tsx` | CÓ (323 dòng) | **KHÔNG CÓ** | +| Route `/purchase-evaluations/workflow-matrix` | `App.tsx:60` | **KHÔNG CÓ** (`App.tsx:78-81` chỉ 4 route PE) | +| Types `types/approvalWorkflowV2.ts` | CÓ | **KHÔNG CÓ** (admin để type inline trong `ApprovalWorkflowsV2Page.tsx`) | +| Catch-all | — | `App.tsx:107-114` render *"Trang này chưa được build"* | + +⇒ Thêm `WfView` vào regex admin **một mình** = đẻ link chết dẫn tới "Trang này chưa được build". +Đó là **regression nhìn thấy được**, tệ hơn trạng-thái-hiện-tại (leaf bị drop im lặng). +Sửa cho đúng cần **3 file mới bên admin** (page + types + route) = ngoài phạm vi đợt 2, và là +lựa-chọn UX (admin nên thấy matrix read-only hay Designer sửa được?) ⇒ **thuộc quyền owner/lead**. + +**3 đường cho lead chọn (đều ≤ 1 lượt):** +- **(a)** mirror hẳn: copy `WorkflowMatrixViewPage.tsx` + `types/approvalWorkflowV2.ts` sang admin + route + regex. +- **(b)** rẻ nhất: admin map `Pe_{code}_WfView` → `/system/approval-workflows-v2/{code}` (Designer đã sống, `Layout.tsx:130-136`). +- **(c)** giữ nguyên: coi lệch này là CỐ Ý (admin có Designer rồi, không cần bản read-only) → ghi 1 dòng comment cho người sau khỏi "vá" lại. + +Tôi để nguyên trạng (đường **c** *de facto*) + không chèn comment tránh đụng byte thừa. Chờ lead. 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 new file mode 100644 index 0000000..9657bd5 --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md @@ -0,0 +1,51 @@ +# sub-d2-test-4 — ĐỢT 2: test đường xóa-khi-đang-duyệt + màn "Đã xóa" + +> Diary GHI TRONG LÚC LÀM (run này đã mất 1 diary 0-byte + 9 return). Flush từng phần, tự `wc -c` sau mỗi lần ghi. + +## 0. Trạng thái nhận việc +- Baseline lead đo: **551 PASS / 0 FAIL** (Domain 45 + Infra 506). Code đợt 2 đã land, 0 test. +- Mục tiêu: 10 test (T21, T22, T22b, T22c, T23, T24, T24b, T25, T25b, T26) ⇒ đích > 551. + +## 1. Đọc vật cần test — ĐÃ XONG + +### `PeSoftDeleteFeatures.cs` (file mới, 249 dòng) — 2 vật +**A. `DeletePurchaseEvaluationByApproverCommandHandler`** (`:50-145`) — thứ tự rào: +1. `!IsAuthenticated || UserId is null` → `UnauthorizedException` +2. PE không tồn tại → `NotFoundException` +3. `Phase != ChoDuyet` → `ConflictException` +4. `ApprovalWorkflowId is not Guid` → `ConflictException` +5. steps rỗng / `CurrentWorkflowStepIndex` ngoài biên → `ConflictException` +6. `pendingLevelGroup.Count == 0` (Bước không có Cấp đang chờ) → `ConflictException` +7. **KHÔNG match `ApproverUserId == actorId`** → `ForbiddenException` ("chưa tới lượt") ← **T24** +8. **`!matchingLevel.AllowApproverDelete`** → `ForbiddenException` ("chưa được bật quyền") ← **T23, T24b** +9. Ghi `PurchaseEvaluationChangelog` (Action=`ChangelogAction.Delete`, EntityType=Workflow, EntityId=`matchingLevel.Id`, PhaseAtChange=ChoDuyet, ContextNote=Reason.Trim() hoặc null) ← **T26** +10. `db.PurchaseEvaluations.Remove(pe)` = XÓA MỀM qua `AuditingInterceptor` (Deleted→Modified + IsDeleted=true) + +🔴 Điểm sống-còn cho test: rào (7) đọc **PER-ROW** `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)`, rào (8) đọc `matchingLevel.AllowApproverDelete` — **không** `g.Any(...)`. Đây đúng là thứ **T24b** phải ghim. + +**B. `ListDeletedPurchaseEvaluationsQueryHandler`** (`:153-248`): +- `IgnoreQueryFilters().Where(x => x.IsDeleted)` ← **T22b** ghim vế `.Where` (gỡ nó ⇒ list toàn phiếu sống) +- INNER join `db.Projects` (❗ phiếu phải có Project THẬT, không Guid ngẫu nhiên — nếu không row rơi khỏi kết quả và test xanh giả) +- IDOR-scope khi `!Roles.Contains(Admin)`: `DrafterUserId == userId` ∥ `eligiblePhases.Contains(Phase)` ∥ (Phase != DangSoanThao ∧ pinned wf ∈ workflow mà user là approver) ← **T22c** + +### `PeBudgetAccumulator.cs` — số học cho T21 (🔴 KHÔNG phải "cả 4 cùng giảm") +| Đại lượng | Hàm | Điều kiện phase | Phiếu `ChoDuyet` có winner đang xét | +|---|---|---|---| +| `PrevSubmitted*` | `ComputeAsync :53-59` | `ChoDuyet ∥ DaDuyet` | ✅ CÓ vào ⇒ xóa thì **↓** | +| `PrevSelected*` | `ComputeAsync :61-62` | `DaDuyet` ∧ có winner | ❌ không (đòi DaDuyet) ⇒ **KHÔNG đổi** | +| `PendingSubmitted*` | `ComputePendingAsync :104-108` | loại tường minh `ChoDuyet` | ❌ không ⇒ **KHÔNG đổi** | +| `PendingSelected*` | `ComputePendingAsync :114-117` | ∉{DangSoanThao,DaDuyet,TuChoi} ∧ winner | ✅ CÓ vào ⇒ xóa thì **↓** | +| `PriorPes` | `:128-133` | ∉{DaDuyet,TuChoi} | ✅ CÓ ⇒ **mất đúng 1 phần tử** | + +Cơ chế triệt tiêu: `Remove` ⇒ `IsDeleted=true` ⇒ global filter `PurchaseEvaluationConfiguration.cs:84` loại khỏi `db.PurchaseEvaluations` ⇒ CẢ 4 phép cộng đọc hụt. Accumulator KHÔNG sửa. + +### Controller (`PurchaseEvaluationsController.cs`) +- `:163-169` `DELETE {id}/by-approver` + `[Authorize(Policy = "PurchaseEvaluations.Delete")]` ← **T25** +- `:174-182` `GET deleted` + `[Authorize(Policy = "PurchaseEvaluations.Read")]` +- `:151-156` `DELETE {id}` (nháp) — **KHÔNG policy**, đừng nhầm ⇒ T25 phải assert cả vế này (nếu ai đó "sửa" bằng cách gắn policy lên method nháp thì T25 vẫn phải phân biệt được 2 method). + +## 2. Kế hoạch file test +- File MỚI `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs` — T21..T24b, T25b, T26 (handler-level, SQLite thật). +- 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). + +