# sub-implementer-backend-0 — W6 Đường ống HĐ V2 (view + inbox + vai-trình)

> Run: `2026-07-29-S161-khkk-w6-duongong` · Spec: `.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md`
> Luật lượt này: BE-only · 0 mig · KHÔNG commit/push/slnx · KHÔNG viết test (đợt-2) · KHÔNG đụng `fe-*/` · KHÔNG đụng Reject `:49-66` (O-A) · KHÔNG đụng `ContractSigningPlan*` (KHKK W1-W3 đã ship).
> Ghi TỪNG MỤC trong lúc làm (chống #53).

---

## §0 — ĐO TRƯỚC KHI SỬA (gate 5-anchor, đọc tươi 2026-07-29)

| Anchor (spec §③-A) | Đo thật | Khớp? | Nội dung |
|---|---|---|---|
| `ContractFeatures.cs:334-351` | `:334` `internal static List<ContractPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)` … `:351` `return phases.ToList();` | ✅ | chữ ký CHỈ nhận role ⇒ không biết HĐ nào |
| `ContractFeatures.cs:421-430` | `:421` `private static readonly Dictionary<ContractPhase, string[]> PhaseActorRoles = new()` … `:430` `[DangDongDau] = [HrAdmin],` | ✅ | 7 key, **0 key `ChoDuyet`** |
| `ContractFeatures.cs:488-489` | `:488` `if (!isDrafter && !isEligibleByRole)` `:489` `throw new ForbiddenException("Bạn không có quyền xem HĐ này.")` | ✅ | 403 detail |
| `ContractWorkflowService.cs:73-78` | `:73` `if (!isAdmin && !isSystem` `:74` `&& !actorRoles.Contains(AppRoles.Drafter)` `:75` `&& !actorRoles.Contains(AppRoles.DeptManager))` `:77-78` throw Forbidden "không đủ quyền trình duyệt HĐ." | ✅ | guard trình |
| `ContractsController.cs:38-40` | `:38` `[HttpGet("inbox")]` `:39` `Inbox(CancellationToken ct)` `:40` `=> Ok(await mediator.Send(new GetMyInboxQuery(), ct));` | ✅ | endpoint inbox |

**Gate 5-anchor: PASS 5/5 content-exact.**

3 call-site `GetEligiblePhases` (grep `src/Backend`, đủ 3 — risk C "sửa chỉ 1/3"):
- `ContractFeatures.cs:301` — `ListContractsQueryHandler` (List)
- `ContractFeatures.cs:381` — `ListDeletedContractsQueryHandler` (ListDeleted, `IgnoreQueryFilters().Where(IsDeleted)`)
- `ContractFeatures.cs:486` — `GetContractQueryHandler` (Detail)
- *(`:441-442` là `PhaseActorRoles` của inbox — call-site THỨ 4 của nhánh phase, không phải của `GetEligiblePhases`)*

Khuôn nguồn để mirror (đọc tươi):
- PE detail V2-approver: `PurchaseEvaluationFeatures.cs:886-898` (`AnyAsync(l => l.Step!.ApprovalWorkflowId == awId && l.ApproverUserId == uid)` + `isDraftPhase` S89)
- PE inbox admin phase liệt tường minh: `PurchaseEvaluationFeatures.cs:731-742` (+ `ChoDuyet` `:740`)
- PE `v2InboxIds` rỗng cho admin: `PurchaseEvaluationFeatures.cs:746-748`
- PE `ResolveV2InboxIdsAsync` in-memory join: `PurchaseEvaluationFeatures.cs:817-852`
- KHKK (chính tao viết W2): `ContractSigningPlanFeatures.cs:229-264` (Resolve) + `:275-283` (`ResolveUserApprovalWorkflowIdsAsync`)

Nền tảng đã verify TRƯỚC khi code (chống bẫy "quên lọc IsDeleted" — risk C):
- `ContractConfiguration.cs:44` `b.HasQueryFilter(x => !x.IsDeleted)` ⇒ `db.Contracts` mặc-định ĐÃ loại HĐ xóa mềm ⇒ inbox + list + `ResolveV2InboxIdsAsync` tự an-toàn, KHÔNG cần `.Where(!IsDeleted)` tay (khác HRM).
- `Contract.cs:23/40/46` có đủ `ApprovalWorkflowId` / `CurrentWorkflowStepIndex` / `CurrentApprovalLevelOrder`; `:16` `DrafterUserId`.
- `BaseEntity.CreatedBy` kiểu **`Guid?`** (không phải string) ⇒ so trực tiếp với `actorUserId`.
- `grep tests/` cho `ContractV2Pipeline|GetMyInboxQuery|ListContractsQuery|GetContractQuery` = **0 hit** ⇒ 3 handler này hiện KHÔNG có test cũ nào bám ⇒ sửa không thể phá test cũ qua đường assert; rủi ro duy nhất là compile + suite tổng.

---
