# SPEC — "KẾ HOẠCH KÝ KẾT HỢP ĐỒNG" (khúc còn thiếu giữa Duyệt NCC và HĐ NTP/NCC) > **Run:** `2026-07-28-S157-ke-hoach-ky-ket-hd` · lead-written sau verify (H21 ①: engine propose-only ⟂ lead ghi spec) > **Nguồn propose:** `sub-invest-kehoach-1.md` §A+§B (23.085 B, `/fable-real investigator-codebase` Fable) > **Lead verify:** 4 claim load-bearing đo lại độc lập trên đĩa — xem `run.md` S4. §C+§D dưới đây **lead viết**. > **Trạng thái:** CHỜ `/fable-clone reviewer` chấm + **5 câu owner** chưa trả lời (không chặn Wave 1-2). --- ## ① TÍNH NĂNG / ĐỀ BÀI ### 1.1 Vấn đề Giữa **Duyệt NCC** (module PE, kết thúc `PE.DaDuyet`) và **Hợp đồng NTP/NCC** (module Contract, bắt đầu `DangSoanThao`) có một khúc quy trình **KHÔNG TỒN TẠI Ở CẢ HAI TẦNG**: - **Không có trong code** — repo 0-hit `shopdrawing|TVGS|duyệt mẫu`. - **Không có trong quy trình ISO đang ban hành** — `QT TRINH KY HOP DONG TP-NCC.docx` bảng 2 đi THẲNG bước 1 *"Lựa chọn NTP/NCC"* → bước 2 *"Soạn thảo hợp đồng"*, **0 bước trung gian**. - **Chỉ có trên sơ đồ PDF** `01- Quy trinh hien tai --- 14_2026` (bản mới), các bước **b.7→12**. 🔴 ⇒ **Spec này vừa là spec phần mềm, vừa là ĐỀ XUẤT BỔ SUNG QUY TRÌNH ISO.** Anh cần duyệt ở cả 2 tư cách. ### 1.2 Tên + mục đích (owner đặt) > **anh:** *"Bước này là bước **'đề xuất giá trị ký kết hợp đồng'**"* · sau đó gọi là **"Kế hoạch ký kết Hợp đồng"**. > 🔄 **OWNER CHỐT NHÃN @S159 (2026-07-29, AskUser ❶):** tên phiếu/menu = **"Đề xuất ký kết hợp đồng"** — mọi label VN trong spec đọc theo tên này (tên bảng/class English `ContractSigningPlan*` GIỮ NGUYÊN). Cùng lượt anh chốt: **ranh GĐ2 = b.7→12** (b.12 chốt con số ∈ GĐ2) · GĐ3 = b.13→18 tận dụng Contract V2 · GĐ4 = b.19→21 + ký cứng **upload-only** · **b.7 email BỎ** khỏi scope, thay bằng tạo-phiếu-từ-PE-`DaDuyet` (= chính thiết kế W2 — **Q8 ĐÓNG: out-of-scope**). Đây là phát biểu **MỤC ĐÍCH**: đầu ra của khúc này = **MỘT CON SỐ CÓ PHÊ DUYỆT** (giá trị ký kết đề xuất). b.8-9 (duyệt mẫu / shopdrawing với TVGS) = **CĂN CỨ**; b.10-12 = **chuỗi duyệt chính con số đó**. ⇒ Vật mang trong hệ thống = **một PHIẾU**, không phải thư mục đính kèm. ### 1.3 Bối cảnh kiến trúc — 2 phát hiện nền (lead đo, invest xác nhận từ nguồn gốc) **(A) 7 phase `[LEGACY]` của `ContractPhase` CHÍNH LÀ quy trình ISO 9 bước, mã-hoá cứng.** Khớp 1:1 đúng thứ tự: `DangChon=1`↔Lựa chọn NTP/NCC · `DangSoanThao=2`↔Soạn thảo · `DangGopY=3`↔Góp ý · `DangDamPhan=4`↔Đàm phán · `DangInKy=5`↔In HĐ · `DangKiemTraCCM=6`↔Kiểm tra CCM · `DangTrinhKy=7`↔Duyệt BOD · `DangDongDau=8`↔Đóng dấu · `DaPhatHanh=9`↔Phát hành. Post-Mig 21/S17 thay bằng `ChoDuyet=10` + con-trỏ chạy trên workflow V2 admin-config ⇒ chuyển từ *cứng 9 bước* sang *cấu-hình N bước*. **Khung V2 sinh ra CHÍNH ĐỂ biểu diễn quy trình ISO này.** **(B) Khoảng trống nằm ĐÚNG giữa ISO-1 và ISO-2** (xem 1.1). ### 1.4 🔴 OWNER ĐÃ CHỐT (2026-07-28) — ràng buộc cứng của spec | # | Anh nói (verbatim) | Hệ quả spec | |---|---|---| | **O-1** | *"Đúng chính xác, cấu trúc trình ký giống, chỉ khác nội dụng thôi."* | **TÁI DÙNG khung duyệt V2** (`ApprovalWorkflow`>Step=Phòng>Level=NV, OR-of-N, `LevelOpinions`). **0 bảng approval mới.** Dựng mới = **NỘI DUNG phiếu**. ⇒ trục tranh luận Q2 hôm qua **ĐÓNG**. | | **O-2** | *"Cái này để tham khảo thôi, ko vấn đề j, đa số là trễ."* (SLA) | **KHÔNG SLA-engine**, không auto-approve-on-timeout, không cảnh báo chặn, **không wave nào cho SLA**. Giữ hiển thị deadline. Lỗ `AddDays(7)` hardcode = ghi nhận, ngoài scope. | | **O-3** | *"Chỗ hợp đồng cứ từ từ nhé."* | Lỗ hổng an ninh `ContractWorkflowService.cs:48-66` + controller HĐ class-trần: **NGOÀI SCOPE**, treo có chủ đích. Wave nào chạm module Contract phải hỏi lại anh. | ### 1.5 Quy trình chi tiết — 6 bước (b.7→12) Role hệ thống có thật (`AppRoles.cs:5-16`): `Procurement` (PMH) · `CostControl` (CCM) · `Director`+`AuthorizedSigner` (BOD/NĐUQ) · `ProjectManager`/`Drafter` (BCH) · `HrAdmin` · `Admin`. 🔸 **"BCH dự án X" KHÔNG có model** (`Department.ManagerUserId` 0-row) ⇒ né bằng: **BCH = người tạo phiếu** (`CreatedBy`); PMH/CCM/CEO = **đích danh user trong Level** của workflow V2 (đúng triết lý V2, **0 model mới**). | b | Việc | Ai | Đầu vào | Đầu ra | Chuyển bước | Nhánh lỗi | |---|---|---|---|---|---|---| | 7 | PMH chuyển thông tin cho BCH | Procurement | PE `DaDuyet` (winners `IsWinner`, giá `SUM Quote.IsSelected`, HoSoLink) | BCH biết việc | **Pull-model**: màn *"PE đã duyệt — chờ lập KH"* lọc `Phase==DaDuyet && chưa có plan` (khuôn `CreateContractFromEvaluationFeatures.cs:173`) + notify in-app. **Email ra ngoài = ngoài scope** (SMTP TODO toàn hệ thống) | PE thiếu winner → không hiện | | 8 | Duyệt mẫu + shopdrawing với TVGS | BCH **nhập hộ** (TVGS ngoài hệ thống) | Mẫu + shopdrawing từ NTP/NCC (offline) | Danh mục **CĂN CỨ** trong phiếu: {Kind, NCC, Status, TvgsName, ngày, scan} | Vòng đời **per-item**: `ChuaNop→DaNop→TvgsDuyet\|TvgsBac`; `TvgsBac→DaNop` (nộp lại) — 🔴 **sơ đồ KHÔNG vẽ vòng lặp này, thực tế có** | Item bị bác = đổi status + note, **KHÔNG đụng state machine phiếu** | | 9 | Tổng hợp + so sánh giá → **ĐỀ XUẤT giá trị** | BCH (Drafter phiếu) | Căn cứ b.8 + báo giá PE (**snapshot** `PeReferenceAmount` lúc tạo — nguyên tắc freeze Mig 67) | Phiếu đủ dòng giá đề xuất per-winner + file so sánh → **TRÌNH** | Guard trình: đủ dòng giá per-winner (mirror completeness-guard PE `PurchaseEvaluationWorkflowService.cs:180-212`); căn cứ chưa đủ `TvgsDuyet` → **cảnh báo mềm** (tiền lệ S62) — **O-Q1 owner quyết mềm/cứng** | Ghi chú sơ đồ *"hồ sơ sai sót → gửi mail làm rõ"* = comment + item `TvgsBac` | | 10 | PMH kiểm tra, xác nhận giá trị | Procurement (đích danh Level) | Phiếu `ChoDuyet` Bước 1 | Duyệt → Bước 2; ý kiến vào `LevelOpinions` (UPSERT khuôn `ContractWorkflowService.cs:292-316`) | OR-of-N trong Cấp (`ApprovalWorkflow.cs:81-94`) | Trả lại → `TraLai`, hoặc 4 return-mode per-level (cờ CÓ SẴN `ApprovalWorkflowLevel.cs:116-125`) | | 11 | CCM kiểm tra | CostControl | Bước 2 | Duyệt → Bước 3 | như trên | như trên | | 12 | **CEO ký HOẶC CCM đóng dấu** | Director / cơ chế kết thúc sớm | Bước 3 (hoặc kết thúc tại Bước 2) | 🔴 **GIÁ TRỊ KÝ KẾT CHỐT** per-winner (`ApprovedAmount`) ghi tại **finalize choke-point** (mirror `ApplyApprovedPriceOnFinalize` PE `:973-1002` + RULE `:1008` *mọi nhánh set DaDuyet phải gọi helper*) | Terminal `DaDuyet` | Từ chối → `TuChoi`; trả lại → `TraLai` | **Rẽ nhánh b.12 "hoặc" — 4 đường ĐỀU CÓ SẴN trong khung V2, 0 migration:** (i) `CeoApprovalThreshold` + CCM tích "duyệt done miễn CEO" (PE `:892-929`, fail-closed: đòi threshold + role `CostControl` + giá < ngưỡng) · (ii) `AllowApproverFinalize` per-slot (PE `:870-890`) · (iii) `AllowApproverSkipToFinal` per-slot, CEO vẫn ký thật (Contract `:320-352`) · (iv) 2 workflow (có/không Step CEO) admin pin. 🔸 **Điều kiện rẽ THẬT: cả 3 nguồn IM LẶNG** → **Q3 owner**; chọn đường nào **cũng không đổi schema**. ### 1.6 b.8-9 là **bảng con trong phiếu** — không phải phiếu con, không phải module riêng Lý do: (1) owner phát biểu b.8-9 = **căn cứ**, không phải đối tượng duyệt riêng; (2) PDF chỉ vẽ **MỘT** chuỗi duyệt (10-12) cho cả khúc — phiếu con = đẻ chuỗi duyệt thứ 2 không có trên giấy; (3) vòng đời mẫu/shopdrawing là **per-item status**, không phải state machine phiếu; (4) TVGS ngoài hệ thống ⇒ chỉ cần **ghi vết**. Module riêng chỉ đáng khi mẫu/shopdrawing cần quản lý **độc lập với gói thầu** — chưa có yêu cầu. ### 1.7 Mâu thuẫn / im lặng giữa 3 nguồn (bảng tra — KHÔNG tự quyết) | # | Điểm | ISO doc | PDF | FO-002.01 | Xử lý | |---|---|---|---|---|---| | 1 | Khúc b.7→12 | **KHÔNG CÓ** | CÓ (duy nhất) | không nói | Spec = đề xuất bổ sung ISO | | 2 | SLA khúc 7→12 | im lặng | im lặng | im lặng | **O-2**: tham khảo, không hoà giải | | 3 | SLA khúc HĐ | 07/07/07/07/03/01 ngày | 3d + 7-10d | 🔴 *"mỗi bộ phận 01 ngày, quá hạn **XEM NHƯ ĐÃ THÔNG QUA**"* | 3 nguồn 3 kiểu; auto-pass **mâu thuẫn mô hình duyệt-chặn V2**. O-2 ⇒ chỉ ghi nhận | | 4 | Điều kiện rẽ b.12 | — | "hoặc", 0 điều kiện | không nói | **Q3 owner** | | 5 | Nhánh trả lại 10/11/12 | không vẽ | không vẽ | không vẽ | Hệ thống có sẵn `TraLai` + 4 return-mode | | 6 | Bypass Chủ đầu tư → GĐ | không nói | không nói | CÓ | = `Contract.BypassProcurementAndCCM` (đã có) — **KHÔNG áp phiếu KH** | | 7 | Checklist kiểm tra per-phòng (PRO: điều khoản/thanh toán/rủi ro pháp lý · CCM: giá vs NS) | mô tả lời | không | CÓ (checkbox từng mục) | Hệ thống chỉ có `LevelOpinions.Comment` tự do ⇒ **GAP**, **O-Q2 owner** có số hoá checkbox không | | 8 | Tên phòng/vai | PRO/CCM/BOD/NĐUQ/HRA | Procurement/CCM/CEO | PB-DA/PRO/CCM/GĐ | Khớp `AppRoles` — **0 mâu thuẫn** | | 9 | Chuỗi ký FO-002.01 (ĐỀ XUẤT→PRO→CCM→GĐ) | ≈ ISO 5-7 | ≈ b.9→10→11→12 **CÙNG HÌNH** | CÓ | **Đối chứng GIẤY cho O-1** | --- ## ② CÁCH IMPLEMENT ### 2.1 ⚠️ BẪY SỐ MỘT — CẤM copy "Đề xuất" (Proposal) cho phần DUYỆT `ProposalFeatures.cs` là twin **nhỏ nhất** (849 LOC BE) nên rất cám dỗ. **Nhưng phần duyệt của nó phá OR-of-N:** `SelectMany` flatten mọi Level xuyên Steps `:427-429` → `ElementAtOrDefault(order-1)` lấy **1 row** `:433` → so đúng **1** `ApproverUserId` `:439`. Comment trong chính file **tự thú**: *"Lite version: assume 1 step per workflow"*. Entity Proposal cũng **chỉ có `CurrentApprovalLevelOrder`, KHÔNG có `CurrentWorkflowStepIndex`**. ⇒ 2 người cùng Cấp (OR-of-N hợp lệ theo `ApprovalWorkflow.cs:81-94`) bị biến thành **2 nấc TUẦN TỰ (AND)**. Mà nhiều người cùng cấp **chính là thứ tờ FO-002.01 cần**. 🔴 **Service PHẢI copy `ContractWorkflowService.ApproveV2Async:217-394`** (con-trỏ ĐÔI StepIndex+LevelOrder · `GroupBy` Order `:246` · OR-of-N `:259-260` · `LevelOpinions` UPSERT `:292-316` · skipToFinal `:320-352`). **Proposal chỉ làm khuôn cho CRUD / DTO / menu / FE.** > ✅ *Lead đã tự đọc `ProposalFeatures.cs:420-445` xác minh — claim ĐÚNG, không phải suy diễn.* ### 2.2 Schema — Mig **69** `AddContractSigningPlans` · **7 CreateTable, 0 ALTER bảng cũ** Down = `DropTable ×7`, reversible sạch. Convention: PascalCase EN · **loose-Guid không FK vật lý** sang PE/Supplier (convention PE, ghi ở Mig 49) · FK vật lý chỉ nội bộ module + sang bảng V2 dùng chung. | # | Bảng | Cột chính | Ràng buộc | |---|---|---|---| | 1 | `ContractSigningPlans` (header) | `MaKeHoach?` · `PurchaseEvaluationId` (loose+IX) · `ProjectId` (denorm lọc) · `Phase int` · `ApprovalWorkflowId Guid?` · `CurrentWorkflowStepIndex int?` · `CurrentApprovalLevelOrder int?` · `DrafterUserId` · `SlaDeadline?` · `HoSoLink nvarchar(1000)?` · `GhiChu` | `AuditableEntity` + `HasQueryFilter(!IsDeleted)` (khuôn `PurchaseEvaluationConfiguration.cs:84`) | | 2 | `ContractSigningPlanLines` (giá per-winner) | `PlanId` FK Cascade · `SupplierId` loose · **`PeReferenceAmount` decimal(18,2) SNAPSHOT lúc tạo** · `ProposedAmount` · `ApprovedAmount?` (chỉ ghi tại finalize) · `Note` | UNIQUE filtered `(PlanId, SupplierId) WHERE IsDeleted=0` (gotcha #57) | | 3 | `ContractSigningPlanDossierItems` (căn cứ b.8-9) | `PlanId` FK Cascade · `Kind int` (MauVatLieu=1/Shopdrawing=2/Khac=99) · `SupplierId?` · `Name` · `Status int` (ChuaNop=0/DaNop=1/TvgsDuyet=2/TvgsBac=3) · `TvgsName?` · `TvgsResultAt?` · `Note` | IX (PlanId) | | 4 | `ContractSigningPlanLevelOpinions` | `PlanId` + `ApprovalWorkflowLevelId` · `Comment nvarchar(2000)` · `SignedAt` · `SignedByUserId` · `SignedByFullName` | UNIQUE (PlanId, LevelId); FK **Cascade Plan + Restrict Level** (mirror `ContractLevelOpinions` Mig 33) | | 5 | `ContractSigningPlanAttachments` | `PlanId` FK Cascade · FileName/Path/Size/ContentType · `Purpose int` · `DossierItemId?` | khuôn `ProposalAttachment.cs` | | 6 | `ContractSigningPlanChangelogs` | `PlanId` · **`EntityType`** · **`EntityId`** · Action · PhaseAtChange · UserId · **`UserName`** · Summary · **`FieldChangesJson` nvarchar(max)** · `ContextNote nvarchar(2000)` | **có từ ngày 1** — tiền lệ PE Mig 60/61. 🔴 **4 cột in đậm THÊM sau review** (lens-schema C1): thiếu chúng thì `LogTransitionAsync` copy sang **KHÔNG BIÊN DỊCH** (`ContractWorkflowService.cs:405` gọi `changelog.LogWorkflowTransitionAsync`) + mất index twin `(PlanId, EntityType)` | | 7 | 🆕 `ContractSigningPlanApprovals` (lịch sử transition) | `PlanId` FK Cascade · `FromPhase` · `ToPhase` · `Decision` · `Comment` · `ApprovalWorkflowLevelId?` · `ApprovedByUserId` · `ApprovedAt` | 🔴 **THÊM sau review** (lens-schema H1): service được lệnh copy **CÓ ghi vào bảng này** — `ContractWorkflowService.cs:151` `db.ContractApprovals.Add(...)`; twin PE `PurchaseEvaluation.cs:137` + index `(PeId, ApprovedAt)`. Thiếu ⇒ đoạn copy trỏ bảng KHÔNG TỒN TẠI | | ~~8~~ | ~~`ContractSigningPlanCodeSequences`~~ **CẮT** | — | 🔴 **GỠ sau review** (lens-schema T1): **TÁI DÙNG `WorkflowAppCodeSequence`** — bảng này **đã dùng chung cho 4 module Office**, phân biệt bằng Prefix namespaced (`"DT/LR/2026"` · `"DT/OT/2026"` · `"DX/XE/2026"` — `WorkflowAppCodeSequence.cs:8-11`). Prefix `"KHKK/{YYYY}"` nhét vào = **0 va chạm**. Tính atomic đến từ **transaction SERIALIZABLE trong CodeGen service**, KHÔNG từ việc bảng thuộc riêng module (`ContractCodeSequence.cs:5` nói rõ) ⇒ lập luận *"cắt = mất mã atomic"* của bản đầu **SAI, đã rút**. | ⇒ **Tổng vẫn 7 bảng** (thêm #7 Approvals, cắt CodeSequences). Down = `DropTable ×7`. **Biến thể tối thiểu 6 bảng** (bỏ #6 Changelogs) — **KHÔNG khuyến nghị**: Mig 60/61 chứng minh cần changelog để backfill. *(Bản đầu đề xuất "tối thiểu 5, cắt #6+#7" — nay #7 cũ đã cắt sẵn, và lý do giữ #6 vẫn đứng.)* **Cột chuỗi PHẢI khai độ dài** (lens-schema C3 — thiếu thì EF sinh `nvarchar(max)`, **không index được, không dùng được trong filtered-unique**): `MaKeHoach` 50 · `GhiChu` 2000 · `Name` 500 · `Note` 1000 · `TvgsName` 200 · `Summary` 1000 · `ContextNote` 2000 · `SignedByFullName` 200 **IsRequired** (khuôn `ContractLevelOpinionConfiguration.cs:21-22`) · `FileName` 500 / `Path` 1000 / `ContentType` 200. **+2 cột header THÊM sau review:** - **`DepartmentId Guid?`** (lens-schema C5) — twin `PurchaseEvaluation.cs:17`. Thiếu nó thì vế **`DeptManager`** của guard trình **không so được với gì** ⇒ *bất kỳ ai mang vai DeptManager trình được phiếu của phòng khác*. `ApprovalWorkflowStep.DepartmentId` chỉ là **hint của quy trình**, KHÔNG thay được phòng của phiếu. - **`ContractId Guid?` trên `Lines`** (lens-schema C6) — đường quay ngược Kế-hoạch → HĐ sau b.13; twin `PurchaseEvaluation.ContractId:31` + index. HĐ sinh **per-NCC**, `Lines` cũng **per-NCC** ⇒ đây là chỗ tự nhiên. - 🔸 **`SlaDeadline?` GIỮ nhưng khai rõ**: **FE hiển thị tham khảo, KHÔNG đăng ký `SlaExpiryJob`, KHÔNG có `SlaWarningSent`** (O-2 anh chốt SLA chỉ tham khảo). Khai vậy để nó **không** rơi vào lớp *"cột có mà 0 ai đọc"* — người đọc nó là FE, không phải job. **Enum:** - `ApprovalWorkflowApplicableType` **+`ContractSigningPlan = 10`** — append-only. ✅ *Lead verify: `ApprovalWorkflow.cs:53-67` dừng ở `TravelRequest = 9`, **slot 10 TRỐNG THẬT**.* - `ContractSigningPlanPhase` **MỚI**, giá trị sạch: `DangSoanThao=1 / ChoDuyet=2 / DaDuyet=3 / TraLai=98 / TuChoi=99`. - 🔴 **`DossierItemStatus` PHẢI bắt đầu từ 1** (vá lens-schema C2): `ChuaNop=1 / DaNop=2 / TvgsDuyet=3 / TvgsBac=4` — **KHÔNG dùng 0**. Mọi enum repo bắt đầu ở 1 (`Office/Enums.cs:9,:19,:31,:41,:50,:58` · `ApprovalWorkflow.cs:55`), và **0 là falsy trong JS** ⇒ `status ? A : B`, `if (filter.status)`, `?status=0` **rơi im lặng ở FE**. Giữ `Khac = 99` cho `Kind` (đúng khuôn `ItTicketCategory.Other = 99`). **KHÔNG copy 7/98 của PE** (sẹo lịch sử enum PE — phiếu mới không nợ data cũ); giữ 98/99 cho quen mắt FE badge. - 🔴 **KHÔNG thêm cột `SigningPlanId` lên PE** — tra ngược bằng `Plans.Where(PurchaseEvaluationId==x)`. Thêm cột = đẻ *"cột có mà 0 ai đọc"* (lớp lỗi đã cắn **6 lần** ở repo này). ### 2.3 CQRS + Service + Controller **`Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`** (mega-file, khuôn `ProposalFeatures.cs`): - `CreateContractSigningPlanCommand(PeId, ApprovalWorkflowId, GhiChu?)` — validator: PE tồn tại + `Phase==DaDuyet` (mirror cầu `src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:53-54` — 🔴 **đường dẫn đầy đủ, đã sửa**: file nằm ở `PurchaseEvaluations/` **KHÔNG phải `Contracts/`**; spec bản đầu để tên trần khiến reviewer đoán sai thư mục và trượt 1 lượt đọc — lens-claims điểm củng cố 3) + **chưa có plan sống** (`AnyAsync(p.PeId==x && p.Phase!=TuChoi)` → Conflict; cho tạo lại sau `TuChoi`) + workflow `ApplicableType==ContractSigningPlan` (mirror Proposal **`:258-267`** — 🔴 **mở rộng khoảng, đã sửa**: `:258-265` chỉ là phần **nạp** `wfType`; **guard thật nằm ở `:266-267`** `if (wfType.Value != (int)...) throw new ConflictException(...)`. Ai copy đúng `258-265` sẽ copy phần đọc mà **bỏ mất chính cái guard** — lens-claims S4). Handler **auto-sinh Lines từ winners**: `pe.Suppliers.Where(IsWinner)`, `PeReferenceAmount = SUM Quote.IsSelected` per winner (copy đúng phép tính cầu `:56-62`, `:88-90`) — **snapshot 1 lần**. - `UpdateContractSigningPlanDraftCommand` — guard `Phase ∈ {DangSoanThao, TraLai}` + **cho re-pin `ApprovalWorkflowId`** (mirror PE; tránh vết Contract UpdateDraft KHÔNG có đường re-pin). - `Get` / `List` / `pendingMe` — detail Include Lines+DossierItems+Attachments+LevelOpinions + workflow-tree (khuôn Proposal `:161-228`); 🔴 **inbox V2 precompute TỪ NGÀY 1** (mirror PE `ResolveV2InboxIdsAsync`) — bẫy đã cắn Contract: thiếu cái này ⇒ **approver không mở được phiếu đang chờ chính mình** (`ContractFeatures.cs:363-372` legacy-only, `grep ChoDuyet` = 0 hit). - `UpsertDossierItemCommand` / `DeleteDossierItemCommand` — chỉ mở ở `DangSoanThao|TraLai` (căn cứ là **input** của con số đang duyệt). Attachment mở mọi phase (triết lý PE S147: BE không phase-guard attachment). **`Infrastructure/Services/ContractSigningPlanWorkflowService.cs`** — copy `ApproveV2Async:217-394` (§2.1), adapt 3 chỗ: 1. Terminal → `Phase=DaDuyet` + **`ApplyApprovedValuesOnFinalize`**: mọi Line `ApprovedAmount ??= ProposedAmount` tại **choke-point duy nhất** + RULE grep-4-site (khuôn `PurchaseEvaluationWorkflowService.cs:1008`). 2. Port 2 nhánh kết thúc sớm: `AllowApproverFinalize` `:870-890` + `finalizeByCcmDelegation`+`CeoApprovalThreshold` `:892-929` (tổng so ngưỡng = `SUM ProposedAmount`). 3. Reject → 4 return-mode per-level (cờ sẵn tại **`Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:116-125`** — 🔴 **anchor đã sửa**: bản đầu ghi `ApprovalWorkflowLevel.cs` nhưng **KHÔNG CÓ FILE NÀO TÊN VẬY** (Glob 0 hit; class `ApprovalWorkflowLevel` nằm TRONG `ApprovalWorkflow.cs:95`) — lens-claims S2, mức MAJOR vì implementer mở theo spec sẽ **file-not-found**; logic mirror PE `ApplyReturnModeAsync:368-547`) — **tối thiểu `TraLai` toàn phần** nếu muốn gọn wave đầu. 🔴 **Guard trình = `CreatedBy==actor ∨ DeptManager ∨ Admin`** — **CỐ Ý KHÁC** khuôn role-based (`ContractWorkflowService.cs:70-79` đòi `Drafter|DeptManager`) để **né lớp 403-PMH**: PMH mang role `Procurement` nên bị chặn ở b.13/b.17 — đúng lớp *"cơ-chế đúng, vai đi qua không có"*. Nếu owner muốn đồng nhất role-based thì BCH users **phải được gán role `Drafter`**. 🔴 **Notify:** mirror `LogTransitionAsync` báo Drafter (`ContractWorkflowService.cs:407-427`) **+ THÊM notify đích danh approver của Cấp kế** — fix lỗ *notify-chỉ-Drafter* **ngay từ ngày 1**, đừng chép nợ. **`Api/Controllers/ContractSigningPlansController.cs`** — 🔴 **authz 2 TẦNG** (bài học #82 / S118): class `[Authorize]` **+ per-action** `[Authorize(Policy = "ContractSigningPlans.{Read|Create|Update|Delete}")]`. **KHÔNG chép kiểu `ContractsController.cs:12-13` class-trần** (đó chính là lỗ đang treo). Policy TỰ SINH khi key vào `MenuKeys.All` (`Program.cs:82-89` foreach `All × Actions`). Endpoints: `GET` list/inbox · `GET {id}` · `POST` · `PUT {id}` · `POST {id}/transitions` · dossier-items CRUD · attachments · `DELETE {id}` (allow-list `DangSoanThao|TuChoi`, mirror PE `:1404`). ### 2.4 Menu + Permission + FE 2 app (Pattern 16-bis 4-place mirror) - 🔴🔴 **BE `MenuKeys.cs` — TÊN KEY PHẢI TRÙNG TIỀN-TỐ POLICY** (vá lỗi CHÍ MẠNG review lens-wave H2 bắt): +4 const **`ContractSigningPlans`** / `ContractSigningPlans_List` / `_Create` / `_Inbox` **VÀ đưa CẢ 4 vào mảng `All`**. Policy controller = **`ContractSigningPlans.{Action}`** ⇒ **khớp key**. ⚠️ **Bản đầu spec đặt key `Csp*` mà policy `ContractSigningPlans.*` ⇒ LỆCH ⇒ `Program.cs` KHÔNG sinh policy ⇒ `[Authorize(Policy=...)]` trỏ policy không tồn tại ⇒ **HTTP 500 lúc chạy, KHÔNG phải 403**. Repo ĐÃ CÓ lưới bắt: `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs:138-139` ghi nguyên văn *"policy trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy"* — lead tự đọc xác nhận test tồn tại. 🔑 **Bài học:** §2.4 bản đầu nhận diện ĐÚNG bẫy `Pe_*`, rồi §2.3 vẫn tự vấp đúng bẫy đó bằng cơ chế khác (lệch TÊN thay vì factory-ngoài-All) — *bài học nằm trong tay vẫn không tự bật*. ✅ *Lead verify `MenuKeys.cs:168`* (**anchor đã sửa từ `:171` — `:171` là `Personal,`**, review lens-claims S1): `OffDeXuat`+3 con **đều trong `All`**; còn `Pe_*` sinh bằng **factory** `:150`/`:154` **NẰM NGOÀI `All`**. **Theo khuôn `Off_DeXuat`, KHÔNG theo `Pe_*`.** ⇒ 2 row canonical `docs/STATUS.md` (Menu keys · Policies = |All|×4) phải bump cùng lúc. - **Seed `DbInitializer.cs`:** 4 dòng menu (khuôn Off_DeXuat `:1817-1820`, label VN *"Đề xuất ký kết hợp đồng"* — 🔄 nhãn owner chốt @S159, thay "Kế hoạch ký kết HĐ" / *"Danh sách"* / *"Tạo mới"* / *"Inbox duyệt"*) + admin-permission (`:2383` — **grep MỌI site seed-permission**, S155 ghi nhận có 2 site cho vài nhóm). 🔴 **GIẤU MENU Ở W1 = KHÔNG CẤP `CanRead` cho role thường** — **KHÔNG** phải `IsVisible=0`. *(vá H6 @S159. Bản S157 viết ở đây *"Dark-launch được bằng `IsVisible=0`"* trong khi §③ WAVE 1 `:288-291` **cùng file** đã đo đĩa và BÁC đúng cơ chế đó — `GetMyMenuTreeQuery.cs:96-100` lọc **chỉ theo `CanRead`**, `IsVisible` chỉ **pass-through** `:88`, `MenuDtos.cs:13` ghi thẳng "fe-admin vẫn thấy". Bản sai nằm **thượng nguồn** bản đúng trong dòng đọc của người thi công ⇒ xếp HIGH, không phải nit.)* - **FE ×2 app** (duplicate CÓ CHỦ ĐÍCH), mỗi app 4 chỗ: `App.tsx` routes · `Layout.tsx` staticMap · `lib/menuKeys.ts` · `pages/` ×3 (khuôn bộ Proposal ~978 LOC/app: List 252 + Create 245 + Detail 386 + types 95). Detail thêm: bảng **Lines** (đề xuất/chốt per-NCC) · bảng **DossierItems** (status chip + TVGS + scan) · **panel workflow** (mirror PE Panel-3 ✓/●/○) · **banner "Đến lượt bạn"** (khuôn PE `blockedByV2Level`). - **Designer admin nhận type 10:** fe-admin `ApprovalWorkflowsV2Page.tsx` map typeCode→int `:143` + các site BE `ApprovalWorkflowV2AdminFeatures.cs`. ⚠️ **Invest tự khai KHÔNG verify được** path `fe-user` matrix-view (`WorkflowMatrixViewPage.tsx` grep 0 hit) ⇒ **PHẢI grep lại lúc làm**, đừng tin line cũ. ### 2.5 Cầu tiếp giáp b.12→13 (sửa 1 file) — ⚠️ chạm module Contract `CreateContractFromEvaluationFeatures.cs`: (a) đọc plan `DaDuyet` của PE → có thì `giaTri = line.ApprovedAmount` khớp `w.SupplierId` **thay** `SUM` `:88-90`, ghi **ContextNote lệch** *"giá KH x vs SUM-PE y"* vào changelog `:121-140` (khuôn audit-note D4 S134); không có plan → giữ `SUM` + **cảnh báo mềm** (**Q11** owner nâng cứng sau). (b) `+param ApprovalWorkflowId?` **pin V2 cho HĐ** — hiện pin V1-only `:68-71`,`:108` ⇒ HĐ V2-less trình xong **KẸT CỨNG** `ConflictException` `ContractWorkflowService.cs:115-116`. 🔴 **Đây là "chỗ hợp đồng"** — anh chốt *"cứ từ từ"* ⇒ **wave này phải hỏi lại anh trước khi chạy**. --- ## ③ CHECKLIST — CHIA WAVE (lead viết) **Nguyên tắc chia:** mỗi wave **deploy độc lập được**, không để nửa tính năng chết trên prod (bài học PE nút-xóa S155: §F.0 chứng minh đợt-1-một-mình vô dụng ⇒ phải chia theo *"đẩy riêng thì người dùng thấy gì"*). | Wave | Tên | Chặn bởi owner? | Deploy riêng được? | |---|---|---|---| | **W1** | Schema + permission (**giấu bằng KHÔNG cấp `CanRead`**) | ❌ không | ✅ có (vô hình với user thường) | | **W2** | CRUD phiếu nháp + căn cứ b.8-9 | ❌ không | ✅ có (bật menu, tạo được phiếu) | | **W3** | Duyệt 3 trạm + finalize chốt giá | ⚠️ **Q3** (chọn cơ chế rẽ b.12) — nhưng chạy được với mặc định | ✅ có (phiếu chạy trọn vòng) | | **W4** | Gate + checklist per-phòng | ✅ **O-Q1, O-Q2** | ✅ có (siết dần) | | **W5** | Cầu sang Hợp đồng | ✅ **Q6, Q11 + anh cho phép chạm HĐ** | ✅ có | --- ### WAVE 1 — Schema + permission + **Designer type-10** (vá sau review) - **Mục tiêu:** đặt nền DB + quyền **VÀ** cho admin tạo được quy trình duyệt `type=10`. 🔴 **Designer KÉO TỪ W2 SANG ĐÂY** (vá lens-wave H1): lệnh `Create` phiếu **đòi** workflow `ApplicableType=10` tồn tại; wiring Designer type-10 **không nằm trong file-list wave nào** ở bản đầu ⇒ **W2 đẩy riêng tạo được 0 phiếu** = tái phạm đúng §F.0 của S155 (đợt-1-một-mình vô dụng). Kéo sang đây còn biến W1 từ *0-giá-trị* thành **UAT được** (admin dựng quy trình 3 bước PMH→CCM→CEO). - **Vai:** `implementer-backend` · design-review `database-agent` trước khi apply migration. - **File:** 7 entity + 2 enum (`Phase`, `DossierItemStatus`) + 7 EF Configuration + DbSets ×2 interface + Mig 69 (3-file rule) + `ApprovalWorkflow.cs` enum **+slot 10** + `MenuKeys.cs` +4 const **vào `All`** + `DbInitializer` menu 4 dòng + admin-permission + **fe-admin `ApprovalWorkflowsV2Page.tsx` map typeCode→10** + 🔴 **`ApprovalWorkflowV2AdminFeatures.cs` 3 SITE liệt-kê-thủ-công** (vá lens-schema H2): `:911`+`:929` merge "chữ ký còn sống" · `:971`+`:992` `RetainedOpinionRef` · `:1044`+`:1056` dọn chữ ký MỒ CÔI. **Thiếu 3 site này ⇒ admin sửa quy trình type-10 KHÔNG thấy chữ ký phiếu KHKK, xoá Level đang có chữ ký → FK Restrict nổ 500** — đúng lớp lỗi S155 vừa vá xong. - **Acceptance ĐO ĐƯỢC:** 1. `dotnet ef migrations list` → migration cuối cùng có tên chứa **`AddContractSigningPlans`**. *(🔴 sửa: bản đầu viết `69_AddContractSigningPlans` — EF in `20260728xxxxxx_AddContractSigningPlans`, **không có tiền tố `69_`** ⇒ literal cũ KHÔNG BAO GIỜ khớp. lens-wave checklistEvidence #1.)* 2. `sqlcmd -d SolutionErp -Q "SELECT COUNT(*) FROM sys.tables WHERE is_ms_shipped=0"` → **89 → 96**. *(🔴 sửa: thêm `is_ms_shipped=0` + DB đích — mốc 89 canonical đo bằng đúng vị ngữ này.)* 3. 🔴 **`dotnet test` → `AuthorizePolicyRegressionTests` XANH** *(thay dòng "sqlcmd đếm policy" — lens-wave H3: policy đăng ký **in-memory trong `Program.cs`**, KHÔNG nằm trong DB ⇒ phép cũ **không chạy được**)*. Test này chính là thứ bắt lỗi tên-key-lệch-policy ở §2.4. Số học **+16 policy** (54→58 key × 4) vẫn đúng. 4. 🔴 **`dotnet test SolutionErp.slnx` → `562 + N`, `N ≥ 2`, 0 fail — VÀ 2 test dưới đây PHẢI tồn tại đúng tên:** `AuthorizePolicyRegressionTests` **phủ 4 key `Ct_KeHoachKyKet_*` mới** (đã nêu ở acceptance #3) · `ContractSigningPlanSchemaTests.BayBang_MoiBang_CoAuditFields_VaQueryFilter`. *(🔴 "562 giữ nguyên" của bản đầu = **0 bit**, không falsify được gì — lens-wave H5. 🔴 **Vá lần 2 @S159:** bản S157 sửa thành "562 + số test mới" nhưng **vẫn để `N` thả tự do** ⇒ `N=0` vẫn PASS ⇒ **chưa đóng lỗ**. Đây là ca **vá-một-nửa cùng lớp** với `:353` — grep `562` ra đúng 2 site, S157 chỉ chạm 1.)* 5. Admin tạo được 1 quy trình `type=10` 3 Bước, **pin `IsUserSelectable`**; xoá 1 Level chưa có chữ ký → OK. 6. `dotnet ef migrations script