# Reviewer — SOI ĐỐI-KHÁNG DIFF ĐỢT 2 (cửa cuối trước deploy prod UAT)

- **Run:** `2026-07-27-S155-pe-delete-approver` · lượt: `reviewer-diff-dot2`
- **Ngày:** 2026-07-27
- **Nền:** đợt 1 đã deploy + verify PASS 6/6 trên prod (`b1bae77`) — cờ F6 `AllowApproverDelete` + `PUT /api/approval-workflows-v2/{id}`.
- **Đợt 2 (lượt này):** nút XÓA phiếu ở màn duyệt + menu 3 mục + màn "Đã xóa".
- **Trạng thái file:** ĐANG GHI (append từng finding — file này hợp-lệ ở mọi thời điểm; dòng cuối cùng có marker END + TOTAL khi hoàn tất).

## Phạm vi vật lý đã chốt từ `git status` (13:2x, 2026-07-27)

**Untracked (2):**
- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs` (264 dòng)
- `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs`

**Modified (11 file code + 4 file governance/memory ngoài phạm vi soi):**
```
 fe-admin/src/components/Layout.tsx                     |  15 ++-
 fe-admin/src/components/pe/PeWorkflowPanel.tsx         | 123 ++++++-
 fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx  | 103 ++++--
 fe-admin/src/types/purchaseEvaluation.ts               |   4 +
 fe-user/... (mirror 4 file y hệt)
 src/Backend/.../PurchaseEvaluationsController.cs       |  31 ++
 src/Backend/.../Identity/MenuKeys.cs                   |   9 ++
 src/Backend/.../Persistence/DbInitializer.cs           |  21 +-
 11 files changed, 517 insertions(+), 31 deletions(-)
```

🔴 **XÁC NHẬN NGAY: KHÔNG có file nào dưới `Migrations/`** trong cả tracked lẫn untracked ⇒ khớp lời khai của lead ("đợt 2 không có migration"). Rủi ro F8 của đợt 1 (migration untracked bị `commit -a` bỏ lại) **KHÔNG tái diễn theo đúng hình dạng cũ** — nhưng xem mục THAO-TÁC-COMMIT ở cuối: hình dạng ĐÃ ĐỔI, không phải đã biến mất.

---

(đang soi — các mục dưới đây append dần)

---

# 🔴 H1 (HIGH) — Nút "Xóa phiếu" KHÔNG BAO GIỜ HIỆN: FE đọc field mà BE không hề trả

**File:**
- FE gate: `fe-admin/src/components/pe/PeWorkflowPanel.tsx:136` (mirror `fe-user/src/components/pe/PeWorkflowPanel.tsx:136`)
- FE khai type: `fe-admin/src/types/purchaseEvaluation.ts:431` (mirror `fe-user/...:431`)
- BE DTO: `src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs:123-131`
- BE site dựng DTO (DUY NHẤT): `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:1088-1097`

**Điều sai.** FE gate nút xóa bằng `levelOptions?.allowApproverDelete === true`. `levelOptions` = `evaluation.currentLevelOptions`, ánh xạ đúng `ApprovalWorkflowOptionsDto`. Record đó có **đúng 8 thành viên**, KHÔNG có `AllowApproverDelete`; site dựng duy nhất `:1088` truyền **đúng 8 đối số**, cũng không có cờ F6. Vậy JSON của `GET /api/purchase-evaluations/{id}` **không chứa khoá `allowApproverDelete`** ⇒ ở runtime giá trị là `undefined` ⇒ `=== true` luôn false ⇒ **nút không render cho bất kỳ ai, kể cả admin đã bật cờ F6 trong Designer**.

Toàn bộ phần owner thật sự cần (nút XÓA ở màn duyệt) **không tới được tay người dùng**. BE handler + endpoint vẫn đúng, nhưng không có đường FE nào gọi tới nó.

**Vì sao mọi cổng xanh mà vẫn lọt.** `tsc --noEmit` xanh vì type FE là bản chép tay, khai `allowApproverDelete: boolean` — trình biên dịch tin lời khai, không đối chiếu BE. `dotnet build` xanh vì record positional 8 tham số nhận đúng 8 đối số. 561 test xanh vì test gọi thẳng handler qua MediatR, không đi qua DTO detail. Đây đúng lớp **xanh-giả**: mọi thước đo đều đo chỗ khác.

**Phép thử phá (làm rồi).** (i) `grep -rn "ApprovalWorkflowOptionsDto" src/Backend --include=*.cs` → chỉ 4 hit: 1 định nghĩa, 1 khai báo trong `PeDetailBundleDto:301`, 1 khai báo biến `:1045`, 1 site `new` `:1088`. Không có site thứ hai nào có thể bơm cờ. (ii) `grep -rn "AllowApproverDelete" src/Backend` → cờ chỉ sống ở entity, config, migration, `ApprovalWorkflowV2AdminFeatures` (màn Designer) và `PeSoftDeleteFeatures:114`; **không xuất hiện lần nào** trong `PurchaseEvaluationFeatures.cs`. (iii) Nếu ai đó đã truyền 9 đối số vào record 8 tham số thì `dotnet build` phải đỏ — build 0 Error nên loại trừ.

**Đề xuất sửa:** thêm `bool AllowApproverDelete` vào `ApprovalWorkflowOptionsDto` (cuối record) và truyền `curLevel.AllowApproverDelete` tại `PurchaseEvaluationFeatures.cs:1097`. Kèm 1 test khoá chiều: dựng bundle detail cho approver có `AllowApproverDelete=true` rồi assert `bundle.CurrentLevelOptions!.AllowApproverDelete == true` — test này phải ĐỎ trên mã hiện tại, nếu nó xanh ngay thì test sai.

---

# 🔴 H2 (HIGH) — Policy `PurchaseEvaluations.Delete` chặn đúng nhóm người mà tính năng nhắm tới

**File:** `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:164` · `src/Backend/SolutionErp.Api/Authorization/MenuPermissionHandler.cs:40-50` · seed `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:2534`

**Điều sai.** Endpoint mới gắn `[Authorize(Policy = "PurchaseEvaluations.Delete")]`. `MenuPermissionHandler` so **khớp khoá chính xác** (`p.MenuKey == req.MenuKey`, không đi cây cha-con) nên policy này đọc cờ `CanDelete` của hàng `PurchaseEvaluations` (khoá gốc). Đo thật trên DB Dev:

```
sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q
  "SELECT r.Name,p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId
   WHERE p.MenuKey='PurchaseEvaluations'"
→ 13 hàng · CanDelete=1 CHỈ ở: Admin, DeptManager
→ CanDelete=0: Accounting, AuthorizedSigner, CatalogManager, CostControl, Director,
               Drafter, Equipment, Finance, HrAdmin, Procurement, ProjectManager  (11/13)
```

Người duyệt trong quy trình V2 là **NV cụ thể**, vai thường là Procurement / CostControl / Director / ProjectManager — tất cả `CanDelete=0`. Đo tiếp trên Dev: người duy nhất đang là approver (`ApprovalWorkflowLevels.ApproverUserId`) là `binh.le@solutions.com.vn`, vai `CostControl` ⇒ **403 ngay ở tầng policy**, chưa kịp chạm ba rào trong handler.

Tệ hơn: FE cũng gate bằng chính quyền đó (`PeWorkflowPanel.tsx:141` `can(MenuKeys.PurchaseEvaluations,'Delete')`), nên người dùng **không thấy nút, không thấy lỗi** — đúng hình dạng gotcha #44 (403 im lặng) mà dự án đã vấp.

Và không có đường tự chữa: `SeedPurchaseEvaluationPermissionDefaultsAsync` **bỏ qua hàng đã tồn tại** (`DbInitializer.cs:2538`), `SeedAllRolesReviewReadPermissionsAsync` chỉ nâng `CanRead`/`CanCreate` (`:2139-2140`). Prod đã có sẵn hàng ⇒ restart API bao nhiêu lần cũng không cấp thêm `CanDelete`.

**Phép thử phá (làm rồi).** Tìm xem có tầng nào nới ra không: `grep -rn 'PurchaseEvaluations\.Delete' src/Backend` → chỉ đúng 1 nơi dùng thật (`Controller:164`), phần còn lại là chú thích. `MenuPermissionHandler` không có nhánh kế thừa cha-con (đã đọc trọn 54 dòng). Kết luận đứng.

**Đề xuất sửa (chọn 1, ghi rõ vào spec):**
(a) Đổi policy sang một khoá mà nhóm approver thật sự có `CanDelete`, hoặc
(b) Bỏ `[Authorize(Policy=...)]` và giữ ba rào nghiệp vụ trong handler làm nguồn quyền duy nhất — cờ F6 vốn đã là "admin chỉ đích danh từng người", chặt hơn permission theo vai, hoặc
(c) Giữ policy nhưng thêm bước backfill nâng `CanDelete=1` trên khoá `PurchaseEvaluations` cho các vai nằm trong quy trình duyệt, và khai rõ cho owner rằng phải bật thêm ô "Xóa" ở `/system/permissions`.
Kèm điều kiện nghiệm thu: đăng nhập bằng chính tài khoản approver (không phải admin) và thấy nút, bấm được, trả 204.

---

# ✅ ƯU TIÊN 1 — bản vá thứ-tự `Remove` trước / `Add` sau: ĐỨNG VỮNG (3 câu hỏi, 3 câu trả lời)

**Vật:** `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs:139` (`Remove`) và `:141` (`Add`), một `SaveChangesAsync` ở `:158`.

### (a) Có chắc không, hay chỉ đúng trên SQLite còn hỏng trên SQL Server?

**Chắc — và không phải chuyện của provider.** Cả hai nửa của cơ chế nằm trong change-tracker, chỗ chưa hề sinh một câu SQL nào:
- Cascade lúc `Remove()` là `ChangeTracker.CascadeDeleteTiming` — thuộc tính của `ChangeTracker`, không phải tuỳ chọn của provider. Quan hệ đang xét được cấu hình `OnDelete(DeleteBehavior.Cascade)` tại `src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs:78`, nên cascade phía client có hiệu lực ở mọi provider như nhau.
- Phần "cứu" nằm ở `AuditingInterceptor` đổi `Deleted` → `Modified` (`src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs:56-62`). Đó là `ISaveChangesInterceptor.SavingChangesAsync`, chạy trước khi `StateManager` bắt đầu lượt lưu.
- Vì phiếu chỉ bị **cập nhật** (xoá mềm) chứ không bị `DELETE` thật, **không có** cascade tầng cơ sở dữ liệu nào kích hoạt trên SQL Server. Câu lệnh gửi đi chỉ là một `INSERT` (changelog) và một `UPDATE` (phiếu), theo thứ tự hợp lệ về khoá ngoại.

**Phép thử phá (đã chạy thật, không suy luận suông):**
```
dotnet test tests/SolutionErp.Infrastructure.Tests --filter FullyQualifiedName~PeDeleteByApproverTests
→ Passed! Failed: 0, Passed: 10, Total: 10 (5s)
```
Trong đó T26 đọc **ngược từ cơ sở dữ liệu** (`AsNoTracking`) và đòi đúng 2 dòng changelog. Với thứ tự cũ (`Add` trước `Remove`) số đó là 0 ⇒ ca này phân biệt được hai thứ tự, không phải ca xanh-mọi-lúc.

### (b) Còn lượt cascade nào ở `SaveChanges` quét lại không?

**Về mặt hệ quả: không.** T26 đọc dòng changelog **từ đĩa sau khi `SaveChanges` đã xong**, nên nếu còn lượt quét thứ hai gỡ mất bản ghi thì con số phải là 0. Nó là 2.

⚠️ **Nhưng lời giải thích trong chú thích đang sai một nhịp** — xem LOW-1 bên dưới. Chú thích `:135-136` viết "Thêm changelog SAU khi state đã đổi thì cascade không còn quét tới nó nữa". Tại thời điểm `Add()` chạy, trạng thái của phiếu **vẫn đang là `Deleted`** — interceptor mãi tới `SaveChanges` mới đổi. Thứ thật sự cứu bản ghi là: lượt cascade tức thời đã xảy ra xong tại `Remove()` khi chưa có đứa con nào để gỡ. Người sau đọc chú thích rồi suy theo mô hình sai có thể vô tình dời `Add()` lên trên.

### (c) Còn site nào khác mang cùng hình dạng "Add con → Remove cha → 1 SaveChanges"?

**Không. Kiểm lại độc lập, không tin lời test-specialist.** Liệt kê vét cạn 34 lời gọi `db.*.Remove(...)`/`RemoveRange(...)` trong `SolutionErp.Application` + `SolutionErp.Infrastructure` (trừ `Migrations/`), rồi lọc những chỗ có `db.*.Add(...)` đứng trước trong phạm vi 40 dòng → còn **8 ứng viên**. Xét từng cái theo quan hệ cha-con thật:

| Site | Vật bị `Remove` | Vật được `Add` trước đó | Có phải cha-con? |
|---|---|---|---|
| `ApprovalWorkflowV2AdminFeatures.cs:828` | `ApprovalWorkflowLevel` (dòng cũ) | `ApprovalWorkflowLevel` (dòng mới) | **Không** — hai anh em cùng một Bước |
| `CatalogsFeatures.cs:93 / :173 / :253 / :331` | thực thể danh mục | chính thực thể đó, nhưng ở **handler Create khác** | Không — cửa sổ 40 dòng bắc qua ranh giới hàm, dương-giả |
| `PurchaseEvaluationDetailFeatures.cs:262` | `PurchaseEvaluationDetail` | `PurchaseEvaluationChangelog` | Không — changelog treo vào **phiếu**, không vào dòng hạng mục |
| `PurchaseEvaluationDetailFeatures.cs:399` | `PurchaseEvaluationQuote` | `PurchaseEvaluationChangelog` | Không — như trên |
| `PurchaseEvaluationSupplierFeatures.cs:170` | `PurchaseEvaluationSupplier` | `PurchaseEvaluationChangelog` | Không — như trên |

Hai đường xoá phiếu còn lại (`PurchaseEvaluationFeatures.cs:1408` xoá nháp, `ContractFeatures.cs:577` xoá hợp đồng) đều nạp thực thể bằng `FirstOrDefaultAsync` **không `Include`**, nên lúc `Remove()` không có đứa con nào đang được theo dõi để cascade đụng tới, và cũng không ghi changelog. ⇒ **Xác nhận: `PeSoftDeleteFeatures.cs` là site duy nhất.** Lời khai của test-specialist đúng.

**Điểm đáng giữ:** T26 là chốt chặn thật cho lớp lỗi này. Nếu sau này ai gom phần ghi vết vào một hàm trợ giúp và gọi nó trước `Remove()`, T26 sẽ đỏ. Đừng nới assert của nó.

---

# ƯU TIÊN 2 — ba cái bẫy: **2 được thi hành đúng, 1 lời-khai-về-bẫy thì sai**

## 2.1 `IgnoreQueryFilters()` GỠ chứ không ĐẢO — ✅ ĐÚNG

`PeSoftDeleteFeatures.cs:182-183` viết liền một mạch:

```csharp
from e in db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
        .Where(x => x.IsDeleted)
```

Mệnh đề `.Where(x => x.IsDeleted)` **có mặt**, đặt ngay trên nguồn `e` trước mọi phép nối. Không có nhánh nào bỏ qua nó.

**Chứng-nhân T22b có răng thật hay không — kiểm bằng số học, không bằng cảm giác.** `PeDeleteByApproverTests.cs:449-458` khẳng định trước rằng bảng có **3** dòng (`IgnoreQueryFilters().CountAsync() == 3`), trong đó **2** dòng đi qua bộ lọc thường, rồi mới đòi `result.Total == 1`. Nếu ai gỡ `.Where(x => x.IsDeleted)`, truy vấn trả cả 3 nên `3 != 1` và ca đỏ. Người gọi là admin nên khối IDOR bị bỏ qua, tức con số 1 không thể do rào IDOR tạo ra. Ba phiếu đều gắn `Project` thật (`:436-437` nói rõ lý do: phép nối `Projects` là INNER, phiếu không có dự án sẽ tự rơi và làm phép đo mất răng). Kết luận: **T22b phân biệt được hai thế giới.**

## 2.2 Rào IDOR — ✅ TÁI LẬP ĐÚNG NGUYÊN VĂN

So từng dòng `PeSoftDeleteFeatures.cs:200-217` với khuôn `PurchaseEvaluationFeatures.cs:596-617`: cùng cổng `!currentUser.Roles.Contains(AppRoles.Admin)`, cùng `GetEligiblePhases(currentUser.Roles)` (gọi qua `ListPurchaseEvaluationsQueryHandler.GetEligiblePhases`, khai báo `internal static` nên gọi được), cùng cách dựng `userApprovalWfIds` từ `ApprovalWorkflowLevels` theo `ApproverUserId`, và cùng biểu thức ba nhánh: là người soạn, hoặc phase nằm trong nhóm vai được xem, hoặc là người duyệt của quy trình đã ghim và phiếu đã rời trạng thái nháp. Không thiếu nhánh, không nới nhánh.

**T22c dùng mấy đối chứng — đếm thật: HAI.** Tại `:499-509`: (b) người soạn phiếu và (c) người duyệt của quy trình đã ghim, cả hai đều phải thấy đúng 1 phiếu; còn (a) người ngoài thấy 0. Cùng dữ liệu, cùng truy vấn, chỉ đổi người gọi, nên số 0 là do rào chứ không do bảng rỗng. Đây là kiểu chứng đúng.

## 2.3 "`IgnoreQueryFilters` chỉ ở ĐÚNG 1 handler" — ⚠️ **M1 (MED): bất-biến được thi hành ĐÚNG, nhưng lời khai trong mã thì SAI**

Ba điều **cần** thì đều đạt, đã kiểm từng cái:

- Không có hàm trợ giúp dùng chung trả `IQueryable`: truy vấn là biến cục bộ `var q = from e in ...` nằm gọn trong thân handler (`:182-193`), không thoát ra ngoài.
- `PurchaseEvaluationConfiguration.cs:84` (`b.HasQueryFilter(x => !x.IsDeleted)`) **không bị đụng** — tệp không có mặt trong `git status`.
- **Không rò sang `PeBudgetAccumulator`**: tệp không bị sửa, và quét toàn kho không có lần dùng `IgnoreQueryFilters` nào trong nó. T21 và T22 đo trực tiếp bốn đại lượng sau khi màn "Đã xóa" đã chạy, nên khoá luôn chiều này.

**Nhưng câu chữ trong mã thì sai sự thật.** Hai chỗ khẳng định đây là nơi **duy nhất** trong toàn `src/Backend` dùng `IgnoreQueryFilters`:

- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs:178`
- `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:173`

Quét thật bằng `grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs` cho **17 lần dùng ở 2 tệp**: 15 lần nằm trong `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` (`:957-999` và `:1038-1062`), trong đó `:1038` là **`db.PurchaseEvaluations.IgnoreQueryFilters()`** — cùng một `DbSet`, không phải bảng khác. Vậy mệnh đề "duy nhất" sai cả ở mức toàn kho lẫn ở mức thu hẹp về riêng `PurchaseEvaluations`.

Vì sao đáng ghi thành FLAG chứ không bỏ qua: một bất-biến ghi sai sẽ dẫn người sau đi sai đường — hoặc tưởng có thể "gom về một mối" mà thật ra đang phá `ApprovalWorkflowV2AdminFeatures`, hoặc dựa vào nó để bỏ bớt một phép kiểm.

**Đề xuất sửa:** đổi câu chữ thành mệnh đề đúng mà vẫn đủ răng, đại ý: "đây là nơi duy nhất dùng `IgnoreQueryFilters` cho **luồng liệt kê phiếu**; chỗ còn lại là `ApprovalWorkflowV2AdminFeatures` dùng để dò chữ ký mồ côi — cấm gộp hai đường."

---

# ƯU TIÊN 3 — các trục còn lại

## 3.4 Guard xóa: PER-NGƯỜI, không bypass admin — ✅ ĐÚNG

Ba rào nằm đúng thứ tự tại `PeSoftDeleteFeatures.cs:65` (phase `ChoDuyet`), `:104` (đúng lượt) và `:114` (cờ F6). Rào thứ hai dùng `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)` rồi **ném `ForbiddenException`** khi không khớp; **không** có `?? pendingLevelGroup.First()`, tức không mượn khuôn admin-ký-thay ở `PurchaseEvaluationWorkflowService.cs:744-745`. Rào thứ ba đọc `matchingLevel.AllowApproverDelete` trên **đúng dòng vừa khớp người**, không phải `g.Any(l => ...)`.

**Thử phá:** tìm mọi đường mà một tài khoản Admin có thể luồn qua. `grep "Admin" PeSoftDeleteFeatures.cs` chỉ ra **một** lần dùng `AppRoles.Admin`, và nó nằm ở handler **danh sách** (`:200`), không nằm ở handler xóa. Nghĩa là một Admin không phải người duyệt hiện tại vẫn bị chặn ở `:104`, đúng như chủ ý. Chứng-nhân là T24b: cùng một Cấp, cùng một phiếu, bob (không được tick) bị chặn còn alice (được tick) xóa được. Với cách đọc `Any` thì bob sẽ lọt và ca đỏ ngay. Thứ tự bob-trước-alice trong ca là bắt buộc, và test đã nêu đúng lý do.

## 3.5 Đường xóa nháp cũ — ✅ NGUYÊN VẸN

`PurchaseEvaluationsController.cs:148-154`: phần thêm vào chỉ là **chú thích**; `[HttpDelete("{id:guid}")]` vẫn không mang `[Authorize(Policy = ...)]`. Lớp chỉ có `[Authorize]` trần (`:15`), nên mọi tài khoản đăng nhập vẫn gọi được như trước, và rào vẫn là phase `{DangSoanThao, TuChoi}` ở `PurchaseEvaluationFeatures.cs:1404-1406`.

**Thử phá:** kiểm thuộc tính ở **cấp lớp** (`:13-16`) — nếu lớp mang policy thì mọi endpoint thừa hưởng và `Drafter` mất quyền dù action để trống. Không có. T25 (`:648-650`) khoá chiều này bằng phản chứng `GetActionAuthorize(..., "Delete") == null`.

## 3.6 Menu seed — ✅ ĐÚNG CẢ BA Ý

- **Đổi nhãn "Duyệt" thành "Đang duyệt":** đúng, và đúng chỗ. Vòng upsert `:1897-1911` khi gặp khoá đã tồn tại thì chỉ chỉnh `Order` rồi `continue`, **không** đụng `Label`, nên sửa chữ trong `tree.Add` là vô ích trên cơ sở dữ liệu đang chạy. Việc đổi tên nằm ở `labelBackfill` `:1933-1934`, chạy sau và có ghi đè `item.Label`. Hai khoá liệt kê là `DuyetNcc` và `DuyetNccPhuongAn`, mà `MenuKeys.PurchaseEvaluationTypeCodes` (`MenuKeys.cs:131-132`) đúng bằng hai mã đó, nên phủ hết, không sót loại nào.
- **Hai khoá mới có mặt ở CẢ HAI danh sách quyền:** có — `DbInitializer.cs:2110-2111` (trong `SeedAllRolesReviewReadPermissionsAsync`) và `:2514-2515` (trong `SeedPurchaseEvaluationPermissionDefaultsAsync`). Không lệch bên nào.
- **`MenuKeys.All` không bị thêm nhầm:** đếm tay từng phần tử trong khối `MenuKeys.cs:157-174` ra **54**, và không phần tử nào chứa `Approved` hay `Deleted`. Phần thêm của diff chỉ gồm 2 hàm sinh khoá và 7 dòng chú thích. Vậy số policy dẫn xuất giữ nguyên **54 × 4 = 216**, đúng như yêu cầu giữ 54/216.
- Phụ: `peOrder` khởi tạo bằng 1 (`:1866`) và tăng dần cho cả hai loại; thêm 2 lá làm dịch số thứ tự của loại thứ hai, nhưng vòng upsert có nhánh cập nhật `Order` cho khoá cũ nên tự chỉnh lại, và `Order` chỉ có nghĩa trong phạm vi cùng một cha, nên không va chạm.

## 3.7 FE gate so với rào BE — ba trên bốn khớp, cái thứ tư chính là H1

| Rào BE | Đối ứng FE | Khớp? |
|---|---|---|
| phase `ChoDuyet` (`:65`) | `evaluation.phase === PurchaseEvaluationPhase.ChoDuyet` (`PeWorkflowPanel.tsx:134`) | ✅ |
| đúng lượt (`:104`) | `actorIsCurrentApprover` dựng từ `currentApproval.approvers` (`:128`) — cố ý **không** dùng `actorInV2Level` vì biến đó có `isAdmin ||` | ✅ chọn đúng, lý do ghi rõ trong mã |
| cờ F6 per-người (`:114`) | `levelOptions?.allowApproverDelete === true` (`:136`) | ❌ **H1** — trường này không bao giờ có trong payload |
| policy `PurchaseEvaluations.Delete` (`Controller:164`) | `can(MenuKeys.PurchaseEvaluations, 'Delete')` (`:141`) | ✅ về hình thức, nhưng xem **H2** về mặt dữ liệu |

**Nút có lọt ra ngoài màn duyệt không? Không, ở hai chỗ dùng chính.** Đếm hết chỗ gắn thẻ `PeWorkflowPanel`: đúng **3** mỗi ứng dụng (`PurchaseEvaluationsListPage.tsx:662`, `:749`, `:788`). Hai chỗ đầu truyền `readOnly={!pendingMe}`, nên ở "Danh sách" và ở view "Đã xóa" (`pendingMe` bằng false) thì `readOnly` bằng true và mệnh đề `!readOnly` ở `:133` cắt nút ngay. Chỗ thứ ba là trang chi tiết toàn màn — xem **L4**.

**md5 gương hai ứng dụng — đo lại độc lập, không dùng số của lead:**

| Tệp | admin | user | Kết luận |
|---|---|---|---|
| `components/pe/PeWorkflowPanel.tsx` | `eb55781a...` | `eb55781a...` | **KHỚP** |
| `pages/pe/PurchaseEvaluationsListPage.tsx` | `d96e56bb...` | `d96e56bb...` | **KHỚP** |
| `components/Layout.tsx` | `50a9d9b7...` | `3e9ac7c3...` | LỆCH **có chủ ý**, lý do ghi tại `Layout.tsx:108-112`: fe-user có `WfView` trong biểu thức, fe-admin cố ý không vì không có trang tương ứng. Kiểm thật: fe-user `:125` chứa `WfView`, fe-admin `:107` không. Cả hai đều đã thêm `Approved` và `Deleted`. |
| `types/purchaseEvaluation.ts` | `cb7d1635...` | `ff9c028e...` | LỆCH, nhưng **khối S155 giống hệt từng chữ** ở cả hai (`:428-431`); chênh lệch đến từ chú thích cũ vốn đã khác nhau từ trước diff này. |

## 3.8 Test mới có ca nào xanh giả không — ✅ KHÔNG, nhưng có MỘT ca gây yên tâm sai chỗ

Chạy thật: `dotnet test --filter FullyQualifiedName~PeDeleteByApproverTests` cho **10/10 PASS**. Soi từng ca theo tiêu chí "khẳng định này có đúng ở cả hai thế giới không":

- **T21** — hai đại lượng giảm, hai đại lượng bất động, và cả hai đại lượng bất động đều **khác 0** (90 triệu và 60 triệu). Nếu chúng bằng 0 thì "không đổi" là vô nghĩa; ca đã chủ động seed thêm phiếu `DaDuyet` và `TraLai` để tránh đúng cái bẫy đó. Có răng.
- **T22, T22b, T22c** — đã phân tích ở mục 2.1 và 2.2. Có răng.
- **T23, T24, T24b** — ba ca phủ định, và chúng **không** đứng một mình: **T25b** là ca chiều thuận, nên một handler ném vô điều kiện sẽ làm T25b đỏ. Đây là cách bố trí đúng.
- **T26** — đòi đúng 2 dòng changelog đọc ngược từ cơ sở dữ liệu, kèm đối chứng ngay trong cùng ca (`ContextNote` phải là null khi lý do toàn khoảng trắng), nên "có ContextNote" không thể là hằng số. Có răng.
- ⚠️ **T25 — không xanh giả, nhưng đo nhầm tầng.** Nó chứng minh tên policy **có được đăng ký** (`MenuKeys.All` chứa `PurchaseEvaluations`, `Actions` chứa `Delete`). Nó **không** chứng minh có vai nào thật sự thoả được policy đó. Chính khoảng trống này khiến **H2** đi qua 561 ca xanh mà không ai thấy. Xem **M3**.

## 3.9 Regression — ✅ không thấy hồi quy

- **Đường dẫn không va nhau:** `[HttpGet("deleted")]` (`:174`) là đoạn chữ tĩnh, còn `[HttpGet("{id:guid}")]` (`:37`) có ràng buộc `guid` nên chuỗi `deleted` không lọt vào. Liệt kê toàn bộ `HttpGet` trong controller: không có mẫu `{id}` nào thiếu ràng buộc để tranh chấp. Tương tự, `DELETE {id:guid}/by-approver` khác hẳn `DELETE {id:guid}`.
- **Khách cũ không bị đụng:** hai endpoint đều mới hoàn toàn; không chữ ký nào bị đổi, không tham số nào bị bỏ.
- **Lũy kế sau khi xóa vẫn đúng:** T21 đo cả bốn đại lượng, còn T22 đo lại **ngay sau khi** truy vấn bỏ-lọc chạy, tức chứng luôn rằng màn "Đã xóa" không kéo phiếu đã chết quay lại phép cộng.

---

# Các FLAG còn lại

## M2 (MED) — chú thích T26 vẫn khai "ca này ĐANG ĐỎ, bug production", trong khi ca đã xanh

**Tệp:** `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs:712-724`

Khối chú thích viết nguyên văn "CA NÀY ĐANG ĐỎ — BUG PRODUCTION, CỐ Ý GIỮ ĐỎ", rồi mô tả cơ chế mất vết và kết luận "Vết KHÔNG BAO GIỜ được ghi". Đo thật thì ca **xanh** (10/10 PASS), vì bản vá đảo thứ tự của lead đã land. Các mốc dòng mà chú thích trích (`:126` Add, `:142` Remove, `:143` SaveChanges) cũng không còn khớp tệp hiện tại (`:139` Remove, `:141` Add, `:158` SaveChanges).

Vì sao là MED chứ không phải LOW: đây là văn bản **khẳng định có một lỗi production đang sống**. Người đọc kế tiếp sẽ hoặc đi vá một thứ đã vá rồi, hoặc mất lòng tin vào toàn bộ khối chú thích trong tệp. Với một tệp mà giá trị chính nằm ở phần giải thích "vì sao ca này có răng", mất lòng tin vào chú thích là mất luôn công dụng.

**Đề xuất sửa:** viết lại khối thành thì quá khứ, nêu rõ ca này từng đỏ vì thứ tự cũ, nay xanh nhờ bản vá tại `PeSoftDeleteFeatures.cs:139-141`, và giữ nguyên câu cảnh báo rằng dời lời gọi `Add` lên trước `Remove` sẽ làm ca đỏ trở lại. Cập nhật ba mốc dòng.

## M3 (MED) — thiếu hẳn lớp test buộc BE và FE nói cùng một hợp đồng

**Tệp liên quan:** `PurchaseEvaluationDtos.cs:123-131`, `PurchaseEvaluationFeatures.cs:1088-1097`, `fe-*/src/types/purchaseEvaluation.ts:428-431`

561 ca xanh mà **H1** vẫn lọt, vì không ca nào đi qua `ApprovalWorkflowOptionsDto`. Tương tự, T25 chứng minh policy tồn tại nhưng không ca nào chứng minh có vai thoả được nó, nên **H2** cũng vô hình. Hai lỗ hổng nghiêm trọng nhất của đợt này đều nằm đúng chỗ bộ test không nhìn tới.

**Đề xuất sửa (điều kiện nghiệm thu, không phải mã):**
1. Một ca dựng bundle chi tiết cho người duyệt có `AllowApproverDelete = true` rồi đòi `CurrentLevelOptions!.AllowApproverDelete == true`. Ca này phải **đỏ trên mã hiện tại**; nếu nó xanh ngay thì ca sai.
2. Một ca đối chiếu số thuộc tính của `ApprovalWorkflowOptionsDto` với số khoá trong khối `ApprovalWorkflowOptions` phía FE, hoặc rẻ hơn: một ca duyệt qua từng thuộc tính `Allow*` của `ApprovalWorkflowLevel` và đòi mỗi cái đều có mặt trong DTO. Cách này bắt được cả những lần thêm cờ về sau.
3. Một ca theo trục quyền: với vai `CostControl` (hoặc bất cứ vai nào đang nằm trong quy trình duyệt), đòi tồn tại ít nhất một hàng `Permissions` có `CanDelete = true` trên khoá `PurchaseEvaluations`. Ca này bắt đúng **H2** và sẽ đỏ ngay hôm nay.

## M4 (MED) — hai chỗ trong mã khai sai vị trí dòng của chính nó, làm người sau tra nhầm

**Tệp:** `PeSoftDeleteFeatures.cs` — chú thích trong thân handler dẫn `:167-168` cho mệnh đề `.Where(x => x.IsDeleted)` (thật ra ở `:182-183`) và `:185-202` cho khối IDOR (thật ra ở `:195-217`); tệp test `PeDeleteByApproverTests.cs:27-28` dẫn handler `:50-145` và `:153-248` trong khi thực tế là `:50-160` và `:168-263`.

Đây là lệch chú thích, không phải lệch hành vi, nên không chặn deploy. Nhưng nó cùng một họ với **M1** và **M2**: tệp này chứa rất nhiều mốc dòng trỏ chéo, và chúng đang trôi. Khi số lượng mốc sai đủ nhiều thì người đọc sẽ ngừng tra, và giá trị của toàn bộ hệ chú thích rơi về 0.

**Đề xuất sửa:** rà lại một lượt các mốc `:NNN` trỏ vào chính hai tệp mới, hoặc đổi sang trỏ theo **tên ký hiệu** (tên handler, tên biến) thay vì số dòng — tên không trôi khi chèn thêm dòng.

## L1 (LOW) — chú thích giải thích bản vá bằng một mô hình sai (lead đã nhận, ghi lại cho đủ hồ sơ)

**Tệp:** `PeSoftDeleteFeatures.cs:135-136`. Câu "Thêm changelog SAU khi state đã đổi thì cascade không còn quét tới nó nữa" sai một nhịp: lúc `Add()` chạy, phiếu **vẫn đang ở trạng thái `Deleted`**, vì `AuditingInterceptor` mãi tới `SaveChanges` mới đổi. Thứ thật sự cứu bản ghi là lượt cascade tức thời đã xảy ra xong ngay tại `Remove()`, khi chưa có đứa con nào để gỡ.

## L2 (LOW) — `IgnoreQueryFilters()` có phạm vi cả truy vấn, nên nó cũng gỡ lọc của các bảng được nối vào

**Tệp:** `PeSoftDeleteFeatures.cs:182-193` và `:250-254`

Trong EF Core, `IgnoreQueryFilters()` là toán tử **cấp truy vấn**, không phải cấp `DbSet`. Vì vậy các phép nối tới `Projects`, `Suppliers`, `Users`, `Departments`, `WorkItems`, cùng truy vấn con lấy tên đơn vị trúng thầu ở `:250-254`, đều mất bộ lọc xóa mềm của chúng. Hệ quả: màn "Đã xóa" có thể hiển thị tên dự án hoặc tên nhà cung cấp đã bị xóa mềm, trong khi danh sách phiếu sống thì phép nối INNER tới `Projects` sẽ làm cả dòng biến mất.

Không phải lỗ rò dữ liệu phiếu, và với một màn đối chiếu thì hành vi này còn dễ chịu hơn. Nhưng nó là một khác biệt hành vi chưa ai ghi lại, nên ghi vào đây.

## L3 (LOW) — hai chỗ dùng chung một khiếm khuyết sẵn có: không phân trang, không giới hạn kích thước trang

**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:70-76` (và bản gương fe-user)

Nhánh "Đã xóa" gọi với `page: 1, pageSize: 50` cố định, không có giao diện phân trang, nên nếu quá 50 phiếu đã xóa thì phần dư biến mất lặng lẽ trên một màn vốn dùng để đối chiếu. Phía máy chủ, `ListDeletedPurchaseEvaluationsQuery` cũng không có lớp `Validator` nào chặn `pageSize` lớn.

**Ghi cho công bằng:** nhánh danh sách sống ngay bên dưới (`:90-97`) cũng đúng `pageSize: 50` cố định, và `ListPurchaseEvaluationsQuery` cũng không có validator. Vậy đây là **kế thừa khuyết tật sẵn có**, không phải hồi quy do đợt 2 gây ra. Xếp LOW vì lý do đó.

## L4 (LOW) — nút xóa cũng xuất hiện ở trang chi tiết toàn màn, tức rộng hơn "màn duyệt" như phát biểu

**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:788` (và bản gương fe-user)

`PurchaseEvaluationDetailPage` gắn `PeWorkflowPanel` mà **không** truyền `readOnly`, nên giá trị mặc định `false` có hiệu lực và nút xóa có thể hiện ở đó. Diff có nhận biết điều này và đã truyền `onDeleted` để điều hướng sau khi xóa, nên không có ngõ cụt.

Đây **không** phải lỗ hổng: ba rào phía máy chủ vẫn nguyên, và trang đó cũng đang hiện các nút Duyệt hay Trả lại theo cùng một logic, tức nó vốn là một mặt duyệt. Ghi lại chỉ để phát biểu phạm vi cho đúng: nút sống ở **hai** mặt, không phải một.

## L5 (LOW) — mốc "xóa ≈" là mốc cập nhật cuối, không phải mốc xóa

**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.

<!-- END reviewer-diff-dot2 · TOTAL=12 FLAG -->
