diff --git a/.claude/WAL.md b/.claude/WAL.md index 847923d..6ac3fde 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -67,7 +67,11 @@ chain: [x] **A-owner chốt (14)**: phương án **(b) BỎ policy theo vai, giữ 3 rào nghiệp vụ**. Lý: cờ F6 = admin tick ĐÍCH DANH từng người ⇒ chặt hơn quyền theo vai; handler có `ICurrentUser` + ném Forbidden ⇒ đúng khuôn controller ("class any-auth, handler fine-grained") [x] A-lead land 3 sửa (2 ĐẦU, chống sót read-site): (1) gỡ `[Authorize(Policy)]` khỏi `DeleteByApprover` + chú thích dài · (2) gỡ `can(...,'Delete')` khỏi FE gate + dọn 3 khai thừa, **re-mirror md5 `7cd6bbfe…` ×2** · (3) T25 viết lại ĐẢO CHIỀU (`Should().BeNull()`) + **đổi tên** `RequiresDeletePolicy`→`MustStayPolicyFree` (tên cũ nói dối sau khi đổi spec). Đo: **561 PASS/0 FAIL** · tsc 0 lỗi ×2 [x] A-lead vá chú-thích LOW-1 reviewer bắt đúng: bản đầu viết "thêm changelog SAU khi state đã đổi" — SAI một nhịp (lúc `Add()` phiếu VẪN `Deleted`); cơ-chế thật = **cascade tức thời đã xong tại `Remove()` khi chưa có con nào để gỡ** -[!] wf: A-ĐỢT-2 reviewer RESUME lần 2 — đã báo nó 3 thay đổi giữa lượt (mục-tiêu-di-động), xin verdict trên trạng-thái HIỆN TẠI +[x] A-ĐỢT-2 reviewer XONG TRỌN — 51.970B · **12 FLAG (2H/4M/6L)** · verdict ban đầu **ĐỪNG-DEPLOY**. H2 (gỡ policy) nó **tự truy 7 chặng** rồi ĐÓNG: chốt chính `PeSoftDeleteFeatures.cs:104` `FirstOrDefault(...) ?? throw Forbidden` — **KHÔNG** có `.First()` fallback (khác `WorkflowService:744-745`), `grep Admin` = 1 hit ở handler LIST không phải handler xóa ⇒ 0 đường ghi mở. R2 FE khớp 1-1, R3 T25 = đổi-spec hợp-lệ (`=="X"` ⟂ `==null` loại-trừ nhau, không phải nới), R4 nó tự chạy test ra 561 khớp lead +[x] 🔴🔴 **H1 = LẦN 4 cùng lớp lỗi, và là ca ĐAU NHẤT**: `ApprovalWorkflowOptionsDto` **THIẾU field `AllowApproverDelete`** ⇒ `grep -c` trong `PurchaseEvaluationFeatures.cs` = **0** ⇒ FE gate `levelOptions?.allowApproverDelete === true` **LUÔN SAI** ⇒ **nút KHÔNG BAO GIỜ hiện với bất kỳ ai**. BE build sạch · FE build sạch · 561 test xanh · **grep hợp-đồng của lead cũng khớp** — vì lead chỉ kiểm tên **ROUTE**, hợp-đồng còn tầng **tên FIELD trong DTO** mà lead không đặt vào lưới +[x] A-lead vá H1 (2 dòng: DTO + site dựng) + **thêm T27 khoá CẢ LỚP** (reflection: mọi cờ `Allow*` trên `ApprovalWorkflowLevel` phải có mặt trên DTO) — 🧪 **FAULT-INJECT xác nhận có RĂNG**: tiêm lại đúng bug ⇒ **T27 ĐỎ**, khôi phục ⇒ xanh +[x] 🚀 **A-ĐỢT-2 DEPLOY XONG** — commit `4464a46`, push `b1bae77..4464a46 → main` exit 0. **562 PASS/0 FAIL** (45+517) · npm build ×2 PASS · md5 mirror `7cd6bbfe…` · **F8 gate qua**: `git check-ignore` sạch, staging 0 `??` ở src/tests/fe, `git show --stat` có ĐỦ 2 file mới (269 + 822 dòng) +[!] wf: A-cicd-verify-dot2 `a66f...` — 🔴 trọng-tâm KHÁC đợt 1: **KHÔNG có migration** (đừng tìm row mới) nhưng **CÓ seed menu** ⇒ phải restart API. Kiểm gắt nhất: **nhãn `Pe_*_Pending` đã đổi "Duyệt"→"Đang duyệt" chưa** (đi qua `labelBackfill` RIÊNG, upsert thường KHÔNG đụng Label ⇒ dễ trượt nhất) [~] wf: A-ĐỢT-2 reviewer — spawn TRỰC TIẾP `a64f...`; ưu tiên: vá-đảo-thứ-tự của lead (chưa ai soi, hỏi có đúng trên SQL Server không chứ không chỉ SQLite) · 3 bẫy `IgnoreQueryFilters`/IDOR/D3 · guard per-NGƯỜI · labelBackfill [~] A-ĐỢT-2 test cũ: 516 total, 1 FAIL = `T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided`. Handler CÓ ghi `ChangelogAction.Delete` (`PeSoftDeleteFeatures.cs:126-131`) ⇒ đỏ ở chi-tiết khác (nghi phần mang LÝ DO). `test-specialist` còn chạy (testhost lock ⇒ build báo MSB3027 = **KHÔNG phải lỗi mã**), chờ nó khai [~] 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`) diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index d15ae77..6008d49 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -1,5 +1,6 @@ # Reviewer Agent — Persistent Memory +- **S155-đợt2 (07-27) PE delete-approver DIFF-review #2 (12 FLAG 2H/4M/6L · ĐỪNG-DEPLOY):** 🔴 class MỚI **`hợp-đồng-đứt-giữa-2-bờ`** — FE type chép tay khai `allowApproverDelete: boolean`, BE record chỉ 8-member KHÔNG có cờ ⇒ nút CHẾT mà `tsc` + `dotnet build` + 561-test đều XANH (mọi thước đo đo chỗ khác) ⇒ **phép rẻ nhất = đếm member DTO vs khoá FE + `grep -c ` ở file dựng DTO**. · policy-gate `X.Delete` chặn ĐÚNG nhóm cần dùng (đo DB: 11/13 vai `CanDelete=0`, 2 seeder KHÔNG BAO GIỜ nâng) ⇒ owner gỡ policy; **verify "gỡ authz" = truy TỪNG dòng chặn thành bảng + grep `.First()` fallback + grep `Admin` trong chính file** (gỡ policy sinh LOW mới: endpoint thành máy-dò 4-loại-phản-hồi cho mọi tài khoản). · **mã ĐỔI giữa lượt soi ⇒ re-đo đĩa TRƯỚC verdict**; test đổi chiều (`==X` → `==null`) = **ĐỔI-SPEC hợp lệ ≠ nới-assert** vì 2 mệnh đề loại trừ nhau (nới = mệnh đề mới SUY RA được từ cũ). · cascade `Add con → Remove cha → 1 SaveChanges`: chứng "site duy nhất" bằng liệt-kê-vét-cạn 34 `Remove` → lọc 8 → xét cha-con từng cái. - **[→ archive/2026-07.md @S155-curate] S155 ×2 (07-27) PE delete-approver — DIFF-review đợt 1 tiền-deploy (14 FLAG 4H/6M/3L/1I, DEPLOY-CÓ-RỦI-RO) + SPEC-review (PWF 4H/8M/5L):** 🔴 class MỚI **`HIGH nằm ở git chứ không ở mã`** — 2/3 file migration UNTRACKED mà snapshot ĐÃ tracked ⇒ `commit -a` nạp model-có-cột nhưng bỏ migration ⇒ `Invalid column name` cả 7 module, build+test vẫn xanh ⇒ **gate deploy phải chấm `git status ^??` cho `Migrations/`** · `ExecuteDelete` ngoài transaction TRƯỚC `SaveChanges` = mất dữ liệu không hoàn tác · purge xoá CỨNG chữ ký phiếu XOÁ MỀM ⇒ **test đang KHOÁ chiều ngược, vá xong test đỏ ≠ hồi quy, CẤM nới assert** · ~600 dòng BE = mã chết (`grep api.put`=0) · **xanh-giả bắt bằng phép 2-thế-giới** (còn-gate ⟂ bỏ-gate cùng cho 0 ⇒ 0 thông tin) · CLEAN chứng bằng **liệt kê vét cạn** write-site + sweep gián-tiếp (#81-EXT) · 2 lỗi TỰ BẮT: trích line-number **từ diff** (diff không mang số dòng file) · `grep -v Snapshot` ăn nhầm tên migration · **file bị sửa TRONG LÚC soi ⇒ neo bằng NỘI DUNG DÒNG + ghi bản-đồ-trôi**. - **[→ archive/2026-07.md @S155-curate] S146·S147·S152-D2 (digest):** S146 lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex ⇒ nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention · S147 **decouple 1 gate ⇒ truy xem prop dùng chung đã mang carve-out chưa** (`itemsReadOnly` ≠ raw `readOnly`); "same fix" cho surface anh-em có thể trúng gate KHÁC · S152-D2 census thay spot 307/307; **3 THƯỚC-HỎNG** (md5 disk vs `git show` = FAIL giả do CRLF⟂LF · `grep -c` dính citation-trap · `grep -iF` MSYS trả 0-hit IM LẶNG trên UTF-8) + máy-trích mù đuôi `{.yml,.json,.sql}`. - **[→ archive/2026-07.md @S145-curate] S145 4-lane (Axis-A/D/E + S145b GATE) — digest:** bất-biến chia-đôi giữa 2 axis nằm ĐÚNG SEAM ⇒ chỉ cross-cut sweep bắt (hook 3→4 path orphan; `session_ctx_kb` ghost-wire #H18) · 'vai-có-sổ' acceptance ⇒ MUST `ls agent-memory//` (folder≠sổ, ring1-audit RỖNG) · stale-fix phải verify caller-TRÊN-ĐĨA — grep-"4 surface"=0-hit là ANTI-TEETH Goodhart; 2-script-same-suffix = conflation-vector · đếm VALID_ROLES = per-file grep bidirectional KHÔNG regex (comment lừa parser). diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/cicd-verify-dot2.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/cicd-verify-dot2.md new file mode 100644 index 0000000..11436d3 --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/cicd-verify-dot2.md @@ -0,0 +1,43 @@ +# CI/CD verify — S155 đợt 2 (`4464a46`) + +> Ghi-đĩa-trong-lúc-làm (#53). File này là SẢN PHẨM CHÍNH. Mỗi mục ghi ngay khi có bằng chứng. +> Bắt đầu: 2026-07-27. + +## 0. Ngữ cảnh + +- Commit: `4464a464c58b062d1d5a0fbeaf558753799baf28` — `[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)` +- Author date: 2026-07-27 14:06:14 +0700 +- Push: `b1bae77..4464a46 → main`, `git log origin/main..HEAD` = RỖNG (đã push thật). +- 15 file đổi: 8 FE (fe-admin ×4 + fe-user ×4), 6 BE, 1 test. + - BE: `PurchaseEvaluationsController.cs`, `PurchaseEvaluationDtos.cs`, `PeSoftDeleteFeatures.cs`, `PurchaseEvaluationFeatures.cs`, `MenuKeys.cs`, `DbInitializer.cs` + - Test: `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs` +- 🔴 **KHÔNG có file nào trong `*Migrations*`** ⇒ đợt 2 NO-MIG (đúng như lead nói). +- Không file nào khớp `paths-ignore` 7-glob ⇒ CI PHẢI chạy. + +## 1. Gitea Actions run — ĐANG ĐO + +(chưa có) + +## 2. Menu prod — ĐANG ĐO + +(chưa có) + +## 3. Permission seed — ĐANG ĐO + +(chưa có) + +## 4. Endpoint mới — ĐANG ĐO + +(chưa có) + +## 5. Smoke chống hồi quy — ĐANG ĐO + +(chưa có) + +## 6. Bundle FE ×2 — ĐANG ĐO + +(chưa có) + +## 7. Verdict — ĐANG ĐO + +(chưa có) diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-diff-dot2.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-diff-dot2.md index d2fb8b4..a050f80 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-diff-dot2.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-diff-dot2.md @@ -291,3 +291,173 @@ Nhánh "Đã xóa" gọi với `page: 1, pageSize: 50` cố định, không có **Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:557-574` (và bản gương fe-user) DTO dùng chung với danh sách sống nên không có `deletedAt` và `deletedBy`; màn hình lấy `updatedAt` làm xấp xỉ, có ký hiệu "≈" và chú giải khi rê chuột. Cách xử lý trung thực, chấp nhận được. Rủi ro còn lại: nếu về sau có đường ghi nào chạm vào phiếu đã xóa (ví dụ một thao tác quản trị), mốc hiển thị sẽ nhảy mà không ai biết. Muốn chắc thì bổ sung `deletedAt` và `deletedBy` vào DTO riêng của màn này. + +--- + +# 🔁 SOI LẠI GIỮA LƯỢT — mã đã đổi sau khi tôi bắt đầu (3 thay đổi của lead, chưa ai soi) + +Toàn bộ phần dưới đây đo trên **trạng thái đĩa hiện tại**, không phải bản lúc tôi mở lượt. + +## R1. `PurchaseEvaluationsController.cs` — gỡ policy khỏi `DeleteByApprover`: ✅ ĐÚNG, **H2 ĐÓNG** + +Đọc lại `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:159-183`: thuộc tính `[Authorize(Policy = "PurchaseEvaluations.Delete")]` **đã không còn**; action chỉ còn `[HttpDelete("{id:guid}/by-approver")]` và thừa hưởng `[Authorize]` trần ở cấp lớp (`:15`). Khối chú thích kèm theo nêu đúng số đo và đúng hệ quả, kể cả câu dặn "FE không được gate lại". **H2 coi như đã đóng.** + +### Gỡ vậy có mở lỗ ghi nào không? — **Không.** Truy đúng từng dòng: + +Kịch bản: một tài khoản đăng nhập bất kỳ, không phải người duyệt, gọi thẳng `DELETE /api/purchase-evaluations/{id}/by-approver`. + +| Chặng | Dòng chặn | Kết quả | +|---|---|---| +| Chưa đăng nhập | `Controller:15` `[Authorize]` | 401 | +| Không có `UserId` trong token | `PeSoftDeleteFeatures.cs:56-57` | `UnauthorizedException` | +| Phiếu không tồn tại / đã xóa | `:60-61` (bộ lọc toàn cục vẫn áp) | `NotFoundException` | +| Phiếu không ở `ChoDuyet` | `:65-68` | `ConflictException` | +| Phiếu ghim quy trình V1 | `:70-72` | `ConflictException` | +| **Không phải người duyệt của Cấp đang tới lượt** | **`:104-107`** | **`ForbiddenException`** ← đây là chốt chính | +| Đúng người nhưng chưa được tích cờ | `:114-117` | `ForbiddenException` | + +**Câu hỏi then chốt: có đường `.First()` nào khiến người ngoài đọc trúng cờ của người khác không?** Tôi đã cảnh báo đúng cơ chế này ở phía FE, nên phải kiểm phía BE cho chắc. **Không có.** Dòng `:104` viết: + +```csharp +var matchingLevel = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId) + ?? throw new ForbiddenException(...); +``` + +Toán tử `??` dẫn thẳng tới `throw`, **không** dẫn tới `pendingLevelGroup.First()`. Đây khác hẳn `PurchaseEvaluationWorkflowService.cs:744-745` (đường admin-ký-thay khi duyệt) và khác `PurchaseEvaluationFeatures.cs:1084-1085` (đường đọc để hiển thị, có fallback về dòng đầu). Nghĩa là cờ F6 luôn được đọc trên **đúng dòng của chính người gọi**, hoặc không đọc gì cả. + +**Thử phá thêm:** tìm mọi lối tắt cho Admin trong tệp — `grep "Admin" PeSoftDeleteFeatures.cs` cho đúng **một** lần dùng `AppRoles.Admin`, nằm ở handler **danh sách** (`:200`), không nằm ở handler xóa. Vậy một Admin không phải người duyệt hiện tại vẫn dừng ở `:104`. Đúng chủ ý. + +**Kết luận về mặt ghi dữ liệu:** ba rào chặt hơn tầng policy vừa gỡ, vì chúng đòi **danh tính đích danh** chứ không đòi **vai**. Việc gỡ policy không mở thêm bất kỳ đường ghi nào. + +### Nhưng có một cái mở ra, nhỏ và cần nói: **L6 (LOW) — endpoint nay thành máy dò cho mọi tài khoản đăng nhập** + +Trước thay đổi, chỉ `Admin` và `DeptManager` chạm được tới thân handler; nay bất kỳ ai đăng nhập cũng chạm được. Thân handler trả về **bốn loại kết quả phân biệt được**: không tìm thấy, sai trạng thái, quy trình V1, chưa tới lượt. Riêng thông điệp ở `:106-107` còn kèm **tên Bước** và **số Cấp**: + +``` +$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: chưa tới lượt duyệt của bạn..." +``` + +Nghĩa là một tài khoản bất kỳ, nếu biết mã định danh của phiếu, có thể suy ra: phiếu có tồn tại không, đang ở trạng thái nào, có ghim quy trình V2 không, đang dừng ở Bước nào tên gì và Cấp mấy. Không ghi được gì, chỉ đọc rò. + +Mức LOW vì mã định danh là GUID nên không dò mò được, và các thông tin trên đều là siêu dữ liệu quy trình chứ không phải số tiền. **Đề xuất:** bỏ `{currentStep.Name}` khỏi thông điệp và gộp nhánh "không tìm thấy" với nhánh "chưa tới lượt" thành một câu trả lời chung, nếu muốn khép hẳn. + +## R2. `PeWorkflowPanel.tsx` hai ứng dụng — gỡ gate theo vai: ✅ ĐÚNG, khớp 1-1 + +Đọc lại `fe-admin/src/components/pe/PeWorkflowPanel.tsx:130-140`. Điều kiện hiện nút nay còn đúng bốn vế: + +``` +!readOnly +&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet +&& actorIsCurrentApprover +&& levelOptions?.allowApproverDelete === true +``` + +`can(...)` đã biến mất khỏi biểu thức. Quét dấu vết còn sót của ba thứ lẽ ra phải gỡ (`usePermission`, `MenuKeys`, `can(`): chỉ còn **một** lần xuất hiện ở mỗi ứng dụng, và nó nằm trong **dòng chú thích** `:134` giải thích vì sao cố ý không dùng — không phải mã sống. Không còn lệnh nhập thừa, khớp với việc `tsc` sạch. + +**md5 gương — tôi tự đo lại, không lấy số của lead:** +``` +7cd6bbfe73e739633582fc47d710e3e1 fe-admin/src/components/pe/PeWorkflowPanel.tsx +7cd6bbfe73e739633582fc47d710e3e1 fe-user/src/components/pe/PeWorkflowPanel.tsx +``` +**KHỚP**, và trùng đúng chuỗi lead báo. + +**Đối chiếu 1-1 với rào BE — có rào nào lệch không:** + +| Rào BE | Vế FE tương ứng | Đánh giá | +|---|---|---| +| `:65` phase `ChoDuyet` | `evaluation.phase === ChoDuyet` | khớp | +| `:70-72` phải ghim quy trình V2 | `actorIsCurrentApprover` dựng từ `currentApproval`, mà trường này **null** khi phiếu ghim V1 | khớp gián tiếp, đúng chiều | +| `:104` đúng lượt | `actorIsCurrentApprover` (cố ý không dùng `actorInV2Level` vì biến đó có `isAdmin ||`) | khớp | +| `:114` cờ F6 per-người | `levelOptions?.allowApproverDelete === true` | **về hình thức thì khớp, nhưng vế này không bao giờ đúng — xem H1** | +| (không có ở BE) | `!readOnly` | FE **chặt hơn** BE: chỉ hiện nút ở mặt duyệt. Lệch theo chiều an toàn, chấp nhận được | + +Không có rào BE nào bị FE bỏ sót (tức không có cảnh "thấy nút rồi ăn lỗi"). Chiều ngược lại — "ẩn nút với người có quyền" — thì **đang xảy ra**, nhưng nguyên nhân không phải gate theo vai nữa mà là **H1**. + +## R3. `PeDeleteByApproverTests.cs` T25 viết lại: ✅ ĐÚNG LÀ ĐỔI SPEC, và vẫn còn răng + +**Có phải nới assert không? Không.** Khẳng định cũ là `Policy == "PurchaseEvaluations.Delete"`; khẳng định mới là `GetActionAuthorize(...) == null`. Hai mệnh đề này **loại trừ nhau** — không có bản mã nào thoả cả hai. Nới assert là khi mệnh đề mới **được suy ra** từ mệnh đề cũ (kiểu đổi `== "X"` thành `!= null`); ở đây thì ngược lại, mệnh đề mới bác bỏ mệnh đề cũ. Đó là **đổi hợp đồng**, và tên hàm cũng đổi theo (`RequiresDeletePolicy` thành `MustStayPolicyFree`) nên không có chuyện tên nói một đằng khẳng định một nẻo. Đúng cách làm. + +**Còn răng không?** Có. Nếu ai đó "siết bảo mật" bằng cách gắn lại `[Authorize(Policy = ...)]` lên `DeleteByApprover`, hàm `GetActionAuthorize` trả về đối tượng khác null, `BeNull()` đỏ ngay. Đúng cái nó hứa ở dòng `:649-650`. + +**Một hụt nhỏ do viết lại, ghi để không quên:** bản cũ có thêm hai khẳng định tách `Policy` ra rồi kiểm `parts[0] ∈ MenuKeys.All` và `parts[1] ∈ MenuKeys.Actions` — đó là chốt chặn cho lớp lỗi "gắn tên policy chưa đăng ký thì test thuộc tính vẫn xanh mà chạy thật thì ném". Bản mới bỏ hai khẳng định đó, và hiện **không ca nào** kiểm điều đó cho `PurchaseEvaluations.Read` của `ListDeleted`. Hiện tại vô hại vì `PurchaseEvaluations` có trong `MenuKeys.All` (tôi đã đếm: 54 phần tử, có mặt). Nhưng nên khôi phục phép kiểm đó cho nhánh `ListDeleted` — gộp vào **M3**. + +## R4. Số đo — tôi tự chạy lại, không dùng số của lead + +``` +dotnet test SolutionErp.slnx --nologo +→ SolutionErp.Domain.Tests : Failed 0, Passed 45 +→ SolutionErp.Infrastructure.Tests: Failed 0, Passed 516 (59 s) + tổng 561 PASS / 0 FAIL +``` +Khớp con số lead báo. Trong đó lớp `PeDeleteByApproverTests` đóng góp 10 ca, đã chạy riêng trước đó cũng 10/10. + +## R5. 🔴 **H1 VẪN CÒN NGUYÊN — và nay nó là chốt chặn DUY NHẤT** + +Đo lại trên đĩa sau ba thay đổi: + +- `PurchaseEvaluationDtos.cs:123-131` — `ApprovalWorkflowOptionsDto` vẫn đúng **8** thành viên, **không** có `AllowApproverDelete`. +- `PurchaseEvaluationFeatures.cs:1088-1097` — site dựng duy nhất vẫn truyền **8** đối số. +- `grep -c "AllowApproverDelete" PurchaseEvaluationFeatures.cs` → **0**. + +Vế thứ tư của FE, `levelOptions?.allowApproverDelete === true`, vì vậy vẫn luôn cho ra sai. **Nút "Xóa phiếu" vẫn không bao giờ hiện với bất kỳ ai.** + +Việc gỡ policy đã dọn xong chướng ngại thứ hai, nhưng chướng ngại thứ nhất còn nguyên, mà nó nằm **trước** trong chuỗi: người dùng chưa bao giờ nhìn thấy nút để mà bấm. Sửa gọn trong hai dòng: thêm `bool AllowApproverDelete` vào cuối `ApprovalWorkflowOptionsDto` và truyền `curLevel.AllowApproverDelete` ở `PurchaseEvaluationFeatures.cs:1097`. + +--- + +# Những điểm làm ĐÚNG, đáng giữ nguyên + +1. **Tách đường xóa nháp và đường xóa khi đang duyệt thành hai endpoint riêng.** Quyết định kiến trúc đúng, và đã cứu một hồi quy thật: gộp chung rồi gắn policy thì vai `Drafter` mất quyền xóa nháp của chính mình. T25 khoá lại bằng phản chứng. +2. **Đọc cờ F6 trên đúng dòng vừa khớp người, không dùng `Any` trên cả Cấp.** Một Cấp có nhiều người; đọc bằng `Any` sẽ phát quyền huỷ chứng-từ tài chính cho cả nhóm. T24b là ca duy nhất phân biệt được hai cách đọc, và nó tồn tại. +3. **Cố ý không cho Admin đi tắt ở đường xóa**, dù khuôn admin-ký-thay nằm ngay bên cạnh. Sau khi gỡ policy, đây trở thành lớp bảo vệ chính, và nó đứng vững. +4. **Không đụng `PeBudgetAccumulator` một dòng nào.** Hiệu quả "xóa rồi thôi ăn lũy kế" đến từ bộ lọc toàn cục sẵn có — cách sửa rẻ nhất, ít rủi ro nhất. T21 chứng minh bốn đại lượng đi bốn hướng khác nhau chứ không phải cùng giảm. +5. **Chất lượng bộ test cao hơn mức thường thấy:** mỗi ca phủ định đều có ca thuận đi kèm, mỗi phép đo "không đổi" đều được seed cho khác 0, ca IDOR có hai đối chứng. Bộ này bắt được lớp "xanh vì rỗng" mà dự án từng vấp nhiều lần. +6. **Cách xử lý phản hồi review cũng đúng:** đổi hợp đồng thì đổi luôn tên ca test và ghi rõ lý do ngay tại chỗ, thay vì lặng lẽ nới khẳng định. Đây là chi tiết nhỏ nhưng là khác biệt giữa "sửa" và "làm cho hết đỏ". + +--- + +# Thao tác commit — chỗ dễ bỏ sót, nói rõ trước khi bấm + +Đợt 1 từng vấp F8: tệp migration chưa được theo dõi bị `commit -a` bỏ lại. Đợt 2 **không có migration**, nhưng hình dạng rủi ro chỉ đổi chứ chưa biến mất. + +**Hai tệp đang ở trạng thái chưa được theo dõi:** +``` +?? src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs +?? tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs +``` + +- `git commit -a` **không** đưa tệp chưa theo dõi vào commit. Phải `git add` đích danh hai đường dẫn trên. +- Đã kiểm `git check-ignore -v` cho cả hai: **không** bị quy tắc bỏ qua nào chặn (mã thoát 1), nên `git add` sẽ nhận, không cần `-f`. +- **Nếu quên `PeSoftDeleteFeatures.cs`:** lần này hỏng **to tiếng** — `PurchaseEvaluationsController.cs` tham chiếu `DeletePurchaseEvaluationByApproverCommand` và `ListDeletedPurchaseEvaluationsQuery`, hai kiểu chỉ tồn tại trong tệp đó, nên CI đỏ ngay ở bước biên dịch. Khác F8 ở chỗ này, và là khác theo hướng tốt. +- **Nếu quên `PeDeleteByApproverTests.cs`:** hỏng **im lặng** — kho vẫn biên dịch, CI vẫn xanh, chỉ mất trọn 10 ca vừa viết, trong đó T26 là chốt chặn duy nhất cho lớp lỗi mất vết và T25 là chốt chặn duy nhất chống việc gắn lại policy. **Đây mới là cái cần canh.** +- Sau khi `git add`, kiểm lại `git status --porcelain` và xác nhận **không còn dòng nào bắt đầu bằng `??`** trong `src/` hay `tests/` trước khi commit. +- Ngoài phạm vi mã còn có `.claude/WAL.md`, `.claude/governance/.session-counter.json`, `.claude/agent-memory/test-specialist/MEMORY.md` và các tệp trong thư mục run. Tách hay gộp là quyền của lead, nhưng đừng để chúng che mất hai dòng `??` ở trên khi đọc `git status`. + +**Một việc bắt buộc sau khi deploy:** đợt này **không có migration nhưng có seed menu mới**, nên phải **khởi động lại API** thì `DbInitializer` mới chạy và mới có ba thứ: hai mục menu mới, việc đổi nhãn "Duyệt" thành "Đang duyệt", và các hàng quyền cho hai khoá mới. Không khởi động lại thì menu y như cũ và owner sẽ báo "chưa thấy gì". + +--- + +# Kết luận + +**Tổng 12 FLAG — 2 HIGH (1 đã đóng ngay trong lượt), 4 MED, 6 LOW. Còn mở: 11.** + +| Mã | Mức | Trạng thái | Một câu | +|---|---|---|---| +| H1 | HIGH | 🔴 **CÒN MỞ** | FE gate nút xóa bằng `allowApproverDelete`, mà BE không có trường đó trong DTO nên nút không bao giờ hiện. | +| H2 | HIGH | ✅ ĐÃ ĐÓNG | Policy `PurchaseEvaluations.Delete` chặn đúng nhóm người duyệt — owner chọn phương án gỡ policy; đã kiểm lại và đúng, không mở lỗ ghi nào. | +| M1 | MED | mở | Chú thích khẳng định `IgnoreQueryFilters` là nơi duy nhất trong `src/Backend`; thực tế 17 lần dùng ở 2 tệp. Vẫn còn ở `PeSoftDeleteFeatures.cs:178` và `Controller:173`. | +| M2 | MED | mở | Chú thích T26 vẫn khai "ca này đang đỏ, bug production" trong khi ca đã xanh; mốc dòng cũng trôi. | +| M3 | MED | mở | Thiếu lớp test ràng buộc hợp đồng BE với FE — chính lỗ này để H1 sống qua 561 ca xanh. Kèm hụt mới: T25 viết lại đã bỏ phép kiểm "tên policy có được đăng ký". | +| M4 | MED | mở | Nhiều mốc `:NNN` trỏ sai vị trí thật; nay thêm hai câu chú thích đã lạc hậu sau khi gỡ policy (`PeWorkflowPanel.tsx:117` còn nhắc "tầng authz độc lập", `Controller:147` dẫn `DbInitializer.cs:2515` trong khi chỗ thật là `:2534`). | +| L1 | LOW | mở | Chú thích giải thích bản vá thứ tự bằng mô hình sai một nhịp (lead đã nhận). | +| L2 | LOW | mở | `IgnoreQueryFilters` có phạm vi cả truy vấn nên gỡ luôn bộ lọc của các bảng được nối vào. | +| L3 | LOW | mở | Màn "Đã xóa" cố định 50 dòng, không phân trang, không giới hạn phía máy chủ — kế thừa khuyết tật sẵn có. | +| L4 | LOW | mở | Nút xóa cũng sống ở trang chi tiết toàn màn, tức rộng hơn phát biểu "chỉ ở màn duyệt". | +| L5 | LOW | mở | Mốc "xóa ≈" lấy từ `updatedAt`, không phải mốc xóa thật. | +| L6 | LOW | mở | **Mới, sinh ra từ việc gỡ policy:** mọi tài khoản đăng nhập nay chạm được thân handler và đọc được bốn loại phản hồi phân biệt, kèm tên Bước trong thông điệp lỗi. Không ghi được gì, chỉ rò siêu dữ liệu. | + +Phần lõi nghiệp vụ đã đúng và đã được đo: ba rào per-người, ghi vết, xóa mềm, lũy kế, rào IDOR, cách ly `IgnoreQueryFilters`. Việc gỡ policy hôm nay được thực hiện đúng và đủ — tôi đã truy từng dòng chặn và xác nhận không có `.First()` nào cho người ngoài mượn cờ của người khác. Nhưng còn **đúng một** chỗ đứt: cờ F6 chưa bao giờ đi được từ cơ sở dữ liệu ra tới trình duyệt. Chừng nào chưa nối, owner mở màn duyệt sẽ không thấy nút, và buổi nghiệm thu kết thúc ở câu "chẳng thấy gì". + +**ĐỪNG-DEPLOY** — chỉ còn đúng một chốt chặn H1 (thêm `AllowApproverDelete` vào DTO và truyền nó ở `PurchaseEvaluationFeatures.cs:1097`, hai dòng); nối xong hai dòng đó rồi đẩy thì tôi đổi sang DEPLOY-OK ngay, vì mọi trục còn lại đã đo và đứng. + +