# sub-implementer-backend-0 — W2 KHKK BE (CRUD nháp + căn cứ b.8-9)

> Run: `2026-07-29-S161-khkk-w2-crud` · vai: implementer-backend · lượt 0
> Luật: ghi TỪNG MỤC ngay sau khi làm xong mục đó (W1: 5/7 lượt chết #53 — file này là thứ duy nhất sống).

---

## §0 — AUDIT TRƯỚC KHI VIẾT (đọc đĩa, không đoán)

Mọi số dưới đây đo từ đĩa lúc bắt đầu lượt (HEAD `77c723b`, W1 đã land ở `a8027c0`).

### 0.1 — 5 anchor spec §③-A: TỰ MỞ LẠI (không tin gate của lead)

| # | Anchor | Đo được | Ghi chú |
|---|---|---|---|
| A1 | `Application/Office/ProposalFeatures.cs:427-439` | ✔ SỐNG — `allLevels = wf.Steps.OrderBy(s=>s.Order).SelectMany(s=>s.Levels...)` rồi `ElementAtOrDefault(CurrentApprovalLevelOrder-1)` | 🔴 BẪY: flatten toàn workflow ⇒ mọi Level thành 1 chuỗi tuần tự = OR-of-N biến thành AND. ĐỌC ĐỂ NÉ. W2 KHÔNG copy (phần duyệt = W3) |
| A2 | `PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:53-62` | ✔ guard `pe.Phase != DaDuyet → ConflictException` (`:53-54`) + `winners = pe.Suppliers.Where(s => s.IsWinner)` (`:56`) + `winners.Count == 0 → Conflict` (`:57-58`) | phép tính tiền ở `:88-90` |
| A3 | `ProposalFeatures.cs:258-267` | ✔ guard workflow-type TRỌN 2 nhánh: `wfType is null → NotFoundException` (`:264-265`) + `wfType != ProposalGeneral → ConflictException` (`:266-267`) | copy CẢ nhánh NotFound |
| A4 | `MenuKeys.cs:42` const + `:180` vào `All` | ✔ `KeHoachKyKet = "KeHoachKyKet"` (`:42`), có mặt trong `All` (`:180`) | ⇒ 4 policy `KeHoachKyKet.{Read,Create,Update,Delete}` sinh runtime (Program.cs nhân `All × Actions`) |
| A5 | `ApprovalWorkflow.cs` enum | ✔ `ContractSigningPlan = 10` (append-only, đúng slot) | dùng cho guard type |

### 0.2 — Nền W1 đang có trên đĩa (7 entity + 7 config + DbSet + codegen)

- Entity: `Domain/ContractSigningPlans/{ContractSigningPlan, ContractSigningPlanLine, ...DossierItem, ...Attachment, ...LevelOpinion, ...Changelog, ...Approval}.cs` + `ContractSigningPlanPhase.cs`.
- Phase enum: `DangSoanThao=1 · ChoDuyet=2 · DaDuyet=3 · TraLai=98 · TuChoi=99` (`ContractSigningPlanPhase.cs:8-15`).
- DbSet ×7: `IApplicationDbContext.cs:149-155`.
- Codegen: `Application/ContractSigningPlans/Services/IContractSigningPlanCodeGenerator.cs` — `Task<string> GenerateAsync(CancellationToken)`; DI đã đăng ký `Infrastructure/DependencyInjection.cs:42`.

### 0.3 — MaxLength THẬT (EF = source of truth, KHÔNG trust spec — bài S35)

Đo từ Configurations (không đọc spec):

| Field | EF config | file:line |
|---|---|---|
| `Plan.MaKeHoach` | 50 | `ContractSigningPlanConfiguration.cs:19` |
| `Plan.HoSoLink` | 1000 | `:21` |
| `Plan.GhiChu` | 2000 | `:22` |
| `Line.Note` | 1000 | `ContractSigningPlanLineConfiguration.cs:20` |
| `DossierItem.Name` | 500 **required** | `ContractSigningPlanDossierItemConfiguration.cs:18` |
| `DossierItem.TvgsName` | 200 | `:19` |
| `DossierItem.Note` | 1000 | `:20` |
| `Attachment.FileName` | 500 | `ContractSigningPlanAttachmentConfiguration.cs:24` |
| `Attachment.StoragePath` | 1000 | `:25` |
| `Attachment.ContentType` | 200 | `:26` |
| `Attachment.Note` | 1000 | `:28` |

### 0.4 — HasQueryFilter: KHKK CÓ (khác HRM), trừ 2 bảng audit

`grep -c HasQueryFilter` trên 7 config KHKK: có ở `ContractSigningPlan/Line/DossierItem/Attachment` (`:42/:33/:30/:38`).
`Changelog` + `Approval` = `BaseEntity` (không `IsDeleted`) ⇒ không có filter. `LevelOpinion` = `AuditableEntity` nhưng **CỐ Ý KHÔNG filter** (`ContractSigningPlanLevelOpinion.cs:18-20` — site dọn chữ ký mồ côi phải thấy row soft-deleted).

⇒ Hệ quả cho W2: list/detail **KHÔNG cần** `.Where(!IsDeleted)` thủ công (filter tự chạy); ngược lại màn "đã xóa" BẮT BUỘC `IgnoreQueryFilters().Where(x => x.IsDeleted)` — luật `ContractFeatures.cs:356-360` (Ignore GỠ filter chứ không ĐẢO).

### 0.5 — LỆCH SPEC vs COMMENT W1 (lead đã phân xử: SPEC THẮNG) + 2 site cùng-lớp lead CHƯA nêu

Lead giao sửa 1 chỗ. Grep cùng-lớp ra **3** chỗ khai cùng một điều sai ("gen lúc Submit"):

| # | Site | Nguyên văn | Lead giao? |
|---|---|---|---|
| 1 | `Domain/ContractSigningPlans/ContractSigningPlan.cs:17` | `// "KHKK/2026/001" — gen atomic lúc Submit (W2)` | ✔ CÓ |
| 2 | `Application/ContractSigningPlans/Services/IContractSigningPlanCodeGenerator.cs:9` | `// Caller: W2 Submit (gen lúc trình duyệt, KHÔNG lúc tạo nháp — mirror quyết spec cũ)` | ✗ KHÔNG |
| 3 | `Infrastructure/.../ContractSigningPlanConfiguration.cs:24` | `// Mã phiếu UNIQUE khi đã gen (gen lúc Submit ⇒ nháp còn null)` | ✗ KHÔNG |

Xử: sửa cả 3 (comment-only, 0 thay đổi hành vi) — theo bài `feedback_root_cause_over_symptom` "được chỉ 1 chỗ ⇒ PHẢI grep cùng-lớp". Index filtered `[MaKeHoach] IS NOT NULL` GIỮ NGUYÊN (vẫn đúng: gen-lúc-create thì cột luôn NOT NULL, filter chỉ rộng hơn cần thiết chứ không sai).

**ĐÃ LÀM** — 3 Edit comment-only:
- `ContractSigningPlan.cs:17-22` (5 dòng comment mới + prop giữ nguyên chữ ký `public string? MaKeHoach`)
- `IContractSigningPlanCodeGenerator.cs:9-11`
- `ContractSigningPlanConfiguration.cs:24-27`

---

## §1 — CREATE (`CreateContractSigningPlanCommand`)

File: `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` (Region 1).

**Chữ ký:** `CreateContractSigningPlanCommand(Guid PeId, Guid ApprovalWorkflowId, string? GhiChu = null) : IRequest<CreateContractSigningPlanResult>`
→ `CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount)`.

🔴 Tên `PeId` (KHÔNG `PurchaseEvaluationId` theo convention repo) là CỐ Ý: spec §②-2 viết đúng chữ đó, lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên = gãy tích hợp 2 lane (`camelCase` body sẽ là `peId`). Đã ghi lý do vào `<remarks>` ngay trên record.

**4 rào theo đúng thứ tự** (fail sớm, rào rẻ trước):
1. Phase PE `!= DaDuyet` → `ConflictException` — mirror `CreateContractFromEvaluationFeatures.cs:53-54`.
2. `winners = pe.Suppliers.Where(s => s.IsWinner)`, rỗng → Conflict — mirror `:56-58`.
3. Đã có kế hoạch SỐNG (`AnyAsync(p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)`) → Conflict 409. `TuChoi` không tính ⇒ lập lại được; phiếu xoá mềm cũng không tính (global filter tự loại) ⇒ acceptance "DELETE → tạo lại → 201" đi qua rào này.
4. Workflow type — copy TRỌN 2 nhánh Proposal `:264-267` vào helper `ContractSigningPlanScope.EnsureWorkflowTypeAsync` (dùng lại ở Update). NotFound giữ nguyên là `NotFoundException`, sai loại là `ConflictException`.

**Codegen:** `await codeGen.GenerateAsync(ct)` gọi **TRƯỚC** `db.ContractSigningPlans.Add(plan)`.
Lý do đo được: `ContractSigningPlanCodeGenerator.cs:23-43` mở transaction SERIALIZABLE **rồi gọi `db.SaveChangesAsync` nội bộ** ⇒ nếu phiếu đã nằm trong change-tracker, lượt lưu đó flush phiếu DỞ (chưa có mã) trong transaction của codegen (bài S88 self-committing codegen). Add + SaveChanges đúng 1 lần ở cuối.

**Lines auto-sinh:** 1 dòng / winner, `PeReferenceAmount = SUM(Quote.ThanhTien WHERE IsSelected && q.PurchaseEvaluationSupplierId == w.Id)` — copy `:88-90`.
🔴 So với `w.Id` (Id của ROW `PurchaseEvaluationSuppliers`), KHÔNG phải `w.SupplierId` (Id master) — nhầm 2 cái này là cộng nhầm tiền của NCC khác. Snapshot 1 lần, không đọc live (cùng triết lý freeze NS Mig 67).

**Denorm:** `ProjectId`/`DepartmentId` ← PE. `DrafterUserId` ← **currentUser** (KHÔNG phải `pe.DrafterUserId`) — xem §6 DECISION-2.

**Changelog:** 1 entry `Header/Insert/PhaseAtChange=DangSoanThao` + `ContextNote = "Nguồn: phiếu Duyệt NCC {MaPhieu} ({Id})"`.

## §2 — UPDATE nháp (`UpdateContractSigningPlanDraftCommand`)

`(Guid Id, string? GhiChu = null, string? HoSoLink = null, Guid? ApprovalWorkflowId = null)`.
- Guard qua `ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser)`: (a) owner ∥ Admin → `ForbiddenException`; (b) phase ∈ {DangSoanThao, TraLai} → `ConflictException`.
- **Null-safe 3/3 field** (#73/S42): `null` = client không gửi ⇒ GIỮ; `""` = chủ động xoá ⇒ set null (khuôn `HoSoLink` `PurchaseEvaluationFeatures.cs:285-286`). Không field nào absolute-set.
- Re-pin workflow: **guard type LẠI** (admin có thể đổi loại quy trình giữa chừng) — cùng helper §1 rào 4.

## §3 — GET detail / LIST / INBOX / ĐÃ XOÁ

**`GetContractSigningPlanQuery`** — Include `Lines + DossierItems + Attachments + LevelOpinions` (khuôn Proposal `:159-162`) + tra thêm: PE (MaPhieu/TenGoiThau), Project (Name/Code), Department, Drafter, **tên NCC 1 lượt** (`Suppliers` dictionary dùng chung Lines + DossierItems), **workflow tree** (Steps→Levels + tên người duyệt) và metadata Cấp cho `LevelOpinions` (StepOrder/StepName/LevelOrder/ApproverUserId) — mirror `ProposalFeatures.cs:182-217` nhưng KHÔNG dùng `SelectMany` flatten (chỉ dựng `Dictionary<levelId, meta>`).

**`ListContractSigningPlansQuery(Phase?, ProjectId?, PurchaseEvaluationId?, PendingMe) : PagedRequest`**
- Không cần `.Where(!IsDeleted)` thủ công — 4 bảng KHKK CÓ global filter (§0.4).
- `PendingMe=true` → giao với `ResolveV2InboxIdsAsync` (non-admin) hoặc `AdminInboxPhases` (admin).
- **IDOR scope** non-admin — xem §6 DECISION-3.

**`GetMyContractSigningPlanInboxQuery`** — admin: phase-list TƯỜNG MINH `AdminInboxPhases = [ChoDuyet]` (mirror PE `:731-742`, nháp KHÔNG vào inbox theo S89); non-admin: `ResolveV2InboxIdsAsync` mirror `PurchaseEvaluationFeatures.cs:817-852` — in-memory join vì `CurrentWorkflowStepIndex` là INDEX 0-based vào list Steps đã sort, không dịch thẳng SQL được; so Cấp bằng GIÁ TRỊ `Level.Order` + `Any(...)` ⇒ **giữ OR-of-N**. W2 luôn trả rỗng (chưa có phiếu ChoDuyet vì transition = W3) — wire sẵn, W3 không phải sửa list/inbox.

**`ListDeletedContractSigningPlansQuery`** — `IgnoreQueryFilters().Where(x => x.IsDeleted)` theo LUẬT `ContractFeatures.cs:356-360` (Ignore **GỠ** filter chứ không ĐẢO) + cùng vị ngữ IDOR như list sống.

Projection dùng chung 3 query qua `ListContractSigningPlansQueryHandler.ProjectPagedAsync` (1 hình dạng DTO, không lệch).

## §4 — Dossier items (căn cứ b.8-9) + Attachments

- `UpsertContractSigningPlanDossierItemCommand(PlanId, Id?, Kind, SupplierId?, Name, Status, TvgsName?, TvgsResultAt?, Note?)` → `Guid`. `Id == null` ⇒ Insert, có ⇒ Update (verify item thuộc ĐÚNG phiếu). Guard = `EnsureDraftEditable` ⇒ **chỉ {DangSoanThao, TraLai}**.
- `DeleteContractSigningPlanDossierItemCommand` — `Remove()` = XOÁ MỀM (`AuditingInterceptor.cs:54-63` đổi Deleted→Modified + set IsDeleted/DeletedAt/DeletedBy) + changelog.
- **Attachments MỞ MỌI PHASE** (triết lý PE S147): người duyệt phải bổ sung được scan kết quả TVGS ngay lúc đang duyệt ⇒ 3 handler upload/download/delete KHÔNG gọi `EnsureDraftEditable`. Chỉ CĂN CỨ mới khoá theo phase.
- Upload: `IFileStorage` + path `contract-signing-plans/{planId}/{attId}_{safeName}` + sanitize (mirror PE `:115-123`). `DossierItemId` phải thuộc đúng phiếu — **handler tự kiểm** vì cột đó CỐ Ý không có FK vật lý (tránh multiple cascade path, `ContractSigningPlanAttachment.cs:14-19`) ⇒ DB không kiểm hộ.
- Validator MaxLength lấy từ EF KHKK (FileName **500** / ContentType 200 / Note 1000) — KHÔNG copy số của PE (255/100/500); PE hẹp hơn vì là bản cũ.

## §5 — DELETE phiếu + PICKER

**`DeleteContractSigningPlanCommand(Id, Reason?)`** — owner ∥ Admin; allow-list **{DangSoanThao, TuChoi}** (M6: thiếu đường này thì lỡ tạo nhầm 1 kế hoạch là PE khoá 409 vĩnh viễn).
🔴 Thứ tự `db.ContractSigningPlans.Remove(plan)` **TRƯỚC** `Add(changelog)` — bug T26 S155: EF chạy cascade client-side NGAY tại `Remove()` (`CascadeDeleteTiming.Immediate`), changelog đang ở state `Added` sẽ bị DETACH ⇒ SaveChanges ghi 0 row, IM LẶNG. (Ở dossier-item/attachment thì changelog trỏ PLAN chứ không trỏ thứ bị Remove nên không dính, vẫn giữ cùng thứ tự cho nhất quán.)

**`ListApprovedPeAwaitingPlanQuery`** — mirror `CreateContractFromEvaluationFeatures.cs:162-174`, đổi vị ngữ: bỏ `e.ContractId == null` (không liên quan GĐ2), thêm `có ≥1 IsWinner` + `chưa có plan sống` ⇒ picker khớp ĐÚNG 2 rào của Create, không mời user bấm vào thứ chắc chắn 409.
⚠️ `WinnerQuoteTotal` viết lại sau lần build đầu: bỏ `Any(...)` lồng trong `Where` của subquery (sâu 2 tầng, vỡ dịch SQL chỉ nổ LÚC CHẠY chứ không nổ lúc build) → dùng hình dạng subquery-có-join đã chạy được ở `:185-189`.

## §6 — 4 QUYẾT ĐỊNH lệch/ngoài chữ của lệnh (khai để lead phân xử)

| # | Chỗ | Lệnh/spec nói | Tôi làm | Vì sao |
|---|---|---|---|---|
| D1 | tên tham số Create | spec + lệnh viết `PeId` | `PeId` (trái convention repo `PurchaseEvaluationId`) | spec = hợp đồng chung 2 lane; FE đọc CÙNG spec ⇒ đổi tên là gãy body JSON |
| D2 | `DrafterUserId` | lệnh gộp "DepartmentId/DrafterUserId/ProjectId denorm từ PE" | `= currentUser.UserId` (chỉ Project+Dept mới denorm) | (a) entity khai "BCH soạn kế hoạch" `ContractSigningPlan.cs:24` — người soạn PE (QS) ≠ người lập kế hoạch (BCH); (b) khuôn Proposal `:276` + PE `:147` đều lấy currentUser; (c) `pe.DrafterUserId` là `Guid?` còn cột này non-null ⇒ copy sang cho phiếu PE cũ thiếu drafter sẽ ra `Guid.Empty` = hỏng cả quyền sửa lẫn scope list |
| D3 | IDOR scope list | lệnh không nói | non-admin thấy: phiếu MÌNH soạn ∪ phiếu đã rời nháp thuộc quy trình mình có chân duyệt | bài 8b S155 "handler MỚI mặc định KHÔNG có rào nào"; khuôn Proposal (0 rào) là khuôn HỞ. Bỏ vế eligible-phase-theo-role vì module này KHÔNG có bản đồ role→phase. **Nới cho cả phòng = thêm 1 vế `DepartmentId` ở đúng 1 chỗ** (comment đã chỉ chỗ), áp cả list sống lẫn màn xoá |
| D4 | `ProposedAmount` lúc tạo | không nói | mồi `= PeReferenceAmount` | W2 KHÔNG có command sửa dòng giá (spec §②-2 không liệt kê) ⇒ để 0 là cả phiếu 0 và không ai sửa được. Mồi = giá đã duyệt ở PE = trường hợp thường gặp |

**Cố ý KHÔNG làm (ngoài spec, để W3):** command sửa Line (ProposedAmount/Note) · mọi transition (submit/approve/reject/return) · `SlaDeadline` để null (cột là display-only, không job — `ContractSigningPlan.cs:34-37`) · xoá phiếu đang duyệt (cần rào đúng-lượt như `PeSoftDeleteFeatures`).

## §7 — Endpoint + authz (14 action, per-action policy 14/14)

Route gốc `api/contract-signing-plans`, class `[Authorize(Policy = "KeHoachKyKet.Read")]`.

| Verb | Path | Policy |
|---|---|---|
| GET | `/` | `KeHoachKyKet.Read` |
| GET | `/inbox` | Read |
| GET | `/deleted` | Read |
| GET | `/approved-pe-awaiting-plan` | Read |
| GET | `/{id}` | Read |
| POST | `/` | **Create** |
| PUT | `/{id}` | **Update** |
| DELETE | `/{id}` | **Delete** |
| POST | `/{id}/dossier-items` | **Update** |
| PUT | `/{id}/dossier-items/{itemId}` | **Update** |
| DELETE | `/{id}/dossier-items/{itemId}` | **Update** |
| POST | `/{id}/attachments` | **Update** |
| GET | `/{id}/attachments/{attId}/download` | Read |
| GET | `/{id}/attachments/{attId}/view` | Read |
| DELETE | `/{id}/attachments/{attId}` | **Update** |

(15 dòng bảng = 15 action; xem số đo grep ở §8.)
Ghi Read tường minh cả trên action chỉ-đọc: `[Authorize]` trần / để trống = lỗ hổng (gotcha #82 — ẩn menu KHÔNG đóng API). Dossier-item + attachment dùng **Update** chứ không Create: "tạo" ở module này = tạo PHIẾU.
Endpoint dossier-item verify `id == cmd.ContractSigningPlanId` → `BadRequest` (Pattern 12-ter).



