# SUB-INVEST-DB (Fable deep-pass, lens DATABASE) — S182 KHKK AWF mirror NCC

> Đề bài: 8 workflow "Duyệt KHKK — Nhóm 1..8" cần đủ chức năng như "Duyệt NCC" trên approval-workflows-v2.
> Câu hỏi trung tâm: **schema hiện tại ĐỦ chưa hay cần mig mới?** Kỳ vọng "máy rộng UI hẹp" ⇒ 0 mig — PHẢI CHỨNG.
> Trạng thái file: đổ ruột liên tục (chống-#53). Khu chưa đo ghi `CHƯA ĐO`.

## §1 Schema V2 — cột thật 3 bảng (từ ModelSnapshot + entity)

Nguồn: `ApplicationDbContextModelSnapshot.cs:128-313` + `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` (1 file chứa cả 3 entity).

### `ApprovalWorkflows` (snapshot :128-187)
| Cột | Kiểu | Ghi chú |
|---|---|---|
| Id | uniqueidentifier PK | BaseEntity |
| Code | nvarchar(100) NOT NULL | "QT-DN-V2-001" |
| Version | int | monotonic per Code |
| ApplicableType | int | enum 1..10 — **ContractSigningPlan=10** (`ApprovalWorkflow.cs:70`) |
| Name | nvarchar(200) NOT NULL | |
| Description | nvarchar(1000) NULL | |
| IsActive | bit | |
| ActivatedAt | datetime2 NULL | |
| IsUserSelectable | bit | Mig 25 — Ghim/Bỏ ghim |
| CeoApprovalThreshold | decimal(18,2) NULL | Mig 53 — ngưỡng gói CEO (PE semantic) |
| CreatedAt/CreatedBy/UpdatedAt/UpdatedBy | | BaseEntity audit |

Index: `(ApplicableType, IsActive)` + **UNIQUE `(Code, Version)`** (:181-184). 🔴 **KHÔNG có IsDeleted** — `ApprovalWorkflow : BaseEntity` (entity :21), KHÔNG AuditableEntity ⇒ 0 soft-delete, 0 query-filter trên cả 3 bảng V2.

### `ApprovalWorkflowSteps` (snapshot :274-313)
Id · ApprovalWorkflowId (FK) · Order int · Name nvarchar(200) NOT NULL · DepartmentId uniqueidentifier NULL (hint) · audit 4 cột. Index: `DepartmentId` + `(ApprovalWorkflowId, Order)`.

### `ApprovalWorkflowLevels` (snapshot :189-272)
Id · ApprovalWorkflowStepId (FK) · Order int (Cấp, OR-of-N rows cùng Order) · Name nvarchar(200) NULL · ApproverUserId NOT NULL (1 row = 1 NV slot) · audit 4 cột · **9 cờ bit Allow\***. Index: `ApproverUserId` + `(ApprovalWorkflowStepId, Order)`.

### §1b Bảng ánh xạ 9 checkbox ảnh ↔ cột — **9/9 CÓ ĐỦ, 0 cột thiếu**

Per-NV = per-row `ApprovalWorkflowLevels` (mỗi row = 1 ApproverUserId ⇒ cờ trên row chính là cờ per-NV).

| # | Checkbox (ảnh YC-013) | Cột thật | Default (snapshot) | Mig gốc |
|---|---|---|---|---|
| 1 | Trả về 1 Cấp trước | `AllowReturnOneLevel` | false (:220-223) | 29 |
| 2 | Trả về 1 Bước trước | `AllowReturnOneStep` | false (:225-228) | 29 |
| 3 | Trả về Người chỉ định | `AllowReturnToAssignee` | false (:230-233) | 29 |
| 4 | Trả về Drafter (mặc định) | `AllowReturnToDrafter` | **true** (:235-238) | 29 |
| 5 | Sửa Section 2 | `AllowApproverEditDetails` | false (:205-208) | 29 |
| 6 | Sửa Section ngân sách | `AllowApproverEditBudget` | false (:200-203) | 30 |
| 7 | Duyệt thẳng Cấp cuối | `AllowApproverSkipToFinal` | false (:215-218) | 31 |
| 8 | Duyệt là KẾT THÚC | `AllowApproverFinalize` | false (:210-213) | 58 |
| 9 | XÓA phiếu | `AllowApproverDelete` | false (:195-198) | 68 |

Cột nằm trên bảng V2 DÙNG CHUNG mọi ApplicableType ⇒ workflow KHKK (ApplicableType=10) **lưu được cả 9 cờ ngay hôm nay, 0 mig**. ⚠️ Ranh giới lens: cột TỒN TẠI ≠ service KHKK ĐỌC cờ (semantic #3 đọc `PeLevelOpinions`, #5/#6 đọc section PE — entity comment `ApprovalWorkflow.cs:119-173`; KHKK service có honor không = lens BE).

## §2 Versioning — đọc từ `ApprovalWorkflowV2AdminFeatures.cs` (KHÔNG đoán từ UI)

- **Lưu version:** cùng `Code` nhiều row, `Version int` monotonic; **UNIQUE (Code, Version)** (snapshot :183-184). "Archived" KHÔNG có cột riêng — chỉ là `IsActive=false` (nhãn UI suy từ bit).
- **Tạo phiên bản mới** = `CreateAwDefinitionCommandHandler` (:326-): INSERT row mới `Version = max(Code)+1` (:334-337), `IsActive=true, IsUserSelectable=true` (:352-353). 🔴 **LANDMINE cho KHKK:** deactivate cũ chạy theo **`ApplicableType`** chứ KHÔNG theo Code — `Where(w.ApplicableType == typeEnum && w.IsActive)` set `IsActive=false` TẤT CẢ (:339-343). Với DuyetNcc (1 code/type) đúng; với KHKK **8 code chung type=10** ⇒ tạo version mới cho `KHKK-N1` sẽ **tắt IsActive của cả KHKK-N2..N8**. Chính seeder đã né bẫy same-class ở check idempotent (`DbInitializer.cs:540-542` "check theo type sẽ chặn 7 nhóm còn lại") nhưng Create handler CHƯA vá — vá-tệp-A-quên-tệp-B. **Fix = code BE (đổi scope deactivate theo Code), 0 mig.**
- **Sửa tại chỗ** = `UpdateAwDefinitionCommandHandler` (:515-900): UPDATE in-place ĐÚNG row + **diff Levels theo `Level.Id` — GIỮ NGUYÊN Id** (:561-634, "F-3 CẤM delete-then-insert") ⇒ không đẻ Id mới, không mồ-côi con trỏ (khác nỗi lo F17/S155 — máy Update nay ĐÃ TỒN TẠI, memory cũ "WF-V2 0 lệnh Update" STALE). Cấm in-place: đổi Code/ApplicableType/CeoThreshold (:539-550), thêm/bớt/đảo Bước (:553-574), đổi Order dòng (:593), toggle 2 cờ đổi-đường-đi F5/F2 (:598-603), bỏ người giữ F5/F2 (:627-632), làm rỗng Cấp (:639-647) — tất cả 409 + gợi ý tạo version mới. 7 cờ còn lại + đổi người + thêm/bỏ người trong Cấp = hợp lệ in-place (:789-797).
- **Guard chữ ký khi bỏ/đổi người:** 2 tầng quét **CẢ 8 bảng LevelOpinions** gồm `ContractSigningPlanLevelOpinions` — count-sống (:961-965) + retained (:1033-1038); transaction rào (:744-745, :895-899). ⇒ máy sửa-tại-chỗ **ĐÃ KHKK-aware ở tầng guard**.
- ⚠️ Vết changelog khi sửa-tại-chỗ chỉ ghi cho **PE** (`ApplicableType ∈ {DuyetNcc, DuyetNccPhuongAn}` :863-892); KHKK khai-giới-hạn defer F-6b (:857-862) — bảng `ContractSigningPlanChangelogs` ĐÃ TỒN TẠI (Mig 69) ⇒ wire = code BE thuần, **0 mig**.
- **Ghim/Bỏ ghim** = `SetAwUserSelectableCommandHandler` (:1173-1185): flip 1 bit, generic mọi type — KHKK dùng ngay.
- **Xoá version** = `DeleteAwDefinitionCommandHandler` (:1193-1205): **HARD delete** (`Remove` + BaseEntity không soft-delete), **0 guard usage** — comment tự khai ":1187-1189 Sau UAT … cần check usage trước khi delete". Phiếu pin (PE/HĐ/KHKK Restrict) hoặc LevelOpinion tồn tại ⇒ SqlException 547 → 500. Version không ai đụng ⇒ xoá sạch (cascade Steps→Levels).

## §3 KHKK wiring — Mig 70/71 + ContractSigningPlans pin/pointer/finalize-flag

### FK map V2 (snapshot :6210-6323) — quyết định risk xoá
| Quan hệ | Behavior |
|---|---|
| Steps → ApprovalWorkflows | **Cascade** (:6229-6233) |
| Levels → Steps | **Cascade** (:6212-6216) |
| Levels.ApproverUserId → Users | Restrict (:6218-6222) |
| Steps.DepartmentId → Departments | Restrict (:6235-6238) |
| `ContractSigningPlans.ApprovalWorkflowId` → ApprovalWorkflows | **Restrict** (:6245-6248) |
| `Contracts.ApprovalWorkflowId` → | Restrict (:6327-6330) |
| `PurchaseEvaluations.ApprovalWorkflowId` → | Restrict (:6755-6758) |
| `ContractSigningPlanLevelOpinions.ApprovalWorkflowLevelId` → Levels | **Restrict** (:6297-6301) |
| `ContractSigningPlanApprovals.ApprovalWorkflowLevelId` | ⚠️ **LOOSE Guid — 0 FK** (cột + index :416/:455 nhưng relationship section :6251-6260 chỉ có 1 HasOne về Plan Cascade) |

⇒ Hard-delete 1 workflow version bị DB CHẶN (SqlException 547) nếu: (a) bất kỳ phiếu PE/HĐ/KHKK pin nó, HOẶC (b) bất kỳ LevelOpinion (PE/HĐ/KHKK) trỏ Level của nó — cascade Steps→Levels đụng Restrict của LevelOpinions.

### Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (`20260731085624_*.cs`)
- **KHÔNG có bảng catalog-nhóm-duyệt riêng** — "catalog" trong tên mig = gắn `ContractSigningPlanLines.CatalogEntryId` (loose-Guid, KHÔNG FK, :70-74) + `TenHangMuc` denorm (:76-81) trỏ về `ContractCatalogEntries` (Mig 70, bảng danh mục 86 dòng).
- `ContractSigningPlans.ApprovalGroup int NOT NULL default 0` + backfill vô-điều-kiện `SET = 1` (:47-61) — nhóm duyệt 1..8 lưu TRÊN PHIẾU dạng int trần (0 bảng, 0 FK, 0 CHECK constraint 1..8).
- `EndedByLevelFinalize bit` runtime-flag (:63-68) — ghost-window khai trước, K3 tiêu thụ.
- Index-swap: UNIQUE `(PlanId, SupplierId)` → UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered `[IsDeleted]=0` (:43-45, :93-98). Down có bẫy F-8 (:132-141).

### `ContractSigningPlans` pin + pointer (snapshot :315-408) — mirror ĐỦ khuôn PE
`ApprovalWorkflowId Guid?` (pin, Restrict) · `CurrentWorkflowStepIndex int?` (:336) · `CurrentApprovalLevelOrder int?` (:333) · `EndedByLevelFinalize bit` (:351) · `ApprovalGroup int` (:321) · `Phase int` · AuditableEntity (IsDeleted/DeletedAt/DeletedBy — phiếu SOFT-delete, ≠ workflow) · UNIQUE filtered `MaKeHoach` (:395-397) · index `(ApprovalGroup, Phase)` (:403).

### 8 workflow nhóm = 8 row `ApprovalWorkflows` phân loại thế nào?
- Seed `SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:549-687`): 8 row `Code="KHKK-N{n}"` n=1..8, `Version=1`, `ApplicableType=10`, `Name="Duyệt KHKK — Nhóm {n}"`, `IsActive=true`, `IsUserSelectable=true` (:609-625). 3 Bước PRO→CCM→BOD, Cấp OR-of-N, riêng Cấp 2 CCM (a Chương) `AllowApproverFinalize=true` (:575-579, :655). Idempotent **PER-CODE** (:610) — cố ý né bẫy check-theo-type (:540-542). Resolve người theo EMAIL, thiếu user → bỏ slot + log, workflow 0 bước vẫn TẠO (:544-548).
- **Liên kết nhóm↔workflow = QUY ƯỚC CODE, không phải cột/FK:** phiếu chốt `ApprovalGroup` lúc tạo (default 1, validator 1..8 `ContractSigningPlanFeatures.cs:415-418, :461`); rào (v) regex `^KHKK-N([1-8])$` trên Code của workflow user pick — lệch nhóm = 409, Code NGOÀI họ (type-10 chung) **cho qua** (:475-486). `ContractCatalogEntry.ApprovalGroup int` 1..8 (validator `ContractCatalogFeatures.cs:120`, KHÔNG CHECK constraint) quyết nhóm của hạng mục; line-gán guard khớp nhóm phiếu (`:691, :802`).
- Con trỏ duyệt KHKK: service `ContractSigningPlanWorkflowService.cs` GHI đủ cặp `CurrentWorkflowStepIndex` (0-based index list sorted) + `CurrentApprovalLevelOrder` (1-based) (:201-202, :295, :309-310) + `EndedByLevelFinalize=true` nhánh finalize (:275-282 — ghost-window Mig 71 ĐÃ ĐÓNG, K3 live) + null-out khi terminal (:323-324). Đọc pointer từ CHÍNH workflow đã pin, KHÔNG đụng `IsActive` (:422-446, có bounds-check) ⇒ **version Archived (IsActive=false) không làm phiếu treo kẹt**.
- Mig 72 `AddMaHangMucToContractSigningPlanLines` (`20260806070242`): +`MaHangMuc nvarchar(20)` denorm + backfill từ catalog Code, idempotent (:13-34) — mig CUỐI trên đĩa.

## §4 Dữ liệu thật LocalDB Dev (sqlcmd -E, đo 2026-08-08)

| Phép đo | Kết quả |
|---|---|
| `ApprovalWorkflows` theo type | chỉ type {3,4,5,6,7,9} × 1 row (6 seeder mẫu). **Type 1/2 (DuyetNcc): 0 row. Type 10 (KHKK): 0 row** |
| 8 workflow `KHKK-N*` | **0/8 trên Dev local** — seeder idempotent sẽ tạo ở lần boot API kế; QT-DN-V2-001 v01-v04 là dữ liệu PROD (admin tạo tay), local không có |
| `ContractSigningPlans` | 0 phiếu · 0 LevelOpinions · 0 Approvals |
| `__EFMigrationsHistory` Dev | top = `20260731085624` (**Mig 71**) — **Mig 72 trên đĩa CHƯA áp** |
| `__EFMigrationsHistory` Design | top = Mig 71 — cùng drift |

⇒ **Câu "8 row đủ chưa, IsUserSelectable bao nhiêu" trên PROD: CHƯA ĐO** (SSH chết khi load SQL-client — S134/S148/S177). Local suy được từ seed-code: lần boot prod sau commit K3 đã tạo 8 row `IsActive=1, IsUserSelectable=1`; ảnh sidebar YC-013 hiện 8 mục = seed đã chạy prod. Query bàn giao implementer chạy trên prod:
```sql
SELECT Code, Version, IsActive, IsUserSelectable FROM ApprovalWorkflows WHERE ApplicableType=10 ORDER BY Code, Version;
SELECT ApprovalGroup, Phase, COUNT(*) n FROM ContractSigningPlans GROUP BY ApprovalGroup, Phase;
SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions;
```

## §5 Verdict migration + risk

### VERDICT: **0 MIGRATION.** Schema V2 dùng chung đã đủ 100% cho mirror
1. 9/9 checkbox = 9 cột bit có sẵn trên `ApprovalWorkflowLevels` (§1b), bảng dùng chung mọi ApplicableType.
2. Versioning v01→v04 + Archived: UNIQUE `(Code, Version)` + `IsActive` bit — đã phục vụ đúng cơ chế này cho `QT-DN-V2-001` v04; `KHKK-N{n}` là Code như mọi Code.
3. Ghim = `IsUserSelectable` sẵn; Sửa-tại-chỗ = máy Update diff-by-Id ĐÃ KHKK-aware ở guard chữ ký (3/3 site :961/:1033/:1099); Xoá = máy Delete sẵn (thiếu guard là thiếu CODE, không thiếu cột).
4. Pin + con trỏ + finalize-flag phiếu KHKK: đủ từ Mig 69/71, service đã ghi/đọc sống.
5. Hạng-mục/nhóm: `ApprovalGroup` (phiếu + catalog) + regex Code — 0 nhu cầu cột mới.

### Nhưng 3 QUẢ MÌN CODE-LEVEL lộ ra từ scan DB-semantics (fix = BE code, vẫn 0 mig)
| # | Mìn | Bằng chứng | Hệ quả nếu bỏ qua |
|---|---|---|---|
| M1 | **Create-new-version deactivate theo `ApplicableType`** — tạo v02 cho `KHKK-N1` sẽ `IsActive=false` cả active-version của N2..N8 (8 Code chung type=10; giả định 1-Code-1-type vỡ) | `ApprovalWorkflowV2AdminFeatures.cs:339-343`; đối chiếu seeder đã né same-class `DbInitializer.cs:540-542` | Overview 7 nhóm kia mất "Active" (thành Archived-oan); mọi consumer lọc IsActive theo type sai |
| M2 | **Overview API `Active = FirstOrDefault(IsActive)` per-TYPE + KHÔNG có param Code** — type 10 có 8 chuỗi version trộn chung 1 `History`, `Active` lấy 1 trong 8 | `:107-109` (query 2 param), `:203-212` | Trang admin 8 mục sidebar không tách được version-list per nhóm nếu FE không tự group by Code |
| M3 | **Delete = hard-delete 0 guard usage** — phiếu pin (Restrict) / opinion (Restrict) → SqlException 547 → 500 thô; version rác thì xoá êm | `:1187-1204`; FK snapshot :6245-6248, :6297-6301 | UX 500 khó hiểu thay 409; data KHÔNG mất (DB chặn được) |

### Risk integrity/concurrency chi tiết
- **Sửa workflow đang có phiếu KHKK treo:** an toàn cấu trúc — mọi mutation đổi-đường-đi bị 409; đổi người/cờ giữ nguyên `Level.Id` ⇒ pointer (StepIndex, LevelOrder) vẫn giải được; guard chữ ký quét cả `ContractSigningPlanLevelOpinions` (sống + retained); transaction rào :744/:899. **Lỗ nhỏ:** vết sửa KHÔNG ghi vào `ContractSigningPlanChangelogs` (chỉ PE :863-892, defer F-6b) — phiếu KHKK đang chạy bị sửa quy trình mà lịch sử phiếu im lặng.
- **Xoá version Archived có phiếu treo/xong trỏ vào:** bị Restrict chặn (pin `ApprovalWorkflowId`) — kể cả phiếu soft-deleted (row còn, FK còn). Pointer phiếu KHÔNG chết vì không xoá được. Riêng `ContractSigningPlanApprovals.ApprovalWorkflowLevelId` LOOSE (0 FK) — nếu tương lai có đường hard-delete phiếu/level thì history giữ Guid treo (hiện không có đường đó).
- **Race 2 admin cùng "Tạo version mới" cùng Code:** UNIQUE (Code,Version) đỡ — kẻ thua 2601/2627 → 500 (không silent-dup). Chấp nhận được cho màn quản trị.
- **Race tạo 2 phiếu cùng (PE, nhóm):** rào handler check-then-insert, CỐ Ý không filtered-unique (`ContractSigningPlanFeatures.cs:456-460`, gate-K2 F-7) — giữ nguyên quyết định cũ, ngoài scope.

## §6 Đề xuất wave DB

- **W-DB-0 (prep local, bắt buộc trước test):** `dotnet ef database update` ×2 DB (Dev + Design đang kẹt Mig 71, đĩa 72) + boot API 1 lần để `SeedKhkkGroupWorkflowsAsync` đẻ 8 row local. 0 mig mới.
- **W-DB-1 (BE, đi cùng wave mirror):** vá M1 — scope deactivate trong `CreateAwDefinitionCommandHandler` đổi `ApplicableType`→`Code` (hoặc Code∧Type); test: tạo v2 `KHKK-N1` ⇒ N2..N8 `IsActive` bất biến + case DuyetNcc regression.
- **W-DB-2 (BE):** overview/API thêm chiều Code (param `code=` hoặc group per-Code trong `AwTypeSummaryDto`) để 8 trang nhóm đọc đúng chuỗi version của mình.
- **W-DB-3 (BE, khuyến nghị):** guard lịch sự cho Delete — pre-count pin (PE/HĐ/KHKK/5 office) + LevelOpinions → 409 kể tên hồ sơ (tái dùng khuôn `CountLiveOpinionsAsync`), thay 547→500.
- **W-DB-4 (BE, optional):** wire changelog KHKK khi sửa-tại-chỗ (bảng `ContractSigningPlanChangelogs` sẵn — mirror khối PE :863-892).
- **Verify prod (bàn giao implementer):** 3 query §4.

## §7 Surprises ngoài đề bài
1. **Local DB drift:** Mig 72 committed trên đĩa (2026-08-06) nhưng CẢ Dev lẫn Design local dừng Mig 71 — pattern committed-but-unapplied-local; test wave nào chạm Lines sẽ lệch schema nếu quên update.
2. **Dev local 0 row type-1/2 (DuyetNcc)** — workflow NCC prod tạo tay qua UI, không seed ⇒ mọi test local về trang DuyetNcc cần tự tạo workflow trước.
3. Memory cũ S155 "WF-V2 KHÔNG có lệnh Update" đã STALE — `UpdateAwDefinitionCommand` (sửa-tại-chỗ diff-by-Id, giữ Id Level) đã land sau đó, guard rất dày.
4. Seeder KHKK resolve người theo EMAIL cứng (11 email prod `DbInitializer.cs:556-584`) — DB thiếu user (local!) ⇒ workflow RỖNG 0 bước vẫn được tạo + log warning; trang designer local sẽ thấy 8 workflow trống người, KHÔNG phải bug.
5. `HaveSequentialOrders` (Create :306-316) — validator jagged-levels per-step chỉ chạy path Create; Update path dùng bộ guard riêng (T1 set-equality) — 2 bộ luật song song cùng file, drift-risk nếu wave sau chỉnh 1 quên 1.

---
*Đo: ModelSnapshot + entity + 2 handler-file + DbInitializer + Mig 69/70/71/72 + sqlcmd Dev/Design. Prod: CHƯA ĐO (SSH giới hạn). ~62K token.*
