wal: flush 20260813T2259

This commit is contained in:
pqhuy1987
2026-08-13 22:59:50 +07:00
parent 3d01333461
commit 65631adb05
5 changed files with 692 additions and 36 deletions

View File

@ -18,8 +18,11 @@ chain:
[x] wf: wf_5f43c10f-506 invest 5/5 lane XONG — 4/5 mất return (empty_result) nhưng 5/5 GHI ĐĨA 85KB ⇒ 0 việc mất, thu hoạch bằng đọc đĩa (bằng chứng #2 cùng phiên: chống-#53 = thiết-kế ARTIFACT)
[x] lead refute P3 giải 2 mâu thuẫn bằng ĐO: M-1 lane5⟂lane2 → 66% chung/f03 +245 dòng riêng ⇒ component chung + 2 lắp ráp · M-2 PHẦN 2 "17 điều" → 002.04 chỉ có 5 ⇒ THIẾU 12 điều (CHẶN vế 2)
[x] spec 3 heading ghi: runs/2026-08-13-S194-yc032-hd-thauphu-2form/spec-hd-thauphu-2form-13-08-2026.md (W0-W6 + acceptance đo-được + §④ 6 điều chưa đo)
[!] wf: wf_71a58432-f90 /fable-clone review 4 lane reviewer (r1-taxonomy r2-formshape r3-prefill r4-plan) — chấm spec, chết giữa ⇒ /tiep §4 theo sub-reviewer-*.md
[!] 5 câu CHỜ ANH cho YC-032 (12 điều thiếu · in bằng gì · khoá 0-mig hay 1-mig · VAT · đánh số menu) — hỏi ở báo cáo lượt này
[x] wf: wf_71a58432-f90 review 4 lane — R1 10F(3 nặng) · R2 9F(6 nặng) · R3 9F(4 nặng); r4 chết chỉ ghi khung rỗng 758B ⇒ ĐÃ phóng lại cắt-gọt (agent lẻ, pin 3 verdict anh em để khỏi lặp)
[!] 3 finding CRITICAL đổi kế hoạch: R2-F4 W2⟂W3 TRANH cùng cột DraftData (phải chốt schemaVersion TRƯỚC W2) · R3-F6 chuỗi VAT của spec SAI (ChuaVat là cột chết ⇒ câu (100) hỏi sai khuôn, ĐÃ đính chính với anh) · R3-F7 bảng giá Σ PeReferenceAmount ⟂ ô Giá trị Σ ApprovedAmount ⇒ HĐ tự mâu thuẫn tiền, Dev 2/2 lệch
[!] R3-F5b: 5/8 ô master IN TRẮNG (TK 83% · người đại diện 83% · chủ đầu tư 93% · địa điểm 93% rỗng) ⇒ pre-fill chạy đúng mà ra HĐ trống — lớp "cơ-chế đúng, dữ-liệu không có" lần thứ N
[!] 5 câu CHỜ ANH cho YC-032 — (97) 12 điều thiếu · (98) in bằng gì · (99) khoá · (100) VAT ĐÃ SỬA KHUÔN thành hỏi quy-ước-nhập-liệu · (101) đánh số menu
[ ] sau r4: lead vá spec theo 28+ finding rồi mới fan-out Opus 5 MAX (CẤM giao thợ bản spec chưa vá — acceptance 2/5 hỏng)
[ ] sau review: lead tổng hợp → fan-out Opus 5 MAX thi công theo wave
[!] 7 câu (90)-(96) CHỜ CHỦ DỰ ÁN — 0 đáp; nhà CANONICAL = docs/HANDOFF.md khối (90)-(96)
[!] residual-write 2 vai/2 phiên (score-counter S192 · score-count-auditor S189+S192) — GIỮ+KHAI theo LT3 bậc-2, chờ owner lật

View File

@ -4,22 +4,199 @@
> Kỷ luật: mọi số phải từ lệnh vừa chạy (kèm lệnh). 0-hit BẮT BUỘC kèm control-dương.
## 0. Khung
(đang ghi)
Chấm 5 điểm theo lệnh lead. Mọi số dưới đây từ lệnh chạy trong lượt này (git grep / sed / sqlcmd), KHÔNG lấy từ spec, KHÔNG lấy từ trí nhớ.
Kết luận sớm: quyết định **A1 (thêm 2 enum) là ĐÚNG** — lập luận (2) đứng vững, lập luận (3) đứng vững. Nhưng **con số "13 site" của spec là ƯỚC THẤP**, và **W1 bỏ sót một lớp site nguyên vẹn (tầng chi-tiết per-loại, BE + FE)** — chính là tầng mà câu "0 migration nếu 2 loại mới dùng chung `ThauPhuDetails`" phụ thuộc vào. Ngoài ra **acceptance "test hiện có vẫn 697 PASS" là SAI BY-CONSTRUCTION**.
## 1. Đếm lại site
(đang ghi)
### 1.1 Mẫu số thô
```
git grep -c "ContractType" -- "src/*.cs" → 30 file NGOÀI Migrations (+78 file Designer/snapshot = nhiễu)
git grep -n "ContractType\." -- "*.cs" | wc -l → 152 dòng (gồm cả tests)
git grep -n "case ContractType" -- "*.cs" → 14 dòng, TẤT CẢ ở ContractDetailsFeatures.cs
(control-dương: git grep -c "case " -- "*.cs" → ContractSigningPlanWorkflowGuards.cs:3, DbInitializer.cs:1 … ⇒ lưới `case` CÓ chạy)
```
### 1.2 Danh sách MUST-EDIT tự dựng (không chép spec)
W1 có kê (✅) / W1 KHÔNG kê (🔴):
| # | Site | W1? | Ghi chú đo được |
|---|---|---|---|
| 1 | `Domain/Contracts/ContractType.cs:5-11` (7 member) | ✅ | enum 1..7, đọc tận mắt |
| 2 | `Domain/Contracts/WorkflowPolicy.cs:155` `_ => "SkipCcm"` | ✅ | CÂM — xem §3 |
| 3 | `Domain/Contracts/ContractApprovalWorkflowFamily.cs:33-42` `Members` | ✅ | nở by-construction: `Codes:45`, `CodeFor:48`, `MenuKeys.ContractWorkflowMenuKeys:156` |
| 4 | `Domain/Identity/MenuKeys.cs:40` `ContractTypeCodes` | ✅ | 5 consumer loop: `DbInitializer.cs:2192,:2212,:2578,:2614` + `:2049` |
| 5 | `Infrastructure/Services/ContractCodeGenerator.cs:20-29` | ✅ | 🔴 nhưng `_ => "HĐ"` = CÂM THỨ HAI, spec không khai — xem §3 |
| 6 | `Infrastructure/Reports/ContractExcelExporter.cs:30-36` `TypeLabels` | ✅ | dict 7 dòng |
| 7 | `Application/Contracts/WorkflowAdminFeatures.cs:64-72` `TypeLabels` | ✅ | `:159` dùng `Enum.GetValues<ContractType>()` ⇒ tự nở; `:136` `GetValueOrDefault(…, ToString())` ⇒ thiếu nhãn thì rơi về tên enum, KHÔNG nổ |
| 8 | `DbInitializer` seed menu-row + nhãn (`:2054`, `:2375`) + seed quy trình `HD-*` | ✅ | |
| 9 | FE `TYPE_CODE_TO_INT` ×4 (`fe-{admin,user}/Layout.tsx`, `ApprovalWorkflowsV2Page.tsx:172`, `WorkflowsPage.tsx:77`) | ✅ | |
| **10** | **`Application/Contracts/ContractDetailsFeatures.cs:41-113`** — switch #1, **KHÔNG default** | **🔴** | rơi ra ⇒ trả bundle RỖNG, HTTP 200. CÂM. |
| **11** | **`Application/Contracts/ContractDetailsFeatures.cs:434-462`** — switch #2 (Delete) | **🔴** | rơi ra ⇒ `removed=false``NotFoundException("ContractDetail")` = **báo sai nguyên nhân** |
| **12** | **`fe-{user,admin}/src/components/contracts/ContractDetailsTab.tsx:33` `TYPE_TO_SLUG`** | **🔴** | thiếu key ⇒ URL `/details/undefined` |
| **13** | **cùng file `:77-83`** — 7 dòng `bundle.type === N &&` | **🔴** | type 8/9 ⇒ **không render gì**, im lặng |
| **14** | **cùng file `:517` `FIELDS_BY_TYPE: Record<number, FieldDef[]>`** | **🔴** | thiếu ⇒ form nhập rỗng |
| **15** | **cùng file `:583-597` `switch (contractType)` KHÔNG default** | **🔴** | rơi ra ⇒ `return common` = payload **thiếu sạch field nghiệp vụ** |
| **16** | **`fe-admin/src/lib/contractWorkflowFamily.ts:46-52`** — 7 cặp `{code, contractType}` chép tay | **🔴** | mirror FE của `Members` |
| **17** | **`fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx:59-65`** — `Record<number,string>` 7 dòng | **🔴** | file tự khai `:56` *"chép TỪNG KÝ TỰ"* |
**17 site must-edit** (chưa tính test). Spec nói **13**. Bốn cái #12-#15 nằm trong **1 file × 2 app** nên nếu spec đếm "file" thì lệch ít hơn, nhưng **cả 8 lượt sửa đó đều không xuất hiện ở W1 dưới bất kỳ hình thức nào** — grep `ContractDetails` trong spec = 0 hit.
🔴 **Điểm chí tử:** ②.2(a) tự đặt cược *"0 migration **nếu 2 loại mới dùng chung `ThauPhuDetails`**"*. "Dùng chung `ThauPhuDetails`" KHÔNG tự xảy ra — nó **chính là** #10 + #12..#15. Spec dùng giả thiết đó để hạ chi phí A1 xuống, rồi **không kê việc làm cho giả thiết đó thành thật**.
### 1.3 By-construction: 6/13 có đúng không?
Đếm lại thứ **thật sự tự nở**: `Family.Codes:45` · `Family.CodeFor:48` · `MenuKeys.ContractWorkflowMenuKeys:156` · 4 vòng `foreach MenuKeys.ContractTypeCodes` (`DbInitializer:2192,:2212,:2578,:2614`) · `WorkflowAdminFeatures.cs:159 Enum.GetValues` · `ContractWorkflowGuards` (đọc qua `Family`, `:159-166 ShortNameOf` cũng loop `Members`).
⇒ "~6 by-construction" **HỢP LÝ**, không bác. Vấn đề nằm ở **mẫu số**, không ở tử số.
## 2. Migration rỗng?
(đang ghi)
**KẾT LUẬN: spec ĐÚNG.** Ba phép đo độc lập:
```
git grep -n "HasConversion<string>" -- "src/*.cs" → 0 hit (exit 1)
control-dương: git grep -c "HasConversion<int>" → ContractConfiguration.cs:7, WorkflowAppsConfiguration.cs:9, … ⇒ lưới CHẠY
```
Cả 4 cột mang `ContractType` đều int:
- `ContractConfiguration.cs:15` `b.Property(x => x.Type).HasConversion<int>()`
- `WorkflowDefinitionConfiguration.cs:15` `HasConversion<int>()`
- `WorkflowTypeAssignmentConfiguration.cs:13` `HasConversion<int>()`
- `ContractTemplateConfiguration.cs:16` `HasConversion<int?>()`
sqlcmd LocalDB `SolutionErp_Dev`:
```
CHECKS_ON_TYPE=0
CONTROL_TOTAL_CHECKS=0 ← ⚠️ xem dưới
ContractsTypeCol=Contracts/int
```
⚠️ **Cảnh báo về THƯỚC (không đổi kết luận):** control-âm ở đây **thoái hoá** — cả DB có **0** check-constraint, nên "0 CHECK trên `Type`" đúng nhưng **không mang thông tin riêng** về cột `Type`; nó đúng với mọi cột. Phép đo mang thông tin thật là **kiểu cột = `int`** + **0 `HasConversion<string>`**. Nếu spec định dùng "0 CHECK" làm bằng chứng chính thì đó là bằng chứng yếu; kiểu cột mới là cái đỡ lưng.
**Không có bảng lookup loại HĐ** (không có `ContractTypes` table). Có 3 bảng *tham chiếu* enum bằng cột int + index (`WorkflowTypeAssignments` unique, `WorkflowDefinitions` composite, `ContractTemplates`) — đều là **DATA**, không phải schema ⇒ không sinh migration, nhưng **buộc seed row** (W1 đã kê phần quy trình `HD-*`).
⇒ Lập luận (2) **HELD**. Tiền lệ Mig 62 `SupplierType +CaHai=6` không cần kiểm thêm vì cơ chế đã chứng trực tiếp.
## 3. Nhánh nuốt im lặng
(đang ghi)
### 3.1 Hai claim của spec — cả hai HELD
**`WorkflowPolicy.cs:155` CÂM — ĐÚNG.** Đọc tận mắt `:154-158`:
```csharp
public static string DefaultPolicyNameFor(ContractType type) => type switch
{
ContractType.HopDongThauPhu or ContractType.HopDongGiaoKhoan or ContractType.HopDongNhaCungCap => "Standard",
_ => "SkipCcm",
};
```
Nặng hơn spec mô tả: loại 1 (Thầu phụ gốc) = `"Standard"`, mà **2 biến-thể Thầu phụ mới nếu quên thêm sẽ rơi vào `"SkipCcm"`****mất trạm CCM** đúng cho HĐ Thầu phụ — loại DUY NHẤT mà YC-032 đụng tới. Xác suất trúng đạn ở đây là cao nhất, không phải trung bình.
**`ContractWorkflowGuards.cs:147-152` TO TIẾNG — ĐÚNG.** `expected is null``throw new ConflictException("Loại HĐ '{contractType}' chưa có quy trình riêng trong họ 'HD-*'…")`. Fail-closed thật.
### 3.2 🔴 Nhánh CÂM spec KHÔNG khai (tìm thêm theo lệnh)
`git grep -n "case ContractType"` chỉ ra 14 dòng; `switch` **biểu thức** (`=>`) phải grep riêng — spec chỉ soi 1 trong 2 dạng.
| # | Vị trí | Nhánh rơi | Hỏng kiểu gì |
|---|---|---|---|
| **C-1** | `Infrastructure/Services/ContractCodeGenerator.cs:28` | `_ => "HĐ"` | **CÂM.** Mã HĐ sinh ra thành `{Project}/HĐ/SOL&{NCC}/{Seq}` thay vì `…/HĐTP/…`**sai RG-001, không nổ, và mã đã cấp thì không rút lại được** (`ContractCodeSequences` đã tăng). W1 CÓ kê site này nhưng **kê như một việc chép nhãn**, không kê như bẫy câm. |
| **C-2** | `Application/Contracts/ContractDetailsFeatures.cs:41-113` | **không có `default`** | **CÂM.** `GetContractDetailsQuery` trả `bundle` với **cả 7 list rỗng**, HTTP 200. UI hiện "không có chi tiết" — không phân biệt được với "HĐ chưa nhập dòng nào". |
| **C-3** | `fe-{user,admin}/src/components/contracts/ContractDetailsTab.tsx:583-597` | `switch` không `default`, sau đó `return common` | **CÂM.** POST đi với payload chỉ có `{id, order, ghiChu}`**rụng sạch** `hangMuc/donGia/khoiLuong…` |
| **C-4** | `fe-…/ContractDetailsTab.tsx:77-83` | 7 dòng `bundle.type === N &&` | **CÂM.** type 8/9 ⇒ không component nào render. |
| C-5 | `Application/Contracts/WorkflowAdminFeatures.cs:136` | `TypeLabels.GetValueOrDefault(d.ContractType, d.ContractType.ToString())` | **BÁN CÂM** — nhãn tụt về `"HopDongThauPhuNhanCongVatTu"` (tên enum, dính chữ) thay vì tiếng Việt. UI xấu, không nổ. |
| C-6 | `ContractDetailsFeatures.cs:434-462` | không `default``if (!removed) throw NotFoundException("ContractDetail")` | **TO TIẾNG NHƯNG SAI TỘI** — báo "không tìm thấy dòng chi tiết" trong khi lỗi thật là "loại HĐ chưa được khai". Người debug đi sai hướng. |
| C-7 | `DbInitializer.cs:1246-1247` | `type is DichVu or MuaBan or NguyenTacNCC or NguyenTacDichVu ? … : …` | Rơi về nhánh **có CCM****đúng ý** cho 2 loại Thầu phụ mới. Chỉ là seed demo. Không cần sửa, ghi để khỏi ai đó "sửa cho đủ". |
⇒ Spec khai **1** nhánh câm; đo được **4 nhánh câm + 1 bán câm + 1 sai-tội**. Câu ②.1 *"mỗi cái đã cháy thật"* đúng, nhưng danh sách **không đầy đủ**, và nó được trình bày như bản kiểm đã quét hết (`"Bốn cái BẪY phải né"`).
## 4. Acceptance W1
(đang ghi)
W1 có 5 mục acceptance. Chấm từng mục theo trục **đo HÌNH DẠNG ⟂ đo HÀNH VI**:
| # | Acceptance W1 | Đo gì | Chấm |
|---|---|---|---|
| A1 | `dotnet ef migrations add` sinh migration **RỖNG** | **HÌNH DẠNG** file sinh ra | ⚠️ YẾU + có tác dụng phụ |
| A2 | boot Dev **không nổ Permission FK** | hành vi, nhưng **sai chỗ** | ⚠️ YẾU |
| A3 | 2 leaf mới **hiện trên sidebar** | HÀNH VI thật | ✅ ĐẠT |
| A4 | pin chéo quy trình ⇒ **409** | hành vi, nhưng **PASS VACUOUS** | 🔴 HỎNG |
| A5 | test hiện có vẫn **697 PASS** | **SAI BY-CONSTRUCTION** | 🔴 HỎNG |
**A1 — yếu + đẻ rác.** Đo hình dạng của một artifact do chính mình sinh ra, rồi artifact đó phải xoá đi (`ef migrations remove`); quên xoá thì gotcha #17 (luật 3 file) kéo theo 3 file no-op vào commit. *Thay bằng:* `dotnet ef migrations has-pending-model-changes` — cùng câu hỏi, **không tạo file**, đọc bằng exit-code. Cộng thêm 1 phép đo HÀNH VI: ghi 1 HĐ `Type=8`, đọc lại, `Type == 8` (chứng round-trip int thật, không chỉ chứng "EF không thấy gì để đổi").
**A2 — đo lệch mục tiêu.** `MenuKeys.cs:69` khai rõ: *"`Ct_*` / `Pe_*` leaf CỐ Ý NGOÀI `All` — sinh động qua factory"*. Nghĩa là 12 key `Ct_*`/`Hdc_*` mới **không vào `All` ⇒ không sinh policy ⇒ không có FK để nổ**. Thứ duy nhất vào `All` là 2 key `AwV2_Hd*` (từ `Family.Members`). Vậy "boot không nổ FK" chỉ canh **2/14** key mới; 12 key kia thiếu row thì boot vẫn **xanh** còn sidebar thì **thiếu mục**. *Thay bằng:* dùng thẳng test đã có (xem §5) cho vế `All`, và tách riêng phép đếm menu-row `Ct_*`/`Hdc_*` = 7→9 bộ.
**A3 — GIỮ, có răng thật.** `fe-user/src/components/Layout.tsx:114` + `:186` tự khai cơ chế: `resolvePath` trả `null` ⇒ **`MenuLeaf` DROP IM LẶNG** (gotcha #50). Nên "hiện trên sidebar" đúng là phép đo phân biệt được. Chỉ cần siết 1 nấc: **bấm vào leaf → landing đúng `?type=8` / `?type=9` và danh sách trả 200**, vì `:163 if (!typeInt) return null` chỉ bảo vệ vế hiển thị.
**A4 — 🔴 PASS VACUOUS, đây là lỗi nặng nhất của khối acceptance.**
`ContractWorkflowGuards.cs:147-152`: khi loại HĐ **chưa có** trong `Family.Members` thì `expected is null` ⇒ **cũng ném 409**. Nghĩa là "pin chéo quy trình ⇒ 409" **cho kết quả XANH ở cả hai thế giới**: thế giới làm đúng, và thế giới **quên hẳn bước thêm 2 tuple vào `Members`**. Nó không phân biệt được gì.
*Thay bằng — bắt buộc có cặp âm/dương:*
- (dương) pin **ĐÚNG** mã của loại mới (vd `HD-TP-NCVT` cho `Type=8`) ⇒ **thành công 200/201**, `Contract.ApprovalWorkflowId` pin đúng;
- (âm) pin mã của loại **KHÁC** (vd `HD-GK`) cho `Type=8` ⇒ **409** với câu *"là quy trình duyệt của loại Hợp đồng KHÁC"* (`:157-160`), **KHÔNG** phải câu *"chưa có quy trình riêng trong họ"* (`:150-152`).
Phân biệt **hai câu 409 khác nhau** chính là chỗ acceptance cũ mù.
**A5 — 🔴 SAI BY-CONSTRUCTION, và nguy hiểm vì nó đẩy người làm về phía sai.**
`697` là số canonical đúng hiện tại (`docs/STATUS.md:6`, `test 680→697`) — không bác số. Bác **mệnh đề**: thêm 2 tuple vào `Family.Members` **BẮT BUỘC** làm đỏ các assert sau, vì chúng neo cứng số 7/17:
| File:line | Assert | Sau W1 |
|---|---|---|
| `AwV2DesignerMenuKeyTests.cs:132` | `family.Should().HaveCount(7, …)` | 9 ⇒ ĐỎ |
| `AwV2DesignerMenuKeyTests.cs:182` | `family.Should().HaveCount(7, …)` | 9 ⇒ ĐỎ |
| `AwV2DesignerMenuKeyTests.cs:204` | `awV2RowKeys.Should().HaveCount(17, …)` | 19 ⇒ ĐỎ |
| `AwV2DesignerMenuKeyTests.cs:232` | `children.Should().HaveCount(17, …)` | 19 ⇒ ĐỎ |
| `AwV2DesignerMenuKeyTests.cs:70-71` | `ContractWorkflowCodes` = 7 mã chép tay | phải +2 |
| `AwV2DesignerMenuKeyTests.cs:81` | `Expected7` (tên biến cũng phải đổi) | phải +2 |
| `AwV2DesignerMenuKeyTests.cs:87` | `ExpectedCount = "7"` | phải đổi |
3 test method dính: `MenuKeys_All_HasSevenContractDesignerKeys_AndKeepsTheTenLegacyOnes` (`:120`) · `SeedMenuTree_ContractDesignerLeaves_HaveRowsUnderApprovalWorkflowsV2_AndMatchAllBothWays` (`:163`) · `SeedMenuTree_AllDesignerLeaves_HaveDistinctLabelsAndDistinctOrders` (`:224`).
🔴 **Vì sao nguy hiểm chứ không chỉ sai:** người thi công cầm acceptance *"697 PASS"* mà thấy 3 test đỏ sẽ bị kéo về "giải pháp" làm chúng xanh lại — cách rẻ nhất là **không thêm 2 loại vào `Family.Members`** (giữ 7). Mà `Members` chính là nguồn by-construction của menu-key + policy + guard + message. Bỏ nó = giữ test xanh, mất luôn 4 thứ. Đây đúng khuôn Goodhart *"rời tập-đo"*.
*Thay bằng:* **"test = 697 + N, N khai trước; 3 method neo-số ở `AwV2DesignerMenuKeyTests` được phép sửa 7→9 / 17→19 và PHẢI sửa trong CÙNG commit với code (spec-change, `docs/rules.md §7`); 0 test bị XOÁ; 0 test bị `Skip`."** Khai rõ "được sửa cái nào" thì mới chặn được đường sửa bậy cái khác.
**Thiếu hẳn khỏi acceptance W1 (không có mục nào phủ):**
- `WorkflowPolicy.DefaultPolicyNameFor(Type=8/9) == "Standard"` — chính là bẫy CÂM #1 mà ②.1 gọi tên. Đề xuất: thêm 2 dòng `[InlineData]` vào `WorkflowPolicyTests.cs:150-156` (đã có sẵn khuôn Theory 7 dòng) ⇒ rẻ, test-before được.
- `ContractCodeGenerator` sinh token đúng cho 8/9 (bẫy C-1) — đã có `ContractCodeGeneratorTests.cs`.
- Bundle chi-tiết cho `Type=8` trả về **đúng list `ThauPhu`** (bẫy C-2) — chính là giả thiết "dùng chung `ThauPhuDetails`" mà ②.2(a) dựa vào.
## 5. Test canh menu-row
(đang ghi)
**CÓ, và nó có RĂNG THẬT.** Spec nhắc đúng địa chỉ. Đọc tận mắt `tests/SolutionErp.Infrastructure.Tests/Application/ContractCatalogTests.cs`:
```
:434 await InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
:435 await InvokePrivateSeedAsync(db, "SeedAdminPermissionsAsync", rm);
:444 var menuKeysInDb = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
:445 MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty(
:446 "mọi key trong MenuKeys.All phải có row MenuItem — thiếu row là Permission FK nổ ngay lần boot đầu");
```
Đây **không** phải test trên danh sách dựng tay: nó **chạy seeder thật** rồi đối chiếu `MenuKeys.All` với `MenuItems` **đã ghi vào DB**. Vì `MenuKeys.ContractWorkflowMenuKeys` (`:156`) sinh **by-construction** từ `Family.Members`, thêm 2 tuple là 2 key tự vào `All` ⇒ nếu quên seed 2 menu-row thì test này **ĐỎ ngay**, trước cả khi ai boot Dev.
Có test thứ hai phủ cùng chỗ, chặt hơn (đối chiếu **hai chiều**): `AwV2DesignerMenuKeyTests.cs:191` (`key ∈ MenuKeys.All ⇒ BẮT BUỘC có menu-row … ParentKey=…`) và `:208` (`awV2RowKeys.Except(MenuKeys.All).Should().BeEmpty()`).
⇒ **Trả lời lệnh (5): W1 RẺ HƠN spec nói ở đúng vế này.** ②.1 bẫy #4 mô tả "Permission FK nổ ngay lần boot đầu" như rủi ro sống, và A2 dựng nguyên một bước acceptance thủ công (boot Dev) để canh nó — trong khi **CI đã canh sẵn, tự động, ở 2 test độc lập**. Nên bỏ A2 dạng thủ công, thay bằng "2 test trên PASS".
🔴 **Nhưng phải khai kèm — cùng cái làm W1 rẻ đi lại làm nó đắt lên ở chỗ khác:** cơ chế by-construction khiến 2 key mới **tự động** vào `All`, mà `All` chính là thứ 3 method ở `AwV2DesignerMenuKeyTests` neo cứng số. Vậy **không tồn tại đường "thêm 2 loại mà 697 giữ nguyên"**. Rủi ro spec đánh giá thấp **không phải** FK boot (đã có lưới), mà là **chi phí spec-change trên test** — thứ acceptance A5 đang phủ nhận sự tồn tại.
## 6. VERDICT + finding đánh số
(đang ghi)
**VERDICT: PASS-WITH-FINDINGS — quyết định ②.2(a) chọn A1 GIỮ NGUYÊN, W1 phải sửa trước khi giao.**
Hai trụ đỡ quyết định A1 đều đứng vững sau khi tự đo: enum là `int` trần (§2) và họ `HD-*` nở by-construction từ `Members` (§1.3). Không có căn cứ nào để lật sang A2 (cờ biến-thể). Tất cả finding dưới đây là **lỗi của W1 (bản kê việc + acceptance)**, không phải lỗi của lựa chọn kiến trúc.
| # | Mức | Finding | Bằng chứng |
|---|---|---|---|
| **F-1** | 🔴 NẶNG | **A4 pass vacuous.** "Pin chéo ⇒ 409" xanh cả khi QUÊN thêm `Members` — cùng hàm ném 409 cho ca "loại ngoài họ". Acceptance mất khả năng phân biệt. | `ContractWorkflowGuards.cs:147-152` ⟂ `:157-160` |
| **F-2** | 🔴 NẶNG | **A5 sai by-construction.** "697 PASS" bất khả thi; 4 assert neo 7/17 + 3 hằng test buộc đỏ. Tệ hơn: nó thưởng cho cách sửa SAI (không đụng `Members`). | `AwV2DesignerMenuKeyTests.cs:132,182,204,232` + `:70-71,:81,:87`; canonical 697 = `docs/STATUS.md:6` |
| **F-3** | 🔴 NẶNG | **W1 bỏ sót trọn tầng chi-tiết per-loại**, đúng tầng mà "0 migration nhờ dùng chung `ThauPhuDetails`" phụ thuộc: 2 switch BE + 4 map FE × 2 app. Spec 0 hit chữ `ContractDetails`. | `ContractDetailsFeatures.cs:41-113`, `:434-462`; `ContractDetailsTab.tsx:33,:77-83,:517,:583-597` |
| **F-4** | 🟠 VỪA | **Đếm site thấp: 13 → đo được 17.** Thiếu #10-#17. | bảng §1.2 |
| **F-5** | 🟠 VỪA | **3 nhánh CÂM nữa không được khai**, trong đó `ContractCodeGenerator.cs:28 _ => "HĐ"` đẻ **mã HĐ sai RG-001 không hồi tố được** (seq đã cháy). ②.1 trình bày như đã quét hết ("Bốn cái BẪY"). | `ContractCodeGenerator.cs:28`; `ContractDetailsFeatures.cs:41`; `ContractDetailsTab.tsx:583` |
| **F-6** | 🟠 VỪA | **2 mirror FE của họ `HD-*` chép tay không nằm trong W1.** Một cái tự khai là bản chép ("chép TỪNG KÝ TỰ"). | `fe-admin/src/lib/contractWorkflowFamily.ts:46-52`; `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx:59-65` (chú `:55-56`) |
| **F-7** | 🟡 NHẸ | **A2 canh sai chỗ** — 12/14 key mới ngoài `All` nên không có FK để nổ; vế `All` thì CI đã canh sẵn ⇒ bước boot thủ công thừa. | `MenuKeys.cs:69`; `ContractCatalogTests.cs:445` |
| **F-8** | 🟡 NHẸ | **A1 đo hình dạng + đẻ rác** (phải `migrations remove`, dính luật 3 file). Có lệnh chuẩn thay thế. | đề xuất `has-pending-model-changes` §4 |
| **F-9** | 🟡 NHẸ | **Bẫy CÂM #1 của chính ②.1 không có acceptance nào phủ.** Khuôn Theory 7 dòng đã sẵn, thêm 2 dòng là xong. | `WorkflowPolicyTests.cs:150-156` |
| **F-10** | ⚪ GHI CHÚ | **Control-âm của phép đo CHECK-constraint bị thoái hoá**: cả DB 0 check ⇒ "0 CHECK trên `Type`" đúng nhưng vô thông tin. Kết luận không đổi (kiểu cột `int` + 0 `HasConversion<string>` mới là bằng chứng). | sqlcmd: `CHECKS_ON_TYPE=0`, `CONTROL_TOTAL_CHECKS=0` |
**Việc phải làm trước khi W1 rời bàn:** sửa A4 thành cặp âm/dương phân biệt 2 câu 409 (F-1) · sửa A5 thành "697+N, khai N, cho phép sửa đúng 3 method neo-số, cấm xoá/skip" (F-2) · thêm tầng chi-tiết vào bản kê việc W1 (F-3) · thêm acceptance cho `DefaultPolicyNameFor` và cho token codegen (F-5, F-9).

View File

@ -1,25 +1,266 @@
# sub-reviewer-2 — R2-FORMSHAPE (YC-032, S194)
> Vai: reviewer (adversarial). Vật chấm: `spec-hd-thauphu-2form-13-08-2026.md` §②.2(b) HTML dựng tay · §②.2(c) component chung + 2 bản lắp ráp · §②.2(d) chỉ fe-user · §②.1 bẫy 3 · W2.
> Kỷ luật: mọi số từ lệnh vừa chạy, kèm lệnh. 0-hit kèm control-dương.
> Kỷ luật: mọi số dưới đây từ lệnh chạy trong lượt này, kèm lệnh. 0-hit kèm control-dương.
> 2 file `.doc` gốc CHỈ ĐỌC (owner đang mở 002.02 — `~$` lock 162 B còn nguyên); text trích đổ ra scratchpad ngoài repo.
## 0. Khung
(đang ghi)
## 1. Đo lại độ chung 2 file
(đang ghi)
**VERDICT: `PASS-WITH-FINDINGS` — 9 finding (6 nặng).**
## 2. draftData:null
(đang ghi)
| Vật chấm | Kết luận |
|---|---|
| (b) HTML dựng tay làm trục | **ĐỨNG** — 3 mảnh thiếu kiểm từng cái đều thật; 1 mảnh mô tả quá nặng (F6), 1 chứng cứ hỏng thước (F6) |
| (c) component chung + 2 bản lắp ráp | **ĐỨNG, và mạnh hơn lead nghĩ** — nhưng **lý do lead đưa là non-sequitur** (F2); số 66%/+245 là artifact wrap-width (F1) |
| (d) chỉ làm fe-user | **ĐỨNG một nửa** — mâu thuẫn với chính W2 gạch-1 và với route fe-admin còn sống (F8) |
| W2 acceptance | **HỤT** — đo trạng thái tĩnh, thiếu 6 lớp ca + thiếu ca âm (F9) |
| Bẫy #3 `draftData:null` | **XÁC NHẬN, nặng hơn spec viết** — đang xoá dữ liệu thật hôm nay, không để lại vết (F3) |
| `DraftData` làm kho | **4 rủi ro spec chưa nêu** (F5) + **1 mâu thuẫn W2⟂W3 trong chính spec** (F4) |
## 3. DraftData làm kho
(đang ghi)
---
## 4. 3 mảnh thiếu
(đang ghi)
## 1. Đo lại độ chung 2 file — bằng thước KHÁC
## 5. Acceptance W2
(đang ghi)
### 1.1 Tái lập số của lead
Lead (spec:52): *"f02 200 dòng không-rỗng → 133 dòng (66%) nằm nguyên trong f03; f03 378 dòng → 245 dòng riêng"*.
Đo lại bằng ĐÚNG lệnh lane-5 dùng:
```
antiword -m UTF-8.txt "<002.02>.doc" > f02.txt # 346 dòng tổng
antiword -m UTF-8.txt "<002.03>.doc" > f03.txt # 538 dòng tổng
grep -c '[^[:space:]]' f02.txt f03.txt # → 140 / 251 (KHÔNG phải 200 / 378)
```
`grep -c .` cho **cùng** kết quả 140/251 ⇒ không phải chuyện "dòng toàn khoảng trắng".
**Nguồn lệch = wrap-width của antiword** (đo, không suy):
| cờ | f02 dòng không-rỗng | f03 |
|---|---|---|
| `-w 0` (= mặc định máy này, lane-5 dùng) | 140 | 251 |
| `-w 100` | 178 | 333 |
| **`-w 78`** | **197** | **374** |
| `-w 60` | 235 | 444 |
`antiword -w 78 … | grep -c .`**197 / 374**, lệch lead 1,5% / 1,1% ⇒ số của lead ra từ bản trích **có wrap**, số lane-5 ra từ bản **không wrap**. Cả hai đều "đúng", nhưng:
🔴 **`+245 dòng riêng` là con số CO GIÃN 1,8× theo một cờ dòng lệnh** ⇒ CẤM dùng nó làm thước khối lượng render. Tỉ lệ thì bền (dưới).
### 1.2 Thước 1 — tập DÒNG THÔ (tái lập thước của lead)
`python cmp.py` (SequenceMatcher + set), trên bản `-w 0`:
- f02 có mặt trong f03: **90/137 = 65,7%****khớp con số 66% của lead**. Tỉ lệ HELD.
- f03 không có trong f02: 142/232 = 61,2% (lead: 245/378 = 64,8%).
### 1.3 Thước 2 — CHUẨN HOÁ rồi mới so (gộp `…`/`....`/`___` → `<B>`, bỏ `|`, gộp space)
- f02 nằm trong f03: **105/134 = 78,4%** (tăng **12,7 điểm %** so với thước thô).
**1/8 phần "khác nhau" của lead chỉ là ĐỘ DÀI DẤU CHẤM LỬNG** — đúng thứ lane-5 §D đã cảnh báo bằng chữ ("khác nhau ở mức dấu cách") mà thước của lead không hấp thụ.
### 1.4 Thước 3 — SO CÓ THỨ TỰ (difflib opcodes, đây là thước quyết định)
```
ratio(dòng, chuẩn-hoá) = 0.586 ratio(ký tự, chuẩn-hoá) = 0.685
equal = 107 | delete (chỉ-f02) = 0 | insert (chỉ-f03) = 67 | replace 29 ↔ 55
equal/f02 = 78,7% equal/f03 = 46,7%
```
🔴 **`delete = 0`** — **không một dòng nào của 002.02 biến mất khỏi 002.03**. Về cấu trúc, **f02 ⊂ f03**: mọi khác biệt là *thêm vào* hoặc *sửa tại chỗ*, không có *bỏ đi*.
### 1.5 Thước 4 — delta nằm ở ĐÂU (số mối nối)
```
9 khối lệch; 5 khối lớn nhất = 115/122 dòng = 94% toàn bộ delta
insert 58 :: PHỤ LỤC 01 (kéo theo PL02/03/04)
replace 28 :: ĐIỀU 3 GIÁ TRỊ HỢP ĐỒNG (f02: bảng 7 cột tại chỗ ⟂ f03: kh.5-8, bảng dời sang PL02)
replace 14 :: ĐIỀU 1 HỒ SƠ HỢP ĐỒNG (f03 liệt 4 phụ lục đích danh)
insert 8 :: TRÁCH NHIỆM RỦI RO VỀ THUẾ (chỉ f03)
replace 7 :: bảng tóm tắt PHẦN 1 dòng 9-16
```
2 khối còn lại 1 dòng (header thân `Dự án`; `gồm:`/`gồm có:`).
### 1.6 Kết luận điểm (1)
**"Component chung + 2 bản lắp ráp" ĐỨNG VỮNG** — nhưng 3 hiệu chỉnh:
- **[F2-a] Nghiêng hẳn về cực lane-5, KHÔNG cân bằng.** Spec:53 viết *"Cả hai đúng một nửa"*. Số nói khác: lane-5 (*"1 xương chung + delta"*) khớp `delete=0` + 78,7% equal; lane-2 (*"KHÔNG phải sinh đôi, sai từ giả thiết"*) dựa trên `diff | grep -c '^[<>]'` = 400 — thước này đếm **cả hai phía của mỗi thay đổi** và mù chuẩn-hoá, nên phóng đại. Trình bày "mỗi bên đúng một nửa" là **trao trọng số bằng nhau cho hai chứng cứ không ngang nhau**.
- **[F2-b] Lý do lead đưa là NON-SEQUITUR.** *"f03 gấp ~1,9× nên một `bool` không chứa nổi"* — kích thước KHÔNG quyết định số nhánh; một `bool` gate 5 khối vẫn là một `bool`. Thứ **thật sự** loại phương án 1-cờ là: 3/5 mối nối là **replace tại chỗ** (ĐIỀU 3 · ĐIỀU 1 · bảng tóm tắt) chứ không phải include/exclude, nên cờ sẽ phải chui **vào trong** component chung — đúng cái smell mà quyết định (c) muốn tránh. Đề nghị thay câu lý do bằng: **"delta = 9 khối, 94% dồn vào 5 mối nối có tên; 2 mối là chèn thuần, 3 mối là thay tại chỗ"** — và liệt đúng 5 mối nối đó vào W2 làm điểm lắp ráp.
- **[F1] Bỏ `+245 dòng riêng` khỏi spec** hoặc kèm cờ trích (`-w`). Giữ tỉ lệ (66% thô / 78% chuẩn-hoá).
⚠️ **Caveat của chính thước tôi (khai thẳng):** chuẩn-hoá làm rơi dòng bảng rỗng (f03 251→229 dòng) ⇒ `insert=67` **đếm HỤT công render** 4 phụ lục (PL01 13 dòng trống + PL02 11 dòng + header merge 2-3 tầng). Thước này đo **lệch VĂN BẢN**, không đo **khối lượng RENDER**. Ai lấy 67 làm ước lượng công là dùng sai thước.
---
## 2. `draftData: null` — verify + "gỡ ra có phá gì không"
### 2.1 Verify (grep của tôi)
`git grep -n "draftData\|DraftData" -- '*.tsx' '*.ts' '*.cs'` (bỏ Migrations):
- **4 site `draftData: null`** — `fe-user/src/pages/contracts/ContractCreatePage.tsx:366` (create) + `:582` (update), và **y hệt**`fe-admin/…:366` + `:582`.
- 2 file này **byte-identical**: `sha256sum` cả hai = `17edc625ddc9654be68af5b4cb3da5542141029343ff046bf3af12e5e184f55e`.
- Khai kiểu: `fe-{user,admin}/src/types/contracts.ts:160/:156 draftData: string | null`.
- **0 site FE ĐỌC** `draftData` (chỉ 4 hit ghi-null + 2 hit khai kiểu).
### 2.2 [F3] Nặng hơn spec viết — đang xoá dữ liệu THẬT, và xoá KHÔNG để lại vết
- BE gán **vô điều kiện**: `ContractFeatures.cs:207` `entity.DraftData = request.DraftData;`
Đối chiếu ngay trên đó `:200-205`: `ApprovalWorkflowId` **CÓ** nhánh *"null = không nói gì"* (`if (request.ApprovalWorkflowId is Guid rePinId && …)`). Cùng một handler, hai kiểu xử lý null ngược nhau ⇒ `null` của `DraftData` = **lệnh xoá**, không phải "bỏ trống".
- Đường mất dữ liệu có thật hôm nay: `CreateContractFromEvaluationFeatures.cs:104` `DraftData = pe.PaymentTerms` → user mở màn sửa → bấm **Lưu thay đổi** (`ContractCreatePage.tsx:609`) → PUT `draftData:null` → điều khoản thanh toán **bay**.
- 🔴 **Không vào changelog**: danh sách `changes` (`ContractFeatures.cs:170-183`) liệt 6 field (GiaTri · TenHopDong · NoiDung · TemplateId · BudgetManualName · BudgetManualAmount) — **không có DraftData** ⇒ mất im lặng, lịch sử không thấy, hậu-kiểm không truy được.
### 2.3 Ai đang DỰA vào `DraftData` bị null?
**Không ai.** Toàn bộ consumer đo được:
| Site | Vai |
|---|---|
| `ContractFeatures.cs:27/:105` | Create nhận + gán |
| `:146/:207` | Update nhận + gán |
| `:851` + `ContractDtos.cs:46` | trả ra **ContractDetailDto** (detail; **list KHÔNG select** — kiểm rồi) |
| `CreateContractFromEvaluationFeatures.cs:104` | đường GHI duy nhất có dữ liệu thật |
| FE | 0 site đọc |
**Gỡ `null` ra KHÔNG phá gì đang chạy.** Rủi ro duy nhất đi theo chiều ngược: khi FE bắt đầu gửi thân HĐ vào cột này thì nó **đụng shape của `PaymentTerms`** → §3/F4.
**Đo Dev (sqlcmd `(localdb)\MSSQLLocalDB` / `SolutionErp_Dev`):**
```
Contracts rows = 9 | DraftData IS NOT NULL = 0 | MAX(LEN(DraftData)) = (n/a)
PurchaseEvaluations có PaymentTerms != null = 1
```
⇒ 2 hệ quả: (a) đúng lý do bug này sống lâu mà không ai thấy; (b) **hiện là thời điểm RẺ NHẤT** để chốt lược đồ (0 dòng cần di trú ở Dev — prod chưa đo, xem §④.2 của spec).
---
## 3. `Contract.DraftData nvarchar(max)` làm kho — rủi ro spec chưa nêu
### [F4] 🔴 MÂU THUẪN TRONG CHÍNH SPEC: 1 cột, 2 lược đồ JSON
- **W2** (spec:114): *"Lưu vào `Contract.DraftData` (JSON) — 0 migration"* = thân hợp đồng.
- **W3** (spec:120): *"Vá regression: cầu KHKK gán lại `DraftData = pe.PaymentTerms`"*.
- `PurchaseEvaluation.cs:29` khai shape của `PaymentTerms`: `JSON {tamUng, thanhToanTam, quyetToan, baoHanh, hanMucCongNo, danhGia}`**khác hoàn toàn** thân HĐ.
⇒ HĐ đi đường PE/KHKK sẽ mang JSON thanh-toán trong cột mà document-view coi là thân HĐ: mở ra parse nhầm hoặc trắng trang; **lần Lưu đầu tiên đè chết** điều khoản thanh toán (lần này không phải bug FE mà là **đúng theo thiết kế đang viết**). Hai wave trong cùng một spec đang tranh một cột.
Tiêu chí thoát (không đề xuất mã): hoặc bao ngoài `{schemaVersion, body:{…}, paymentTerms:{…}}`, hoặc cột riêng cho thân HĐ. **Cả hai đều phải có `schemaVersion`** — hiện 0 chỗ nào có.
### [F5] 4 rủi ro kỹ thuật spec chưa nêu (đo được)
1. **0 validator cho `DraftData`.** `CreateContractCommandValidator` (`ContractFeatures.cs:39-49`) đặt `MaximumLength`: TenHopDong 500 · NoiDung 2000 · BudgetManualName 200. `grep RuleFor.*DraftData` toàn `Application/` = **0 hit** (control-dương: 5 `MaximumLength` khác trong cùng file, dòng 47-49/246/285). `nvarchar(max)` + 0 rào ⇒ thân HĐ (~30-60 ô + 3 bảng N dòng) đi qua **không ai canh kích thước**; hàng rào duy nhất còn lại là giới hạn body mặc định của Kestrel — tức "không phải rào của mình".
2. **0 concurrency token.** `grep -rn "RowVersion|IsConcurrencyToken|IsRowVersion"` trên `Domain/Contracts/` + `ContractConfiguration.cs` = **0** (control-dương: `HasMaxLength` = 11 hit cùng file ⇒ thước sống). Với payload từng-field, last-write-wins mất **1 ô**; với **1 blob cả thân HĐ**, 2 người sửa 2 chỗ KHÁC NHAU thì người lưu sau **xoá trọn phần của người lưu trước**. Rủi ro đổi BẬC, không phải đổi mức. (Skill `contract-workflow` xếp RowVersion vào Tier-4 "future" — đó là đánh giá cho thời `DraftData` còn rỗng.)
3. **Detail payload phình.** `DraftData` nằm trong `ContractDetailDto` (`ContractFeatures.cs:851`) ⇒ mọi lần mở HĐ kéo cả thân. Điểm CỘNG cần giữ: **list KHÔNG select** `DraftData` — nếu wave sau tiện tay thêm vào list projection thì mỗi trang danh sách gánh N× thân HĐ.
4. **0 `schemaVersion`, 0 đường di trú.** Thân HĐ sẽ đổi (thêm ĐIỀU, đổi khoá ô, tách 002.02/002.03). JSON cũ không có mốc để biết đọc theo luật nào ⇒ hoặc phải đoán bằng hình dạng, hoặc phải viết migration mò. Dev đang **0 row** ⇒ thêm version bây giờ tốn 1 khoá; thêm sau khi có dữ liệu thật là việc khác hẳn.
---
## 4. "Thiếu 3 mảnh" của (b) — kiểm từng mảnh
### Mảnh 1 — `DocxRenderer` không lặp bảng: **ĐỨNG** (đọc mã, không tin comment)
Đọc `src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs` (81 dòng):
- `:53-80 ReplaceInElement` chỉ duyệt `root.Descendants<Paragraph>()`, gom `<w:t>` **trong CÙNG paragraph**, replace, gán vào `<w:t>` đầu. **Không nhân bản node nào.**
- `grep "CloneNode|InsertAfter|TableRow"` trên cả `Infrastructure/Forms/` = **0 hit**; hit duy nhất của từ "loop" là **chính dòng comment `:10`** ⇒ comment và mã KHỚP (đây là ca comment nói thật, ngược với bài "lời hứa chỉ sống trong comment").
- `XlsxRenderer.cs:20-37` cũng chỉ replace `CellsUsed()`**cả 2 renderer đều không nhân dòng**.
🔸 **Sắc thái spec bỏ (làm mảnh này NHẸ hơn spec tả):** regex `:14``\{\{([a-zA-Z0-9_\.]+)\}\}`**chấp nhận dấu chấm** ⇒ bảng có **số dòng CỐ ĐỊNH** render được ngay bằng khoá đánh số (`{{r1.dongia}}`…), không cần engine mới. Chiếu vào 2 form: **bảng tóm tắt PHẦN 1 (14 dòng f02 / 16 dòng f03) là cố định** ⇒ đường vòng dùng được; chỉ **bảng DANH MỤC N dòng** là thật sự bí. Tức "không render được bảng" đúng với **1 trong 3 loại bảng**, không phải cả 3.
### Mảnh 2 — 2 file gốc chưa placeholder-hoá: **ĐỨNG**
`grep -c '{{'` trên text trích: **f02 = 0 · f03 = 0** (control-dương cùng lệnh: `grep -c 'ĐIỀU'` = 44 / 45 ⇒ thước sống, file đọc được).
🔸 Spec bỏ một nửa tin tốt: đường **đưa file vào hệ thống đã tự động**`FormFeatures.cs:188-239` nhận upload `.doc` rồi convert `.docx` (`AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>`). ⇒ việc còn lại là **sửa tay trong Word** (chèn `{{…}}`), không phải dựng máy.
Kèm theo, 2 khai của spec §②.5 tôi kiểm và ĐÚNG: `DbInitializer.cs:3833/:3837` seed sẵn row 002.02/002.03 trỏ tên file **`.docx`** với `IsActive = fileExists`; `:3861 if (existingCodes.Contains(formCode)) continue` ⇒ thả file vào sau **không bật cờ**.
### Mảnh 3 — 0 viewer docx trong trình duyệt: **kết luận ĐỨNG, chứng cứ HỎNG THƯỚC**
Lệnh mà lane-2 `:91` và spec:49 trích có nhánh `slate` trong alternation. Đo:
```
git grep -c "slate" -- 'fe-user/src' | wc -l → 81 file (Tailwind text-slate-*)
```
**lệnh đó không thể trả `0 file`**; con số "0" trong lane-2/spec không tái lập được.
Đo lại bằng token chính xác (`git grep -l -- <token> -- 'fe-user/src' 'fe-admin/src'`):
```
contenteditable 0 · dangerouslySetInnerHTML 0 · tiptap 0 · slate-react 0 · @tiptap 0
quill 0 · ProseMirror 0 · mammoth 0 · docx-preview 0 · react-doc-viewer 0
control-dương cùng thể lệnh: useMemo = 41 file
package.json ×2: 0 hit editor/docx (control-dương: 'tanstack' 2 hit)
```
**giữ kết luận, thay chứng cứ.** (Ghi thêm: `dangerouslySetInnerHTML = 0` là tin tốt cho lens bảo mật khi sắp dựng document-view — đừng để wave nào phá mốc này.)
### [F7] 🔴 Mảnh THỨ TƯ spec chưa thấy — tách 2 loại làm CHẾT dropdown template
- 2 row template seed **cứng** `ContractType.HopDongThauPhu`: `DbInitializer.cs:3833` (002.02) và `:3837` (002.03).
- FE nạp template **theo loại**: `ContractCreatePage.tsx:565` `params: { type: contract.type }`; BE lọc `FormFeatures.cs:33` `query.Where(x => x.ContractType == request.ContractType)`.
⇒ Sau W1 (Thầu phụ tách thành type 8 và 9), HĐ loại mới hỏi `type=8/9`**0 template** → ô "Mẫu" rỗng. Seeder **skip theo FormCode** (`:3861`) nên không tự sửa; phải có vế `UPDATE ContractTemplates.ContractType` (hoặc nhân đôi row).
**W1 (spec:101-107) không có việc này, và acceptance W1 cũng không nhìn thấy nó.**
### Kết luận điểm (4)
Không mảnh nào "thực ra đã có". Nhưng **2 hiệu chỉnh về mức độ**: mảnh 1 chỉ chặn **bảng N dòng động** (bảng tóm tắt cố định thì đi được bằng khoá đánh số), mảnh 2 chỉ còn **việc tay trong Word** (đường nạp + convert đã tự động). Đổi lại, phát sinh **mảnh thứ tư** (F7) nặng hơn cả ba.
---
## 5. Acceptance W2 — "nhập → Lưu → F5 → đủ ô còn nguyên"
### [F8] Trước hết: W2 tự mâu thuẫn với quyết định (d)
- (d) spec:55 — *"Làm 1 app — `fe-user`"*.
- W2 gạch-1 spec:110 — *"Gỡ `draftData:null` **4/4 site**"* ⇒ **buộc sửa `fe-admin`**.
- W2 acceptance spec:115 — *"`npm run build` **×1 app** sạch"* ⇒ **build không phủ file vừa sửa**.
- Thực địa: fe-admin **chỉ ẩn MENU**, route sống nguyên — `fe-admin/src/App.tsx:82-84` (`/contracts`, `/contracts/new`, `/contracts/:id`); `Layout.tsx:234-247` `ADMIN_HIDDEN_KEYS` (chú thích ngay trong mã: *"route + quyền còn nguyên"*) + `:255` prefix `Ct_`. Admin lại **bypass mọi policy**.
⇒ Nếu đọc (d) theo nghĩa đen mà chỉ gỡ null ở fe-user, **đường xoá trắng vẫn sống** ở app của chính người hay mở màn đó. Đề nghị sửa (d) thành: *"**document-view** dựng ở fe-user; **vá bẫy #3 + dict `TYPE_CODE_TO_INT` áp CẢ 2 app**"*, acceptance build **×2**.
### [F9] Acceptance đo TRẠNG THÁI TĨNH — hụt 6 lớp ca + thiếu ca âm
| # | Ca thiếu | Vì sao chết nếu không đo | Neo |
|---|---|---|---|
| 1 | **Ô lặp bind-1-nguồn** | `Số` ×2 · `Hạng mục` ×2 · `Địa điểm` ×2 · **tên Bên B ×3** · `Dự án` ×2 (f03). "Đủ ô còn nguyên" pass ngay cả khi 3 vị trí giữ **3 giá trị khác nhau**. Phải đo: sửa 1 chỗ ⇒ 2 chỗ kia đổi theo, **và** sau F5 vẫn bằng nhau | lane-5 §C1 dòng 138; spec:113 có nêu việc, acceptance **không** đo |
| 2 | **Bảng N dòng** | thêm/xoá/**đổi thứ tự** dòng bảng DANH MỤC → F5 phải giữ đúng **số dòng + thứ tự**. "Đủ ô" không đếm được dòng; JSON array ⟂ object-key là chỗ mất thứ tự im lặng | lane-5 §E (bảng 7 cột + 3 dòng tổng) |
| 3 | **Ô số/tiền** | nhập `1.234.567` (FE đang dùng `toLocaleString`**30 file**) → lưu phải là số thô, hiện lại phải đúng định dạng. Và **ô dẫn xuất** (`THÀNH TIỀN = KL × ĐƠN GIÁ`, `TỔNG CỘNG`, `VAT 10%`, `TỔNG SAU THUẾ`) phải khai **tính lại hay lưu** — lưu mà lệch = 2 nguồn sự thật trên cùng tờ HĐ | lane-5 §C2 ô 27 |
| 4 | **Ô ngày** | bản gốc là `……/……/20……` (**3 mảnh rời**), không phải date-picker. Phải chốt lưu 1 chuỗi hay 3 mảnh, và F5 giữ nguyên ca **điền một phần** (có tháng, trống ngày) | lane-5 §C1 ô 9/24 |
| 5 | **Xoá về rỗng** | điền → **xoá trắng** → Lưu → F5 phải **vẫn rỗng**. Khi W3 nối pre-fill vào, đây là ca chết người: prefill điền lại đúng thứ người dùng cố ý xoá ⇒ W2 pass, W3 làm vỡ mà không ai thấy | spec W3:117-122 |
| 6 | **"Bằng chữ"** | `numberToWords` = **0 file** toàn repo (control-dương `toLocaleString` = 30 file fe-user) ⇒ ô `Bằng chữ: … đồng` hoặc gõ tay hoặc chờ W3 — W2 phải khai, không để lửng | spec:77 có nêu, W2 không nhắc |
| **âm** | **Ca ÂM (thiếu hẳn)** | bẫy #3 chỉ lộ khi có ca âm: **sửa ô header cũ** (Tên HĐ / Giá trị) rồi Lưu ⇒ `DraftData` **KHÔNG được** về null. Chính `:582` (đường gây bug) là đường này, và nó **không** nằm trong luồng "nhập thân HĐ → Lưu" | `ContractFeatures.cs:207` |
Thêm 1 nhẹ: acceptance *"ô máy-điền ⟂ ô phải-gõ **phân biệt được bằng mắt**"* (spec:115) là tiêu chí **chủ quan** — không phải phép đo. Muốn đo được thì đổi thành thuộc tính kiểm được (vd mỗi ô prefill mang `data-source` / class riêng, đếm được bằng số).
---
## 6. VERDICT + finding
(đang ghi)
**`PASS-WITH-FINDINGS` — 9 finding (6 nặng).** 3 quyết định (b)/(c)/(d) đều đứng về bản chất; (d) và W2 cần sửa câu chữ + bổ sung acceptance trước khi giao thợ.
| # | Mức | Finding | Neo |
|---|---|---|---|
| F1 | nhẹ | `66% / +245 dòng` là artifact **wrap-width** (140/251 ở `-w 0` ⟂ 197/374 ở `-w 78`; co giãn 1,8×). Tỉ lệ HELD (65,7%), con số tuyệt đối phải bỏ hoặc kèm cờ trích | spec:52 |
| F2 | nhẹ | Kết luận (c) đúng nhưng **lý do sai**: "gấp 1,9× nên bool không chứa nổi" = non-sequitur. Thật ra: `delete=0` (f02 ⊂ f03), 9 khối lệch, **94% dồn vào 5 mối nối**, 3/5 là *replace tại chỗ*. Và "cả hai lane đúng một nửa" là **cân bằng giả** — lane-5 khớp số | spec:52-53 |
| F3 | **NẶNG** | Bẫy #3 **đang xoá dữ liệu thật hôm nay** (`:207` gán vô điều kiện, đối chiếu `:200-205` có nhánh giữ-nguyên) và **không vào changelog** (`:170-183` thiếu DraftData) ⇒ mất im lặng. Gỡ ra **không phá gì** (0 site đọc) | `ContractFeatures.cs:207` · `ContractCreatePage.tsx:366,582` ×2 app (sha trùng) |
| F4 | **NẶNG** | **W2 ⟂ W3 tranh 1 cột**: W2 ghi thân HĐ vào `DraftData`, W3 ghi `pe.PaymentTerms` (`{tamUng,thanhToanTam,quyetToan,baoHanh,hanMucCongNo,danhGia}`) vào **cùng cột** ⇒ parse nhầm + đè chết. Phải chốt bao-ngoài/cột-riêng + `schemaVersion` **trước** W2 | spec:114 ⟂ spec:120 · `PurchaseEvaluation.cs:29` |
| F5 | **NẶNG** | Kho `DraftData`: **0 validator** (control-dương 5 `MaximumLength` khác cùng file) · **0 RowVersion** (control-dương 11 `HasMaxLength`) ⇒ blob-level last-write-wins mất TRỌN phần người kia · detail payload phình (list còn sạch, giữ) · **0 schemaVersion** trong khi Dev đang 0 row = thời điểm rẻ nhất | `ContractFeatures.cs:39-49,851` · `ContractConfiguration.cs` |
| F6 | nhẹ | "3 mảnh" đều thật, nhưng: mảnh-1 chỉ chặn **bảng N dòng động** (regex `:14` nhận dấu chấm ⇒ bảng cố định 14/16 dòng đi được) · mảnh-2 chỉ còn **việc tay trong Word** (upload `.doc``.docx` đã tự động) · mảnh-3 **chứng cứ hỏng thước** (`slate` khớp 81 file Tailwind), đo lại bằng token chính xác vẫn 0 ⇒ giữ kết luận, thay chứng cứ | `DocxRenderer.cs:10,14,53-80` · `FormFeatures.cs:188-239` · spec:49 |
| F7 | **NẶNG** | **Mảnh thứ tư spec chưa thấy**: 2 row template seed cứng `ContractType.HopDongThauPhu`; FE+BE lọc template theo type ⇒ sau khi tách type 8/9 thì **dropdown Mẫu rỗng**, seeder skip-theo-FormCode không tự sửa. W1 thiếu vế `UPDATE ContractTemplates.ContractType` | `DbInitializer.cs:3833,3837,3861` · `FormFeatures.cs:33` · `ContractCreatePage.tsx:565` |
| F8 | **NẶNG** | (d) "làm 1 app" ⟂ W2 gạch-1 "4/4 site" ⟂ acceptance "build ×1 app". fe-admin **chỉ ẩn menu, route sống** (`App.tsx:82-84`) + admin bypass policy ⇒ đọc theo nghĩa đen sẽ **để lại đường xoá trắng** | spec:55,110,115 · `Layout.tsx:234-255` |
| F9 | **NẶNG** | Acceptance W2 đo **trạng thái tĩnh**: hụt 6 lớp ca (ô lặp · bảng N dòng thứ-tự · số/tiền + ô dẫn xuất · ngày 3 mảnh · **xoá về rỗng** · bằng chữ) + **thiếu ca ÂM** (sửa ô header cũ ⇒ `DraftData` không được về null). Thêm 1 tiêu chí chủ quan không đo được ("phân biệt bằng mắt") | spec:115 |
### Đề nghị thứ tự xử lý (không phải mã, chỉ điều kiện nghiệm thu)
1. **Chốt F4 trước mọi thứ** — lược đồ `DraftData` (bao ngoài + `schemaVersion`) là tiền đề của cả W2 lẫn W3; sửa sau khi có dữ liệu là việc khác hẳn.
2. **F3 + F8 đi chung 1 nhát**: gỡ null **4/4 site**, thêm `DraftData` vào `changes` của changelog, build **×2 app**, và ca âm ở §5 làm test-before (bug tái diễn ⇒ theo `docs/rules.md §7` là test-before **bắt buộc**).
3. **F7 nhét vào W1** kèm acceptance đo được: sau seed, `GET /api/forms/templates?type=8``?type=9` mỗi cái **≥1 row `isActive`**.
4. **F5**: `MaximumLength` cho `DraftData` ở cả 2 validator + quyết định RowVersion (chấp nhận rủi ro thì **khai thẳng** trong spec, đừng để trống).
5. **F1/F2/F6**: sửa câu chữ spec — bỏ số co giãn, thay lý do bằng "5 mối nối", thay lệnh `slate` bằng lệnh token chính xác.
---
### Lệnh tái lập (mọi số trong file này)
```bash
# §1
antiword -m UTF-8.txt "<FORM>/…002.02….doc" > f02.txt ; antiword -m UTF-8.txt "<FORM>/…002.03….doc" > f03.txt
grep -c '[^[:space:]]' f02.txt f03.txt # 140 / 251
for W in 60 78 100 0; do antiword -w $W -m UTF-8.txt <f> | grep -c . ; done # 235/444 · 197/374 · 178/333 · 140/251
python cmp.py # set-thô 65,7% · set-chuẩn-hoá 78,4% · equal/f02 78,7% · delete=0 · 9 khối / 5 khối = 94%
# §2-§5
git grep -n "draftData\|DraftData" -- '*.tsx' '*.ts' '*.cs'
sha256sum fe-{admin,user}/src/pages/contracts/ContractCreatePage.tsx
sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT COUNT(*) FROM Contracts; SELECT COUNT(*) FROM Contracts WHERE DraftData IS NOT NULL; SELECT COUNT(*) FROM PurchaseEvaluations WHERE PaymentTerms IS NOT NULL"
grep -rn "RowVersion\|IsConcurrencyToken" src/Backend/SolutionErp.Domain/Contracts/ …/ContractConfiguration.cs # 0 (control: HasMaxLength=11)
grep -rn "CloneNode\|InsertAfter\|TableRow" src/Backend/SolutionErp.Infrastructure/Forms/ # 0 (control: 'loop' 1 hit = comment)
grep -c '{{' f02.txt f03.txt # 0/0 (control: 'ĐIỀU' 44/45)
git grep -c "slate" -- 'fe-user/src' | wc -l # 81 ← chứng thước của spec:49 hỏng
git grep -l -- contenteditable|tiptap|slate-react|quill|ProseMirror|mammoth|docx-preview -- fe-{user,admin}/src # 0 (control: useMemo 41)
```

View File

@ -1,22 +1,239 @@
# sub-reviewer-3 — YC-032 chấm ②.4 bản đồ pre-fill + 2 lỗ nghiêm trọng + wave W3
# sub-reviewer-3 — chấm ②.4 bản đồ pre-fill + 2 lỗ nghiêm trọng + wave W3 (YC-032)
- **run-id:** `2026-08-13-S194-yc032-hd-thauphu-2form` · **vai:** `reviewer` (adversarial, READ-only)
- **vật chấm:** `spec-hd-thauphu-2form-13-08-2026.md` §②.4 + §③ W3 · nguồn thô `sub-investigator-codebase-3.md`
- **VERDICT:** `R3-PREFILL: PASS-WITH-FINDINGS — 9 finding (4 nặng)`
## 0. Khung
(đang đo)
## 1. Verify 24/17/3
(đang đo)
Mọi số dưới đây từ lệnh chạy trong lượt này. Công cụ: `grep` (repo), `sed -n` (đọc neo), `sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev` (đo dữ liệu THẬT). 0-hit đều kèm control-dương.
## 2. Regression cầu KHKK
(đang đo)
3 lớp tôi tách ra khi chấm (spec trộn cả 3 vào một câu ⇒ đây là gốc của 3/4 finding nặng):
1. **Lớp MÃ** — code có gán field đó không.
2. **Lớp DỮ LIỆU** — cột đó trên Dev có giá trị không.
3. **Lớp NGƯỜI ĐỌC** — có ai render nó ra không.
## 3. Chuỗi tiền + VAT
(đang đo)
Spec §②.4 chấm rất tốt lớp 1, **không chạm lớp 2 và 3** — mà YC-032 là việc IN RA GIẤY nên lớp 2+3 mới là thứ owner nhìn thấy.
## 4. Đọc vượt tầng
(đang đo)
---
## 5. Acceptance W3
(đang đo)
## 1. Verify 24 / 17 / 3
### 1a. Con số **3** — HELD (tự đo, không chép)
Đọc trọn `CreateContractFromSigningPlanFeatures.cs`. Initializer `new Contract` (`:215-238`) + codegen (`:243`) gán **15 cột**:
`Type:217 · Phase:218 · SupplierId:219 · ProjectId:220 · DepartmentId:221 · DrafterUserId:222 · GiaTri:223 · TenHopDong:224 · NoiDung:225 · BudgetManualName:228 · BudgetManualAmount:229 · WorkflowDefinitionId:230 · ApprovalWorkflowId:233 · SlaDeadline:236 · MaHopDong:243`.
Giao với 24 ô của bảng §C lane-3 ⇒ đúng **3 ô**: `TenHopDong`→C2 (`:224`) · `GiaTri`→D1 (`:223`) · `MaHopDong`→F1 (`:243`). **Claim ĐỨNG.**
### 1b. 🔴 Nhưng "3" chỉ đúng dưới MỘT định nghĩa — spec không khai định nghĩa đó [FINDING-1, MAJOR]
"Đã chảy" ở đây = *có một cột vô hướng trên hàng `Contracts` giữ đúng chuỗi in ra giấy*. Dưới định nghĩa **"lấy được lúc render mà không cần nối đường dữ liệu mới"** thì con số khác hẳn: `SupplierId:219` · `ProjectId:220` đã nằm sẵn trên hàng ⇒ **8 ô (B1-B6 + C3 + C4) chỉ cách 1 phép JOIN**, không phải "đường chưa nối".
Hệ quả không nhỏ: W3 hiện được mô tả như việc **nối ống dữ liệu**, trong khi phần lớn là việc **chọn kiểu đọc + viết DTO/render**. Và chỗ đó ẩn một quyết định kiến trúc mà spec chưa hề nêu: **ảnh chụp (snapshot vào `DraftData`) hay đọc sống (join runtime)?** HĐ đã ký mà địa chỉ NCC tự đổi theo master về sau là sai về pháp lý; ngược lại ảnh chụp thì phép so "byte-exact với nguồn" của W3 chỉ đúng tại thời điểm t0. **Spec phải chốt trục này trước khi W3 chạy.**
### 1c. Cận trên của "24" — 24 là số LOẠI ô, không phải số ô trên giấy [FINDING-2, MINOR]
Spec §④.6 tự khai "24 là cận dưới" — đúng nhưng chưa nói cận trên lớn cỡ nào. Ước lượng có cấu trúc:
| nguồn phình | công thức | ghi chú |
|---|---|---|
| D4 bảng giá | `7 × N 1` | 1 dòng bảng = 7 ô; §C đếm cả bảng là **1** ô |
| D5 tổng | `+2` | TỔNG CỘNG · VAT · TỔNG SAU THUẾ đếm là 1 |
| ô lặp (spec W2 tự liệt) | `+6` | Số ×2 · Hạng mục ×2 · Địa điểm ×2 · tên Bên B ×3 · Dự án ×2 |
**ô vật lý ≈ 31 + 7N**. Với N=10 hạng mục là **~101 ô**; N=20 là **~171 ô**. Chênh 4-7 lần so với "24" — đủ để đổi ước lượng công của W2/W3. Đề nghị spec ghi thẳng "24 = số LOẠI ô; số ô trên giấy ≈ 31 + 7N".
### 1d. "6/7 cột 1-1" đếm hụt 1 [FINDING-3, MINOR — sai theo chiều AN TOÀN]
Lane-3 (và spec §②.4 chép lại) nói `ThauPhuDetail` khớp **6/7 cột**. Đo thật: `ThauPhuDetail.cs``HangMuc·DonViTinh·KhoiLuong·DonGia·ThoiGianHoanThanh`, còn `ThanhTien` + `GhiChu` + **`Order`** nằm ở lớp cha `ContractDetailBase.cs:14-16`. Cột `STT` của .doc ↔ `Order`**7/7, không phải 6/7**.
---
## 2. Regression cầu KHKK — claim ĐỨNG ở lớp MÃ, **SAI Ở MỨC ĐỘ** [FINDING-4, MAJOR]
### 2a. Mã: HELD, mở cả hai file so trực tiếp
| field | cầu PE cũ | cầu KHKK mới |
|---|---|---|
| `DraftData = pe.PaymentTerms` | **CÓ**`CreateContractFromEvaluationFeatures.cs:104` (`// carry forward payment terms`) | **KHÔNG** — vắng trong `:215-238` |
| `BypassProcurementAndCCM` | **CÓ**`:103` (từ `request`, mặc định `false` `:27`) | **KHÔNG** — vắng |
### 2b. 🔴 "Bug ĐANG SỐNG trên prod?" — **KHÔNG.** Đo 3 tầng, cả 3 đều bác
**(i) Dữ liệu:** `SELECT COUNT(*) ... FROM Contracts`**9 HĐ, `DraftData IS NOT NULL` = 0, `BypassProcurementAndCCM=1` = 0.**
⇒ Câu của spec *"HĐ đi đường KHKK mất sạch điều khoản thanh toán mà HĐ đi đường PE vẫn có"* **không đúng với dữ liệu**: đường PE cũng **0/9**. Không có HĐ nào đang "vẫn có" cả.
**(ii) Người ghi đè:** `draftData: null` hard-code **4/4 site**`fe-admin/src/pages/contracts/ContractCreatePage.tsx:366,:582` + `fe-user/...:366,:582` (spec §②.1 bẫy 3 — ĐỨNG, tôi đo lại đủ 4). Nghĩa là kể cả HĐ đi đường PE có mang `PaymentTerms`, **lần bấm Lưu đầu tiên là xoá trắng**. Chênh lệch giữa 2 cầu bị bịt ngay ở nhịp sau.
**(iii) Người đọc:** grep toàn repo `DraftData|draftData` = 87 hit, bỏ Migrations còn **10 hit sống**: 3 write BE (`ContractFeatures.cs:105` create · `:207` update · `CreateContractFromEvaluationFeatures.cs:104`), 2 khai DTO (`ContractFeatures.cs:851` · `ContractDtos.cs:46`), 1 entity, 1 EF config, **4 FE = 2 khai type + 2×2 gán `null`**. **0 chỗ render.**
**Phát biểu lại cho đúng:** đây không phải "regression làm mất điều khoản thanh toán". Đây là **`DraftData` = cột CHỈ-GHI: 3 người ghi, 0 người đọc, 4 site FE chủ động xoá.** Và đó mới là điều đáng lo cho YC-032, vì **W2 định đem toàn bộ thân hợp đồng nhét vào đúng cột này** — xây nhà trên cột mà hành vi sống duy nhất của nó hôm nay là *bị xoá*.
### 2c. Vế `BypassProcurementAndCCM` gần như VÔ HẠI với HĐ sinh từ KHKK [FINDING-5, MINOR]
Grep người đọc cờ này: chỉ `WorkflowPolicy.cs:164` (`ForContract`) + `:172` (`ForContractWithOverrides`), và 2 hàm đó chỉ được gọi ở `ContractFeatures.cs:690-697` (dựng policy hiển thị). Máy chuyển phase thì `ContractWorkflowService.cs:165` **rẽ V2 TRƯỚC**: `if (contract.ApprovalWorkflowId is Guid awId) → ApproveV2Async → return`. Mà cầu KHKK **luôn pin V2** (không pin được thì ném 409, `:171-177`) ⇒ với mọi HĐ sinh từ KHKK, cờ bypass **không đụng vào đường duyệt**. Dev: `bypass_true = 0/9`.
⇒ Xếp 2 vế này ngang nhau trong một gạch đầu dòng "REGRESSION" là **thổi phồng vế nhẹ**. W3 nên vá `DraftData` (có lý do thật, xem 2b) và **bỏ** vế bypass, hoặc khai rõ nó chỉ để đối xứng mã.
### 2d. Số đo mà W3 đòi nhưng chưa ai chạy — tôi chạy rồi
`SELECT COUNT(*) FROM PurchaseEvaluations`**4 phiếu, `PaymentTerms IS NOT NULL` = 1** (25%).
⇒ Vá regression sẽ mang được dữ liệu cho **1/4 phiếu**. Không phải 0 (nên vá vẫn có nghĩa), nhưng cũng đủ nhỏ để đừng đặt nó làm trục demo cho owner.
---
## 3. Chuỗi tiền + VAT — **HƯỚNG cảnh báo đúng, CHUỖI NHÂN QUẢ SAI** [FINDING-6, CRITICAL]
Đây là claim nặng nhất của spec (*"sai thì owner ra quyết định sai về tiền"*) nên tôi truy từng mắt xích.
### 3a. Truy ngược đúng như spec vẽ
`Contract.GiaTri` = `lines.Sum(l => l.ApprovedAmount!.Value)` (`CreateContractFromSigningPlanFeatures.cs:202`,`:223`)
`ApprovedAmount`: grep toàn `src/` (bỏ Migrations) ⇒ **write-site DUY NHẤT** `ContractSigningPlanWorkflowService.cs:358` `line.ApprovedAmount ??= line.ProposedAmount;`
`ProposedAmount`: **SỬA TAY ĐƯỢC**`ContractSigningPlanFeatures.cs:751-754` (`line.ProposedAmount = amt`) + `:862` (tạo dòng theo request); mồi ban đầu `= peReference` (`:573`)
`PeReferenceAmount` = Σ `Quote.ThanhTien` WHERE `IsSelected` (`:552-554`)
`PurchaseEvaluationQuote.ThanhTien` — entity chú thích `// = KL × ChuaVat (tính sẵn)`.
Mắt xích cuối chính là chỗ spec dựa vào để kết luận "chưa VAT". **Mắt xích đó đã mục.**
### 3b. 🔴 `ChuaVat` là cột CHẾT — UI không nhập nó nữa
`fe-user/src/components/pe/PeDetailTabs.tsx:2959-2962` (nguyên văn):
```
// Session 20 turn 3: user yêu cầu "tạm thời chỉ cần nhập số tiền, không
// cần 3 cột có VAT / không VAT / tổng". UI chỉ 1 input thanhTien; bgVat /
// chuaVat / note vẫn gửi BE giữ schema (default 0 / empty cho row mới,
// giữ giá trị cũ nếu existing).
```
Payload xác nhận: `bgVat: existing?.bgVat ?? 0, chuaVat: existing?.chuaVat ?? 0, thanhTien: form.thanhTien` (`:2975-2978`). BE nhận nguyên si, không tính lại: `PurchaseEvaluationDetailFeatures.cs:310` (`existing.ThanhTien = request.ThanhTien`) + `:341`.
**Đo trên Dev:** `PurchaseEvaluationQuotes` = **34 dòng · `ChuaVat=0` = 12 · `BgVat=0` = 12**; trong 22 dòng còn lại, **22/22 thoả `ThanhTien = KhoiLuongNganSach × ChuaVat`** ⇒ đúng công thức seed `DbInitializer.cs:1565`.
⇒ Phân đôi sạch: **22 dòng seed giữ công thức · 12 dòng người nhập qua UI thì `ChuaVat=0`**.
**Kết luận đảo lại claim:** `Contract.GiaTri` **không phải "số chưa VAT"**. Nó là **số tiền người ta gõ tay, không có bất kỳ chỗ nào trong schema ghi số đó đã gồm VAT hay chưa**. Chú thích entity `// = KL × ChuaVat`**chú thích thối** (đúng cho seed, sai cho mọi dòng UI từ Session 20).
🔴 **Vì sao đây là finding nặng nhất:** spec đang định trình owner một lựa chọn nhị phân *"số hiện tại chưa VAT — có nhân 1,1 lên không?"*. Owner trả lời "có" ⇒ **mọi báo giá vốn đã gõ gồm-VAT sẽ bị cộng VAT lần hai**. Câu hỏi đúng phải là: ***"số tiền NCC gõ vào phiếu Duyệt NCC là gồm VAT hay chưa — và hệ thống có cần ghi lại điều đó không?"*** — tức là câu hỏi về **quy ước nhập liệu + cột mới**, không phải câu hỏi về phép nhân.
### 3c. Phụ đề: "VAT 10%" của .doc cũng không có chỗ dựa
Cột `BgVat` ("Báo giá NCC gửi (đã VAT)") **đã là** đơn giá gồm VAT ⇒ nếu nó được nhập thì thuế suất suy ra được (`BgVat/ChuaVat 1`) và **không nhất thiết là 10%**, có thể khác nhau từng dòng. Nhưng đo ra `BgVat=0` ở 12/34 ⇒ đường đó cũng không dùng được hôm nay. Dòng "VAT 10%" in cứng trên .doc vì thế là **giả định của tờ giấy**, chưa có dữ liệu nào đỡ.
### 3d. 🔴 LỖ TIỀN THỨ HAI — spec KHÔNG nêu, nặng hơn VAT vì không cần giả định gì [FINDING-7, CRITICAL]
W3 định lấy **bảng giá 7 cột từ PE** (Σ = `PeReferenceAmount`) trong khi **ô "Giá trị HĐ" lấy `Contract.GiaTri`** (= Σ `ApprovedAmount`). Mã tự khai 2 số này **cố ý khác nhau** (`CreateContractFromSigningPlanFeatures.cs:226-229`: *"Σ số PE ... KHÔNG phải số đã chốt"*).
Đo 2/2 dòng KHKK đã bridge trên Dev (không phải seed — grep `ContractSigningPlanLine` trong `DbInitializer.cs` = **0 hit**, đây là dữ liệu chạy thật):
| dòng | `PeReferenceAmount` | `ProposedAmount` | `ApprovedAmount` | `Contract.GiaTri` | `BudgetManualAmount` |
|---|---|---|---|---|---|
| 9B10… | 1.600.000.000 | 1.550.000.000 | 1.500.000.000 | **1.500.000.000** | 1.600.000.000 |
| 9B10… | 550.000.000 | 520.000.000 | 500.000.000 | **500.000.000** | 550.000.000 |
**2/2 dòng (100%) có `ApprovedAmount ≠ PeReferenceAmount`**, lệch **100 triệu (6,25%)****50 triệu (9,1%)**.
Nếu W3 làm đúng y như đang viết, hợp đồng in ra sẽ có **dòng TỔNG CỘNG của bảng = 1,6 tỷ** còn **ô Giá trị hợp đồng = 1,5 tỷ**, trên **cùng một tờ giấy**. Đây là hợp đồng tự mâu thuẫn về tiền, **không cần dính dáng gì tới VAT**, và đo được ở 100% dữ liệu hiện có. W3 buộc phải chốt: hoặc bảng scale về `ApprovedAmount`, hoặc thêm dòng "điều chỉnh sau thương thảo", hoặc bảng đọc số chốt chứ không đọc số PE.
### 3e. Bẫy liên đới: hai cột khối lượng [FINDING-8, MINOR — hôm nay còn ngủ]
`PurchaseEvaluationDetail`**cả** `KhoiLuongNganSach``KhoiLuongThiCong`. Bản đồ lane-3 lấy `KhoiLuongThiCong` cho cột KHỐI LƯỢNG, còn tiền (`Quote.ThanhTien` seed) tính theo `KhoiLuongNganSach` ⇒ khi 2 số lệch thì **KL × ĐơnGiá ≠ ThànhTiền ngay trong một dòng bảng**. Đo Dev: `KhoiLuongThiCong <> KhoiLuongNganSach` = **0/12****đang ngủ, chưa cháy**. Vẫn phải chốt bằng chữ trong W3 chọn cột nào.
---
## 4. Đọc vượt tầng qua `plan.PurchaseEvaluationId` — HELD, không N+1, nhưng có 2 bẫy
### 4a. Đường CÓ mở thật
`CreateContractFromSigningPlanFeatures.cs:199-200`: `var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(e => e.Id == plan.PurchaseEvaluationId, ct);`**TRACKED** (cố ý, để ghi `pe.ContractId ??=``:277`). Claim W3 **ĐỨNG**.
`ContractSigningPlan.cs:24` `public Guid PurchaseEvaluationId`**non-nullable**, loose-Guid không FK. Write-site duy nhất `ContractSigningPlanFeatures.cs:529` `= pe.Id`. Đo Dev: **1 phiếu KHKK, 0 phiếu trỏ tới PE không tồn tại, 0 PE soft-deleted** ⇒ đường vượt tầng hôm nay 1/1 nối được.
### 4b. Ranh tầng: KHÔNG phá — đã có tiền lệ trong CHÍNH file
File này vốn đã đọc PE (`pe.MoTa` `:225`, `pe.TenGoiThau` `:209`) và namespace `Application.ContractSigningPlans` đã `using SolutionErp.Domain.Contracts` (`:13`). Đọc thêm `pe.Details/.Quotes` **không mở ranh mới**, chỉ dùng sâu hơn đường đã mở. Cầu PE cũ cũng `Include(p => p.Details).ThenInclude(d => d.Quotes)` (`CreateContractFromEvaluationFeatures.cs:48`) ⇒ có khuôn sẵn.
### 4c. N+1: KHÔNG — nhưng chỉ khi viết bằng `Include`
Cầu chạy **1 lần cho 1 HĐ**, không lặp theo N. Rủi ro N+1 chỉ xuất hiện nếu implementer duyệt `pe.Details` rồi truy `d.Quotes` mà quên `Include`. Đo: `UseLazyLoadingProxies` = **0 hit** trong `src/` (control-dương `UseSqlServer` = 2 hit ⇒ lưới sống) ⇒ **không có lazy-loading**: quên `Include` sẽ ra **danh sách rỗng lặng lẽ**, không phải N+1 chậm. **Hỏng câm, không hỏng ồn** — nguy hơn.
### 4d. 🔴 BẪY KHOÁ NỐI — hai chữ "SupplierId" là hai thứ khác nhau [FINDING-9, MAJOR]
- `ContractSigningPlanLine.SupplierId` = **`Supplier.Id` master** (chứng: `:559 SupplierId = w.SupplierId`; và cầu dùng nó tra `db.Suppliers` `:185`).
- `PurchaseEvaluationQuote.PurchaseEvaluationSupplierId` = **`PurchaseEvaluationSuppliers.Id`** (hàng nối PE×NCC) — cầu PE cũ lọc đúng bằng `q.PurchaseEvaluationSupplierId == w.Id` với `w` là hàng PE-supplier (`CreateContractFromEvaluationFeatures.cs:89`).
Dev có **11 hàng `PurchaseEvaluationSuppliers`** so với **24 `Suppliers`** ⇒ hai không gian Id khác hẳn. Viết `q.PurchaseEvaluationSupplierId == line.SupplierId` sẽ khớp **0 dòng****bảng giá in ra RỖNG mà không có lỗi nào nổ**. Cộng với 4c (không lazy-load) thì đây là **hai đường dẫn tới cùng một kiểu hỏng câm**. W3 phải ghi rõ: *trước hết tra `PurchaseEvaluationSuppliers` theo `(PurchaseEvaluationId, SupplierId)` để lấy id hàng nối, rồi mới lọc quote.*
---
## 5. Acceptance W3 — CHƯA ĐỦ
Acceptance đang viết: *"tạo HĐ từ KHKK thật trên Dev → đếm ô tự điền ≥14 · so từng ô với nguồn byte-exact · ĐO DỮ LIỆU TRƯỚC: COUNT(PE có PaymentTerms != null)"*.
### 5a. 🔴 Ngưỡng "≥14" không có mẫu số — đo HÌNH DẠNG chứ không đo HÀNH VI
Đếm lại bảng §C của lane-3 theo từng dòng: 24 dòng = 5 (✍ gõ tay) + 2 (❓) + **17 (có nguồn)**; trong 17 có 3 đã chảy **và có A1 "in cứng trong form — không cần nối"**. ⇒ **số ô còn phải nối thật sự = 17 3 1 = 13, không phải 14.** Spec đặt ngưỡng **≥14 trên một tập chỉ có 13 phần tử** ⇒ **cài đặt hoàn hảo vẫn trượt**, trừ khi đếm D4 thành 7 ô (lúc đó ngưỡng 14 lại quá dễ, qua ngay ở dòng bảng thứ hai).
Nặng hơn: "ô tự điền" **không định nghĩa ô rỗng tính hay không tính**. Xem 5b — phần lớn ô nối xong sẽ rỗng, mà máy đếm vẫn ghi "đã điền". **Acceptance xanh trong khi tờ hợp đồng trắng.**
⇒ Sửa: bỏ ngưỡng trần trụi, thay bằng **bảng điểm danh 13 dòng, mỗi dòng ghi `<tên ô> | <nguồn file:line> | <giá trị đọc được> | có/rỗng`** — điểm danh từng dòng thì không Goodhart được.
### 5b. 🔴 "ĐO DỮ LIỆU TRƯỚC" chỉ kê 1 phép — thiếu ít nhất 5 phép NẶNG HƠN. Tôi đo giúp, kết quả xấu:
| # | phép đo | kết quả trên Dev | ảnh hưởng |
|---|---|---|---|
| 1 | PE có `PaymentTerms` (spec CÓ kê) | **1/4 (25%)** | vá regression mang được data cho 1/4 phiếu |
| 2 | `Suppliers.BankAccount` rỗng | **20/24 (83%)** | ô B4 **in trắng** |
| 3 | `Suppliers.LegalRepresentative` rỗng | **20/24 (83%)** | ô B6 **in trắng** |
| 4 | `Suppliers.TaxCode` rỗng | **6/24 (25%)** | ô B5 trắng 1/4 |
| 5 | `Projects.Investor` rỗng | **64/69 (93%)** | ô C3 **in trắng** |
| 6 | `Projects.Location` rỗng | **64/69 (93%)** | ô C4 **in trắng** |
| — | (đối chứng) `Suppliers.Address` / `Phone` rỗng | 2/24 (8%) | B2·B3 ổn |
| — | (đối chứng) PE `DiaDiem`/`MoTa`/`TenGoiThau` rỗng | **0/4 (0%)** | PE điền rất đủ |
🔴 **Đây là finding có sức nặng nhất với owner:** trong 8 ô master-data mà W3 định nối, **5 ô sẽ in ra trắng trên đa số hợp đồng** dù mã hoàn toàn đúng. Đúng lớp *"cơ-chế đúng, dữ-liệu không có"* đã lặp nhiều lần ở dự án này (S157: 57/62 dự án rỗng `Location`; nay **64/69** — dữ liệu **xấu đi**, không tốt lên).
⇒ W3 phải kèm **một nhánh dữ liệu**: hoặc chiến dịch điền master (Bên B: TK + người đại diện; Dự án: chủ đầu tư), hoặc cho người soạn **ghi đè tay** vào đúng ô đó và lưu ở `DraftData`. Nối ống mà không có nước thì owner bấm xong vẫn thấy tờ giấy trắng và kết luận "chưa làm được".
⇒ Và đổi ngay nguồn ô C4: **`pe.DiaDiem` (0/4 rỗng) tốt hơn hẳn `Project.Location` (64/69 rỗng)** — lane-3 có ghi ` pe.DiaDiem` như phương án phụ, **phải đảo thành phương án chính**.
### 5c. "So byte-exact" chưa đủ nghĩa để chạy
Thiếu 3 vế: (a) so với **nguồn nào** khi ô có 2 nguồn (C4: `Project.Location` hay `pe.DiaDiem`)? (b) so ở **thời điểm nào** — nếu snapshot vào `DraftData` thì byte-exact chỉ đúng tại t0 (xem 1b); (c) **ô rỗng có coi là khớp không** — theo 5b thì 5 ô sẽ "rỗng khớp rỗng" và qua bài.
### 5d. 3 ca acceptance còn thiếu hẳn
1. **Ca tiền tự mâu thuẫn (3d):** tạo HĐ từ dòng KHKK có `ApprovedAmount ≠ PeReferenceAmount` (Dev đang có sẵn 2/2 dòng như vậy) ⇒ **khẳng định TỔNG bảng và ô Giá trị HĐ nói cùng một số**.
2. **Ca hỏng câm (4c+4d):** cố tình lọc quote bằng `line.SupplierId` ⇒ phải thấy bảng rỗng; acceptance phải **cấm bảng rỗng đi qua** (0 dòng = FAIL, không phải "không có gì để điền").
3. **Ca `DraftData` bị xoá (2b):** nhập → Lưu → F5 → còn nguyên. Spec đã đặt ca này ở **W2**, nhưng W3 ghi vào cùng cột ấy ⇒ **W3 phụ thuộc CỨNG vào việc W2 gỡ xong 4 site `draftData: null`**. Quan hệ phụ thuộc này **không có trong spec** (W3 chỉ khai phụ thuộc W0). Nếu W3 land trước W2 thì mọi ô pre-fill lưu vào `DraftData` sẽ bị chính FE xoá ở lần Lưu đầu tiên.
---
## 6. VERDICT + finding
(đang đo)
**`R3-PREFILL: PASS-WITH-FINDINGS — 9 finding (4 nặng)`**
Spec §②.4 **không có claim nào bịa**: 24/17/3 đúng, regression đúng ở lớp mã, đường vượt tầng đúng là đã mở. Nhưng **3 trong 5 kết luận bị lệch MỨC ĐỘ theo hai chiều ngược nhau** — một vế bị thổi (regression), một vế bị hụt (tiền) — và cả hai lệch đều do spec dừng ở lớp mã, không chạm lớp dữ liệu.
| # | mức | nội dung | neo |
|---|---|---|---|
| 6 | 🔴 CRITICAL | Chuỗi "GiaTri ← ThanhTien = KL × ChuaVat ⇒ chưa VAT" **SAI**: `ChuaVat` là cột chết (UI gửi 0, 12/34 dòng Dev = 0), `ThanhTien` là số gõ tay **không khai VAT**. Hỏi owner theo khuôn cũ ⇒ rủi ro **cộng VAT hai lần**. | `PeDetailTabs.tsx:2959-2962` · `PurchaseEvaluationDetailFeatures.cs:310,341` · sqlcmd 12/34 |
| 7 | 🔴 CRITICAL | Bảng giá lấy từ PE (Σ=`PeReferenceAmount`) trong khi ô Giá trị HĐ = `GiaTri` (Σ=`ApprovedAmount`) ⇒ **hợp đồng tự mâu thuẫn về tiền**; Dev **2/2 dòng lệch 100tr & 50tr**. Spec **không nêu**. | `CreateContractFromSigningPlanFeatures.cs:202,223,226-229` · sqlcmd 2/2 |
| 5b | 🔴 MAJOR | 5/8 ô master-data sẽ **in trắng**: TK 83% · người đại diện 83% · chủ đầu tư 93% · địa điểm 93% · MST 25% rỗng. W3 thiếu nhánh dữ liệu. Đổi C4 sang `pe.DiaDiem` (0% rỗng). | sqlcmd Suppliers/Projects/PE |
| 5a | 🔴 MAJOR | Ngưỡng "≥14 ô" đặt trên tập chỉ có **13** phần tử + không định nghĩa ô-rỗng ⇒ đo hình dạng, không đo hành vi. Thay bằng bảng điểm danh 13 dòng. | đếm lại §C lane-3 |
| 4 | MAJOR | Regression bị **thổi phồng**: `DraftData` **0/9 HĐ** có giá trị (cả đường PE), **0 người đọc**, 4 site FE xoá. Vấn đề thật = **cột chỉ-ghi** mà W2 định dùng làm nhà cho cả thân HĐ. | `ContractCreatePage.tsx:366,582` ×2 app · grep 10 hit sống · sqlcmd 0/9 |
| 9 | MAJOR | Bẫy khoá nối: `Line.SupplierId` (master) ⟂ `Quote.PurchaseEvaluationSupplierId` (hàng PE×NCC, 11 hàng) ⇒ lọc nhầm = **bảng rỗng câm**; không lazy-load nên không nổ. | `…SigningPlanFeatures.cs:559` · `…FromEvaluationFeatures.cs:89` · 0-hit `UseLazyLoadingProxies` (control 2) |
| 1 | MAJOR | "3 ô đã chảy" đúng nhưng thiếu định nghĩa; 8 ô khác **cách 1 JOIN**. Trục **snapshot ⟂ đọc sống** chưa ai chốt — ảnh hưởng cả tính pháp lý lẫn phép so byte-exact. | `…FromSigningPlanFeatures.cs:219-220` |
| 2 | MINOR | "24 ô" là số **loại** ô; ô trên giấy ≈ **31 + 7N** (N=10 ⇒ ~101). | §④.6 + W2 danh sách ô lặp |
| 3 | MINOR | "6/7 cột 1-1" thật ra **7/7**`STT ↔ ContractDetailBase.Order`. | `ContractDetailBase.cs:14-16` |
| 8 | MINOR | `KhoiLuongThiCong``KhoiLuongNganSach` (tiền tính theo cột sau) — Dev **0/12 lệch**, đang ngủ. | `PurchaseEvaluationDetail.cs` · `DbInitializer.cs:1565` |
**Việc phải làm trước khi W3 chạy (theo thứ tự):**
1. Sửa câu hỏi VAT gửi owner — hỏi **quy ước nhập liệu**, đừng hỏi phép nhân (F-6).
2. Chốt bảng giá đọc số nào để TỔNG khớp `GiaTri` (F-7).
3. Thêm nhánh dữ liệu master + đảo C4 sang `pe.DiaDiem` (F-5b).
4. Thay ngưỡng ≥14 bằng bảng điểm danh 13 dòng, cấm bảng-giá-rỗng qua bài (F-5a, F-9).
5. Khai phụ thuộc **W3 → W2** (gỡ `draftData: null`) vào spec (F-4, 5d-3).
---
### Ghi chú thủ tục (không phải finding về spec)
Bản giao việc tự mâu thuẫn về đường ghi: phần TASK yêu cầu ghi `sub-reviewer-3.md`, khối RUN-TRACE ở cuối lại ghi `sub-reviewer-2.md` (và khai `reviewer` **không** có Write tool, trong khi lượt này có). Tôi theo TASK (cụ thể, đích danh vai trò của lượt) ⇒ file này là `sub-reviewer-3.md`. `sub-reviewer-2.md` **không bị tôi đụng tới**.

View File

@ -3,21 +3,39 @@
> Vật chấm: `spec-hd-thauphu-2form-13-08-2026.md` (LEAD tổng hợp 5 lane invest).
> Lăng-kính: kế hoạch — thứ tự wave, phụ thuộc, vế 4 (bản cứng/khoá/in), §②.3 phát hiện CHẶN.
> ⚠️ Xung-đột đường-dẫn trong spec giao việc: khối TASK ghi `sub-reviewer-4.md`, khối RUN-TRACE footer ghi `sub-reviewer-3.md`. Chọn `-4` (khối TASK cụ-thể hơn + khớp tên lane R4); khai ở finding.
> 🔁 Lượt này = CHẠY LẠI đã-cắt-gọt (lượt trước ghi khung rỗng rồi chết). Kỷ luật: append sau MỖI phát hiện, không gom cuối.
## 0. Khung
(đang chạy — append dần)
- Không chấm lại 3 trục đã có lane anh em phủ: taxonomy/W1 (R1) · form-shape/W2 (R2) · prefill/W3 (R3).
- Việc lượt này: (1) verify §②.3 "thiếu 12 điều" độc lập · (2) khoá chết vế 4 · (3) wave thiếu/chồng · (4) đồ thị phụ thuộc thật · (5) rủi ro §④ xếp hạng · (6) 5 câu owner.
- Mọi số dưới đây đến từ lệnh chạy trong lượt này; 0-hit luôn kèm control-dương.
- CHỈ ĐỌC toàn bộ (owner đang mở `002.02` trong Word).
## 1. Verify 12 điều thiếu
(đang đo)
## 2. Wave thiếu/chồng
(chưa)
## 3. Phụ thuộc
(chưa)
## 4. Khoá chết vế 4
(chưa)
## 5. Rủi ro đánh giá thấp
(chưa)
## 6. Câu hỏi owner
(chưa)
## 7. VERDICT + finding
(chưa)