17 KiB
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ụcIsActive=falsehoặc sai nhóm 409. Ngữ nghĩa đúng. AsNoTrackingtrên entry: đúng — chỉ đọcIsActive/ApprovalGroup/TenVi;linelấy từplan.Linesnê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")
- Class
[Authorize(Policy="KeHoachKyKet.Read")]—ContractSigningPlansController.cs:29. - 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 ORKhkk_*. 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.
- Hạng mục bị xoá mềm SAU khi gán → submit-guard bắt (global filter loại ⇒
okCounthụt) ⇒ bịt. - Hạng mục
IsActive=falseSAU khi gán → submit-guard không kiểmIsActive⇒ 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). - Đổi nhóm duyệt của phiếu sau khi gán → bất khả:
ApprovalGroupchỉ set lúc tạo (:459);UpdateContractSigningPlanDraftCommandchỉ nhậnGhiChu/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-cataloglỗ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ữ
TenHangMucvà 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)
- Tên tham số query đúng — FE gửi
?approvalGroup=, BEContractCatalogController.cs:28nhậ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. - Hình dạng payload khớp — BE trả
List<ContractCatalogEntryDto>trần, không bọc paging; FE khai{id, code, tenVi}[]⇒.mapan toàn. - DTO có sẵn field —
KhkkLineDto.catalogEntryIdvà.contractIdtồn tại trên đĩa (types/khkk.ts:212,218), BE map ở:737⇒ hợp đồng FE↔BE không đứt. - Query-key không va —
['contract-catalog', group]so với trang danh mục dùng['catalogs', kind, search]. - Route không đụng route cũ —
PUT {id}/lines/{lineId}là đường duy nhất chứalinestrong controller. Summarykhông tràn cột — tối đa khoảng 20 + 500 + phụ ≈ 550 ký tự, dướiHasMaxLength(1000)⇒ không dính bẫy "SQLite nuốt maxlength, SQL Server mới ném" (đã kiểm, không đoán).- Denorm
TenHangMuclú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ũ. EnsureDraftEditablechạ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.- Body chỉ nhận
CatalogEntryId,planId/lineIdlấ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).