16 KiB
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 LoadStepsAsync — y 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() |
:398 — ký 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-468 — cù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.cs → 1 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):
- Nguồn có
isSystem(job SLA tự duyệt) — KHKK không có job SLA ⇒ không port. Hợp lý. - Nguồn đặt
SlaDeadlinemỗ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:42CÓ cộtSlaDeadline, khai Q2 của lane là ĐÚNG. Hệ quả còn lại → I-7 §Minor.) - 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 |
CÓ — 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 và :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;. ApprovedAmount là decimal? (ContractSigningPlanLine.cs:26), ProposedAmount là decimal 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): ContractSigningPlanLine có HasQueryFilter(!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-118isAdmin = 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đềudisabled={blockedByLevel}+onClickcòn chặn lần 2 (if (!blockedByLevel)) +titlegiả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/rejectdùng CHÍNHResolveActingLevel—:335-336). currentLevels:106-109dùng.filter(order === curLevelOrder)+.some(...)⇒ mirror OR-of-N, không phải.find()1 người. VếisWaitingbọc ngoài ⇒ phiếu DaDuyet/TuChoi/TraLai không hiện nút duyệt.- Nút Gửi duyệt
:125canSubmit = isDraftLike && !!plan.approvalWorkflowId— CỐ Ý không gate theo vai (chú thích:120-124nêu đúng lý do:UserInfokhô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 ở BEEnsureCanSubmitAsync→ 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-253amber/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-100 → ConflictException 409). Người dùng gõ 1001–2000 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|null ⟷ string? Name; approverUserId: string ⟷ Guid 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']+onChanged→invalidate 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).