wal: flush 20260729T2046

This commit is contained in:
pqhuy1987
2026-07-29 20:46:44 +07:00
parent 77c723b07a
commit fd68407a2f
6 changed files with 237 additions and 4 deletions

View File

@ -8,9 +8,9 @@
| # | Stage | Vai / cách | Trạng thái |
|---|---|---|---|
| 1 | Đợt-1a BE: mega-file `ContractSigningPlanFeatures.cs` (Create+auto-Lines+MaKeHoach@CREATE · UpdateDraft · Get/List/pendingMe+inbox-precompute · Dossier UPSERT/DELETE · Attachments · DELETE allow-list · picker PE-chờ-lập-KH) + `ContractSigningPlansController` authz 2-tầng per-action | hmw `implementer-backend` | [ ] |
| 2 | Đợt-1b FE: `pages/khkk/` 3 page + types ×2 app + 4-place mirror (routes + staticMap re-point 6 leaf khỏi coming-soon + menuKeys) + build ×2 + SHA-pair | hmw `implementer-frontend` | [ ] |
| 3 | Đợt-2: test-specialist (≥2 test PIN: `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` + `CreateContractSigningPlan_FromNonApprovedPe_Throws`) | hmw `test-specialist` | [ ] |
| 1 | Đợt-1a BE: mega-file `ContractSigningPlanFeatures.cs` (Create+auto-Lines+MaKeHoach@CREATE · UpdateDraft · Get/List/pendingMe+inbox-precompute · Dossier UPSERT/DELETE · Attachments · DELETE allow-list · picker PE-chờ-lập-KH) + `ContractSigningPlansController` authz 2-tầng per-action | hmw `implementer-backend` | [x] `wf_6f0aad26-229` lane-0 chết #53 sau §7 NHƯNG sub-md **16.5KB ghi-từng-mục TRỌN** (tự mở 5 anchor + grep-cùng-lớp 3 site comment + 15 endpoint policy 15/15 + **4 quyết D1-D4 khai-lead-phân-xử → lead DUYỆT cả 4**) · build lead 0W/0E |
| 2 | Đợt-1b FE: `pages/khkk/` 3 page + types ×2 app + 4-place mirror (routes + staticMap re-point 6 leaf khỏi coming-soon + menuKeys) + build ×2 + SHA-pair | hmw `implementer-frontend` | [x] lane-1 chết #53 sau MUC-1+cp (SHA-pair 4/4) → **lead solo đuôi**: Layout ×2 re-point 6 leaf (WfView 2 app 2 đường: user=alias matrix?type=10 whitelist+10 · admin=Designer deep-link) + fe-admin App routes + root KeHoachKyKet→/khkk/list; menuKeys place-3 SKIP-có-khai (0 consumer). Build ×2 PASS + coming-soon **0/0** |
| 3 | Đợt-2: test-specialist (≥2 test PIN: `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` + `CreateContractSigningPlan_FromNonApprovedPe_Throws`) | hmw `test-specialist` | [!] `wf_7490179a-70b` ĐANG CHẠY nền |
| 4 | Lead: build ×2 + suite + acceptance §③-B sweep | lead | [ ] |
| 5 | Reviewer adversarial (CRUD endpoints POST/PUT/DELETE — bắt buộc) | `reviewer` | [ ] |
| 6 | Commit + push + cicd (3-chân-kiềng + 2-bearer NẾU có tài khoản non-Admin — nv.test KHÔNG tồn tại prod, xem w1-synthesis tồn-2) | lead + `cicd-monitor` | [ ] |

View File

@ -0,0 +1,174 @@
# 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).

View File

@ -0,0 +1,54 @@
# sub-implementer-frontend-1 — W2 KHKK FE 3 page x 2 app (S161)
> Lane: FE-only. KHONG cham src/Backend (chi Read entity de suy DTO shape). KHONG commit.
> Ghi TUNG MUC ngay sau khi lam xong muc do (chong #53).
## MUC 0 — Recon (DONE)
Khuon nguon doc that (khong doan):
- `fe-user/src/pages/office/ProposalsListPage.tsx` 252 dong / `ProposalCreatePage.tsx` 245 / `ProposalDetailPage.tsx` 386 / `fe-user/src/types/proposal.ts` 95.
SHA256 = IDENTICAL voi fe-admin counterpart cho ca 4 file (do bang `sha256sum`):
- Create `fb852a8df3191aa8696468ee6c762c21710f662db146ab678933c57a7373464b`
- Detail `a550a5da29729d0d44981627bf8a6934255ac02c80477545f8c4eff393e83f81`
- List `780060dc1c4a9f08387e7ad35737846e38aff92191ad9fe2a7e8f2fa303f5af6`
- types `95607052ff1138f21d266d7fe66d45bab180c97270030f939be1ffcb681fdeda`
- Entity BE W1 (`src/Backend/SolutionErp.Domain/ContractSigningPlans/*.cs`, 8 file) = nguon shape cho types:
`ContractSigningPlan` (MaKeHoach, PurchaseEvaluationId, ProjectId, DepartmentId, DrafterUserId, Phase,
ApprovalWorkflowId, CurrentWorkflowStepIndex, CurrentApprovalLevelOrder, SlaDeadline, HoSoLink, GhiChu)
· `Line` (SupplierId, PeReferenceAmount, ProposedAmount, ApprovedAmount?, ContractId?, Note?)
· `DossierItem` (Kind 1/2/99, SupplierId?, Name, Status 1..4, TvgsName?, TvgsResultAt?, Note?)
· `Attachment` (FileName, StoragePath, FileSize, ContentType, Purpose 1/2/99, Note?, DossierItemId?)
· `Phase` enum 1/2/3/98/99.
- BE lane CHUA land file Application/Controller luc toi recon (`ls src/Backend/SolutionErp.Application/ContractSigningPlans/` = chi co `Services/` cua W1)
=> types FE viet theo ENTITY (dung luat de bai: "uu tien shape ENTITY").
- Layout staticMap truoc khi sua: fe-user `Layout.tsx:59-62,79-80` (6 leaf Khkk_* -> /coming-soon),
fe-admin `Layout.tsx:34-39` (6 leaf -> /coming-soon).
- `Khkk_*` KHONG ton tai trong `fe-{user,admin}/src/lib/menuKeys.ts` (grep 0 hit) => phai them (place 3).
- Pe_WfView hien tai: fe-user `Layout.tsx:159` -> `/purchase-evaluations/workflow-matrix?type=N`
(page `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx`, whitelist type `1|2|3` o `:60`).
fe-admin CO Y KHONG co Pe_WfView (`Layout.tsx:125-129` lead chot 2026-07-27 — khong co matrix page ben admin).
- fe-admin Designer da mo type-10: `ApprovalWorkflowsV2Page.tsx:147` `TYPE_INT_CONTRACT_SIGNING_PLAN = 10`,
`:162` `ContractSigningPlan: 10` (deep-link), route `fe-admin/src/App.tsx:76` `/system/approval-workflows-v2/:typeCode`.
- Upload multipart khuon: `fe-user/src/components/pe/PeDetailTabs.tsx:3323-3331` (FormData + header multipart)
+ download blob `:3352-3360`.
## MUC 1 — 4 file MOI ben fe-user (DONE, viet truoc roi cp sang fe-admin)
- `fe-user/src/types/khkk.ts` — enum-as-const 4 bo (Phase 1/2/3/98/99 · DossierItemKind 1/2/99 ·
DossierItemStatus 1..4 · AttachmentPurpose 1/2/99) + badge full-class-literal (Pattern 14) +
DTO (List/Line/DossierItem/Attachment/LevelOpinion/Detail) + input (Create/UpdateDraft/UpsertDossier)
+ `KhkkPeCandidateDto` (CO Y la TAP CON cua `PurchaseEvaluationListItemDto` BE tra — field thua bo qua runtime)
+ `PagedResult<T>`. Header file KHAI HOP DONG FE<->BE 12 endpoint (de BE lane doi chieu 1 cho).
- `fe-user/src/pages/khkk/KhkkListPage.tsx` — 6 KpiCard loc phase + o tim + bang 7 cot + phan trang;
`?filter=` map ten-phase -> int (`FILTER_TO_PHASE`), `?view=deleted` -> endpoint rieng
`/contract-signing-plans/deleted` (mirror PE S155) + an nut tao + row khong click.
🔴 Chip loc chi doi STATE (khong ghi URL) => menu khong sang nham (bug UAT S155).
- `fe-user/src/pages/khkk/KhkkCreatePage.tsx` — picker PE nguon (list nut chon, hien maPhieu +
tenGoiThau + du an/hang muc + chip winner names) + dropdown workflow `applicableType=10` loc
`isUserSelectable` (khuon ProposalCreatePage:59-69, endpoint tra `{types:[{applicableType,history}]}`
KHONG phai flat array) + GhiChu; POST -> navigate `/khkk/{id}`. FE KHONG gui lines (BE sinh tu winner).
- `fe-user/src/pages/khkk/KhkkDetailPage.tsx` — 4 section: Thong tin (sua GhiChu/HoSoLink khi
DangSoanThao|TraLai) · bang Lines per-NCC READ-ONLY (PeReferenceAmount/Proposed/Approved formatVnd —
W2 khong co endpoint sua line nen KHONG bia) · bang DossierItems chip 4 muc + upsert/delete dialog
(chi mo khi draft) · Attachments upload multi/download blob/delete (mo MOI phase).
🔴 KHONG panel trinh/duyet + KHONG banner cap duyet = W3.