wal: flush 20260801T1702

This commit is contained in:
pqhuy1987
2026-08-01 17:02:13 +07:00
parent 2305c39019
commit 5c94612fd5
3 changed files with 148 additions and 5 deletions

View File

@ -9,6 +9,8 @@
- **[→ archive/2026-07.md §@S168-curate — verbatim ĐÃ PROBE moved-not-cut] S168 gate GĐ3-D cắm cây vào Panel-3 ×3 trang — PASS-WITH-FLAGS 10, 0 blocker (digest):** **khuôn dùng-lại phải ĐO LẠI theo HOST**`max-h`/`sticky` hiệu-chỉnh cho host CŨ: cây cần 100vh158 mà Panel-3 chỉ có 100vh184 (InboxPage) ⇒ 2 thanh cuộn lồng; 2 trang kia thừa 5px/13px = khít đến mong manh. **Class chết ÂM THẦM khi đổi kiểu cha** (`self-start` cha `block` · `lg:sticky` con DUY NHẤT của chính scroll-container) — tsc+build KHÔNG bắt, chỉ đọc CSS-chain mới thấy. `aside` lồng `aside``aria-label` vẫn giữ `role=complementary` ⇒ 2 landmark lồng. Prod-probe biến lo-ngại thành số: `canPe` 13/13 True ⇒ ca `!canPe` chưa xảy ra (control-âm `Permissions`/`Users` False 12/13). `{!selectedId && <X/>}` = unmount ⇒ mất state thuần (`search`) trong khi `localStorage` sống ⇒ trade-off spec khai THIẾU. Thước hỏng: python Windows đọc JSON phải khai `encoding` · `while read` bỏ dòng CUỐI khi thiếu newline · `join` text-mode đẻ CRLF vào tên file. Tag `[s168, khuon-do-lai-theo-host, class-chet-am-tham, unmount-mat-state]`
- **[→ archive/2026-07.md — verbatim @S168 K8] S168 gate K8-blocker Lines-editor KHKK — PASS-WITH-FLAGS 8, 0 blocker (digest):** 642 = 639 baseline + 3 Fact ⇒ **1 con số đóng 2 câu hỏi** · **quy-ước-của-chính-file là thước rẻ nhất** (mọi changelog sub-entity điền `EntityId`, chỗ mới là chỗ DUY NHẤT trống ⇒ MAJOR bằng 1 grep) · **`<select>` controlled NUỐT giá-trị đã lưu** khi option vắng (đang tải · query lỗi không toast · entry `IsActive=false` sau gán) ⇒ regression hiển thị do ĐỔI KIỂU Ô, tsc+build+test 0 bắt · **bình-luận tự-hẹn mắt-xích tương lai** chưa trả ⇒ nhưng ĐO khả-chạm trước khi hô (UNIQUE tầng trên khoá ⇒ hạ MINOR) · khe RỖNG khai rỗng ×2 · test phủ NỬA vế `if` (`!IsActive` chưa chạy lần nào) · thước hỏng `$?` sau `| tail` ⇒ đo lại `tsc -b --force`.
- **[→ archive/2026-07.md §@S167-K7-curate-2 — verbatim] S167 gate K6 admin thu-gọn — PASS-WITH-FLAGS 5 (digest):** **cây menu PROD SỐNG là thước, seed chỉ là bản vẽ** (`curl /api/menus/me` + control-âm bỏ 1 key). **`filterForAdmin` mới là bước MỘT** — còn `resolvePath→null` drop im lặng (#50) + group rỗng-hoá render như LEAF. **typo-noop**: key ẩn sai chính tả = `Set.has` false, module vẫn hiện, tsc vẫn xanh ⇒ assert TỪNG key TỒN TẠI trong cây prod. MAJOR: CẤM số-đếm trong comment.
- **[→ archive/2026-07.md §@S166-curate — verbatim] S164/S165 K3 · S164 K1 · S162 cây-4-folder · S161-W2 (digest):** tham-số-chết-vì-thiếu-tầng-UI (đo TẦNG-CUỐI ai GỬI) · sweep 2-cờ bất-đối-xứng · flake ⇒ 'N/0 tươi' phải khai SỐ LƯỢT · **ghost-policy** (grep policy, đừng suy từ 'key đã vào All') · **#82 ngược chiều** (FE OR nhiều key LỎNG HƠN policy khớp-CHÍNH-XÁC-1-key ⇒ tự chuốc 403) · hợp-đồng FE↔BE VỠ 6 điểm mà tsc+build+574-test XANH ⇒ so ROUTE+FIELD trên ĐĨA.
@ -96,6 +98,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
---
## 🔄 Curate trigger
- ⚠️ **@S168-K8 đo lại: 18.3KB — VƯỢT cap 17.1KB ⇒ spawn sau curate NGAY** (đã append verbatim K8 vào `archive/2026-07.md`, L1 chỉ giữ digest).
-**@S168 curate: 22.2KB → <17.1KB (ĐO, không đoán).** Trình tự S102 giữ nguyên: append archive TRƯỚC probe moved-not-cut + control-âm mới cắt. 🔴 **Bài mới:** (a) tôi ghi '~20.6KB' bằng ƯỚC LƯỢNG trong khi `wc -c` cho 22.2KB **số trong sổ phải là số ĐO**, baseline '19.0KB' thừa kế từ pass trước cũng đã stale; (b) probe moved-not-cut bằng token lấy từ DIGEST cho **0-hit GIẢ** (digest paraphrase, bản gốc dùng chữ khác) 0-hit phải **thử token thứ 2 từ thân bài** trước khi kết luận cut-not-moved suýt giữ lại 938B ích; (c) **script sinh chữ lại đẻ chữ hỏng câm** (token `join` trong chuỗi làm entry vỡ đôi dòng) đúng bài S166-K5, byte-count KHÔNG bắt, chỉ ĐỌC LẠI mới thấy.
- Hook-cap **>17.1KB** (24.4KB read-limit, S109) → archive recent → L2 `archive/<period>.md` (append additive) + `_INDEX.md` pointer. Stale >3mo → remove.
- **Prev curates:** S166-K5 (probe 3/3) · S162 (2/2) · S145 · S140 · S134 · S128 · **S102 recovery: cut-not-moved ×5 `f229b07` ⇒ mọi L1-shrink kèm '→archive' BẮT BUỘC grep moved-not-cut TRƯỚC khi trust** · S116·S113·S109·S80·S71·S70·S40.

View File

@ -236,3 +236,5 @@
## @S168-curate — verbatim moved from L1 (reviewer MEMORY.md)
- **S168 gate GĐ3-D cắm cây vào Panel-3 ×3 trang PASS-WITH-FLAGS 10, 0 blocker (digest):** **khuôn dùng-lại phải đo LẠI theo HOST**, `max-h`/`sticky` của khuôn được hiệu-chỉnh cho host CŨ: `lg:max-h-[calc(100vh-16rem)]` cộng header+search = 100vh158, còn Panel-3 chỉ 100vh184 InboxPage (dải "Vai trò" +29px) **2 thanh cuộn lồng**; 2 trang kia thừa 5px/13px = **khít đến mức mong manh** (đổi 1 Button `h-8``h-10` lật). **Class chết ÂM THẦM khi đổi kiểu cha**: `self-start` (cha `lg:block` không phải flex) + `lg:sticky` (cây con DUY NHẤT của chính scroll-container quãng dính 0) tsc/build KHÔNG bắt được loại này, chỉ đọc CSS-chain mới thấy. **`aside` lồng `aside` `aria-label` vẫn giữ `role=complementary`** (chỉ tụt `generic` khi KHÔNG tên) 2 landmark lồng. Prod-probe biến 1 lo-ngại thành 1 con số: `canPe` false panel đổi câu chỉ-dẫn thành than-phiền-quyền, nhưng `/permissions/by-role` ×13 vai cho `PurchaseEvaluations.canRead`=**13/13 True** ca chưa xảy ra (control-âm: `Permissions`/`Users` False 12/13, chỉ Admin True phép đo phân biệt). **`{!selectedId && <X/>}` = unmount mất state thuần** (`search`) `localStorage` (`expandedSet`) vẫn sống trade-off spec khai THIẾU. Thước hỏng gặp lại: python Windows đọc JSON UTF-8 phải khai `encoding` (cp1252 nổ) · `while read` bỏ dòng CUỐI khi file thiếu newline (mất 1/13 vai) · python `'\n'.join` ghi text-mode Windows đẻ CRLF vào tên file. Tag `[s168, khuon-do-lai-theo-host, class-chet-am-tham, probe-bien-lo-ngai-thanh-so, unmount-mat-state]` **Evidence:** `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-gd3d.md`
- **S168 gate K8-blocker Lines-editor KHKK PASS-WITH-FLAGS 8, 0 blocker (verbatim):** 5 file (command+validator+handler · route PUT `{id}/lines/{lineId}` policy `KeHoachKyKet.Update` · +3 `[Fact]` · FE ×2 app SHA-pair `21e0ca05` khớp byte). **(1) Con số 642 đóng 2 câu hỏi**: baseline S167 = 639, +3 Fact 642 khớp chằn chặn chứng CẢ "test mới chạy thật" LẪN "0 test vỡ" bằng MỘT phép đo. **(2) Quy-ước-của-chính-file thước rẻ nhất**: mọi write changelog sub-entity trong module điền `EntityId` (`:1069`, `:1107`, `:1215`, `:1295`, `Service:550`) chỗ mới chỗ DUY NHẤT bỏ trống MAJOR F-1 tìm được bằng 1 lệnh grep, không cần suy luận. **(3) `<select>` controlled NUỐT giá trị đã lưu**: `value={l.catalogEntryId}` + option-list từ query khác khi option vắng (đang tải · query lỗi KHÔNG toast · entry `IsActive=false` sau khi gán) trình duyệt rơi về `<option value="">` placeholder **dòng đã gán trông như chưa gán**, trước luôn hiện `tenHangMuc` regression hiển thị do ĐỔI KIỂU Ô (textselect), tsc+build+test 0 bắt. Lớp "vắng-mặt trông giống ổn". **(4) Bình luận trong config TỰ HẸN mắt xích tương lai** (`LineConfiguration:35` hẹn "validator K4b khi Lines-editor ra đời") Lines-editor land lời hẹn KHÔNG trả trùng UNIQUE sẽ ra 500 + message SQL; nhưng **đo khả-chạm trước khi hô hoán**: PE UNIQUE `(PeId, SupplierId)` winners khác NCC đường tạo dòng DUY NHẤT (`Lines.Add` 1 chỗ) không sinh nổi ca trùng hạ xuống MINOR "lời hẹn chưa trả", KHÔNG phải lỗ đang chảy. **(5) Khe RỖNG phải khai rỗng** ×2: đổi `ApprovalGroup` sau khi gán = bất khả (chỉ set lúc tạo, Update-draft không nhận field) · BE thiếu rào `line.ContractId` nhưng `DaDuyet` terminal dòng đã bắc cầu luôn nằm ngoài phase sửa được. **(6) Test mới phủ NỬA vế `if`**: guard `!IsActive || sai-nhóm`, helper `AddCatalogAsync` luôn `IsActive=true` **một nửa vế chưa từng chạy lần nào**; ca "gán đè" (chính câu spec hỏi) đúng hành-vi nhưng 0 test ghim. **(7) Thước hỏng tự bắt**: `echo "ADMIN_EXIT=$?"` sau `tsc | tail` đo exit của `tail` nghĩa; đo lại bằng `tsc -b --force` (chống cả incremental-skip) tín hiệu hợp lệ = 0 dòng lỗi. **(8) Bẫy KHÔNG dính (ghi để khỏi soi lại)**: FE gửi `?approvalGroup=` khớp `[FromQuery] int? approvalGroup` của ContractCatalogController KHÔNG lẫn với `[FromQuery(Name="group")]` của list KHKK; payload `List<T>` trần khớp type FE; `Summary` ~550 < maxlength 1000 (SQLite nuốt maxlength, prod mới nổ kiểm chứ không đoán). Tag `[s168, mot-so-dong-2-cau-hoi, quy-uoc-file-la-thuoc, select-nuot-gia-tri-da-luu, loi-hen-trong-comment, khe-rong-khai-rong]` **Evidence:** `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-lineeditor.md`

View File

@ -1,9 +1,147 @@
GATE-LINEEDITOR: IN-PROGRESS — đang đo diff, caverdict.
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, nng2 MAJOR (changelog thiếu `EntityId` · `<select>` nuốt hạng mục đã lưu).
# Gate reviewer — K8 blocker vá: KHKK Lines-editor (S168)
# Gate reviewer — K8 blocker vá: KHKK Lines-editor (S168, 2026-08-01)
Bắt đầu: 2026-08-01. Trạng thái: đang thu thập bằng chứng, đổ ruột liên tục (chống #53).
## A. Diff scope (ĐO bằng `git diff --stat`, đo lại NGAY TRƯỚC khi chốt verdict)
## Diff scope (đo, chưa điền)
Cây làm việc bẩn 14 file vì trộn 2 lane. **Phần thuộc vá K8 = 5 file:**
## Findings (append liền tay)
| 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``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``.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).