GATE-LINEEDITOR: PASS-WITH-FLAGS 8 — vá K8 chạy thật, đóng đúng blocker, 642/642 test xanh, 2 app tsc sạch, SHA-pair khớp; 0 blocker mới, nhưng có 2 MAJOR (changelog thiếu `EntityId` · `<select>` nuốt hạng mục đã lưu).

# Gate reviewer — K8 blocker vá: KHKK Lines-editor (S168, 2026-08-01)

## A. Diff scope (ĐO bằng `git diff --stat`, đo lại NGAY TRƯỚC khi chốt verdict)

Cây làm việc bẩn 14 file vì trộn 2 lane. **Phần thuộc vá K8 = 5 file:**

| File | Δ |
|---|---|
| `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` | +65 |
| `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` | +15 |
| `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs` | +86 |
| `fe-admin/src/pages/khkk/KhkkDetailPage.tsx` | +58 / −... |
| `fe-user/src/pages/khkk/KhkkDetailPage.tsx` | +58 / −... |

**Ngoài scope K8** (lane GĐ3-D, KHÔNG tính vào gate này): `PipelineTreePanel.tsx` ×2 · `ContractsListPage.tsx` · `InboxPage.tsx` · `MyContractsPage.tsx` · `.claude/*`.
⇒ Không có file BE/test nào ngoài phạm vi spec ⇒ **anti-fiddle PASS**.

## B. Phép đo cứng đã chạy

| Phép đo | Lệnh | Kết quả |
|---|---|---|
| Suite BE | `dotnet test SolutionErp.slnx` | Domain **45/45** + Infrastructure **597/597** = **642 PASS / 0 FAIL** |
| Đối chiếu baseline | sổ S167 = 639 | 639 + 3 `[Fact]` mới = **642 khớp chằn chặn** ⇒ một con số đóng hai câu hỏi: test mới CHẠY THẬT **và** 0 test cũ vỡ |
| Build BE | (trong lượt test) | 4 project build, **0 error** |
| tsc fe-admin | `npx tsc -b --force` | **0 dòng lỗi** |
| tsc fe-user | `npx tsc -b --force` | **0 dòng lỗi** |
| SHA-pair FE (R6) | `sha256sum` 2 file | `21e0ca0507d4074965805c94a60c7bc58aeb2cd58a55fd1627ee25e062a42469` — **hai app BẰNG NHAU byte-level** ⇒ R6 PASS |

⚠️ **Thước hỏng tự khai:** lượt đầu tôi viết `echo "ADMIN_EXIT=$?"` sau `tsc | tail` — số đó là exit của `tail`, vô nghĩa. Đã bỏ và đo lại bằng `--force` (chống cả bẫy incremental-skip của `tsc -b`); tín hiệu hợp lệ = **0 dòng output lỗi**, cả hai app.

## C. R1 — Handler: race · idempotency · ngữ nghĩa mã lỗi

Code đo: `ContractSigningPlanFeatures.cs:577-641`.

- **Gán ĐÈ hạng mục cũ**: HỢP LỆ — handler không chặn `line.CatalogEntryId != null`, chọn nhầm thì chọn lại được. Đúng ý spec, **nhưng chưa có test ghim** (F-4).
- **NotFound ⟂ Conflict**: phiếu 404 · dòng 404 · hạng mục không tồn tại / đã xoá mềm 404 (global filter `!IsDeleted`, `ContractCatalogEntryConfiguration.cs:29`) · hạng mục `IsActive=false` hoặc sai nhóm 409. **Ngữ nghĩa đúng.**
- **`AsNoTracking` trên entry**: đúng — chỉ đọc `IsActive`/`ApprovalGroup`/`TenVi`; `line` lấy từ `plan.Lines` nên vẫn tracked, không có bẫy "sửa mà không lưu".
- **Race hai request cùng một dòng**: last-write-wins, không `RowVersion`; cả hai giá trị đều đã qua rào nên vô hại.
- **Race gán ∥ trình**: xem F-5.
- **Changelog lặp**: gán lại đúng hạng mục cũ vẫn đẻ một dòng nhật ký (không so giá trị trước). Nhiễu, không sai.

## D. R2 — AUTHZ (3 tầng, không có lỗ "sửa dòng của phiếu người khác")

1. Class `[Authorize(Policy="KeHoachKyKet.Read")]` — `ContractSigningPlansController.cs:29`.
2. Action mới `[Authorize(Policy="KeHoachKyKet.Update")]` — `:109`. **Đúng khuôn 2 tầng (gotcha #82/#85), dùng đúng key policy của endpoint, KHÔNG OR `Khkk_*`.**
3. `ContractSigningPlanScope.EnsureDraftEditable` (`:244-252`) = (chủ phiếu ∥ Admin) **và** phase ∈ {Nháp, Trả lại}.

Người khác chủ phiếu, dù có `KeHoachKyKet.Update`, vẫn ăn `ForbiddenException` ở tầng 3; người ngoài còn không đọc nổi phiếu nháp (`EnsureCanViewAsync` `:231-242` — nháp là riêng tư, S89). Guard này **đã có test trực tiếp sẵn** trong chính file test (~`:595-615`: stranger → Forbidden; Admin vẫn bị chặn theo PHASE), và `[Fact]` mới `GanHangMuc_PhieuDaTrinh_BiChanPhase` chứng minh handler **có gọi** guard ⇒ vế chủ-phiếu đi kèm cùng hàm. **R2 PASS.**

## E. R3 — Guard phối hợp (fail-fast tầng gán ⟂ submit-guard đếm-so-khớp)

Submit-guard: `ContractSigningPlanWorkflowService.cs:176-187`.

1. **Hạng mục bị xoá mềm SAU khi gán** → submit-guard bắt (global filter loại ⇒ `okCount` hụt) ⇒ **bịt**.
2. **Hạng mục `IsActive=false` SAU khi gán** → submit-guard **không** kiểm `IsActive` ⇒ phiếu vẫn trình được. Đánh giá: **đúng thiết kế** (tên đã denorm là ảnh chụp lúc gán; ngừng hiệu lực không được phép làm kẹt phiếu đang chạy). Không phải lỗi — nhưng đẻ ra hệ quả hiển thị F-2(c).
3. **Đổi nhóm duyệt của phiếu sau khi gán** → **bất khả**: `ApprovalGroup` chỉ set lúc tạo (`:459`); `UpdateContractSigningPlanDraftCommand` chỉ nhận `GhiChu`/`HoSoLink`/`ApprovalWorkflowId`. Khe này **RỖNG — và tôi khai rõ là rỗng**, không tính công.

## F. FINDINGS (8)

### F-1 [MAJOR] Changelog dòng `Line` bỏ trống `EntityId` — nhật ký không biết là DÒNG NÀO
`ContractSigningPlanFeatures.cs:627-636` ghi `EntityType = Line` nhưng không gán `EntityId`.
**Đo bằng chính quy ước của file:** mọi lần ghi nhật ký cho thực thể con đều điền — `:1069`, `:1107` (`EntityId = item.Id`), `:1215` (`= attId`), `:1295` (`= att.Id`), `ContractSigningPlanWorkflowService.cs:550` (`= actingLevelId`). Chỗ mới là **chỗ DUY NHẤT lệch**.
Hệ quả thật: phiếu liên danh nhiều dòng (đúng ca chính của module này) sẽ có N dòng nhật ký "Gán hạng mục '…' cho dòng NCC" mà **không cách nào biết dòng nào**; `Summary` cũng không nêu tên nhà cung cấp. Cột sinh ra để trả lời đúng câu hỏi đó thì để null.
**Acceptance:** gán `EntityId = line.Id`; khuyến nghị thêm mốc nhận dạng nhà cung cấp vào `Summary`.

### F-2 [MAJOR] `<select>` nuốt mất hạng mục ĐÃ LƯU khi option không có trong danh sách
`KhkkDetailPage.tsx:559-578` (cả hai app): `value={l.catalogEntryId ?? ''}`, danh sách option = `catalogOptions.data ?? []`, và có sẵn `<option value="">— Chọn hạng mục —</option>`. Khi `catalogEntryId` **không khớp option nào**, trình duyệt rơi về placeholder ⇒ **dòng đã gán trông như chưa gán**. Ba ca:
- (a) đang tải danh mục ⇒ nháy tạm (nhẹ);
- (b) query `/catalogs/contract-catalog` lỗi mạng/5xx ⇒ **mất hẳn** tên hạng mục của mọi dòng, mà lỗi này **không có toast** (chỉ mutation mới có `onError`);
- (c) hạng mục bị ngừng hiệu lực sau khi gán ⇒ **vĩnh viễn** hiển thị như chưa gán, dù DB vẫn giữ `TenHangMuc` và submit-guard vẫn cho trình (mục E.2).

Trước vá, ô này luôn hiện `l.tenHangMuc`. Sau vá, dữ liệu đã lưu **biến mất khỏi màn hình** trong ba ca trên — đúng lớp bệnh "vắng mặt trông giống ổn".
**Acceptance:** khi `l.catalogEntryId` không nằm trong options thì chèn một `<option value={l.catalogEntryId}>` nhãn `l.tenHangMuc`; hoặc render text `l.tenHangMuc` cho tới khi `catalogOptions.isSuccess`. Kèm hiển thị lỗi khi query danh mục thất bại.

### F-3 [MINOR] Nhóm không còn hạng mục hiệu lực ⇒ dropdown rỗng, không một chữ giải thích
`enabled` chỉ gác `planDraftEditable && approvalGroup != null`. Nếu nhóm duyệt của phiếu không còn dòng danh mục `IsActive` nào, người soạn thấy dropdown trống, không hiểu vì sao, và **không bao giờ trình được phiếu** (submit-guard chặn null) — tái lập đúng lớp bệnh K8-blocker dưới lớp áo khác. Hôm nay khó chạm vì seeder `SeedContractCatalogAsync` (`DbInitializer.cs:3503`) nạp 86 dòng phủ 8 nhóm; rào là để chống hồi quy khi quản trị ngừng hiệu lực cả nhóm.
**Acceptance:** khi `isSuccess && data.length === 0` → hiện dòng chữ "Nhóm duyệt N{n} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục".

### F-4 [MINOR] Ba `[Fact]` mới phủ đúng một nửa số nhánh của chính guard vừa viết
Có test: sai nhóm (409) · phiếu đã trình (409) · ca dương gán rồi trình QUA.
Không có test: (a) `!entry.IsActive` — helper `AddCatalogAsync` luôn đặt `IsActive = true`, tức **một nửa vế của `if` mới chưa từng chạy lần nào**; (b) `NotFoundException` cho hạng mục rác/đã xoá mềm; (c) **gán đè** — chính câu R1 hỏi; hành vi đang đúng nhưng chưa bị ghim, người sau thêm `if (line.CatalogEntryId != null) throw` sẽ không có test nào đỏ; (d) `lineId` thuộc phiếu khác → 404.
Ghi nhận mặt tốt: ca dương **nối thẳng tới cửa ra thật** (`ActAsync … "submit"` rồi assert `ChoDuyet`) chứ không đo gán suông — đúng bài "răng phải cắn tới cửa ra".
**Acceptance:** thêm `[Fact]` `IsActive=false` → 409; thêm `[Fact]` gán đè (gán A rồi gán B ⇒ `CatalogEntryId=B` và `TenHangMuc` đổi theo).

### F-5 [MINOR] Cửa sổ tranh chấp giữa gán và trình (không có `RowVersion`)
Handler đọc phiếu ở trạng thái Nháp → qua guard → lượt khác `submit` đẩy sang Chờ duyệt → `SaveChanges` của gán vẫn ghi ⇒ dòng bị sửa sau khi phiếu rời tay người soạn. Xác suất thấp (một người soạn), giá trị ghi vào vẫn hợp lệ, và cả repo chưa dùng `RowVersion` (đang nằm ở backlog Tier 4) ⇒ **không đòi sửa lượt này**, ghi để không ai tưởng khe này đã đóng.

### F-6 [MINOR] Backend không rào `line.ContractId == null`, chỉ frontend khoá
FE khoá dòng đã bắc cầu hợp đồng (`isDraft && !l.contractId`) kèm bình luận "đổi hạng mục sau khi có HĐ = đổi lịch sử", nhưng BE không kiểm `ContractId`. Hôm nay **không thủng**: cầu sang hợp đồng đòi `plan.Phase == DaDuyet` (`CreateContractFromSigningPlanFeatures.cs:97`), và `DaDuyet` là trạng thái cuối (`return`/`reject` đều đòi Chờ duyệt; `submit` đòi Nháp/Trả lại) ⇒ dòng có `ContractId` luôn nằm trong phiếu mà `EnsureDraftEditable` đã chặn. Khe rỗng hôm nay, nhưng ý định đang được giữ bởi mỗi FE — đúng lớp "ẩn menu không đóng được API". Đề xuất rào một dòng ở BE cho khớp lời bình luận.

### F-7 [MINOR] Thiếu rào trùng `(Plan, Supplier, CatalogEntry)` ⇒ nếu chạm sẽ là 500 kèm rò lỗi SQL
`ContractSigningPlanLineConfiguration.cs:37-39` có UNIQUE `(ContractSigningPlanId, SupplierId, CatalogEntryId)` lọc `[IsDeleted]=0`, và bình luận ngay trên đó **tự hẹn** mắt xích thứ hai là "[K4b — thì tương lai] validator đường gán hạng mục khi Lines-editor ra đời". Lines-editor land lượt này mà validator trùng **không có**; vi phạm sẽ ném `DbUpdateException` → `GlobalExceptionMiddleware` rơi nhánh mặc định ⇒ **500 + đẩy nguyên message SQL ra `detail`**.
**Đo khả chạm:** `PurchaseEvaluationConfiguration.cs:105` có UNIQUE `(PurchaseEvaluationId, SupplierId)` ⇒ mỗi nhà cung cấp trúng thầu chỉ một dòng ⇒ đường tạo dòng DUY NHẤT (`Lines.Add` chỉ ở `:475`) không thể sinh hai dòng cùng nhà cung cấp ⇒ **hôm nay bất khả chạm**. Khai đúng mức: không phải lỗ đang chảy, mà là **lời hẹn trong chính bình luận chưa được trả**, và sẽ thành 500 ngay khi có đường thêm dòng theo hạng mục — đúng thứ Mig 71 mở đường tới.

### F-8 [MINOR] `aria-label` trùng nhau trên mọi dòng
`aria-label="Gán hạng mục SP-002 cho dòng"` giống hệt cho N dòng ⇒ trình đọc màn hình đọc N hộp chọn cùng tên, không phân biệt được dòng của nhà cung cấp nào. **Acceptance:** nhét tên nhà cung cấp vào nhãn.

## G. Chín điểm ĐÃ CHỐNG được soi (ghi nhận dương)

1. **Tên tham số query đúng** — FE gửi `?approvalGroup=`, BE `ContractCatalogController.cs:28` nhận `[FromQuery] int? approvalGroup`. Bẫy "K4b ship `?group=`" (đó là tham số của **list phiếu KHKK**, `[FromQuery(Name="group")]`) **không dính**.
2. **Hình dạng payload khớp** — BE trả `List<ContractCatalogEntryDto>` trần, không bọc paging; FE khai `{id, code, tenVi}[]` ⇒ `.map` an toàn.
3. **DTO có sẵn field** — `KhkkLineDto.catalogEntryId` và `.contractId` tồn tại trên đĩa (`types/khkk.ts:212,218`), BE map ở `:737` ⇒ hợp đồng FE↔BE không đứt.
4. **Query-key không va** — `['contract-catalog', group]` so với trang danh mục dùng `['catalogs', kind, search]`.
5. **Route không đụng route cũ** — `PUT {id}/lines/{lineId}` là đường duy nhất chứa `lines` trong controller.
6. **`Summary` không tràn cột** — tối đa khoảng 20 + 500 + phụ ≈ 550 ký tự, dưới `HasMaxLength(1000)` ⇒ không dính bẫy "SQLite nuốt maxlength, SQL Server mới ném" (đã kiểm, không đoán).
7. **Denorm `TenHangMuc` lúc gán** đúng khuôn lịch sử: danh mục đổi tên về sau không viết lại phiếu cũ.
8. **`EnsureDraftEditable` chạy TRƯỚC mọi truy vấn khác** ⇒ không rò sự tồn tại của hạng mục cho người không có quyền sửa.
9. **Body chỉ nhận `CatalogEntryId`**, `planId`/`lineId` lấy từ route ⇒ không có lỗi lệch id giữa route và body.

## H. Bảng 6 category

| Category | Kết quả | Ghi chú |
|---|---|---|
| 1. Wire BE / feature claim | **PASS** | Không còn dấu vết giả lập: `git diff` không có `// Mock` / `alert(` / `TODO wire`; có `api.put` thật ở FE và `[HttpPut]` thật ở BE; đường gán nối tới submit-guard được test dương chứng minh |
| 2. Schema integrity | **PASS** | Vá 0 migration (đúng — chỉ dùng cột `CatalogEntryId` của Mig 71); không đụng snapshot ⇒ luật 3-file không áp |
| 3. Security | **PASS** | 3 tầng ở mục D; không rò dữ liệu; không nối chuỗi SQL |
| 4. Code quality | **PASS** | BE 0 error · tsc 2 app sạch · 642 test xanh · không `--no-verify` · 0 file lạc ngoài spec · mirror 2 app khớp byte |
| 5. Test coverage | **PASS-với-cờ** | +3 `[Fact]`, đúng luật test-after; thiếu 4 nhánh ở F-4 |
| 6. Writing quality | **N-A** | Không có nội dung hướng ra ngoài trong diff |

## I. Khuyến nghị

- **Cho commit lượt này** — không có blocker; đường gán đã đóng đúng lỗ "phiếu mới kẹt Nháp vĩnh viễn" mà dry-run K8 chỉ ra.
- **Nên vá ngay trước commit** (rẻ, mỗi cái vài dòng): **F-1** (`EntityId = line.Id`) và **F-2** (option dự phòng cho hạng mục đã gán).
- **Để lượt sau**: F-3, F-4, F-6, F-7, F-8. **F-5** ghi nhận, không sửa.
- Nếu chỉ chọn được một thứ để làm: **F-1**, vì nhật ký sai là thứ không backfill lại được.

END sub-reviewer-gate-lineeditor — VERDICT=PASS-WITH-FLAGS (0 blocker · 2 MAJOR · 6 MINOR) — TOTAL=8

---

## LEAD DISPOSITION @S168 (từng dòng)

| F | Xử | Chi tiết |
|---|---|---|
| F-1 MAJOR | ✅ VÁ | `EntityId = line.Id` + lookup `Suppliers.Name` vào Summary ("cho dòng NCC {tên}") |
| F-2 MAJOR | ✅ VÁ | option-fallback từ dữ liệu đã lưu khi `catalogEntryId ∉ options` (3 ca loading/error/ngừng-hiệu-lực) + dòng lỗi đỏ `isError` kèm nút thử-lại |
| F-3 MINOR | ✅ VÁ | `isSuccess && length===0` → dòng amber "Nhóm N{n} chưa có hạng mục đang hiệu lực" |
| F-4 MINOR | ✅ VÁ | +2 Fact: `IsActive=false → 409` (nhánh guard độc lập) · gán-ĐÈ đổi cả Id lẫn `TenHangMuc` snapshot |
| F-5 MINOR | 🔸 GHI NỢ | race gán-vs-trình không RowVersion — cùng gói nợ K7 `race-2-request` (unique/RowVersion nhịp sau, đã có tên trong sổ) |
| F-6 MINOR | ✅ VÁ | rào BE `line.ContractId != null → 409` (FE khoá chỉ là hiển thị — bài #82 2-layers) |
| F-7 MINOR | 🔸 GHI NỢ | unique `(Plan,Supplier,CatalogEntry)` — hôm nay bất-khả-chạm (UNIQUE PE-side + đường tạo dòng duy nhất, gate tự đo); vào cùng gói RowVersion/unique nhịp sau |
| F-8 MINOR | ✅ VÁ | `aria-label` += `l.supplierName` per-dòng |

Sau vá: mirror SHA-pair mới `0d6147d0d43c` ×2 app · suite + build ×2 re-verify (vòng chốt).
