# sub-implementer-backend-3 — S161 W3 ĐỢT-3 IMPLEMENT (6 test RED → GREEN)

> Ghi TỪNG MỤC trong lúc làm (bài #53: chỉ thứ trên đĩa mới sống).
> Index **3** — KHÔNG đè `sub-implementer-backend-0.md` (đợt-1 skeleton).
> Scope MINE: `Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (điền thân)
> + 1 dòng seed trong `tests/.../ContractSigningPlanApprovalTests.cs` (lead UỶ QUYỀN riêng lượt này, chỉ khi lỗi ở SEED).
> KHÔNG chạm: `fe-*/**` · slnx · commit/push · agent-memory · store_memory.

---

## §0 — ĐO RED TRƯỚC KHI GÕ (tự chạy, không tin báo cáo)

`dotnet test tests/SolutionErp.Infrastructure.Tests --filter FullyQualifiedName~ContractSigningPlanApprovalTests`

| # | Test | RED vì | Bằng chứng |
|---|---|---|---|
| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | `NotImplementedException: W3 đợt-3: approve` | `ContractSigningPlanWorkflowService.cs:113` |
| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:422`) |
| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:501`) |
| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **ASSERT** — `Expected ... to be a collection with 1 item(s) ..., but found an empty collection` (`Tests.cs:574`) | KHÔNG chạm service — xem §4 |
| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | `NotImplementedException: W3 đợt-3: approve` | `:113` |
| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | `NotImplementedException: W3 đợt-3: submit` | `:108` (stack `...Tests.cs:689`) |

`Failed: 6, Passed: 0, Total: 6` — khớp RED-evidence lead đưa (5 con NotImplemented + 1 con đỏ assertion).

---

## §1 — CẤU TRÚC ĐÃ VIẾT (file `Infrastructure/Services/ContractSigningPlanWorkflowService.cs`)

2 dòng `#pragma warning disable/restore CS9113` **ĐÃ XOÁ** — cả 4 dependency giờ dùng thật:
`db` (mọi truy vấn) · `currentUser` (actor + roles + fallback tên) · `dateTime` (`SignedAt`/`ApprovedAt`) · `notifications` (2 đường báo).

| Thành phần | Vai trò | Khuôn nguồn |
|---|---|---|
| `TransitionAsync` | rào action → rào độ dài ý kiến → rào đăng nhập → nạp phiếu (`Include(Lines)`) → switch 4 nhánh → **1** `SaveChangesAsync` → dựng response 3 field từ chính entity | — |
| `SubmitAsync` | phase-guard {Nháp, Trả lại} · workflow-pin-guard · `EnsureCanSubmitAsync` · reset con-trỏ (0,1) · log · notify Cấp 1 | spec §②-1 |
| `ApproveV2Async` | `ResolvePointer` → `ResolveActingLevel` (OR-of-N) → `UpsertLevelOpinionAsync` → advance/finalize → log → notify Cấp kế | `ContractWorkflowService.cs:217-394` |
| `ApplyApprovedValuesOnFinalize` | `ApprovedAmount ??= ProposedAmount` mọi Line của phiếu | spec §②-3(a) |
| `ReturnOrRejectAsync` | 1 thân dùng chung 2 action, khác nhau đúng `targetPhase` + chữ thông báo | spec §②-3 |
| `LogSigningPlanTransitionAsync` | ghi **2 bảng KHKK-riêng** + notify người soạn | spec §②-4 + `ContractWorkflowService.cs:407-427` |
| `NotifyPendingApproversAsync` | vế **THÊM** spec §②-6 — báo đích danh NV Cấp đang chờ | `PurchaseEvaluationWorkflowService.cs:1243-1289` |
| `ResolvePointer` / `LoadStepsAsync` / `ResolveActingLevel` / `LevelsOf` / `ResolveActorFullNameAsync` / `Truncate` | helper | `:228-266`, `:433-442` |

**4 điểm GIỮ của hợp-đồng — thi hành đủ 4:**
1. Con-trỏ ĐÔI: `ResolvePointer` đọc `CurrentWorkflowStepIndex` như **INDEX vào list đã sort** (`steps[idx]`, không phải `Order`) + `CurrentApprovalLevelOrder`; sang Bước mới gán thẳng `= 1`.
2. `GroupBy(l => l.Order)` — `ResolvePointer`, y khuôn `:246`. KHÔNG `SelectMany`/flatten (bẫy Proposal).
3. OR-of-N — `ResolveActingLevel`: `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)`; có tên là qua, KHÔNG đòi đủ chữ ký cả nhóm.
4. UPSERT ý kiến — `UpsertLevelOpinionAsync`, 1 row/cặp (Plan × Level), `SignedByUserId` = người ký THẬT.

**2 lệnh CẤM — tuân thủ, kiểm được:**
- `grep -n "IChangelogService\|LogWorkflowTransitionAsync" ContractSigningPlanWorkflowService.cs` → **0 hit** (dep vẫn CỐ Ý vắng ctor = rào FK-547 bằng vắng-mặt, không bằng trí nhớ).
- `grep -n "SelectMany" ContractSigningPlanWorkflowService.cs` → **0 hit** (không dính Proposal-flatten).

**KHÔNG port (khai rõ):** `AllowApproverFinalize` · `CeoApprovalThreshold` · `AllowApproverSkipToFinal` (`:318-352` nguồn) · 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) → `grep -n "AllowApprover\|CeoApprovalThreshold\|skipToFinal"` trong file = **0 hit**. W3 chỉ đường THƯỜNG (anh phán "PHÁ VỠ" @S155).

---

## §2 — 🔴 CHOKE-POINT FINALIZE: **1 SITE, KHÔNG CÓ SITE THỨ 2** (đo, không đoán)

Lead dặn: "nếu mày không port admin-override thì KHAI RÕ 0-site-thứ-2". Khai:

- **Không port** nhánh admin-override kiểu nguồn copy (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` QUA BIẾN). **Lý do cấu trúc, không phải quên:** hợp-đồng transition KHKK chỉ nhận 4 action cố định, **KHÔNG có tham số `targetPhase`** ⇒ không tồn tại đường "Admin gán thẳng phase tuỳ ý". Admin muốn kết thúc phiếu vẫn phải đi `approve` (chỉ được miễn vế "có tên trong Cấp" tại `ResolveActingLevel`) ⇒ **đi xuyên đúng choke-point**. Đó CHÍNH LÀ write-path 2 mà T3 đo — và nó xanh.
- Đo trên đĩa (2 lệnh, khớp nhau):
  - `grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs` → **1 hit** = `ContractSigningPlanWorkflowService.cs` (nhánh terminal, ngay SAU `ApplyApprovedValuesOnFinalize`).
  - `grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs` → **2 hit** = 1 khai báo helper + 1 call-site.
  - Đối chứng toàn module: `grep -rn "Phase = ContractSigningPlanPhase\." src/Backend --include=*.cs` → 4 hit: `Features.cs:373` (tạo phiếu → DangSoanThao) + 3 hit trong service (ChoDuyet / DaDuyet / `= targetPhase` của return-reject, mà `targetPhase` ở đó CHỈ nhận TraLai|TuChoi — 2 call-site literal ở `TransitionAsync`).
- RULE-comment dựng theo khuôn **`ContractFeatures.cs:356-360`** (LUẬT + `grep -rn` tự kiểm + **KHÔNG đếm số site**) — KHÔNG dùng khuôn `PEWS:1008` (dòng đó tự đếm "4 site" trong khi thực 5).
- Vế `AND PlanId = @id`: bảo đảm bằng **QUAN HỆ**, không bằng nhớ viết `Where`. `plan.Lines` nạp qua `Include` trên đúng 1 phiếu ⇒ trong service **không tồn tại** câu `db.ContractSigningPlanLines.Where(...)` nào để mà quên vế lọc (`grep -n "ContractSigningPlanLines"` trong file = **0 hit**). T3 có phiếu chứng nhân `planB` bất động qua CẢ HAI write-path.

---

## §3 — SAVE-ĐIỂM-NÀO (khai theo yêu cầu lead)

**ĐÚNG MỘT `await db.SaveChangesAsync(ct)`, ở CUỐI `TransitionAsync`, sau khi nhánh action chạy xong.**

- Khác nguồn copy: `ContractWorkflowService.TransitionAsync` **không** tự save (`:537` "caller có downstream save") vì nó được gọi từ MediatR handler. Ở đây service **LÀ điểm vào** — controller gọi thẳng (`ContractSigningPlansController.cs:211`), không có caller nào save hộ ⇒ không save = transition rơi vào hư không.
- 1 transition = 1 unit-of-work: con-trỏ + `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs` + `LevelOpinion` + giá chốt + `Notifications` **cùng sống hoặc cùng chết**. Được phép làm vậy vì `NotificationService.cs:7-8` CỐ Ý không tự `SaveChanges` ("caller's unit of work flushes both").
- Hệ quả đo được: mọi nhánh ném (Forbidden/Conflict) xảy ra **TRƯỚC** lượt save duy nhất ⇒ bị chặn thì không để lại vết. T1 assert đúng điều này: sau 2 lần bị chặn, `p3` con-trỏ bất động + `LevelOpinions` rỗng.
- **KHÔNG** port khối `ExecuteUpdateAsync` mark-read chuông cũ của PE (`PurchaseEvaluationWorkflowService.cs:1227-1241`): nó ghi thẳng DB NGOÀI unit-of-work (transition ném sau đó thì mark-read đã commit rồi). Ngoài spec §②-6. Khai để lead quyết có kéo về sau không.

---

## §4 — 🔧 SỬA TEST: ĐÚNG 1 THAM SỐ SEED (khai từng chỗ + lý do)

**Chỗ sửa duy nhất:** `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs`, phiếu `pendingLevel1` trong `Inbox_OnlyPendingLevelApprover_SeesPlan`:

```
- ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);   // FAULT-INJECT tạm
+ ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
```

- **Lỗi ở SEED, không ở assert.** Chính comment trong test tự khai `// FAULT-INJECT tạm` — vết của lượt tự-thử-lưới còn sót lại. Với `0, 2`, **cả hai** phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B (approver Cấp 1) rỗng và C thấy 2 phiếu. Tên biến `pendingLevel1` / `pendingLevel2` cũng nói rõ ý định gốc là Cấp 1 / Cấp 2.
- **KHÔNG nới một assert nào** — 0 ký tự nào trong các câu `Should()` bị đổi. Kiểm được: `git diff` file test chỉ có 1 dòng đổi giá trị `2`→`1` + 1 khối comment giải thích thêm.
- **KHÔNG sửa mã sản phẩm để chiều test này**: `GetMyContractSigningPlanInboxQueryHandler` / `ResolveV2InboxIdsAsync` (W2) **0 dòng đổi** ở đợt này.
- Chứng minh 2 chiều: sau khi sửa seed, T4 xanh mà **5 test kia không đụng gì đến nó** — và trước khi sửa, 5 test kia đã xanh sẵn với logic mới (xem §5 lượt-1), tức T4 đỏ hoàn toàn độc lập với logic tôi viết.

---

## §5 — SỐ ĐO (build + test)

| Lượt | Lệnh | Kết quả |
|---|---|---|
| Build | `dotnet build SolutionErp.slnx` | **Build succeeded · 0 Warning(s) · 0 Error(s)** · 42.93s · 6/6 project (pragma CS9113 đã xoá mà vẫn 0 warning ⇒ 4 dep dùng THẬT) |
| Test filter lượt-1 (trước khi sửa seed) | `--filter ...ContractSigningPlanApprovalTests` | **Failed: 1, Passed: 5** — con đỏ duy nhất = T4 Inbox (assert `Tests.cs:574`), 5 con logic mới XANH ngay lượt đầu |
| Test filter lượt-2 (sau khi sửa seed) | như trên | **Failed: 0, Passed: 6, Total: 6** ✔ |

### Bảng RED → GREEN từng con

| # | Test | RED (đầu đợt) | GREEN | Vế acceptance được nó khoá |
|---|---|---|---|---|
| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | NotImplemented `:113` | ✔ | OR-of-N 4 vế (a)(b)(c)(d) + phiếu bị chặn bất động |
| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | NotImplemented `:113` | ✔ | con-trỏ ĐÔI, reset Cấp ×2 lần, Order 5/15/25 ≠ index, terminal 2 con-trỏ null |
| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | NotImplemented `:113` | ✔ | choke-point 2 write-path (approver + Admin) · `??=` giữ 300 · phiếu chứng nhân sạch |
| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **assert** (seed sai) | ✔ | đúng-Cấp / khác-Cấp / ngoài-quy-trình / người-soạn / phiếu nháp |
| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | NotImplemented `:113` | ✔ | UPSERT 1 row · placeholder ý kiến rỗng · `SignedAt` từ `IDateTime` · latest-write-wins |
| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | NotImplemented `:108` | ✔ | trả lại toàn phần + gửi lại chạy LẠI từ (Bước 1, Cấp 1) + vòng mới là thật |

---

## §6 — FULL SUITE

**[LEAD điền on-behalf]** `dotnet test SolutionErp.slnx` → Domain **45/45** + Infra **535/535** = **580/0** (574 + 6, đích ĐẠT). Build 0W/0E.

---

## §7 — QUYẾT ĐỊNH KHAI ĐỂ LEAD PHÂN XỬ (không tự nuốt)

| # | Quyết | Vì sao | Đảo lại tốn gì |
|---|---|---|---|
| Q1 | Guard trình nhận **thêm** vế `DrafterUserId == actor` (spec chữ đen: `CreatedBy == actor ∨ DeptManager ∨ Admin`) | 2 cột trỏ CÙNG một người ở đường tạo W2 (`Features.cs:372` gán `DrafterUserId = actorId`; `CreatedBy` do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ **không mở thêm ai**. Nhưng phiếu sinh ngoài CQRS (seed/import chạy dưới danh nghĩa hệ thống) có `CreatedBy = null` ⇒ đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình | xoá 1 vế `||` |
| Q2 | **KHÔNG** đặt `SlaDeadline` | W3 không định nghĩa số ngày SLA cho KHKK; bê "7 ngày" của Contract sang = bịa một hạn chót rồi cho FE hiển thị như thật. Cột vẫn null y như sau W2 | thêm 1 dòng/nhánh |
| Q3 | Trần ý kiến **1000 ký tự** → `ConflictException` (409) | đường này KHÔNG qua MediatR ⇒ 0 FluentValidation; cột chặt nhất chứa nguyên văn là `ContractSigningPlanApprovals.Comment` = 1000 (`...ApprovalConfiguration.cs:26`). Để lọt = SQL Server 2628 (chuỗi bị cắt) → 500 khó hiểu. Chọn 409 cho ĐỒNG BỘ với "Hành động không hợp lệ." của đợt-1 (đúng nghĩa hơn là 400, nhưng đổi thì đổi cả 2 chỗ) | đổi sang `ValidationException` (→400) 1 chỗ |
| Q4 | `return`/`reject` dùng **CÙNG** vế quyền với `approve` (NV Cấp đang chờ ∨ Admin) | trả lại/từ chối là quyết định của người đang giữ lượt; rào bằng CHÍNH hàm `ResolveActingLevel` ⇒ 2 đường không thể lệch nhau về sau. (Khuôn Contract nới hơn: FE cho mọi user bấm Trả lại/Từ chối — cố ý KHÔNG bắt chước) | nới 1 hàm |
| Q5 | `return`/`reject` **KHÔNG** ghi `LevelOpinion` | bảng ý kiến là chữ ký DUYỆT (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync"); lý do trả lại nằm ở `Approvals` + `Changelogs`. T5 phụ thuộc vế này (nếu return đẻ row thì `ContainSingle` vỡ) | — (đổi = vỡ T5) |
| Q6 | Notify người soạn **loại** trường hợp chính họ bấm; notify approver Cấp kế **loại** actor + người soạn | mirror bài UAT S86 ở PE (`PEWS:1269-1274`): người tạo phiếu không nhận chuông "cần bạn duyệt" cho phiếu của mình | bỏ 1 vế `Where` |

**Ambiguity còn lại (KHÔNG tự quyết):**
- Chuông "cần bạn duyệt" CŨ của Cấp trước không được mark-read khi phiếu rời Cấp (PE đã vá S96 bằng `ExecuteUpdateAsync`). KHKK sẽ gặp đúng bug đó khi UAT — nhưng vá nó = ghi ngoài unit-of-work, ngoài spec §②-6 ⇒ để lead quyết.
- `ContractSigningPlanApproval.ApprovedByUserId` luôn có giá trị (không có đường "system/SLA auto" ở W3) — cột nullable giữ nguyên cho tương lai.

---

## §8 — FULL SUITE + CONTAINMENT

**[LEAD điền on-behalf — vai chết #53 sau §7]** Suite **580/0** (lead chạy tươi 2 nguồn khớp §6). Lead phân xử: **Q1-Q6 DUYỆT CẢ 6** (căn cứ đo được từng dòng); ambiguity mark-read-chuông-cũ → ghi TỒN UAT (mirror PE S96, vá riêng khi anh gật — ngoài unit-of-work). Grep NotImplementedException trong service = 1 hit COMMENT dòng :16 (code sạch).
