Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/sub-implementer-backend-3.md
2026-07-29 22:23:46 +07:00

146 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).