Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-spec-review.md
2026-07-27 10:30:24 +07:00

28 KiB
Raw Blame History

reviewer-spec-review — chấm đối-kháng SPEC spec-pe-delete-approver.md

run 2026-07-27-S155-pe-delete-approver · S3 · vai reviewer (READ-only, verdict-only) Quy ước: mọi FLAG đều có file:line mở được. Mọi trục PASS đều kèm đã thử phá bằng cách nào. Ghi TRONG LÚC LÀM (chống #53).

VERDICT: PASS-WITH-FLAGS — 17 FLAG (4 HIGH / 8 MED / 5 LOW). Khung spec đứng vững: trích dẫn chịu lực đúng, mệnh đề trung tâm F.0 không bác được. Nhưng 4 lỗ HIGH sẽ cho ra bản cài đặt hỏng hoặc hở nếu giao thẳng cho hmw. H1/H2/H3 phải chốt trước khi bắt đầu code (H1 + H2 cần owner trả lời 1 câu; H3 là 2 dòng ràng buộc phải viết vào checklist).


🔴 HIGH

H1 — Nới authz mà không một ai có quyền: checklist 7 vừa phá chức năng đang chạy vừa giết luôn tính năng mới

Điều sai. Checklist 7 gắn [Authorize(Policy = "PurchaseEvaluations.Delete")] lên action Delete, và spec kết luận "0 key mới, 0 migration quyền" (§B3 dòng 66). Nhưng spec không kiểm ai đang có cờ CanDelete.

Bằng chứng đo được (Dev DB, SolutionErp_Dev, lệnh chạy thật):

SELECT r.Name, p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId
WHERE p.MenuKey='PurchaseEvaluations'

Admin=1 · DeptManager=1 · Accounting/AuthorizedSigner/CatalogManager/CostControl/Director/Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager = 0 (11/13 role = 0).

Nguồn của con số đó nằm trong mã: DbInitializer.cs:2515bool canDelete = roleName == AppRoles.DeptManager; // chỉ TPB xóa nháp. Và seeder thứ hai chỉ nâng CanRead/CanCreate, không bao giờ chạm CanDelete (DbInitializer.cs:2120-2126), lại skip-if-exists (:2118) ⇒ sửa seeder cũng không vá được prod.

Tầng policy không kế thừa: MenuPermissionHandler.cs:41-51 lọc p.MenuKey == req.MenuKey khớp chính xác rồi AnyAsync(p => p.CanDelete). Cơ chế kế thừa mà spec dẫn (GetMyMenuTreeQuery:66-72) chỉ là display-layer — đúng như spec nói, nhưng nó không cấp quyền API.

Hai hệ quả cụ thể:

  1. Regression trên chức năng đang chạy: nút "Xóa phiếu" bản Nháp (fe-user/src/components/pe/PeDetailTabs.tsx:445 + :460) hôm nay chạy được vì endpoint không có policy. Gắn policy vào ⇒ role Drafter (người soạn phiếu) mất quyền xoá nháp của chính mình.
  2. Tính năng mới chết ngay: approver trong ảnh UAT thuộc Phòng Cung ứng ⇒ role Procurement, CanDelete=0403 dù đúng lượt và đúng cờ F6.

Test 25 chỉ kiểm chiều 403 nên nó sẽ xanh trong cả hai kịch bản hỏng trên. Không có test chiều thuận (đủ quyền ⇒ 204).

Đề xuất sửa. (a) Thêm mục checklist: cấp CanDelete cho tập role đích + ghi rõ đường cấp (tick tay /system/permissions trên prod hay seeder có upgrade-path như khối :2120-2126); (b) chốt với owner: quyền xoá-nháp (Drafter) và quyền xoá-khi-đang-duyệt (approver) dùng chung 1 cờ hay tách; (c) thêm test happy-path 204 cạnh test 25.


H2 — AllowApproverDelete per-ROW hay per-CẤP: spec chốt "per-Cấp" nhưng giấu hệ quả, và mã có sẵn cả hai cách đọc

Điều sai (2 lớp).

Lớp 1 — mơ hồ thi hành. Nhà của cờ là ApprovalWorkflowLevel, mà 1 row = 1 NGƯỜI (ApprovalWorkflow.cs:83-88, ApproverUserId là field của row). "Cấp" chỉ là tập row cùng Order. Vậy AllowApproverDelete của "Cấp" phải đọc bằng cách nào? Repo đang dùng cả hai cho cờ anh em F5:

  • per-ROW: PurchaseEvaluationWorkflowService.cs:859if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)
  • per-CẤP: PurchaseEvaluationFeatures.cs:1182g.Any(l => l.AllowApproverFinalize)

⇒ hai người cài đặt sẽ ra hai kết quả khác nhau từ đúng câu chữ §B2 dòng 60 ("Cấp đó có AllowApproverDelete == true").

Lớp 2 — hệ quả chưa khai. Đọc theo per-Cấp (Any) ⇒ mọi người trong Cấp đều xoá được. Ảnh prod (run.md dòng 74) ghi rõ Cấp 2 Phòng Cung ứng có 3 người (Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). Owner chốt (1) là "Người đứng đầu phòng đc xóa khi đến lượt họ" = 1 người. Spec §2 có khai "đổi nghĩa" nhưng chỉ khai vế "thành cấp nào admin tick"không khai vế "và như vậy N người cùng Cấp đều xoá được". Đó là hệ quả trực tiếp lên quyền huỷ chứng-từ tài chính, phải nằm trên bàn.

Đề xuất sửa. Chốt bằng chữ trong spec + checklist: dùng matchingLevel.AllowApproverDelete (per-row, 1 người — sát ý owner) hay pendingLevelGroup.Any(...) (per-Cấp, N người). Nếu chọn per-Cấp thì thêm 1 dòng khai hệ quả "N người" cho owner duyệt lại.


H3 — Endpoint "Đã xóa" thiếu 2 ràng buộc bắt buộc; D3 kín về rò-accumulator nhưng hở về rò-dữ-liệu

Điều sai. Checklist 8 chỉ ghi "handler RIÊNG list 'Đã xóa' với IgnoreQueryFilters() — tuân D3". Thiếu hai thứ:

  1. Thiếu .Where(x => x.IsDeleted). IgnoreQueryFilters() gỡ toàn bộ filter chứ không đảo nó ⇒ query trả cả phiếu sống lẫn phiếu xoá. Màn "Đã xóa" sẽ liệt kê mọi phiếu trong hệ thống. Không mục nào trong spec nói câu này; test 22 chỉ soi accumulator nên không bắt được.
  2. Thiếu tái lập IDOR-scope. Handler list hiện hành có rào chặt: PurchaseEvaluationFeatures.cs:596-617 — non-admin chỉ thấy phiếu khi (i) mình là Drafter, (ii) phase ∈ eligiblePhases theo role, hoặc (iii) mình là approver trong workflow đã pin. Handler MỚI dựng từ đầu mặc định không có gì. Kết quả: mọi user đăng nhập đọc được toàn bộ phiếu đã xoá của mọi dự án, kèm BudgetPeriodAmount + tên gói thầu + NCC (đúng bộ field của PurchaseEvaluationListItemDto).

Đây là 2 "đường rò thứ 4/thứ 5" mà 3 điều cấm của D3 không phủ — D3 chỉ chống rò sang accumulator, không chống rò ra người dùng.

Đề xuất sửa. Viết thẳng vào checklist 8: IgnoreQueryFilters().Where(x => x.IsDeleted) sao chép nguyên khối IDOR :596-617 (hoặc siết hơn: chỉ Drafter của phiếu + approver của workflow pin + Admin). Thêm 2 test: (a) list "Đã xóa" không chứa phiếu sống; (b) user ngoài phạm vi không thấy phiếu đã xoá của người khác.


H4 — Lệnh Update quy trình (F-1…F-11) không khai policy authz, trong khi chính nó là cổng cấp quyền

Điều sai. F-1 chỉ nói "UpdateAwDefinitionCommand mới". Không dòng nào nói endpoint mới phải mang policy. Mọi verb GHI hiện có của controller này đều có: ApprovalWorkflowsV2Controller.cs:29 (POST), :40 (PATCH user-selectable), :48 (DELETE) — cả ba đều [Authorize(Policy = "Workflows.Create")]; class chỉ [Authorize] trần (:18).

Vì sao nghiêm trọng hơn một endpoint thường: hai thao tác mà F-1 cho phép đều là hành vi cấp quyền:

  • op #1 "thêm người vào Cấp có sẵn" ⇒ tự thêm mình vào pendingLevelGroup ⇒ lọt guard PurchaseEvaluationWorkflowService.cs:702-708duyệt được phiếu người khác;
  • op #2 "bật/tắt cờ Allow*" ⇒ gồm chính F6 ⇒ tự cấp quyền xoá phiếu đang duyệt.

Thiếu 1 dòng attribute = leo thang đặc quyền cho mọi tài khoản đăng nhập.

Đề xuất sửa. Ghi rõ trong F-1: [Authorize(Policy = "Workflows.Create")] (hoặc key riêng nếu owner muốn tách), + 1 test 403 cho user thường, + cân nhắc ghi vết thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng không đòi vết cho việc sửa quyền — bất đối xứng).


🟠 MED

M1 — Quy tắc F.2 không đủ chặt: 3 phản ví dụ giữ nguyên cả 2 tập Order mà vẫn hỏng phiếu đang chạy

Phát biểu đang xét: "AN TOÀN ⟺ KHÔNG đổi tập {Step.Order} và tập {Level.Order}" (§F.2 dòng 208).

(a) Cờ AllowApproverFinalize (F5) bật/tắt giữa chừng — đổi ĐIỂM KẾT THÚC của phiếu đang chờ. PurchaseEvaluationWorkflowService.cs:859-878: khi matchingLevel.AllowApproverFinalize bật, người duyệt tại cấp đó kết thúc luôn (Phase = DaDuyet, :866, kèm ApplyBudgetSnapshotOnFinalizeAsync :865 = đóng băng số tiền). Bật cờ này cho một Cấp mà phiếu chưa đi tới ⇒ phiếu đang chờ nay không lên Ban Giám đốc nữa. Tắt ⇒ ngược lại. Spec xếp mọi cờ Allow* vào AN TOÀN (#2) "thuộc tính row, 0 đụng Order", trong khi xếp CeoApprovalThreshold vào HỎI OWNER (#12) với lý do "đổi nghĩa quyết định đang chờ". Cùng một lý do, hai kết luận. F5 còn nặng hơn threshold: threshold vẫn cần CCM chủ động tích (:881-901), F5 thì đọc thẳng ở :859.

(b) matchingLevel fallback pendingLevelGroup.First() — thao tác #1 "thêm người" chạm đúng chỗ này. :733-734: var matchingLevel = pendingLevelGroup.FirstOrDefault(l => … l.ApproverUserId == actorUserId) ?? pendingLevelGroup.First();. Nhánh .First() chạy khi Admin duyệt thay. Thứ tự bên trong nhóm do OrderBy(l => l.Order) (:689) quyết — mà các row cùng Order thì không có tie-breaker ⇒ thứ tự do DB trả về. Thêm/bớt row cùng Cấp có thể đổi "row đầu tiên" ⇒ (i) chữ ký admin gắn sang Level row của người khác, (ii) UPSERT LevelOpinion khoá theo (PE, ApprovalWorkflowLevelId) (:736-738) ghi vào row khác. Spec có nhắc First() ở nguồn (sub-invest-wfver-4 F9) nhưng không đưa vào bảng 13 thao tác — đúng chỗ trục 3 nghi ngờ.

(c) Thêm người vào Cấp ĐÃ QUA ⇒ lịch sử duyệt hiển thị SAI. ComputeLevelStatus (PurchaseEvaluationFeatures.cs:1135-1148) thuần con-trỏ: stepIdx0 < currentIdx → "Done". Row mới thêm vào một Cấp đã qua sẽ render "Done" dù người đó chưa từng kýkhông có LevelOpinion nào. Ở phiếu DaDuyet còn tuyệt đối hơn: :1137 if (isTerminalDone) return "Done". Bảng 13 thao tác chỉ soi đường duyệt (:689-702), không soi 4 read-site hiển thị cho ca thêm.

Đề xuất sửa. Tách #2 thành 2 dòng: cờ không đổi luồng (F1 4 mode, F3, F4) = ; cờ đổi luồng/quyền (F5 AllowApproverFinalize, F6 AllowApproverDelete) = ⚠️/ cùng hạng với CeoApprovalThreshold. Thêm 2 dòng vào bảng: "thêm người vào Cấp ĐANG CHỜ khi có khả năng Admin-duyệt-thay" và "thêm người vào Cấp ĐÃ QUA". Bổ sung câu: quy tắc F.2 chỉ bảo đảm con trỏ không dịch, KHÔNG bảo đảm ngữ nghĩa/hiển thị không đổi.

M2 — Bảng 13 thao tác bỏ sót 2 trường admin sửa được mà vẫn giữ nguyên 2 tập Order

ApprovalWorkflow.Code (ApprovalWorkflow.cs:23) và ApplicableType (:25).

  • Đổi Code ⇒ vỡ chuỗi version vì nextVersion = MAX(Version) WHERE Code = request.Code (ApprovalWorkflowV2AdminFeatures.cs:325-328) — bản kế tiếp có thể tụt về v1 hoặc đụng version cũ.
  • Đổi ApplicableType ⇒ (i) phiếu đang pin bỗng chạy workflow của module khác; kiểm tra khớp loại chỉ xảy ra lúc pin (PurchaseEvaluationFeatures.cs:258-265), không có re-validate; (ii) vỡ bất-biến "chỉ 1 active mỗi type" vì khối hạ-active lọc theo ApplicableType (:331-334).

Cả hai giữ nguyên {Step.Order}{Level.Order} ⇒ theo chữ của F.2 chúng là "AN TOÀN". Nếu F-1 là allow-list đóng (chỉ #1#2#3) thì thực thi vẫn an toàn, nhưng bảng phân loại là tài liệu người sau dùng để mở rộng ⇒ phải khai tường minh 2 trường này thuộc NGOÀI-PHẠM-VI/PHÁ-VỠ.

M3 — Nợ N1 không trung tính lượt này: nó ăn thẳng vào F-5 của chính spec

Xoá mềm ⇒ cascade không chạy (AuditingInterceptor.cs:56-62 đổi Deleted → Modified) ⇒ PurchaseEvaluationLevelOpinions của phiếu đã xoá ở lại DBvẫn Restrict FK về ApprovalWorkflowLevel (PurchaseEvaluationLevelOpinionConfiguration.cs:25-28). F-5 lại chốt: "chặn khi row đã có LevelOpinion". ⇒ Sau khi tính năng chạy, admin sẽ bị chặn bỏ người khỏi Cấp vì một phiếu đã bị xoá — chặn thật, không phải cảnh báo. Quan trọng: trước lượt này rác chỉ sinh từ phiếu Nháp (hiếm có LevelOpinion); sau lượt này mọi phiếu bị xoá đều ở ChoDuyetchắc chắn đã có LevelOpinion + Approvals + Changelogs. Tức lượt này làm N1 tệ hơn theo đúng cái trục mà spec vừa dựng luật. Đề xuất sửa. F-5 phải nói rõ: đếm LevelOpinion loại trừ opinion thuộc PE đã IsDeleted (hoặc khai nhận hành vi chặn-do-phiếu-xoá là chấp nhận được, kèm thông điệp lỗi nói đúng lý do).

M4 — Test 21 phát biểu sai số học ⇒ không thi hành được như viết

Phiếu ChoDuyet chỉ nằm trong 2/4 phép cộng:

  • PrevSubmittedPeBudgetAccumulator.cs:53-59 (ChoDuyet || DaDuyet);
  • PendingSelected:114-117 (loại {DangSoanThao, DaDuyet, TuChoi} + có winner);
  • PrevSelected:61-62 đòi Phase == DaDuyet;
  • PendingSubmitted:107 loại tường minh ChoDuyet.

Test 21 viết "cả 4 phép cộng lũy kế giảm đúng". Với 2 phép sau, delta đúng = 0. Ai viết assert "giảm" cho cả 4 sẽ có test không thể xanh; ai viết assert "giảm" cho 2 và quên 2 kia sẽ mất chốt chặn hồi quy. Ngoài ra ComputePendingAsync còn output thứ 5: PriorPes (:128-133, gồm cả phiếu Nháp) cũng đổi sau khi xoá — spec không nhắc. Đề xuất sửa. Viết lại test 21 thành 4 assert có hướng: PrevSubmitted ↓, PendingSelected ↓, PrevSelected không đổi, PendingSubmitted không đổi, + PriorPes mất đúng 1 phần tử.

M5 — §E1 nói mạnh hơn bằng chứng, và checklist 14 ⟂ 15 mô tả hai nút khác nhau

  • Sự thật đo được: PeWorkflowPanel nhận đúng 3 prop {evaluation, readOnly, onApproved}fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43. Không có onDelete, không có mutation xoá trong file.
  • onDelete được truyền cho PeDetailTabs, không phải cho panel: PurchaseEvaluationsListPage.tsx:570-575:665-670 (đúng số dòng spec dẫn, sai component).
  • §E2 lại đặt nút mới vào PeWorkflowPanel.tsx:458-506.

⇒ Câu "Dây đã nối sẵn" (§E1) không đúng cho chỗ đặt nút. sub-invest-fe-2 đã khai đúng ở PH-14 mục 4 ("phải nâng mutation del lên rồi truyền prop mới cho PeWorkflowPanel, hoặc tạo mutation riêng") — spec bỏ mất caveat này. Kèm theo: checklist 14 đặt nút trong panel; checklist 15 bảo "bỏ chặn readOnly cho riêng nút Xóa" — nhưng nút bị chặn đó nằm trong PeDetailTabs với gate 3 điều kiện mode === 'workspace' && canEditPhase && !readOnly (PeDetailTabs.tsx:445) cộng phase === DangSoanThao (:460); màn Duyệt truyền mode mặc định 'detail'canEditPhase=falseChoDuyet. ⇒ "bỏ chặn readOnly" không đủ, và hai mục sẽ đẻ ra 2 nút Xoá hoặc 1 nút không nối dây. Đề xuất sửa. Chốt 1 chỗ đặt nút duy nhất; ghi rõ prop/mutation phải thêm; xoá hoặc viết lại mục 15 cho khớp mục 14.

M6 — Checklist 27 bảo sửa row Policies + Menu keys trong STATUS.md — hai số đó KHÔNG được đổi

docs/STATUS.md:450 ghi rõ Policies = |MenuKeys.All| × |MenuKeys.Actions| = 54 × 4 = 216 (DERIVED, governance-detectors.ps1 resolve canonical từ đúng row này) và :451 Menu keys = 54 (đếm MenuKeys.All). Key mới "Đã duyệt"/"Đã xóa" là Pe_* sinh qua factory (MenuKeys.cs:134-141), không nằm trong All (:147-165 — đã đọc toàn bộ, Pe_* vắng mặt; đây cũng chính là điều spec dùng để loại [Authorize(Policy="Pe_….Delete")]). ⇒ Cả 2 số giữ nguyên 54 / 216. Làm theo mục 27 = ghi số sai vào chỗ canonical đang bị detector soi. Đề xuất sửa. Mục 27 chỉ giữ "Mig mới"; nếu muốn ghi số mục menu thì ghi ở chỗ khác và nói rõ đó là số row MenuItems, không phải MenuKeys.All.

M7 — Thứ tự thi công: văn xuôi và checklist đá nhau

§F.0 chốt "A-E KHÔNG CHẠY nếu thiếu F", nhưng §4 CHECKLIST xếp A-E (mục 1→28) trước, còn F (F-1→F-16) nằm ở PHẦN II cuối file, không có mốc thứ tự nào. Người làm top-down sẽ dựng đúng thứ tự mà spec vừa cảnh báo là hỏng. Migration F6 (mục 2) đứng trước lệnh Update (F-1) cũng đúng, nhưng phải nói ra. Đề xuất sửa. 1 dòng thứ-tự cứng đầu §4: mục 1-2 (schema F6) → F-1…F-4 (lệnh Update + validator) → 3-8 → 9-13 → 14-20 → test.

M8 — Mục 3 và F-11 là cùng một việc, hai độ sâu ⇒ mục 3 sẽ rơi đúng bẫy F-11 cảnh báo

Mục 3: "DTO + Designer admin: tick F6 per-Cấp (mirror chỗ tick F5)". F-11: liệt 5 dây FE :54 / :110 / :159 / :177 / :605 + cảnh báo 🔴 "thiếu :605 = tick xong KHÔNG gửi lên BE" + fe-user:282 + 4 neo BE :41 / :230 / :188 / :368. Ai làm theo mục 3 mà không lật xuống PHẦN II sẽ sót đúng cái :605. (Neo BE của F-11 kiểm chứng được: ApprovalWorkflowV2AdminFeatures.cs:230 AllowApproverFinalize trong CreateAwLevelInput, :368 map vào entity — khuôn đúng.) Đề xuất sửa. Gộp mục 3 vào F-11 hoặc trỏ chéo tường minh.


🟡 LOW

L1 — Tiêu đề F.0 mạnh hơn thân bài

"A-E KHÔNG CHẠY nếu thiếu F" → chính xác phải là "A-E không tới được phiếu đang treo". Phiếu tạo mới sau khi kích hoạt v(n+1) vẫn có cờ và vẫn xoá được. Thân bài (dòng 195-196) nói đúng điều này; chỉ tiêu đề quá rộng. Giữ nguyên kết luận thứ-tự-thi-công, chỉ sửa chữ để người sau không suy rộng sai.

L2 — §1 bỏ mất dữ kiện đối trọng cho vế "quay lại không được"

Vế lũy kế của spec đúng (đã kiểm, xem trục 2). Nhưng ở TraLai, người soạn được đổi hạng mục: UpdatePurchaseEvaluationDraftCommand.WorkItemId (PurchaseEvaluationFeatures.cs:221) + write :301-302, guard cho phép DangSoanThao ∥ TraLai (:248-250). Tức "bấm sai gói thầu" đường tự sửa (dời lũy kế sang gói đúng), chỉ là vẫn ăn số trong lúc chờ sửa. Không đổi quyết định owner, nhưng nên khai để §1 không bị đọc thành "không còn cách nào khác".

L3 — 2 trích dẫn lệch biên 1 dòng (nội dung đúng)

  • DbInitializer.cs:1893-1903: nhánh if (existingItems.TryGetValue…) bắt đầu đúng :1893, nhưng continue; nằm ở :1904 (đóng nhánh :1905). Nội dung "chỉ update Order rồi continue, KHÔNG đụng Label" đúng 100% (comment :1897-1898 tự khai).
  • :1917-1923: var labelBackfill = … khai ở :1918, 4 entry :1920-1923, đóng :1924. Dòng :1917 là comment. Vô hại nhưng nên chỉnh cho khớp.

L4 — F-12 bị dán nhãn "bài kiểm chứng F.0" nhưng chỉ chứng chiều thuận

F-12 chứng "update tại chỗ ăn ngay vào phiếu đang chạy". Mệnh đề F.0 còn nửa kia: "POST version mới không tới phiếu đang treo". Không có test cho nửa đó, mà nó rẻ: tick F6 bằng POST ⇒ phiếu treo vẫn bị từ chối xoá. Thêm 1 test này thì F.0 mới có răng.

L5 — N4 ("TuChoi là nhánh chết") đúng về transition nhưng đừng dọn vội

Guard PurchaseEvaluationWorkflowService.cs:101-106 chặn đường đi tới TuChoi, nhưng chính comment :99 ghi "Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter"phiếu TuChoi cũ vẫn tồn tại, và nhánh allow-list xoá vẫn phục vụ chúng. Dọn nhánh = mất quyền xoá phiếu lịch sử. Nên ghi rõ trong N4.


ĐIỂM SPEC LÀM ĐÚNG — đừng sửa hỏng

  1. Trích dẫn chịu lực: 16/16 mẫu ĐÚNG DÒNG + ĐÚNG NỘI DUNG. Gồm PeBudgetAccumulator.cs:53-59 · :104-126 · :114-126 · PurchaseEvaluationFeatures.cs:1404-1406 · :149 · :249-251 · :291 · ApprovalWorkflowV2AdminFeatures.cs:325-328 · :331-334 · :336 · PurchaseEvaluationWorkflowService.cs:674 · :683 · :686 · :694-695 · :705 · :101-106 · PurchaseEvaluationsController.cs:146-151 · MenuKeys.cs:147-165 · Program.cs:82-89 (khớp chính xác khối foreach menu → foreach action → AddPolicy) · DbInitializer.cs:2092-2096 + :2492-2496 (cả hai đúng từng dòng — 5 dòng Add(...) mỗi bên) · PurchaseEvaluationConfiguration.cs:84 · ApprovalWorkflow.cs:81-82.
  2. E3 md5 kiểm lại bằng lệnh thật: md5sum cho cùng 02671be6334110028c55fe82f5e70dee ở cả fe-userfe-admin, 942 dòng — đúng y spec. Luật "mirror 2 app thay cho 4-place" là luật đúng cho việc này.
  3. B3 chọn policy root thay Pe_* là ĐÚNG và lý do đúng: Pe_* thật sự vắng khỏi MenuKeys.All (đọc trọn :147-165), mà Program.cs:82-89 chỉ đăng ký policy cho phần tử của All.
  4. Bẫy labelBackfill là THẬT và quan trọng: vòng upsert gặp key cũ chỉ sửa Order rồi continue, comment :1897-1898 tự khai "KHÔNG đụng Label / ParentKey / Icon". Sửa tree.Add mà không thêm labelBackfill sẽ không đổi được nhãn trên DB đã seed. Giữ nguyên cảnh báo 🔴 ở mục 11.
  5. D3 (3 điều cấm + "handler riêng, KHÔNG thêm cờ includeDeleted") là đúng và đáng giữ nguyên chữ — đã kiểm: IgnoreQueryFilters() là toán tử trên một IQueryable, 2 hàm accumulator đều dựng peers mới (:49-51, :100-102), nên không rò chéo. (Lỗ của D3 nằm ở chỗ khác — xem H3.)
  6. F-3 (CẤM delete-then-insert, diff theo Level.Id) và F-9 (usage-check trước khi xoá version) là hai điều đúng và bắt buộc: FK RestrictPurchaseEvaluationLevelOpinionConfiguration.cs:25-28 + PurchaseEvaluationConfiguration.cs:67-73, và handler xoá hiện tại (ApprovalWorkflowV2AdminFeatures.cs:406-418) thật sự Remove() thẳng, 0 usage-check, đúng như nợ tự khai :400-402.
  7. Khảo cổ S60 (6db195d) và mắt xích "guard tự trỏ sang Xóa phiếu" — kiểm lại trên đĩa: PurchaseEvaluationWorkflowService.cs:101-106 đúng nguyên văn. Đây là phần chẩn-đoán mạnh nhất của spec.

🎯 THÁCH-CLEAN: đã thử phá bằng cách nào

Trục 2 — mệnh đề F.0: KHÔNG bác được (6 đường tấn công, đều tắc)

# Đường thử bác Kết quả
1 Có write-site nào khác ghi PE.ApprovalWorkflowId (job/seeder/admin action)? grep -rn "ApprovalWorkflowId\s*=" --include=*.cs src/Backend | grep -v Migrations → PE đúng 2 write-site: :149 (create) + :291 (update). Các module Office (LeaveOtApprovalFeatures.cs:238/:509/:537/:702, ProposalFeatures.cs:277/:339, TravelVehicleApprovalFeatures.cs:189/:549/:774/:802) re-pin — nhưng không phải PE. Mắt xích 4 đứng.
2 Có migration/Sql() nào backfill lại con trỏ workflow? grep -rniE "ApprovalWorkflowId" Migrations/*.cs | grep -iE "UPDATE|Sql\("0 hit. grep "Sql(" Migrations/*.cs | grep -i purchaseeval0 hit. Đứng.
3 Có chỗ nào phiếu resolve workflow theo Code+IsActive thay vì Id? ApproveV2Async :671-675 chỉ w.Id == awId. Inbox :832-836 lấy wfIds từ chính phiếu. Badge list :672-685 join lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId. Flow-tree dùng workflow đã pin. Không có đường Code/IsActive. Đứng.
4 Admin xoá bản cũ để ép phiếu rơi sang bản mới? FK Restrict (PurchaseEvaluationConfiguration.cs:67-73) ⇒ nổ FK, không "rơi sang". Đứng.
5 Drafter sửa phiếu lúc ChoDuyet để re-pin? Chặn :248-250. Đứng.
6 Trả lại → sửa → gửi lại có re-pin không? (:248-250 cho TraLai, write :291) — nhưng spec đã tự khai ở dòng 196 và đúng là không giải được ca gốc trong 1 bước. Chỉ làm yếu tiêu đề, không làm yếu kết luận (⇒ L1).

Kết luận F.0 ĐỨNG. Thứ tự thi công "F trước" là đúng.

Trục 1 — vì sao không cho FAIL: đã kiểm từng trích dẫn chịu lực bằng cách mở file, không đọc lại spec. Cách phá đã dùng: (a) so từng số dòng với sed -n/Read có đánh số; (b) đếm ngược thủ công 2 khối DbInitializer (2075→2096 và 2470→2496) để chắc 2 danh sách permission song song đúng dòng; (c) tính lại md5 thay vì tin con số spec chép. Lỗi duy nhất tìm được là diễn giải (M5), không phải số dòng.

Trục 5 — an toàn/số liệu: đã thử phá bằng 4 câu hỏi

(i) "3 điều cấm D3 có đủ không" → KHÔNG (H3, 2 đường rò khác trục); (ii) "nới allow-list rồi ai vẫn xoá được" → đo bảng Permissions thật (H1); (iii) "test có răng không" → test 21 sai số học (M4), test 25 xanh-giả (H1), test 22 răng cho đúng thứ nó tuyên bố; (iv) "hoãn N1 có trung tính không" → KHÔNG (M3, đụng thẳng F-5).

Trục 6 — trung thực: 3 chỗ nói mạnh hơn bằng chứng đã tìm được là M5 (dây FE), L1 (tiêu đề F.0), H2-lớp-2 (hệ quả "N người cùng Cấp"). Ngoài ra spec giữ đúng nhãn nguồn: những chỗ sub gắn [SUY LUẬN] (vd F10 "chỉ cần nới guard là số tự đúng") được spec trình bày kèm cơ chế query filter chứ không đội lốt [CODE].


📌 Việc phải làm trước khi giao hmw (rút gọn)

  1. Owner trả lời 1 câu: F6 đọc per-ROW (1 người) hay per-CẤP (N người)? → H2.
  2. Chốt đường cấp quyền PurchaseEvaluations.Delete cho tập role đích + xác nhận không cắt quyền xoá-nháp của Drafter → H1.
  3. Thêm 2 ràng buộc vào checklist 8 (.Where(IsDeleted) + IDOR-scope) → H3.
  4. Thêm policy vào F-1 → H4.
  5. Sửa bảng F.2 (M1/M2), sửa test 21 (M4), gỡ mâu thuẫn 14/15 (M5), bỏ "Policies/Menu keys" khỏi mục 27 (M6), thêm dòng thứ tự (M7).