# sub-reviewer-4 — CHẤM KẾ HOẠCH (lăng-kính "cái gì THIẾU")

> 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

- 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

**Phương pháp:** `python zipfile` đọc `word/document.xml`, gộp mọi `<w:t>` trong từng `<w:p>` (script `scratchpad/dieu_scan.py` · `hunt12.py`); `.doc` dùng `antiword -m UTF-8.txt -w 0`.

🔴 **THƯỚC HỎNG lần 1 — phải khai:** `antiword` KHÔNG có `-m UTF-8.txt` trả text mà **control-dương `'hợp đồng'` = 0 hit** trên cả 3 file `.doc` ⇒ output không phải UTF-8, mọi số đọc từ đó là RÁC. Thêm `-m UTF-8.txt` ⇒ control lên **56 hit** ở `002.02`. (Bài: 0-hit trên file 516 KB mà không có control-dương thì đọc thành "file không chứa gì".)

### 1.1 — Claim §②.3 ĐỨNG (verify độc lập, 2 nơi)

| Đo | Kết quả |
|---|---|
| `002.04` ở `wwwroot/templates` vs `FORM/` | **sha256[:16] = `d83fd7ad832b0ba3` cả hai** · 192.608 B ⇒ byte-identical, khớp spec |
| `002.04` thân | `w:p=62` · nonempty=40 · `<w:tbl>`=0 · **đúng 5 heading** `ĐIỀU 1..5` |
| 5 điều đó | 1 AM HIỂU TÀI LIỆU · 2 NỘI DUNG CÔNG VIỆC · 3 TIẾN ĐỘ-CHẤT LƯỢNG · **4 XỬ LÝ KHI CÓ TRANH CHẤP** · **5 SỬA ĐỔI, BỔ SUNG HỢP ĐỒNG** |
| Mục lục `002.02` (`L0042-L0058`) | **ĐIỀU 1→17**, trong đó `16 = XỬ LÝ KHI CÓ TRANH CHẤP`, `17 = SỬA ĐỔI, BỔ SUNG` |
| Mục lục `002.03` (`L0041-L0057`) | **giống hệt 1→17** |

⇒ Số học khớp: 17 − 5 = **12 điều thiếu**, và ánh xạ đổi-số (16→4, 17→5) đúng như spec mô tả. **Claim §②.3 HELD.**

### 1.2 — Truy 12 điều ở file khác: KHÔNG CÓ (0-hit có control-dương)

Quét **7 file** `FORM/` + **4 file** `wwwroot/templates/`, khớp **nguyên văn tiêu đề** 12 điều:

| File | 12 tiêu đề có mặt | Control-dương (3 tiêu đề thân `002.04`) |
|---|---|---|
| `002.02` `.doc` | 12/12 — **nhưng chỉ trong khối MỤC LỤC `L0042-58`** | c1·c2·c3 ✓ |
| `002.03` `.doc` | 12/12 — **cũng chỉ mục lục** | c1·c2·c3 ✓ |
| `002.04` | **2/12** (`BẢO HÀNH`·`BẤT KHẢ KHÁNG`) — khớp thân-văn rời, KHÔNG phải heading | c1·c2·c3 ✓ |
| `002.05` Giao khoán | 3/12 (`HỦY BỎ HĐ`·`BẢO HÀNH`·`BẤT KHẢ KHÁNG`) — HĐ **loại khác**, thân riêng | c2·c3 ✓ |
| `002.06` Mua bán `.doc` | 1/12 | c3 ✓ |
| `002.01`, `RG-001` | **0/12** | **0 control** ⇒ thước còn sống, file thật sự không liên quan |

Phép kiểm quyết định: `'TẠM ỨNG VÀ THANH TOÁN'` trong `002.02` = **đúng 1 lần** (dòng mục lục `L0049`) ⇒ **không có thân ĐIỀU 8**. Đối chứng: `'ĐỊNH NGHĨA VÀ DIỄN GIẢI'` = **3 lần** — `L0035` mục lục + `L0130` **heading thân** + 1 lần trong câu ⇒ điều CÓ thân thì đếm ≥2.

⇒ **12 điều KHÔNG nằm ở bất kỳ file nào trong `FORM/` hay `wwwroot/templates/`.** §②.3 là CHẶN thật, không phải "chưa tìm kỹ".

### 1.3 — 🔴 PHÁT HIỆN MỚI spec chưa có: số hiệu ĐIỀU là AUTO-NUMBER, không nằm trong text

- `L0130` thân `002.02` in ra **`ĐỊNH NGHĨA VÀ DIỄN GIẢI` KHÔNG kèm chữ "ĐIỀU 2"** ⇒ số điều ở **thân** 2 file `.doc` do Word tự sinh (list numbering), **không có trong luồng text**.
- `002.05`: `<w:numPr>` = **168** · `pStyle="Heading*"` = **102**, mà `'ĐIỀU <n>'` literal chỉ **1 hit** ⇒ cùng bệnh.
- Đối chứng: `002.04` có `<w:numPr>`=33 · `pStyle Heading`=**0**, và `ĐIỀU 1..5` **là chữ thật** ⇒ hai file cùng họ dùng **hai cơ chế đánh số khác nhau**.

**Hệ quả cho kế hoạch:** W2 phải chép tay mục lục + tiêu đề điều, nhưng §④.5 đã cấm chép text `antiword`; đồng thời **mọi phép trích `<w:t>` sẽ mất trắng số hiệu**. ⇒ Không có nguồn chữ nào đáng tin trên đĩa, mà **không wave nào nhận việc sản xuất bản chữ chuẩn** (xem F-03).

⚠️ Vết cần Word xác nhận (không tự phán): dòng mục lục **ĐIỀU 14** ở CẢ 2 file đọc ra `"THÔNG TIN VÀ THÔNG BÁOXỬ LÝ KHI CÓ TRANH CHẤP"` — dính 2 tiêu đề, không dấu ngăn. Có thể là lỗi copy-paste trong form gốc, cũng có thể là `antiword` gộp 2 đoạn cùng ô. Ai chép mục lục vào HTML phải mở Word đọc lại ô này.

## 2. Wave thiếu/chồng

### 2.1 — 🔴 Nạp 2 file template: KHÔNG wave nào nhận, và nó KHÔNG chỉ là "thả file" (F-04)

`DbInitializer.cs:3833-3838` khai tên file cho `002.02` / `002.03` **đuôi `.docx`**, mô tả tự ghi *"(cần convert .doc → .docx qua Word COM)"*. Đĩa thật:

```
FORM/  SOL-CCM-FO-002.02.v01 ... .doc   516.608 B     (ls -la)
FORM/  SOL-CCM-FO-002.03.v01 ... .doc   591.360 B
wwwroot/templates/  → 002.02/002.03 KHÔNG có mặt (4 file: 002.01, 002.04, 002.05, 002.07, RG-001)
```

⇒ việc thật là **3 việc**, spec chỉ nhắc 1 nửa của việc thứ 3, và **0 wave nhận cả ba**:
1. **CONVERT `.doc → .docx`** — chất lượng chưa ai chạy thử (chính §④.3 khai); nếu convert hỏng bảng thì mọi thứ dưới nó hỏng theo.
2. Chép 2 file vào `wwwroot/templates/`.
3. `UPDATE ContractTemplates SET IsActive=1` — vì `DbInitializer.cs:3861 if (existingCodes.Contains(formCode)) continue` chặn mọi lượt boot sau.

*(Khác ca R2 đã bắt: R2 nói 2 row seed cứng `ContractType.HopDongThauPhu`; đây là ca **file nguồn sai đuôi + không tồn tại**.)*

### 2.2 — Wave số-hoá 12 ĐIỀU: không tồn tại (F-05)

W0 chỉ "hỏi owner". Không có nhánh dự phòng cho đáp án *"12 điều nằm ở file anh chưa gửi"* hoặc *"chưa có, tự soạn"*. Khối lượng ước theo số đo: `002.04` dùng **40 đoạn không rỗng cho 5 điều**; 12 điều còn lại (gồm ĐIỀU 8 Tạm ứng-thanh toán và ĐIỀU 10 Phạt — 2 điều dài nhất) ⇒ khối chữ **≳2-3×** cái đang có. W2 chỉ dành cho nó **1 gạch đầu dòng** ("điều khoản chung") trong danh sách 8 mục.

### 2.3 — Wave "sản xuất bản chữ chuẩn": không tồn tại (F-05b, nối §1.3)

§④.5 **CẤM** chép text `antiword` vào code; `<w:t>` thì mất trắng số hiệu điều (auto-number). Vậy chữ đưa vào HTML lấy từ đâu, ai kiểm? Không wave nào nhận, không acceptance nào đo. Đây là đầu vào của W2 — thiếu nó thì W2 không có gì để gõ.

## 3. Phụ thuộc

Vẽ lại từ chính nội dung checklist (không theo câu tuyên bố ở W0):

```
W0 (owner) ──┬──► W3 (prefill)  ──► W2 (document-view)  ──► W4 (in)  ──► W6
             └──► W5 (khoá)     ──────────────────────────────────────► W6
W1 (tách loại) ─────────────────► W2 (acceptance) ─────────────────────► W6
[wave THIẾU: convert+nạp template] ──► W4 (nếu đường in dùng docx)
```

- **Spec sai ở câu "W1/W2 chạy song song được".** W2 phải "gỡ `draftData: null` 4/4 site" và ghi `DraftData`; W3 cũng ghi `DraftData` (vá regression `pe.PaymentTerms`) — R2/R3 đã bắt vế tranh-cột. Vế **tôi bổ sung**: acceptance của W2 (*"nhập → Lưu → F5 → đủ ô còn nguyên"*) **không có đường chạy** nếu 2 loại HĐ mới chưa có route/menu — người kiểm không biết mở màn nào. ⇒ W1 là tiền-đề **của acceptance** W2, dù không phải tiền-đề của việc gõ component.
- ⇒ **không cặp wave nào song song thật.** Chuỗi đúng: `W1 → W3 → W2 → W4 → W6`, nhánh `W5` rẽ sau W0.

## 4. Khoá chết vế 4

### 4.1 — 🔴 Tiền đề của spec SAI: người soạn CÓ thấy trang bản cứng (F-01)

`ContractFeatures.cs:347-354` là chuỗi **OR 4 vế**, vế **ĐẦU TIÊN** là `x.c.DrafterUserId == userId`:

```csharp
q = q.Where(x => x.c.DrafterUserId == userId          // ← người soạn, KHÔNG lệ thuộc phase
    || eligiblePhases.Contains(x.c.Phase)
    || (x.c.Phase != DangSoanThao && myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
    || (isHardCopyActor && x.c.Phase == ContractPhase.DaPhatHanh));   // ← 4 role, vế THỨ TƯ
```

`HardCopyActorRoles` (`:427-428`, 4 role) là vế **nới thêm** cho người *không* có chân nào, **không phải bộ lọc thu hẹp**. Thêm nữa: `fe-user/src/App.tsx` có **`PermissionGuard` = 0 lần** (`grep -c`), route `/hard-copies` (`:69`) trần ⇒ mọi user đăng nhập mở được trang.

⇒ Gạch đầu dòng W5 *"Nới cửa GĐ4 nếu owner muốn người soạn thấy"* là **việc trên tiền đề sai** — cửa đã mở sẵn cho drafter.

### 4.2 — 🔴 Khoá chết THẬT nằm chỗ khác: upload không có cửa nào (F-02)

| Bề mặt | Đo | Kết quả |
|---|---|---|
| `POST /contracts/{id}/attachments` | `ContractsController.cs:105-121` | **0 `[Authorize(Policy=…)]`** — chỉ `[Authorize]` trần ở class `:13` |
| Handler | `ContractAttachmentFeatures.cs:62-84` | **0 phase-guard · 0 kiểm người soạn/vai · 0 kiểm `Purpose`** — chỉ `FirstOrDefault` HĐ rồi ghi |
| `DELETE …/attachments/{attId}` | `ContractsController.cs:130` + `:143 .Remove(att)` | **0 policy · HARD delete** |

⇒ nếu W5 chọn **(0-mig) khoá theo `HasSealedCopy`** thì **bất kỳ user đăng nhập nào** biết `contractId` cũng: (a) upload `purpose=SealedCopy` ⇒ **khoá HĐ của người khác**; (b) `DELETE` ⇒ **mở khoá lại**. Owner nói "khoá" = mốc 1 chiều; máy hiện cho 2 chiều **và cho người lạ cầm công tắc**. Vế 4 bắt buộc kèm authz — spec **0 dòng** về chuyện này.

### 4.3 — 🔴 Acceptance W5 là 0-bit (F-03)

W5 đo: *"upload bản cứng → sửa HĐ ⇒ 409"*. Nhưng `ContractFeatures.cs:167-168` đã ném 409 cho **mọi** phase ≠ `DangSoanThao`, còn trang bản cứng chỉ nạp phase **9** (`HardCopiesPage.tsx:81`) ⇒ **409 xảy ra dù W5 không viết dòng nào**. Phép đo XANH cả khi tính năng vắng mặt. Thước đúng phải nhắm bề mặt **còn mở** ở phase 9 = tập đính kèm (xoá `SealedCopy` sau khi seal) — đúng cái §②.5 tự nhận là "cờ 2 chiều".

### 4.4 — Ba nghi ngờ tôi ĐÃ ĐO và bác bỏ (khai để lượt sau khỏi đo lại)

| Nghi | Đo | Kết luận |
|---|---|---|
| HĐ không bao giờ tới phase 9 | `ContractWorkflowService.cs:576 FinalizeToDaPhatHanhAsync` + `:241` + `ContractPhase.cs` comment | **Tới được** — cấp cuối duyệt ⇒ `DaPhatHanh` (terminal) |
| 2 quy trình `HD-*` mới sẽ không seed trên prod đã có data | `DbInitializer.cs:789 if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue` + `:801-802 IsActive/IsUserSelectable = true` | **Idempotent PER-CODE ⇒ +2 member là seed thêm 2**, không chặn |
| In sớm thì chưa có số HĐ | `ContractFeatures.cs:122` · `CreateContractFromSigningPlanFeatures.cs:243` | **Mã sinh ngay lúc TẠO** ở cả 2 đường ⇒ không chặn |

## 5. Rủi ro đánh giá thấp

- **④.2 "prod có HĐ Thầu phụ sống không" — xếp nhầm hạng: đây là TIỀN-ĐỀ của W1, không phải "chưa đo" chung chung** (F-07). Row `Type=1` đang sống sẽ **không thuộc** 2 menu mới sau khi tách ⇒ phải quyết "giữ nguyên hay backfill" TRƯỚC khi seed menu, không phải sau. Rẻ: 1 câu `sqlcmd`, **không cần owner**.
- **④ BỎ SÓT hẳn: số hiệu ĐIỀU là auto-number, vô hình với mọi phép trích chữ** (F-08) — chứng ở §1.3. Ảnh hưởng cả W2 (gõ tay) lẫn nhánh `DocxRenderer` (§②.5).
- **④ BỎ SÓT: authz đính kèm** (F-02) — không mục nào trong 6 mục nói tới, mà nó là bản lề của vế 4.
- **④.5 (antiword cắt UTF-8) xếp ĐÚNG hạng nhưng thiếu một vế:** lượt này đo được thêm — **thiếu `-m UTF-8.txt` thì output KHÔNG phải UTF-8 và `U+FFFD` = 0** ⇒ "trông như sạch" theo đúng nghĩa đen, mọi census chạy trên nó ra 0-hit im lặng.

## 6. Câu hỏi owner

| Câu | Chấm | Lý do (đo được) |
|---|---|---|
| (97) 12 điều thiếu | **ĐÚNG, giữ** | §1 xác nhận 0-hit toàn bộ 11 file có control-dương ⇒ chỉ owner mới có nguồn |
| (98) in bằng gì | **THỪA / tự mâu thuẫn** (F-09) | §③ W4 **đã chốt sẵn** "Print CSS `@media print`", §②.5 đã đo `soffice` ghost-wire ⇒ lead tự quyết rồi mới hỏi. Nếu vẫn hỏi thì hỏi cái owner mới trả lời được: *"in giấy A4 ký tay, hay xuất PDF gửi NCC ký số?"* |
| (99) 0-mig hay 1-mig | **SAI KHUÔN** (F-10) | Hỏi owner chọn *cách cài đặt* — owner không có dữ kiện để chọn. Khuôn đúng: *"sau khi upload bản cứng, có được XOÁ / THAY file đó không, và ai được làm?"* — trả lời đó mới suy ra 0-mig hay 1-mig |
| (100) VAT | (R3 đã chấm — không lặp) | |
| (101) đánh số menu | Được | |
| **THIẾU** | 🔴 **QUÊN HỎI: "ai được bấm upload bản cứng?"** (F-11) | Owner mô tả *"upload → khóa"* nhưng chưa ai hỏi **actor**. F-02 chứng đây không phải chi tiết nhỏ: hiện tại là **bất kỳ ai đăng nhập**. Câu này quyết cả authz (W5) lẫn việc có nới cửa GĐ4 hay không — mà lại **không nằm trong 5 câu đã gửi** |

## 7. VERDICT + finding

**`R4-PLAN: PASS-WITH-FINDINGS — 12 finding (6 nặng)`**
Kế hoạch đúng trục và §②.3 đứng vững khi đo lại độc lập; nhưng **vế 4 dựa trên một tiền đề sai**, acceptance của nó **0-bit**, và **3 việc bắt buộc không wave nào nhận**.

| # | Mức | Neo | Nội dung |
|---|---|---|---|
| F-01 | NẶNG | `ContractFeatures.cs:347` · `fe-user/src/App.tsx:69` (`PermissionGuard`=0) | Spec §②.5 nói người soạn không thấy trang bản cứng — SAI: `DrafterUserId == userId` là vế OR **đầu tiên**; 4 role là vế **nới thêm**. W5 "nới cửa GĐ4" = việc trên tiền đề sai |
| F-02 | NẶNG | `ContractsController.cs:105`, `:130` · `ContractAttachmentFeatures.cs:62-84`, `:143` | Upload/xoá đính kèm **0 policy · 0 phase-guard · 0 ownership**. Phương án 0-mig của W5 ⇒ bất kỳ ai cũng khoá/mở khoá HĐ người khác |
| F-03 | NẶNG | spec `③ W5` vs `ContractFeatures.cs:167-168` | Acceptance "upload → sửa HĐ ⇒ 409" **PASS sẵn** trước khi viết code (phase 9 đã khoá sửa) ⇒ 0-bit |
| F-04 | NẶNG | `DbInitializer.cs:3833-3838`, `:3861` · `ls FORM/` | 2 template trên đĩa là **`.doc`**, seed khai **`.docx`**, `wwwroot/templates` **không có** ⇒ cần CONVERT + chép + `UPDATE IsActive=1`: **3 việc, 0 wave** |
| F-05 | NẶNG | spec `③ W0/W2` | 12 điều thiếu (đã xác nhận §1) **không có wave số-hoá**, cũng không có nhánh dự phòng; và **không wave nào sản xuất bản chữ chuẩn** dù §④.5 cấm dùng text antiword |
| F-06 | NẶNG | spec `③ W0` | "W1/W2 song song" sai: acceptance W2 không có đường chạy khi chưa có 2 loại (W1); cộng vế tranh `DraftData` R2/R3 ⇒ **không cặp wave nào song song thật** |
| F-07 | vừa | spec `④.2` | "Prod có HĐ Thầu phụ sống không" là **tiền-đề của W1** (row `Type=1` không thuộc 2 menu mới), đo bằng 1 query — không phải rủi ro "chưa đo" |
| F-08 | vừa | `002.02 L0130` · `002.05 numPr=168/Heading=102` vs `002.04 Heading=0` | §④ bỏ sót: số hiệu ĐIỀU ở thân là **auto-number Word**, vô hình với mọi phép trích `<w:t>`/antiword |
| F-09 | vừa | spec `③ W4` vs câu (98) | Câu owner (98) **thừa** — W4 đã chốt Print CSS trước khi hỏi |
| F-10 | vừa | câu (99) | **Sai khuôn** — hỏi owner chọn cách cài đặt; khuôn đúng hỏi *"có được xoá/thay file bản cứng không, ai được làm"* |
| F-11 | vừa | F-02 | **Quên hỏi**: *"ai được bấm upload bản cứng?"* — không có trong 5 câu, mà nó quyết authz + cửa GĐ4 |
| F-12 | nhẹ | spec giao việc | Xung đột đường dẫn: khối TASK ghi `sub-reviewer-4.md`, footer RUN-TRACE ghi `sub-reviewer-3.md` |

**Thước hỏng đã tự bắt:** `antiword` thiếu `-m UTF-8.txt` ⇒ control-dương `'hợp đồng'` = **0** trên cả 3 file `.doc` (sau khi vá: 56). Mọi số `.doc` ở §1 đều đo lại sau khi vá thước.
