# sub-investigator-codebase-2 — LANE-2 CODEBASE DRY-RUN MAP (S160, /fable-real)

> Vai: investigator-codebase · engine-đắt REAL · ghi-đĩa-trong-lúc-làm (chống #53).
> Trục: CODE THẬT trên repo. Lane-1 (song song) lo nghiệp vụ từ QT/FORM.
> Spec KHKK cũ (GĐ2 b.7→12, W1-W5, 7 bảng, Mig 69, type=10) = RATIFIED — chỉ BỔ SUNG GĐ3+GĐ4.
> Ranh giai đoạn (owner chốt @S159, spec §1.2): GĐ2=b.7→12 · GĐ3=b.13→18 (tận dụng Contract V2) · GĐ4=b.19→21 + ký cứng upload-only.

## §0 — Nguồn đã đọc + tiền đề

- `run.md` S160 (lệnh anh verbatim + ràng buộc 5-anchor + authz S156 + gotcha #84 no-SQL-tay).
- Spec S157 497 dòng ĐỌC TRỌN (①②③④⑤): 7 bảng `ContractSigningPlan*`, Mig 69, `ApplicableType=10`, W1-W5, NOTE W1 @S160 (Khkk_* skeleton ĐÃ prod, dark-launch ĐẢO — menu public, ComingSoon→trang thật mới là "bật").
- `src-QT-TRINH-KY-HD.txt`: bảng 2 = 9 bước ISO (Lựa chọn→Soạn thảo→Góp ý→Đàm phán→In HĐ→CCM→BOD→Đóng dấu→Phát hành/Lưu trữ) ↔ `ContractPhase` 1-9 khớp 1:1 (xác nhận S157 lens-fidelity).
- `src-RG-001-v02-ma-so-hd.txt` (2.907B): format `A/B/SOL&C/D` + 6 ví dụ HĐ + 2 dạng PO (`X/Y/PO Z` theo nguyên tắc · `A/PO/SOL&C/D` không nguyên tắc).

(mục chi tiết append dần bên dưới — mỗi §=1 câu hỏi)

---

## §1 — Q1: MAP TÁI DÙNG b.13→21 → Contract V2 (mọi dòng RE-VERIFY trên đĩa 07-29, KHÔNG chép mù S156 — line đã TRÔI vì arc-2 thêm `ListDeleted`)

**Khung đọc:** GĐ3 = b.13→18 (owner @S159) chạy trên state machine 5-trạng-thái `ContractPhase.cs:14-28` (sống: DangSoanThao=2 · ChoDuyet=10 · DaPhatHanh=9 · TraLai=98 · TuChoi=99; 7 phase `[LEGACY]` = chính các trạm 13-21 đã gỡ CỐ Ý Mig 21). GĐ4 = b.19→21. Duyệt V2 = `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`) cookie-cutter PE: OR-of-N `:259-260` · UPSERT `ContractLevelOpinions` `:292-316` · skipToFinal `:320-352` · terminal gen-mã+DaPhatHanh `:366-385`.

| b | Việc (theo QT/PDF) | Phase/feature hiện có | Trạng thái | Chứng (file:line, đo 07-29) |
|---|---|---|---|---|
| 13 | PMH soạn draft HĐ từ giá chốt KHKK | `DangSoanThao`; 2 đường tạo: bridge PE→HĐ + create thường (create thường ĐÃ nhận pin V2 `ContractFeatures.cs:109`; gen `MaHopDong` NGAY tại create `:113-118` — RG-001 có mã từ nháp, KHÔNG đợi terminal) | **PARTIAL** | Bridge pin V1-only `CreateContractFromEvaluationFeatures.cs:68-71,108` + KHÔNG đọc giá KHKK (`giaTri=SUM Quote.IsSelected` `:88-90`) → chi tiết §2 |
| 14 | BCH bổ sung/hoàn thiện draft | `UpdateContractDraftCommand` guard duy nhất `Phase==DangSoanThao` (`ContractFeatures.cs:156-157`), KHÔNG check drafter ⇒ BCH SỬA được về cơ chế | **PARTIAL** | Nhưng BCH KHÔNG XEM được: view-guard `GetEligiblePhases` (`:334-351`) map `DangSoanThao` chỉ cho `Drafter/DeptManager` (`:342-343`); role ProjectManager chỉ được `DangGopY` legacy. Command cũng KHÔNG có `ApprovalWorkflowId` (`:136-145`) ⇒ 0 đường re-pin V2 sau tạo |
| 15 | Đàm phán với TP (ngoài hệ thống) | Không phase riêng (DangDamPhan=LEGACY đã gỡ) — ghi vết = comment + attachment | **SẴN** (triết lý Mig 21: trạm offline = chứng từ đính kèm) | `ContractPhase.cs:19` LEGACY; attachment upload KHÔNG phase-guard (`ContractAttachmentFeatures.cs` 0 hit guard `Phase` — chỉ changelog `:84,:144`) |
| 16 | TP ký nháy → BCH chuyển PMH (scan) | Upload attachment mọi phase | **GAP nhỏ** | `AttachmentPurpose` (`ContractAttachment.cs:5-11`) = DraftExport/ScannedSigned/SealedCopy/Other — KHÔNG có "ký nháy" (InitialSigned). Enum-extend int-column 0-migration (tiền lệ PE `ComparisonTable=4`) |
| 17 | PMH trình ký; CCM kiểm tra + ký nháy | Trình `DangSoanThao→ChoDuyet` (`ContractWorkflowService.cs:70-88`, init con-trỏ đôi `:81-84`); CCM = Step 1 của workflow V2 `ApplicableType.Contract=3` — duyệt qua `ApproveV2Async` | **PARTIAL** | 🔴 3 lỗ: (i) guard trình đòi role `Drafter∨DeptManager` (`:73-78`) — PMH mang `Procurement` bị 403 (lớp "vai đi qua không có"); (ii) approver KHÔNG XEM được HĐ ChoDuyet — `GetEligiblePhases` 0 entry ChoDuyet; (iii) KHÔNG vào inbox — `PhaseActorRoles` (`:421-430`) 100% legacy, 0 entry ChoDuyet |
| 18 | CEO ký nếu >5 tỷ | Step CEO trong workflow V2 (Level đích danh Director) — chạy được nếu CEO ký MỌI HĐ | **GAP** (phần ngưỡng) | `CeoApprovalThreshold` = cột chung `ApprovalWorkflow.cs:43` nhưng grep `ContractWorkflowService.cs` = **0 hit** (đọc trọn 443 dòng 07-29) — admin set vào workflow Contract-type bị NGÓ LƠ im lặng. `AllowApproverFinalize` cũng PE-only. Đường có sẵn 0-code: 2 workflow (có/không Step CEO) + user chọn lúc tạo |
| — | (kết GĐ3) | Duyệt cấp cuối → `DaPhatHanh` terminal + gen mã nếu chưa có (`:366-385`) | **SẴN** | Terminal của V2 = "Đã phát hành" — nghĩa là b.19-21 GĐ4 xảy ra SAU terminal về mặt hệ thống |
| 19 | HR đóng dấu | 2 lựa chọn: (a) HR = Step cuối workflow V2 (SẴN, chỉ cấu hình — terminal dời về sau HR); (b) tracking riêng trên vật thể "bản cứng" GĐ4 | **SẴN-config / GAP-nếu-tracking-riêng** | `DangDongDau=8` LEGACY (`ContractPhase.cs:23`) — KHÔNG hồi sinh. §3 đề xuất |
| 20 | CCM scan bản cứng, lưu trữ | `AttachmentPurpose.SealedCopy=3` upload ở DaPhatHanh | **SẴN** | `ContractAttachment.cs:9`; upload không phase-guard ⇒ up được sau terminal |
| 21 | Chuyển phát nhanh bản gốc về TP + lưu | Không field nào (mã vận đơn/số bản gốc/vị trí lưu) | **GAP** (nhỏ — comment đủ nếu owner không đòi field) | `Contract.cs:8-70` đọc trọn: 0 field bản-cứng (không seal-date, không courier, không số-bản-gốc, không vị-trí-lưu-file-cứng) |

**Kết Q1:** cơ chế duyệt (17-18) + terminal + attachment (15,16,20) = TÁI DÙNG được ngay sau khi vá 3 lỗ "đường ống" (view/inbox/vai-trình) — đúng verdict LAI S156, hôm nay re-verify còn nguyên. GĐ4 (19-21) KHÔNG có vật mang trong Contract entity — cần quyết định §3 (field-on-Contract vs entity mới).

**Đếm endpoint (đo 07-29):** `ContractsController.cs` = **30 endpoint** (8 đọc + **22 ghi**), class `[Authorize]` trần `:13`; ngoại lệ DUY NHẤT có policy: `GET /deleted` `[Authorize(Policy="Contracts.Read")]` `:28-29` (arc-2 S159) — khuôn per-action ĐÃ CÓ TIỀN LỆ ngay trong file này.

---

## §2 — Q2: BRIDGE KHKK chen giữa PE và Contract

**Hiện trạng bridge (đo 07-29):** `POST /api/purchase-evaluations/{id}/create-contract` (`PurchaseEvaluationsController.cs:339-346`, body `{ContractType, TenHopDong, Bypass}` `:379-382` — KHÔNG có `ApprovalWorkflowId`) → handler `CreateContractFromEvaluationFeatures.cs`: guard `Phase==DaDuyet` + `winners.Count>0` + `pe.ContractId==null` (`:53-60`); per-winner `giaTri = SUM Quote.IsSelected` (`:88-90`); pin **V1-only** `WorkflowDefinitionId=activeWfId` (`:68-71,:108`); gen mã atomic self-committing codegen (`:78-115`); idempotency `pe.ContractId = contracts[0].Id` (`:145`). Picker: `GET /purchase-evaluations/approved-pending-contract` (`:335-337`) → `ListApprovedPurchaseEvaluationsQuery` filter `Phase==DaDuyet && ContractId==null` (`CreateContractFromEvaluationFeatures.cs:173`).

**(a) KHKK kế thừa từ PE** (đã RATIFIED spec S157 §2.3 — chỉ tóm + anchor sống): header `PurchaseEvaluationId` loose-Guid + `ProjectId` denorm + `DepartmentId` (C5) + snapshot `HoSoLink`; **Lines auto-sinh từ winners** `pe.Suppliers.Where(IsWinner)` với `PeReferenceAmount = SUM Quote.IsSelected` per-winner **snapshot 1 lần** — copy đúng phép tính bridge cũ `:88-90` + guard `:53-60`. Khuôn nguyên con nằm sẵn trong file bridge.

**(b) Contract tạo từ KHKK kế thừa gì** (spec §2.5 RATIFIED, W5): (i) `giaTri = line.ApprovedAmount` khớp `w.SupplierId` **thay** `SUM :88-90` khi PE có plan `DaDuyet` + ContextNote lệch *"giá KH x vs SUM-PE y"* vào changelog (khuôn `:121-140`); (ii) **+param `ApprovalWorkflowId` pin V2** — thiếu nó HĐ từ phiếu rơi nhánh V1: nếu 0 active `WorkflowDefinition` cho type đó → **KẸT CỨNG** `ConflictException` `ContractWorkflowService.cs:115-116` (re-verify 07-29: đúng dòng `def == null || def.Steps.Count == 0`); (iii) phần còn lại giữ nguyên từ PE (TenHopDong/PaymentTerms→DraftData/BudgetManual* `:101-107`); (iv) 🆕 ghi ngược `Line.ContractId = contract.Id` per-winner (cột C6 spec đã có chỗ) — vòng lặp `:82-115` đã per-winner nên chỗ ghi tự nhiên.

**(c) Sửa bridge cũ hay thêm bridge mới → SỬA BRIDGE CŨ (1 file), KHÔNG đẻ bridge thứ 2** — đây là hướng spec W5 đã ratify; lý do kỹ thuật đứng vững hôm nay: entry-point PE giữ idempotency `pe.ContractId` (`:59-60,:145`) + FE nút tạo HĐ đã tồn tại màn PE; bridge mới từ KHKK = endpoint thứ 2 cùng nghĩa (2 chỗ sửa về sau — lớp lỗi 2-site S155). Bổ sung DUY NHẤT cho dry-run: màn KHKK detail (DaDuyet) thêm **nút deep-link** gọi CÙNG endpoint cũ — 0 endpoint mới, 0 handler mới. *(Phương án B ghi để review chốt: `POST /contract-signing-plans/{id}/create-contracts` riêng — chỉ đáng nếu owner muốn 1 KHKK chia nhiều đợt HĐ; hiện 0 yêu cầu.)*

**(d) Cardinality chuỗi PE↔KHKK↔Contract:**
| Cặp | Ràng buộc | Cơ chế enforce |
|---|---|---|
| PE → KHKK | 1 PE — **≤1 plan SỐNG** (1-N lịch sử, tạo lại được sau TuChoi) | app-level `AnyAsync(p.PeId==x && p.Phase!=TuChoi)` → 409 (spec §2.3; KHÔNG đặt được UNIQUE-filtered theo Phase động — đúng lựa chọn app-level) |
| PE → Contract | 1 PE — N HĐ (multi-winner Mig 58) | `pe.ContractId = contracts[0].Id` `:145` + guard `:59-60` "đã tạo HĐ rồi" |
| KHKK → Contract | 1 plan — N Lines (UNIQUE filtered `PlanId+SupplierId`) — mỗi Line ↔ ≤1 HĐ | `Line.ContractId Guid?` (C6) ghi tại bridge; truy ngược HĐ→plan qua Line |
| ⚠️ đường tắt | PE → Contract KHÔNG QUA KHKK vẫn sống (Q11 mặc định = cảnh báo mềm) | dry-run phải chấp nhận 2 đường song song cho tới khi owner nâng cứng Q11 |

**Pattern tái dùng cite được:** (1) pull-model picker `ListApprovedPurchaseEvaluationsQuery:173` — nhân bản đổi vị ngữ `&& chưa-có-plan-sống` cho màn "PE chờ lập KH" (W2 spec cũ); picker tạo-HĐ đổi nguồn thành plan `DaDuyet` (hoặc giữ PE + banner có/không plan — Q11). (2) Multi-entity create + self-committing codegen atomic `:78-82` comment + loop. (3) Auto-Lines từ winners `:56-62,:82-91`.

---

## §3 — Q3: GĐ4 `Hdc_*` trang thật cần gì

**🔴 SỬA GROUNDING đề bài (2 chỗ):**
1. **`Hdc_*` KHÔNG phải 6-leaf {WfView/List/Create/Pending/Approved/Deleted}** — đó là cấu trúc `Khkk_*` (`DbInitializer.cs:1779-1786`). `Hdc_*` thật = **7 leaf theo ContractType**, 1 leaf/loại, seed loop `DbInitializer.cs:1862-1867` (`tree.Add(("Hdc_"+code, label+" - Bảng cứng", MenuKeys.HopDongCung, ...))`), label "01. HĐ Thầu phụ - Bảng cứng"…"07." (`:1980-1986`), staticMap → `/coming-soon?stage=4&t=01..07` (`fe-user Layout.tsx:63-69`). Owner tự vẽ cấu trúc này @S159-đợt4 — KHÔNG mirror mù bộ 6-leaf.
2. **Enum KHÔNG có "KyCung"** — grep `KyCung` toàn `src` = **0 hit**. Phase 8 = `DangDongDau` `[LEGACY]` (`ContractPhase.cs:23`), 9 = `DaPhatHanh` terminal SỐNG (`:24`). "Bản cứng" chưa từng là phase.

**Đã có gì cho GĐ4 (đo):** `DaPhatHanh` = terminal V2 gen-mã (`ContractWorkflowService.cs:366-385`) · `AttachmentPurpose.ScannedSigned=2/SealedCopy=3` (`ContractAttachment.cs:8-9`) upload không phase-guard ⇒ up ĐƯỢC sau terminal · `Contract.cs` **0 field bản-cứng**.

**Đề xuất TỐI THIỂU cho dry-run (options — review chốt):**
- **(A) 0-migration DERIVE** ⬅ đề xuất chính: mỗi leaf `Hdc_<Code>` → trang list `GET /contracts?type=N&phase=9` (param `phase` CÓ SẴN `ContractsController.cs:20`, FE đã chứng minh dùng được: leaf `Ct_*_Approved` → `/my-contracts?type=N&phase=9` `Layout.tsx:141`); trạng thái bản cứng = DERIVE `attachments.Any(Purpose==SealedCopy)` → badge "Đã lưu bản cứng / Chưa"; hành động = upload `ScannedSigned`/`SealedCopy` + enum-extend `InitialSigned=4` ký-nháy (int column, 0-mig — tiền lệ PE `ComparisonTable=4`). List DTO hiện KHÔNG trả attachments (`ContractListItemDto` 12 field `ContractFeatures.cs:323-327`) ⇒ cần +1 cột derive `hasSealedCopy` trong query (sửa DTO, 0-mig).
- **(B) field mỏng trên Contract**: `HardCopyStatus int?` (+`HardCopyNote`) — 1 mig AddColumn (Mig 70+, bảng giữ 89) khi owner muốn trạng thái TƯỜNG MINH (in/ký/đóng dấu/lưu/chuyển phát per bước b.19-21) thay vì derive từ attachment.
- **(C) entity `ContractHardCopy` riêng** — KHÔNG khuyến nghị cho dry-run: b.19-21 là 3 thao tác trên 1 HĐ đã phát hành, không có vòng đời duyệt riêng (giấy đã ký xong); bảng mới = màn admin + FK + configuration thừa.

**Bind 7 leaf (phương án A):** `Hdc_<Code>` → `/hard-copies?type=<N>` (page mới) hoặc rẻ nhất tái dùng `/my-contracts?type=N&phase=9&hdc=1` biến thể hiển thị. Route mới = 4-place mirror ×2 app (App.tsx route `fe-user/src/App.tsx:52-65` khuôn + Layout staticMap `:63-69` đổi khỏi coming-soon + page + types) — khuôn `MyContractsPage.tsx` ĐÃ đọc đủ `type`/`phase`/`deleted` params (`:29,:34-35`) + tự rẽ `/contracts/deleted` (`:41-46`) ⇒ **mirror được, page ~300-400 LOC/app theo khuôn**.

**Mirror khuôn 6-leaf Ct_* được không?** — Về CƠ CHẾ (regex resolvePath `Layout.tsx:132-143` + query-param page) = ĐƯỢC và NÊN; về HÌNH DẠNG menu = KHÔNG (owner đã chốt 7-leaf-per-type cho Hdc). Nếu sau này owner muốn Pending/Approved trong Hdc thì nới regex `Hdc_([^_]+)_(...)` — hiện KHÔNG tự đẻ key mới (gotcha #84 seeder-only + đổi cấu trúc menu = hỏi owner).

**Authz lớp menu:** `Hdc_*` leaf + root `HopDongCung` đều **NGOÀI `MenuKeys.All`** (`MenuKeys.cs:37-42` — CỐ Ý, comment ghi rõ) ⇒ 0 policy `Hdc_*`/`HopDongCung.*` tồn tại; per-action authz cho trang Hdc phải dùng policy root **`Contracts.{Action}`** (root ∈ All `:168`) — tiền lệ đúng khuôn: `GET /contracts/deleted` dùng `Contracts.Read` (`ContractsController.cs:29`).

---

## §4 — Q4: "DRY-RUN" về mặt kỹ thuật (prod đang có 37 NCC / 17 user / 62 dự án seed / 7 HĐ V1 thật)

**Đề xuất: phương án LAI "dự án test riêng", KHÔNG cờ IsDryRun.**

| Phương án | Đánh giá | Chứng |
|---|---|---|
| **(i) Dự án test riêng `ZZTEST` + 1-2 NCC test** ⬅ đề xuất cho GĐ3+GĐ4 | Mã HĐ dry-run nằm CHUỖI SEQUENCE RIÊNG — prefix codegen = `{ProjectCode∨Year}/{typeCode}/SOL&{SupplierCode}` (`ContractCodeGenerator.cs:33`), seq per-prefix (`:40-53`) ⇒ KHÔNG ăn số của dự án thật. 2 loại framework scope=Year vẫn tách nhờ SupplierCode test. Dự án test NGOÀI danh sách seed ⇒ soft-delete xong **KHÔNG resurrect** (#75/#76 chỉ áp per-code trong seed `SeedRealMasterDataAsync` ungated). Tạo qua UI CRUD, 0 code | `ContractCodeGenerator.cs:33,:40-53` |
| **(ii) Cờ `IsDryRun`** — KHÔNG khuyến nghị | +1 mig + PHẢI sửa MỌI list/inbox/picker query (blast hàng chục handler — đúng lớp lỗi cardinality S87/S88 "sửa write-site sót read-site") + FE filter ×2 app. Đắt hơn giá trị 1 đợt dry-run | bài học `feedback_cardinality_change_grep_consumers` |
| **(iii) Cứ dùng thật** | ĐÚNG cho **KHKK GĐ2** (7 bảng mới 0-data — mỗi phiếu đầu đều là "thử", xóa mềm sạch); **SAI cho Contract GĐ3** (7 HĐ V1 prod thật chạy nhánh V1 `ContractWorkflowService.cs:97` comment + 37 NCC thật trong picker + mã ăn sequence thật nếu chọn dự án thật) | — |

**Rollback path (soft-delete sẵn có + 2 hố):**
- Cơ chế nền: soft-delete qua interceptor (Remove→IsDeleted, lớp #81-EXT indirect) + global `HasQueryFilter(!IsDeleted)` — bằng chứng Contract có filter: `ListDeleted` phải `IgnoreQueryFilters().Where(IsDeleted)` (`ContractFeatures.cs:372-373` + LUẬT comment `:356-360`). Màn "Đã xóa" chỉ-xem ĐÃ CÓ cả Contract (arc-2) lẫn PE (S155).
- 🔴 **HỐ 1 — HĐ dry-run chạy trọn vòng KHÔNG XÓA ĐƯỢC qua API:** guard `DeleteContractCommand` = `Phase >= DangInKy` (`ContractFeatures.cs:632-633`) là so sánh SỐ trên enum ⇒ chặn cả `DaPhatHanh=9`, `ChoDuyet=10`, `TraLai=98`, `TuChoi=99` (chỉ xóa được phase 2/3/4). Đường vòng tồn tại nhưng xấu: Admin manual override set `targetPhase=DangSoanThao` (`ContractWorkflowService.cs:191-198`) rồi DELETE. ⇒ dry-run phải CHẤP NHẬN HĐ test DaPhatHanh nằm lại (lọc bằng dự án ZZTEST) hoặc dùng đường vòng admin — ghi vào checklist wave cuối, review chốt.
- HỐ 2 — phiếu KHKK/PE `DaDuyet` cũng không xóa (KHKK spec allow-list `DangSoanThao|TuChoi` mirror PE `:1404`) — cùng cách xử lý.
- Sequence KHÔNG rollback — gap số chấp nhận được, chính comment repo nói vậy (`ContractFeatures.cs:113-115` "wasted... Acceptable").
- Notification dry-run bắn cho user THẬT (in-app `LogTransitionAsync→NotifyAsync` `ContractWorkflowService.cs:407-427`) — không tắt được per-record; dặn trước team.

**Rủi ro lẫn data thật (liệt kê để checklist):** (1) record test hiện CHUNG list với data thật (IDOR theo role; admin thấy hết) → convention "mọi record dry-run thuộc ZZTEST" + tên phiếu tiền tố; (2) CẤM đặt Code dự án/NCC test TRÙNG danh sách seed (twin FLOCK-01 S148 — seed re-fill theo Code); (3) `pe.ContractId` idempotency — mỗi vòng thử cần PE test MỚI (`:59-60`); (4) file upload thật nằm `wwwroot/uploads` — delete attachment có cleanup, HĐ xóa mềm thì file ở lại (rác chấp nhận); (5) menu đã public 13/13 role + authz trần ⇒ trong lúc dry-run ai cũng bấm được nút ghi trên HĐ THẬT — sang §5.
**Ràng buộc đo đạc:** SSH prod chết khi mở SQL-client (S134/S148 re-confirm) ⇒ phép đo prod trong §7 ưu tiên **curl API** làm chân chính; sqlcmd chỉ chạy local/Dev hoặc đưa lệnh cho người có RDP.

---

## §5 — Q5: AUTHZ khoanh vùng cho dry-run (options — REVIEW CHỐT, không tự quyết)

**Hiện trạng lộ (menu public 13/13 role ❹ + `[Authorize]` trần):** bất kỳ user đăng nhập nào gọi được 22 endpoint ghi của `ContractsController` (`:13` class-trần; ngoại lệ duy nhất `/deleted` có policy `:28-29`). Phân nhóm theo độ nguy hiểm:

| Nhóm | Endpoint | Nguy hiểm THẬT (đã re-verify service) |
|---|---|---|
| 🔴 Workflow | `POST {id}/transitions` (`:61-66`) | **Reject-trước-guard** `ContractWorkflowService.cs:49-66`: nhánh Reject KHÔNG check role, KHÔNG check phase-nguồn ⇒ user bất kỳ TraLai/TuChoi **bất kỳ HĐ ở bất kỳ phase — kể cả HĐ THẬT `DaPhatHanh`** (đặt TuChoi=99 lên HĐ đã phát hành). Nhánh Approve NGƯỢC LẠI có guard (OR-of-N 403 `:259-265`; trình đòi Drafter/DeptManager `:73-78`) |
| 🔴 CRUD | `POST /` · `PUT {id}` · `DELETE {id}` | Create = tạo HĐ rác **+ ĂN SEQUENCE thật ngay lúc tạo** (gen mã tại create `ContractFeatures.cs:113-118`) nếu chọn dự án thật; Update draft KHÔNG check drafter (`:151-157`) ⇒ sửa `GiaTri` nháp người khác; Delete không role-check (chỉ phase-guard `:632-633`) |
| 🟠 Attachment | `POST/DELETE {id}/attachments` | Không phase-guard (§1) ⇒ thay/xóa file scan trên HĐ THẬT đã phát hành; delete cleanup file vật lý |
| 🟠 Details | 15 endpoint add/update/delete details (`:123-226`) | Sửa ruột chi tiết 7 loại HĐ mọi lúc (handler có lock-edit 17 handler phase≠DangSoanThao? — lock S8/S9 áp Contract handlers cũ; details HĐ đã qua guard này, mức rủi ro còn lại là nháp người khác) |
| 🟡 Comment | `POST {id}/comments` | Spam/ghi đè ngữ cảnh — nhẹ |
| 🟡 Form-export | `FormsController` (`api/forms`, cũng `[Authorize]` trần `:11`): render/export-pdf (`:25,:32`) = STATELESS đọc-render, rủi ro thấp; `POST templates` (`:42`) = ghi template dùng chung — cùng lỗ class-trần, NGOÀI ContractsController | đo 07-29 |

**Khuôn per-action CÓ SẴN trong repo (đối chiếu):** (a) Master: `SuppliersController.cs:67-99` 4× `[Authorize(Policy="Suppliers.Update")]`; (b) chính ContractsController: `/deleted` dùng `Contracts.Read` `:28-29`; (c) S155 kết luận cho PE: root-policy dùng được NGAY vì root ∈ `MenuKeys.All` — áp y nguyên cho Contract (`Contracts` ∈ All `MenuKeys.cs:168` ⇒ 4 policy `Contracts.{Read,Create,Update,Delete}` ĐÃ đăng ký runtime, 0 key mới, 0 mig). 🔴 **Sửa grounding đề bài #3:** "khuôn PE controller per-action 3 tầng" KHÔNG tồn tại — `PurchaseEvaluationsController` cũng class-trần (S155 đo `:15`); khuôn per-action thật nằm ở Master + `/deleted`.

**OPTIONS cho review chốt (chi phí ↑ dần):**
- **O-A GIỮ NGUYÊN + chấp nhận có ý thức** (đúng O-3 "hợp đồng cứ từ từ" + tinh thần dry-run mở): rủi ro tập trung 2 ô 🔴; giảm nhẹ bằng quy ước ZZTEST + backup DB trước đợt dry-run (`scripts/backup-sql.ps1`). 0 file.
- **O-B PER-ACTION ROOT-POLICY 22 endpoint ghi** (`Contracts.Create/Update/Delete` theo verb): 1 file controller, 0 key mới, tiền lệ `/deleted`. ⚠️ kèm bắt buộc: cấp CanCreate/CanUpdate/CanDelete cho role tham gia dry-run QUA SEEDER (gotcha #84, KHÔNG SQL tay) TRƯỚC deploy — không thì 403 im lặng (gotcha #44) làm chết chính dry-run. Chú ý: policy chặn theo MENU-permission, user được cấp vẫn đụng được HĐ THẬT ⇒ O-B thu hẹp AI bấm, không thu hẹp BẤM VÀO ĐÂU.
- **O-C = O-B + vá tối thiểu Reject-trước-guard**: thêm 1 điều kiện `fromPhase==ChoDuyet` cho nhánh Reject (`ContractWorkflowService.cs:49`) — đóng đúng ca "TuChoi HĐ DaPhatHanh", không đổi hành vi hợp lệ nào (Reject hợp lệ chỉ có nghĩa khi đang ChoDuyet). Chạm service HĐ ⇒ **cần anh gật riêng** (O-3).
- **O-D khoanh DATA:** trước dry-run, đo phase 7 HĐ V1 thật (curl admin `GET /api/contracts` — SSH-SQL không dùng được) — nếu cả 7 đã DaPhatHanh thì O-C đóng được ~hết mặt tấn công transitions lên chúng. **UNCERTAIN hiện tại: phase thật của 7 HĐ V1 chưa đo được từ đây** — khai đúng trạng thái, không đoán.

---

## §6 — Q6: WAVE-STRUCTURE KỸ THUẬT GĐ3+GĐ4 (nối W1-W5 cũ; đánh số tiếp W6→W9)

> Nguyên tắc giữ nguyên spec cũ: mỗi wave **deploy độc lập**, không để nửa tính năng chết trên prod. Đơn vị độ lớn = **số file + có/không mig** (KHÔNG ước giờ). Số bảng nền hôm nay: 89 `sys.tables` (88 app + history); mig cuối trên đĩa = `20260727033522_AddPeAllowApproverDelete` ⇒ **mig kế tiếp = 69** (W1 KHKK dùng), GĐ3/GĐ4 dưới đây **0 mig** trừ W8 nếu owner chọn (B).

| Wave | Tên | Deploy riêng? | Người dùng THẤY gì | Mig? | Reuse gì | Độ lớn (file) |
|---|---|---|---|---|---|---|
| **W6** | **Đường ống HĐ V2** (điều kiện tiên quyết GĐ3) | ✅ có — 0 UI mới, HĐ V2 hết "vô hình" | Approver V2 **xem được + thấy trong inbox** HĐ đang chờ mình (nay: không) | ❌ 0 | mở rộng `GetEligiblePhases` (`ContractFeatures.cs:334-351`) + inbox `PhaseActorRoles` (`:421-430`) theo khuôn PE `ResolveV2InboxIdsAsync`; guard trình `:73-78` nới cho `Procurement`/drafter-của-HĐ (lớp 403-PMH) | **2-3 file BE** (ContractFeatures + service guard [+ DTO nếu trả `currentApproval`]) |
| **W7** | **Cầu KHKK→HĐ + pin V2** (= W5 spec cũ, đổi tên cho liền mạch) 🔴 CHẶN owner (chạm Contract, Q6/Q11) | ✅ có | Nút "Tạo HĐ" từ KHKK `DaDuyet`; HĐ sinh ra chạy trạm V2 thật (hết kẹt `ConflictException :115-116`) | ❌ 0 | `CreateContractFromEvaluationFeatures.cs` (+param `ApprovalWorkflowId`, giá từ `ApprovedAmount`, ContextNote lệch, ghi `Line.ContractId`) + body record `PurchaseEvaluationsController.cs:379-382` + 1 nút FE ×2 app | **2 BE + 2 FE** |
| **W8** | **GĐ4 bản cứng `Hdc_*`** (7 leaf hết ComingSoon) | ✅ có | 7 trang "…- Bảng cứng" list HĐ `phase=9` + badge trạng thái bản cứng + upload ScannedSigned/SealedCopy/InitialSigned | ❌ 0 nếu (A); ✅ 1 mig AddColumn nếu owner chọn (B) `HardCopyStatus` | param `phase` (`ContractsController.cs:20`) + khuôn `MyContractsPage.tsx:29-46` + enum-extend `AttachmentPurpose` (`ContractAttachment.cs:5-11`) + staticMap `Layout.tsx:63-69` re-point | **~6-8 FE** (page+types+App.tsx+Layout ×2 app) **+1-2 BE** (DTO thêm cờ derive) |
| **W9** | **Authz khoanh vùng** (theo option §5 review chốt) | ✅ có | (không thấy gì nếu làm đúng) — hoặc 403 sạch thay vì cho phép âm thầm | ❌ 0 | khuôn `/deleted :28-29` + `SuppliersController.cs:67-99`; **+ seeder cấp quyền** (gotcha #84) | **1 BE controller + 1 DbInitializer** (+1 service nếu O-C) |

**Thứ tự & phụ thuộc:** W6 phải TRƯỚC W7 (không thì HĐ pin V2 xong vẫn không ai duyệt được — đúng lỗi "cơ chế đúng, đường đi không có"). W8 độc lập hoàn toàn với W6/W7 (chỉ đọc HĐ `DaPhatHanh`) ⇒ **chạy song song được** — hữu ích cho dry-run vì owner thấy GĐ4 sớm. W9 nên đi **trước hoặc cùng** buổi mở rộng người bấm; nếu review chọn O-A thì W9 = 0 file (ghi quyết định vào plan cha).
**Kết nối W1-W5 cũ:** W1-W4 (KHKK) không đụng Contract ⇒ chạy trước/song song thoải mái. W5 cũ ≡ **W7** ở đây (đổi nhãn để khỏi 2 tên cho 1 việc — plan cha nên chọn 1 cách gọi).

---

## §7 — Q7: ACCEPTANCE ĐO ĐƯỢC per wave (khuôn H5: pin `N ≥ x` + TÊN test cụ thể; neo TÍNH-CHẤT tránh literal tự-lão-hoá)

> ⚠️ Neo số test: KHÔNG viết "562 + N" như spec cũ (mốc 562 tự lão hoá + `N` thả tự do = 0 bit). Viết: **`dotnet test SolutionErp.slnx` PASS 0 fail, và số test SAU ≥ số test TRƯỚC + x, và x test dưới đây tồn tại ĐÚNG TÊN**. Baseline đo NGAY TRƯỚC wave bằng chính lệnh đó.
> Phép prod theo khuôn 3-chân-kiềng (cicd): ① CI run PASS · ② artifact/bundle prod verify byte-level (curl-grep, #77) · ③ dữ liệu prod đúng (curl API — **KHÔNG sqlcmd qua SSH**, chết client S134/S148).

**W6 — Đường ống HĐ V2** (≥4)
1. Test-before, class `ContractV2PipelineTests` (`tests/SolutionErp.Infrastructure.Tests/Application/`), ≥3 tên: `Inbox_ApproverCapDangCho_ThayHopDongChoDuyet` · `Inbox_CapKhac_KhongThayHopDong` (ca ÂM) · `View_ApproverV2_KhongPhaiDrafter_XemDuocHopDongChoDuyet` (nay ForbiddenException `ContractFeatures.cs:488-489`).
2. `dotnet test` PASS 0 fail; số test ≥ baseline + 3.
3. Phép prod ③: đăng nhập user approver-V2 (không Admin) → `curl GET /api/contracts/inbox` trả **≥1** HĐ đang `ChoDuyet` mà user là Level hiện tại; cùng user `GET /api/contracts/{id}` → **200** (trước W6: inbox 0 + detail 403).
4. Ca ÂM giữ nguyên đường cũ: user KHÔNG thuộc Level nào và không Drafter → `GET /{id}` vẫn **403** (chứng không mở toang bằng cách bỏ guard).
5. Phép prod ①: CI run xanh trên commit W6.

**W7 — Cầu KHKK→HĐ + pin V2** (≥4)
1. Test-before `ContractSigningPlanBridgeTests`: `Bridge_CoPlanDaDuyet_GiaTriHopDong_BangApprovedAmountCuaDungNCC` · `Bridge_KhongCoPlan_GiuSumBaoGiaPE_VaCanhBaoMem` · `Bridge_PinApprovalWorkflowV2_HopDongTrinhDuoc_KhongConConflictException` · `Bridge_GhiNguoc_LineContractId_DungTungWinner`.
2. `dotnet test` PASS 0 fail; số test ≥ baseline + 4; test cũ `CreateContractFromEvaluationMultiWinnerTests` (đã tồn tại trên đĩa) **vẫn xanh** — chống hồi quy multi-winner.
3. TÍNH-CHẤT đo được (thay literal): với 1 PE có W winner và plan `DaDuyet`, `POST /{peId}/create-contract` trả **đúng W** contractId, và **mỗi** HĐ có `giaTri == ApprovedAmount` của Line cùng `supplierId`; tổng W HĐ == `SUM(ApprovedAmount)` toàn plan.
4. Phép prod ③ (curl, dự án ZZTEST): HĐ vừa tạo → `POST /{id}/transitions {targetPhase:10, decision:1}` bởi Drafter → **204** (không 409 `ConflictException`); đọc lại `GET /{id}` thấy `phase=10` + `currentApprovalLevelOrder=1`.
5. Changelog có ContextNote so-lệch khi giá KH ≠ SUM-PE (đọc `GET /{id}/changelogs`, chuỗi chứa cả 2 số).

**W8 — GĐ4 bản cứng** (≥4)
1. Test `ContractHardCopyTests`: `Danhsach_BangCung_ChiTraHopDong_PhaseDaPhatHanh` · `CoBanCung_Derive_TuAttachment_SealedCopy` (+ nếu chọn (B): `HardCopyStatus_MacDinh_Null_KhongVoLuoc`).
2. `dotnet test` PASS 0 fail; số test ≥ baseline + 2 (hoặc +3 nếu (B)).
3. FE: `npm run build` PASS **×2 app**; 7 leaf `Hdc_*` KHÔNG còn trỏ `/coming-soon` — grep `Hdc_` trong `fe-user/src/components/Layout.tsx` + `fe-admin/.../Layout.tsx` trả **0 hit chuỗi `coming-soon`** trên các dòng `Hdc_`.
4. Phép prod ②+③: sau deploy, curl-grep bundle prod chứa route mới (byte-level, #77 — cache≠deploy); rồi curl `GET /api/contracts?type=1&phase=9` trả list (200) và mọi item có `phase==9`.
5. Ca ÂM: leaf Hdc của loại KHÔNG có HĐ phát hành nào → trang rỗng có empty-state, KHÔNG lỗi 500.

**W9 — Authz** (≥4; nếu review chọn O-A thì wave này = ghi quyết định, các acceptance dưới KHÔNG áp)
1. `AuthorizePolicyRegressionTests` (ĐÃ TỒN TẠI `tests/SolutionErp.Infrastructure.Tests/Api/`) **vẫn xanh** — nó bắt ca policy trỏ menu-key ngoài `MenuKeys.All` (⇒ chứng `Contracts.*` hợp lệ, không đẻ 500).
2. Test mới `ContractsControllerAuthzTests`: `MoiEndpointGhi_CoAuthorizePolicy` (reflection đếm, neo TÍNH-CHẤT: **mọi** action `[HttpPost|HttpPut|HttpDelete]` của `ContractsController` có `[Authorize(Policy=...)]` — không neo con số 22) + `User_ThieuQuyen_GoiPost_Tra403` (ca ÂM, KHÔNG 500/200).
3. `dotnet test` PASS 0 fail; số test ≥ baseline + 2.
4. Phép prod ③ sau deploy: user dry-run ĐÃ được seeder cấp quyền → `POST /api/contracts` **201**; user chưa cấp → **403** (không 500). ⚠️ chạy ngay sau deploy: 403 nhầm cho người đang dry-run = tự chặn chính mình (gotcha #44).
5. Nếu chọn O-C: `Reject_KhiHopDongDaPhatHanh_BiChan` (ca ÂM cho lỗ `ContractWorkflowService.cs:49-66`) + verify HĐ thật không đổi phase.

**Gate 5-anchor (ràng buộc run.md) — anchor gợi ý cho mỗi wave:** W6 = `ContractFeatures.cs:334-351` · `:421-430` · `:488-489` · `ContractWorkflowService.cs:73-78` · `ContractsController.cs:38-40`. W7 = `CreateContractFromEvaluationFeatures.cs:53-60` · `:68-71` · `:88-90` · `:145` · `ContractWorkflowService.cs:115-116`. W8 = `ContractAttachment.cs:5-11` · `DbInitializer.cs:1862-1867` · `Layout.tsx:63-69` · `MyContractsPage.tsx:29-46` · `ContractsController.cs:16-24`. W9 = `ContractsController.cs:13` · `:28-29` · `SuppliersController.cs:67` · `MenuKeys.cs:163-181` · `ContractWorkflowService.cs:49-66`.

<!-- END sub-investigator-codebase-2 · TOTAL=8 mục (§0 nguồn + §1..§7 = 7 câu hỏi) -->

