Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-diff-dot2.md
2026-07-27 13:50:59 +07:00

34 KiB
Raw Blame History

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 CostControl403 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()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 DeletedModified (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:

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:1038-1062), trong đó :1038db.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?? 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à DuyetNccDuyetNccPhuongAn, 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
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 ApprovedDeleted.
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 DaDuyetTraLai để 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 PermissionsCanDelete = 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:153-248 trong khi thực tế là :50-160: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 M1M2: 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: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 PeWorkflowPanelkhô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ó deletedAtdeletedBy; 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 deletedAtdeletedBy vào DTO riêng của màn này.