# 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 ` + **script Down chạy sạch trên DB copy** (reversible thật). - 🔴 **BỎ tiêu chí "menu không hiện" của bản đầu** (lens-wave H6 — đo đĩa bác): `GetMyMenuTreeQuery.cs:96-100` filter **CHỈ theo `CanRead`**, `IsVisible` chỉ **pass-through** `:88`; `MenuDtos.cs:13` ghi thẳng *"fe-admin vẫn thấy"* ⇒ `IsVisible=0` **KHÔNG phải cơ chế dark-launch toàn cục**, chỉ tối với fe-user. Cách đúng để giấu: **không cấp `CanRead`** cho role thường ở W1. - **Rủi ro:** seed-permission có **>1 site** (S155) ⇒ grep hết. · Slot enum 10 **re-verify ngay trước khi viết**. · 🔸 W1 **đóng băng** format mã + số bảng vào Mig 69 ⇒ *"không chặn"* chỉ đúng ở nghĩa **không chờ câu trả lời**, **không** đúng ở nghĩa *không có quyết định nào bị khoá* (lens-wave M1). Owner nên liếc format `KHKK/{YYYY}/{Seq:D3}` trước khi W1 chạy. ### WAVE 2 — CRUD phiếu nháp + căn cứ b.8-9 (bật menu) - **Mục tiêu:** BCH **tạo được phiếu nháp**, điền dòng giá đề xuất + danh mục căn cứ, **chưa trình được**. - **Vai:** `implementer-backend` (Features + Controller) · `implementer-frontend` (3 page × 2 app) · `test-specialist` (test-after). - **File:** `ContractSigningPlanFeatures.cs` (Create/Update/Get/List + DossierItem CRUD + attachment) · `ContractSigningPlansController.cs` (**2-tầng authz**) · FE ×2 app 4 chỗ · 🔴 `DbInitializer` **CẤP `CanRead`** cho role đích (BCH/PRO/CCM) — **KHÔNG** phải "bật `IsVisible=1`". *(vá H6 site-4 @S159 — **site này lọt cả 3 vòng trước**: HANDOFF đếm H6 = 1 site, lead @S159 đếm 3, đo lại ra **4**. Lọt vì grep `IsVisible` CÓ trả về dòng này nhưng nó nằm ở W2 và trông như bước dữ-liệu vô hại; chỉ khi hỏi *"W1 đã không set `IsVisible=0` thì `bật IsVisible=1` ở W2 còn nghĩa gì?"* mới lộ. ⇒ **grep tìm được CHUỖI, chỉ mô-hình nhân-quả tìm được HỆ-QUẢ** — bài học bổ sung cho luật grep-cùng-lớp S122.)* - **Acceptance ĐO ĐƯỢC:** 1. `POST /api/contract-signing-plans` từ PE `DaDuyet` → **201**, response có `id`; DB có **N Lines = số winner** của PE đó (`sqlcmd` đếm đối chiếu `PurchaseEvaluationSuppliers WHERE IsWinner=1`). 2. `PeReferenceAmount` mỗi Line == `SUM(Quotes WHERE IsSelected)` của đúng NCC đó (`sqlcmd` 2 câu, so khớp). 3. Tạo lần 2 trên **cùng PE** → **409 Conflict**. 4. Tạo từ PE **chưa `DaDuyet`** → **400/409** (không phải 500). 5. 🔴 **Authz 2 tầng:** user thiếu quyền gọi `POST` → **403** (không phải 200/401/**500**). User có quyền → 201. *(Phép chặn lỗ #82 tái phát — bắt buộc chạy.)* **+ gate rẻ bổ sung** (lens-claims điểm củng cố 2): `grep -c "Authorize(Policy" ContractSigningPlansController.cs` **≥ 4**. Đối chứng: cùng lệnh trên `ContractsController.cs` = **0 hit toàn file** ⇒ mọi endpoint HĐ chỉ cần "đăng nhập bất kỳ". 6. FE 2 app: `npm run build` PASS ×2 · SHA256 3 file mirror **IDENTICAL**. 7. Menu hiện đúng 4 mục tiếng Việt trên cả 2 app. 8. 🔴 **`dotnet test` phải có ≥1 test MỚI cho W2** (lens-wave H5: bản đầu W2 **không có dòng test nào** dù W2 đẻ controller + policy). Tối thiểu: 1 test authz-regression cho controller mới + 1 test guard `Phase!=DaDuyet`. 9. 🔸 **`DELETE` (lens-wave M6):** W2 mở `DELETE` ở `DangSoanThao` **ngay từ wave này** — nếu để tới W3 thì guard *"chưa có plan sống"* (409) **khoá cứng PE**: tạo nhầm 1 phiếu là không tạo lại được cho PE đó. - **Rủi ro:** quên `HasQueryFilter(!IsDeleted)` ⇒ phiếu xóa mềm vẫn hiện. · UNIQUE filtered thiếu `WHERE IsDeleted=0` ⇒ tạo lại sau xóa bị chặn oan (gotcha #57). ### WAVE 3 — Duyệt 3 trạm + finalize chốt giá 🔴 **wave nặng nhất** - **Mục tiêu:** phiếu chạy trọn `DangSoanThao → ChoDuyet → DaDuyet`, chốt `ApprovedAmount`. - **Vai:** `implementer-backend` (service) · `implementer-frontend` (panel + banner) · `test-specialist` (**test-before cho OR-of-N** — đây là critical algorithm) · `reviewer` (adversarial trước commit). - **File:** `ContractSigningPlanWorkflowService.cs` (copy `ApproveV2Async:217-394`) · `transitions` endpoint · inbox V2 precompute · notify · FE workflow panel + banner "Đến lượt bạn". - **Acceptance ĐO ĐƯỢC:** 1. 🔴 **OR-of-N còn sống — 4 vế, THIẾU VẾ NÀO LÀ 0 BIT** *(vá lens-wave H4: bản đầu chỉ thử 1 người ⇒ **trúng-may dưới đúng bug `ElementAtOrDefault`**, và service **bỏ hẳn guard** cũng PASS)*. Workflow có **1 Cấp 2 người (A, B)**: (a) **2 phiếu song sinh**: phiếu-1 để **A** duyệt → tiến; phiếu-2 để **B** duyệt → **cũng tiến** (chứng OR thật, không phải trúng thứ tự); (b) **ca âm**: người **ngoài cấp** duyệt → **403** (chứng guard còn sống); (c) `SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions WHERE PlanId=@id` = **1** (không đúp); (d) phiếu **KHÔNG** đòi cả A và B cùng ký. Test unit + 1 lượt UAT thật. 2. **Con-trỏ đôi:** workflow **3 Bước** → sau mỗi lượt duyệt, đọc `sqlcmd` 2 cột: `CurrentWorkflowStepIndex` đi **1→2→3**, `CurrentApprovalLevelOrder` **reset về 1 mỗi khi sang Bước mới**. *(🔴 sửa: "2 con trỏ đều đổi" của bản đầu sai nghĩa đen — trong 1 Bước nhiều Cấp thì StepIndex **không** đổi; lens-wave M7.)* 3. **Finalize choke-point:** duyệt cấp cuối → **mọi** Line của **đúng phiếu đó** có `ApprovedAmount != NULL`: `SELECT COUNT(*) FROM ContractSigningPlanLines WHERE PlanId=@id AND ApprovedAmount IS NULL` = **0**. *(🔴 sửa: bản đầu thiếu `AND PlanId=@id` ⇒ đếm toàn bảng ⇒ **FAIL giả vĩnh viễn** ngay khi có phiếu nháp thứ hai tồn tại; lens-wave M7.)* Test cả 3 đường tới `DaDuyet` — **grep 4-site RULE**. ⚠️ **Mâu thuẫn cần anh gỡ** (lens-wave M2 + M3): acceptance này **ép đủ 3 đường**, nhưng bảng wave khai W3 *"chạy được với mặc định"*; **và** 2 trong 3 đường (`AllowApproverFinalize` · `CeoApprovalThreshold`) từng bị anh phán **"PHÁ VỠ"** ở S155 (owner-decision 10/11). ⇒ **W3 mặc định chỉ làm đường THƯỜNG**; 2 đường kia **chờ anh xác nhận còn muốn không**. 4. **Inbox:** approver Cấp đang chờ gọi `GET /inbox` → **thấy phiếu**; approver cấp khác → **không thấy**. *(Phép chặn đúng lỗ Contract đang mắc.)* 5. `LevelOpinions` UPSERT: duyệt 2 lần cùng cấp → DB **1 row** (không đúp), `SignedByUserId` đúng người thật. 6. Trả lại → `TraLai`, sửa rồi gửi lại chạy **từ Cấp 1 Bước 1**. 7. Notify: approver Cấp kế **nhận** thông báo (không chỉ Drafter). 8. 🔴 **`dotnet test` → `562 + N`, `N ≥ 6`, 0 fail — VÀ 6 test dưới đây PHẢI tồn tại đúng tên** *(vá H5 @S159; bản S157 để `N` thả tự do ⇒ **`N=0` vẫn PASS** ⇒ acceptance 0 bit, đúng lỗ reviewer chỉ)*. Sàn 6 test = ánh xạ 1:1 với acceptance **#1–#6 ngay trên**, class `ContractSigningPlanApprovalTests` (`tests/SolutionErp.Infrastructure.Tests/Application/`): | # | Tên test BẮT BUỘC | Chốt acceptance | |---|---|---| | 1 | `OrOfN_MotNguoiTrongCap_DuyetDuoc_KhongDoiDuCaHaiKy` | #1 (d) — ca ÂM: phiếu KHÔNG đòi cả A và B | | 2 | `ConTroDoi_SangBuocMoi_LevelOrder_ResetVe1` | #2 — StepIndex 1→2→3, LevelOrder reset | | 3 | `Finalize_MoiLineCuaDungPhieuDo_CoApprovedAmount` | #3 — `AND PlanId=@id`, chống FAIL-giả toàn bảng | | 4 | `Inbox_ChiApproverCapDangCho_ThayPhieu` | #4 — ca ÂM: cấp khác KHÔNG thấy | | 5 | `LevelOpinions_DuyetHaiLanCungCap_ChiMotRow` | #5 — UPSERT + `SignedByUserId` đúng người thật | | 6 | `TraLai_GuiLai_ChayLaiTuCap1Buoc1` | #6 — vòng trả-lại | 🔸 #7 (notify approver cấp kế) **không tính vào sàn 6** — cần hạ tầng thông báo, để test-after. 🔴 **Sàn là SÀN, không phải trần:** `N > 6` là bình thường và tốt; `N < 6` **hoặc thiếu 1 tên** ⇒ **W3 TRƯỢT**. - **Rủi ro:** 🔴 **copy nhầm Proposal** ⇒ mất OR-of-N **âm thầm** (build sạch, test cũ vẫn xanh) — acceptance #1 là lưới duy nhất bắt được. · Quên inbox precompute ⇒ lặp lại đúng lỗi Contract. ### WAVE 4 — Gate trình + checklist per-phòng ✅ **CHẶN: O-Q1, O-Q2** - **Mục tiêu:** siết chất lượng đầu vào theo ý anh. - **Chờ anh:** O-Q1 căn cứ b.8 chưa đủ `TvgsDuyet` → **cảnh báo mềm hay chặn cứng**? · O-Q2 checkbox FO-002.01 (PRO: điều khoản/thanh toán/rủi ro pháp lý · CCM: giá vs NS) → **số hoá thành checklist** hay giữ **comment tự do**? - **Acceptance (khi có câu trả lời):** trình thiếu căn cứ → đúng hành vi anh chọn (toast cảnh báo **hoặc** 409); checklist (nếu số hoá) lưu + hiện lại đúng trên phiếu đã duyệt. ### WAVE 5 — Cầu sang Hợp đồng ✅ **CHẶN: Q6, Q11 + anh cho phép chạm HĐ** - 🔴 **Wave này chạm module Contract — anh đã chốt *"chỗ hợp đồng cứ từ từ"*. KHÔNG tự chạy.** - **Chờ anh:** Q6 giá vào HĐ = `ApprovedAmount` của KH (mặc định thiết kế: **KH thắng** + audit lệch) hay vẫn `SUM`-PE? · Q11 chưa có KH duyệt thì tạo HĐ = **chặn** hay **cảnh báo**? - **Acceptance:** HĐ tạo từ PE có KH `DaDuyet` → `GiaTri == SUM(ApprovedAmount)`; changelog có ContextNote lệch; HĐ pin được workflow V2 (không còn `ConflictException` kẹt cứng). --- ### Ngoài mọi wave (ghi nhận, KHÔNG làm trong run này) - **SLA** — O-2: chỉ hiển thị. `AddDays(7)` hardcode vẫn sai nhưng **không ai enforce** ⇒ không đáng 1 wave. - **Lỗ hổng an ninh HĐ** (`ContractWorkflowService.cs:48-66` Reject-trước-guard + controller class-trần) — **vẫn SỐNG trên prod**, anh chốt *"từ từ"*. Nhắc đúng 1 dòng cho khỏi quên. - **FO-002.01 auto-pass** (*"quá hạn xem như đã thông qua"*) — mâu thuẫn mô hình duyệt-chặn V2. O-2 ⇒ ghi nhận. --- ## ④ §D — HẤP THỤ 6 việc sửa của `review-synthesis.md` §G (S156) | # | Việc review yêu cầu | Xử lý trong spec này | |---|---|---| | 1 | Claim SAI *"hardcoded policy fallback"* → thật là `ConflictException:115-116` (HĐ **kẹt cứng** `ChoDuyet`) | ✅ **Đã sửa** — §2.5(b) ghi đúng: pin V1-only ⇒ HĐ V2-less **KẸT CỨNG**, hỏng **cứng** không degrade êm. Lead tự xác minh trên đĩa. | | 2 | Siết biên `b.14→19`, không phải `b.13→21` | ✅ Spec này **không nhắc lại biên cũ**; phạm vi khai rõ = **b.7→12**, khúc 13→21 để nguyên verdict `LAI` S156. | | 3 | Thiếu Q **vai người TRÌNH** (PMH `Procurement` bị 403 `:70-79`) | ✅ **Đã xử ở thiết kế**, không đẩy thành câu hỏi: §2.3 guard trình = `CreatedBy ∨ DeptManager ∨ Admin` **cố ý khác** khuôn role-based, kèm lối thoát nếu owner muốn đồng nhất. | | 4 | Thêm cảnh báo lỗ hổng reject — *việc RIÊNG* | ✅ Giữ đúng 1 dòng ở "Ngoài mọi wave"; **không nhét vào wave** (anh chốt *"từ từ"*). | | 5 | Q5 thiếu phương án **(d) `skipToFinal`** đã wire | ✅ Có mặt — §1.5 liệt **4 đường rẽ b.12**, trong đó (iii) `AllowApproverSkipToFinal` `Contract:320-352`. | | 6 | Viết lại Q2 theo **2 trục + 3 số đo cùng đơn vị từ twin thật** | ⚠️ **Trục cơ-chế ĐÓNG bởi O-1** (anh chốt tái dùng khung) ⇒ chỉ còn **trục NỘI DUNG**. Bảng dưới. | ### Trục NỘI DUNG — 3 nhánh, 3 số cùng đơn vị > Số twin **đo thật**: `Proposal` = **4 bảng · 849 LOC BE · 1.956 LOC FE** (invest `wc`/`grep` trực tiếp). > Số cho từng nhánh = **ngoại suy** từ twin đó — khai rõ đâu là đo, đâu là ước. | Nhánh | Bảng mới | LOC BE (ước) | LOC FE (ước) | Đánh giá | |---|---|---|---|---| | **(A) Phiếu độc lập** ⬅ *spec này chọn* | **7** (5 nếu cắt) | ~1.100–1.400 | ~2.000–2.400 | Vòng đời riêng, xóa/soft-delete riêng, workflow riêng type 10. **Đắt nhất về bảng, rẻ nhất về rủi ro.** | | **(B) Mở rộng PE** (thêm cột + bảng con vào phiếu PE) | ~3 | ~600–800 | ~900–1.200 | Rẻ hơn, **nhưng**: PE `DaDuyet` là **terminal thành công** — mở lại vòng duyệt thứ 2 trên phiếu đã đóng ⇒ phá bất-biến PE, đụng cả `budgetFrozen` snapshot Mig 67. **Rủi ro cao nhất.** | | **(C) Entity con của Contract** | ~4 | ~700–900 | ~1.100–1.400 | Kéo khúc 7→12 vào module HĐ — mà anh vừa chốt **"chỗ hợp đồng cứ từ từ"**; đồng thời HĐ chưa tồn tại ở b.7-12 (HĐ mới sinh ở b.13) ⇒ **sai thứ tự nghiệp vụ**. | **Lý do chọn (A)** — không phải vì rẻ, mà vì: nó là **PHIẾU** theo đúng phát biểu mục đích của anh (có đối tượng được duyệt + 3 trạm + người chốt); (B) phá bất-biến của PE đang chạy ổn; (C) sai thứ tự nghiệp vụ và chạm vùng anh vừa yêu cầu để yên. **Nếu owner ưu tiên cắt chi phí thì cắt trong (A)** (7→5 bảng), đừng đổi sang (B)/(C). --- --- ## ⑤ KẾT QUẢ REVIEW `/fable-clone reviewer` — 4 lăng kính, run `wf_57af0163-053` **4/4 lane sạch, 0 chết, 0 rỗng** (đóng gói C2 ăn: ≤3 file/lane + ép khung rỗng lượt 1-2 + trần 25). | Lăng kính | Verdict | Điểm | |---|---|---| | lens-wave (wave-plan + acceptance) | `SUA-TRUOC-W1` | 19 (6H · 8M · 5L) + 10 củng cố | | lens-fidelity (trung thực nguồn) | `GO-WITH-FIXES` | 23 (3 CAO · 8 TRUNG · 12 THẤP) + 8 củng cố | | lens-schema (7 bảng) | `GO-WITH-FIXES` (5 HIGH chặn W1) | 22 (5H · 7M · 3MED · 7 củng cố) | | lens-claims (verify `file:line`) | `SPEC §② VỮNG` | **48 claim: 39Đ / 4S / 5KKĐ** | ### 🔑 Claim NỀN của spec ĐỨNG VỮNG lens-fidelity đọc lại `src-QT-TRINH-KY-HD.txt` và **XÁC NHẬN**: bảng 2 ISO = tiêu đề + **đúng 9 dòng bước** (`:59-68`), khớp **9/9 cả tên trạm lẫn thứ tự** với `ContractPhase`; *"đi thẳng 1→2"* đúng theo nghĩa hẹp (không dòng nào giữa `:60` và `:61`). **Spec không sập.** ### 🔴 Đã VÁ trong bản này (7 lỗi nặng) 1. **Tên key ≠ tiền tố policy ⇒ HTTP 500** — `Csp*` vs `ContractSigningPlans.*`. Repo có sẵn test bắt (`AuthorizePolicyRegressionTests.cs:138-139`). **Đây là lỗi tự-vấp-bẫy-mình-vừa-nhận-diện.** → §2.4 2. **W2 đẩy riêng tạo được 0 phiếu** — wiring Designer `type=10` rơi khỏi mọi wave → **kéo vào W1** 3. **Thiếu bảng `Approvals`** — service được lệnh copy CÓ ghi vào (`ContractWorkflowService.cs:151`) → thêm #7 4. **Changelogs thiếu 4 cột** (`EntityType`/`EntityId`/`UserName`/`FieldChangesJson`) ⇒ copy service **không biên dịch** 5. **3 site `ApprovalWorkflowV2AdminFeatures.cs`** chưa nêu ⇒ admin xoá Level có chữ ký → **FK Restrict nổ 500** 6. **`Status` bắt đầu từ 0** ⇒ bẫy falsy JS → đổi sang 1 7. **4 anchor lệch** — nặng nhất `ApprovalWorkflowLevel.cs` **KHÔNG TỒN TẠI** (class nằm trong `ApprovalWorkflow.cs:95`) Kèm: bỏ tiêu chí dark-launch sai cơ chế *(🔴 **đính chính @S159:** @S157 chỉ vá **1/3 site** — `:288-291` sửa đúng, còn `:224` và bảng wave `:252` **vẫn dạy cơ chế sai**, tức spec tự mâu thuẫn suốt từ S157. Vá nốt 2 site còn lại @S159. Đây là ca **"vá một chỗ, sót chỗ cùng lớp"** — bài học S122: được-chỉ-1-chỗ ⇒ PHẢI grep cùng-lớp)* · thay "sqlcmd đếm policy" (không đo được) bằng test regression · OR-of-N nâng từ 1 vế lên **4 vế có ca âm** · sửa 2 phép đo sai phạm vi · thêm độ dài 13 cột chuỗi · thêm `DepartmentId` + `ContractId` · cắt `CodeSequences` (tái dùng `WorkflowAppCodeSequence`). ### ⚠️ CÒN MỞ — cần anh hoặc cần đo thêm - **Nguồn thứ 4 chưa ai đọc:** ISO bước 1 trỏ ra **`SOL-PRO-SP-001 Quy trình Cung ứng`** (`:47`, `:60`). ⇒ Câu *"khúc này KHÔNG CÓ trong quy trình ISO"* nên **hạ phạm vi** thành *"không có trong QT trình ký"* trước khi trình anh với tư cách đề xuất sửa ISO. **Anh có file này không?** - **W3 hai đường kết thúc sớm** (`AllowApproverFinalize` · `CeoApprovalThreshold`) — anh từng phán **"PHÁ VỠ"** ở S155. Giữ hay bỏ? - **5 câu owner** (O-Q1 · O-Q2 · Q3 · Q6 · Q11) vẫn nguyên. - **5 claim chưa kiểm được** (lens-claims KKĐ): ranh CUỐI của 6 khoảng dòng · `WorkflowMatrixViewPage.tsx` (spec đã tự gắn cờ "grep lại lúc làm" — **giữ cờ**) · mệnh đề *"HĐ V2-less kẹt cứng"* **đúng CÓ ĐIỀU KIỆN** (phụ thuộc DB prod có `WorkflowDefinitions` active + step cho `ContractType` đó không — **chưa đo DB**). - **Gate rẻ reviewer đề nghị, lead nhận:** trước mỗi wave, **mở 5 anchor bất kỳ** của wave đó; ≥1 anchor trượt ⇒ chấm lại toàn bộ anchor của wave. *(Anchor drift lan từ MÃ sang SPEC: comment `ApprovalWorkflow.cs:161` ghi lệch 1 dòng, spec chép y nguyên.)*