21 KiB
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:
- Entity + bảng thật:
src/Backend/SolutionErp.Domain/Identity/MenuItem.cs— bảngMenuItems(Key PK, Label, ParentKey, Order, Icon, +IsVisible/DisplayLabeltừ Mig 27). - Nơi khai 4 mục con của
Pe_<Code>:src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:1867-1878
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.
- FE render từ API, KHÔNG hard-code danh sách:
fe-user/src/components/Layout.tsxrenderAuthContext.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— gompeKeys(Group/WfView/List/Create/Pending)DbInitializer.cs:2492-2496— gommenuKeys(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:
// 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):
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:
// :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
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:
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
PATCHset 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 theoComputePendingAsync:105loạiDangSoanThao). - ⚠️
Suppliers/Projects/Departmentscó 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ênPurchaseEvaluations) ⇒ 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:
// 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"
// 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:217entity.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ùngManagerUserIdđể 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
// 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 đã đoblockedByV2Level(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:
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;