# 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)

> 🔴 **NOTE @S160 (sau ARC-2 S159 — tiền-đề menu của W1 ĐỔI, đọc TRƯỚC khi thi công):**
> 1. **Menu skeleton GĐ2 ĐÃ TỒN TẠI trên prod** (arc-2 đợt-3+5, commit `2a72695`/`ea962b1`): root **`Khkk_G1`** "1. Kế hoạch ký kết HĐ (NCC-TP)" + **6 leaf** `Khkk_{WfView,List,Create,Pending,Approved,Deleted}` — seed tại `DbInitializer.cs:1779-1786`, leaf trỏ `/coming-soon`, **CanRead 13/13 role** (grant-list `:2179`). ⇒ **W1 TÁI DÙNG bộ `Khkk_*` này** — CẤM đẻ bộ key mới cùng nghĩa (`Ct_KeHoachKyKet_*` của bản spec gốc); mục "`MenuKeys.cs` +4 const + `DbInitializer` menu 4 dòng" ở **File:** dưới coi như ĐÃ LÀM MỘT PHẦN, việc còn lại ở lớp menu = re-point leaf khỏi `/coming-soon` sang trang thật.
> 2. **Tiền-đề dark-launch "giấu bằng KHÔNG cấp `CanRead`" KHÔNG còn đúng cho lớp MENU** — anh chốt @S159 (verbatim): *"đang phát triển → cho hiển thị hết để mọi người góp ý"* ⇒ menu GĐ2 đã public từ skeleton. Dark-launch của W1 lùi về lớp TRANG/DATA (ComingSoon → trang thật mới là "bật"); dòng "✅ có (vô hình với user thường)" ở bảng wave trên đọc lại theo nghĩa này.
> 3. Seeder **skip-existing** (gotcha #84): thêm key qua seeder OK, nhưng ĐỔI nhãn/parent/CanRead của key ĐÃ TỒN TẠI phải đi đường update-backfill kiểu `labelBackfill` đợt-5, KHÔNG phải insert; và **CẤM SQL tay** cho menu.
  🔴 **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 <trước> <sau>` + **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.)*

<!-- END · 5 WAVE · 7 BANG · 4/4 LANE SACH · 112 DIEM REVIEW · 7 LOI-NANG DA-VA · 5 CAU-HOI-OWNER -->
