Files
solution-erp/.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-lineeditor.md
2026-08-01 17:02:13 +07:00

17 KiB
Raw Blame History

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 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 21e0ca0507d4074965805c94a60c7bc58aeb2cd58a55fd1627ee25e062a42469hai 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) 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ánbấ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=BTenHangMuc đổ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 DbUpdateExceptionGlobalExceptionMiddleware 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 fieldKhkkLineDto.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 EntityId = line.Id + lookup Suppliers.Name vào Summary ("cho dòng NCC {tên}")
F-2 MAJOR 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 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 +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 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 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).