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

### B. `MenuKeys.All` (claim quyết định "theo khuôn `Off_DeXuat`, không theo `Pe_*`")

| # | 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 |

### F. Menu / seed / FE (§2.4)

| # | 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Ố

1. **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.
2. **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"*.
3. **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.
4. **§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).
5. **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.
6. **Đề 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.

<!-- END · TOTAL=48 CLAIM · VERDICT=SPEC §② VỮNG — 39Đ/4S(chỉ lệch anchor)/5KKĐ, sửa 4 anchor (1 sai đường dẫn) trước khi giao implementer -->
