# c3-l1

LANE 1/3 review cụm-3 — LENS QUYẾT-vs-CODE. Đối tượng: `sub-invest-fable-b2-cum3.md` (K7 bridge 8v/8test + K8 dry-run 17 bước B0-B16).
Mọi verify = grep/read trên ĐĨA hôm nay (2026-07-31), không mượn số của lane khác. Đường dẫn gốc repo = `D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP`.

## Findings

### Bảng chấm 7 claim chịu lực (đề bài a-g)

| # | Claim của cụm-3 | Phán | Dòng grep/read |
|---|---|---|---|
| a | 3 lỗ S156 ĐÃ vá @W6-S161 (list/detail · inbox · guard trình) | **ĐÚNG** — và ĐỦ cả 3 chân cho HĐ sinh-từ-KHKK (F-1) | `ContractFeatures.cs:306-318`, `:621-637`, `:530-543`, `ContractWorkflowService.cs:73-92` |
| b | Cấm sửa `GetEligiblePhases` (nhét ChoDuyet = rò HĐ xoá-mềm sang màn "Đã xoá") | **ĐÚNG** | comment `ContractFeatures.cs:381-387`; call-site thứ 3 `:441` `ListContractsQueryHandler.GetEligiblePhases`; `:442-447` cố ý không ghép vế V2 |
| c | `ContractSigningPlanLine.ContractId` write-site = 0 | **ĐÚNG, và chắc hơn lời khai** (F-3) | grep repo-wide → 0 assignment |
| d | HỐ-1-KHKK: allow-list `{Nháp,TuChoi}` SAU nhánh admin ⇒ `DaDuyet` không xoá nổi kể cả Admin | **ĐÚNG**; hệ quả "chiếm slot" có 1 điều-kiện ẩn (F-5) | `ContractSigningPlanFeatures.cs:1137-1148`; rào `:342-347`; filter `ContractSigningPlanConfiguration.cs:44` |
| e | type-3 = any-ContractType ⇒ buộc thêm input `ApprovalWorkflowId` | **ĐÚNG**; picker lọc còn 1 vế nữa spec không nêu (F-9) | `ApprovalWorkflow.cs:57`; khuôn guard `ContractFeatures.cs:74-88`; picker `ContractCreatePage.tsx:334-341` |
| f | `pe.ContractId ??=` giữ cây GĐ3 (hook đi `pe.contractId` đơn) | **ĐÚNG nhưng THIẾU 1 chiều** (F-6) | `usePipelineStages.ts:158-160`, `:214-218` (2 app sha256 khớp `7144eeb787a8…`) |
| g | Mã HĐ gen lúc bridge mirror PE `:113`; terminal chống double-gen `:384` | **ĐÚNG**; spec thiếu 2 tiền-đề + 1 lệch-khuôn (F-7) | `CreateContractFromEvaluationFeatures.cs:113`; `ContractWorkflowService.cs:384-391` |

**Tổng: 7/7 ĐÚNG về sự-kiện · 0 SAI · 4 claim hở ở phần SUY-LUẬN/hệ-quả · +2 lỗ mới không nằm trong 7 câu hỏi (F-2 policy, F-9 picker).**

---

### F-1 [PASS — control dương] BÁC-tiền-đề của cụm-3 đứng vững: 3 chân ĐỦ cho HĐ sinh-từ-KHKK

Giả định bridge pin `ApprovalWorkflowId` type-3 + `DrafterUserId = người bấm`:

1. **Approver THẤY** — List `ContractFeatures.cs:311-318`: `x.c.Phase != DangSoanThao && myWorkflowIds.Contains(x.c.ApprovalWorkflowId)`; helper `:403-411` gom mọi workflow user có chân `ApproverUserId`. Detail `:624-637`: `isV2Approver` hỏi theo `c.ApprovalWorkflowId` (không theo role) + chặn Nháp `:631-636`.
2. **INBOX** — `:532-534` `v2InboxIds`; where `:543`; thoát-sớm `:538` xét CẢ 2 nguồn (user 0 role legacy vẫn có hộp thư). `ResolveV2InboxIdsAsync:559-593` đòi `Phase==ChoDuyet && AWId!=null && StepIndex!=null && LevelOrder!=null` — trình sẽ set `StepIndex=0` (`ContractWorkflowService.cs:94`) và `LevelOrder=1` **đúng vì `ApprovalWorkflowId is not null`** (`:97`). Admin đi nhánh phase `AdminInboxPhases:503-513` (đã có `ChoDuyet`).
3. **TRÌNH được** — `ContractWorkflowService.cs:82-92`: `isCreator` xét `CreatedBy || DrafterUserId` ⇒ người bấm bridge luôn trình được dù không mang 3 role kia.

⇒ K7(b) co lại thành V3 test-khoá là **kết luận đúng**; "làm cho xong theo đề bài cũ" đúng là sẽ tạo lỗ.

### F-2 [MEDIUM] "policy `Contracts.Create` = đúng-key-endpoint-đích (#85)" — sai về sự-thật đo được; FE-gate lệch server 2 chiều

Spec `:54` `:67` `:161` `:163` chốt key = `Contracts.Create`, viện gotcha #85. Đo:

- Endpoint đích thật `POST /api/contracts` **KHÔNG có policy**: `ContractsController.cs:13` class `[Authorize]` **trần**; action `:49-50` trống. Y hệt `List:16-17`, `Inbox:41-42`, `Get:45-46`, `Transition:64-65`. Chỉ `ListDeleted:31-32` mang `[Authorize(Policy="Contracts.Read")]`. ⇒ "đúng key policy của endpoint đích" theo nghĩa đen = `[Authorize]` trần. (Đúng lớp S162: 2 endpoint cùng module cho kết luận NGƯỢC nhau ⇒ phải đo TỪNG cái, không suy từ tên module.)
- Gắn action lên `ContractSigningPlansController` thì policy **CỘNG DỒN**: class `:28` `[Authorize(Policy="KeHoachKyKet.Read")]` + action `Contracts.Create` ⇒ hiệu lực = **AND 2 policy**, không phải "KHÔNG phải `KeHoachKyKet.*`" như spec `:54` viết.
- Hệ quả: (i) B9 `:122` mô tả actor = "Người có `Contracts.Create`" là **điều kiện THIẾU**; (ii) chiều ngược nặng hơn — BCH drafter (trọn `KeHoachKyKet.*`) có thể **không** được cấp `Contracts.Create` ⇒ mất nút, trong khi chính người đó **vẫn tạo HĐ tay được** qua `POST /api/contracts` (0 policy). Gate chặt hơn năng-lực-thật = chặn nhầm, không phải bảo mật.

**Đề nghị (mô tả, không sửa code):** spec khai hiệu-lực `KeHoachKyKet.Read AND Contracts.Create`; B0 thêm 1 dòng kiểm actor B9 đủ CẢ HAI; và bỏ/viết lại câu viện #85 vì bằng chứng đang chỉ chiều ngược.

### F-3 [PASS — chắc hơn lời khai] write-site = 0, xác nhận ở phạm vi RỘNG hơn spec đo

Spec `:13` chỉ grep 2 file. Tôi grep repo-wide 2 lượt:
- `grep -rn "\.ContractId\s*=[^=]" src/Backend --include=*.cs` → **duy nhất** `CreateContractFromEvaluationFeatures.cs:145` `pe.ContractId = contracts[0].Id` (là `PurchaseEvaluation.ContractId`).
- Mở rộng bắt object-initializer `grep -rn "ContractId\s*=[^=]"` → mọi hit còn lại thuộc `ContractAttachment / ContractDetail* / ContractChangelog / ContractComment` + `DbInitializer` + column-def migration; **0 hit gán cho `ContractSigningPlanLine`**.
⇒ "assignment ĐẦU TIÊN toàn codebase" (`:50`) đúng. `ContractSigningPlanWorkflowService.cs` chỉ 1 hit `:54` và đúng là COMMENT.

### F-4 [LOW] Neo `:381` trong chú thích của MÃ là neo tự-trỏ (không phải lỗi cụm-3)

`ContractFeatures.cs:384` viết "…hàm DÙNG CHUNG với `:381` ListDeleted", nhưng `:381` chính là dòng mở khối chú thích đó; handler ở `:427`, call-site `:441`. Cụm-3 `:17` trích **đúng** (`:441`). Nêu để implementer đọc mã không bị neo sai dẫn lạc.

### F-5 [MEDIUM] HỐ-1-KHKK đúng, nhưng "chiếm slot (PE, nhóm-4)" là hệ quả CÓ ĐIỀU KIỆN — K2 chưa land thì thiệt hại RỘNG hơn

- Cơ chế: `:1137-1140` chặn không-owner/không-admin → `:1145-1148` allow-list `{DangSoanThao, TuChoi}` **không có ngoại lệ admin** ⇒ `DaDuyet` bất-khả-xoá qua API kể cả Admin. ĐÚNG.
- Đường thoát sqlcmd soft-delete có căn cứ: `ContractSigningPlanConfiguration.cs:44` `HasQueryFilter(x => !x.IsDeleted)` ⇒ rào `:342-344` không đếm phiếu đã xoá mềm. ĐÚNG.
- **Lỗ suy luận:** rào `:342-344` trên đĩa HÔM NAY là `AnyAsync(p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)` — **per-PE, KHÔNG có `ApprovalGroup`**. B15 `:131` viết "chiếm slot (A/049, **nhóm-4**)" = trạng thái GIẢ ĐỊNH sau K2. Nếu K8 chạy khi K2 chưa merge (hoặc K2 đổi phạm vi), phiếu ZZTEST `DaDuyet` khoá **mọi nhóm** của PE thật `PE/2026/A/049`, không riêng nhóm-4.
**Đề nghị:** B0 thêm dòng kiểm điều-kiện "rào `:342` đã là per-(PeId, ApprovalGroup)?" — nếu chưa thì hoãn hoặc chuyển vòng-1 sang bộ ZZTEST riêng. Đây là lý do KỸ THUẬT (không phải cảm tính) nghiêng câu giờ-G `:140` về phương án bộ ZZTEST riêng.

### F-6 [MEDIUM] `pe.ContractId ??=` — spec khai 2 chiều hệ quả, thiếu chiều thứ 3 (bridge cũ chạy TRƯỚC)

Hook xác nhận GĐ3 chỉ đi qua `pe.contractId`: `usePipelineStages.ts:158-160` (chú thích) + `:214-218` (`for (const p of peItems) { if (!p.contractId …`), 2 app byte-identical (sha256 `7144eeb787a8ff8b…`). Spec `:52` liệt (i) khoá bridge cũ, (ii) liên-danh chỉ HĐ[0].
**Thiếu:** thứ tự NGƯỢC. Guard #5 của spec (`:43`) chỉ xét `line.ContractId == null`, **không xét `pe.ContractId`** ⇒ PE đã tạo HĐ bằng đường cũ (`pe.ContractId != null`) vẫn bridge tiếp bằng KHKK → `??=` **no-op im lặng** ⇒ HĐ mới **vô hình trên cây GĐ3** + 2 HĐ song song cho cùng gói, 0 cảnh báo. Đúng lớp "vắng-mặt trông giống ổn".
**Đề nghị:** K7.1 phải chốt 1 trong 2 — chặn 409 ("PE này đã có HĐ tạo từ đường cũ"), hoặc cho phép nhưng bắt buộc changelog PE nêu "cây chỉ hiển thị HĐ đầu" + thêm case test T9. Không để `??=` quyết định im lặng.

### F-7 [LOW→MEDIUM] Gen mã lúc bridge: thiếu 2 tiền-đề của codegen, bỏ trống `SlaDeadline`, và `WorkflowDefinitionId=null` lệch CẢ HAI tiền lệ

- `codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct)` (`CreateContractFromEvaluationFeatures.cs:113`, `ContractWorkflowService.cs:390`) **cần entity Project + Supplier** để lấy `Code`. Bridge cũ nạp `project` `:65-66` + `supplierMap` `:63-64` kèm 2 `NotFoundException` (`:66`, `:85-86`). Khối "Build Contract (map field)" spec `:46` chỉ nêu `ProjectId`/`SupplierId` — **không nêu bước nạp + 2 guard NotFound**.
- `SlaDeadline` vắng trong field-map (bridge cũ set `:109-110`; create tay set `ContractFeatures.cs:110`). Không chí mạng (trình sẽ set +7d `ContractWorkflowService.cs:98`) nhưng HĐ ở Nháp trống SLA — khác HĐ tạo tay; nên khai là CỐ Ý.
- `WorkflowDefinitionId = null` (spec `:46`) lệch cả 2 tiền lệ: bridge cũ pin V1 `:108`; create tay pin **CẢ HAI** với chú thích tường minh `ContractFeatures.cs:105-109` *"Cả 2 set ok — Service ApproveV2Async branch dispatch theo ApprovalWorkflowId trước"*. Hệ quả đo được: `GetContractQueryHandler:647-662` khi `WorkflowDefinitionId == null` rơi về `WorkflowPolicyRegistry.ForContractWithOverrides` (policy hardcode) ⇒ khối `workflow` trong DTO detail của HĐ sinh-từ-KHKK **khác** HĐ V2 tạo tay. Spec đang phá luật tự đặt ("GIỐNG NHAU — CHỈ KHÁC FORM — ĐỪNG CHẾ THÊM" `:25`) mà không chứng "FE V2 không đọc `workflow.nextPhases`".
- Chốt nhỏ: guard `:384` thực tế là `string.IsNullOrEmpty(contract.MaHopDong)` (spec ghi `is null`) — mạnh hơn, không sai nghĩa.

### F-8 [LOW] Nhãn số trong chính spec lệch: "7 case" vs bảng 8 dòng

`:71` "K7.4 (d) Test — **7 case**" nhưng bảng `:75-82` có **T1…T8 = 8 dòng**; `:92` A7 "baseline+**8**", `:96` liệt V1-V8, `:164` "T1-T8", `:171` END "8 test" ⇒ 1 nhãn sai / 5 chỗ đúng. Đếm B0-B16 thì ĐÚNG: B0 + (B1…B14 = 14 dòng bảng) + B15 + B16 = **17**.

### F-9 [MEDIUM] Picker quy trình lọc **2 vế**, spec + B0 chỉ nêu 1 ⇒ B0 xanh mà dialog B9 vẫn RỖNG

Dropdown mà spec bảo mirror lọc **cả hai**: `fe-user/src/pages/contracts/ContractCreatePage.tsx:334-341` — gọi `/approval-workflows-v2?applicableType=3` rồi `.filter(w => w.isUserSelectable)` (`:341`; chú thích `:332` "IsUserSelectable=true (admin ghim cho user pick)"). BE có cột + toggle riêng: `ApprovalWorkflowV2AdminFeatures.cs:68/129-131/1164-1175`, **độc lập `IsActive`**.
Spec K7.3(3) `:68` chỉ ghi "filter ApplicableType=3"; K7.2 `:62` và B0(ii) `:110` chỉ đòi "≥1 ApprovalWorkflow ApplicableType=3 **ACTIVE**". ⇒ Nếu workflow type-3 trên prod có `IsUserSelectable=false`, **B0 vẫn PASS** nhưng tới B9 dialog rỗng, không tạo được HĐ — hỏng giữa dry-run (đúng thứ B0 sinh ra để chặn).
**Đề nghị:** sửa B0(ii) thành "ApplicableType=3 **VÀ `IsUserSelectable=true`**" (sqlcmd đọc cả 2 cột), và K7.3(3) khai đủ 2 vế lọc.

### F-10 [LOW] Bằng chứng B6 neo vào cột CHƯA tồn tại + sai bảng

B6 `:119` đòi `sqlcmd EndedByLevelFinalize,ApprovedAmount`. Đo: `grep -rn "Finalize\|ApprovedAmount" src/Backend/SolutionErp.Domain/ContractSigningPlans/*.cs` → chỉ `ContractSigningPlanLine.cs:23 ApprovedAmount` ("CHỈ ghi tại choke-point finalize (W3)") + `ContractSigningPlanPhase.cs:12`. **0 hit `EndedByLevelFinalize`** phía KHKK (cột này hiện chỉ có ở PE, Mig 60/61). Ngoài ra `ApprovedAmount` nằm ở bảng **`ContractSigningPlanLines`**, không cùng bảng với cờ header như câu sqlcmd gợi ý.
**Đề nghị:** B6 khai tường minh "cột do K3 đẻ — nếu K3 không đẻ thì bằng chứng đổi sang X", và tách 2 câu sqlcmd đúng bảng. (Lớp "acceptance literal tự-lão-hoá".)

### F-11 [LOW] Guard workflow copy nguyên khuôn ⇒ kế thừa lỗ: không re-check `IsActive`/`IsUserSelectable` phía server

Khuôn `ContractFeatures.cs:74-88` (và `EnsureWorkflowTypeAsync` `ContractSigningPlanFeatures.cs:209-220`) chỉ kiểm tồn-tại + `ApplicableType`. Với endpoint MỚI, POST forge `ApprovalWorkflowId` của quy trình type-3 đã **retired/unpinned** vẫn pin được ⇒ HĐ chạy roster cũ. Không chặn merge (bằng đúng mặt bằng hiện tại), nhưng spec nên khai là CHẤP NHẬN có ý thức, hoặc thêm 1 dòng guard vào K7.1(6).

### F-12 [PASS — control dương, chống dương-giả]

- B12 khả thi thật: `fe-{user,admin}/src/pages/contracts/HardCopiesPage.tsx:279` `form.append('purpose', String(SEALED_COPY))`; BE nhận `AttachmentPurpose Purpose` (`ContractAttachmentFeatures.cs:22/51/80`); cờ đọc `ContractFeatures.cs:348-355` (EXISTS, cố ý không có `!IsDeleted` — đã giải thích tại chỗ).
- QUYẾT-2 (`:103`) đúng mã: `ContractCodeGenerator.cs:23` `HopDongNhaCungCap => "NCC"`; "MB" `:25` tồn tại nhưng né là hợp lý.
- Neo FE `:66` chính xác: khối `actions={` của `fe-user/src/pages/khkk/KhkkDetailPage.tsx` bắt đầu `:291` (nút Xoá `:303-313`, Danh sách `:314-317`); bản `fe-admin` tồn tại (26.927 B).
- Rào (i)/(iii)/(iv) mà spec trích đúng vị trí: `:330-332`, `:342-347`, `:349-350`.

## Verdict

**PASS-WITH-FLAGS** — spec cụm-3 **không có claim SAI** (7/7 đúng về sự-kiện, kể cả cú BÁC-tiền-đề đề-bài là đúng và có lợi). Nhưng **4 MEDIUM** phải vá TRƯỚC khi implementer khởi công / trước khi chốt lịch K8:

- **F-2** policy cộng-dồn + viện #85 sai chiều (đổi câu chữ + B0 kiểm 2 quyền).
- **F-9** B0 thiếu vế `IsUserSelectable` ⇒ B0 xanh mà B9 rỗng (chặn được bằng 1 dòng sqlcmd).
- **F-6** chiều ngược `pe.ContractId != null` không có guard ⇒ HĐ mới vô hình trên cây.
- **F-5** "chiếm slot nhóm-4" chỉ đúng SAU K2; chưa K2 thì khoá cả PE thật.

LOW (F-4, F-7 phần `WorkflowDefinitionId`, F-8, F-10, F-11) nên vá trong cùng lượt sửa spec vì đều là 1-2 dòng.
Không có blocker nào thuộc loại "phải build lại spec".

END sub-reviewer-c3-l1 — TOTAL: 12 finding (0 SAI-claim · 4 MEDIUM · 6 LOW · 2 PASS-control-dương) · 7/7 claim đề-bài đã chấm.
