Files
solution-erp/.claude/workflows/runs/2026-07-28-S157-ke-hoach-ky-ket-hd/spec-ke-hoach-ky-ket-hd-28-07-2026.md
2026-07-29 15:23:09 +07:00

497 lines
50 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 thật (Contract `:320-352`) · (iv) 2 workflow (/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
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 trên giấy; (3) vòng đời mẫu/shopdrawing
**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 **độc lập với gói thầu** chưa 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ử |
|---|---|---|---|---|---|
| 1 | Khúc b.712 | **KHÔNG CÓ** | (duy nhất) | không nói | Spec = đề xuất bổ sung ISO |
| 2 | SLA khúc 712 | im lặng | im lặng | im lặng | **O-2**: tham khảo, không hoà giải |
| 3 | SLA khúc | 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 sẵn `TraLai` + 4 return-mode |
| 6 | Bypass Chủ đầu | không nói | không nói | | = `Contract.BypassProcurementAndCCM` (đã ) **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 · CCM: giá vs NS) | tả lời | không | (checkbox từng mục) | Hệ thống chỉ `LevelOpinions.Comment` tự do **GAP**, **O-Q2 owner** số hoá checkbox không |
| 8 | Tên phòng/vai | PRO/CCM/BOD/NĐUQ/HRA | Procurement/CCM/CEO | PB-DA/PRO/CCM/ | Khớp `AppRoles` **0 mâu thuẫn** |
| 9 | Chuỗi FO-002.01 (ĐỀ XUẤTPROCCM) | ISO 5-7 | b.9101112 **CÙNG HÌNH** | | **Đố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` 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)**.
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 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 ) 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 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ỉ **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 sau b.13; twin
`PurchaseEvaluation.ContractId:31` + index. sinh **per-NCC**, `Lines` cũng **per-NCC** đây chỗ tự nhiên.
- 🔸 **`SlaDeadline?` GIỮ nhưng khai **: **FE hiển thị tham khảo, KHÔNG đăng `SlaExpiryJob`, KHÔNG
`SlaWarningSent`** (O-2 anh chốt SLA chỉ tham khảo). Khai vậy để **không** rơi vào lớp *"cột có mà 0 ai đọc"*
người đọc 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** ( 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`),
**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 ); 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 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ỉ 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 **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 đườ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ứ **input**
của con số đang duyệt). Attachment mở mọi phase (triết 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
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ỗ đ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** ( 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*` 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 ĐÃ 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 chế khác (lệch TÊN thay 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` `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ết " /
*"Danh sách"* / *"Tạo mới"* / *"Inbox duyệt"*) + admin-permission (`:2383` **grep MỌI site seed-permission**,
S155 ghi nhận 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 BÁC đúng 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 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 typeCodeint `: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 .
### 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 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 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` 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 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 | ( hình với user thường) |
| **W2** | CRUD phiếu nháp + căn cứ b.8-9 | không | (bật menu, tạo được phiếu) |
| **W3** | Duyệt 3 trạm + finalize chốt giá | **Q3** (chọn chế rẽ b.12) nhưng chạy được với mặc định | (phiếu chạy trọn vòng) |
| **W4** | Gate + checklist per-phòng | **O-Q1, O-Q2** | (siết dần) |
| **W5** | Cầu sang Hợp đồng | **Q6, Q11 + anh cho phép chạm HĐ** | |
---
### 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** ( 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 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 PMHCCMCEO).
- **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** ( lens-schema H2): `:911`+`:929` merge
"chữ còn sống" · `:971`+`:992` `RetainedOpinionRef` · `:1044`+`:1056` dọn chữ MỒ CÔI. **Thiếu 3 site này
admin sửa quy trình type-10 KHÔNG thấy chữ phiếu KHKK, xoá Level đang chữ FK Restrict nổ 500**
đúng lớp lỗi S155 vừa xong.
- **Acceptance ĐO ĐƯỢC:**
1. `dotnet ef migrations list` migration cuối cùng 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 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 **in-memory trong `Program.cs`**, KHÔNG nằm trong DB phép **không chạy được**)*.
Test này chính thứ bắt lỗi tên-key-lệch-policy §2.4. Số học **+16 policy** (5458 key × 4) vẫn đúng.
4. 🔴 **`dotnet test SolutionErp.slnx` `562 + N`, `N ≥ 2`, 0 fail 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 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 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 chữ 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 **>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 đó**`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.1001.400 | ~2.0002.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 | ~600800 | ~9001.200 | Rẻ hơn, **nhưng**: PE `DaDuyet`**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 | ~700900 | ~1.1001.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`**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``: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 -->