# sub-investigator-codebase-4 — LENS: BẢN CỨNG + KHOÁ + IN (YC-032)

> Run: 2026-08-13-S194-yc032-hd-thauphu-2form · Agent: investigator-codebase (lane 4)
> Vùng pin: `src/Backend/SolutionErp.Domain/ContractSigningPlans/**` + `src/Backend/SolutionErp.Application/ContractSigningPlans/**`
> Trạng thái: ĐANG ĐỔ RUỘT (append liên tục, chống #53). Mọi số = từ lệnh vừa chạy.

## 0. Khung

VERDICT sớm: **bản-cứng + khoá = 0 migration (đã có sẵn trên Contract)**; **đường IN = có máy nhưng ĐỨT ở 3 chỗ**.
- Cơ chế "bản cứng 1-MỐC" KHÔNG nằm ở KHKK — nó land thẳng trên **Contract** (`AttachmentPurpose.SealedCopy = 3`).
- KHÔNG có khoá nào bật khi upload bản cứng (đo ở §B).
- Repo CÓ form-engine thật (OpenXml + ClosedXML + LibreOffice) nhưng **0 đường nối Contract → template data**.

## A. Bản-cứng 1-MỐC của KHKK

**Kết luận: "1-MỐC" của W7 hiện thực trên CONTRACT, không phải trên KHKK. KHKK 0 field bản-cứng.**

| Vế | Đo được | Evidence |
|---|---|---|
| Entity/field | KHÔNG có cột mới. Mốc = **tồn tại ≥1 attachment purpose 3** | `Domain/Contracts/ContractAttachment.cs:9` `SealedCopy = 3, // Scan HĐ đã đóng dấu ở phase DangDongDau` |
| Cờ đọc ra | `HasSealedCopy` DERIVED, append CUỐI record + có default | `Application/Contracts/Dtos/ContractDtos.cs:18-25` (`bool HasSealedCopy = false`) |
| Nơi tính | EXISTS trong LIST query | `Application/Contracts/ContractFeatures.cs:384-391` — `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` |
| Endpoint upload | tái dùng upload đính kèm HĐ sẵn có | `Api/Controllers/ContractsController.cs:105` `[HttpPost("{id:guid}/attachments")]` |
| Menu GĐ4 | 7 leaf `Hdc_*` dưới root `HopDongCung` | `Infrastructure/Persistence/DbInitializer.cs:2197`, nhãn `:2375-2381` ("01. HĐ Thầu phụ - Bảng cứng") |
| FE trang | có ×2 app | `fe-admin/src/pages/contracts/HardCopiesPage.tsx:204` + `fe-user/...:204` (`c.hasSealedCopy ? …`), panel upload `:259 HardCopyPanel` |
| Test | 3 test PIN tên theo spec | `tests/…/Application/ContractHardCopyTests.cs:22` (T3 `HardCopyBadge_DerivesFromSealedCopyAttachment`), `:316`, `:344` |

**KHKK 0-hit bản-cứng/khoá** — `grep -n "SealedCopy|Sealed|Khoa|Lock|readonly|khoá|khóa"` trên `Domain/ContractSigningPlans/` ⇒ **No matches found**. Control dương cùng thước: cùng regex chạy trên `src/Backend` ⇒ 13 hit (`ContractAttachment.cs:9`, `ContractDtos.cs:18-25`, `ContractFeatures.cs:384-391`, `DbInitializer.cs:2197/2375-2381/3658`) ⇒ thước sống, vắng ở KHKK là **vắng thật**.

**Trạng thái sau upload = KHÔNG ĐỔI GÌ. KHÔNG KHOÁ.**
- `UploadContractAttachmentCommandHandler` chỉ check contract tồn tại rồi Add + log changelog — **0 guard phase, 0 set cờ, 0 đổi Phase**: `Application/Contracts/ContractAttachmentFeatures.cs:64` (`?? throw new NotFoundException`), `:80` (`Purpose = request.Purpose`), `:84` (log), `:89` (return DTO). Không có dòng nào đụng `contract.Phase`.
- Xoá đính kèm = **HARD delete, không soft** (`ContractAttachmentFeatures.cs:139-144`; `ContractAttachment : BaseEntity` không có `IsDeleted` — `ContractAttachment.cs:13`) ⇒ **cờ `hasSealedCopy` ĐẢO NGƯỢC ĐƯỢC**: xoá file là badge tắt, HĐ "mở khoá" lại. Mốc 1-chiều mà owner mô tả ("upload = xong") hiện là **cờ 2-chiều**.

`ContractSigningPlanAttachment` (KHKK) có 3 purpose: `DossierScan=1 / PlanDocument=2 / ApprovalAttachment=3 / Other=99` — `Domain/ContractSigningPlans/ContractSigningPlanAttachment.cs:9-15`. **Không** có purpose "bản cứng đã ký".

**Cửa vào GĐ4 hẹp hơn tưởng — 2 rào:**
1. Trang bản cứng CHỈ nạp HĐ **phase 9 DaPhatHanh**: `fe-admin/src/pages/contracts/HardCopiesPage.tsx:80-81` (`phase: ContractPhase.DaPhatHanh`) + test T1 `ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase`. ⇒ HĐ vừa nhập xong ở document-view (phase 2) **không xuất hiện** ở GĐ4 cho tới khi chạy hết 9 phase.
2. Vai được thấy = danh sách ĐÓNG 4 role: `ContractFeatures.cs:427-428` `HardCopyActorRoles = [Director, AuthorizedSigner, HrAdmin, CostControl]` (test `:518` khai rõ "danh sách ĐÓNG"). Người soạn HĐ (Drafter) **không** nằm trong đó.

**ĐO DỮ LIỆU (chống 'cơ-chế đúng, dữ-liệu không có'):** LocalDB `SolutionErp_Dev` ⇒ `SELECT COUNT(*) FROM ContractAttachments` = **0** (tổng, mọi purpose) ⇒ chưa từng có bản cứng nào được upload ở Dev; `SUM(Purpose=3)` trả **NULL** (tập rỗng, không phải 0-do-lọc). Máy đủ, đường đi chưa ai bước.

## B. Mirror sang HĐ + migration

**Không cần mirror — đã ở sẵn trên HĐ. 0 cột mới, 0 migration cho vế "lưu bản cứng".**
Chỉ cần thêm nếu owner muốn **KHOÁ THẬT** (xem 3 lựa chọn dưới).

Khoá hiện có trên Contract (phase-based, KHÔNG liên quan bản cứng):
- `Application/Contracts/ContractFeatures.cs:167-168` — `if (entity.Phase != ContractPhase.DangSoanThao) throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.")` ⇒ HĐ rời Nháp là header đã cấm sửa. Với YC-032, **document-view nhập liệu chỉ mở ở DangSoanThao** là hành vi sẵn có, không phải dựng mới.
- Xoá HĐ chặn từ `DangInKy` trở lên (memory S160 `delete-guard-numeric`, `Phase >=`).

3 đường cho "KHOÁ sau upload bản cứng", đắt dần:
1. **0-mig, 0 cột** — dựa `HasSealedCopy` đã derive: thêm guard `if (contract.Attachments.Any(a => a.Purpose == SealedCopy)) throw Conflict` vào `UpdateContractCommand` + `DeleteAttachment` (chặn gỡ chính bản cứng). Điểm yếu: cờ vẫn 2-chiều, ai xoá attachment là mở lại; và EXISTS phải load thêm.
2. **1 cột, 1 mig** — `Contracts.SealedAt datetime2?` (+ optional `SealedByUserId`), set 1 lần ở upload purpose=3, guard `SealedAt != null`. Mốc thành **1 chiều thật**, audit được ai/khi nào, không phụ thuộc file còn/mất. Đây là đường khớp chữ owner nhất ("upload bản cứng = xong").
3. **1 cột + 1 phase** — extend enum `ContractPhase`. 🔴 **KHÔNG khuyến nghị**: owner đã chốt 1-MỐC = **0 enum-extend** (skill `contract-workflow` §KHKK GĐ4); và `DeleteContract` guard so SỐ `Phase >=` (S160) ⇒ chèn số mới là đụng guard số học.

⇒ Nếu lead cần "khoá thật + audit": **1 migration, 1-2 cột** (`SealedAt`, `SealedByUserId`). Nếu chấp nhận khoá mềm: **0 migration**.

## C. Đường IN

**Có máy render, nhưng ĐỨT 3 chỗ. Không có đường "in HĐ này" hôm nay.**

CÓ sẵn (không phải dựng mới):
- `Infrastructure/Forms/DocxRenderer.cs:12-51` — OpenXml 3.5.1, thay placeholder `{{fieldName}}` (regex `:14`), phủ cả header/footer (`:39-42`).
- `Infrastructure/Forms/XlsxRenderer.cs` (ClosedXML 0.105) · `Infrastructure/SolutionErp.Infrastructure.csproj:8-9` (2 package đã cài).
- `Infrastructure/Forms/LibreOfficePdfConverter.cs:11-87` — `LibreOfficeDocumentConverter` shell `soffice --headless --convert-to`; DI singleton `Infrastructure/DependencyInjection.cs:36`.
- Endpoint: `Api/Controllers/FormsController.cs:25` `POST templates/{id}/render` · `:32` `POST templates/{id}/export-pdf` (→ `FormFeatures.cs:103-125`, `converter.ConvertAsync(rendered.Content, tpl.Format, "pdf")`).
- Upload template CÒN tự convert `.doc → .docx`: `FormFeatures.cs:175-240` (`converter.ConvertAsync(srcBytes, srcExt, targetExt)`).

3 chỗ ĐỨT (mỗi cái là 1 wave):
1. 🔴 **Không ai nạp dữ liệu HĐ vào render.** `grep -rn "RenderTemplateCommand(|ExportTemplatePdfCommand("` trên `src/Backend` ⇒ **4 hit, đều là khai báo + FormsController**: `FormsController.cs:28`, `:35`, `FormFeatures.cs:59`, `:103`. **0 hit từ `Contracts/`**. Data dictionary hiện do người dùng gõ tay ở `fe-admin/src/pages/forms/FormsPage.tsx:55` (`DynamicFormValues`) rồi POST thẳng (`:65`). ⇒ Muốn in HĐ phải viết **map Contract → Dictionary<string,string?>** mới.
2. 🔴 **2 file form của YC-032 CHƯA có trên đĩa.** Seed `DbInitializer.cs:3833-3838` ĐÃ khai `SOL-CCM-FO-002.02` + `002.03` → `ContractType.HopDongThauPhu`, nhưng `IsActive = fileExists` (`:3865-3876`) và `ls src/Backend/SolutionErp.Api/wwwroot/templates` ⇒ **5 file** (002.01, 002.04, 002.05, 002.07 xlsx, RG-001) — **002.02/002.03/002.06 VẮNG**. Comment tự khai `:3826-3827`: *"3 file .doc (FO-002.02/03/06) chưa convert sẽ IsActive=false"*. ⇒ 2 row template tồn tại nhưng **chết cờ**, đúng lớp "cơ-chế đúng, dữ-liệu không có".
3. 🔴 **DocxRenderer không lặp bảng.** `DocxRenderer.cs:9-11` tự khai: *"Không support `{{#loop}}…{{/loop}}` (table lặp) — Phase 2 iteration 2"*. Mà thân 2 form có bảng **DANH MỤC HỢP ĐỒNG** N dòng (lead đã đọc bằng antiword) ⇒ render docx hiện **không đổ được N dòng hạng mục**.

Rủi ro thứ 4 (ghost-wire): `Pdf:SofficePath` **0-hit trong `appsettings*.json`** (control dương: `appsettings.json` 37 dòng, không rỗng) ⇒ chạy fallback hard-code `C:\Program Files\LibreOffice\program\soffice.exe` (`LibreOfficePdfConverter.cs:15-18`). Lead đã đo: máy dev **không có soffice ở đường đó**. Prod chưa đo (§E).

**Print CSS = 0.** `grep -rn "window.print|@media print|print:"` trên `fe-admin/src` + `fe-user/src` (`*.tsx`,`*.css`) ⇒ **0 hit**. Control dương cùng cây: 56 file fe-admin có `useState` ⇒ cây có được quét.

**Đánh đổi (dựa trên cái repo ĐÃ có):**

| | (a) Print CSS trang document-view | (b) Render .docx từ template |
|---|---|---|
| Vốn sẵn | 0 (phải dựng từ đầu, nhưng chỉ là CSS) | ~70% (renderer + converter + endpoint + seed row đã có) |
| Chặn cứng | không | cần đủ CẢ 3: file .docx thật + map data + loop bảng |
| Khớp YC-032(2) | **Rất khớp** — document-view CHÍNH LÀ thứ đem in; WYSIWYG tuyệt đối, người nhập thấy sao in vậy | phải giữ 2 bản trình bày (HTML + docx) đồng bộ → drift |
| Font/dấu tiếng Việt | trình duyệt lo | phụ thuộc font trên máy chủ IIS/LibreOffice |
| Ra PDF | Ctrl+P → "Save as PDF" (client), 0 hạ tầng | cần soffice sống trên prod (chưa chứng) |
| Nợ về sau | muốn file .docx sửa tay thì vẫn phải làm (b) | đã có file để lưu trữ/ký |

⇒ Cho phạm-vi-lượt-này (2 HĐ, "chỉ cần in ra trình ký"): **(a) print CSS** là đường ngắn nhất và khớp lời owner nhất; (b) để wave sau, và khi làm thì việc TỐN NHẤT không phải renderer mà là **3 chỗ đứt trên**.

## D. Dossier tái dụng

**Dossier KHÔNG phải kho file — nó là CHECKLIST. Cái tái dụng được là bộ endpoint attachment của KHKK, không phải dossier.**

- `Domain/ContractSigningPlans/ContractSigningPlanDossierItem.cs:27-40` — `Kind` (MauVatLieu/Shopdrawing/Khac `:7-12`), `Status` (ChuaNop/DaNop/TvgsDuyet/TvgsBac `:17-23`), `TvgsName`, `TvgsResultAt`, `Note`. **0 cột file** (không FileName/StoragePath/ContentType). CRUD chỉ mở ở `{DangSoanThao, TraLai}` (comment `:26`).
- File nối vào dossier bằng **loose-Guid trên bảng attachment**: `ContractSigningPlanAttachment.ContractSigningPlanDossierItemId` (`:28`), CỐ Ý không FK vật lý vì **multiple cascade paths** (`:19-24`).
- Bộ endpoint attachment KHKK (đây mới là thứ mirror được): `ContractSigningPlansController.cs:198` upload · `:226` upload-approval (BE **ép cứng** Purpose=ApprovalAttachment, `:241`) · `:245` download · `:254` **view** (inline) · `:268` delete.
- 🔴 **HĐ hiện THIẾU cửa `view` inline** — `ContractsController.cs` chỉ có `:105` upload + `:130` delete (download nằm ở chỗ khác/không cùng khuôn). Muốn xem bản cứng ngay trên trang mà không tải về ⇒ mirror `:254` của KHKK.
- Authz: KHKK per-action thật (`Read/Create/Update/Delete` — `ContractSigningPlansController.cs:29,37,100,109,154,…`, ≥23 dòng `[Authorize(Policy = "KeHoachKyKet.*"]`). Contract thì **class `[Authorize]` trần** + chỉ `:32` `Contracts.Read` cho 1 action; `:87` có comment tự khai *"CỐ Ý CHƯA gắn policy per-action cho verb này"* ⇒ nếu mirror upload bản cứng sang HĐ thì **không thừa hưởng authz per-action** (gotcha #82/#44 còn treo — W8 owner HOÃN).

## E. Chưa đo được

- **soffice trên PROD**: chưa đo (SSH + đo file trên VPS ngoài lượt này; memory S134/S148: SSH session chết khi SQL-client load — nhưng chỉ với sqlcmd, `Test-Path` thì chưa thử). ⇒ đừng hứa export PDF server-side trước khi đo.
- **Prod có row ContractTemplate 002.02/003 chưa** (seed idempotent theo FormCode; nếu prod seed từ lâu thì row đã có với `IsActive=0`, và **seed sẽ KHÔNG tự bật lại khi ta thả file .docx vào** — `:3861 if (existingCodes.Contains(formCode)) continue`). ⇒ Wave nào thả file phải kèm **UPDATE IsActive=1** (hoặc sửa qua `PUT /forms/templates/{id}`), nếu không file có mà cờ vẫn tắt.
- Chất lượng convert `.doc → .docx` của LibreOffice với 2 file 516KB/591KB (bảng phức tạp): chưa chạy thử.
- `FieldSpec` JSON của template (`ContractTemplate.cs:16`) — chưa đọc file nào có FieldSpec thật; chưa rõ có bộ field chuẩn nào để tái dụng.
