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

**VERDICT: `PASS-WITH-FINDINGS` — 9 finding (6 nặng).**

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

---

## 1. Đo lại độ chung 2 file — bằng thước KHÁC

### 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` là `\{\{([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

**`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` và `?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)
```
