Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md
2026-07-27 09:39:31 +07:00

257 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# sub-invest-menu-3 — lát cắt 3: MENU restructure + màn "Đã xóa" + "người đứng đầu phòng"
> run: `2026-07-27-S155-pe-delete-approver` · vai: investigator-codebase · ghi-đĩa-trong-lúc-làm (chống #53)
> Nhãn: `[CODE]` = đọc thẳng file có file:line · `[SUY LUẬN]` = diễn giải · `[CHƯA XÁC MINH]` = chưa chứng được
---
## Q-A — Menu định nghĩa ở ĐÂU, thêm mục mới chạm những gì
### A-1 `[CODE]` 🔴 MENU LÀ **SEED DB**, KHÔNG phải hard-code FE — dứt khoát
Bằng chứng 3 tầng:
1. **Entity + bảng thật:** `src/Backend/SolutionErp.Domain/Identity/MenuItem.cs` — bảng `MenuItems` (Key PK, Label, ParentKey, Order, Icon, +`IsVisible`/`DisplayLabel` từ Mig 27).
2. **Nơi khai 4 mục con của `Pe_<Code>`:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:1867-1878`
```csharp
foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)
{
tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck")); // :1870
tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", ...group..., peOrder++, "Network")); // :1874
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", ...group..., peOrder++, "List")); // :1875
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", ...group..., peOrder++, "Plus")); // :1876
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", ...group..., peOrder++, "CheckCircle2")); // :1877
}
```
**Đúng 4 mục owner thấy trên prod = 4 dòng `:1874-1877`.** Nhãn `"Duyệt"` nằm ở **`:1877`**, key = `Pe_<Code>_Pending`.
3. **FE render từ API, KHÔNG hard-code danh sách:** `fe-user/src/components/Layout.tsx` render `AuthContext.menu` (fetch `/api/menus/me``GetMyMenuTreeQuery`). FE chỉ có **map key → route** (`resolvePath`, `Layout.tsx:55-137`), không có danh sách mục.
### A-2 `[CODE]` KHÔNG cần migration — nhưng CẦN chạy seed (restart API)
`DbInitializer.cs:1888-1914` = upsert idempotent: `existingItems` theo Key → **thiếu thì INSERT**, có rồi thì **chỉ sửa `Order`** (`:1899-1903`), **KHÔNG đụng Label/ParentKey/Icon** (`:1897-1898` comment nói rõ).
⇒ Thêm 2 mục mới `Đã duyệt` + `Đã xóa` = thêm 2 dòng `tree.Add(...)` + 2 const trong `MenuKeys.cs`**API restart là có** (DbInitializer chạy lúc startup). **KHÔNG cần EF migration** (không đổi schema). `[SUY LUẬN]` nhưng dựa thẳng vào cơ chế upsert ở `:1891-1908`.
### A-3 `[CODE]` Không phải 4-place mirror — là **6 chỗ** cho mục PE mới
| # | Chỗ | File:line | Ghi chú |
|---|---|---|---|
| 1 | Hằng key BE | `MenuKeys.cs:134-141` (thêm `PurchaseEvaluationApproved/Deleted(typeCode)`) | factory `$"Pe_{typeCode}_<X>"` |
| 2 | **Seed tree DB** | `DbInitializer.cs:1874-1877` (+2 dòng) | 🔴 chỗ FE-only-mindset hay quên |
| 3 | Seed permission admin | `DbInitializer.cs:2092-2096` + `:2492-2496` | **2 nơi**, xem A-3bis |
| 4 | FE `resolvePath` | `fe-user/Layout.tsx:120` regex + `:128-134` branch · `fe-admin/Layout.tsx:107` regex + `:110-118` | regex `(List\|Create\|Pending\|WfView)` phải nới |
| 5 | FE route `App.tsx` | (chỉ nếu dùng URL MỚI; nếu tái dùng `/purchase-evaluations?...` thì **0 chỗ**) | xem Q-B |
| 6 | FE `menuKeys.ts` | `fe-{admin,user}/src/lib/menuKeys.ts` | 🔴 xem A-3ter: **hiện KHÔNG có `Pe_*`** ⇒ có thể là 0 chỗ |
### A-3bis `[CODE]` Chỗ thứ 5 & 6 THẬT: 2 seed permission
- `DbInitializer.cs:2092-2096` — gom `peKeys` (Group/WfView/List/Create/Pending)
- `DbInitializer.cs:2492-2496` — gom `menuKeys` (cùng 5 key)
**2 danh sách song song liệt kê ĐÍCH DANH 5 key PE.** Thêm key mới mà quên 1 trong 2 = permission seed lệch. (Đã đọc ngữ cảnh 2 hàm ở A-6.)
### A-3ter `[CODE]` `menuKeys.ts` FE **không chứa `Pe_*`** — lệnh đã chạy
```
grep -rn "Pe_DuyetNcc|Pe_\$\{" fe-user/src/lib/menuKeys.ts fe-admin/src/lib/menuKeys.ts → 0 dòng output
```
⇒ FE mirror const chỉ có key top-level; `Pe_*` sinh động qua regex trong `Layout.tsx`. **Không phải chỗ phải sửa.**
### A-4 `[CODE]` Đổi tên `Duyệt` → `Đang duyệt`: **2 đường, 1 rủi ro thật**
**Đường rẻ nhất (0 code):** admin sửa `DisplayLabel` qua `/system/menu-visibility` (`fe-admin/src/pages/system/MenuVisibilityPage.tsx``PATCH` `MenusController.cs:26-32`). ⚠️ **CHỈ ăn ở fe-user**`MenuDtos.cs:14` ghi rõ *"override label cho fe-user; fe-admin luôn render Label gốc"*; fe-user đọc `effectiveLabel()` (`Layout.tsx:161-163`), fe-admin không có hàm này.
**Đường code:** thêm `[Pe_<code>_Pending] = "Đang duyệt"` vào `labelBackfill` (`DbInitializer.cs:1918-1924`) — vì vòng upsert `:1893-1904` **KHÔNG update Label** row đã tồn tại. Sửa mỗi `:1877`**KHÔNG ăn trên DB đã seed**.
🔴 **Rủi ro nếu ĐỔI KEY (không chỉ label):** đo trên Dev DB —
```
sqlcmd -d SolutionErp_Dev -Q "SELECT MenuKey,COUNT(*) FROM Permissions WHERE MenuKey LIKE 'Pe[_]%' GROUP BY MenuKey"
→ 10 key × 13 row (13 role) mỗi key, gồm Pe_DuyetNcc_Pending = 13 row
```
⇒ Permission row là **explicit, không phải chỉ dựa inherit**. Đổi key = 13 row/key thành mồ côi ⇒ role mất quyền (rơi về inherit từ root `PurchaseEvaluations` — có cứu, nhưng cờ CRUD thành cờ của ROOT chứ không phải cờ đã tinh chỉnh). **Khuyến nghị: GIỮ key `Pe_*_Pending`, chỉ đổi label.**
### A-5 `[CODE]` Cả 2 app đều có menu này — nhưng **lệch 1 nhánh**
fe-user `Layout.tsx:120` regex `(List|Create|Pending|WfView)` · fe-admin `Layout.tsx:107` regex `(List|Create|Pending)`**fe-admin THIẾU `WfView`** ⇒ leaf "Luồng duyệt" resolve `null``MenuLeaf` drop im lặng bên admin. Thêm leaf mới phải sửa **cả 2**, và biết trước là 2 file này **đã lệch sẵn** (không byte-identical như các file PE khác).
---
## Q-B — Dựng 2 màn `Đã duyệt` + `Đã xóa`
### B-1 `[CODE]` Màn `Duyệt` KHÔNG lọc phase ở BE — nó gọi **endpoint khác** rồi lọc ở FE
`pendingMe=1` **không phải** tham số BE. FE rẽ nhánh sang endpoint inbox:
```ts
// fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx:61-68
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: { type: typeFilter ?? undefined, approvalWorkflowId: approvalWorkflowId || undefined },
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
```
BE = `GetMyPurchaseEvaluationInboxQuery` (`PurchaseEvaluationFeatures.cs:715-771`), controller `PurchaseEvaluationsController.cs:30-35`. **Không có `Phase == ChoDuyet` đơn lẻ** — mà là 2 nhánh OR (`:762`):
```csharp
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
```
`v2InboxIds` = `ResolveV2InboxIdsAsync(userId)` (`:748`) — phiếu V2 mà actor là approver **Cấp hiện tại**.
Lọc "Đã gửi duyệt" cứng nằm ở **FE**, sau khi nhận data:
```ts
// :182-183
const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
```
`[SUY LUẬN]` ⇒ màn `Đang duyệt` giữ nguyên 100%, chỉ đổi nhãn menu. Không đụng BE.
### B-2 `[CODE]` `Đã duyệt` = **ĐÃ CHẠY ĐƯỢC HÔM NAY**, 0 dòng BE, 0 route mới
Tham số `phase=` đã tồn tại đủ 3 tầng:
| Tầng | file:line |
|---|---|
| Controller | `PurchaseEvaluationsController.cs:23` `[FromQuery] PurchaseEvaluationPhase? phase = null` |
| Query record | `PurchaseEvaluationFeatures.cs:565` `PurchaseEvaluationPhase? Phase = null` |
| Áp filter | `PurchaseEvaluationFeatures.cs:620` `if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase)` |
| FE đọc URL | `PurchaseEvaluationsListPage.tsx:39` `sp.get('phase')``:75` truyền xuống API |
| FE đã có dropdown | `:389-403` — option "Đã duyệt" map `String(PurchaseEvaluationPhase.DaDuyet)` (`:395`) |
`DaDuyet = 7` (`PurchaseEvaluationPhase.cs:23`).
**URL `/purchase-evaluations?type=1&phase=7` chạy ngay.** Việc phải làm = 1 key menu + 1 nhánh `resolvePath` (×2 app) + 1 dòng seed. **Ô #5 bảng A-3 = 0 chỗ** (không route mới, không page mới).
⚠️ 1 khác biệt UX: nhánh `phase` đi đường `list` (có phân trang `pageSize:50`, `readOnly={!pendingMe}` `:590`/`:676`**detail read-only**) — khác nhánh inbox (không phân trang).
### B-3 `[CODE]` Xác minh 2 con số bằng lệnh của chính tôi
```bash
grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs | wc -l → 0
grep -rn "b\.HasQueryFilter(" src/Backend --include=*.cs | wc -l → 12 (20 hit thô, 8 là comment)
grep -rn "IgnoreQueryFilters" tests --include=*.cs | wc -l → 15 ← BẤT NGỜ, xem B-4
```
✅ Cả 2 con số sub BE báo đều **ĐÚNG**: `IgnoreQueryFilters` = **0 hit toàn `src/Backend`**; PE global filter ở `PurchaseEvaluationConfiguration.cs:84`.
### B-4 `[CODE]` 🎁 Đã có **15 tiền lệ `IgnoreQueryFilters`** — nhưng ở **tests**, không phải production
`tests/.../Application/MasterCatalogFilteredUniqueTests.cs:71,102,134` · `PeWorkItemBudgetTests.cs:260` · `Services/SupplierExcelImportServiceTests.cs:78,174` · `Services/SupplierImportV2DedupTests.cs` (×9).
Khuôn dùng: `await db.Suppliers.IgnoreQueryFilters().CountAsync()`*"không có bản ghi phantom nào kể cả đã soft-delete"* (`SupplierExcelImportServiceTests.cs:78`).
`[SUY LUẬN]` Đội đã quen cú pháp này; cái mới **chỉ là lần đầu ở tầng production**, không phải kỹ thuật lạ. Test cho màn "Đã xóa" có khuôn assert sẵn để bắt chước.
### B-5 `[CODE]` 🔴 Chỗ HẸP NHẤT + ranh giới an toàn (câu quan trọng nhất của Q-B)
**Vì sao an toàn về mặt cấu trúc:** `IgnoreQueryFilters()` là toán tử **trên MỘT `IQueryable`**, không phải cờ trên `DbContext`. Cả 2 hàm accumulator đều dựng `peers` từ `db.PurchaseEvaluations.AsNoTracking()` **mới tinh** mỗi lần gọi:
- `PeBudgetAccumulator.cs:42-44` (`ComputeAsync`) — `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
- `PeBudgetAccumulator.cs:100-102` (`ComputePendingAsync`) — y hệt, `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
**Không có đường rò** từ 1 query list sang accumulator, **trừ khi** vi phạm 1 trong 3 điều sau. Đây là ranh giới cần ghi vào checklist:
| 🔴 CẤM | Vì sao |
|---|---|
| Đặt `IgnoreQueryFilters()` trong **helper dùng chung** trả `IQueryable<PurchaseEvaluation>` mà accumulator/list cũng gọi | rò gián tiếp — biến 1 dòng thành N call-site |
| Gỡ / sửa `HasQueryFilter``PurchaseEvaluationConfiguration.cs:84` | phá toàn hệ, đúng thứ đang cho owner "xóa là hết ăn lũy kế" |
| Bật `IgnoreQueryFilters` ở query **detail** rồi tái dùng cho snapshot/finalize | `PeBudgetAccumulator` được gọi từ cả display lẫn `ApplyBudgetSnapshotOnFinalizeAsync` (comment `PeBudgetAccumulator.cs:10-12`) — chung đường |
**Đề xuất hẹp nhất `[SUY LUẬN]`:** **query/handler RIÊNG** (vd `ListDeletedPurchaseEvaluationsQuery`) + endpoint riêng `GET /api/purchase-evaluations/deleted`, `IgnoreQueryFilters()` đặt **đúng 1 lần trong thân handler đó**. KHÔNG thêm cờ `includeDeleted` vào `ListPurchaseEvaluationsQuery` — vì handler đó đã có 4 filter + IDOR block (`:596-617`) + subquery projection (`:656-685`); nhét cờ vào = mọi call-site cũ đều thành đường có-thể-rò và phải review lại.
### B-6 `[CODE]` **KHÔNG có tiền lệ "thùng rác" nào trong repo** — nói thẳng
Lệnh đã chạy:
```bash
grep -rniE "Thùng rác|thung-rac|deleted=|showDeleted|includeDeleted" fe-user/src fe-admin/src → 0 màn thùng rác
grep -rniE "IsDeleted\s*=\s*false" src/Backend --include=*.cs | grep -v Migrations → 0 dòng
grep -rniE "Restore|Undelete|Khôi phục" src/Backend --include=*.cs → 1 hit DUY NHẤT, là chuỗi thông báo
```
Hit duy nhất: `EmployeeFeatures.cs:162``$"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."`**câu chữ có, chức năng KHÔNG có** (ghost-wire loại "message nói về thứ chưa tồn tại"). Màn "Đã xóa" sẽ là **màn đầu tiên** loại này trong toàn hệ.
### B-7 `[CODE]` Restore: **0 cơ chế sẵn** — chỉ khảo sát, không chốt
Không endpoint nào set `IsDeleted=false` (lệnh B-6). Chiều ngược lại — `AuditingInterceptor.cs:56-62` chỉ chuyển `Deleted→Modified`, **không có nhánh un-delete**.
Dữ kiện để owner quyết `[SUY LUẬN]`:
- Restore = 1 `PATCH` set 3 field (`IsDeleted=false`, `DeletedAt=null`, `DeletedBy=null`) — **rẻ về code**;
- nhưng phiếu restore **quay lại ăn lũy kế ngay** (accumulator không lọc gì thêm ngoài query filter) ⇒ đúng thứ owner muốn tránh sẽ quay lại. Nếu owner muốn restore thì phải nói rõ phiếu restore về **phase nào** (giữ `ChoDuyet` = ăn lũy kế lại · ép về `DangSoanThao` = không ăn theo `ComputePendingAsync:105` loại `DangSoanThao`).
- ⚠️ `Suppliers`/`Projects`/`Departments` có UNIQUE **filtered `[IsDeleted]=0`** (Mig 47) — restore ở các bảng đó có thể đụng key trùng. **PE thì KHÔNG** (không có unique filtered trên `PurchaseEvaluations`) ⇒ riêng PE restore không vướng index.
### B-8 `[CODE]` 🎁 Vết xóa **KHÔNG bị chôn** — changelog sống sót, không cần `IgnoreQueryFilters`
`PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) — **BaseEntity, KHÔNG phải AuditableEntity****không có `IsDeleted`****không có query filter** (12 `HasQueryFilter` không có cái nào cho bảng này).
Query lịch sử lọc **chỉ theo Id**, không join PE:
```csharp
// PurchaseEvaluationFeatures.cs:1424-1425
return await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
```
`ListPurchaseEvaluationChangelogsQuery` **chạy được nguyên vẹn cho phiếu đã xóa**, 0 sửa đổi.
🔴 Cái BỊ chôn là **chỗ khác**: `GetPurchaseEvaluationQuery` (detail bundle) load PE root ⇒ dính filter ⇒ **404**. Nên: mở lại lịch sử = miễn phí; **mở lại DETAIL = phải `IgnoreQueryFilters` chỗ thứ 2**. Owner cần chốt: màn "Đã xóa" chỉ LIST (rẻ, 1 chỗ) hay LIST + mở được phiếu (2 chỗ).
---
## Q-C — "NGƯỜI ĐỨNG ĐẦU PHÒNG" map vào đâu?
### C-1 `[CODE]` 🎯 **CÓ TỒN TẠI** — `Department.ManagerUserId`, chú thích đúng chữ "Trưởng Phòng ban"
```csharp
// src/Backend/SolutionErp.Domain/Master/Department.cs:9
public Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
```
⇒ Giả thuyết **(b) ĐÚNG về schema**. Không cần migration để có field.
### C-2 `[CODE]` 🔴 NHƯNG field đó **RỖNG + bị FE chủ động ghi `null`** — đây là cái bẫy
```
sqlcmd -d SolutionErp_Dev -Q "SELECT COUNT(*), SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) FROM Departments WHERE IsDeleted=0"
→ TotalDept=10 · HasManager=0 (0/10)
```
Tệ hơn: **cả 2 app hardcode `managerUserId: null` trong payload SỬA**, nên mỗi lần ai đó sửa phòng ban là **xóa sạch** giá trị vừa nhập:
- `fe-user/src/pages/master/DepartmentsPage.tsx:43``const payload = { id, code, name, managerUserId: null, note }`
- `fe-admin/src/pages/master/DepartmentsPage.tsx:57``managerUserId: null,`
BE thì ghi thẳng, không bảo vệ: `DepartmentFeatures.cs:217` `entity.ManagerUserId = request.ManagerUserId;`
⇒ Dùng đường (b) thì **bắt buộc** vá 2 file FE trước, nếu không tính năng sẽ "tự tắt" sau lần sửa phòng ban đầu tiên. `[CHƯA XÁC MINH]` prod (SSH chết khi nạp SQL-client, S134/S148) — nhưng vì FE null-hoá nên khả năng prod cũng rỗng là cao.
📌 0 read-site nào dùng `ManagerUserId` để phân quyền — grep toàn BE chỉ ra 5 hit, **tất cả là DTO passthrough** (`DepartmentFeatures.cs:15,126,140,144,164,173,217`).
### C-3 `[CODE]` Giả thuyết **(a) SAI** — `Level.Order` là *trình tự*, không phải *thứ bậc*
```csharp
// ApprovalWorkflow.cs:81-88
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. ... Sequential trong cùng bước: cấp 1 → cấp 2 → ...
public int Order { get; set; } // 1-based trong cùng step
```
`Order` 1-based tăng dần = **ai duyệt trước/sau**, không mang nghĩa chức vụ. Nhiều row cùng `Order` = cùng 1 Cấp (đúng ảnh: Cấp 2 có 3 người). ⇒ "Cấp cao nhất trong Bước" **không phải khái niệm có sẵn**; muốn dùng phải TỰ định nghĩa `MAX(Order)` = trưởng phòng — `[SUY LUẬN]` đây là **suy diễn**, schema không hứa điều đó (một Bước có thể cấu hình ngược).
Còn "Kết thúc tại Cấp 2" trong ảnh = cờ **`AllowApproverFinalize`** (`ApprovalWorkflow.cs:142`, F5 Mig 58) — là **quyền per-slot do admin tick**, KHÔNG phải dấu hiệu chức vụ.
### C-4 `[CODE]` Giả thuyết **(c): 2 ứng viên, 1 có data 1 không**
| Ứng viên | file:line | Data Dev |
|---|---|---|
| Role `DeptManager` | `AppRoles.cs:8` | **8 user** ✅ |
| `PositionLevel.TruongPhong = 3` | `PositionLevel.cs:14` (chữ "TruongPhong" đúng nghĩa nhất) | **0 user — 33/33 NULL** ❌ |
```
sqlcmd -d SolutionErp_Dev -Q "SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel" → NULL|33
sqlcmd -d SolutionErp_Dev -Q "SELECT r.Name, COUNT(ur.UserId) FROM Roles r LEFT JOIN UserRoles ur ... GROUP BY r.Name"
→ Drafter|10 · CostControl|8 · DeptManager|8 · Procurement|6 · ProjectManager|2 · Director|2 · ...
```
⚠️ `PositionLevel` là di sản **V1** (Mig 18/21): mọi read-site còn lại đều thuộc workflow V1 (`PeWorkflowAdminFeatures.cs:187,223,264` · `WorkflowAdminFeatures.cs:194,223,271`) + CRUD user (`UsersController.cs:81`). **`ApproveV2Async` không đọc nó.**
⚠️ Role `DeptManager` **không nói PHÒNG NÀO** — phòng của user nằm ở `User.DepartmentId` (Dev: **31/33 có**). Nên (c) chỉ dùng được khi ghép `role DeptManager ∧ User.DepartmentId == Step.DepartmentId`. Đó là "một trưởng phòng nào đó của phòng này", không phải "ĐÚNG người đứng đầu".
### C-5 `[CODE]` Mảnh ghép "đến lượt họ" thì đã có sẵn và ĐÁNG TIN
- `Step.DepartmentId` (`ApprovalWorkflow.cs:75`) — Dev **6/6 step có DepartmentId** (`SELECT COUNT(*), SUM(CASE WHEN DepartmentId IS NOT NULL...) FROM ApprovalWorkflowSteps``6|6`).
- Con trỏ hiện tại = `PE.CurrentWorkflowStepIndex` + `PE.CurrentApprovalLevelOrder`; sub FE đã đo `blockedByV2Level` (`PeWorkflowPanel.tsx:99-103`) = `isAdmin ∥ actor ∈ currentApproval.approvers`.
⇒ Vế "đến lượt" **không phải làm mới**. Chỉ vế "người đứng đầu phòng" là thiếu.
### C-6 `[SUY LUẬN]` KẾT LUẬN Q-C + 3 chỗ thêm rẻ nhất (owner chọn, tôi không chốt)
**Kết luận:** khái niệm "người đứng đầu phòng" **có tên trong schema nhưng chưa có ĐỜI SỐNG**. `Department.ManagerUserId` là chỗ duy nhất mang đúng nghĩa — 0/10 dữ liệu, 0 read-site, và bị 2 file FE ghi đè `null`. Không có cờ nào trên `ApprovalWorkflowLevel` nói "người này là trưởng phòng". ⇒ **Viết spec được, nhưng phải chọn 1 trong 3 và trả giá tương ứng:**
| # | Cách | Migration? | Giá phải trả |
|---|---|---|---|
| **1** | Dùng `Department.ManagerUserId`, so với `Step.DepartmentId` của bước đang chờ | **KHÔNG** | phải vá 2 FE null-hoá (`DepartmentsPage.tsx:43` / `:57`) + thêm ô chọn TP + **nhập tay 10 phòng** trên prod; chưa nhập = nút không hiện với ai (fail-closed, an toàn) |
| **2** | Thêm cờ per-slot `AllowApproverDelete` trên `ApprovalWorkflowLevel` | **CÓ** (1 AddColumn, no new table) | đắt hơn 1 migration nhưng **khớp khuôn đã lặp 4× ở đúng bảng này** (F2 Mig 31 · F3 Mig 29 · F4 Mig 30 · F5 Mig 58 — `ApprovalWorkflow.cs:104-142`); admin tick trong Designer, 0 phụ thuộc data master, đúng chỗ "đến lượt" đã có sẵn |
| **3** | Role `DeptManager``User.DepartmentId == Step.DepartmentId` | **KHÔNG** | có data ngay (8 user) nhưng **ngữ nghĩa lỏng**: 8 người mang role trên 10 phòng ⇒ có phòng nhiều "trưởng", có phòng không ai. Không phải "người đứng đầu" theo nghĩa owner nói |
`[CHƯA XÁC MINH]` — số liệu C-2/C-4 là **Dev DB**. Prod chưa đo được (SSH `vietreport-vps` đứt phiên mỗi khi nạp SQL-client, tái xác nhận S134/S148). Lệnh để implementer chạy trên prod:
```sql
SELECT COUNT(*) Total, SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) HasManager FROM Departments WHERE IsDeleted=0;
SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel;
```
<!-- END sub-invest-menu-3 · TOTAL=21 PHAT-HIEN -->