# 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` là **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 -->

