[CLAUDE] Docs: spec "Ke hoach ky ket Hop dong" S157 (khuc con thieu PE -> HD) + review 4 lang kinh

Anh giao: dieu tra quy trinh CON THIEU giua Duyet NCC va HD NTP/NCC, ra spec day
du + chia wave. Chay /fable-real invest (Fable deep-pass) roi /fable-clone
reviewer 4 lang kinh.

PHAT HIEN NEN (lead do, invest + reviewer xac nhan tu nguon goc):
- 7 phase [LEGACY] cua ContractPhase CHINH LA quy trinh ISO 9 buoc ma-hoa cung
  (khop 1:1 dung thu tu). Khung V2 sinh ra de bieu dien chinh quy trinh do.
- Khoang trong nam DUNG giua ISO-1 va ISO-2, va ISO doc CUNG KHONG CO no
  => thieu o CA HAI tang (doc + code) => spec vua la spec phan mem vua la de
  xuat bo sung ISO.

OWNER CHOT trong cua: (O-1) cau truc trinh ky GIONG, chi khac NOI DUNG => tai
dung khung duyet V2, 0 bang approval moi => truc tranh luan Q2 hom qua DONG.
(O-2) SLA chi tham khao => khong SLA-engine, khong wave SLA.
(O-3) "cho hop dong cu tu tu" => lo hong an ninh HD NGOAI SCOPE.

REVIEW 4/4 lane sach 0 chet (dong goi C2 an), 112 diem. Claim nen DUNG VUNG.
Bat 7 loi NANG, da va het trong ban nay:
1. Ten menu key `Csp*` != tien to policy `ContractSigningPlans.*` => Program.cs
   khong sinh policy => HTTP 500 luc chay. Repo DA CO test bat
   (AuthorizePolicyRegressionTests.cs:138-139). Day la loi TU-VAP-BAY-MINH-VUA-
   NHAN-DIEN: §2.4 bat dung bay Pe_* roi §2.3 van vap bang co che khac.
2. W2 day rieng tao duoc 0 phieu (wiring Designer type-10 roi khoi moi wave)
   => keo vao W1, tai pham dung §F.0 S155.
3. Thieu bang Approvals ma service duoc lenh copy CO ghi vao.
4. Changelogs thieu 4 cot => copy service khong bien dich.
5. Thieu 3 site ApprovalWorkflowV2AdminFeatures => FK Restrict no 500.
6. Status enum bat dau tu 0 => bay falsy JS.
7. 4 anchor lech, nang nhat ApprovalWorkflowLevel.cs KHONG TON TAI.

Kem: bo tieu chi dark-launch sai co che (IsVisible chi pass-through, filter la
CanRead) - thay "sqlcmd dem policy" (khong do duoc, policy in-memory) bang test
regression - OR-of-N nang tu 1 ve len 4 ve co ca am - cat CodeSequences (tai
dung WorkflowAppCodeSequence dung chung 4 module Office).

Su co #53: invest luot 1 skeleton-ruot-rong (202K tok, dia 1.661B khung rong)
=> ghi-dia-trong-luc-lam la CAN KHONG DU, phai verify RUOT. Luot 2 resume cat
con 2 muc => CLEAN 23.085B. Luot 3 tien-trinh CLI thoat, §C/§D chua ghi => lead
tu viet theo H21 (engine propose-only, spec do lead ghi).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-28 11:30:03 +07:00
parent 1d6d7b9df3
commit d7eaeceaa7
5 changed files with 762 additions and 52 deletions

View File

@ -122,15 +122,36 @@ PE/Supplier (convention PE, ghi ở Mig 49) · FK vật lý chỉ nội bộ mod
| 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) | | 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) | | 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` | | 5 | `ContractSigningPlanAttachments` | `PlanId` FK Cascade · FileName/Path/Size/ContentType · `Purpose int` · `DossierItemId?` | khuôn `ProposalAttachment.cs` |
| 6 | `ContractSigningPlanChangelogs` | `PlanId` · Action · PhaseAtChange · UserId · Summary · ContextNote | **có từ ngày 1** tiền lệ PE: Changelogs nguồn transition-marker/backfill (Mig 60/61) | | 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 | `ContractSigningPlanCodeSequences` | `Prefix` PK · `LastSeq` | khuôn `ProposalCodeSequence.cs`; format đề xuất **`KHKK/{YYYY}/{Seq:D3}`** **owner chốt format** | | 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**. |
**Biến thể tối thiểu 5 bảng** (bỏ #6 + #7) **KHÔNG khuyến nghị**: mất audit-trail transition (Mig 60/61 chứng **Tổng vẫn 7 bảng** (thêm #7 Approvals, cắt CodeSequences). Down = `DropTable ×7`.
minh cần) mất phiếu atomic. Nếu owner muốn cắt, cắt #7 trước ( tay), giữ #6. **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:** **Enum:**
- `ApprovalWorkflowApplicableType` **+`ContractSigningPlan = 10`** append-only. *Lead verify: `ApprovalWorkflow.cs:53-67` dừng ở `TravelRequest = 9`, **slot 10 TRỐNG THẬT**.* - `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`. - `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 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)`. - 🔴 **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). Thêm cột = đẻ *"cột có mà 0 ai đọc"* (lớp lỗi đã cắn **6 lần** repo này).
@ -139,9 +160,13 @@ minh là cần) và mất mã phiếu atomic. Nếu owner muốn cắt, cắt #7
**`Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`** (mega-file, khuôn `ProposalFeatures.cs`): **`Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`** (mega-file, khuôn `ProposalFeatures.cs`):
- `CreateContractSigningPlanCommand(PeId, ApprovalWorkflowId, GhiChu?)` validator: PE tồn tại + `Phase==DaDuyet` - `CreateContractSigningPlanCommand(PeId, ApprovalWorkflowId, GhiChu?)` validator: PE tồn tại + `Phase==DaDuyet`
(mirror cầu `CreateContractFromEvaluationFeatures.cs:53-54`) + **chưa có plan sống** (mirror cầu `src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:53-54`
(`AnyAsync(p.PeId==x && p.Phase!=TuChoi)` Conflict; cho tạo lại sau `TuChoi`) + workflow 🔴 **đường dẫn đầy đủ, đã sửa**: file nằm `PurchaseEvaluations/` **KHÔNG phải `Contracts/`**; spec bản đầu
`ApplicableType==ContractSigningPlan` (mirror Proposal `:258-265`). để 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` 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**. 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 - `UpdateContractSigningPlanDraftCommand` guard `Phase ∈ {DangSoanThao, TraLai}` + **cho re-pin
@ -158,8 +183,11 @@ minh là cần) và mất mã phiếu atomic. Nếu owner muốn cắt, cắt #7
tại **choke-point duy nhất** + RULE grep-4-site (khuôn `PurchaseEvaluationWorkflowService.cs:1008`). 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` 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`). `:892-929` (tổng so ngưỡng = `SUM ProposedAmount`).
3. Reject 4 return-mode per-level (cờ sẵn `ApprovalWorkflowLevel.cs:116-125`, logic mirror PE 3. Reject 4 return-mode per-level (cờ sẵn tại **`Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:116-125`**
`ApplyReturnModeAsync:368-547`) **tối thiểu `TraLai` toàn phần** nếu muốn gọn wave đầu. 🔴 **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 🔴 **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` (`ContractWorkflowService.cs:70-79` đòi `Drafter|DeptManager`) để **né lớp 403-PMH**: PMH mang role `Procurement`
@ -178,9 +206,18 @@ attachments · `DELETE {id}` (allow-list `DangSoanThao|TuChoi`, mirror PE `:1404
### 2.4 Menu + Permission + FE 2 app (Pattern 16-bis 4-place mirror) ### 2.4 Menu + Permission + FE 2 app (Pattern 16-bis 4-place mirror)
- **BE `MenuKeys.cs`:** +4 const `Csp` / `Csp_List` / `Csp_Create` / `Csp_Inbox` **VÀ đưa CẢ 4 vào mảng `All`**. - 🔴🔴 **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):
*Lead verify `MenuKeys.cs:171`: `OffDeXuat`+3 con **đều trong `All`**; còn `Pe_*` sinh bằng **factory** +4 const **`ContractSigningPlans`** / `ContractSigningPlans_List` / `_Create` / `_Inbox`
`:150`/`:154` **NẰM NGOÀI `All`** per-action policy **không tồn tại**.* **Theo khuôn `Off_DeXuat`, KHÔNG theo `Pe_*`.** **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. 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 *"Kế hoạch ký kết HĐ"* / - **Seed `DbInitializer.cs`:** 4 dòng menu (khuôn Off_DeXuat `:1817-1820`, label VN *"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**, *"Danh sách"* / *"Tạo mới"* / *"Inbox duyệt"*) + admin-permission (`:2383` **grep MỌI site seed-permission**,
@ -220,22 +257,42 @@ S155: §F.0 chứng minh đợt-1-một-mình vô dụng ⇒ phải chia theo *"
--- ---
### WAVE 1 — Schema + permission scaffold (dark-launch) ### WAVE 1 — Schema + permission + **Designer type-10** (vá sau review)
- **Mục tiêu:** đặt nền DB + quyền, **không đổi gì người dùng thấy**. - **Mục tiêu:** đặt nền DB + quyền **VÀ** cho admin tạo được quy trình duyệt `type=10`.
- **Vai:** `implementer-backend` · review `database-agent` (design-review migration trước apply). 🔴 **Designer KÉO TỪ W2 SANG ĐÂY** ( lens-wave H1): lệnh `Create` phiếu **đòi** workflow `ApplicableType=10`
- **File:** 7 entity + `ContractSigningPlanPhase.cs` + 7 EF Configuration + `ApplicationDbContext`/`IApplicationDbContext` 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
DbSets + Mig 69 (3-file rule) + `ApprovalWorkflow.cs` enum +slot 10 + `MenuKeys.cs` +4 const **vào `All`** + 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ị*
`DbInitializer` menu 4 dòng `IsVisible=0` + admin-permission. 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:** - **Acceptance ĐO ĐƯỢC:**
1. `dotnet ef migrations list` `69_AddContractSigningPlans` cuối. 1. `dotnet ef migrations list` migration cuối cùng tên chứa **`AddContractSigningPlans`**.
2. `sqlcmd -Q "SELECT COUNT(*) FROM sys.tables"` **89 → 96** (+7). *(🔴 sửa: bản đầu viết `69_AddContractSigningPlans` EF in `20260728xxxxxx_AddContractSigningPlans`,
3. `dotnet test SolutionErp.slnx` **562 PASS giữ nguyên, 0 fail** (chưa test mới). **không có tiền tố `69_`** literal KHÔNG BAO GIỜ khớp. lens-wave checklistEvidence #1.)*
4. `sqlcmd` đếm policy: `|MenuKeys.All| × 4` tăng đúng **+16**; grep `ContractSigningPlans.Read` trong list policy. 2. `sqlcmd -d SolutionErp -Q "SELECT COUNT(*) FROM sys.tables WHERE is_ms_shipped=0"` **89 → 96**.
5. Menu **KHÔNG hiện** trên cả 2 FE (IsVisible=0) screenshot hoặc `GET /api/menus` không chứa `Csp`. *(🔴 sửa: thêm `is_ms_shipped=0` + DB đích — mốc 89 canonical đo bằng đúng vị ngữ này.)*
6. `dotnet ef migrations script 68 69` + script Down chạy sạch trên DB copy (reversible thật, không chỉ nhìn code). 3. 🔴 **`dotnet test` `AuthorizePolicyRegressionTests` XANH** *(thay dòng "sqlcmd đếm policy" lens-wave H3:
- **Rủi ro:** seed-permission **>1 site** (S155) ⇒ grep hết trước khi kết luận. · Enum slot 10 nếu ai đó chèn policy đăng **in-memory trong `Program.cs`**, KHÔNG nằm trong DB phép **không chạy được**)*.
trước ⇒ re-verify `ApprovalWorkflow.cs` ngay trước khi viết. 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 + số test mới**, **0 fail**. *(🔴 "562 giữ nguyên" của bản đầu = **0 bit**
không falsify được ; lens-wave H5.)*
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) ### WAVE 2 — CRUD phiếu nháp + căn cứ b.8-9 (bật menu)
@ -250,10 +307,16 @@ S155: §F.0 chứng minh đợt-1-một-mình vô dụng ⇒ phải chia theo *"
2. `PeReferenceAmount` mỗi Line == `SUM(Quotes WHERE IsSelected)` của đúng NCC đó (`sqlcmd` 2 câu, so khớp). 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**. 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). 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). User có quyền → 201. 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.
*(Đây là phép chặn lỗ #82 tái phát — bắt buộc chạy, không được bỏ.)* *(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**. 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. 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 - **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). `WHERE IsDeleted=0` ⇒ tạo lại sau xóa bị chặn oan (gotcha #57).
@ -265,13 +328,23 @@ S155: §F.0 chứng minh đợt-1-một-mình vô dụng ⇒ phải chia theo *"
- **File:** `ContractSigningPlanWorkflowService.cs` (copy `ApproveV2Async:217-394`) · `transitions` endpoint · - **File:** `ContractSigningPlanWorkflowService.cs` (copy `ApproveV2Async:217-394`) · `transitions` endpoint ·
inbox V2 precompute · notify · FE workflow panel + banner "Đến lượt bạn". inbox V2 precompute · notify · FE workflow panel + banner "Đến lượt bạn".
- **Acceptance ĐO ĐƯỢC:** - **Acceptance ĐO ĐƯỢC:**
1. 🔴 **OR-of-N còn sống** *(phép chặn bẫy §2.1)*: workflow 1 Bước có **1 Cấp 2 người** → **người thứ hai 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
duyệt được** và phiếu **tiến sang Bước kế** (không đòi cả 2 ký). Test unit + 1 lượt UAT thật. dưới đúng bug `ElementAtOrDefault`**,service **bỏ hẳn guard** cũng PASS)*. Workflow có **1 Cấp 2 người (A, B)**:
2. **Con-trỏ đôi:** workflow **3 Bước** → sau mỗi lượt duyệt, `CurrentWorkflowStepIndex` **và** (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,
`CurrentApprovalLevelOrder` đều đổi đúng (`sqlcmd` đọc 2 cột sau mỗi bước). 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);
3. **Finalize choke-point:** duyệt cấp cuối → **mọi** Line có `ApprovedAmount != NULL` (c) `SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions WHERE PlanId=@id` = **1** (không đúp);
(`sqlcmd COUNT(*) WHERE ApprovedAmount IS NULL` = **0**). Test cả 3 đường tới `DaDuyet` (d) phiếu **KHÔNG** đòi cả A và B cùng ký. Test unit + 1 lượt UAT thật.
(thường · `AllowApproverFinalize` · `CeoApprovalThreshold`) — **grep 4-site RULE**. 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**. 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.)* *(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. 5. `LevelOpinions` UPSERT: duyệt 2 lần cùng cấp → DB **1 row** (không đúp), `SignedByUserId` đúng người thật.
@ -337,4 +410,53 @@ anh vừa yêu cầu để yên. **Nếu owner ưu tiên cắt chi phí thì c
--- ---
<!-- END · 5 WAVE · 7 BANG · 3 CHAN-BOI-OWNER (W3-mem/W4/W5) · 5 CAU-HOI-OWNER --> ---
## ⑤ 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ế · 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 -->

View File

@ -1,23 +1,121 @@
# LĂNG KÍNH 4/4 — VERIFY ĐỘC LẬP CLAIM file:line (reviewer, S157) # LĂNG KÍNH 4/4 — VERIFY ĐỘC LẬP CLAIM file:line (reviewer, S157)
Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §② — mọi claim `file:line` load-bearing được mở-file-thật đối chiếu. Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §② (dòng 95-204). Phép đo: MỞ FILE THẬT trên đĩa (Read/Grep/Glob), KHÔNG dựa skill-doc, KHÔNG dựa `sub-invest-kehoach-1.md`.
## Verdict ## Verdict
(đang đo) **SPEC §② VỮNG — 39 ĐÚNG / 4 SAI (toàn bộ là lệch ANCHOR, 0 sai NỘI DUNG) / 5 KHÔNG KIỂM ĐƯỢC.**
Hai claim quyết-định-kiến-trúc (B.1 "Proposal phá OR-of-N" và "Pe_* ngoài `MenuKeys.All`") **ĐÚNG, khớp từng dòng**.
Trước khi giao implementer phải sửa 4 anchor ở mục "Claim SAI" — 1 trong 4 là **đường dẫn file KHÔNG TỒN TẠI** (implementer mở sẽ trượt).
## Claim ĐÚNG ## Claim ĐÚNG
(đang đo) ### A. B.1 — `ProposalFeatures.cs` phá OR-of-N (claim quyết định "copy Contract, không copy Proposal")
| # | Claim gốc (spec dòng 99-104) | Đo được trên đĩa | Verdict |
|---|---|---|---|
| 1 | `SelectMany` flatten mọi Level xuyên Steps `:427-429` | `ProposalFeatures.cs:427-429` = `wf.Steps.OrderBy(s => s.Order).SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l })).ToList()` | ĐÚNG — khớp DÒNG chính xác |
| 2 | `ElementAtOrDefault(order-1)` lấy **1 row** `:433` | `:433` = `allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1)` | ĐÚNG — chính xác |
| 3 | So đúng **1** `ApproverUserId` `:439` | `:439` = `if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)` — so 1 Guid, KHÔNG có tập `allowedUserIds` | ĐÚNG — chính xác |
| 4 | Comment tự thú *"Lite version: assume 1 step per workflow"* | `:424` = `// Multi-step workflow: traverse step-by-step. Lite version: assume 1 step per workflow` | ĐÚNG — nguyên văn |
| 5 | Entity Proposal chỉ có `CurrentApprovalLevelOrder`, KHÔNG có `CurrentWorkflowStepIndex` | `Domain/Office/Proposal.cs:27``CurrentApprovalLevelOrder`; grep `CurrentWorkflowStepIndex` trong `**/Proposal.cs` = **0 hit** | ĐÚNG |
| 6 | Hệ quả: 2 người cùng Cấp bị biến thành 2 nấc TUẦN TỰ (AND) | **CỦNG CỐ THÊM (tôi tự đo):** `:473-481` advance = `if (CurrentApprovalLevelOrder < allLevels.Count) CurrentApprovalLevelOrder + 1` — so **Order** (1-based trong Bước) với **Count của danh sách ĐÃ FLATTEN**. Không `GroupBy(Order)` ở bất kỳ đâu ⇒ N row cùng Order = N slot tuần tự. | ĐÚNG |
| 7 | OR-of-N hợp lệ theo `ApprovalWorkflow.cs:81-94` | `Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:81` mở *"Cấp = tập các row CÙNG Order"*, `:82` *"1 Cấp có thể có N row ⇒ N người duyệt OR-of-N"*, kết `:94` *"Cấp 2 Phòng Cung ứng có 3 người"*; `:95``public class ApprovalWorkflowLevel` | ĐÚNG — khung 81-94 khít |
**Kết luận B.1: claim ĐÚNG 7/7.** Khuyến nghị "PHẢI copy `ContractWorkflowService.ApproveV2Async`" có cơ sở đo được, không phải suy diễn.
### B. `MenuKeys.All` (claim quyết định "theo khuôn `Off_DeXuat`, không theo `Pe_*`")
| # | Claim gốc (spec dòng 181-183) | Đo được | Verdict |
|---|---|---|---|
| 8 | `OffDeXuat` + 3 con **đều trong `All`** | `MenuKeys.cs:168` = `OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox,` nằm trong mảng `All` (`:156-174`) | ĐÚNG **nội dung** — nhưng anchor spec ghi `:171` là SAI (xem S1) |
| 9 | `Pe_*` sinh bằng **factory**, **NẰM NGOÀI `All`** | Factory `:134-137` (`Pe_{t}` / `_List` / `_Create` / `_Pending`), `:145-146` (`_Approved` / `_Deleted`), `:150` (`_WfView`); mảng `All` chỉ chứa root `PurchaseEvaluations` (`:162`) — **không một literal `Pe_` nào trong `All`**. Comment `:142-144` tự khai đúng chính sách này. | ĐÚNG |
| 10 | ⇒ per-action policy `Pe_*` **không tồn tại** | `Program.cs:82-89` = `foreach (var menu in MenuKeys.All) foreach (var action in MenuKeys.Actions) opts.AddPolicy($"{menu}.{action}", ...)` — policy sinh **duy nhất** từ `All` ⇒ key ngoài `All` không có policy | ĐÚNG (suy ra bằng cấu trúc, không phải phỏng đoán) |
| 11 | Policy TỰ SINH khi key vào `MenuKeys.All` (`Program.cs:82-89`) | Khớp từng dòng: `:82` foreach All · `:84` foreach Actions · `:86-87` AddPolicy · `:89` đóng | ĐÚNG — chính xác |
### C. Khuôn Contract phải copy (§2.1 + §2.3)
| # | Claim | Đo được (`Infrastructure/Services/ContractWorkflowService.cs`) | Verdict |
|---|---|---|---|
| 12 | `ApproveV2Async:217-394` | `:217` = `private async Task ApproveV2Async(`; hàm kế `LogTransitionAsync` bắt đầu `:396` ⇒ thân kết ~`:394` | ĐÚNG (đầu chính xác, đuôi khớp mốc kề) |
| 13 | `GroupBy` Order `:246` | `:246` = `currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList()` | ĐÚNG — chính xác |
| 14 | OR-of-N `:259-260` | `:259` = `allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet()`, `:260` = `if (!allowedUserIds.Contains(actorUserId.Value))` | ĐÚNG — chính xác |
| 15 | `LevelOpinions` UPSERT `:292-316` | `:292` = `db.ContractLevelOpinions` (đọc bản cũ), `:300` = `.Add(new ContractLevelOpinion`, `:318` đã là comment skipToFinal ⇒ khối UPSERT nằm trọn 292-316 | ĐÚNG |
| 16 | `skipToFinal` `:320-352` | `:320` = `if (skipToFinal)`, `:332` = `lastStep.Levels...GroupBy` | ĐÚNG (đầu chính xác) |
| 17 | Guard trình role-based `:70-79` đòi `Drafter\|DeptManager` | `:70-71` điều kiện `DangSoanThao \|\| TraLai`, `:73-75` `!actorRoles.Contains(AppRoles.Drafter) && !...DeptManager`, `:77-79` `throw new ForbiddenException` | ĐÚNG — khung 70-79 khít |
| 18 | `ConflictException` `:115-116` khi HĐ V2-less trình | `:116` = `throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step.")` | ĐÚNG về **anchor** (xem K5 về mệnh đề nhân-quả) |
| 19 | `LogTransitionAsync` báo Drafter `:407-427` | `:396` = `private async Task LogTransitionAsync(`, `:407` = `if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)` | ĐÚNG — notify quả thật CHỈ nhánh Drafter |
### D. Lỗ đang treo ở Contract (lý do "authz 2 tầng" + "inbox V2 từ ngày 1")
| # | Claim | Đo được | Verdict |
|---|---|---|---|
| 20 | `ContractsController.cs:12-13` class-trần | `:11` `[ApiController]` · `:12` `[Route("api/contracts")]` · `:13` `[Authorize]` **trần**; grep `Authorize\(Policy` trong file = **0 hit** ⇒ 0 per-action policy | ĐÚNG (bằng chứng mạnh hơn claim: 0 hit toàn file) |
| 21 | `ContractFeatures.cs:363-372` legacy-only | `:363` = `PhaseActorRoles = new()`, `:364-372` chỉ map `DangSoanThao … DangDongDau` rồi `};` | ĐÚNG — khung khít |
| 22 | `grep ChoDuyet` = 0 hit | Grep `ChoDuyet` trong `**/ContractFeatures.cs` = **0 occurrence / 0 file** | ĐÚNG — chính xác |
| 23 | PE có `ResolveV2InboxIdsAsync` để mirror | Grep toàn `src` = 2 hit, **chỉ** trong `PurchaseEvaluationFeatures.cs` (Contract không có) | ĐÚNG |
### E. Khuôn PE phải port (§2.3 mục 1-3)
| # | Claim | Đo được (`PurchaseEvaluationWorkflowService.cs`) | Verdict |
|---|---|---|---|
| 24 | choke-point + RULE grep-4-site, khuôn `:1008` | `:1008` = `// RULE: MỌI nhánh set Phase=DaDuyet PHẢI gọi helper này ngay TRƯỚC assignment (reviewer grep-check 4 site).`, `:1009` = `private async Task ApplyBudgetSnapshotOnFinalizeAsync(` | ĐÚNG — anchor trúng ĐÚNG dòng RULE |
| 25 | `AllowApproverFinalize` `:870-890` | `:870` = `if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)` | ĐÚNG (đầu chính xác) |
| 26 | `finalizeByCcmDelegation` + `CeoApprovalThreshold` `:892-929` | `:892` = `if (finalizeByCcmDelegation)`, `:894` = `if (aw.CeoApprovalThreshold is not decimal ceoThreshold)` | ĐÚNG (đầu chính xác) |
| 27 | `ApplyReturnModeAsync:368-547` | `:368` = `private async Task<string> ApplyReturnModeAsync(` | ĐÚNG (đầu chính xác) |
| 28 | `HasQueryFilter(!IsDeleted)` khuôn `PurchaseEvaluationConfiguration.cs:84` | `:84` = `b.HasQueryFilter(x => !x.IsDeleted);` | ĐÚNG — chính xác |
### F. Menu / seed / FE (§2.4)
| # | Claim | Đo được | Verdict |
|---|---|---|---|
| 29 | Seed 4 dòng menu khuôn `DbInitializer.cs:1817-1820` | `:1817` `OffDeXuat "Đề xuất"` · `:1818` `OffDeXuatList "Danh sách"` · `:1819` `OffDeXuatCreate "Tạo mới"` · `:1820` `OffDeXuatInbox "Inbox duyệt"`**cả 3 nhãn VN spec đề xuất trùng khớp nhãn khuôn** | ĐÚNG — chính xác |
| 30 | admin-permission `:2383` | `:2383` = `MenuKeys.OffDeXuat, MenuKeys.OffDeXuatList, MenuKeys.OffDeXuatCreate, MenuKeys.OffDeXuatInbox,` | ĐÚNG — chính xác |
| 31 | `ContractLevelOpinions` FK Cascade + Restrict (Mig 33) | `ContractLevelOpinionConfiguration.cs:27` `OnDelete(DeleteBehavior.Cascade)` · `:32` `OnDelete(DeleteBehavior.Restrict)` | ĐÚNG |
| 32 | `ApprovalWorkflowsV2Page.tsx:143` map typeCode→int | `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:143` = `// FE typeCode → BE int (giống MenuKeys ApplicableType)` (đầu bảng `TYPE_CODE_TO_INT`, dùng ở `:257`) | ĐÚNG |
| 33 | Bộ Proposal FE ~978 LOC/app: List 252 + Create 245 + Detail 386 + types 95 | Đếm dòng thật fe-admin: `ProposalsListPage.tsx` **252** · `ProposalCreatePage.tsx` **245** · `ProposalDetailPage.tsx` **386** · `types/proposal.ts` **95** ⇒ tổng **978** | ĐÚNG — 4/4 con số khớp tuyệt đối |
### G. Enum + cầu b.12→13 (§2.2 + §2.5)
| # | Claim | Đo được | Verdict |
|---|---|---|---|
| 34 | `ApprovalWorkflow.cs:53-67` dừng ở `TravelRequest = 9`, **slot 10 trống** | `:53` = `public enum ApprovalWorkflowApplicableType`, `:66` = `TravelRequest = 9,`, `:67` = `}` — giá trị lớn nhất 9 | ĐÚNG — khung 53-67 khít, slot 10 trống thật |
| 35 | cầu `:53-54` = PE phải `DaDuyet` | `CreateContractFromEvaluationFeatures.cs:53-54` = `if (pe.Phase != PurchaseEvaluationPhase.DaDuyet) throw new ConflictException(...)` | ĐÚNG — chính xác |
| 36 | `:56-62` phép lấy winners | `:56` `winners = pe.Suppliers.Where(s => s.IsWinner).ToList()``:62` `supplierIds = winners.Select(w => w.SupplierId)` | ĐÚNG — khung khít |
| 37 | `:88-90` `SUM Quote.IsSelected` per winner | `:88-90` = `pe.Details.SelectMany(d => d.Quotes).Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected).Sum(q => q.ThanhTien)` | ĐÚNG — chính xác |
| 38 | changelog `:121-140` (chỗ ghi ContextNote lệch) | `:121-130` `ContractChangelogs.Add(...)` (ContextNote `:129`) + `:131-140` `PurchaseEvaluationChangelogs.Add(...)` (ContextNote `:139`) | ĐÚNG — khung khít, **2 changelog chứ không phải 1** (implementer nhớ ghi note vào đúng bảng) |
| 39 | pin **V1-only** `:68-71`, `:108` | `:68-71` = `activeWfId` lấy từ `db.WorkflowDefinitions … IsActive`; `:108` = `WorkflowDefinitionId = activeWfId`; **không có dòng nào set `ApprovalWorkflowId`** trong cả khối `new Contract{}` `:92-111` | ĐÚNG |
**Bonus — measured-label gate:** con số **"849 LOC BE"** (spec dòng 99) tái-lập được: đếm dòng 10 file `Proposal*.cs` trong `src/` = **đúng 849** (`ProposalFeatures.cs` 556 + Controller 86 + 4 Configuration 108 + 4 Domain 99). Nhãn "BE" cứu con số. ⚠️ Nhưng câu đặt số ngay sau **tên 1 file** ⇒ dễ đọc thành "file này 849 dòng" (thật: **556**). Đề nghị đổi thành *"twin nhỏ nhất — 849 LOC BE trải 10 file, riêng `ProposalFeatures.cs` 556"*.
## Claim SAI ## Claim SAI
(đang đo) Cả 4 đều là **lệch anchor / sai đường dẫn**, KHÔNG có claim nào sai về nội dung kỹ thuật. Nhưng đây đúng lớp lỗi đã cắn repo hôm qua, và 1 cái làm implementer mở-file-trượt.
| # | Claim gốc | Đo được | Mức |
|---|---|---|---|
| **S1** | *"Lead verify `MenuKeys.cs:171`: `OffDeXuat`+3 con đều trong `All`"* (spec dòng 182) | 4 key nằm ở **`MenuKeys.cs:168`**. Dòng **`:171``Personal,`** — key khác hẳn, chẳng liên quan `Off_DeXuat` | MINOR (nội dung đúng, anchor lệch 3 dòng) |
| **S2** | *"cờ sẵn `ApprovalWorkflowLevel.cs:116-125`"* (spec dòng 161) | **KHÔNG CÓ FILE NÀO TÊN `ApprovalWorkflowLevel.cs`** (Glob `**/ApprovalWorkflowLevel.cs` = 0 hit). Class `ApprovalWorkflowLevel` nằm trong `Domain/ApprovalWorkflowsV2/**ApprovalWorkflow.cs**:95`. Khoảng dòng thì ĐÚNG: `:116` `AllowReturnOneLevel` · `:119` `AllowReturnOneStep` · `:122` `AllowReturnToAssignee` · `:125` `AllowReturnToDrafter = true` | **MAJOR** — implementer mở theo spec sẽ file-not-found (tôi vừa dính đúng lỗi này khi đoán thư mục ở S5) |
| **S3** | *"DELETE allow-list `DangSoanThao\|TuChoi`, mirror PE `:1404`"* (spec dòng 177) | `PurchaseEvaluationFeatures.cs:1404`**dòng TRỐNG**. Allow-list thật ở **`:1405-1407`**. Drift này **kế thừa** từ comment trong mã: `ApprovalWorkflow.cs:161` cũng ghi `PurchaseEvaluationFeatures.cs:1404-1406` (lệch 1) | MINOR |
| **S4** | *"workflow `ApplicableType==ContractSigningPlan` (mirror Proposal `:258-265`)"* (spec dòng 144) | Khoảng `:258-265` chỉ chứa phần **nạp** `wfType` + throw NotFound. **Hai dòng quyết định nằm NGOÀI khoảng trích**: `:266-267` `if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral) throw new ConflictException(...)`. Ai copy đúng 258-265 sẽ copy phần đọc mà **bỏ mất chính cái guard** | MINOR-nhưng-nguy (trích hụt đúng dòng load-bearing) |
## Claim KHÔNG KIỂM ĐƯỢC ## Claim KHÔNG KIỂM ĐƯỢC
(đang đo) | # | Claim | Vì sao chưa kết luận |
|---|---|---|
| K1 | Ranh **CUỐI** của 6 khoảng: `ApproveV2Async:…394`, `skipToFinal:…352`, `ApplyReturnModeAsync:…547`, `AllowApproverFinalize:…890`, `CcmDelegation:…929`, `UPSERT:…316` | Tôi chỉ verify **dòng đầu + mốc liền kề** (hàm/khối kế tiếp). Chưa đọc trọn ruột từng khoảng. Đầu đúng + mốc kề hợp lý ⇒ khả năng đúng cao, nhưng **chưa đo** ⇒ không tuyên ĐÚNG |
| K2 | `fe-user` `WorkflowMatrixViewPage.tsx` (spec dòng 193-194 tự khai invest KHÔNG verify được) | Tôi cũng chưa kiểm. **Spec đã tự gắn cờ "PHẢI grep lại lúc làm"** — giữ nguyên cờ đó, đừng gỡ |
| K3 | `ProposalFeatures.cs:161-228` (khuôn Get/List detail Include) | Chưa đọc — hết ưu tiên |
| K4 | Mig **69** · 7 CreateTable · 2 row `docs/STATUS.md` bump | Claim về **việc sẽ làm**, không có vật trên đĩa để chấm |
| K5 | Mệnh đề nhân-quả *"pin V1-only ⇒ HĐ V2-less trình xong **KẸT CỨNG** `:115-116`"* | Anchor ĐÚNG, nhưng `:116` ném khi **`WorkflowDefinitionId` null HOẶC def không có step**. Ở cầu này `:108` **có** set `activeWfId` (V1) ⇒ có kẹt hay không **phụ thuộc DB prod**: có tồn tại `WorkflowDefinitions` active + có step cho `ContractType` đó không. **Chưa đo DB** ⇒ mệnh đề đúng CÓ ĐIỀU KIỆN |
## Điểm CỦNG CỐ ## Điểm CỦNG CỐ
(đang đo) 1. **B.1 mạnh hơn spec viết.** Ngoài 3 dòng spec nêu, `ProposalFeatures.cs:473` còn so `CurrentApprovalLevelOrder` (Order 1-based **trong Bước**) với `allLevels.Count` (đếm row **đã flatten toàn workflow**) — trộn hai hệ đơn vị. Cùng lớp `#43 Step.Order ≠ index 0-based`. Thêm 1 lý do nữa để KHÔNG copy Proposal cho phần duyệt.
2. **Cat-3 authz: bằng chứng mạnh hơn claim.** Spec chỉ trỏ `ContractsController.cs:12-13`; đo thật `grep Authorize\(Policy` = **0 hit toàn file** ⇒ mọi endpoint HĐ chỉ cần "đăng nhập bất kỳ". Đúng bài học #82/S118 (menu-ẩn ≠ API-đóng). Yêu cầu "authz 2 TẦNG" của §2.3 giữ nguyên, nên nhấn thêm câu *"gate acceptance = grep `Authorize(Policy` trong controller mới phải ≥ 4 hit"*.
3. **Thêm đường dẫn vào spec, đừng để tên trần.** `CreateContractFromEvaluationFeatures.cs` thật nằm ở **`src/Backend/SolutionErp.Application/PurchaseEvaluations/`** (KHÔNG phải `.../Contracts/` như tiêu đề §2.5 "chạm module Contract" gợi ý). Spec để tên trần ⇒ tôi đoán sai thư mục và trượt 1 lượt đọc. Cùng cách chữa cho S2.
4. **§2.5 sửa 2 bảng changelog, không phải 1.** `:121-130` `ContractChangelogs``:131-140` `PurchaseEvaluationChangelogs`. Câu spec *"ghi ContextNote lệch vào changelog `:121-140`"* nên nói rõ ghi vào **bảng nào** (đề nghị: cả hai, để tra ngược từ 2 phía).
5. **Anchor drift lan từ MÃ sang SPEC.** S3 chứng minh: comment trong `ApprovalWorkflow.cs:161` ghi lệch 1 dòng, spec chép lại y nguyên. ⇒ quy tắc rẻ: mọi anchor `file:line` trong spec phải được **mở lại lúc bắt đầu wave**, vì mã đã trôi giữa lúc viết comment và lúc viết spec.
6. **Đề nghị gate rẻ cho implementer (không phải sửa mã, chỉ là phép chấm):** trước mỗi wave, chạy 1 lượt "mở 5 anchor bất kỳ trong wave đó" — nếu ≥1 anchor trượt file/dòng, chấm lại toàn bộ anchor của wave.
<!-- END · TOTAL=48 CLAIM · VERDICT=SPEC §② VỮNG — 39Đ/4S(chỉ lệch anchor)/5KKĐ, sửa 4 anchor (1 sai đường dẫn) trước khi giao implementer -->

View File

@ -2,12 +2,66 @@
## Verdict ## Verdict
**GO-WITH-FIXES — chân chống KHÔNG sập, nhưng phải hạ phạm vi một câu và vá bảng §1.7 trước khi trình owner với tư cách "đề xuất sửa ISO".**
Trả lời thẳng câu (e), là câu quyết định:
1. **"9 bước ISO khớp 1:1 với 9 `ContractPhase`" — XÁC NHẬN.** Tôi đếm tay Bảng 2 của ISO: khai `(10r x 3c)`, một dòng tiêu đề *"BƯỚC / STEP | TRÁCH NHIỆM | NỘI DUNG CHI TIẾT"* (`src-QT-TRINH-KY-HD.txt:59`) rồi đúng **9** dòng bước (`:60-68`). Ánh xạ khớp cả tên trạm lẫn thứ tự, 9 trên 9: *Lựa chọn NTP/NCC* (`:60`) ↔ `DangChon`; *Soạn thảo hợp đồng* (`:61`) ↔ `DangSoanThao`; *Góp ý nội dung* (`:62`) ↔ `DangGopY`; *Đàm phán, thương thảo hợp đồng* (`:63`) ↔ `DangDamPhan`; *In hợp đồng* (`:64`) ↔ `DangInKy`; *Kiểm tra — CCM* (`:65`) ↔ `DangKiemTraCCM`; *Duyệt — BOD/NĐUQ* (`:66`) ↔ `DangTrinhKy`; *Đóng dấu hợp đồng — HRA/ISO* (`:67`) ↔ `DangDongDau`; *Phát hành hợp đồng. Lưu trữ hồ sơ* (`:68`) ↔ `DaPhatHanh`. Vế ISO chắc chắn là 9; chỗ lung lay nằm ở phía spec, xem B3.
2. **"ISO đi thẳng bước 1→2, không có bước trung gian" — XÁC NHẬN THEO NGHĨA HẸP, BÁC THEO NGHĨA RỘNG.** Trong Bảng 2 thì đúng: giữa dòng bước 1 (`:60`) và dòng bước 2 (`:61`) không có dòng nào khác. Nhưng câu spec dùng để kết luận lại rộng hơn nhiều — *"Không có trong quy trình ISO đang ban hành"* (`spec:18`) — trong khi chính bước 1 trỏ sang một quy trình khác chưa ai đọc: *"Tham khảo Quy trình Cung ứng…"* (`:60`) với biểu mẫu *"SOL-PRO-SP-001 Quy trình Cung ứng"* (`:47`). Chi tiết ở N1.
Nói gọn: **spec không sập**, vì kết luận "khoảng trống nằm giữa ISO-1 và ISO-2" vẫn đứng đối với văn bản trình ký. Cái phải sửa là **phạm vi của câu khẳng định vắng mặt** và ba ô trong bảng đối chiếu nguồn đang nói quá (B1, B2, N1). Không có điểm nào chặn Wave 1-2 về mặt kỹ thuật; các điểm CAO chặn **tư cách văn bản** gửi owner.
## Bịa ## Bịa
> Giới hạn tự khai: tôi chỉ cầm **2 trong 3 nguồn** (ISO docx + FO-002.01). Nguồn PDF `01- Quy trinh hien tai` KHÔNG có trong run-folder, nên mọi chi tiết b.8-9 mà spec quy cho PDF tôi **không kết luận bịa** — chỉ đánh dấu chưa kiểm được.
- **B1 — CAO — "CÙNG HÌNH" là 4 trạm đối 3 bước, không phải cùng hình.** Spec `spec-ke-hoach-ky-ket-hd-28-07-2026.md:91` (bảng §1.7 dòng 9) viết: *"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**"*. Đối chiếu ISO Bảng 2: bước 5 *"In hợp đồng | QS/NV.PB"* (`src-QT-TRINH-KY-HD.txt:64`), bước 6 *"Kiểm tra | CCM"* (`:65`), bước 7 *"Duyệt | BOD/ / NĐUQ"* (`:66`). **Không có bước nào của ISO dành cho Phòng Cung ứng trong chuỗi ký** — ISO bước 5 nói thẳng *"QS/NV.PB chuyển HĐ cho CCM"* (`:64`), tức là đi từ đơn vị đề xuất sang CCM, bỏ qua PRO. Trong khi form giấy có hẳn một trạm PHÒNG CUNG ỨNG 10 dòng ký (`src-FO-002.01-bang-kiem-tra-HD.txt:21-30`) và LƯU Ý bắt buộc: *"Tất cả các loại hợp đồng còn lại phải chuyển qua Phòng Cung ứng và Phòng CCM trước."* (`:46`). ⇒ Ô "≈ ISO 5-7" và chữ "CÙNG HÌNH" khai một sự tương ứng không tồn tại (4↔3, trạm PRO không có bước ISO đối ứng). **Ghi chú quan trọng: phát hiện này lại là ĐẠN CHO spec** — nó chứng minh form giấy đã chạy chuỗi PRO→CCM→GĐ mà văn bản ISO chưa cập nhật, tức chuỗi b.10→11→12 của spec bám form giấy chặt hơn bám ISO. Sửa đúng cách là tách ô này thành 1 dòng mâu thuẫn ISO↔FO thật, chứ không phải xoá.
- **B2 — CAO — kết luận "0 mâu thuẫn" về vai không dựng được từ nguồn.** Spec `:90` (dòng 8) viết: *"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**"*. Bảng định nghĩa ISO (`src-QT-TRINH-KY-HD.txt:17-39`) liệt **23 mục**, trong đó tối thiểu 6 vai có mặt trong dòng chảy nhưng không có trong danh sách 3 cột của spec: `FIN` (`:22`), `ACT` (`:23`), `EQU` (`:24`), `TBP`/`TP` (`:27-28`), `QS` (`:33`), và cặp `PD` = *"Giám đốc Thi công"* (`:31`) tách khỏi `PM` = *"Giám đốc Dự án"* (`:32`). Bước 3 ISO gọi đích danh cả cụm: *"Góp ý nội dung | PD/PM/PRO/ / CCM/FIN/ACT"* (`:62`). Muốn giữ kết luận "0 mâu thuẫn" thì phải nêu rõ phạm vi là *chỉ 3 trạm duyệt của khúc b.10-12*, chứ không phải toàn bộ tên phòng/vai.
- **B3 — TRUNG — số "7 phase" tự chọi với chính câu của nó.** Spec `:34` viết: *"**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ự"* rồi liệt kê **9 cặp**`:35-37`. Bên ISO tôi đã đếm tay: Bảng 2 khai `(10r x 3c)` = 1 dòng tiêu đề + **9 dòng bước** (`src-QT-TRINH-KY-HD.txt:59-68`) ⇒ **9 là con số đúng của phía ISO**. Vậy lệch nằm ở phía spec: hoặc viết "9 phase", hoặc phải nói rõ 2 phase nào không mang nhãn `[LEGACY]`, vì "khớp 1:1" trên 9 mục mà chỉ có 7 mục ở vế trái là không đóng được.
- **B4 — TRUNG — mệnh đề thực nghiệm không nguồn, không phép kiểm.** Spec `:60` (b.8) viết: *"🔴 **sơ đồ KHÔNG vẽ vòng lặp này, thực tế có**"* cho vòng `TvgsBac→DaNop`. Đây là một khẳng định về thực tế vận hành, không kèm trích lời owner, không kèm dữ liệu, và không nêu phép kiểm nào làm nó trượt nếu sai. Nó lại thêm hẳn một cạnh chuyển-trạng-thái vào vòng đời item. Cần một câu owner xác nhận hoặc hạ xuống mức đề xuất có dấu hỏi.
- **B5 — THẤP (chỉ ghi nhận, không kết tội) — cụm b.8-9 không kiểm được bằng 2 nguồn tôi cầm.** Bộ trường *{Kind, NCC, Status, TvgsName, ngày, scan}* và vòng đời *`ChuaNop→DaNop→TvgsDuyet|TvgsBac`* (`spec:60`) không xuất hiện trong ISO lẫn FO-002.01. Điểm kiểm được: chữ **TVGS không có trong bảng ĐỊNH NGHĨA, TỪ VIẾT TẮT của ISO** (`src-QT-TRINH-KY-HD.txt:16-39`) và cũng không có trong bất kỳ bước nào của Bảng 2. Vì spec tự nhận là *"ĐỀ XUẤT BỔ SUNG QUY TRÌNH ISO"* (`spec:22`), nếu ban hành thì phải bổ sung định nghĩa TVGS vào mục 4 của QT.
## Rơi ## Rơi
- **R1 — TRUNG — checklist CCM rơi mất một nửa.** Spec `:89` (dòng 7) ghi checklist là *"(PRO: điều khoản/thanh toán/rủi ro pháp lý · CCM: giá vs NS)"*. Form giấy cho CCM có **hai** mục, không phải một: *"G/trị HĐ so với NS / / Rủi ro pháp lý HĐ"* (`src-FO-002.01-bang-kiem-tra-HD.txt:31`, lặp lại y hệt ở `:32-40`). Mục *"Rủi ro pháp lý HĐ"* của CCM bị rơi, trong khi chính nó là mục trùng tên với PRO — nghĩa là form cố ý bắt **hai** phòng cùng soi rủi ro pháp lý một cách độc lập. Nếu O-Q2 chốt số hoá checkbox mà bảng tra chỉ liệt 1 mục cho CCM thì sẽ dựng thiếu.
- **R2 — TRUNG — kiểm kê checklist bỏ 2 trạm trên 4.** Form có mục kiểm cho cả bốn trạm: trạm đề xuất *"PHÒNG BAN/DỰ ÁN / ĐỀ XUẤT"* với mục *"Soạn thảo / / Nội dung HĐ"* (`:15`, lặp `:16-20`); trạm *"PHÒNG CUNG ỨNG"* (`:21`); trạm *"PHÒNG CCM"* (`:31`); và trạm *"GIÁM ĐỐC / Hoặc Người được ủy quyền"* với mục *"Ký kết, phát hành"* (`:41`, lặp `:42-45`). Spec chỉ liệt 2 trạm giữa. Riêng mục của trạm giám đốc đáng chú ý vì nó gộp **ký kết và phát hành** vào một ô, tức trên giấy hai việc này là một hành vi của cùng một người.
- **R3 — THẤPTRUNG — LƯU Ý của form bị trích mất nửa sau.** Spec `:85` trích đúng vế đầu: *"mỗi bộ phận 01 ngày, quá hạn XEM NHƯ ĐÃ THÔNG QUA"*. Nguyên văn form còn vế sau: *"Nếu cần kéo dài thời gian, bộ phận kiểm tra phải thông báo cho bộ phận soạn thảo biết và theo dõi, đồng thời phải ghi chú về việc kéo dài thời gian vào phần \"Ý kiến\"."* (`src-FO-002.01-bang-kiem-tra-HD.txt:46`). Vế sau **không phải SLA-engine** — nó là ràng buộc nội dung đặt lên ô ý kiến, nên quyết định O-2 (*"không SLA-engine"*, `spec:47`) không phủ hết nó. Đây là thứ rẻ nhất để giữ: một dòng ghi chú trong `LevelOpinions.Comment`.
- **R4 — THẤP — nghĩa vụ lưu trữ bản kiểm tra.** Form kết bằng: *"Bảng kiểm tra này phải được lưu kèm với bộ hồ sơ gốc."* (`:46`). Spec §① không nhắc đường xuất hoặc lưu bảng kiểm tra số hoá kèm hồ sơ. Nếu O-Q2 chốt số hoá checkbox thì nghĩa vụ này thành yêu cầu xuất file, cần nêu trước khi chốt.
- **R5 — THẤP — toàn bộ phần đầu form (THÔNG TIN HỢP ĐỒNG) không xuất hiện trong spec §①.** Form yêu cầu: *"Phòng ban/Dự án"* (`:8`), *"Loại Hợp đồng : Chủ đầu tư | Nhà cung cấp | Thầu phụ | Tổ đội | Thiết bị thuê | Khác"* (`:9`), *"Tên đối tác"* (`:10`), *"Nội dung hợp đồng"* (`:11`). Đáng chú ý là hai lựa chọn **"Thiết bị thuê"** và **"Khác"** — chúng không nằm trong 7 loại hợp đồng của hệ thống, nên nếu phiếu kế hoạch mang trường "Loại HĐ" thì phải quyết ánh xạ hai ô này về đâu.
- **R6 — THẤP — bước 9 ISO bị rút gọn khi ánh xạ.** Spec `:36` ghi *"`DaPhatHanh=9`↔Phát hành"*. Nguyên văn ISO là *"Phát hành hợp đồng. Lưu trữ hồ sơ / Issue and archive the contract"* với 4 việc con: *"PB/BCH CT scan và gởi 01 bản gốc cho CCM"*, *"thông báo và chuyển bản gốc hợp đồng đã ký (theo đúng số lượng) đến NTP/NCC"*, *"Lưu trữ bản gốc hợp đồng"*, *"Lưu trữ trên Server theo Filing System (Hợp đồng bản cứng chính và bản scan)"* (`src-QT-TRINH-KY-HD.txt:68`). Nửa "lưu trữ" biến mất khỏi nhãn ánh xạ.
- **R7 — THẤP — kênh luân chuyển do ISO quy định là Email, spec thay bằng thông báo trong ứng dụng.** ISO bước 2 nguyên văn: *"QS/NV.PB gởi dự thảo HĐ cho các Phòng ban để lấy ý kiến qua Email."* (`:61`). Spec b.7 chốt: *"**Email ra ngoài = ngoài scope** (SMTP TODO toàn hệ thống)"* (`spec:59`). Đây là đánh đổi có khai báo nên không phải lỗi giấu, nhưng vì spec tự nhận là đề xuất sửa ISO thì phải nói rõ: bản ISO sửa đổi sẽ phải sửa luôn cụm "qua Email" ở bước 2, nếu không văn bản và phần mềm sẽ nói hai kiểu.
- **R8 — THẤP — quy định mã số bị bỏ trống cho phiếu mới.** Cột Biểu mẫu của ISO gắn *"SOL-CCM-RG-001 / Quy định mã số hợp đồng và đơn hàng"* vào **mọi** bước (`:48-56`), và bước 5 nói *"Cập nhật số Hợp đồng theo quy định."* (`:64`). Spec §① không nói phiếu Kế hoạch ký kết có mã theo quy tắc nào. Đây là câu hỏi owner rẻ, nên hỏi cùng đợt 5 câu đang treo.
- **R9 — THẤP — nguồn ISO tự vênh, spec chưa ghi nhận.** Bảng 1 gán bước 5 cho **NTP/NCC/TĐ** kèm hạn *07 ngày* (`:51`), còn Bảng 2 gán bước "In hợp đồng" cho **QS/NV.PB** (`:64`) và mô tả bên trong lại là *"NTP/NCC/TĐ in hợp đồng 2 mặt, ký, đóng dấu Hợp đồng và gửi về cho PB/BCH CT."*. Vênh này nằm ngoài khúc b.7→12 nên không chặn, nhưng nếu spec định trích ISO làm căn cứ ban hành thì nên biết là bản gốc có chỗ tự mâu thuẫn.
## Sai vai ## Sai vai
Ba ánh xạ lõi **đúng nguyên văn**, xem mục Củng cố. Bốn điểm lệch:
- **V1 — TRUNG — "PB/BCH CT" bị thu về một mình "BCH".** ISO viết cặp này ở mọi chỗ trọng yếu: trách nhiệm bước 1 là *"PB/BCH CT"* (`src-QT-TRINH-KY-HD.txt:60``:47`), bước 8 *"PB/BCH CT chuyển hợp đồng đã được ký duyệt đến HRA/ISO"* (`:67`), bước 9 *"PB/BCH CT/ / CCM"* (`:68`). Form giấy đặt tên trạm đề xuất là *"PHÒNG BAN/DỰ ÁN"* (`src-FO-002.01-bang-kiem-tra-HD.txt:14-15`). Spec `:59` viết b.7 là *"PMH chuyển thông tin cho BCH"*`:54` chốt *"BCH = người tạo phiếu (`CreatedBy`)"*. Cơ chế `CreatedBy` thì trung tính nên chạy được cho cả hai loại đơn vị, nhưng **nhãn** thì hẹp: nếu phiếu do một Phòng ban khởi tạo (mua sắm tập trung, không thuộc công trường nào) thì cả câu chữ b.7 lẫn tên màn hình chờ lập kế hoạch đều gọi sai đối tượng. Sửa rẻ: đổi nhãn thành "Phòng ban / Ban chỉ huy công trường".
- **V2 — TRUNG — gộp PD với PM, bỏ QS và TBP/TPB.** ISO tách rõ *"PD : Giám đốc Thi công"* (`:31`) và *"PM : Giám đốc Dự án"* (`:32`) là hai chức danh khác nhau, đồng thời dùng *"QS : QS công trường"* (`:33`), *"TBP : Trưởng Bộ phận"* (`:28`), *"TP : Trưởng Phòng ban"* (`:27`) như những vai có hành vi riêng — ví dụ bước 5: *"PD/PM/TPB kiểm tra các điều khoản hợp đồng và ký nháy trên từng HĐ"**"PD/PM/TPB/TBP ký duyệt và cho ý kiến (nếu có) lên tờ cover chấp thuận HĐ"* (`:64`). Spec `:52-53` chỉ liệt `ProjectManager`/`Drafter` cho toàn bộ cụm này. Với khúc b.7→12 (BCH lập phiếu, ba trạm duyệt) thì gộp như vậy tạm chấp nhận được, nhưng phải khai là **gộp có chủ đích**, không được kết luận "0 mâu thuẫn".
- **V3 — THẤP — "CEO" không phải từ của nguồn.** ISO định nghĩa *"BOD : Ban Giám đốc/Board of Directors"* (`:18`) và *"NĐUQ : Người được ủy quyền"* (`:19`); form ghi trạm là *"GIÁM ĐỐC / Hoặc Người được ủy quyền"* (`src-FO-002.01-bang-kiem-tra-HD.txt:41`). Spec b.12 gọi *"CEO ký"* (`spec:64`). BOD là một tập thể, CEO là một cá nhân — vai hệ thống `Director` + `AuthorizedSigner` thì đúng, chỉ có chữ "CEO" là nhập từ sơ đồ PDF chứ không từ hai nguồn văn bản.
- **V4 — THẤP — trạm đóng dấu trong ISO là "HRA/ISO", hai bên chứ không một.** Nguyên văn bước 8: *"Đóng dấu hợp đồng | HRA/ISO"**"chuyển hợp đồng đã được ký duyệt đến HRA/ISO để đóng dấu theo quy định"* (`src-QT-TRINH-KY-HD.txt:67`). Spec `:90` chỉ ghi HRA. Ngoài ra form giấy **không có trạm HRA nào** (`:15-45` chỉ 4 trạm) — tức con dấu nằm ngoài bảng kiểm tra. Điều này liên quan trực tiếp tới b.12: nếu "CCM đóng dấu" trong spec `:64` là đóng dấu **hợp đồng** thì nó chọi với ISO (dấu thuộc HRA/ISO); nếu là đóng dấu **phiếu kế hoạch** thì phải nói rõ, vì hai nghĩa dẫn tới hai thiết kế khác nhau.
## Nống biên ## Nống biên
- **N1 — CAO — khai "nguồn im lặng" ở chỗ nguồn có nói, và đây đúng là chân chống của cả spec.** Spec `:17-19` viết: *"**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**."*, và bảng §1.7 dòng 1 ghi cột ISO là *"**KHÔNG CÓ**"* (`:83`), từ đó suy ra *"Spec này vừa là spec phần mềm, vừa là ĐỀ XUẤT BỔ SUNG QUY TRÌNH ISO"* (`:22`). Nhưng chính bước 1 của ISO **trỏ ra một quy trình khác**: nội dung chi tiết viết *"Tham khảo Quy trình Cung ứng để so sánh và lựa chọn NTP/NCC/TĐC đúng quy định."* (`src-QT-TRINH-KY-HD.txt:60`), và cột Biểu mẫu của đúng dòng đó ghi *"SOL-PRO-SP-001 Quy trình Cung ứng / SOL-PRO-SP-001 Procurement Procedure"* (`:47`) — đây là biểu mẫu **duy nhất** khác cả 8 dòng còn lại, tức là một cái trỏ có chủ đích. Hệ quả: cái đo được là *"không có trong QT-TRINH-KY-HD"*, **không** phải *"không có trong quy trình ISO đang ban hành"*. Muốn giữ tư cách "đề xuất bổ sung ISO" thì phải đọc `SOL-PRO-SP-001` trước, vì khúc b.7→12 nằm ngay ranh giữa cung ứng và ký kết — đúng vùng mà văn bản kia phụ trách. Đây là nguồn thứ tư, chưa ai chạm.
- **N2 — TRUNG — bảng tự đặt luật "KHÔNG tự quyết" rồi tự quyết 3 ô.** Tiêu đề `:79` viết *"Mâu thuẫn / im lặng giữa 3 nguồn (bảng tra — **KHÔNG tự quyết**)"*. Trong bảng: dòng 3 kết *"O-2 ⇒ chỉ ghi nhận"* (`:85`), dòng 5 kết *"Hệ thống có sẵn `TraLai` + 4 return-mode"* (`:87`), dòng 6 kết *"**KHÔNG áp phiếu KH**"* (`:88`). Ba ô này đều là quyết định thiết kế, không phải tra cứu. Dòng 6 nặng nhất vì nó gạt đi quy tắc rẽ nhánh **duy nhất** mà giấy tờ đưa ra (xem N3). Đề nghị: hoặc đổi nhãn cột thành "Xử lý (đã quyết / chờ owner)", hoặc chuyển 3 ô này sang mục quyết định để owner nhìn thấy chúng là lựa chọn chứ không phải sự thật.
- **N3 — TRUNG — dùng FO-002.01 theo hai chiều ngược nhau.** Dòng 9 lấy form làm *"Đối chứng GIẤY"* rằng chuỗi duyệt của phiếu kế hoạch **cùng hình** với chuỗi trên giấy (`:91`); nhưng dòng 2 lại khai form *"im lặng"* về thời hạn của khúc 7→12 (`:84`). Nếu hai chuỗi thật sự cùng hình thì quy tắc xử lý của form — *"Mỗi bộ phận chỉ có 01 ngày để xử lý. Nếu kéo dài hơn 01 ngày mà vẫn chưa xử lý xong, thì xem như đã thông qua."* (`src-FO-002.01-bang-kiem-tra-HD.txt:46`) — chính là thứ gần nhất với một quy định thời hạn cho chuỗi đó. Không đòi hoà giải (O-2 đã chốt bỏ SLA-engine), chỉ đòi bảng đừng nói nguồn im lặng ở nơi mình vừa dùng nguồn đó làm bằng chứng.
- **N4 — THẤP — "ISO không nói" về bypass Chủ đầu tư thực ra là "ISO loại trừ".** Dòng 6 ghi cột ISO là *"không nói"* (`:88`). Phạm vi ISO nói rõ: *"Áp dụng đối với các hợp đồng/phụ lục hợp đồng **Thầu phụ/Nhà cung cấp/Tổ đội**"* (`src-QT-TRINH-KY-HD.txt:12`), tức hợp đồng Chủ đầu tư **nằm ngoài phạm vi** văn bản này — khác hẳn im lặng. Ô đúng là "ngoài phạm vi". Khác biệt này có ích: nó giải thích vì sao chỉ có form giấy mới nói tới nhánh CĐT (`src-FO-002.01-bang-kiem-tra-HD.txt:46`).
- **N5 — THẤP — mẫu tìm hẹp cho một khẳng định vắng mặt.** Spec `:17` khẳng định *"repo 0-hit `shopdrawing|TVGS|duyệt mẫu`"*. Khẳng định này có phép kiểm (chạy lại lệnh tìm) nên đạt sàn đo được, nhưng ba từ khoá tiếng Anh và một cụm tiếng Việt không phủ các biến thể khả dĩ như "bản vẽ thi công", "duyệt vật tư mẫu", "giám sát". Vắng mặt chỉ chắc bằng đúng độ rộng của mẫu tìm.
- **N6 — KHÔNG TÍNH LỖI, ghi nhận kiểm soát tốt.** Phạm vi khai là b.7→12, và §2.5 có *"Cầu tiếp giáp b.12→13 (sửa 1 file) — ⚠️ chạm module Contract"* (`:196`) tức bước ra ngoài phạm vi. Nhưng spec đã tự rào đúng chỗ: O-3 chốt *"Wave nào chạm module Contract phải hỏi lại anh"* (`:48`) và WAVE 5 đánh dấu *"CHẶN: Q6, Q11 + anh cho phép chạm HĐ"* (`:293`). Nống có khai báo và có cổng chặn thì không phải nống lén.
## Điểm CỦNG CỐ ## Điểm CỦNG CỐ
Tám chỗ tôi cố đánh mà không đổ, ghi lại để đợt sau đừng soi lại:
1. **Số 9 của ISO đúng.** Bảng 2 = tiêu đề + 9 bước (`src-QT-TRINH-KY-HD.txt:59-68`); Bảng 1 cũng có đúng 9 dòng trách nhiệm có nội dung (`:47-55`) — hai bảng khớp nhau về số bước.
2. **Ánh xạ 9↔9 khớp cả tên trạm lẫn thứ tự** (chi tiết ở Verdict mục 1). Không có bước nào bị đảo, không có bước nào bị nhét thêm.
3. **Dãy thời hạn trích đúng từng ô.** Spec `:85` ghi *"07/07/07/07/03/01 ngày"*; Bảng 1 cho: `07 ngày` ở bước 2 (`:48`), bước 3 (`:49`), bước 4 (`:50`), bước 5 (`:51`), rồi `03 ngày` ở CCM (`:52`), `01 ngày` ở BOD/NĐUQ (`:53`), và ba bước 1/8/9 bỏ trống. Sáu trên sáu.
4. **Quy tắc quá hạn của form trích đúng nguyên văn** (`src-FO-002.01-bang-kiem-tra-HD.txt:46`), và nhận xét *"auto-pass mâu thuẫn mô hình duyệt-chặn V2"* (`spec:85`) là đúng bản chất: form cho đi tiếp khi im lặng, khung V2 thì chặn tới khi có người bấm.
5. **Ba ánh xạ vai lõi đúng từng chữ:** *"CCM : Phòng Kiểm soát Chi phí"* (`:20`) ↔ `CostControl`; *"PRO : Phòng Cung ứng"* (`:21`) ↔ `Procurement`; *"BOD : Ban Giám đốc"* + *"NĐUQ : Người được ủy quyền"* (`:18-19`) ↔ `Director` + `AuthorizedSigner`, và form gọi đúng cặp đó là *"GIÁM ĐỐC / Hoặc Người được ủy quyền"* (`src-FO-002.01-bang-kiem-tra-HD.txt:41`).
6. **Thứ tự bốn trạm trên form đúng như spec ghi.** Đọc theo thứ tự vật lý các dòng của bảng: ĐỀ XUẤT (`:15-20`) → PHÒNG CUNG ỨNG (`:21-30`) → PHÒNG CCM (`:31-40`) → GIÁM ĐỐC (`:41-45`). Chuỗi `ĐỀ XUẤT→PRO→CCM→GĐ``spec:91` không bịa thứ tự; chỗ sai chỉ là mệnh đề "cùng hình với ISO 5-7" (B1).
7. **Nhánh bypass Chủ đầu tư có thật và trích đúng.** Form: *"Đối với hợp đồng/phụ lục hợp đồng ký kết với Chủ đầu tư, Đơn vị đề xuất có thể chuyển trực tiếp hợp đồng Giám đốc."* (`:46`) — khớp `spec:88`.
8. **Lập luận §1.6 "chỉ một chuỗi duyệt" nhất quán với cả hai nguồn tôi cầm.** Form có đúng một chuỗi ký cho cả bộ hồ sơ, ISO cũng chỉ có một dòng duyệt tuyến tính; không nguồn nào vẽ chuỗi duyệt thứ hai cho mẫu hay bản vẽ. Kết luận "b.8-9 là bảng con, không phải phiếu con" (`spec:72-77`) không bị nguồn phản bác.
<!-- END · TOTAL=23 DIEM (3 CAO / 8 TRUNG / 12 THAP; +1 ghi-nhan N6 khong tinh, +8 diem cung co) · VERDICT=GO-WITH-FIXES -->

View File

@ -1,27 +1,192 @@
# LĂNG KÍNH 3/4 — SCHEMA 7 BẢNG (reviewer, S157) # LĂNG KÍNH 3/4 — SCHEMA 7 BẢNG (reviewer, S157)
> Khung RỖNG ghi TRƯỚC khi đọc (chống #53 return-garble). Nội dung điền dần trong lúc soi. Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §②.2 (dòng 113-136).
Nguồn đối chiếu ĐÃ ĐỌC: `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` ·
`src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs` (+ grep xác minh trên config/service/enum).
## Verdict ## Verdict
_(chưa chấm)_ **GO-WITH-FIXES** — hình dạng 7 bảng ĐÚNG HƯỚNG (loose-Guid, decimal(18,2), query-filter, FK Cascade/Restrict
đều khớp twin thật), nhưng **5 điểm HIGH phải đóng TRƯỚC Wave 1** vì chúng làm migration/CQRS/FE sai IM LẶNG
(build vẫn xanh): thiếu bảng lịch-sử-transition mà chính service được lệnh copy có ghi vào, thiếu đăng ký ở 3 site
cross-module `ApprovalWorkflowV2AdminFeatures`, thiếu 4 cột Changelog, không khai base-class cho bảng #2-#7, và
bộ số enum phase đụng khuôn FE đang được copy.
Phân bố: **5 HIGH · 7 MAJOR · 3 MEDIUM · 7 CỦNG CỐ = 22 điểm**.
## Bảng THỪA ## Bảng THỪA
_(chưa soi)_ **T1 [MAJOR] — #7 `ContractSigningPlanCodeSequences` gộp được, KHÔNG mất gì.**
Repo đã có **4 bảng giống hệt nhau về cấu trúc** `(Prefix string PK · LastSeq int · UpdatedAt)`:
`Contracts/ContractCodeSequence.cs:6-11` · `Office/ProposalCodeSequence.cs:8-13` ·
`Office/WorkflowAppCodeSequence.cs:14-19` · (+ `PurchaseEvaluationCodeSequence`, mirror khai ở
`ProposalCodeSequence.cs:4`). Trong đó `WorkflowAppCodeSequence` **đã là bảng DÙNG CHUNG cho 4 module Office**,
phân biệt nhau bằng Prefix có không-gian-tên: `"DT/LR/2026"` (nghỉ phép) · `"DT/OT/2026"` (OT) ·
`"DT/CT/2026"` (công tác) · `"DX/XE/2026"` (đặt xe) — xem `WorkflowAppCodeSequence.cs:8-11`.
Prefix `"KHKK/{YYYY}"` nhét vào bảng đó là **0 va chạm** (khoá là chuỗi, đã namespaced sẵn).
⇒ Cắt #7 ⇒ còn **6 bảng**, giảm 1 CreateTable + 1 configuration + 1 DbSet, **không mất tính atomic**.
**T2 [MAJOR] — lý do bác biến-thể-5-bảng đứng NỬA, đổ NỬA.**
- Nửa ĐỨNG: "giữ #6 Changelogs" có bằng chứng thật — Mig 60/61 phải backfill từ changelog (CLAUDE.md ghi
Mig 60 soi nhầm bảng Approvals → Mig 61 mới đúng Changelogs). Thứ tự ưu tiên spec đưa ("cắt #7 trước,
giữ #6") là ĐÚNG.
- Nửa ĐỔ: mệnh đề *"mất mã phiếu atomic"* **không suy ra được**. Tính atomic đến từ **transaction
SERIALIZABLE trong CodeGen service**, không đến từ việc bảng đó thuộc riêng module — nói rõ trong chính
comment của cả 3 bảng: `ContractCodeSequence.cs:5` · `ProposalCodeSequence.cs:7` ·
`WorkflowAppCodeSequence.cs:12-13`. Vậy "cắt #7" ⇏ "mã tay"; nó chỉ là "dùng chung bảng sequence sẵn có".
- Các bảng còn lại **KHÔNG gộp được** (đã xét từng cặp): #2 Lines (multi-winner, per-NCC) không nhét vào
header; #3 DossierItems không nhét vào #5 Attachments vì trạng thái `ChuaNop` là hàng **không có file**
gộp là phải đẻ attachment giả; #4 LevelOpinions bị máy-móc admin V2 truy vấn theo `(phiếu × Level)` nên
phải là bảng riêng có UNIQUE.
## Bảng THIẾU ## Bảng THIẾU
_(chưa soi)_ **H1 [HIGH] — không có `ContractSigningPlanApprovals` (lịch sử transition), trong khi service được lệnh copy CÓ ghi vào bảng đó.**
`ContractWorkflowService.cs:151` ghi thẳng: `db.ContractApprovals.Add(new ContractApproval { ContractId = …,
FromPhase = fromPhase, ToPhase = fromPhase, … })`. Twin PE cũng có nhánh này: `PurchaseEvaluation.cs:137`
(`List<PurchaseEvaluationApproval> Approvals`) + index chuyên dụng `(PurchaseEvaluationId, ApprovedAt)`
`PurchaseEvaluationConfiguration.cs:168`; `PurchaseEvaluationApproval.cs:6-7` ghi rõ *"Lịch sử phê duyệt —
giống ContractApproval pattern"*. Spec §2.1 bắt copy `ApproveV2Async:217-394` ⇒ đoạn copy sẽ trỏ vào một
bảng KHÔNG TỒN TẠI trong danh sách 7 bảng.
⇒ Chọn 1 trong 2 và **ghi vào spec**: (i) thêm bảng #8 `…Approvals`, hoặc (ii) tuyên bố "Changelog gánh vai
Approvals" **kèm** bù cột `FromPhase/ToPhase/Decision/Comment/ApprovalWorkflowLevelId` (hiện #6 chỉ có MỘT
trường phase là `PhaseAtChange`, không có cặp from→to, không có Decision).
**H2 [HIGH] — module thứ 8 phải đăng ký ở 3 site liệt-kê-thủ-công trong `ApprovalWorkflowV2AdminFeatures.cs`; spec chỉ nói chung chung.**
File này liệt kê **từng bảng `*LevelOpinions` một** ở 3 chỗ:
`:911` + `:929` (Merge "chữ ký còn sống" — `db.ProposalLevelOpinions…` / `db.TravelRequestLevelOpinions…`) ·
`:971` + `:992` (rows.AddRange dựng `RetainedOpinionRef`) · `:1044` + `:1056` (dọn chữ ký MỒ CÔI).
Chuỗi guard S155 đứng trên dữ liệu này: `:651` *"F-5/F-5a — chặn LỊCH SỰ trước khi FK nổ"*`:733`
*"Dọn rác chữ ký MỒ CÔI trước khi xoá dòng"*.
⇒ Nếu `ContractSigningPlanLevelOpinions` không được thêm vào **cả 3 site**, admin sửa quy trình `type = 10`
sẽ **không nhìn thấy** chữ ký của phiếu KHKK, xoá Level đang có chữ ký → FK Restrict nổ **500** — đúng lớp lỗi
S155 vừa vá xong. Spec §2.4 chỉ viết *"các site BE `ApprovalWorkflowV2AdminFeatures.cs`"* (không nêu 3 site này,
chỉ nêu map typeCode) ⇒ chưa đủ để người làm không sót.
## Cột sai ## Cột sai
_(chưa soi)_ **C1 [HIGH] — #6 Changelogs thiếu 4 cột so với twin.**
`PurchaseEvaluationChangelog.cs:14-22` có: `EntityType` · `EntityId` · `Action` · `PhaseAtChange` · `UserId` ·
`UserName` · `Summary` · `FieldChangesJson` · `ContextNote`. Spec #6 chỉ liệt `Action · PhaseAtChange · UserId ·
Summary · ContextNote`**thiếu `EntityType`, `EntityId`, `UserName`, `FieldChangesJson`**.
Hệ quả đo được: index twin `(PurchaseEvaluationId, EntityType)` (`PurchaseEvaluationConfiguration.cs:188`) mất
chỗ dựa; `LogTransitionAsync` (`ContractWorkflowService.cs:405`) gọi `changelog.LogWorkflowTransitionAsync(...)`
— copy service changelog sang module mới sẽ không biên dịch nếu entity thiếu trường; và mất khả năng ghi diff
trường (`FieldChangesJson`, twin khai `nvarchar(max)``PurchaseEvaluationConfiguration.cs:185`).
**C2 [MAJOR] — `Status int` bắt đầu từ **0** (`ChuaNop=0`) là lệch quy ước + bẫy JavaScript.**
Mọi enum trong repo bắt đầu ở 1: `Office/Enums.cs:9` (`Confirmed=1`) · `:19` (`Nhap=1`) · `:31` (`Nhap=1`) ·
`:41` (`Hardware=1`) · `:50` (`Low=1`) · `:58` (`Open=1`); `ApprovalWorkflow.cs:55` (`DuyetNcc=1`).
Số 0 là giá trị **falsy** trong JS ⇒ `status ? A : B`, `if (filter.status)`, `?status=0` trên query-string đều
rơi im lặng ở FE. Đề nghị `ChuaNop = 1` (giữ `Khac = 99` của `Kind` — cái đó ĐÚNG khuôn `ItTicketCategory.Other = 99`,
`Office/Enums.cs:45`).
**C3 [MAJOR] — ~13 cột chuỗi không khai độ dài ⇒ EF sinh `nvarchar(max)`.**
Spec chỉ khai 2 độ dài (`HoSoLink 1000`, `Comment 2000`). Không khai: `MaKeHoach`, `GhiChu`, `Name` (#3), `Note`
(#2/#3), `TvgsName`, `Summary`, `ContextNote`, `SignedByFullName`, `FileName/Path/ContentType` (#5), `Prefix` (#7).
Twin khai đủ: `PurchaseEvaluationConfiguration.cs:22` (`HoSoLink` 1000) · `:184` (`ContextNote` 2000) ·
`ContractLevelOpinionConfiguration.cs:21-22` (`Comment` 2000, `SignedByFullName` 200 **IsRequired**).
`nvarchar(max)` không index được và không so sánh được trong filtered-unique.
**C4 [MAJOR] — `SlaDeadline?` chưa có người đọc ⇒ đúng lớp "cột có mà 0 ai đọc" mà chính spec cấm (dòng 136).**
Thiếu `SlaWarningSent` (twin `PurchaseEvaluation.cs:26`) và thiếu index `SlaDeadline` (twin
`PurchaseEvaluationConfiguration.cs:62`). Job SLA hiện chỉ đọc/ghi cờ này trên nhánh Contract:
`SlaExpiryJob.cs:139` (`.Where(c => !c.SlaWarningSent …)`) và `:169` (`c.SlaWarningSent = true;`).
⇒ Hoặc bỏ `SlaDeadline`, hoặc thêm cờ + index + khai rõ wave nào đăng ký module vào `SlaExpiryJob`.
**C5 [MAJOR] — header thiếu `DepartmentId`, làm vế "DeptManager" của guard trình mất chỗ neo.**
Twin có: `PurchaseEvaluation.cs:17` (`Guid? DepartmentId`). Guard spec §2.3 (dòng 164) =
`CreatedBy == actor DeptManager Admin`. Không có phòng trên phiếu thì "DeptManager" 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`
(`ApprovalWorkflow.cs:75`) chỉ là *hint* của quy trình, không thay được phòng của phiếu.
**C6 [MAJOR] — không có đường quay ngược Kế-hoạch → Hợp-đồng sau b.13.**
Twin có `PurchaseEvaluation.ContractId` (`:31`) + index (`PurchaseEvaluationConfiguration.cs:65`).
Ở đây HĐ sinh **theo từng NCC thắng**, mà #2 Lines cũng **theo từng NCC** ⇒ chỗ tự nhiên là
`ContractSigningPlanLines.ContractId?` (+ IX). Thiếu nó thì: không trả lời được "dòng này đã ra HĐ chưa",
không chặn được tạo HĐ hai lần cho cùng một NCC, và §2.5(a) không có chỗ ghi kết quả bắc cầu.
*(Lưu ý: đây KHÁC với "cột `SigningPlanId` trên PE" mà spec bác — bác cột đó là đúng, xem G6.)*
**C7 [MEDIUM] — `PeReferenceAmount`: GIỮ snapshot (đúng), nhưng thiếu mốc thời gian + luật ghi lại.**
Xem G5 để biết vì sao snapshot thắng "đọc live". Hai chỗ hở: (i) không có `PeReferenceAmountAt`
twin dùng `ApprovedBudgetSnapshotAt` làm **cờ "đã chốt"** (`PurchaseEvaluation.cs:132`, ghi rõ ở `:120`);
(ii) spec nói *"snapshot 1 lần"* lúc tạo nhưng không nói điều gì xảy ra khi Drafter **re-pin workflow** hay
sửa nháp (§2.3 cho phép re-pin) — có sinh lại Lines không, có tính lại số không.
## Ràng buộc sai ## Ràng buộc sai
_(chưa soi)_ **R1 [HIGH] — không khai lớp cơ sở (`BaseEntity` vs `AuditableEntity`) cho bảng #2-#7, trong khi #2 lại đặt UNIQUE lọc `WHERE IsDeleted=0`.**
Bộ lọc đó **đòi** cột `IsDeleted`, tức #2 buộc phải là `AuditableEntity`. Nhưng twin gần nhất của #2
`PurchaseEvaluationSupplier.cs:9``: BaseEntity` (không có xoá mềm) — và UNIQUE của nó **không lọc**:
`PurchaseEvaluationConfiguration.cs:105` `HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique()`.
⇒ Nếu người làm mirror twin (BaseEntity) thì migration sinh filter trỏ vào cột không tồn tại; nếu mirror spec
(AuditableEntity) thì lệch khuôn con-của-phiếu. Spec phải khai **base class cho từng bảng**.
**R2 [MEDIUM] — bất nhất nội bộ #2 (lọc) vs #4 (không lọc), và #4 kế thừa một vi phạm #57 tiềm ẩn.**
#4 không lọc là **khớp cả 6 twin**: `ContractLevelOpinionConfiguration.cs:34` · `ProposalLevelOpinionConfiguration.cs:28` ·
`TravelRequestLevelOpinionConfiguration.cs:28` · `OtRequestLevelOpinionConfiguration.cs:28` (+ Leave, PE).
Nhưng các entity đó **là** `AuditableEntity`: `PurchaseEvaluationLevelOpinion.cs:24`, `TravelRequestLevelOpinion.cs:16`
⇒ cả họ đang ở thế "xoá mềm + UNIQUE không lọc" = gotcha #57 dạng ngủ (chỉ chưa cắn vì đường ghi là UPSERT,
không soft-delete). Kế thừa thì kế thừa **có ý thức**: ghi 1 dòng lý do trong spec, nếu không lần sau ai đó
thêm đường soft-delete chữ ký là kẹt không ký lại được.
**R3 [MEDIUM] — loose-Guid đúng convention nhưng thiếu index đi kèm.**
Spec chỉ khai IX cho `PurchaseEvaluationId`. Twin đánh index cho **mọi** loose-Guid dùng để lọc:
`ProjectId` (`:58`) · `WorkItemId` (`:61`) · `ApprovalWorkflowId` (`:64`) · `ContractId` (`:65`) ·
`SlaDeadline` (`:62`); và ở bảng con: `SupplierId` (`:106`), `PurchaseEvaluationSupplierId` (`:206`).
⇒ Bổ sung IX: `ProjectId`, `ApprovalWorkflowId`, `DrafterUserId` (header) + `SupplierId` (#2) + `PlanId` (#4/#6/#7 theo cặp).
## Điểm CỦNG CỐ ## Điểm CỦNG CỐ
_(chưa soi)_ **G1 — trích dẫn `PurchaseEvaluationConfiguration.cs:84` CHÍNH XÁC ĐẾN DÒNG:** `b.HasQueryFilter(x => !x.IsDeleted);`
nằm đúng dòng 84. (Đã kiểm bằng cách đọc file, không tin lời spec.)
**G2 — `decimal(18,2)` đúng quy ước tiền tệ của repo:** `PurchaseEvaluationConfiguration.cs:23-30`
(`HasColumnType("decimal(18,2)")` cho các cột ngân sách/giá).
**G3 — "loose-Guid không FK vật lý sang PE/Supplier/Project" là quy ước THẬT, không phải suy diễn:**
`PurchaseEvaluation.cs:16` (comment `WorkItemId` — *"scalar loose-Guid (KHÔNG navigation, KHÔNG FK vật lý —
convention PE giống ProjectId/SelectedSupplierId)"*) + `PurchaseEvaluationConfiguration.cs:59-61`
(*"index lọc query, KHÔNG HasOne/FK vật lý (convention PE: chỉ ApprovalWorkflowId có FK)"*) + `:72-73`
(FK duy nhất ra ngoài module là `ApprovalWorkflowId`, `DeleteBehavior.Restrict`).
**G4 — bảng #4 mirror ĐÚNG 100% khuôn `ContractLevelOpinions`:** FK Cascade phiếu
(`ContractLevelOpinionConfiguration.cs:25-27`) + FK **Restrict** Level (`:30-32`) + UNIQUE `(phiếu, Level)` (`:34`)
+ `Comment` 2000 (`:21`). Đây là phần chắc tay nhất của §②.2. *(Kèm nghĩa vụ H2 — chọn Restrict thì phải vào 3 site admin.)*
**G5 — `PeReferenceAmount` snapshot: BÁC phương án "đọc live", giữ snapshot.** Ba căn cứ:
(i) tiền lệ cùng repo — `ApprovedPriceAmount` (`PurchaseEvaluation.cs:97`) và 11 cột `ApprovedBudget*`
(`:122-132`, Mig 67) đều là ảnh chụp tại thời điểm chốt;
(ii) nguồn live **biến động được dưới chân phiếu KHKK**: cờ F3 `AllowApproverEditDetails` cho người duyệt sửa
Hạng mục/NCC/Báo giá lúc đang duyệt (`ApprovalWorkflowLevel.cs:127-129`), cờ F6 `AllowApproverDelete` cho xoá
mềm phiếu (`:156-169`) — mà phiếu PE xoá mềm thì `HasQueryFilter` làm số live **biến mất**, snapshot thì còn;
(iii) spec **đã tự cài máy dò lệch** ở §2.5(a) (ghi ContextNote *"giá KH x vs SUM-PE y"*) ⇒ snapshot không
biến thành số mồ côi không ai đối chiếu.
**G6 — "KHÔNG thêm cột `SigningPlanId` lên PE" là quyết định ĐÚNG** (tra ngược bằng `Plans.Where(PeId == x)`);
đây đúng là lớp lỗi cột-phái-sinh-kẹt-stale (gotcha #81, `docs/gotchas.md:1343`).
**G7 (câu e, vế 2) — `ApplicableType = 10` TRỐNG THẬT, xác nhận bằng đọc mã chứ không bằng lời spec:**
`ApprovalWorkflow.cs:53-67` — enum dừng ở `TravelRequest = 9`, không có giá trị 10. Quét cả `src/` với mẫu
`= 10,` chỉ ra **một** kết quả duy nhất và nó thuộc enum KHÁC: `PurchaseEvaluationPhase.cs:24` `ChoDuyet = 10`.
⇒ Claim của spec ĐÚNG, append-only an toàn.
## Enum — điểm HIGH riêng
**E1 [HIGH] — `ContractSigningPlanPhase = 1/2/3/98/99` là bộ số THỨ BA, và nó đụng đúng khuôn FE mà §2.4 bắt copy.**
Repo đang sống hai quy ước:
- Họ PE/Contract: `PurchaseEvaluationPhase.cs:24` `ChoDuyet = 10` (+ TraLai 98 / TuChoi 99).
- Họ Office/Proposal — **chính là khuôn FE spec chọn** (§2.4: *"khuôn bộ Proposal ~978 LOC/app"*):
`Office/Enums.cs:17-24` `ProposalStatus { Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5 }`
`:29-36` `WorkflowAppStatus` y hệt, **dùng chung cho 5 module** (LeaveRequest + OtRequest + TravelRequest +
VehicleBooking + ItTicket — `:27`).
Va chạm cụ thể, đo được: **giá trị 3** = `DaDuyet` ở enum mới, nhưng = `TraLai` ở khuôn FE Proposal đang được
copy. Bảng map badge/nhãn trạng thái copy nguyên si sẽ **render sai mà không có lỗi biên dịch** — đúng lớp
"xanh-giả". Lý do spec đưa (*"giữ 98/99 cho quen mắt FE badge"*) chỉ đúng với FE PE/Contract, **không đúng với
FE khuôn đã chọn**.
⇒ Hai lối ra, chọn 1 và ghi vào spec: (a) dùng đúng bộ `WorkflowAppStatus` 1-5 (hết va chạm, thêm cơ hội tái
dùng chip/badge có sẵn), hoặc (b) giữ 1/2/3/98/99 nhưng **khai nghĩa vụ viết lại toàn bộ map trạng thái** khi
copy trang Proposal, kèm 1 dòng kiểm ở checklist wave.
<!-- END · TOTAL=22 DIEM · VERDICT=GO-WITH-FIXES (5 HIGH chặn Wave 1) -->

View File

@ -1,17 +1,288 @@
# LĂNG KÍNH 1/4 — WAVE-PLAN + ACCEPTANCE (reviewer đối kháng) # LĂNG KÍNH 1/4 — WAVE-PLAN + ACCEPTANCE (reviewer đối kháng)
> Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §③ CHIA WAVE. > Đích chấm: `spec-ke-hoach-ky-ket-hd-28-07-2026.md` §③ (dòng 208-340).
> Đối chứng: `runs/2026-07-27-S155-pe-delete-approver/run.md` (tiền lệ chia 2 đợt đã chạy THẬT). > Đối chứng: `runs/2026-07-27-S155-pe-delete-approver/run.md` (tiền lệ chia 2 đợt đã chạy THẬT).
> 4 trục tấn công: (a) acceptance đo được thật? (b) wave deploy độc lập thật? (c) thứ tự wave? (d) cờ CHẶN sai? > 4 trục: (a) acceptance đo được thật? (b) wave deploy độc lập thật? (c) thứ tự? (d) cờ CHẶN sai?
> Ký hiệu chứng: `spec:NNN` = dòng trong file spec; `run.md:NNN` = tiền lệ S155; còn lại là đường dẫn thật trong repo.
## Verdict ## Verdict
_(đang soi — điền cuối)_ **SUA-TRUOC-W1** — khung 5 wave đúng hướng và phần lớn acceptance có hình dạng đo được, **nhưng W1 và W2 chưa
deploy độc lập được như bảng khai**, và 4 phép đo trụ cột (policy · OR-of-N · test-count · dark-launch) hiện
**không thể làm cho trượt** kể cả khi implement sai. 19 điểm: **6 HIGH · 8 MED · 5 LOW** (+ 9 điểm củng cố).
Sửa 6 HIGH là spec đủ răng để phóng W1.
---
## Điểm HIGH ## Điểm HIGH
### H1 — W2 đẩy riêng lên prod thì **không tạo được phiếu nào**: phần wiring Designer type-10 không nằm trong wave nào
- **Chứng:** `spec:141` lệnh Create nhận `ApprovalWorkflowId` bắt buộc; `spec:143-144` validator đòi workflow có
`ApplicableType == ContractSigningPlan` (=10). `spec:192-194` khai phải sửa **Designer admin nhận type 10**
(`fe-admin/.../ApprovalWorkflowsV2Page.tsx:143` + `ApprovalWorkflowV2AdminFeatures.cs`, và phải grep lại đường
`fe-user` matrix). Nhưng danh sách file của **W1** (`spec:227-229`), **W2** (`spec:245-246`), **W3**
(`spec:265-266`) đều **không chứa 2 file đó**.
- **Hệ quả đo được:** sau khi deploy W2, admin không dựng nổi 1 `ApprovalWorkflow` type 10 ⇒ mọi `POST` đều rơi
validator ⇒ **acceptance W2#1 (`spec:248`, "→ 201") không thể đạt** trên chính prod mà nó vừa lên.
- **Đây đúng lớp lỗi tiền lệ:** `run.md:125`*"F.0 chứng minh A-E không chạy nếu thiếu F"*, tức đợt-1-một-mình
vô dụng. Spec trích đúng bài học đó ở `spec:210-211` rồi lặp lại nó ở W2.
- **Đề xuất:** đưa wiring type-10 (BE `ApprovalWorkflowV2AdminFeatures.cs` + fe-admin Designer + grep lại fe-user)
vào **W1**, và thêm acceptance W1: *admin tạo + lưu 1 quy trình type 10 →
`sqlcmd -Q "SELECT COUNT(*) FROM ApprovalWorkflows WHERE ApplicableType=10"` trả về `1`*. Việc này đồng thời
biến W1 từ "0 giá trị" thành "nền + dựng được quy trình" (xem C1 ở mục thứ tự wave).
### H2 — Tên policy trong spec **không khớp** menu key của chính spec ⇒ test hiện có sẽ ĐỎ, và trên prod là **500** chứ không phải 403
- **Chứng lệch:** `spec:181` chốt 4 key = `Csp` / `Csp_List` / `Csp_Create` / `Csp_Inbox`. `spec:173-174`
`spec:234` lại dùng policy = `ContractSigningPlans.{Read|Create|Update|Delete}`. Chính spec khai luật sinh
policy là `{menuKey}.{action}` (`spec:175`, foreach `All × Actions`) ⇒ với key `Csp` thì policy hợp lệ là
`Csp.Read`, **không tồn tại** `ContractSigningPlans.Read`.
- **Repo đã có lưới bắt đúng ca này:**
`tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs:138-139`
`MenuKeys.All.Should().Contain(parts[0], $"policy \"{policy}\" của {action} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy (bẫy Pe_* trong spec S155 §B3)")`.
⇒ code theo `spec:173` sẽ làm test này **đỏ**, và nếu lọt thì runtime ném lỗi policy-không-đăng-ký (**500**),
không phải 403 như W2#5 kỳ vọng.
- **Đề xuất:** chốt **một** tên canonical trước khi phóng W1 (khuyến nghị giữ key ngắn `Csp` ⇒ policy `Csp.Read`
…, hoặc đổi key thành `ContractSigningPlans*` cho khớp tên dài) và ghi vào bảng hợp-đồng-liên-lane (M4).
Đây là **đúng lớp `hợp-đồng-đứt-giữa-2-bờ`** của S155: hai bờ đều "hợp lý" khi đọc riêng, chỉ chết lúc nối.
### H3 — W1 acceptance #4 đo một thứ **không tồn tại trong DB**; đó lại là dòng duy nhất canh phần authz của W1
- **Chứng:** `spec:234`*"`sqlcmd` đếm policy: `|MenuKeys.All| × 4` tăng đúng **+16**; grep
`ContractSigningPlans.Read` có trong list policy"*. Policy được đăng ký **in-memory** trong
`Program.cs` (`AddAuthorization`, chính spec khai ở `spec:175`); DB chỉ có bảng `MenuItems``Permissions`.
**Không có bảng policy nào để `sqlcmd` đếm**, cũng không có artifact "list policy" để grep.
- **Mức độ:** W1 chỉ có 6 dòng acceptance, phần authz gói trọn trong dòng này ⇒ nếu 4 key vào nhầm chỗ
(đúng bẫy `Pe_*``spec:182-183` vừa cảnh báo), **không phép đo nào của W1 phát hiện được**.
- **Con số thì đúng:** `docs/STATUS.md:453` Menu keys = **54** ⇒ 54×4 = 216 → 58×4 = 232 = **+16**. Lỗi nằm ở
**cách đo**, không ở số.
- **Đề xuất:** thay bằng 3 phép chạy được — (i) `dotnet test --filter AuthorizePolicyRegression` PASS;
(ii) `grep -c "MenuKeys.Csp" src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs` trong khối `All` = 4;
(iii) 2 row canonical `docs/STATUS.md` bump `54→58``216→232` cùng commit.
### H4 — Phép "OR-of-N" của W3 **không chứng minh OR-of-N** (3 lỗ, trong đó 1 lỗ khiến service *không guard gì* cũng PASS)
- **Chứng:** `spec:268-269`*"workflow 1 Bước có 1 Cấp 2 người → **người thứ hai** duyệt được và phiếu **tiến
sang Bước kế** (không đòi cả 2 ký)"*.
- **Lỗ 1 (tự mâu thuẫn):** workflow **1 Bước** thì **không có "Bước kế"** — duyệt xong phải sang `DaDuyet`.
Người chạy acceptance sẽ hoặc bỏ vế sau, hoặc kết luận FAIL trên bản đúng.
- **Lỗ 2 (trúng may):** dưới đúng bug Proposal mà `spec:100-101` mô tả (`SelectMany` flatten +
`ElementAtOrDefault(order-1)` lấy **1 row**), trong 1 cấp 2 người thì **đúng một người** duyệt được. Nếu người
đó tình cờ là "người thứ hai" theo thứ tự flatten, **phép thử PASS trên implement hỏng**. Nhãn "người thứ hai"
cũng không định nghĩa (thứ tự tạo? thứ tự UI? thứ tự `Order` trong DB?).
- **Lỗ 3 (thiếu ca âm):** một service **bỏ hẳn guard approver** (ai cũng duyệt được) cũng PASS dòng này ⇒ phép đo
cho **0 bit** thông tin về authz. Đây đúng "phép 2-thế-giới": còn-guard và bỏ-guard cho cùng kết quả.
- **Đề xuất (thay nguyên dòng #1):** hai phiếu song sinh cùng workflow (1 Bước, 1 Cấp, người A + người B):
(i) phiếu-1 **chỉ A ký**`Phase=DaDuyet`; (ii) phiếu-2 **chỉ B ký**`Phase=DaDuyet`;
(iii) mỗi phiếu `SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions WHERE PlanId=@id` = **1** (không đòi 2 chữ ký);
(iv) **ca âm bắt buộc:** người C ngoài Cấp `POST {id}/transitions`**403**, và `Phase` không đổi.
Thêm 1 ca 2-Bước để vế "tiến sang Bước kế" có đối tượng thật.
### H5 — Lưới test rỗng ở đúng hai wave cần răng nhất; W2 **không có dòng `dotnet test` nào**
- **W1 (`spec:233`):** *"562 PASS **giữ nguyên**, 0 fail (chưa có test mới)"* — W1 không thêm test, không chạm
bảng mới ⇒ tiêu chí này chỉ là regression-guard, **0 bit** về việc W1 làm đúng. Tệ hơn: nó còn là **kỳ vọng
sai** nếu code theo `spec:173` (xem H2) vì `AuthorizePolicyRegressionTests` sẽ đỏ ⇒ người chạy sẽ tưởng mình
làm hỏng thứ khác.
- **W3 (`spec:280`):** *"562 + **N mới**, 0 fail"* — `N` thả tự do ⇒ **N = 0 vẫn PASS**. Trong khi rủi ro tự khai
của chính W3 (`spec:281`) là *"copy nhầm Proposal ⇒ mất OR-of-N **âm thầm** (build sạch, test cũ vẫn xanh)"*.
Tiêu chí duy nhất có thể bắt lại đúng là tiêu chí không có sàn.
- **W2 (`spec:247-256`):** 7 dòng acceptance, **không dòng nào chạy `dotnet test`** — dù W2 chính là wave đẻ
controller + policy, tức đúng thứ mà `AuthorizePolicyRegressionTests` canh.
- **Đề xuất:** W1 = *"562 PASS **và** `AuthorizePolicyRegression` xanh"*; W2 = thêm `dotnet test` với **≥3 test
mới có tên** (Create-happy · 409-trùng-PE · 400-PE-chưa-DaDuyet); W3 = pin **N ≥ 6****liệt tên** (OR-of-N ×2
chiều · ca âm 403 · con-trỏ đôi · finalize · inbox 2 vế).
- **Ghi công bằng:** con số **562 là ĐÚNG canonical**`docs/STATUS.md:642` ghi *"test 532→562"* cho đợt PE
nút-xóa. Lỗi ở **dạng tiêu chí**, không ở con số.
### H6 — "Dark-launch, vô hình với user" **sai trên fe-admin**, và phép đo #5 sẽ trượt trên bản làm đúng
- **Chứng cơ chế (đo trên đĩa):**
`src/Backend/SolutionErp.Application/Permissions/Queries/GetMyMenuTree/GetMyMenuTreeQuery.cs:86-102`
`IsVisible` chỉ được **bơm vào DTO** (`:88`), còn **bộ lọc cuối chỉ theo `CanRead`** (`:96-100`,
`static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess)`). Không có mệnh đề nào lọc
`IsVisible`.
`src/Backend/SolutionErp.Application/Permissions/Dtos/MenuDtos.cs:13` ghi thẳng:
*"`IsVisible = false` → ẩn khỏi response `/menus/me` FE-user, **fe-admin vẫn thấy**"* (cùng nội dung ở
`src/Backend/SolutionErp.Domain/Identity/MenuItem.cs:12`).
- **Hệ quả 1 (phép đo sai):** `spec:235`*"`GET /api/menus` không chứa `Csp`"***API vẫn trả node `Csp`**
(kèm `isVisible=false`) vì việc ẩn nằm ở phía FE. Bản implement đúng sẽ **trượt** dòng acceptance này.
- **Hệ quả 2 (cờ deploy sai):** `spec:215` khai W1 *"Deploy riêng được ✅ (vô hình với user)"* — nhưng
**fe-admin cố ý bỏ qua cờ**, mà admin chính là người UAT. Sau W1, admin thấy 4 mục menu tiếng Việt trỏ vào
route chưa tồn tại (routes tới **W2**, `spec:246`) ⇒ **4 mục menu chết** trên prod.
- **Đề xuất (chọn 1):** (a) **dời 4 dòng seed menu sang W2**, W1 chỉ giữ `MenuKeys` + policy + schema ⇒ dark thật,
không phụ thuộc cơ chế `IsVisible`; hoặc (b) giữ nguyên nhưng sửa acceptance thành *"`GET /api/menus/me` trả
node `Csp` với `isVisible=false`; fe-user KHÔNG render; **fe-admin CÓ render** — chấp nhận admin thấy mục chết
trong 1 nhịp deploy"* và sửa cột "Deploy riêng được" của W1 thành ✅*(trừ fe-admin)*.
---
## Điểm MED ## Điểm MED
### M1 — W1 khai "❌ không chặn" nhưng thực chất **đóng băng 2 quyết định của owner** vào migration
`spec:126` — format mã *"`KHKK/{YYYY}/{Seq:D3}` — **owner chốt format**"*; `spec:128-129` — *"biến thể tối thiểu
5 bảng … nếu owner muốn cắt, cắt #7 trước"*. Cả hai đều nằm **trong** phạm vi Mig 69 của W1 (`spec:227-229`),
trong khi bảng `spec:215` ghi *"Chặn bởi owner? ❌ không"*. Đổi ý sau khi Mig 69 lên prod = tốn Mig 70
(DropTable / đổi format mã). Ở W1 bảng còn rỗng nên chi phí chưa cao, nhưng sau W2 (đã có dữ liệu) thì đắt.
**Đề xuất:** cột chặn của W1 = ⚠️ (2 câu: 7-hay-5 bảng · format mã), hỏi owner **trước** khi phóng W1.
### M2 — W3 khai *"chạy được với mặc định"* nhưng acceptance **ép làm cả 3 đường**
`spec:217`: *"⚠️ Q3 (chọn cơ chế rẽ b.12) — nhưng chạy được với mặc định"*`spec:273-274`: *"Test cả **3 đường**
tới `DaDuyet` (thường · `AllowApproverFinalize` · `CeoApprovalThreshold`)"*. Nếu thật sự chạy mặc định thì 2/3
dòng acceptance **không có đối tượng để đo**; nếu phải làm cả 3 thì Q3 **đang chặn** chứ không phải cảnh báo.
**Đề xuất:** cắt acceptance về đường thường (W3a) + đẩy 2 nhánh kết-thúc-sớm sang W3b, **hoặc** đổi cờ W3 thành ✅ Q3.
### M3 — Spec port đúng 2 cơ chế mà owner **vừa phán "PHÁ VỠ"** ở tiền lệ, và không nhắc lại phán quyết đó
`run.md:114-115` (bảng *OWNER CHỐT — LƯỢT 3*): *"Cờ F5 `AllowApproverFinalize` → **PHÁ VỠ**"*,
*"`CeoApprovalThreshold` → **PHÁ VỠ**"* — đối lại `run.md:116` *"`Step.DepartmentId` → **AN TOÀN**"*.
Spec `spec:159-160` (§2.3 mục 2) lại **port cả hai** vào module mới, còn `spec:273` **ép test cả hai**.
**Tôi không suy được câu hỏi gốc từ một ô hai chữ** — có thể "phá vỡ" chỉ áp cho bất-biến của việc xóa-khi-duyệt
ở S155. **Đề xuất:** trích nguyên văn câu hỏi S155 vào spec trước khi code; nếu "phá vỡ" nói về bất-biến duyệt thì
đây là **chép nợ có chủ đích** và phải khai trong wave.
### M4 — Thiếu **bảng hợp-đồng-liên-lane**, đúng thứ tiền lệ dựng ra sau khi bị cắn
`run.md:135-137`: *"HỢP-ĐỒNG LIÊN-LANE — chốt TRƯỚC khi phóng (bài học đợt 1: **2 nửa build sạch mà không nối
được**) … Lệch một ký tự = silent drop, cả hai build vẫn xanh"*, kèm bảng canonical route API / menu key / route FE.
Spec §③ giao W2 cho `implementer-backend` + `implementer-frontend` + `test-specialist` (`spec:243-244`) mà
**không có bảng đó** — và H2 chính là **ca lệch tên đã xảy ra ngay trong spec**.
**Đề xuất:** thêm bảng canonical: route `/api/contract-signing-plans` (kebab, khuôn `/api/purchase-evaluations`
`run.md:142`) · 4 menu key · 4 policy · 3 route FE · tên field DTO mà FE đọc (`peReferenceAmount`,
`proposedAmount`, `approvedAmount`).
### M5 — Rủi ro được kê nhưng **không dòng acceptance nào bắt được**
- W2 `spec:257-258` kê 2 rủi ro: quên `HasQueryFilter(!IsDeleted)` ⇒ phiếu xóa mềm vẫn hiện; UNIQUE filtered thiếu
`WHERE IsDeleted=0` ⇒ chặn oan (gotcha #57). 7 dòng acceptance `spec:248-256` **không dòng nào chạm** 2 rủi ro này.
- W1 `spec:237` kê rủi ro *"seed-permission có >1 site (S155)"* — dòng đo gần nhất là #4, mà #4 đã hỏng (H3).
Tiền lệ ghi rất rõ điều này: `run.md:163`*"thêm key vào **CẢ HAI** danh sách permission seed `:2092-2096`
**và** `:2492-2496`"*.
**Đề xuất:** W2 thêm 2 dòng — (i) xóa mềm 1 phiếu → `GET /list` **không còn****và** `sqlcmd` thấy row
`IsDeleted=1`; (ii) xóa rồi tạo lại đúng cặp `(PlanId, SupplierId)`**201**, không 409. W1 thêm: `grep -n`
đếm site seed-permission = **2** (hoặc số thật) và cả hai đều có 4 key mới.
### M6 — `DELETE` **không thuộc wave nào**; cộng với guard 409 thì W2-một-mình **khoá cứng** PE tạo nhầm
`spec:177` khai endpoint `DELETE {id}` (allow-list `DangSoanThao|TuChoi`), nhưng file-list W2 (`spec:245`) chỉ có
*"Create/Update/Get/List + DossierItem CRUD + attachment"*, và W3 (`spec:265`) không nhắc. Trong khi `spec:143`
quy định chỉ cho tạo lại *"sau `TuChoi`"* — mà `TuChoi` chỉ đến được qua endpoint `transitions` của **W3**.
⇒ Trên prod chỉ-W2: một phiếu tạo nhầm là **không xóa được, không từ chối được**, và PE đó **vĩnh viễn không có
phiếu đúng**. Đây là "nửa tính năng chết" theo đúng định nghĩa mà `spec:210` đặt ra.
**Đề xuất:** đưa `DELETE {id}` vào **W2** + acceptance *"tạo → xóa → tạo lại cùng PE = 201"*.
### M7 — Ba phép đo của W3 sai phạm vi hoặc mơ hồ tới mức bản đúng cũng trượt
- `spec:270-271` *"sau mỗi lượt duyệt, `CurrentWorkflowStepIndex` **và** `CurrentApprovalLevelOrder` **đều đổi
đúng**"* — sai theo nghĩa đen: tiến từ Cấp 1 → Cấp 2 **trong cùng một Bước** thì `StepIndex` **không đổi**
(đó mới là hành vi đúng của con-trỏ đôi). **Đề xuất:** bảng kỳ vọng từng lượt, ví dụ 3 Bước × 2 Cấp:
`(0,1) → (0,2) → (1,1) → (1,2) → (2,1) → (2,2) → DaDuyet`.
- `spec:272-273` *"`sqlcmd COUNT(*) WHERE ApprovedAmount IS NULL` = 0"* — **thiếu `AND PlanId=@id`**: mọi phiếu
nháp hợp lệ đều có `ApprovedAmount = NULL` (`spec:121` ghi *"chỉ ghi tại finalize"*) ⇒ chỉ cần tồn tại 1 nháp là
phép đo **luôn khác 0** ⇒ FAIL giả vĩnh viễn.
- `spec:277` *"duyệt 2 lần cùng cấp → DB 1 row"* — không nêu **ai** duyệt lần 2; theo OR-of-N, sau lượt đầu thì cấp
đã qua nên người thứ hai bị chặn ⇒ **kịch bản có thể không dựng được**. **Đề xuất:** nêu rõ actor (Admin duyệt-thay
/ cùng một người bấm lại) + trạng thái phiếu tại thời điểm bấm.
### M8 — Acceptance W5 **mâu thuẫn với chính thiết kế §2.5**, và sai theo hướng **phóng đại tiền**
`spec:199` (thiết kế): *"`giaTri = line.ApprovedAmount` **khớp `w.SupplierId`** thay `SUM` `:88-90`"* — tức
**per-winner**. `spec:298` (acceptance): *"`GiaTri == SUM(ApprovedAmount)`"* — tức **cộng mọi dòng**. Với PE
liên-danh nhiều winner (Mig 58 multi-winner), code theo acceptance sẽ nhét **tổng giá của tất cả NCC** vào **từng**
hợp đồng. Direction-of-error là phóng đại giá trị HĐ ⇒ không phải lỗi làm tròn.
**Đề xuất:** sửa acceptance thành *"HĐ của NCC X → `GiaTri == ApprovedAmount` của đúng Line `SupplierId = X`"* +
1 ca 2-winner để phân biệt hai công thức.
---
## Điểm LOW ## Điểm LOW
### L1 — Hai lệnh của W1 không chạy đúng như viết
`spec:231` *"`dotnet ef migrations list` → `69_AddContractSigningPlans` ở cuối"* — EF in ra
`20260728xxxxxx_AddContractSigningPlans`, **không có tiền tố `69_`**; số 69 chỉ là cách đánh số trong tài liệu.
`spec:236` *"`dotnet ef migrations script 68 69`"* — tham số là **tên** migration, không phải số thứ tự.
`spec:236` *"chạy sạch"* cũng thiếu kỳ vọng (0 lỗi + số bảng quay lại mốc cũ).
*(Số **69 là đúng**: file mới nhất trên đĩa `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260727033522_AddPeAllowApproverDelete.cs` = Mig 68 theo `docs/STATUS.md:642`.)*
**Đề xuất:** kỳ vọng theo tên + `sqlcmd -Q "SELECT TOP 1 MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC"`.
### L2 — Phép đếm bảng lệch vị-ngữ so với canonical và không nêu đo trên DB nào
`spec:232` dùng `SELECT COUNT(*) FROM sys.tables`; canonical `docs/STATUS.md:448` dùng
`sys.tables` **`is_ms_shipped=0`** (cicd Run #482 LIVE = **89**). Hai vị ngữ có thể lệch nhau, và acceptance không
nói đo trên **prod** hay **LocalDB Dev** (Dev hay lệch do migration chưa apply).
*(Con số 89 **đúng và chưa trôi** — đo: 6 migration mới nhất `20260706 → 20260727` đều **0 `CreateTable`, 0 `DropTable`**.)*
**Đề xuất:** chép nguyên vị ngữ canonical + ghi rõ DB đích + giữ **delta +7** làm bất-biến chính.
### L3 — "SHA256 3 file mirror IDENTICAL" không nêu **3 file nào**, và mâu thuẫn quy ước 2 app
`spec:255` ⟂ quy ước *"duplicate giữa 2 app là CÓ CHỦ ĐÍCH, mỗi app UX riêng"*`spec:189` (page của 2 app khác
nhau về nội dung). Tiền lệ chỉ áp mirror byte cho **đúng 1 component**: `run.md:166` — *"`PeWorkflowPanel.tsx`
phải giữ md5 giống nhau ×2 app"*.
**Đề xuất:** nêu đích danh file phải giống byte (`lib/menuKeys.ts` + `types/contractSigningPlan.ts`), bỏ page.
### L4 — W2#5 không nêu tài khoản nên "user thiếu quyền" là biến tự do
`spec:253`. **Đề xuất:** ghi rõ 2 tài khoản (ví dụ `nv.test@solutions.com.vn` = thiếu quyền → **403**;
`admin@solutions.com.vn` = có quyền → **201**), vì kết quả 401-vs-403 phụ thuộc token hợp lệ.
### L5 — Đánh số W4/W5 ngụ ý thứ tự không có thật
Cả hai chỉ phụ thuộc W3, **không phụ thuộc nhau**; và W5 mới là khúc nghiệp vụ b.12→13 mà `spec:1` đặt làm đề bài,
còn W4 là siết chất lượng. **Đề xuất:** ghi *"W4 ⟂ W5 — chạy theo thứ tự anh trả lời câu hỏi"*, tránh việc
implementer coi W4 là điều kiện tiên quyết của W5.
---
## Thứ tự wave — trả lời trực tiếp câu (c)
- **C1. W1 hiện KHÔNG phải "rẻ nhất mà đã có giá trị" — nó rẻ và có giá trị bằng 0** *(MED, đã tính trong M1/H1)*.
`spec:225` tự khai mục tiêu *"không đổi gì người dùng thấy"*. So với tiêu chuẩn thật của tiền lệ —
`run.md:125` *"đợt 1 xong là **UAT được ngay**, biết chắc nền đúng trước khi xây tiếp"* — W1 **không UAT được**.
Nó chỉ thoả tiêu chí yếu *"không để nửa tính năng chết"* bằng cách không giao gì cả.
**Đề xuất gộp 2 việc:** kéo wiring Designer type-10 (H1) vào W1 ⇒ W1 thành *"nền + admin dựng được quy trình
type 10"*, UAT được ngay bằng 1 phép thật (tạo quy trình, thấy nó trong dropdown), và **mở khoá W2**.
- **C2. Nên tách W3 làm hai** *(MED, cùng gốc M2)*: **W3a** = advance + OR-of-N + inbox precompute + panel FE +
`TraLai` toàn phần (chính `spec:162` đã chừa đường *"tối thiểu `TraLai` toàn phần nếu muốn gọn wave đầu"*) —
đủ để phiếu chạy trọn vòng, deploy được. **W3b** = 2 nhánh kết-thúc-sớm + 4 return-mode + notify approver.
Lý do: W3 hiện gánh 8 dòng acceptance và 3 cơ chế rủi ro cao trong một lần deploy; nếu vỡ thì không truy được
vỡ ở nhánh nào.
- **C3. Không nên gộp W1 với W2** *(giữ nguyên)*: migration cần `database-agent` review trước khi apply
(`spec:226`), và blast radius của Mig 69 là 7 bảng mới — tách ra vẫn đúng.
---
## Cờ CHẶN — trả lời trực tiếp câu (d)
| Wave | Spec khai | Đánh giá | Chứng |
|---|---|---|---|
| W1 | ❌ không chặn | 🔴 **SAI — đang chặn** | `spec:126` format mã *"owner chốt"* + `spec:128-129` 7-hay-5 bảng; cả hai đóng băng vào Mig 69 (M1) |
| W1 | ✅ deploy riêng, *"vô hình với user"* | 🔴 **SAI một nửa** | fe-admin cố ý bỏ qua `IsVisible` (`MenuDtos.cs:13`), query không lọc (`GetMyMenuTreeQuery.cs:96-100`) (H6) |
| W2 | ✅ deploy riêng *"bật menu, tạo được phiếu"* | 🔴 **SAI — tạo được 0 phiếu** | thiếu wiring type-10 ở mọi wave (H1) + `DELETE` vô chủ (M6) |
| W3 | ⚠️ Q3 *"chạy được với mặc định"* | 🟠 **Nhẹ hơn thực tế** | acceptance ép cả 3 đường (M2) |
| W4 | ✅ chặn O-Q1, O-Q2 | 🟢 **ĐÚNG** | `spec:287-289` — và acceptance cũng khai rõ *"khi có câu trả lời"* |
| W5 | ✅ chặn Q6, Q11 + xin phép chạm HĐ | 🟢 **ĐÚNG** | `spec:295` neo đúng lệnh owner *"chỗ hợp đồng cứ từ từ"* |
Ghi chú W4: nếu O-Q2 chốt *"số hoá thành checklist"* thì W4 **đẻ thêm schema** (bảng/cột checklist) — mà §2.2 chỉ
thiết kế 7 bảng, không có chỗ cho nó ⇒ chi phí W4 hiện **chưa bị chặn trên**. Nên ghi thẳng: *"nếu O-Q2 = số hoá
⇒ W4 kèm Mig 70"*.
---
## Điểm CỦNG CỐ ## Điểm CỦNG CỐ
1. **Số 562 đúng canonical**`docs/STATUS.md:642` *"test 532→562"*. Không phải số bịa; phê bình của tôi ở H5
nhắm vào **dạng tiêu chí**, không nhắm con số.
2. **Số 89 bảng đúng và chưa trôi**`docs/STATUS.md:448` (re-ground S97, `sys.tables is_ms_shipped=0`, Run #482
LIVE = 89); tôi kiểm lại 6 migration mới nhất (`20260706 → 20260727`) đều **0 `CreateTable` / 0 `DropTable`**
⇒ mốc 89 vẫn sống ở Mig 68. Delta **+7** khớp đúng "7 CreateTable" của §2.2.
3. **Mig 69 là số đúng** — file mới nhất trên đĩa là `20260727033522_AddPeAllowApproverDelete` = Mig 68.
4. **Phép tính +16 policy đúng số học** — 54 key (`docs/STATUS.md:453`) × 4 → 58 × 4 = 232.
5. **`spec:182-183` bắt đúng khác biệt sống-còn** giữa `Off_DeXuat` (**trong** `MenuKeys.All`) và `Pe_*` (factory,
**ngoài** `All` ⇒ không có policy). Đây chính là bẫy mà `AuthorizePolicyRegressionTests.cs:139` đặt tên — spec
nhận diện đúng nguồn, chỉ tự vấp ở khâu đặt tên (H2).
6. **§2.1 chặn bẫy Proposal có dẫn chứng dòng thật + lead tự đọc xác minh** (`spec:99-111`), và W3#1 tuy hỏng cách
đo nhưng **đặt lưới đúng chỗ** — đây là rủi ro số một của cả run.
7. **W2#5 (403 authz 2 tầng)** — phép chặn tái phát #82/S118, kèm câu *"bắt buộc chạy, không được bỏ"*
(`spec:253-254`). Hiếm thấy trong spec; giữ nguyên, chỉ thêm tên tài khoản (L4).
8. **W3#4 (inbox) có ĐỦ hai vế***"approver Cấp đang chờ **thấy**; approver cấp khác **không thấy**"*
(`spec:275-276`). Đúng kỷ luật hai-thế-giới; nên nhân bản chính kỷ luật này sang #1 (H4).
9. **Acceptance #6 của W1 đòi chạy `Down` thật trên DB copy** (`spec:236`, *"reversible thật, không chỉ nhìn code"*)
— phản chứng thật chứ không phải đọc code đoán; cùng hạng với `spec:135-136` (cấm thêm cột `SigningPlanId` chết)
`spec:132`/`spec:238` (verify enum slot 10 **và** buộc re-verify ngay trước khi viết).
10. **Provenance trung thực**`spec:193-194` khai thẳng claim của invest **không verify được** (fe-user matrix
grep 0 hit) và buộc grep lại; `spec:325-326` tách bạch **"đo thật"** (Proposal 4 bảng · 849 LOC BE · 1.956 LOC
FE) với **"ngoại suy"** cho 3 nhánh. Tôi **không đo lại** 3 số LOC trong lượt này — nhưng đường phản chứng có
tồn tại (`wc -l` trên file twin), nên nhãn "đo thật" là hợp lệ.
<!-- END · TOTAL=19 DIEM (6H/8M/5L + 10 cung-co) · VERDICT=SUA-TRUOC-W1 -->