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

32 KiB
Raw Blame History

GATE-K7: PASS-WITH-FLAGS 10

Gate K7 — adversarial review cầu KHKK → HĐ (S167, 2026-08-01)

Vai: reviewer (READ-only). Base đo: HEAD = f5ee146 (wal: flush 20260801T1242), diff UNCOMMITTED. Nguyên tắc: mọi mục dưới đây là phép đo TỰ CHẠY của reviewer, không chép claim của lane.


§0 — Mốc cây + residual scope

Đo bằng git status --porcelain + git diff --stat (2026-08-01, đầu lượt review).

13 file thuộc phạm vi K7 — CÓ ĐỦ, đúng như spec giao:

Nhóm File Trạng thái
BE src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs M (+33)
BE src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs M (+23/-2)
BE src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs ?? MỚI (252 dòng)
BE src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs M (+86)
FE fe-{admin,user}/src/hooks/usePipelineStages.ts M (+61/-?) ×2
FE fe-{admin,user}/src/pages/khkk/KhkkDetailPage.tsx M (+308) ×2
FE fe-{admin,user}/src/types/khkk.ts M (+49) ×2
Test tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs M (+48/-?)
Test tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs ?? MỚI
Test tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs ?? MỚI

FLAG RESIDUAL (F-R1, MINOR) — 3 file dirty NGOÀI cả 13-file-set LẪN lead-set đã khai (.claude/* + docs/STATUS.md):

  • scripts/distill-shard-probe.ps1 (+1/-1)
  • scripts/measure-agent-memory.ps1 (+1/-1)
  • scripts/memory-archive-gate.ps1 (+11/-2)

Đọc diff: cả 3 là việc governance memory-index rotation _INDEX-<period>.md @S167, KHÔNG liên quan K7. Không phải lỗi nội dung; rủi ro là git add -A sẽ nuốt chúng vào commit K7. Đề nghị: hoặc khai chúng vào lead-set, hoặc commit tách. Untracked trong src/tests = đúng 3 file MỚI đã khai, 0 file lạ.


§1 — TRỌNG TÂM 1: hội-2-khoá 2 tầng (BE ∧ FE)

1a. Tầng server — ĐO ĐƯỢC, ĐẠT

  • Class-level: ContractSigningPlansController.cs:29[Authorize(Policy = "KeHoachKyKet.Read")].
  • Action mới: ContractSigningPlansController.cs:227-228[HttpPost("{id:guid}/create-contract")] + [Authorize(Policy = "Contracts.Create")].
  • ASP.NET Core cộng dồn class + action theo AND ⇒ hội 2 khoá là THẬT, không phải claim.
  • Policy Contracts.Create có tồn tại: MenuKeys.cs:12 khai const Contracts, và MenuKeys.cs:228 đưa Contracts VÀO mảng AllProgram.cs sinh đủ 4 policy. (Nếu key ngoài All thì runtime 500 chứ không 403 — đã kiểm, không rơi vào ca đó.)

1b. Tầng FE — ĐỌC DIFF TỪNG DÒNG (lead ghi §3-§4 on-behalf; đây là lưới THẬT đầu tiên)

fe-user/src/pages/khkk/KhkkDetailPage.tsx (bản admin byte-identical, xem §3c):

  • :138-140const { can } = usePermission(); canReadPlan = can('KeHoachKyKet','Read'); canCreateContract = can('Contracts','Create'). Đúng 2 key của server, KHÔNG OR Khkk_* (gotcha #85 giữ được).
  • :299showBridge = phase === KhkkPhase.DaDuyet && canReadPlan.
  • :303-312bridgeBlockReason: nhánh ĐẦU TIÊN là !canCreateContract ⇒ nút render nhưng disabled.
  • :352-363<Button disabled={bridgeBlockReason != null} onClick={() => setBridgeOpen(true)}>.

Truy đường lách: grep toàn file cho setBridgeOpen(true) → xuất hiện ĐÚNG 1 lần, tại onClick của nút đã disabled. Dialog CreateContractFromKhkkDialog render có điều kiện {bridgeOpen && ...} (:781) ⇒ không có đường nào tới create.mutate() khi thiếu Contracts.Create. React không bắn onClick trên <button disabled>. ⇒ FE KHÔNG lỏng hơn server. ĐẠT.

Verify hook đọc gì: fe-user/src/hooks/usePermission.tsfindNode(menu, key) đệ quy trên cây THÔ từ AuthContext, trả false khi không thấy node; Createnode.canCreate. Nghĩa là gate FE phụ thuộc row Permission của key Contracts, đúng thứ seeder mới cấp.

CLARIFY-1 (không phải lỗi mã, là điều kiện nghiệm thu): cây menu FE nạp lúc login và cache localStorage (AuthContext.loadMenu). Comment seeder khai "grant ăn @restart, KHÔNG phải deploy FE" — đúng cho BE, nhưng nút vẫn xám cho tới khi user logout/login lạican() đọc menu cache cũ. UAT phải nói rõ bước đăng-xuất/đăng-nhập, nếu không sẽ bị đọc thành "seeder không ăn".


§2 — TRỌNG TÂM 2: seeder grant, gotcha #84 nghiêm

DbInitializer.cs:2738-2814 = SeedContractCreateAccessAsync; call-site :2361.

2c. THỨ TỰ (vế nặng nhất) — ĐO BẰNG SỐ DÒNG THẬT, ĐẠT

awk liệt kê mọi await Seed*/Revoke* trong khối 2250-2420:

2331: await SeedAllRolesReviewReadPermissionsAsync(...)
2341: await RevokeTemporarilyHiddenModulesAsync(...)      ← revoker
2347: await SeedProcurementMasterAccessAsync(...)
2354: await SeedKeHoachKyKetAccessAsync(...)
2361: await SeedContractCreateAccessAsync(...)            ← MỚI, SAU revoker ✔

Và trong cửa sổ 2361→2520 không còn call nào khác ⇒ seeder mới là seeder-quyền CHẠY CUỐI, không ai lật lại nó. ĐẠT.

Đọc thêm thân revoker :2550-2589 để không chỉ tin thứ tự: filter là MenuKey.StartsWith("Hrm") || StartsWith("Off") || == Personalnhánh Contracts/Ct_* đã bị GỠ từ S159. Vậy claim "revoker hiện không còn nhánh Contracts" là ĐÚNG, và việc giữ thứ tự sau revoker là phòng-thủ dư (đúng chiều, không hại).

2b. UPGRADE-ONLY + IDEMPOTENT — ĐẠT

  • Nhánh row-đã-có (:2790-2797): chỉ if (!row.CanRead) row.CanRead = trueif (!row.CanCreate) row.CanCreate = true. Không chạm CanUpdate/CanDelete, không hạ cờ nào. Không có = false ở bất kỳ đâu trong hàm.
  • Nhánh row-chưa-có (:2800-2808): tạo CanRead=true, CanCreate=true, CanUpdate=false, CanDelete=false.
  • Idempotent: lượt 2 trở đi added=0, upgraded=0không gọi SaveChangesAsync (:2811), không log rác. ĐẠT.
  • Guard role-chưa-seed (:2779-2784): roles.Count == 0 → log warning + return, không ném.

2a/2d. TIỀN ĐỀ "chưa role nào có Contracts.Create" — ĐO ĐƯỢC TỪ MÃ

DbInitializer.cs:2474 reviewKeys = MenuKeys.All.Where(InReviewScope)…Contracts nằm trong tập. IsPeKey("Contracts") == false ⇒ nhánh insert dùng CanCreate = isPe (:2523) = false, và nhánh row-đã-có là skip-existing cho key non-Pe (:2515) ⇒ không ai nâng CanCreate lên. Tiền đề của lane ĐÚNG.

Bộ vai: roleNames = { Drafter, Procurement, Admin } (:2775) — đúng 3 vai spec nêu, kèm comment "câu III default, chờ anh ratify" và chỉ-dẫn sửa 1 chỗ. ĐẠT.

MINOR F-2 — Admin thừa: MenuPermissionHandler.cs:26-31 có Admin-bypass (Succeed ngay khi role chứa Admin) ⇒ cấp row cho Admin vô hại nhưng vô tác dụng. Không đề nghị sửa; nêu để người sau đừng đọc row Admin thành "bằng chứng Admin cần row".

MINOR F-3 — comment :2814 viết "Thu hồi khi cần: xoá call ở SeedAsync", nhưng call thật nằm trong SeedAdminPermissionsAsync (thân 2288-2381), không phải SeedAsync. Chỉ-dẫn trỏ sai tên hàm.

MINOR F-4 (mở rộng ngoài Create) — hàm nâng cả CanRead, trong khi tên hàm + comment chỉ hứa Contracts.Create. Với 3 vai này CanRead gần như đã true từ S159 nên thực tế không đổi gì; nhưng reviewKeys có ghi rõ "trên prod row false KHÔNG được nâng ở đây" ⇒ nếu prod có role nào đang Contracts.CanRead=false thì hàm này âm thầm mở lại quyền XEM, rộng hơn nhãn.


§3 — TRỌNG TÂM 3: chuỗi dữ liệu cây, khớp từng khớp nối

3a. Write-site → DTO (BE)

  • Write-site: CreateContractFromSigningPlanFeatures.cs:238foreach (var l in lines) l.ContractId = contract.Id; Đây là gán THẬT trên entity đã tracked (plan.Lines nạp qua Include :93), nằm TRƯỚC SaveChangesAsync :248. ĐẠT.
  • Fallback: :246 if (pe is not null) pe.ContractId ??= contract.Id;??= đúng chiều (HĐ đầu thắng, không cướp ô của cầu PE cũ).
  • DTO list: ContractSigningPlanFeatures.cs:75 List<Guid> ContractIds (append CUỐI record ⇒ positional-arg của các call-site cũ không lệch).
  • Projection: ContractSigningPlanFeatures.cs:836-842 — subquery Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null).OrderBy(CreatedAt) .Select(l => l.ContractId!.Value).ToList(). Filter != null đứng TRƯỚC .Value ⇒ không NRE, và line chưa bridge thì mảng RỖNG (không phải null-element). ĐẠT.

3b. DTO → FE (tên + null-safety)

  • types/khkk.ts:200contractIds?: string[] | null. JSON camelCase khớp ContractIds. Khai optional dù BE non-null: đúng, vì cache React Query cũ / API chưa deploy không có field.
  • Helper types/khkk.ts:304-313 khkkPlanContractIds(plan): for (const id of plan.contractIds ?? []) if (id) out.push(id) + for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId). Ca "line không chọn ⇒ ContractId null ⇒ mảng rỗng, không crash" = ĐẠT — cả 2 vòng đều ?? [] và đều lọc falsy trước khi push.
  • Chữ ký plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto,'lines'>> ⇒ truyền KhkkListItemDto thuần vẫn hợp lệ (lines optional). ĐẠT.
  • Union + dedupe: usePipelineStages.ts:295-300contractIdsOfPackageSet<string>, đổ nguồn (1) p.contractId rồi nguồn (2) khkkPlanContractIds(k). Chồng nhau ở HĐ đầu là CỐ Ý và Set khử. ĐẠT.
  • Dòng phụ: usePipelineStages.ts:321-326fallbackName nay tra CẢ 2 nguồn (peItems.find(...)?.tenGoiThau ?? khkkItems.find(k => khkkPlanContractIds(k).includes(id))?.peTenGoiThau ?? null). Đo field tồn tại: types/khkk.ts:171 peTenGoiThau: string | null, BE …Features.cs:39 string? PeTenGoiThau. Khớp. ĐẠT.

3c. Mirror 2 app — TỰ ĐO SHA, KHÔNG TIN CLAIM

IDENTICAL  src/types/khkk.ts             60ec8fa3a5aea94456e2180b1b80f63c1994257c55a424f4e1f7adf376cbbc07
IDENTICAL  src/hooks/usePipelineStages.ts 76e1823345d4e78b87e802bc10d22ea0a42307bbb426b8787eb2d25b02e8c52f
IDENTICAL  src/pages/khkk/KhkkDetailPage.tsx cf0a2272061f4bd8cc19c54b1a4992fdac331e9093665ec4ce708e119ef38123

3/3 byte-identical — claim của lane TÁI LẬP ĐƯỢC.

Rủi ro riêng của mirror byte-identical (đã kiểm, KHÔNG thủng): file dùng đường dẫn cứng /contracts/${id} ở 3 chỗ (:527 neo "✓ HĐ", :975 neo "Đã có HĐ", :855 navigate). Vì 2 app có bảng route KHÁC nhau, đây là chỗ dễ 404 một bên. Đo: fe-user/src/App.tsx:65fe-admin/src/App.tsx:84 đều có <Route path="/contracts/:id">. ⇒ ĐẠT ở cả 2 app.

MINOR F-5 — 2 neo dùng <a href> thay <Link> ⇒ full reload (mất SPA state); và neo "Đã có HĐ" (:975) nằm LỒNG trong <label> (nested-interactive, cùng lớp a11y với finding K5 về <button> trong <summary>). Radio của dòng đó disabled nên không sinh double-activate, nhưng khuôn thì vẫn là interactive-lồng-interactive.

MINOR F-6 (hiệu năng, không sai)usePipelineStages.ts:324 gọi khkkPlanContractIds(k) BÊN TRONG findfind lại nằm trong vòng lặp theo contractIdsOfPackage ⇒ O(n·m) và cấp phát mảng mới mỗi lượt. Quy mô hiện tại (list kẹp 200) vô hại.

3d. Chuông DEV — đọc kỹ vì dễ thành chuông-câm hoặc chuông-loạn

usePipelineStages.ts:303-315: chỉ chạy khi import.meta.env.DEV, có cờ module-level warnedMissingLineContractIds (:62) chặn lặp; điều kiện approved.length > 0 && approved.every(k => k.contractIds == null). == null bắt cả null lẫn undefined — ĐÚNG chủ đích "vắng-field ⟂ mảng-rỗng", vì sau khi BE deploy field luôn là [] (non-null) ⇒ chuông tự im. Logic ĐẠT. Lưu ý (không phải lỗi): prod strip DEV ⇒ đây không phải lưới an toàn, chỉ là đèn báo lúc dev.


§4 — TRỌNG TÂM 4: guard 4-vế + idempotency

Đọc CreateContractFromSigningPlanFeatures.cs toàn file (252 dòng).

Rào theo thứ tự thực thi:

# Dòng Nội dung Nhận xét
0 :87-89 IsAuthenticated + UserId ném UnauthorizedException
1 :92-99 phiếu tồn tại + Phase == DaDuyet ĐẠT
2 :101-109 dòng ∈ phiếu (đếm sau Distinct) id lạ/của phiếu khác đều rơi vào đây — ĐẠT
3 :111-115 1 HĐ = 1 NCC ĐẠT
4 :125-127 ApprovedAmount is null → 409 KHÔNG fallback ProposedAmount — ĐÚNG
5 :128-129 ContractId is not null → "đã có Hợp đồng" chống bridge-lặp
6 :134-148 workflow pin 4 vế xem dưới

vá-3 (4 vế workflow) — ĐẠT, và ĐÚNG "throw tách": :136 NotFound nếu không tồn tại · :137-140 sai ApplicableType · :143-145 !IsActive ném riêng · :146-148 !IsUserSelectable ném riêng. Bốn thông điệp bốn nguyên nhân — đúng yêu cầu spec, và rộng hơn khuôn tiền lệ ContractFeatures.cs:79-87 (khuôn cũ CHỈ có 2 vế đầu — tôi đã đọc lại ContractFeatures.cs:74-88 để xác nhận, claim của lane đúng).

Idempotency / double-click — ĐÂY LÀ FINDING NẶNG NHẤT. Rào (5) là kiểm tra đọc-rồi-ghi trong bộ nhớ, không có khoá nào ở tầng CSDL:

  • không unique index trên ContractSigningPlanLines.ContractId;
  • không RowVersion/optimistic concurrency trên Line;
  • không transaction bao ngoài (SaveChangesAsync trần :248; transaction duy nhất trong luồng là transaction NỘI BỘ của ContractCodeGenerator, đã commit xong trước đó).

⇒ Hai request song song (double-click thật, hoặc 2 tab) đều đọc l.ContractId == null:128, cùng đi tiếp, tạo 2 Contract, và lượt ghi sau đè Line.ContractId. Kết quả: 1 HĐ mồ côi (đã tiêu 1 số sequence, đã có changelog) mà không dòng nào trỏ tới. Đây là TOCTOU thật, không phải lo xa: nút "Tạo hợp đồng" chỉ chặn bằng disabled={create.isPending} (:892) — chặn được double-click trong CÙNG tab, không chặn 2 tab / retry mạng.

Xếp hạng: MAJOR F-1, không phải blocker của gate (dữ liệu không mất, HĐ mồ côi xoá được, và xác suất thấp vì thao tác có dialog), nhưng phải được KHAI, vì lane trình mục này như "đã đóng". Acceptance đề nghị (không phải chỉ-dẫn sửa mã): một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ sinh 1 HĐ — hoặc bằng ràng buộc CSDL, hoặc bằng cập-nhật-có-điều-kiện, hoặc khai công khai "chấp nhận rủi ro, chặn ở UI".


§5 — TRỌNG TÂM 5: vá-6

  • Project/Supplier NotFound guard: :153-154 (Project) và :156-157 (Supplier), cả 2 ném NotFoundException trước khi dùng .Code. ĐẠT — loose-Guid không có FK đỡ lưng nên guard này là bắt buộc.
  • Codegen TRƯỚC Add: :212 contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct); rồi :214 db.Contracts.Add(contract);. Đúng thứ tự bài S88 (codegen tự-commit ⇒ không được để HĐ dở trong context). Chữ ký khớp IContractWorkflowService.cs:29 Task<string> GenerateAsync(Contract, string, string, CancellationToken). ĐẠT.
  • SlaDeadline: :205-206 DateTime.UtcNow.Add(workflow.GetPhaseSla(DangSoanThao) ?? TimeSpan.FromDays(7)) — trùng từng chữ với 2 tiền lệ (ContractFeatures.cs:110, bridge PE :109-110). ĐẠT.
  • WorkflowDefinitionId pin: :163-166 truy WorkflowDefinitions active theo request.ContractType:201 gán. Ngang bridge PE cũ (đã đọc lại CreateContractFromEvaluationFeatures.cs:67-71 + field-map :105-113 để đối chiếu). Set CẢ V1 + V2 (:202 ApprovalWorkflowId) là ĐÚNG — service dispatch V2 trước. ĐẠT.

KHAI-1 của test-lane (invest T1 ghi null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG — ĐÓNG, không mở lại. Lý do đo được: vá-6 trong spec ②:16 yêu cầu pin WorkflowDefinitionId theo 2 tiền lệ; mã hiện tại làm đúng thế; "để null" là hành vi mà chính spec loại bỏ. Không có mâu thuẫn còn lại.

MINOR F-7 (field-map thiếu so với tiền lệ, có thể CỐ Ý): bridge PE cũ carry DraftData = pe.PaymentTerms (CreateContractFromEvaluationFeatures.cs:104); cầu K7 không carry DraftData, dù đã nạp pe sẵn ở :170. Cũng không có BypassProcurementAndCCM (cái này hợp lý vì command K7 không nhận tham số đó). Nếu bỏ DraftData là chủ đích thì nên có một dòng comment, vì mọi khoản lệch khác so với tiền lệ đều đã được chú thích.


§6 — TRỌNG TÂM 6+7 (test có răng · comment sạch số-đếm)

6b. Comment mang số-đếm / mỏ-neo — CÓ LỖI, ĐO ĐƯỢC BẰNG 1 LỆNH

MAJOR F-8 — mỏ-neo TỰ VÔ HIỆU ngay lúc land (đúng lớp S166-K4b, tái phát): 2 comment MỚI trỏ DbInitializer.cs:2516 cho CanCreate = isPe:

  • ContractSigningPlansController.cs:224 ("seeder DbInitializer.cs:2516")
  • DbInitializer.cs:2743 ("SeedAllRolesReviewReadPermissionsAsync:2516")

Phép đo (2 lệnh, cho 2 thế giới):

git show HEAD:…/DbInitializer.cs | grep -n "CanCreate = isPe"   → 2516   (TRƯỚC diff)
grep -n "CanCreate = isPe" …/DbInitializer.cs                    → 2523   (SAU diff)

Chính diff này chèn 7 dòng ở :2353-2359, đẩy dòng đích xuống 2523. Nghĩa là con trỏ ĐÚNG lúc viết và SAI ngay khoảnh khắc commit — không ai làm gì sai sau đó cả. Đây là lỗi loại "đo trước khi chèn". Acceptance: hoặc cập nhật thành 2523, hoặc bỏ số dòng và trỏ bằng TÊN (SeedAllRolesReviewReadPermissionsAsync + token CanCreate = isPe là duy nhất, grep ra ngay).

MINOR F-9 — số-đếm trong comment tự mâu thuẫn: DbInitializer.cs:2743-2744 viết "nút bridge sẽ 403 với 13/13 role (kể cả Drafter), chỉ Admin qua được nhờ bypass". Đếm thật: AppRoles.cs:23-28 liệt 13 vai và Admin nằm trong đó; MenuPermissionHandler.cs:26-31 cho Admin bypass ⇒ con số đúng phải là 12/13. Một câu chứa cả con số lẫn phản-ví-dụ của chính nó.

Ngoài 2 mục trên, đã quét các con số khác trong comment MỚI và chúng kiểm được: ContractConfiguration TenHopDong = 500 khớp MaximumLength(500) (:73) và clamp :184; ContractTypeLabel 1..7 (fe-user/src/types/forms.ts:13-21) khớp ĐÚNG ContractType.cs:5-11 1..7 ⇒ IsInEnum() không bao giờ đá dropdown.

6a. Ba file test — ĐỌC HẾT, CÓ RĂNG

Đếm: KhkkBridgeAuthzTests 5 [Fact] + CreateContractFromSigningPlanTests 12 [Fact] = 17 — khớp đúng con số "17 test mới" spec nêu (đếm bằng grep -c '^\s*\[Fact\]', không chép).

KhkkBridgeAuthzTests.cs — răng thật, và đúng loại răng:

  • :33-48 BridgePolicies() đọc 2 tên policy TỪ CHÍNH CONTROLLER bằng reflection, không hardcode ⇒ ai sửa attribute thì test đo theo cái mới mà vẫn hỏi đúng câu. Kèm actionPolicy.Should().NotBe(classPolicy) (:45) — chặn ca "2 vế trùng nhau thì hội-2-khoá còn 1 khoá mà nhìn vẫn như cũ".
  • Chạy máy quyết định THẬT MenuPermissionHandler (:57-68), không mô phỏng.
  • (2) :170-171 và (3) :195-196 mỗi test đều assert vế còn lại VẪN QUA trước khi assert vế thiếu bị chặn ⇒ không thể xanh nhờ "chặn tất cả". Đây đúng là thứ tôi định đòi.
  • (5) :235-243 phép 2 thế giới 1 biến: cùng bộ quyền, chỉ lật IsActive, kết quả phải đảo.
  • (4) :214 assert Permissions.Count == 0 trước khi kết luận Admin qua bằng bypass.

CreateContractFromSigningPlanTests.cs — 12 ca, assert phân biệt được thế giới hỏng:

  • T1 :243-245 — 3 con số CỐ Ý khác nhau (750 chốt / 999 đề xuất / 900 NS) ⇒ lấy nhầm nguồn nào cũng ra số khác. T1 :266 soi RUỘT mã HĐ {ProjectCode}/HĐTP/SOL&{SupplierCode}/01 — chính là bằng chứng vá-6 đã nạp 2 bảng Project/Supplier, mạnh hơn NotBeNull.
  • T1 :255-257 — assert pin đúng bản V1 active của đúng loại, và seed sẵn 1 bản NHIỄU loại khác (:197-201) để != null không đủ để xanh. Đây là cách đúng để đóng KHAI-1.
  • T3 :360 + T7 :491 + T11 :661-662 — assert ContractCodeSequences không cháy, tức đo được thứ tự "rào đứng TRƯỚC codegen" bằng side-effect, không bằng đọc code.
  • T6 :436-440 và T7 :496-498đối chứng dương cùng lượt, đổi đúng 1 biến thì QUA.
  • T10 :625-627 — đo CHIỀU của ??=: viết = thì mọi assert khác vẫn xanh, chỉ vế này đỏ.
  • T8 :549-551 + :555-562 — mối nối cầu → đường ống V2, đo bằng hộp thư của đúng người (Cấp 1 thấy, Cấp 2 chưa thấy), không phải đọc cột.

T12 vs vá-7 — ĐÚNG, đủ 3 vế spec đòi: (a) phiếu TỰ đi tới DaDuyet qua service THẬT (:724-730), không seed thẳng; (b) ApprovedAmount đông cứng {250, 111} với tương phản chứng minh ??= (dòng chốt tay giữ 111, không bị 999 đè) :735-736, và GiaTri = 361 với lập luận "Σ Proposed = 1249 ⇒ mọi cách sai đều ra số khác" :744-745; (c) sau bắc cầu, bấm duyệt lại → Conflict, rồi đọc lại cả ContractId lẫn ApprovedAmount đều không đổi :753-758. ĐẠT.

T11 (double-click) — CÓ RĂNG NHƯNG CHỈ CHO NỬA TUẦN TỰ. Assert thật, 4 vế: Conflict + đúng thông điệp + Contracts.Count == 1 + LastSeq == 1. Nhưng :652-655await lần 1 rồi mới lần 2 (ChangeTracker.Clear() mô phỏng request thứ 2). Test này không đo được và không thể đo ca ĐỒNG THỜI — tức F-1 vẫn hở. Bản thân comment test khai đúng phạm vi ("bấm 2 lần liên tiếp"), nên đây KHÔNG phải lane nói quá; rủi ro là người đọc sau tưởng "idempotency đã đóng".

Ngoại lệ CreateContract trong AuthorizePolicyRegressionTests — SIẾT, không mở lỗ:

  • :370-373Dictionary<string,string> map 1 action → 1 chuỗi policy CHÍNH XÁC, dùng authz.Policy.Should().Be(expectedPolicy) (:419) — so khớp tuyệt đối, không StartsWith / Contains / danh sách bỏ-qua.
  • Action nào không có tên trong map vẫn rơi vào else đòi prefix KeHoachKyKet (:428) ⇒ action tương lai KHÔNG được miễn trừ tự động. Đúng yêu cầu "chỉ miễn đúng 1 action".
  • MenuKeys.All.Should().Contain(parts[0]) (:434) nằm NGOÀI if/else ⇒ vẫn chạy cho cả nhánh ngoại lệ (chặn ca policy không tồn tại → 500).
  • :477-479 KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys) — chống cấu-hình-chết (đổi tên/xoá action mà quên dọn map thì mục đó im lặng không ai đo). Đây là vế "vắng-mặt trông giống ổn" mà tôi hay phải tự đòi; lane tự đặt sẵn.

§7 — Trọng tài tươi (reviewer tự chạy, không nhận số của lane)

Phép đo Lệnh Kết quả
Build BE dotnet build SolutionErp.slnx -v q Build succeeded — 0 Warning, 0 Error (62s)
3 lớp test K7 dotnet test --filter …KhkkBridgeAuthzTests|…CreateContractFromSigningPlanTests|…AuthorizePolicyRegressionTests 39/39 passed, 0 failed
Toàn suite dotnet test SolutionErp.slnx --no-build Domain 45/45 + Infrastructure 594/594 = 639 pass / 0 fail
tsc fe-user npx tsc --noEmit exit 0, 0 dòng lỗi
tsc fe-admin npx tsc --noEmit exit 0, 0 dòng lỗi

🔴 Phép cộng khớp: baseline trước đợt này là 622 (đo ở gate K4c cùng ngày) và diff thêm 17 [Fact] ⇒ kỳ vọng 639. Đo được đúng 639. Nghĩa là 17 test mới có chạy thật (không bị filter/skip câm) 0 test cũ bị vỡ. Đây là 1 con số đóng cả 2 câu hỏi.

Prod probe — TIỀN ĐỀ SEEDER, ĐO ĐƯỢC, XÁC NHẬN

GET /api/permissions/by-role/{id} cho cả 13 vai trên api.solutions.com.vn (token admin; lưu ý field token là accessToken, không phải token — sai tên là "login failed" giả):

roles on prod: 13
Accounting/AuthorizedSigner/CatalogManager/CostControl/DeptManager/Director/
Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager  → Contracts R=True  C=False
Admin                                                          → Contracts R=True  C=True
--- roles with Contracts.CanCreate = true: 1 / 13

Kết luận đo được (không suy từ code):

  • Tiền đề của lane ĐÚNG: hôm nay Drafter = False, Procurement = False ⇒ nút bridge sẽ 403 cho họ nếu không có seeder. Lỗ là THẬT.
  • Sửa lại F-9 bằng số sống: không phải "13/13 role", mà 12/13; và Admin qua không chỉ nhờ bypass — Admin đã có sẵn row CanCreate=True trên prod.
  • Củng cố F-2 bằng số sống: vì Admin đã True, nhánh Admin của seeder mới là no-op.
  • Dự đoán kiểm chứng được sau deploy+restart: seeder phải log đúng 0 added + 2 upgraded (Drafter, Procurement). Lệch con số này = có thứ khác đang lật quyền. Đây là acceptance rẻ nhất cho nghiệm thu #75/#76 "restart THẬT rồi đếm row".

Giới hạn khai thật: prod chưa deploy diff này ⇒ phép đo trên chứng minh TIỀN ĐỀ, không chứng minh seeder chạy đúng. Không đo được bằng con mắt người dùng thường vì nv.test@solutions.com.vn đăng nhập thất bại (tài khoản không còn/đổi mật khẩu) — đã dùng đường admin thay thế.


§8 — Tổng hợp finding (CHỐT)

# Sev Vị trí Một câu
F-1 MAJOR CreateContractFromSigningPlanFeatures.cs:128-129 + :248 Chống-trùng chỉ là kiểm-tra-trong-bộ-nhớ, không ràng buộc CSDL / không transaction bao ngoài ⇒ 2 request ĐỒNG THỜI tạo 2 HĐ, 1 cái mồ côi; T11 chỉ phủ ca tuần tự.
F-8 MAJOR ContractSigningPlansController.cs:224 · DbInitializer.cs:2743 · KhkkBridgeAuthzTests.cs:153 Mỏ-neo DbInitializer.cs:2516 đúng lúc viết, sai ngay lúc land (dòng thật 2523 — chính diff này chèn 7 dòng ở :2353-2359 đẩy nó xuống); 3 nơi, không phải 2.
F-R1 MINOR scripts/{distill-shard-probe,measure-agent-memory,memory-archive-gate}.ps1 Dirty ngoài cả 13-file-set lẫn lead-set (việc rotate _INDEX-<period>.md); git add -A sẽ nuốt vào commit K7.
F-9 MINOR DbInitializer.cs:2743-2744 "403 với 13/13 role… chỉ Admin qua nhờ bypass" tự mâu thuẫn; đo prod: 12/13CanCreate=False, và Admin đã có sẵn row True (không chỉ bypass).
F-2 MINOR DbInitializer.cs:2775 Cấp row cho Admin vô hại nhưng vô tác dụng — có Admin-bypass (MenuPermissionHandler.cs:26-31) và prod đo được Admin đã CanCreate=True.
F-3 MINOR DbInitializer.cs:2814 "Thu hồi khi cần: xoá call ở SeedAsync" — call thật nằm trong SeedAdminPermissionsAsync (thân 2288-2381).
F-4 MINOR DbInitializer.cs:2792 Hàm nâng cả CanRead trong khi tên + comment chỉ hứa Contracts.Create (rộng hơn nhãn).
F-5 MINOR KhkkDetailPage.tsx:527 · :975 <a href> thay <Link> (full reload, mất state SPA) + neo "Đã có HĐ" lồng trong <label> (interactive-lồng-interactive, cùng lớp a11y với finding K5).
F-6 MINOR usePipelineStages.ts:324 khkkPlanContractIds gọi trong find trong vòng lặp — O(n·m) + cấp phát mảng mỗi lượt; vô hại ở list kẹp 200.
F-7 MINOR CreateContractFromSigningPlanFeatures.cs:186-207 Không carry DraftData như bridge PE tiền lệ (CreateContractFromEvaluationFeatures.cs:104) dù pe đã nạp sẵn, và không có dòng nào nói đó là chủ đích.
CLARIFY-1 usePermission.ts + AuthContext.loadMenu Sau khi seeder chạy, nút vẫn xám cho tới khi user đăng-xuất/đăng-nhập lại (menu cache localStorage) — UAT phải nêu, không thì bị đọc thành "seeder không ăn".

Không có blocker. F-1 và F-8 phải xử lý nhưng không chặn commit: F-1 xác suất thấp + không mất dữ liệu (HĐ mồ côi xoá được), F-8 là con trỏ trong chú thích.

ĐẠT (đo xong, không có finding): hội-2-khoá đủ 2 tầng và FE không lỏng hơn server (0 đường gọi API lách gate) · thứ tự seeder SAU revoker (2341 → 2361) và là seeder-quyền chạy CUỐI · upgrade-only + idempotent (0 chỗ hạ cờ, lượt 2 không SaveChanges) · chuỗi Line.ContractIdContractIdskhkkPlanContractIdsSet union null-safe từng khớp · mirror 3/3 byte-identical (SHA tự đo) · route /contracts/:id tồn tại ở CẢ 2 app · guard 4 vế workflow ném tách từng nguyên nhân · vá-6 đủ 4 khoản (2 NotFound guard · codegen trước Add · SlaDeadline · pin WorkflowDefinitionId) · 17 test mới có răng thật · ngoại lệ regression siết đúng 1 action + chống cấu-hình-chết · build 0/0 · 639/0 test · tsc sạch 2 app.

KHAI-1 (test-lane: invest T1 ghi WorkflowDefinitionId null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG — ĐÓNG, không mở lại. Spec ②:4 chốt "16 vá THẮNG khi xung đột với nền"; mã theo vá-6 (:163-166

  • :201); và T1 :255-257 assert đúng bản active + có bản nhiễu để chống xanh-giả.

§9 — Đề nghị hành động (mô tả tiêu chí, KHÔNG chỉ-dẫn sửa mã)

  1. F-8 — bỏ số dòng, trỏ bằng TÊN (SeedAllRolesReviewReadPermissionsAsync + token CanCreate = isPe là duy nhất trong file, grep ra ngay), hoặc cập nhật cả 3 nơi thành 2523. Tiêu chí nghiệm thu: sau khi vá, grep -n "CanCreate = isPe" trên cây ĐÃ COMMIT phải khớp con số ghi trong chú thích.
  2. F-1 — hoặc dựng một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ sinh 1 HĐ, hoặc khai công khai "chấp nhận rủi ro, chỉ chặn ở UI" ngay tại :128-129 để người sau không đọc T11 thành "đã đóng".
  3. F-9 / F-2 — sửa "13/13" thành "12/13" và bỏ vế "chỉ Admin qua nhờ bypass" (prod đo được Admin có row thật).
  4. F-R1 — tách 3 file scripts/*.ps1 khỏi commit K7, hoặc khai chúng vào lead-set trước khi git add.
  5. Nghiệm thu sau deploy — restart THẬT rồi soi log seeder: kỳ vọng 0 added + 2 upgraded; và UAT phải có bước đăng-xuất/đăng-nhập lại (CLARIFY-1).

END gate-k7 — VERDICT=PASS-WITH-FLAGS 10