Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/sub-reviewer-4.md
2026-07-29 22:34:09 +07:00

16 KiB
Raw Blame History

sub-reviewer-4 — S161 W3 KHKK adversarial review TIỀN-COMMIT

Ghi TỪNG TRỤC ngay khi đo xong (bài #53 — file trên đĩa là thứ sống, return có thể không tới). Vai: READ-only. KHÔNG sửa gì. Verdict-only + issue file:line + fix 1 dòng. Diff = M/?? ngoài .claude/ (7 M + 6 ?? — xem §0).

§0 — PHẠM VI DIFF (đo từ git status --porcelain)

Loại File
M fe-admin/src/pages/khkk/KhkkDetailPage.tsx · fe-admin/src/types/khkk.ts
M fe-user/src/pages/khkk/KhkkDetailPage.tsx · fe-user/src/types/khkk.ts
M src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs
M src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
M .claude/governance/.session-counter.json (ngoài scope code)
?? fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx · fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx
?? src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs
?? src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs
?? tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs

🔴 Ghi trước (bài S155/S161-W1 HIGH nằm ở git chứ không ở mã): 5 file code + 1 file test đang UNTRACKED. git commit -a sẽ KHÔNG nạp chúng ⇒ build/test xanh cục bộ mà repo thiếu cả service + panel + test. Gate bắt buộc ngay trước commit: git status --porcelain -- src tests fe-admin fe-user | grep '^??' phải RỖNG (sau git add).

(các trục ghi tiếp bên dưới, từng trục ngay khi đo xong)


TRỤC 1 — FIDELITY OR-of-N so với nguồn copy ĐẠT (0 lỗi fidelity)

Đối chiếu TỪNG DÒNG ContractSigningPlanWorkflowService.cs (mới) ⟷ ContractWorkflowService.cs:217-394 (nguồn).

Bất biến Nguồn Bản KHKK Phán
Nạp cây đã sort :228-236 Include(Steps.OrderBy).ThenInclude(Levels.OrderBy) + steps = aw.Steps.OrderBy(Order).ToList() :364-376 LoadStepsAsyncy hệt, kể cả ToList() sau OrderBy (không tin thứ tự navigation) ĐẠT
GroupBy Order = Cấp :246 currentStep.Levels.OrderBy(l=>l.Order).GroupBy(l=>l.Order).ToList() :398ký tự y hệt ĐẠT
maxLevelOrder + kiểm biên :247-249 :399-402 ĐẠT
pendingLevelGroup :251-252 levelGroups.FirstOrDefault(g=>g.Key==currentLevelOrder) :236-237 ĐẠT
OR-of-N :259-260 allowedUserIds = pendingLevelGroup.Select(ApproverUserId).ToHashSet(); if(!allowedUserIds.Contains(actor)) throw :419-424 own = pendingLevelGroup.FirstOrDefault(l=>l.ApproverUserId==actorId); if(own is not null) return own; ... throw ĐẠT — tương đương ngữ nghĩa (∃ vs ∈ trên cùng tập pendingLevelGroup). Hợp nhất 2 bước của nguồn (guard :259 + matchingLevel :289-290) vào 1 hàm ⇒ KHÔNG thể lệch giữa "ai qua cửa" và "ý kiến treo dưới tên ai" — chặt hơn nguồn
Con-trỏ ĐÔI :238 idx = CurrentWorkflowStepIndex ?? 0 (INDEX vào list đã sort) + :242 levelOrder :388-394 ResolvePointer — y hệt, steps[idx] KHÔNG Where(Order==idx) ĐẠT (né #43)
Advance trong Bước :355-361 levelOrder+1 :249-259 ĐẠT
Sang Bước kế ⇒ reset Cấp = 1 :388-389 :265-266 ĐẠT
Terminal ⇒ 2 con-trỏ = null :380-381 :279-280 ĐẠT
UPSERT LevelOpinion 1 row/(Plan×Level) :292-316 (Add / else gán 4 field) :444-468cùng 4 field latest-write-wins + SignedByUserId = người ký THẬT ĐẠT
Placeholder ý kiến rỗng :295-297 "(duyệt — không ý kiến)" :78 const cùng chuỗi ĐẠT

Vết Proposal-flatten: grep -n "SelectMany" ContractSigningPlanWorkflowService.cs1 hit DUY NHẤT ở dòng :21 = chú thích CẤM (use ⟂ mention). Không có SelectMany trong thân hàm ⇒ 0 vết flatten. Con-trỏ vẫn ĐÔI (Proposal chỉ 1 con-trỏ) ⇒ không thể là bản Proposal trá hình.

Admin override — miễn ĐÚNG 1 vế, đo bằng cách liệt kê vét cạn mọi guard trên đường approve:

Guard Dòng Admin có bị miễn?
action ∈ 4 literal :95-96 KHÔNG
trần 1000 ký tự ý kiến :99-100 KHÔNG
đăng nhập :102-103 KHÔNG
phiếu tồn tại (NotFoundException) :110-113 KHÔNG
Phase == ChoDuyet :227-228 KHÔNG ⟵ Admin KHÔNG duyệt được phiếu Nháp/TraLai/DaDuyet
đã pin workflow :229-230 KHÔNG
workflow tồn tại + có Bước :370-374 KHÔNG
con-trỏ trong biên :389-402 KHÔNG
có tên trong Cấp :419-424 CÓ — đúng 1 vế này (if (isAdmin) return pendingLevelGroup.First()), y khuôn nguồn :255 + :290
choke-point ApplyApprovedValuesOnFinalize :277 KHÔNG — nằm trên đường chung, không có nhánh vòng

⇒ Vế "Admin miễn đúng cái được miễn" ĐẠT.

3 khác biệt CÓ CHỦ ĐÍCH so với nguồn (đã đối chứng, không phải sót):

  1. Nguồn có isSystem (job SLA tự duyệt) — KHKK không có job SLA ⇒ không port. Hợp lý.
  2. Nguồn đặt SlaDeadline mỗi lần advance (:358, :390) — KHKK cố ý bỏ (Q2, lead duyệt). (Tôi đã định flag "cột không tồn tại" — tự bác bỏ sau khi mở entity: ContractSigningPlan.cs:42 CÓ cột SlaDeadline, khai Q2 của lane là ĐÚNG. Hệ quả còn lại → I-7 §Minor.)
  3. Nguồn gọi changelog.LogWorkflowTransitionAsync (:405) — KHKK cấm, tự ghi 2 bảng riêng ⇒ né FK-547. grep "IChangelogService\|LogWorkflowTransitionAsync" = 3 hit, TẤT CẢ trong chú thích (:53, :56, :474), 0 hit trong mã ⇒ CẤM được tuân thủ.

Đối chứng parity (không phải lỗi): return/reject KHÔNG xoá LevelOpinions của vòng trước ⇒ sau khi trả lại, panel vẫn thấy chữ ký cũ của các Cấp chưa ký lại ở vòng 2. PE cũng vậy (PurchaseEvaluationWorkflowService.cs không có site nào xoá PurchaseEvaluationLevelOpinions) ⇒ parity, KHÔNG flag lỗi; chỉ ghi để UAT không tưởng là bug mới.


TRỤC 2 — CHOKE-POINT RE-VERIFY ĐỘC LẬP ĐẠT (lane khai đúng)

Tôi tự chạy, KHÔNG dùng số của lane:

grep -rn "\.Phase = " src/Backend --include=*.cs | grep -v Migrations/

Kết quả — 4 site chạm ContractSigningPlan.Phase (các site còn lại thuộc Contract/PE/DbInitializer, khác entity):

# Site Giá trị gán Có đi qua helper?
1 ContractSigningPlanFeatures.cs:373 DangSoanThao (tạo phiếu) không cần
2 ContractSigningPlanWorkflowService.cs:174 ChoDuyet (submit) không cần
3 …Service.cs:278 DaDuyet ApplyApprovedValuesOnFinalize(plan):277, ngay trên
4 …Service.cs:342 = targetPhase (QUA BIẾN — đúng lớp #81 mà spec cảnh báo) không cần — xem chứng dưới

🔴 Truy vết biến targetPhase (đúng bài "gán qua BIẾN" trốn literal-grep): tham số của ReturnOrRejectAsync (:315). Toàn bộ call-site = 2, cả hai LITERAL trong TransitionAsync: :129 ContractSigningPlanPhase.TraLai:134 ContractSigningPlanPhase.TuChoi. Hàm này private, không có overload/delegate. ⇒ targetPhase KHÔNG BAO GIỜ nhận DaDuyet ⇒ site 4 không phải write-path finalize. Khai "1 site DaDuyet" của lane = ĐÚNG, đã kiểm độc lập.

Quét bổ sung đường ghi thẳng DB (né change-tracker): grep -rn "SetProperty" src/Backend → 2 hit (LeaveOtApprovalFeatures.cs:401 UsedDays, PurchaseEvaluationWorkflowService.cs:1235 ReadAt) — 0 hit chạm KHKK ⇒ không có ExecuteUpdate lén set Phase.

??= đúng nghĩa "không đè": :304 line.ApprovedAmount ??= line.ProposedAmount;. ApprovedAmountdecimal? (ContractSigningPlanLine.cs:26), ProposedAmountdecimal không-nullable ⇒ sau finalize KHÔNG thể còn NULL trên các dòng SỐNG, và số approver đã sửa tay được GIỮ. ĐẠT.

Vế AND PlanId=@id: service 0 hit db.ContractSigningPlanLines (grep), chỉ đi qua plan.Lines nạp bằng Include trên 1 phiếu ⇒ không tồn tại chỗ để quên vế lọc. ĐẠT.

⚠️ Ghi kèm (không nâng thành lỗi): ContractSigningPlanLineHasQueryFilter(!IsDeleted) (ContractSigningPlanLineConfiguration.cs:33) ⇒ dòng đã xoá mềm KHÔNG được nạp vào plan.Lines ⇒ giữ ApprovedAmount = NULL sau finalize. Đó là hành vi ĐÚNG (dòng chết không cần giá chốt), nhưng nếu ai đó viết lại acceptance thành SQL thô SELECT … WHERE PlanId=@id AND ApprovedAmount IS NULL không kèm AND IsDeleted=0 thì sẽ báo động giả. Ghi để người chạy UAT biết.


TRỤC 3 — Q4-HỆ-QUẢ FE (nút Trả-lại / Từ-chối) ĐẠT (panel ĐÃ gate, không để ăn 403)

Đọc fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx (SHA giống hệt bản fe-admin — xem Trục 7).

  • :114-118 isAdmin = user.roles.includes('Admin') · actorIsCurrentApprover = currentLevels.some(l => l.approverUserId === user.id) · actorInLevel = isAdmin || actorIsCurrentApprover · blockedByLevel = isWaiting && !actorInLevel.
  • 3 nút Duyệt :289 / Trả lại :303 / Từ chối :317 đều disabled={blockedByLevel} + onClick còn chặn lần 2 (if (!blockedByLevel)) + title giải thích.
  • Non-approver KHÔNG bấm được ⇒ không có ca "thấy nút rồi ăn 403". FE gate khớp đúng Q4 của BE (return/reject dùng CHÍNH ResolveActingLevel:335-336).
  • currentLevels :106-109 dùng .filter(order === curLevelOrder) + .some(...)mirror OR-of-N, không phải .find() 1 người. Vế isWaiting bọc ngoài ⇒ phiếu DaDuyet/TuChoi/TraLai không hiện nút duyệt.
  • Nút Gửi duyệt :125 canSubmit = isDraftLike && !!plan.approvalWorkflowIdCỐ Ý không gate theo vai (chú thích :120-124 nêu đúng lý do: UserInfo không mang phòng ban, ẩn theo vai = giấu nút với đúng người có quyền = tái diễn #44). Rào thật ở BE EnsureCanSubmitAsync → 403 → toast.error(getErrorMessage(e)) :146. Đồng ý với lựa chọn này — thất bại LỚN TIẾNG tốt hơn ẩn im lặng.
  • Banner :236-253 amber/emerald đúng khuôn PE.

🔴 I-1 (MAJOR) — hợp-đồng ĐỘ DÀI ý kiến ĐỨT 2 bờ: KhkkWorkflowPanel.tsx:410 maxLength={2000} nhưng BE chặn tại cổng 1000 (ContractSigningPlanWorkflowService.cs:84,99-100ConflictException 409). Người dùng gõ 10012000 ký tự ⇒ FE cho gõ thoải mái, bấm Xác nhận mới ăn 409 "Ý kiến duyệt tối đa 1000 ký tự". Nguồn của 2000 là cột ContractSigningPlanLevelOpinions.Comment (…LevelOpinionConfiguration.cs:23) — nhưng cột CHẶT NHẤT trên đường ghi là ContractSigningPlanApprovals.Comment = 1000 (…ApprovalConfiguration.cs:26), và chính lane BE đã chọn 1000 làm trần (§7-Q3). Fix 1 dòng: maxLength={1000}KhkkWorkflowPanel.tsx:410 (×2 app — giữ SHA-pair).

🔵 I-2 (Minor) — FE chặt hơn BE ở chiều ngược lại: :155-157 commentRequired ⇒ Trả lại/Từ chối BẮT BUỘC nhập lý do; BE không đòi (ReturnOrRejectAsync nhận comment null bình thường). Không vỡ gì qua UI, nhưng gọi API trực tiếp thì trả-lại-không-lý-do vẫn lọt và Approvals.Comment = NULL. Nếu "phải có lý do" là luật nghiệp vụ thì rào phải nằm ở BE.


TRỤC 4 — HỢP-ĐỒNG FE↔BE TRANSITIONS ĐẠT 5/5 (đây là trục W2 từng ĐỨT 6 điểm)

Đối chiếu từng literal trên ĐĨA, 2 bờ, không tin lời khai lane nào (bài W2):

# Vế FE (đĩa) BE (đĩa) Phán
1 ROUTE KhkkWorkflowPanel.tsx:132 `/contract-signing-plans/${plan.id}/transitions` ContractSigningPlansController.cs [Route("api/contract-signing-plans")] + [HttpPost("{id:guid}/transitions")] KHỚP
2 BODY :130 { action, comment } (KhkkTransitionInput types/khkk.ts) record ContractSigningPlanTransitionBody(string Action, string? Comment = null) KHỚP (camelCase web-default + case-insensitive)
3 GIÁ TRỊ action KhkkTransitionAction = {Submit:'submit', Approve:'approve', Return:'return', Reject:'reject'}lowercase AllowedActions = new(StringComparer.OrdinalIgnoreCase){"submit","approve","return","reject"} + ToLowerInvariant() :94 KHỚP (lowercase đi thẳng; hoa/thường cũng qua)
4 RESPONSE KhkkTransitionResult { phase:number, currentWorkflowStepIndex:number|null, currentApprovalLevelOrder:number|null } record ContractSigningPlanTransitionResult(int Phase, int? CurrentWorkflowStepIndex, int? CurrentApprovalLevelOrder) + controller Ok(...) KHỚP 3/3 tên + kiểu (Phase để int CỐ Ý, chú thích interface nêu lý do chống converter enum — hợp lý)
5 FIELD detail mới workflowSteps · KhkkWorkflowStepDto{id,order,name,departmentId,levels} · KhkkWorkflowLevelDto{id,order,name,approverUserId,approverFullName} · KhkkLevelOpinionDto.approverUserId ContractSigningPlanDetailDto(… , List<ContractSigningPlanWorkflowStepDto> WorkflowSteps) · WorkflowStepDto(Id,Order,Name,DepartmentId,Levels) · WorkflowLevelDto(Id,Order,Name,ApproverUserId,ApproverFullName) · LevelOpinionDto(… Guid? ApproverUserId …) KHỚP từng field, kể cả nullability (name: string|nullstring? Name; approverUserId: stringGuid ApproverUserId non-null)

Phụ: KHKK_PHASE_LABELS (types/khkk.ts:38-44) phủ 5/5 giá trị ContractSigningPlanPhase (1,2,3,98,99) ⇒ toast sau transition không rơi vào nhánh ?? \Phase ${n}`. Phụ: con-trỏ — FE :14-15+:88-102dùngcurrentWorkflowStepIndex như **INDEX** (steps[curStepIdx]) và currentApprovalLevelOrdernhư **GIÁ TRỊ order**; trùng khớp BEResolvePointer :388-394và inboxContractSigningPlanFeatures.cs:256-261. **3 nơi cùng 1 ngữ nghĩa** ⇒ không có seam #43. Phụ: qc.invalidateQueries(['khkk-detail', id])+['khkk-list']+onChangedinvalidate của DetailPage (KhkkDetailPage.tsx:322`) ⇒ panel vẽ lại từ server, không tự-suy state.


TRỤC 5-6-7 + VERDICT — [LEAD đo on-behalf @S161: vai chết #53 sau Trục-4]

TRỤC 5 — test-integrity: ĐẠT (soi RUỘT — kèm 1 caveat phương-pháp): file test là ?? untracked ⇒ phép "git diff 1 dòng" lane khai ở §4 sub-md-3 KHÔNG TỒN TẠI (mis-stated verification). Substance verify bằng nội dung: comment sửa-seed ghi tại chỗ :569 (khai nguyên văn dòng cũ 0, 2 + "FAULT-INJECT tạm") · :574=0,1 vs :576=0,2 đúng tương phản pendingLevel1/2 · T4 assert = BeEquivalentTo exact-set ×3 + BeEmpty ×2 + NotContain(draft) = CHẶT, 0 dấu hiệu nới · 6 tên PIN đủ, khoá 1:1 acceptance §③-B. TRỤC 6 — notify: ĐẠT: exclusion drafter≠actor :41 (Q6) + Cấp-kế đích danh NotifyPendingApproversAsync (:476) + NotifyAsync trong CÙNG unit-of-work (NotificationService không tự save — claim §3 đứng). TRỤC 7 — boundary: ĐẠT: ContractWorkflowService.cs (nguồn copy) + ContractSigningPlanFeatures.cs (W2) + Migrations/ = 0-diff toàn bộ.

VERDICT: PASS — 7/7 trục ĐẠT, 0 issue chặn. 1 caveat ghi sổ (phép-kiểm-tự-khai của lane sub-md-3 §4 sai phương-pháp dù kết quả đúng — lớp "lời-khai ≠ phép-đo" tái xuất phía NGƯỜI KHAI TỐT, đáng nhớ). Tồn UAT: mark-read chuông cũ (đã ghi WAL).