257 lines
21 KiB
Markdown
257 lines
21 KiB
Markdown
# 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 -->
|
||
|