# c3-l3

Lane 3/3 review cụm-3 (K7 bridge 8 việc/8 test + K8 dry-run 17 bước) — LENS ĐO-ĐƯỢC + THỨ TỰ.
Target: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-invest-fable-b2-cum3.md` (viết tắt **SPEC:**)
Mọi neo dưới đây đã mở file trên đĩa xác nhận (không tin số của spec).

## Findings

### HIGH

**H1 — `Contracts.Create` chưa được cấp cho vai nào ⇒ nút K7 chết + 403 câm; nhãn "đúng-key-endpoint-đích" sai tiền đề.** [Q1 403-path · Q2]
- SPEC:54 đặt `[Authorize(Policy = "Contracts.Create")]` và tự dán nhãn *"đúng-key-policy-endpoint-đích (gotcha #85)"*; SPEC:67 gate FE `can('Contracts','Create')`; SPEC:122 B9 "Người có `Contracts.Create`".
- Đo seeder: `DbInitializer.cs:2222` nối `ContractMenuKeys()` (`:2165-2178`, phần tử đầu = `MenuKeys.Contracts`) vào `reviewKeys`; vòng cấp `:2251-2258` ghi `CanCreate = isPe` (**`:2256`**) mà `IsPeKey("Contracts") = false` (`:2163`) ⇒ **CanCreate=false cho MỌI role**. Nhánh key non-Pe là **skip-existing** (`:2249-2250`) ⇒ row prod cũ cũng không được nâng. Comment `:2217-2218` nói thẳng: *"HĐ (Contracts+Ct_*) … CanRead-only mọi role"*.
- Đo endpoint đích: `POST /api/contracts` **KHÔNG có action-policy** — `ContractsController.cs:13` class `[Authorize]` trần, `:49 [HttpPost]` trống (đối chứng dương: `:32` có `Contracts.Read`) ⇒ "key của endpoint đích" là **không có key**; bridge sẽ CHẶT HƠN chính chỗ tạo HĐ.
- Hệ quả đo được: B9 chỉ chạy được bằng **Admin (bypass)** ⇒ dry-run không chứng được luồng vai thật; và câu III (SPEC:56) *"đổi answer chỉ đổi điều kiện hiện nút FE, máy BE không đổi"* là sai — đổi answer phải đổi cả **quyền được cấp**.
- Đề nghị: (a) chọn 1 trong 2 — `[Authorize]` trần + guard nghiệp vụ (bằng đích) HOẶC giữ `Contracts.Create` **kèm việc cấp quyền tường minh**; (b) B0 pre-flight thêm `SELECT COUNT(*) FROM Permissions WHERE MenuKey='Contracts' AND CanCreate=1` **≥1**; (c) test 2 chiều 200/403.

**H2 — Gate thật là HỘI 2 policy (`KeHoachKyKet.Read` ∧ `Contracts.Create`); spec + FE chỉ khai 1 vế.** [Q1 403-path]
- `ContractSigningPlansController.cs:28` class `[Authorize(Policy = "KeHoachKyKet.Read")]` (quy ước 2 tầng ghi ở `:14-16`); mọi action hiện hữu đều `KeHoachKyKet.*` (`:36,:84,:93,:103,:208`).
- ASP.NET Core AND 2 attribute ⇒ action mới đòi **cả hai**. SPEC:54/:56/:67 chỉ nói `Contracts.Create` ⇒ FE-BE lệch: ai có `Contracts.Create` mà thiếu `KeHoachKyKet.Read` sẽ thấy nút, ăn 403 câm (lớp #44).
- Đề nghị: spec khai công thức hội; FE `can('KeHoachKyKet','Read') && can('Contracts','Create')`; 1 test 403 cho từng vế thiếu.

**H3 — T2 "gộp 2 line CÙNG NCC" BẤT KHẢ THI trên schema hiện tại; phụ-thuộc THẬT vào cụm-1 bị sót khỏi 3 lý do.** [Q3]
- `ContractSigningPlanLineConfiguration.cs:22-24` **UNIQUE filtered `(ContractSigningPlanId, SupplierId)`**; entity comment `ContractSigningPlanLine.cs:9-11` xác nhận ⇒ 1 plan KHÔNG thể có 2 line cùng NCC.
- Máy OG-1 GỘP của K7 (`SPEC:31` `LineIds[]`, `SPEC:41` guard "tất cả cùng SupplierId", `SPEC:76` T2, `SPEC:80` T6) chỉ có nghĩa SAU khi unique bị bỏ. `spec-cum1-chi-tiet-31-07-2026.md:16` (vá-9) mới là chỗ khai: *"unique cũ bị DROP trong chính Mig 71"*.
- SPEC:154 liệt 3 lý do "K7 sau cụm-1" (TenHangMuc · phase-guard · ApprovedAmount) — **không có** lý do DROP-unique, tức lý do khiến T2 dựng được. Viết T2 trước Mig 71 = fixture vỡ unique.
- Đề nghị: thêm phụ thuộc (iv) + acceptance "sau Mig 71: index `(Plan,Supplier)` không còn `is_unique=1`" chạy TRƯỚC khi khởi công T2/guard-3.

**H4 — B16 rollback cho HĐ là NO-OP: về `TuChoi` vẫn KHÔNG xóa được; neo citation sai chỗ.** [Q4]
- `ContractFeatures.cs:780` `if (entity.Phase >= ContractPhase.DangInKy) throw ConflictException(...)` — **không phải `:632-633`** như SPEC:130 ghi (vùng `:625-638` là authz của Detail).
- `ContractPhase.cs`: DangInKy=5 (`:20`), DaPhatHanh=9 (`:24`), ChoDuyet=10 (`:25`), TraLai=98 (`:26`), TuChoi=99 (`:27`) ⇒ **mọi phase ≥5 bị chặn, kể cả TuChoi**.
- SPEC:136 B16 đặt "HĐ kẹt ChoDuyet → admin Reject→TuChoi" **song song** với vế KHKK ("TuChoi → xóa được qua UI `:1145`" — vế KHKK ĐÚNG, verified `ContractSigningPlanFeatures.cs:1145-1148` allow-list nằm SAU nhánh admin `:1138-1140`) ⇒ người đọc suy ra tính đối xứng không tồn tại.
- Cửa sổ dọn sạch DUY NHẤT của HĐ = Phase `DangSoanThao`(2) < 5, tức **trước B10 (trình)**.
- Đề nghị: B16 ghi rõ "HĐ đã trình = bản ghi vĩnh viễn, chỉ soft-delete bằng sqlcmd" + đánh dấu **abort-point cuối cùng còn dọn sạch = ngay sau B9**.

### MED

**M5 — Rollback thiếu 2 loại bản ghi + lệnh dọn không chạy được như-là.** [Q4]
- SPEC:134 chỉ nêu `pe.ContractId`. **`line.ContractId`** — chính write-site MỚI của K7 (SPEC:50) — không có lệnh dọn ⇒ sau soft-delete HĐ, line vẫn `ContractId != null` ⇒ guard 5 (SPEC:43) khoá line **vĩnh viễn** + badge "Đã có HĐ" trỏ HĐ đã xóa.
- B12 (SPEC:125) upload scan bản cứng ⇒ sinh row `ContractAttachments` + file vật lý; B15 (SPEC:129-134) không liệt.
- SPEC:131 viết `UPDATE ContractSigningPlans SET IsDeleted=1,…` — dấu "…" + thiếu WHERE ⇒ không chạy được; và B1/B9 chỉ chụp **MÃ** (SPEC:114/:122), không có bước ghi lại Guid để dựng WHERE.
- (Đối chứng dương: đường thoát "soft-delete giải phóng slot" ĐÚNG — comment `ContractSigningPlanFeatures.cs:338-340` xác nhận global filter loại phiếu xoá mềm khỏi rào `hasLivePlan` `:342-347`.)

**M6 — Acceptance A1 PASS được với impl ghi ĐÈ CẢ PLAN.** [Q2]
- SPEC:86 chỉ `SELECT ContractId ... WHERE Id IN (…)`. Impl set `ContractId` cho **mọi** line của plan vẫn ra "đúng N dòng". Thiếu vế bù: `SELECT COUNT(*) WHERE ContractSigningPlanId=@p AND ContractId=@c` == N.
- T1/T2 (SPEC:75-76) cũng không assert "line KHÔNG chọn giữ `ContractId` null".

**M7 — "1 SaveChanges cuối" tự mâu thuẫn trong 1 đoạn ⇒ impl 2-save vẫn PASS cả 8 test.** [Q2]
- SPEC:50: *"sau khi Add contract **+ SaveChanges chung 1 lần**: `line.ContractId = ...`"* rồi kết *"Atomic: 1 SaveChanges cuối"*. Vế đầu đọc ra 2 lần save ⇒ khe hở: HĐ tồn tại mà line chưa gắn (line vẫn bridge được lần nữa).
- Không test nào bắt: T3 (SPEC:77) chỉ chứng atomic ở **nhánh guard** (fail TRƯỚC mọi save).
- Khuôn có sẵn câu chuẩn để copy nguyên văn: `CreateContractFromEvaluationFeatures.cs:76-79`.

**M8 — Baseline test bỏ ngỏ + dấu "≥" không khoá được việc xoá test.** [Q1]
- SPEC:92 (A7) và SPEC:164: *"≥ baseline+8 (khai k con số)"*. Baseline canonical **có sẵn**: `docs/STATUS.md:472` = **590 PASS** (45 Domain + 545 Infra, đo tươi @S162).
- Nhưng K7 land **sau** cụm-1 (SPEC:156) mà cụm-1 tự khai "~5 test mới" (`spec-cum1:27`) ⇒ baseline của K7 ≠ 590 và spec không nói đo ở **commit nào**. "≥" cho phép xoá 1 test cũ + thêm 9 vẫn xanh.
- Đề nghị: ghi **2 số đo tươi** (trước/sau) trên đúng commit rebase, dùng `==`, kèm 8 tên test.

**M9 — Danh sách "wave phải land trước K8" thiếu K1 + K2; B0 không đếm danh mục.** [Q3]
- SPEC:157 liệt K4/K5/K6/K3/K7. Nhưng B2 (SPEC:115) chạy *"gán hạng mục B1-xx"* = danh mục **K1** (86 row) và bị *"submit-guard vá-5"* = **K2**; B8 (SPEC:121) dựa *"phase-guard vá-6"* = **K2**.
- B0 (SPEC:110) verify workflow + PE nhưng **không đếm danh mục trên prod** ⇒ dry-run chết ở B2 nếu seeder danh mục chưa chạy/bị gate (bài #75/#76: seed gated ≠ migration).
- (Đối chứng dương — hạ mức: **migration không cần verify tay**, `DbInitializer.cs:64 await db.Database.MigrateAsync()` tự apply lúc khởi động API. Chỉ SEED mới cần đếm.)

**M10 — Bộ bằng chứng K8 tự mâu thuẫn với nhánh mặc định + B0 thiếu 2/5 bằng chứng.** [Q2]
- SPEC:105 chốt "nhánh chính = A"; B7 (SPEC:120) là *"chỉ nhánh B"* nhưng vẫn mang số `ss-k8-07`; checklist SPEC:167 đòi **"ss-k8-01..13"** ⇒ chạy đúng default sẽ **thiếu 07 vĩnh viễn** ⇒ checklist hoặc luôn FAIL hoặc bị bỏ qua (bỏ qua = mất răng).
- Bằng chứng B0 (SPEC:110) chỉ "ảnh backup + sqlcmd 3 dòng" — không có bằng chứng cho (iv) dặn team và (v) trả lời câu giờ-G, dù checklist SPEC:166 đòi đủ 5 mục.
- Ghi nhận **dương**: B1-B13 mỗi bước có `ss-k8-NN` riêng ⇒ **KHÔNG dồn cuối** (đúng yêu cầu).

**M11 — Thiếu 3 ca test đo được: đua-2-tab · Σ null-mix · nhánh `??=`.** [Q1]
- (a) **Double-POST đồng thời**: index `ContractId` **non-unique** (`ContractSigningPlanLineConfiguration.cs:26`), guard 5 đọc-rồi-ghi không transaction ⇒ 2 HĐ + cháy 2 seq (seq không thu hồi được, SPEC:132). Khuôn FE cũ chỉ chặn double-click 1 tab (`PeDetailTabs.tsx:2209 disabled={mut.isPending}`) và SPEC:68 không nhắc. Đề nghị: re-check trong transaction (KHÔNG đặt unique trên `ContractId` — sẽ cấm luôn máy gộp N line/1 HĐ), hoặc khai thẳng "không chống được đua đa-tab".
- (b) **Σ null-mix**: T3 (SPEC:77) là "1 line ApprovedAmount=null" đơn lẻ; thiếu ca 2 line {có, null} trong CÙNG call ⇒ impl `Sum(x => x.ApprovedAmount ?? 0)` bỏ guard vẫn xanh.
- (c) **`pe.ContractId ??=`** (SPEC:52): 0 test cho nhánh pe **đã có** ContractId (liên-danh / bridge cũ) — đúng chỗ spec tự khai hố W9. Không khoá ⇒ lần sau ai đổi `??=` thành `=` (đè) sẽ không ai biết.

### LOW

**L12 — Đếm test không khớp trong cùng file:** tiêu đề SPEC:71 *"7 case"* · bảng SPEC:73-82 có **8** dòng T1-T8 · SPEC:96 V7="T1-T7" + V3=T8 · SPEC:164/:171 "8 test". Sửa tiêu đề → "8 (7 mới + 1 khóa)" kẻo implementer làm 7.

**L13 — Citation lệch:** SPEC:130 ghi `ContractFeatures.cs:632-633` cho guard xoá HĐ; thực tế `:780`. Nội dung guard đúng, neo sai (xem H4).

**L14 — Đếm file test sai (ảnh hưởng H1/H2):** SPEC:14 nói *"4 file SigningPlan (Approval/Crud/Schema/AuthorizePolicy)"*; đĩa chỉ có **3** — `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs`, `.../Application/ContractSigningPlanCrudTests.cs`, `.../Common/ContractSigningPlanSchemaTests.cs`. File authz duy nhất là `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` (S118, **không có ca SigningPlan**) ⇒ khuôn test-403 mà spec ngầm dựa vào **không có sẵn** cho controller này. (Kết luận "0 test bridge" của SPEC:14 vẫn ĐÚNG.)

**L15 — Field-map bridge thiếu so với khuôn:** SPEC:46 không mang `SlaDeadline` (khuôn cũ `CreateContractFromEvaluationFeatures.cs:111` có set), `NoiDung`, `DraftData`. Bán kính nhỏ vì `ContractWorkflowService.cs:99` set lại SLA lúc trình ⇒ chỉ draft-trước-trình trống SLA. Nên khai "cố ý bỏ + lý do" thay vì im (§① đòi khai chỗ khác khuôn).

**L16 — [Q5] Khuôn dialog CÓ SẴN không được trỏ tới ⇒ nguy cơ chế lại lần 3.**
- Khuôn thật: `fe-user/src/components/pe/PeDetailTabs.tsx:2202-2237` (+ mirror `fe-admin/...:2202-2237`) = dialog **"Tạo HĐ từ phiếu Duyệt NCC"**: `Dialog` + `Label/Select` `typeOptions` 7 enum (`:2193-2201`) + `Input` tên HĐ + footer Hủy/Tạo + `toast` + `navigate('/contracts/{id}')` + `disabled={mut.isPending}`.
- SPEC:68 chỉ trỏ *"mirror dropdown ContractCreatePage"* ⇒ `typeOptions` 7 enum sẽ bị chép lần thứ 3 (hiện là hằng **cục bộ trong** PeDetailTabs, không export).
- 3 phần CHẾ THÊM có lý do chính đáng (radio chọn line UI-1-1 · Select workflow V2 · badge "Đã có HĐ") nhưng spec **không có bảng "khác khuôn ở đâu + vì sao"** như §① đòi.
- **Đối chứng dương (không phải lỗi):** nguồn dropdown workflow KHÔNG dính #44 — `ApprovalWorkflowsV2Controller.cs:18` class `[Authorize]` trần, `[HttpGet]` `:21` không có action-policy (`Workflows.Create` chỉ từ `:29` trở đi) ⇒ mọi user đăng nhập list được workflow `ApplicableType=3`.

**L17 — Phân tích rebase trỏ nhầm file:** SPEC:156 nói K7 *"thêm code vào `ContractSigningPlanFeatures.cs`"* trong khi SPEC:27 khai **file MỚI** `CreateContractFromSigningPlanFeatures.cs`. File đụng thật với cụm-1 = `ContractSigningPlansController.cs` (+ `KhkkDetailPage.tsx` ×2 — vế này spec ghi đúng).

### Neo đã verify ĐÚNG (control dương — chống "bác cũng phải có chứng")
`ContractSigningPlanLine.cs:23` (`ApprovedAmount` "CHỈ ghi tại choke-point finalize") · `:25` (`ContractId` "[C6 review-schema]") · `LineConfiguration.cs:26` index non-unique · `ContractSigningPlanFeatures.cs:67` DTO `Guid? ContractId` · `:209-220` `EnsureWorkflowTypeAsync` trọn 2 nhánh · `:330-332` rào phase · `:342-347` rào 1-PE-1-plan-sống · `:1145-1148` allow-list SAU nhánh admin (**HỐ-1-KHKK CÓ THẬT**) · `ContractWorkflowService.cs:97` `CurrentApprovalLevelOrder = AWId is not null ? 1 : null` · `:111-113` dispatch V2 · `:384` `if (string.IsNullOrEmpty(contract.MaHopDong))` chống double-gen · `ContractFeatures.cs:381-387` comment CẤM nhét ChoDuyet vào `GetEligiblePhases` (⇒ khuyến nghị "KHÔNG SỬA" của SPEC:17/:162 là ĐÚNG) · `ContractFeatures.cs:74-86` guard `ApplicableType=Contract` (khuôn cho T7) · `fe-{user,admin}/src/pages/khkk/KhkkDetailPage.tsx` tồn tại cả 2 app, khối `actions` PageHeader ở `:290-318`.

## Verdict

**FAIL-cần-vá-trước-khi-build** — 4 HIGH đều là lỗi **đo được**, không phải khẩu vị:
H1 quyền `Contracts.Create` = 0 vai được cấp (seeder `CanCreate=isPe`) ⇒ nút chết/403 ngoài Admin;
H2 gate thật là hội 2 policy, spec+FE khai 1 vế;
H3 T2/guard-gộp bất khả thi tới khi Mig 71 DROP unique — phụ thuộc này KHÔNG có trong danh sách lý do;
H4 rollback HĐ là no-op (numeric guard chặn cả TuChoi).
Phần đúng: bằng chứng K8 per-bước (không dồn cuối), HỐ-1-KHKK có thật, việc "KHÔNG sửa `GetEligiblePhases`" là đúng, dropdown workflow không dính #44.

END sub-reviewer-c3-l3 — TOTAL: 17 finding (4 HIGH · 7 MED · 6 LOW)
