LĂNG KÍNH 4/4 — VERIFY ĐỘC LẬP CLAIM file:line (reviewer, S157)
Đích chấm: spec-ke-hoach-ky-ket-hd-28-07-2026.md §② (dòng 95-204). Phép đo: MỞ FILE THẬT trên đĩa (Read/Grep/Glob), KHÔNG dựa skill-doc, KHÔNG dựa sub-invest-kehoach-1.md.
Verdict
SPEC §② VỮNG — 39 ĐÚNG / 4 SAI (toàn bộ là lệch ANCHOR, 0 sai NỘI DUNG) / 5 KHÔNG KIỂM ĐƯỢC.
Hai claim quyết-định-kiến-trúc (B.1 "Proposal phá OR-of-N" và "Pe_* ngoài MenuKeys.All") ĐÚNG, khớp từng dòng.
Trước khi giao implementer phải sửa 4 anchor ở mục "Claim SAI" — 1 trong 4 là đường dẫn file KHÔNG TỒN TẠI (implementer mở sẽ trượt).
Claim ĐÚNG
A. B.1 — ProposalFeatures.cs phá OR-of-N (claim quyết định "copy Contract, không copy Proposal")
| # |
Claim gốc (spec dòng 99-104) |
Đo được trên đĩa |
Verdict |
| 1 |
SelectMany flatten mọi Level xuyên Steps :427-429 |
ProposalFeatures.cs:427-429 = wf.Steps.OrderBy(s => s.Order).SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l })).ToList() |
ĐÚNG — khớp DÒNG chính xác |
| 2 |
ElementAtOrDefault(order-1) lấy 1 row :433 |
:433 = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1) |
ĐÚNG — chính xác |
| 3 |
So đúng 1 ApproverUserId :439 |
:439 = if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value) — so 1 Guid, KHÔNG có tập allowedUserIds |
ĐÚNG — chính xác |
| 4 |
Comment tự thú "Lite version: assume 1 step per workflow" |
:424 = // Multi-step workflow: traverse step-by-step. Lite version: assume 1 step per workflow |
ĐÚNG — nguyên văn |
| 5 |
Entity Proposal chỉ có CurrentApprovalLevelOrder, KHÔNG có CurrentWorkflowStepIndex |
Domain/Office/Proposal.cs:27 có CurrentApprovalLevelOrder; grep CurrentWorkflowStepIndex trong **/Proposal.cs = 0 hit |
ĐÚNG |
| 6 |
Hệ quả: 2 người cùng Cấp bị biến thành 2 nấc TUẦN TỰ (AND) |
CỦNG CỐ THÊM (tôi tự đo): :473-481 advance = if (CurrentApprovalLevelOrder < allLevels.Count) CurrentApprovalLevelOrder + 1 — so Order (1-based trong Bước) với Count của danh sách ĐÃ FLATTEN. Không GroupBy(Order) ở bất kỳ đâu ⇒ N row cùng Order = N slot tuần tự. |
ĐÚNG |
| 7 |
OR-of-N hợp lệ theo ApprovalWorkflow.cs:81-94 |
Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:81 mở "Cấp = tập các row CÙNG Order", :82 "1 Cấp có thể có N row ⇒ N người duyệt OR-of-N", kết :94 "Cấp 2 Phòng Cung ứng có 3 người"; :95 là public class ApprovalWorkflowLevel |
ĐÚNG — khung 81-94 khít |
Kết luận B.1: claim ĐÚNG 7/7. Khuyến nghị "PHẢI copy ContractWorkflowService.ApproveV2Async" có cơ sở đo được, không phải suy diễn.
| # |
Claim gốc (spec dòng 181-183) |
Đo được |
Verdict |
| 8 |
OffDeXuat + 3 con đều trong All |
MenuKeys.cs:168 = OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, nằm trong mảng All (:156-174) |
ĐÚNG nội dung — nhưng anchor spec ghi :171 là SAI (xem S1) |
| 9 |
Pe_* sinh bằng factory, NẰM NGOÀI All |
Factory :134-137 (Pe_{t} / _List / _Create / _Pending), :145-146 (_Approved / _Deleted), :150 (_WfView); mảng All chỉ chứa root PurchaseEvaluations (:162) — không một literal Pe_ nào trong All. Comment :142-144 tự khai đúng chính sách này. |
ĐÚNG |
| 10 |
⇒ per-action policy Pe_* không tồn tại |
Program.cs:82-89 = foreach (var menu in MenuKeys.All) foreach (var action in MenuKeys.Actions) opts.AddPolicy($"{menu}.{action}", ...) — policy sinh duy nhất từ All ⇒ key ngoài All không có policy |
ĐÚNG (suy ra bằng cấu trúc, không phải phỏng đoán) |
| 11 |
Policy TỰ SINH khi key vào MenuKeys.All (Program.cs:82-89) |
Khớp từng dòng: :82 foreach All · :84 foreach Actions · :86-87 AddPolicy · :89 đóng |
ĐÚNG — chính xác |
C. Khuôn Contract phải copy (§2.1 + §2.3)
| # |
Claim |
Đo được (Infrastructure/Services/ContractWorkflowService.cs) |
Verdict |
| 12 |
ApproveV2Async:217-394 |
:217 = private async Task ApproveV2Async(; hàm kế LogTransitionAsync bắt đầu :396 ⇒ thân kết ~:394 |
ĐÚNG (đầu chính xác, đuôi khớp mốc kề) |
| 13 |
GroupBy Order :246 |
:246 = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList() |
ĐÚNG — chính xác |
| 14 |
OR-of-N :259-260 |
:259 = allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet(), :260 = if (!allowedUserIds.Contains(actorUserId.Value)) |
ĐÚNG — chính xác |
| 15 |
LevelOpinions UPSERT :292-316 |
:292 = db.ContractLevelOpinions (đọc bản cũ), :300 = .Add(new ContractLevelOpinion, :318 đã là comment skipToFinal ⇒ khối UPSERT nằm trọn 292-316 |
ĐÚNG |
| 16 |
skipToFinal :320-352 |
:320 = if (skipToFinal), :332 = lastStep.Levels...GroupBy |
ĐÚNG (đầu chính xác) |
| 17 |
Guard trình role-based :70-79 đòi Drafter|DeptManager |
:70-71 điều kiện DangSoanThao || TraLai, :73-75 !actorRoles.Contains(AppRoles.Drafter) && !...DeptManager, :77-79 throw new ForbiddenException |
ĐÚNG — khung 70-79 khít |
| 18 |
ConflictException :115-116 khi HĐ V2-less trình |
:116 = throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step.") |
ĐÚNG về anchor (xem K5 về mệnh đề nhân-quả) |
| 19 |
LogTransitionAsync báo Drafter :407-427 |
:396 = private async Task LogTransitionAsync(, :407 = if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId) |
ĐÚNG — notify quả thật CHỈ nhánh Drafter |
D. Lỗ đang treo ở Contract (lý do "authz 2 tầng" + "inbox V2 từ ngày 1")
| # |
Claim |
Đo được |
Verdict |
| 20 |
ContractsController.cs:12-13 class-trần |
:11 [ApiController] · :12 [Route("api/contracts")] · :13 [Authorize] trần; grep Authorize\(Policy trong file = 0 hit ⇒ 0 per-action policy |
ĐÚNG (bằng chứng mạnh hơn claim: 0 hit toàn file) |
| 21 |
ContractFeatures.cs:363-372 legacy-only |
:363 = PhaseActorRoles = new(), :364-372 chỉ map DangSoanThao … DangDongDau rồi }; |
ĐÚNG — khung khít |
| 22 |
grep ChoDuyet = 0 hit |
Grep ChoDuyet trong **/ContractFeatures.cs = 0 occurrence / 0 file |
ĐÚNG — chính xác |
| 23 |
PE có ResolveV2InboxIdsAsync để mirror |
Grep toàn src = 2 hit, chỉ trong PurchaseEvaluationFeatures.cs (Contract không có) |
ĐÚNG |
E. Khuôn PE phải port (§2.3 mục 1-3)
| # |
Claim |
Đo được (PurchaseEvaluationWorkflowService.cs) |
Verdict |
| 24 |
choke-point + RULE grep-4-site, khuôn :1008 |
:1008 = // RULE: MỌI nhánh set Phase=DaDuyet PHẢI gọi helper này ngay TRƯỚC assignment (reviewer grep-check 4 site)., :1009 = private async Task ApplyBudgetSnapshotOnFinalizeAsync( |
ĐÚNG — anchor trúng ĐÚNG dòng RULE |
| 25 |
AllowApproverFinalize :870-890 |
:870 = if (matchingLevel.AllowApproverFinalize && applyLevelFinalize) |
ĐÚNG (đầu chính xác) |
| 26 |
finalizeByCcmDelegation + CeoApprovalThreshold :892-929 |
:892 = if (finalizeByCcmDelegation), :894 = if (aw.CeoApprovalThreshold is not decimal ceoThreshold) |
ĐÚNG (đầu chính xác) |
| 27 |
ApplyReturnModeAsync:368-547 |
:368 = private async Task<string> ApplyReturnModeAsync( |
ĐÚNG (đầu chính xác) |
| 28 |
HasQueryFilter(!IsDeleted) khuôn PurchaseEvaluationConfiguration.cs:84 |
:84 = b.HasQueryFilter(x => !x.IsDeleted); |
ĐÚNG — chính xác |
| # |
Claim |
Đo được |
Verdict |
| 29 |
Seed 4 dòng menu khuôn DbInitializer.cs:1817-1820 |
:1817 OffDeXuat "Đề xuất" · :1818 OffDeXuatList "Danh sách" · :1819 OffDeXuatCreate "Tạo mới" · :1820 OffDeXuatInbox "Inbox duyệt" — cả 3 nhãn VN spec đề xuất trùng khớp nhãn khuôn |
ĐÚNG — chính xác |
| 30 |
admin-permission :2383 |
:2383 = MenuKeys.OffDeXuat, MenuKeys.OffDeXuatList, MenuKeys.OffDeXuatCreate, MenuKeys.OffDeXuatInbox, |
ĐÚNG — chính xác |
| 31 |
ContractLevelOpinions FK Cascade + Restrict (Mig 33) |
ContractLevelOpinionConfiguration.cs:27 OnDelete(DeleteBehavior.Cascade) · :32 OnDelete(DeleteBehavior.Restrict) |
ĐÚNG |
| 32 |
ApprovalWorkflowsV2Page.tsx:143 map typeCode→int |
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:143 = // FE typeCode → BE int (giống MenuKeys ApplicableType) (đầu bảng TYPE_CODE_TO_INT, dùng ở :257) |
ĐÚNG |
| 33 |
Bộ Proposal FE ~978 LOC/app: List 252 + Create 245 + Detail 386 + types 95 |
Đếm dòng thật fe-admin: ProposalsListPage.tsx 252 · ProposalCreatePage.tsx 245 · ProposalDetailPage.tsx 386 · types/proposal.ts 95 ⇒ tổng 978 |
ĐÚNG — 4/4 con số khớp tuyệt đối |
G. Enum + cầu b.12→13 (§2.2 + §2.5)
| # |
Claim |
Đo được |
Verdict |
| 34 |
ApprovalWorkflow.cs:53-67 dừng ở TravelRequest = 9, slot 10 trống |
:53 = public enum ApprovalWorkflowApplicableType, :66 = TravelRequest = 9,, :67 = } — giá trị lớn nhất 9 |
ĐÚNG — khung 53-67 khít, slot 10 trống thật |
| 35 |
cầu :53-54 = PE phải DaDuyet |
CreateContractFromEvaluationFeatures.cs:53-54 = if (pe.Phase != PurchaseEvaluationPhase.DaDuyet) throw new ConflictException(...) |
ĐÚNG — chính xác |
| 36 |
:56-62 phép lấy winners |
:56 winners = pe.Suppliers.Where(s => s.IsWinner).ToList() … :62 supplierIds = winners.Select(w => w.SupplierId) |
ĐÚNG — khung khít |
| 37 |
:88-90 SUM Quote.IsSelected per winner |
:88-90 = pe.Details.SelectMany(d => d.Quotes).Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected).Sum(q => q.ThanhTien) |
ĐÚNG — chính xác |
| 38 |
changelog :121-140 (chỗ ghi ContextNote lệch) |
:121-130 ContractChangelogs.Add(...) (ContextNote :129) + :131-140 PurchaseEvaluationChangelogs.Add(...) (ContextNote :139) |
ĐÚNG — khung khít, 2 changelog chứ không phải 1 (implementer nhớ ghi note vào đúng bảng) |
| 39 |
pin V1-only :68-71, :108 |
:68-71 = activeWfId lấy từ db.WorkflowDefinitions … IsActive; :108 = WorkflowDefinitionId = activeWfId; không có dòng nào set ApprovalWorkflowId trong cả khối new Contract{} :92-111 |
ĐÚNG |
Bonus — measured-label gate: con số "849 LOC BE" (spec dòng 99) tái-lập được: đếm dòng 10 file Proposal*.cs trong src/ = đúng 849 (ProposalFeatures.cs 556 + Controller 86 + 4 Configuration 108 + 4 Domain 99). Nhãn "BE" cứu con số. ⚠️ Nhưng câu đặt số ngay sau tên 1 file ⇒ dễ đọc thành "file này 849 dòng" (thật: 556). Đề nghị đổi thành "twin nhỏ nhất — 849 LOC BE trải 10 file, riêng ProposalFeatures.cs 556".
Claim SAI
Cả 4 đều là lệch anchor / sai đường dẫn, KHÔNG có claim nào sai về nội dung kỹ thuật. Nhưng đây đúng lớp lỗi đã cắn repo hôm qua, và 1 cái làm implementer mở-file-trượt.
| # |
Claim gốc |
Đo được |
Mức |
| S1 |
"Lead verify MenuKeys.cs:171: OffDeXuat+3 con đều trong All" (spec dòng 182) |
4 key nằm ở MenuKeys.cs:168. Dòng :171 là Personal, — key khác hẳn, chẳng liên quan Off_DeXuat |
MINOR (nội dung đúng, anchor lệch 3 dòng) |
| S2 |
"cờ sẵn ApprovalWorkflowLevel.cs:116-125" (spec dòng 161) |
KHÔNG CÓ FILE NÀO TÊN ApprovalWorkflowLevel.cs (Glob **/ApprovalWorkflowLevel.cs = 0 hit). Class ApprovalWorkflowLevel nằm trong Domain/ApprovalWorkflowsV2/**ApprovalWorkflow.cs**:95. Khoảng dòng thì ĐÚNG: :116 AllowReturnOneLevel · :119 AllowReturnOneStep · :122 AllowReturnToAssignee · :125 AllowReturnToDrafter = true |
MAJOR — implementer mở theo spec sẽ file-not-found (tôi vừa dính đúng lỗi này khi đoán thư mục ở S5) |
| S3 |
"DELETE allow-list DangSoanThao|TuChoi, mirror PE :1404" (spec dòng 177) |
PurchaseEvaluationFeatures.cs:1404 là dòng TRỐNG. Allow-list thật ở :1405-1407. Drift này kế thừa từ comment trong mã: ApprovalWorkflow.cs:161 cũng ghi PurchaseEvaluationFeatures.cs:1404-1406 (lệch 1) |
MINOR |
| S4 |
"workflow ApplicableType==ContractSigningPlan (mirror Proposal :258-265)" (spec dòng 144) |
Khoảng :258-265 chỉ chứa phần nạp wfType + throw NotFound. Hai dòng quyết định nằm NGOÀI khoảng trích: :266-267 if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral) throw new ConflictException(...). Ai copy đúng 258-265 sẽ copy phần đọc mà bỏ mất chính cái guard |
MINOR-nhưng-nguy (trích hụt đúng dòng load-bearing) |
Claim KHÔNG KIỂM ĐƯỢC
| # |
Claim |
Vì sao chưa kết luận |
| K1 |
Ranh CUỐI của 6 khoảng: ApproveV2Async:…394, skipToFinal:…352, ApplyReturnModeAsync:…547, AllowApproverFinalize:…890, CcmDelegation:…929, UPSERT:…316 |
Tôi chỉ verify dòng đầu + mốc liền kề (hàm/khối kế tiếp). Chưa đọc trọn ruột từng khoảng. Đầu đúng + mốc kề hợp lý ⇒ khả năng đúng cao, nhưng chưa đo ⇒ không tuyên ĐÚNG |
| K2 |
fe-user WorkflowMatrixViewPage.tsx (spec dòng 193-194 tự khai invest KHÔNG verify được) |
Tôi cũng chưa kiểm. Spec đã tự gắn cờ "PHẢI grep lại lúc làm" — giữ nguyên cờ đó, đừng gỡ |
| K3 |
ProposalFeatures.cs:161-228 (khuôn Get/List detail Include) |
Chưa đọc — hết ưu tiên |
| K4 |
Mig 69 · 7 CreateTable · 2 row docs/STATUS.md bump |
Claim về việc sẽ làm, không có vật trên đĩa để chấm |
| K5 |
Mệnh đề nhân-quả "pin V1-only ⇒ HĐ V2-less trình xong KẸT CỨNG :115-116" |
Anchor ĐÚNG, nhưng :116 ném khi WorkflowDefinitionId null HOẶC def không có step. Ở cầu này :108 có set activeWfId (V1) ⇒ có kẹt hay không phụ thuộc DB prod: có tồn tại WorkflowDefinitions active + có step cho ContractType đó không. Chưa đo DB ⇒ mệnh đề đúng CÓ ĐIỀU KIỆN |
Điểm CỦNG CỐ
- B.1 mạnh hơn spec viết. Ngoài 3 dòng spec nêu,
ProposalFeatures.cs:473 còn so CurrentApprovalLevelOrder (Order 1-based trong Bước) với allLevels.Count (đếm row đã flatten toàn workflow) — trộn hai hệ đơn vị. Cùng lớp #43 Step.Order ≠ index 0-based. Thêm 1 lý do nữa để KHÔNG copy Proposal cho phần duyệt.
- Cat-3 authz: bằng chứng mạnh hơn claim. Spec chỉ trỏ
ContractsController.cs:12-13; đo thật grep Authorize\(Policy = 0 hit toàn file ⇒ mọi endpoint HĐ chỉ cần "đăng nhập bất kỳ". Đúng bài học #82/S118 (menu-ẩn ≠ API-đóng). Yêu cầu "authz 2 TẦNG" của §2.3 giữ nguyên, nên nhấn thêm câu "gate acceptance = grep Authorize(Policy trong controller mới phải ≥ 4 hit".
- Thêm đường dẫn vào spec, đừng để tên trần.
CreateContractFromEvaluationFeatures.cs thật nằm ở src/Backend/SolutionErp.Application/PurchaseEvaluations/ (KHÔNG phải .../Contracts/ như tiêu đề §2.5 "chạm module Contract" gợi ý). Spec để tên trần ⇒ tôi đoán sai thư mục và trượt 1 lượt đọc. Cùng cách chữa cho S2.
- §2.5 sửa 2 bảng changelog, không phải 1.
:121-130 ContractChangelogs và :131-140 PurchaseEvaluationChangelogs. Câu spec "ghi ContextNote lệch vào changelog :121-140" nên nói rõ ghi vào bảng nào (đề nghị: cả hai, để tra ngược từ 2 phía).
- Anchor drift lan từ MÃ sang SPEC. S3 chứng minh: comment trong
ApprovalWorkflow.cs:161 ghi lệch 1 dòng, spec chép lại y nguyên. ⇒ quy tắc rẻ: mọi anchor file:line trong spec phải được mở lại lúc bắt đầu wave, vì mã đã trôi giữa lúc viết comment và lúc viết spec.
- Đề nghị gate rẻ cho implementer (không phải sửa mã, chỉ là phép chấm): trước mỗi wave, chạy 1 lượt "mở 5 anchor bất kỳ trong wave đó" — nếu ≥1 anchor trượt file/dòng, chấm lại toàn bộ anchor của wave.