Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w2-crud/sub-implementer-backend-0.md
2026-07-29 20:46:44 +07:00

175 lines
16 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-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``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).