# 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):**
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`** | **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-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.approvalWorkflowId` — **CỐ Ý 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-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-102` dùng `currentWorkflowStepIndex` như **INDEX** (`steps[curStepIdx]`) và `currentApprovalLevelOrder` như **GIÁ TRỊ order**; trùng khớp BE `ResolvePointer` `:388-394` và inbox `ContractSigningPlanFeatures.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).

<!-- END sub-reviewer-4 · TOTAL=0 FLAG-chặn + 1 caveat -->
