[CLAUDE] Contract: K4a+K4b SP-002 sidebar 8 nhom x 6 leaf + noi ?group= end-to-end (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m23s

K4a (gate PASS-WITH-FLAGS 4, sub-reviewer-gate-k4a.md): 49 key Khkk_G{2-8} bo-sinh
by-construction 3-phia (All/seed/grant) MenuKeys.cs + DbInitializer 49 row + labelBackfill
G1 nhan A1 + site-2 Concat + va 7/10/11/13/15 (nhan N2 co "pha do"; comment 1893 -> mo-neo
grep). All 64->113, Policies 256->452 (test T2 khoa runtime). Test +7 KhkkGroupMenuSeedTests
(T7 revoker-khong-dung-Khkk: probe vi-phan 112->0, gotcha #84).

K4b (gate PASS-WITH-FLAGS 4M/5m 0-chan, sub-reviewer-gate-k4b.md): KhkkListPage x2 derive
phase+group tu searchParams (leaf->leaf khong F5, navKey reset) + queryKey {group,phase,
search,page,deletedView} + KhkkCreatePage preset ?group= + staticMap G1 6-leaf group=1 +
regex Khkk_G([1-8])_* fe-user (fe-admin GHI RANG BUOC isAdminHidden) + FLAG-1 lead-fix:
GET /deleted +group mirror List (Controller+Query+handler SAU-IDOR) + FE noi 2-nhanh.

2 wave 1 commit CO KHAI: Layout.tsx dinh ca 2 wave khong tach hunk phi-tuong-tac; moi dong
deu qua gate rieng. STATUS canonical 5-row dong bo (71/97/452/113/620->621) + bundle :479.
No K4c: MAJOR-A test /deleted+group filter + MAJOR-C WfView doc group. Suite 621/0
(45D+576I) + filter 41/41 + npm x2 + dotnet 0E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 10:10:11 +07:00
parent bc1e7c937d
commit 88e7ecf263
14 changed files with 880 additions and 63 deletions

File diff suppressed because one or more lines are too long

View File

@ -32,12 +32,17 @@ function resolvePath(key: string): string | null {
// [W2 KHKK S161] GĐ2 hết skeleton — re-point trang thật (gotcha #50: thiếu route = // [W2 KHKK S161] GĐ2 hết skeleton — re-point trang thật (gotcha #50: thiếu route =
// sidebar drop SILENT). WfView phía admin trỏ Designer type-10 (fe-admin CỐ Ý không có // sidebar drop SILENT). WfView phía admin trỏ Designer type-10 (fe-admin CỐ Ý không có
// matrix page — chốt 2026-07-27; admin "Luồng duyệt" = dựng/sửa quy trình). // matrix page — chốt 2026-07-27; admin "Luồng duyệt" = dựng/sửa quy trình).
// [K4b S166 — spec K4b-7] 5 leaf trỏ `/khkk/*` mang `group=1` tường minh, mirror
// fe-user (trang coi "vắng group" = xem tất cả, nên thiếu ở đây là nhóm 1 nói dối).
// 🔴 `Khkk_WfView` CỐ Ý KHÔNG mang `group`: bên admin nó trỏ Designer quy trình,
// không phải trang danh sách — Designer không đọc tham số này (fe-user mới đi
// `/khkk/workflow-matrix?type=10&group=1`). Lệch có chủ đích, không mirror mù.
Khkk_WfView: '/system/approval-workflows-v2/ContractSigningPlan', Khkk_WfView: '/system/approval-workflows-v2/ContractSigningPlan',
Khkk_List: '/khkk/list', Khkk_List: '/khkk/list?group=1',
Khkk_Create: '/khkk/create', Khkk_Create: '/khkk/create?group=1',
Khkk_Pending: '/khkk/list?filter=ChoDuyet', Khkk_Pending: '/khkk/list?group=1&filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet', Khkk_Approved: '/khkk/list?group=1&filter=DaDuyet',
Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50) Khkk_Deleted: '/khkk/list?group=1&view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50)
// [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật
// `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên). // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên).
// gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar. // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar.
@ -191,6 +196,19 @@ function resolvePath(key: string): string | null {
// [S57] BỎ ẩn "Danh mục" (Master/Catalogs + Cấu hình HRM re-parent vào đây) — gom // [S57] BỎ ẩn "Danh mục" (Master/Catalogs + Cấu hình HRM re-parent vào đây) — gom
// master data về 1 chỗ cho CẢ admin lẫn nhân viên (đảo S29 hide). Admin nay quản // master data về 1 chỗ cho CẢ admin lẫn nhân viên (đảo S29 hide). Admin nay quản
// master trực tiếp trên fe-admin; write vẫn khóa Admin/CatalogManager ở BE controller. // master trực tiếp trên fe-admin; write vẫn khóa Admin/CatalogManager ở BE controller.
// 🔴 [K4a — S166, vá-8] RÀNG BUỘC NGƯỢC với `resolvePath` bên trên — đọc trước khi
// sửa bộ lọc này. fe-user có nhánh regex `^Khkk_G([1-8])_(WfView|List|Create|Pending|
// Approved|Deleted)$`; fe-admin CỐ Ý KHÔNG có (admin không có `WorkflowMatrixViewPage`
// — lead chốt 2026-07-27, giữ lệch). Hệ quả: 42 leaf `Khkk_G{2..8}_*` mà BE seed sẽ
// rơi vào `resolvePath → null` ⇒ MenuLeaf trả null ⇒ DROP IM LẶNG (gotcha #50).
// ⚠️ ĐO ĐĨA 2026-08-01: hàm này CHỈ ẩn `Ct_*` — `KeHoachKyKet` KHÔNG hề nằm trong tập
// ẩn, tức ràng buộc trên KHÔNG phải chuyện tương lai mà ĐANG hiệu lực: sidebar admin
// hiện root KHKK, 7 nhóm con, và 42 leaf của nhóm 2..8 im lặng biến mất (nhóm 1 sống
// nhờ 6 dòng `Khkk_*` không-infix trong `staticMap`). Đây là trạng thái ĐÃ KHAI, chấp
// nhận có chủ đích ở lượt K4a — KHÔNG phải bug mới phát sinh.
// ⇒ Ai muốn admin thấy đủ 8 nhóm (hoặc ngược lại, thêm `KeHoachKyKet` vào tập ẩn rồi
// sau đó gỡ ra) thì PHẢI nới regex `Khkk_G*` trong `resolvePath` TRƯỚC — và nới thì
// phải quyết luôn `WfView` bên admin trỏ đâu, vì Designer không nhận `?group=`.
function isAdminHidden(key: string): boolean { function isAdminHidden(key: string): boolean {
return key.startsWith('Ct_') return key.startsWith('Ct_')
} }

View File

@ -14,7 +14,7 @@
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". // không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react' import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
@ -57,11 +57,31 @@ function firstFreeGroup(taken: number[]): number | null {
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
const groupParam = searchParams.get('group')
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT) const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route
// `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng
// tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup)
}
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409). // ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`). // [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).

View File

@ -3,13 +3,20 @@
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với // KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart. // fe-admin counterpart.
// //
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): // URL vào từ menu (Layout.tsx staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
// Khkk_List → /khkk/list // 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
// Khkk_Pending → /khkk/list?filter=ChoDuyet // Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Approved → /khkk/list?filter=DaDuyet // Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Deleted → /khkk/list?view=deleted // Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng // 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc). // nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
//
// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
@ -29,6 +36,7 @@ import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPhaseValue, type KhkkPhaseValue,
type PagedResult, type PagedResult,
@ -58,17 +66,50 @@ function formatDate(iso: string): string {
export function KhkkListPage() { export function KhkkListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const initialFilter = searchParams.get('filter')
const deletedView = searchParams.get('view') === 'deleted'
const [phase, setPhase] = useState<KhkkPhaseValue | null>( // ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null, // Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
) // query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const list = useQuery({ const list = useQuery({
queryKey: ['khkk-list', { phase, search, page, deletedView }], // 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
// cache cho hai request y hệt nhau.
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
queryFn: async () => { queryFn: async () => {
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng // Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155). // `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
@ -76,6 +117,11 @@ export function KhkkListPage() {
return (await api.get<PagedResult<KhkkListItemDto>>(url, { return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: { params: {
phase: deletedView ? undefined : (phase ?? undefined), phase: deletedView ? undefined : (phase ?? undefined),
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
group: group ?? undefined,
search: search.trim() || undefined, search: search.trim() || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@ -115,17 +161,29 @@ export function KhkkListPage() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Giai đoạn 2" eyebrow="Giai đoạn 2"
title={deletedView ? 'Kế hoạch ký kết HĐ — đã xóa' : 'Kế hoạch ký kết HĐ'} title={
subtitle={
deletedView deletedView
? 'Phiếu đã xóa mềm — chỉ xem danh sách' ? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null
? `Kế hoạch ký kết HĐ — N${group}`
: 'Kế hoạch ký kết HĐ'
}
subtitle={
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
deletedView
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
: group !== null
? khkkGroupMenuLabel(group)
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng' : 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
} }
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />} icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
accent="brand" accent="brand"
actions={ actions={
deletedView ? undefined : ( deletedView ? undefined : (
<Button onClick={() => navigate('/khkk/create')}> // Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Lập kế hoạch Lập kế hoạch
</Button> </Button>
@ -151,7 +209,9 @@ export function KhkkListPage() {
accent={c.accent} accent={c.accent}
active={phase === c.value} active={phase === c.value}
onClick={() => { onClick={() => {
setPhase(c.value) // Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
setChipPhase(c.value)
setPage(1) setPage(1)
}} }}
/> />

View File

@ -126,6 +126,12 @@ export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu // đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page. // ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}"). // Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
//
// [K4a S166 — acceptance-5 single-source] ĐÃ ĐỐI CHIẾU TỪNG KÝ TỰ với nhãn BE seed
// (`DbInitializer.cs:1960` cho nhóm 1 + dict `khkkGroupLabels` `:2127-2136` cho nhóm
// 2..8, cùng `labelBackfill:2235`): 8/8 nhóm khớp theo quan hệ `"{n}. " + chuỗi dưới đây`.
// ⇒ K4a KHÔNG đẻ map thứ hai. Bản đầy đủ (có số thứ tự) lấy qua `khkkGroupMenuLabel(n)`
// ngay bên dưới — DERIVE chứ không chép, để sửa chữ vẫn chỉ đụng đúng 1 chỗ này.
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = { export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
1: 'Thiết bị, máy móc, vật tư phụ (A1)', 1: 'Thiết bị, máy móc, vật tư phụ (A1)',
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)', 2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
@ -137,6 +143,25 @@ export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
8: 'HĐ chưa ủy quyền — CEO (5.2)', 8: 'HĐ chưa ủy quyền — CEO (5.2)',
} }
// [K4a S166] Nhãn nhóm ĐẦY ĐỦ = đúng chuỗi BE ghi vào `MenuItems.Label` khi seed.
// 🔴 ĐÂY KHÔNG PHẢI NGUỒN CỦA SIDEBAR. Sidebar render nhãn từ payload `/api/menus/me`
// (bảng `MenuItems`, admin còn đổi được qua `displayLabel` — Mig 27), nên đừng ai
// tưởng sửa chuỗi ở đây thì sidebar đổi theo. Hàm này phục vụ chỗ FE tự vẽ tên nhóm
// mà không đi qua menu: cây 8 thư mục nhóm (K5), tiêu đề trang khi vào từ leaf nhóm,
// màn xem luồng duyệt theo nhóm (WfView).
// ⚠️ Ràng buộc ngầm: BE đang đặt nhãn theo khuôn "{n}. {tên}". Nếu BE bỏ tiền tố số thì
// hàm này lệch một cách CÂM (không đỏ build) — lúc đó sửa chính hàm này, đừng vá page.
export function khkkGroupMenuLabel(n: number): string {
const label = KHKK_APPROVAL_GROUP_LABELS[n]
return label ? `${n}. ${label}` : `Nhóm ${n}`
}
// [K4a S166 — O-2] Mã quy trình duyệt của nhóm n. BE khớp bằng regex `^KHKK-N([1-8])$`
// (`ContractSigningPlanFeatures.cs:420`, rào 409 "phiếu nhóm 3 pin quy trình nhóm 5");
// 8 workflow do `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed, ApplicableType=10.
// Gom về 1 hàm để FE không rải chuỗi `KHKK-N` ở nhiều nơi rồi lệch nhau.
export const khkkGroupWorkflowCode = (n: number) => `KHKK-N${n}`
export interface KhkkListItemDto { export interface KhkkListItemDto {
id: string id: string
maKeHoach: string | null maKeHoach: string | null

View File

@ -56,10 +56,14 @@ function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = { const staticMap: Record<string, string> = {
// [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50: // [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50:
// thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE). // thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE).
Khkk_List: '/khkk/list', // [K4b S166 — spec K4b-7] 6 leaf nhóm 1 mang `group=1` TƯỜNG MINH. Chúng là 6 key
Khkk_Create: '/khkk/create', // cũ KHÔNG-infix (S159, cấm đổi tên) nên không đi qua regex `Khkk_G{n}_*` bên dưới;
Khkk_Pending: '/khkk/list?filter=ChoDuyet', // thiếu `group=1` ở đây thì "Danh sách" của nhóm 1 lại hiện phiếu của cả 8 nhóm —
Khkk_Approved: '/khkk/list?filter=DaDuyet', // đúng một mục nói dối giữa 8 mục nói thật. Trang coi "vắng group" = xem tất cả.
Khkk_List: '/khkk/list?group=1',
Khkk_Create: '/khkk/create?group=1',
Khkk_Pending: '/khkk/list?group=1&filter=ChoDuyet',
Khkk_Approved: '/khkk/list?group=1&filter=DaDuyet',
// [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật
// `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên). // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên).
// gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar. // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar.
@ -79,8 +83,8 @@ function resolvePath(key: string): string | null {
KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu
HopDongCung: '/dashboard', HopDongCung: '/dashboard',
// [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới) // [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới)
Khkk_WfView: '/khkk/workflow-matrix?type=10', Khkk_WfView: '/khkk/workflow-matrix?type=10&group=1',
Khkk_Deleted: '/khkk/list?view=deleted', Khkk_Deleted: '/khkk/list?group=1&view=deleted',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ // [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không // fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent. // MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
@ -181,6 +185,41 @@ function resolvePath(key: string): string | null {
if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved` if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved`
if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1` if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1`
} }
// ===================== [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT ======================
// `Khkk_G{n}_{action}` — 42 leaf của nhóm 2..8 (BE `MenuKeys.KhkkLeaf` :77, sinh từ
// `KhkkGroupNumbers = [2..8]` × `KhkkLeafActions` 6 hành động). Đây là "chỗ thứ 4"
// Pattern 16-bis: BE seed 42 row mà thiếu nhánh này thì `resolvePath` trả null ⇒
// MenuLeaf nhánh `if (!path) return null` (grep chuỗi đó — số dòng trượt theo edit) ⇒ 42 leaf DROP IM LẶNG (gotcha #50).
//
// 🔴 NHÓM 1 KHÔNG đi qua đây: 7 key cũ KHÔNG-infix (`Khkk_G1` + `Khkk_WfView` …
// `Khkk_Deleted`, seed S159) nằm trong `staticMap` bên trên và CẤM đổi tên
// (đổi = mồ côi permission-row đã cấp — bài S155). Vì thế regex dưới về mặt dữ
// liệu chỉ ăn n = 2..8; để dải `[1-8]` theo spec là CỐ Ý — rộng hơn tập seed một
// nấc, vô hại hôm nay và đúng sẵn nếu sau này nhóm 1 chuyển sang khuôn infix.
//
// 🔴 `?group=n` là DANH TÍNH điều hướng, không phải bộ lọc tuỳ hứng: nó KHÔNG nằm
// trong `TRANSIENT_QUERY_KEYS` (grep tên hằng — đừng neo số dòng) nên 6 leaf cùng nhóm và cùng leaf khác nhóm
// đều phân biệt được khi so khớp active (bài `view=approved` S155 ngay khối trên).
// Tên tham số `filter` / `view` / `group` lấy ĐÚNG từ `KhkkListPage` (grep
// `get('filter')` / `get('view')` / `get('group')` trong file đó — đừng neo số dòng,
// chính wave K4b đã làm số cũ trượt) — sai một chữ ở đây là leaf trỏ tới trang mở
// được nhưng lọc SAI, hỏng câm chứ không đỏ build.
//
// 🔴 fe-admin CỐ Ý KHÔNG nới regex này (lead chốt 2026-07-27, giữ lệch): fe-admin
// không có `WorkflowMatrixViewPage` (`App.tsx` bên đó chỉ có /khkk/list|create|:id)
// nên `WfView` bên admin sẽ là link chết; "Luồng duyệt" phía admin trỏ Designer.
// Khai lệch có chủ đích — xem ghi chú ràng buộc ngược tại `isAdminHidden` fe-admin.
const khkkMatch = key.match(/^Khkk_G([1-8])_(WfView|List|Create|Pending|Approved|Deleted)$/)
if (khkkMatch) {
const [, group, action] = khkkMatch
if (action === 'WfView') return `/khkk/workflow-matrix?type=10&group=${group}`
if (action === 'List') return `/khkk/list?group=${group}`
if (action === 'Create') return `/khkk/create?group=${group}`
if (action === 'Pending') return `/khkk/list?group=${group}&filter=ChoDuyet`
if (action === 'Approved') return `/khkk/list?group=${group}&filter=DaDuyet`
if (action === 'Deleted') return `/khkk/list?group=${group}&view=deleted`
}
return null return null
} }

View File

@ -14,7 +14,7 @@
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". // không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react' import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
@ -57,11 +57,31 @@ function firstFreeGroup(taken: number[]): number | null {
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
const groupParam = searchParams.get('group')
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT) const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route
// `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng
// tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup)
}
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409). // ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`). // [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).

View File

@ -3,13 +3,20 @@
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với // KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart. // fe-admin counterpart.
// //
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): // URL vào từ menu (Layout.tsx staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
// Khkk_List → /khkk/list // 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
// Khkk_Pending → /khkk/list?filter=ChoDuyet // Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Approved → /khkk/list?filter=DaDuyet // Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Deleted → /khkk/list?view=deleted // Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng // 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc). // nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
//
// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
@ -29,6 +36,7 @@ import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPhaseValue, type KhkkPhaseValue,
type PagedResult, type PagedResult,
@ -58,17 +66,50 @@ function formatDate(iso: string): string {
export function KhkkListPage() { export function KhkkListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const initialFilter = searchParams.get('filter')
const deletedView = searchParams.get('view') === 'deleted'
const [phase, setPhase] = useState<KhkkPhaseValue | null>( // ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null, // Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
) // query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const list = useQuery({ const list = useQuery({
queryKey: ['khkk-list', { phase, search, page, deletedView }], // 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
// cache cho hai request y hệt nhau.
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
queryFn: async () => { queryFn: async () => {
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng // Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155). // `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
@ -76,6 +117,11 @@ export function KhkkListPage() {
return (await api.get<PagedResult<KhkkListItemDto>>(url, { return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: { params: {
phase: deletedView ? undefined : (phase ?? undefined), phase: deletedView ? undefined : (phase ?? undefined),
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
group: group ?? undefined,
search: search.trim() || undefined, search: search.trim() || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@ -115,17 +161,29 @@ export function KhkkListPage() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Giai đoạn 2" eyebrow="Giai đoạn 2"
title={deletedView ? 'Kế hoạch ký kết HĐ — đã xóa' : 'Kế hoạch ký kết HĐ'} title={
subtitle={
deletedView deletedView
? 'Phiếu đã xóa mềm — chỉ xem danh sách' ? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null
? `Kế hoạch ký kết HĐ — N${group}`
: 'Kế hoạch ký kết HĐ'
}
subtitle={
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
deletedView
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
: group !== null
? khkkGroupMenuLabel(group)
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng' : 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
} }
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />} icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
accent="brand" accent="brand"
actions={ actions={
deletedView ? undefined : ( deletedView ? undefined : (
<Button onClick={() => navigate('/khkk/create')}> // Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Lập kế hoạch Lập kế hoạch
</Button> </Button>
@ -151,7 +209,9 @@ export function KhkkListPage() {
accent={c.accent} accent={c.accent}
active={phase === c.value} active={phase === c.value}
onClick={() => { onClick={() => {
setPhase(c.value) // Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
setChipPhase(c.value)
setPage(1) setPage(1)
}} }}
/> />

View File

@ -126,6 +126,12 @@ export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu // đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page. // ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}"). // Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
//
// [K4a S166 — acceptance-5 single-source] ĐÃ ĐỐI CHIẾU TỪNG KÝ TỰ với nhãn BE seed
// (`DbInitializer.cs:1960` cho nhóm 1 + dict `khkkGroupLabels` `:2127-2136` cho nhóm
// 2..8, cùng `labelBackfill:2235`): 8/8 nhóm khớp theo quan hệ `"{n}. " + chuỗi dưới đây`.
// ⇒ K4a KHÔNG đẻ map thứ hai. Bản đầy đủ (có số thứ tự) lấy qua `khkkGroupMenuLabel(n)`
// ngay bên dưới — DERIVE chứ không chép, để sửa chữ vẫn chỉ đụng đúng 1 chỗ này.
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = { export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
1: 'Thiết bị, máy móc, vật tư phụ (A1)', 1: 'Thiết bị, máy móc, vật tư phụ (A1)',
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)', 2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
@ -137,6 +143,25 @@ export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
8: 'HĐ chưa ủy quyền — CEO (5.2)', 8: 'HĐ chưa ủy quyền — CEO (5.2)',
} }
// [K4a S166] Nhãn nhóm ĐẦY ĐỦ = đúng chuỗi BE ghi vào `MenuItems.Label` khi seed.
// 🔴 ĐÂY KHÔNG PHẢI NGUỒN CỦA SIDEBAR. Sidebar render nhãn từ payload `/api/menus/me`
// (bảng `MenuItems`, admin còn đổi được qua `displayLabel` — Mig 27), nên đừng ai
// tưởng sửa chuỗi ở đây thì sidebar đổi theo. Hàm này phục vụ chỗ FE tự vẽ tên nhóm
// mà không đi qua menu: cây 8 thư mục nhóm (K5), tiêu đề trang khi vào từ leaf nhóm,
// màn xem luồng duyệt theo nhóm (WfView).
// ⚠️ Ràng buộc ngầm: BE đang đặt nhãn theo khuôn "{n}. {tên}". Nếu BE bỏ tiền tố số thì
// hàm này lệch một cách CÂM (không đỏ build) — lúc đó sửa chính hàm này, đừng vá page.
export function khkkGroupMenuLabel(n: number): string {
const label = KHKK_APPROVAL_GROUP_LABELS[n]
return label ? `${n}. ${label}` : `Nhóm ${n}`
}
// [K4a S166 — O-2] Mã quy trình duyệt của nhóm n. BE khớp bằng regex `^KHKK-N([1-8])$`
// (`ContractSigningPlanFeatures.cs:420`, rào 409 "phiếu nhóm 3 pin quy trình nhóm 5");
// 8 workflow do `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed, ApplicableType=10.
// Gom về 1 hàm để FE không rải chuỗi `KHKK-N` ở nhiều nơi rồi lệch nhau.
export const khkkGroupWorkflowCode = (n: number) => `KHKK-N${n}`
export interface KhkkListItemDto { export interface KhkkListItemDto {
id: string id: string
maKeHoach: string | null maKeHoach: string | null

View File

@ -55,16 +55,17 @@ public class ContractSigningPlansController(
public async Task<ActionResult<List<ContractSigningPlanListItemDto>>> Inbox(CancellationToken ct) public async Task<ActionResult<List<ContractSigningPlanListItemDto>>> Inbox(CancellationToken ct)
=> Ok(await mediator.Send(new GetMyContractSigningPlanInboxQuery(), ct)); => Ok(await mediator.Send(new GetMyContractSigningPlanInboxQuery(), ct));
/// Màn "Đã xoá" — CHỈ XEM. /// Màn "Đã xoá" — CHỈ XEM. [K4b S166] +`group` mirror List `:42` (8 leaf Đã-xóa per-nhóm).
[HttpGet("deleted")] [HttpGet("deleted")]
[Authorize(Policy = "KeHoachKyKet.Read")] [Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> ListDeleted( public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> ListDeleted(
[FromQuery(Name = "group")] int? group = null,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] string? search = null,
[FromQuery] bool sortDesc = true, [FromQuery] bool sortDesc = true,
CancellationToken ct = default) CancellationToken ct = default)
=> Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery => Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery(group)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct)); { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
/// Picker màn tạo: phiếu Duyệt NCC đã duyệt, có NCC trúng thầu, chưa có kế hoạch sống. /// Picker màn tạo: phiếu Duyệt NCC đã duyệt, có NCC trúng thầu, chưa có kế hoạch sống.

View File

@ -862,7 +862,10 @@ public class GetMyContractSigningPlanInboxQueryHandler(IApplicationDbContext db,
} }
/// Màn "Đã xoá" — CHỈ XEM (mirror `ListDeletedPurchaseEvaluationsQuery`). /// Màn "Đã xoá" — CHỈ XEM (mirror `ListDeletedPurchaseEvaluationsQuery`).
public record ListDeletedContractSigningPlansQuery // [K4b S166 — gate-K4b FLAG-1] +ApprovalGroup mirror list SỐNG (`:756-757`): 8 leaf "Đã xóa"
// per-nhóm trỏ `?group=n` mà endpoint này không đọc ⇒ 8 URL trả CÙNG tập mọi-nhóm; lọc client-side
// thì total/số-trang nói dối (server đếm trên tập chưa lọc) ⇒ phải lọc server-side như list sống.
public record ListDeletedContractSigningPlansQuery(int? ApprovalGroup = null)
: PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>; : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser) public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
@ -889,6 +892,10 @@ public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext d
&& userWfIds.Contains(p.ApprovalWorkflowId.Value))); && userWfIds.Contains(p.ApprovalWorkflowId.Value)));
} }
// [K4b S166] Lọc nhóm — mirror list sống `:756-757`, SAU vế IDOR (lọc chỉ thu hẹp).
if (request.ApprovalGroup is not null)
q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);
if (!string.IsNullOrWhiteSpace(request.Search)) if (!string.IsNullOrWhiteSpace(request.Search))
{ {
var kw = request.Search.Trim(); var kw = request.Search.Trim();

View File

@ -46,6 +46,43 @@ public static class MenuKeys
public const string KeHoachKyKet = "KeHoachKyKet"; public const string KeHoachKyKet = "KeHoachKyKet";
public const string HopDongCung = "HopDongCung"; public const string HopDongCung = "HopDongCung";
// ============================================================
// [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT × 6 leaf. Danh mục SOL-CCM-SP-002
// (Mig 70) chia 86 dòng thành 8 nhóm duyệt; mỗi nhóm 1 workflow `KHKK-N{n}`
// type-10 (K3) ⇒ sidebar cũng tách 8 nhóm.
// - NHÓM 1 GIỮ NGUYÊN 7 key CŨ KHÔNG-infix (`Khkk_G1` + `Khkk_WfView` …
// `Khkk_Deleted`, seed từ S159 — xem SeedMenuTreeAsync). Đổi tên key =
// mồ côi permission-row đã cấp (bài S155) ⇒ CẤM đổi.
// - NHÓM 2..8 dùng khuôn CÓ infix: `Khkk_G{n}` + `Khkk_G{n}_{action}`.
// 🔴 49 key (7 group + 42 leaf) VÀO `All` ⇒ sinh thêm 196 policy
// `Khkk_G*.{Read,Create,Update,Delete}` (registered-unused: endpoint KHKK
// vẫn CHỈ dùng `KeHoachKyKet.*` — gotcha #85, đừng OR key con vào gate)
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +49 · Policies +196
// (Policies = |All| × |Actions|, DERIVED — khai DELTA, không neo mốc tuyệt đối).
// 🔴 Vào `All` thì BẮT BUỘC có menu-row tương ứng trong `SeedMenuTreeAsync`
// (thiếu row = Permission FK nổ ngay lần boot đầu — invariant T6 K1).
// `KhkkGroupMenuKeys` dưới là NGUỒN CHUNG cho cả `All` LẪN vòng seed
// menu-row + vòng grant site-2 ⇒ 3 phía khớp by-construction, KHÔNG mirror tay.
// ⚠️ 2 KHUÔN menu-key cùng sống trong file này (đọc 1 khuôn đừng tưởng khuôn kia sai):
// (a) `Ct_*` / `Pe_*` leaf CỐ Ý NGOÀI `All` — sinh động qua factory, Admin
// grant qua seeder riêng (xem chú thích tại PurchaseEvaluationApproved).
// (b) catalog / `AwV2_*` / `Khkk_G*` TRONG `All` — Admin tự có row qua
// loop `MenuKeys.All` ở SeedAdminPermissionsAsync. K4a đi khuôn (b).
// ============================================================
public static readonly int[] KhkkGroupNumbers = [2, 3, 4, 5, 6, 7, 8];
public static readonly string[] KhkkLeafActions =
["WfView", "List", "Create", "Pending", "Approved", "Deleted"];
public static string KhkkGroup(int n) => $"Khkk_G{n}";
public static string KhkkLeaf(int n, string action) => $"Khkk_G{n}_{action}";
// 49 key theo thứ tự: group n → 6 leaf của n → group n+1 → …
// (khai báo TRƯỚC `All` vì static field initializer chạy theo thứ tự văn bản).
public static readonly string[] KhkkGroupMenuKeys = KhkkGroupNumbers
.SelectMany(n => new[] { KhkkGroup(n) }
.Concat(KhkkLeafActions.Select(a => KhkkLeaf(n, a))))
.ToArray();
public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}"; public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}";
public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List"; public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List";
public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create"; public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create";
@ -200,6 +237,11 @@ public static class MenuKeys
OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report
Personal, // [S57] Cá nhân (Puro grouping — Chấm công re-parent) Personal, // [S57] Cá nhân (Puro grouping — Chấm công re-parent)
KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete} KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete}
// [K4a — S166] 49 key sidebar KHKK nhóm 2..8 (7 group + 42 leaf) — nguồn sinh
// `KhkkGroupMenuKeys` (dùng chung với seed menu-row + grant site-2). 7 key nhóm 1
// KHÔNG-infix (`Khkk_G1`/`Khkk_WfView`…) CỐ Ý ngoài `All` như từ S159 — giữ nguyên
// trạng thái cũ, quyền của chúng do site-2/site-3 phát.
.. KhkkGroupMenuKeys,
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows, System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22 ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy). // [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy).

View File

@ -1953,7 +1953,11 @@ public static class DbInitializer
// [S159-đợt3] skeleton menu GĐ2/GĐ4 (owner vẽ: "viết hoa + phân list như // [S159-đợt3] skeleton menu GĐ2/GĐ4 (owner vẽ: "viết hoa + phân list như
// Duyệt NCC" / "thêm Sub đánh số như Duyệt Hợp đồng"). Root CÓ CON ⇒ render // Duyệt NCC" / "thêm Sub đánh số như Duyệt Hợp đồng"). Root CÓ CON ⇒ render
// MenuGroup uppercase tự động. Leaves → /coming-soon (staticMap ×2 app). // MenuGroup uppercase tự động. Leaves → /coming-soon (staticMap ×2 app).
("Khkk_G1", "1. Kế hoạch ký kết HĐ (NCC-TP)", MenuKeys.KeHoachKyKet, 1, "FileCheck"), // [K4a — S166] Nhóm 1 trong 8 NHÓM DUYỆT (danh mục SP-002): nhãn skeleton S159
// "1. Kế hoạch ký kết HĐ (NCC-TP)" → nhãn nhóm A1. Key `Khkk_G1` GIỮ NGUYÊN;
// DB đang chạy hội tụ qua `labelBackfill` bên dưới (dòng này chỉ ăn với DB trống).
// 7 nhóm còn lại (G2..G8) seed ở vòng lặp phía dưới (sau vòng `PeWf_*`).
("Khkk_G1", "1. Thiết bị, máy móc, vật tư phụ (A1)", MenuKeys.KeHoachKyKet, 1, "FileCheck"),
// [S159-đợt5 owner] đủ 6 leaf "y chang Duyệt NCC" (WfView đầu + Deleted cuối). // [S159-đợt5 owner] đủ 6 leaf "y chang Duyệt NCC" (WfView đầu + Deleted cuối).
("Khkk_WfView", "Luồng duyệt", "Khkk_G1", 1, "Network"), ("Khkk_WfView", "Luồng duyệt", "Khkk_G1", 1, "Network"),
("Khkk_List", "Danh sách", "Khkk_G1", 2, "List"), ("Khkk_List", "Danh sách", "Khkk_G1", 2, "List"),
@ -2092,8 +2096,8 @@ public static class DbInitializer
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List")); tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List"));
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus")); tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus"));
// [S155 đợt 2] Nhãn ở đây CHỈ có tác dụng cho DB TRỐNG. Với DB đang chạy, upsert // [S155 đợt 2] Nhãn ở đây CHỈ có tác dụng cho DB TRỐNG. Với DB đang chạy, upsert
// `:1893-1905` gặp key đã tồn tại thì chỉ update `Order` rồi `continue` — KHÔNG // `:2162-2179` (grep `existingItems.TryGetValue`) gặp key đã tồn tại thì chỉ update
// đụng Label ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill` bên dưới. // `Order` rồi `continue` ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill`.
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Đang duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2")); tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Đang duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
// 2 mục MỚI (owner chốt 2026-07-27) — key mới ⇒ đi nhánh INSERT bình thường. // 2 mục MỚI (owner chốt 2026-07-27) — key mới ⇒ đi nhánh INSERT bình thường.
tree.Add((MenuKeys.PurchaseEvaluationApproved(code), "Đã duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCheck")); tree.Add((MenuKeys.PurchaseEvaluationApproved(code), "Đã duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCheck"));
@ -2108,6 +2112,50 @@ public static class DbInitializer
tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck")); tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck"));
} }
// [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT × 6 leaf — 49 row MỚI cho nhóm 2..8
// (nhóm 1 = 7 row CŨ KHÔNG-infix nằm trong tree initializer trên: `Khkk_G1` +
// `Khkk_WfView`…`Khkk_Deleted`, key GIỮ NGUYÊN — đổi = mồ côi permission-row, S155).
// Nhãn 8 nhóm = bảng owner đã soát `danh-muc-sp002-transcribe.md:22-29` (nguồn: danh
// mục SOL-CCM-SP-002 Mig 70 chia 86 dòng thành 8 nhóm duyệt). Nhãn/icon/Order của 6
// leaf MIRROR Y nhóm 1 (Luồng duyệt/Danh sách/Thao tác/Đang duyệt/Đã duyệt/Đã xóa).
// 🔴 Key sinh bằng CHÍNH `MenuKeys.KhkkGroup/KhkkLeaf` + duyệt CHÍNH `KhkkGroupNumbers`/
// `KhkkLeafActions` ⇒ mọi key trong `MenuKeys.All` đều có menu-row by-construction
// (thiếu row = Permission FK nổ lần boot đầu — invariant T6 K1). Thêm nhóm/action mà
// quên nhãn ⇒ KeyNotFoundException NGAY lúc seed (fail-fast, hơn là FK nổ về sau).
// ⚠️ Đổi NHÃN sau khi đã seed: BẮT BUỘC thêm entry vào `labelBackfill` bên dưới +
// restart — sửa chữ tại đây VÔ TÁC DỤNG trên DB đang chạy (upsert chỉ re-set Order).
var khkkGroupLabels = new Dictionary<int, string>
{
[2] = "2. Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)",
[3] = "3. Hoạt động văn phòng (A4)",
[4] = "4. Vật tư (B1)",
[5] = "5. Thầu phụ xây dựng (B2)",
[6] = "6. Thầu phụ MEP (B3)",
[7] = "7. BCH công trường (C1-C3)",
[8] = "8. HĐ chưa ủy quyền — CEO (5.2)",
};
var khkkLeafMeta = new Dictionary<string, (string Label, string Icon)>
{
["WfView"] = ("Luồng duyệt", "Network"),
["List"] = ("Danh sách", "List"),
["Create"] = ("Thao tác", "Plus"),
["Pending"] = ("Đang duyệt", "CheckCircle2"),
["Approved"] = ("Đã duyệt", "CheckCheck"),
["Deleted"] = ("Đã xóa", "Trash2"),
};
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var groupKey = MenuKeys.KhkkGroup(n);
// Order nhóm = n (nhóm 1 đang Order 1 ⇒ 8 nhóm xếp 1..8 dưới root KeHoachKyKet).
tree.Add((groupKey, khkkGroupLabels[n], MenuKeys.KeHoachKyKet, n, "FileCheck"));
var khkkLeafOrder = 1;
foreach (var action in MenuKeys.KhkkLeafActions)
{
var (leafLabel, leafIcon) = khkkLeafMeta[action];
tree.Add((MenuKeys.KhkkLeaf(n, action), leafLabel, groupKey, khkkLeafOrder++, leafIcon));
}
}
var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key); var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key);
var added = 0; var added = 0;
var reordered = 0; var reordered = 0;
@ -2146,7 +2194,7 @@ public static class DbInitializer
[MenuKeys.PeWorkflowTypeLeaf("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp", [MenuKeys.PeWorkflowTypeLeaf("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp",
// [S155 đợt 2] 🔴 Rename "Duyệt" → "Đang duyệt" (owner, ảnh menu 2026-07-27). // [S155 đợt 2] 🔴 Rename "Duyệt" → "Đang duyệt" (owner, ảnh menu 2026-07-27).
// BẮT BUỘC đi qua đây: sửa chữ trong `tree.Add(...)` là VÔ TÁC DỤNG trên DB đang // BẮT BUỘC đi qua đây: sửa chữ trong `tree.Add(...)` là VÔ TÁC DỤNG trên DB đang
// chạy vì upsert `:1893-1905` chỉ update `Order` cho key đã tồn tại. // chạy vì upsert `:2162-2179` (grep `existingItems.TryGetValue`) chỉ re-set `Order`.
[MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt", [MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt",
[MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt", [MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt",
// [S159] 🔴 UAT anh Kiệt: đánh số 01.-07. nhóm HĐ (verbatim "01 . HĐ thầu phụ"). // [S159] 🔴 UAT anh Kiệt: đánh số 01.-07. nhóm HĐ (verbatim "01 . HĐ thầu phụ").
@ -2180,6 +2228,11 @@ public static class DbInitializer
[MenuKeys.ContractTypeGroup("MuaBan")] = "05. HĐ Mua bán", [MenuKeys.ContractTypeGroup("MuaBan")] = "05. HĐ Mua bán",
[MenuKeys.ContractTypeGroup("NguyenTacNcc")] = "06. HĐ Nguyên tắc NCC", [MenuKeys.ContractTypeGroup("NguyenTacNcc")] = "06. HĐ Nguyên tắc NCC",
[MenuKeys.ContractTypeGroup("NguyenTacDv")] = "07. HĐ Nguyên tắc Dịch vụ", [MenuKeys.ContractTypeGroup("NguyenTacDv")] = "07. HĐ Nguyên tắc Dịch vụ",
// [K4a — S166] Nhóm 1 KHKK đổi nhãn skeleton S159 ("1. Kế hoạch ký kết HĐ
// (NCC-TP)") → nhãn nhóm duyệt A1, khớp bảng 8 nhãn owner đã soát. CHỈ đổi
// Label, KHÔNG đổi key ⇒ 0 row mới, 0 permission-row mồ côi. 7 nhóm G2..G8 là
// key MỚI nên đi nhánh INSERT của upsert, không cần entry backfill.
["Khkk_G1"] = "1. Thiết bị, máy móc, vật tư phụ (A1)",
}; };
var updatedLabels = 0; var updatedLabels = 0;
foreach (var (key, expectedLabel) in labelBackfill) foreach (var (key, expectedLabel) in labelBackfill)
@ -2282,6 +2335,9 @@ public static class DbInitializer
// Cá nhân (Personal) khỏi MỌI role TRỪ Admin. Menu tự ẩn cả 2 app // Cá nhân (Personal) khỏi MỌI role TRỪ Admin. Menu tự ẩn cả 2 app
// (GetMyMenuTree lọc CanRead). Chạy SAU grant seed để revoke thắng. // (GetMyMenuTree lọc CanRead). Chạy SAU grant seed để revoke thắng.
// Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope. // Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope.
// [K4a — S166] Đã soi revoker cho 49 key KHKK mới: filter chỉ bắt prefix `Hrm*`/`Off*`
// + key `Personal` ⇒ **0 match `Khkk_*`** ⇒ 49 row CanRead vừa cấp KHÔNG bị lật. Phép
// thử của khẳng định này = restart API ×2 rồi đếm lại row `Khkk%` (acceptance A5).
await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger); await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger);
// [S118 2026-07-14 — anh Kiệt FDC] CHẠY SAU revoke để THẮNG: NGOẠI-LỆ role Procurement // [S118 2026-07-14 — anh Kiệt FDC] CHẠY SAU revoke để THẮNG: NGOẠI-LỆ role Procurement
@ -2370,11 +2426,24 @@ public static class DbInitializer
MenuKeys.CatalogServices, MenuKeys.CatalogWorkItems, MenuKeys.Departments, MenuKeys.CatalogServices, MenuKeys.CatalogWorkItems, MenuKeys.Departments,
MenuKeys.Projects, MenuKeys.Suppliers, MenuKeys.Projects, MenuKeys.Suppliers,
}; };
// [S159] 7 key nhóm 1 (không-infix) + [K4a S166] 49 key nhóm 2..8 (7 group + 42 leaf,
// sinh từ `MenuKeys.KhkkGroupMenuKeys` — CÙNG bộ sinh với `MenuKeys.All` và vòng seed
// menu-row ⇒ không mirror tay). CanRead mọi role, skip-existing.
// 🔴 LÝ DO GIỮ site này (đính chính lý-do sai đang lưu hành "group-key cần row để
// render"): group KHÔNG cần row CanRead của chính nó — `GetMyMenuTreeQuery.cs:96`
// `HasAccess(n) = n.CanRead || n.Children.Any(HasAccess)`. Cái BẮT BUỘC là 42 LEAF
// phải có row CanRead cho non-Admin: site-1 (loop MenuKeys.All) CHỈ cấp Admin, còn
// site-3 `SeedKeHoachKyKetAccessAsync` CHỈ đụng root + 6 leaf nhóm 1. Group vẫn liệt
// kê ở đây để có hàng tường minh trên ma trận Phân quyền (admin tự bật/tắt).
// ⚠️ CanRead là ĐỦ cho 42 leaf mới, KHÔNG cần nhét chúng vào site-3: mọi endpoint KHKK
// gate bằng policy `KeHoachKyKet.{Read,Create,Update,Delete}` (gotcha #85 — gate FE
// cũng phải dùng đúng key đó, KHÔNG OR key con), và cờ GHI của root do site-3 phát
// (13/13 role C+U, Delete = Drafter+Admin). Leaf chỉ là điểm điều hướng.
static IEnumerable<string> KhkkKeys() => new[] static IEnumerable<string> KhkkKeys() => new[]
{ {
"Khkk_G1", "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_G1", "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending",
"Khkk_Approved", "Khkk_Deleted", "Khkk_Approved", "Khkk_Deleted",
}; }.Concat(MenuKeys.KhkkGroupMenuKeys);
static IEnumerable<string> HdcKeys() static IEnumerable<string> HdcKeys()
=> MenuKeys.ContractTypeCodes.Select(c => "Hdc_" + c); => MenuKeys.ContractTypeCodes.Select(c => "Hdc_" + c);

View File

@ -0,0 +1,431 @@
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K4a — S166 2026-08-01] Test-after cho sidebar KHKK 8 NHÓM DUYỆT (danh mục SOL-CCM-SP-002
// Mig 70 chia 86 dòng thành 8 nhóm; mỗi nhóm 1 workflow `KHKK-N{n}` type-10 — K3).
//
// BE đã land: `MenuKeys.KhkkGroupNumbers/KhkkLeafActions/KhkkGroupMenuKeys` (49 key spread vào
// `All`) + vòng seed 49 menu-row trong `SeedMenuTreeAsync` + entry `labelBackfill["Khkk_G1"]`
// + site-2 grant `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)`.
//
// KHÔNG nhân bản test đã có: `ContractCatalogTests.MenuKey_ContractCatalog_...` (T6 lane K1)
// đã assert `MenuKeys.All.Except(menuKeysInDb).BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant
// "mọi key ∈ All có menu-row" (thiếu row = Permission FK nổ lúc boot) TỰ ĐỘNG phủ 49 key mới.
// File này đo phần T6 KHÔNG đo:
// T1 hình bộ sinh (49 key, group-trước-6-leaf, đúng chuỗi ký tự `Khkk_G{n}_{action}`)
// T2 `All` — chứa đủ 49 + KHÔNG key đôi + số tổng runtime + nhóm 1 CỐ Ý đứng ngoài
// T3 seed ×2 idempotent — 56 row họ Khkk (7 nhóm-1 + 49 mới), 0 nhân đôi, hình cây đúng
// T4 6 leaf nhóm 2..8 MIRROR nhãn/icon/Order của nhóm 1 (đo DB↔DB, không bảng hardcode)
// T5 labelBackfill đổi NHÃN mà KHÔNG thêm row + đối chứng key KHÔNG có entry thì KHÔNG tự lành
// T6 site-2 phát CanRead cho 49 key mới (read-only) + đối chứng Pe_* cùng lượt được CanCreate
//
// Harness: seeder là `private static` → REFLECTION (KHÔNG sửa prod đổi visibility).
// `SeedMenuTreeAsync(ApplicationDbContext, ILogger)`
// `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager<Role>, ILogger)`
// Fixture `IdentityFixture` (cần RoleManager cho site-2) — KHÔNG chế fixture mới.
public class KhkkGroupMenuSeedTests
{
// ── 49 key kỳ vọng, VIẾT TAY (KHÔNG gọi lại `MenuKeys.KhkkGroup/KhkkLeaf` — gọi lại là
// tautology: bộ sinh sai chuỗi thì kỳ vọng cũng sai theo và test xanh vô nghĩa). ────────
private static readonly string[] Expected49 =
{
"Khkk_G2", "Khkk_G2_WfView", "Khkk_G2_List", "Khkk_G2_Create", "Khkk_G2_Pending", "Khkk_G2_Approved", "Khkk_G2_Deleted",
"Khkk_G3", "Khkk_G3_WfView", "Khkk_G3_List", "Khkk_G3_Create", "Khkk_G3_Pending", "Khkk_G3_Approved", "Khkk_G3_Deleted",
"Khkk_G4", "Khkk_G4_WfView", "Khkk_G4_List", "Khkk_G4_Create", "Khkk_G4_Pending", "Khkk_G4_Approved", "Khkk_G4_Deleted",
"Khkk_G5", "Khkk_G5_WfView", "Khkk_G5_List", "Khkk_G5_Create", "Khkk_G5_Pending", "Khkk_G5_Approved", "Khkk_G5_Deleted",
"Khkk_G6", "Khkk_G6_WfView", "Khkk_G6_List", "Khkk_G6_Create", "Khkk_G6_Pending", "Khkk_G6_Approved", "Khkk_G6_Deleted",
"Khkk_G7", "Khkk_G7_WfView", "Khkk_G7_List", "Khkk_G7_Create", "Khkk_G7_Pending", "Khkk_G7_Approved", "Khkk_G7_Deleted",
"Khkk_G8", "Khkk_G8_WfView", "Khkk_G8_List", "Khkk_G8_Create", "Khkk_G8_Pending", "Khkk_G8_Approved", "Khkk_G8_Deleted",
};
// 7 key nhóm 1 KHÔNG-infix, seed từ S159 — CẤM đổi (đổi key = mồ côi permission-row, bài S155).
private const string Group1GroupKey = "Khkk_G1";
private static readonly string[] Group1LeafKeys =
{ "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved", "Khkk_Deleted" };
// Nhãn nhóm 1 sau K4a (entry `labelBackfill` mới) và nhãn skeleton S159 mà nó thay thế.
private const string Group1LabelAfterK4a = "1. Thiết bị, máy móc, vật tư phụ (A1)";
private const string Group1LabelSkeletonS159 = "1. Kế hoạch ký kết HĐ (NCC-TP)";
// ── Reflection helper (mirror `ContractCatalogTests.InvokePrivateSeedAsync`) ────────────
private static async Task InvokePrivateSeedAsync(
TestApplicationDbContext db, string methodName, RoleManager<Role>? rm = null)
{
var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
mi.Should().NotBeNull($"DbInitializer.{methodName} phải tồn tại (private static) — đổi signature thì cập nhật test");
var args = rm is null
? new object[] { db, NullLogger.Instance }
: new object[] { db, rm, NullLogger.Instance };
await (Task)mi!.Invoke(null, args)!;
}
private static Task SeedMenuAsync(TestApplicationDbContext db)
=> InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
// Đếm họ Khkk bằng so-khớp Ordinal TRONG BỘ NHỚ (LIKE của SQLite case-insensitive ASCII —
// không muốn phép đếm phụ thuộc collation của provider).
private static async Task<List<string>> KhkkKeysInDbAsync(TestApplicationDbContext db)
{
var keys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
return keys.Where(k => k.StartsWith("Khkk", StringComparison.Ordinal)).OrderBy(k => k, StringComparer.Ordinal).ToList();
}
private static Task<MenuItem?> RowAsync(TestApplicationDbContext db, string key)
=> db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == key);
// =====================================================================================
// T1 — HÌNH BỘ SINH: đúng 49 key, đúng THỨ TỰ group-trước-6-leaf, đúng chuỗi ký tự.
// `Equal` (thứ tự có nghĩa) chứ không `BeEquivalentTo`: nếu ai đổi `SelectMany`
// thành "7 group trước rồi 42 leaf sau" thì vòng seed menu vẫn chạy nhưng cây menu
// đổi hình ⇒ phải ĐỎ. Kèm phép kiểm liền-kề tường minh (G{n} đứng NGAY TRƯỚC
// G{n}_WfView) để thông điệp lỗi chỉ đúng thuộc tính bị vỡ.
// =====================================================================================
[Fact]
public void KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves()
{
MenuKeys.KhkkGroupMenuKeys.Should().HaveCount(49,
"7 nhóm (2..8) × (1 group + 6 leaf) = 49 — số này quyết định delta `Menu keys` + `Policies` ở docs/STATUS.md");
MenuKeys.KhkkGroupMenuKeys.Should().Equal(Expected49,
"bộ sinh phải ra ĐÚNG chuỗi `Khkk_G{{n}}` / `Khkk_G{{n}}_{{action}}` theo thứ tự group → 6 leaf; " +
"sai 1 ký tự là menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot");
// Liền-kề tường minh: mỗi group đứng ngay trước leaf `_WfView` của chính nó.
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var idx = Array.IndexOf(MenuKeys.KhkkGroupMenuKeys, $"Khkk_G{n}");
idx.Should().BeGreaterThanOrEqualTo(0, $"phải có key nhóm Khkk_G{n}");
MenuKeys.KhkkGroupMenuKeys[idx + 1].Should().Be($"Khkk_G{n}_WfView",
$"leaf đầu của nhóm {n} phải đứng NGAY SAU key nhóm — vòng seed menu dựa vào hình này");
}
MenuKeys.KhkkLeafActions.Should().Equal(
new[] { "WfView", "List", "Create", "Pending", "Approved", "Deleted" },
"6 action khớp Y 6 leaf nhóm 1 (owner: 'y chang Duyệt NCC') — thêm/bớt action là đổi 2 số canonical");
}
// =====================================================================================
// T2 — `MenuKeys.All`: chứa đủ 49 + KHÔNG key đôi + số tổng ĐO ĐƯỢC (Policies = |All|×4,
// DERIVED ⇒ số này là nguồn của 2 row canonical docs/STATUS.md).
// ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào
// `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở
// initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống.
// =====================================================================================
[Fact]
public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside()
{
MenuKeys.All.Should().Contain(Expected49,
"49 key phải nằm trong All — ngoài All thì Program.cs không sinh policy và Admin không được grant qua loop All");
MenuKeys.All.Should().OnlyHaveUniqueItems(
"key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
MenuKeys.All.Should().HaveCount(113,
"số ĐO runtime tại S166 sau K4a (+49). `Policies` = |All| × |Actions| = 113 × 4 = 452 — " +
"đổi số ở đây BẮT BUỘC đổi 2 row canonical `Menu keys` / `Policies` trong docs/STATUS.md");
MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 },
"nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync");
foreach (var g1 in Group1LeafKeys.Append(Group1GroupKey))
MenuKeys.All.Should().NotContain(g1,
$"{g1} (nhóm 1) giữ nguyên trạng thái NGOÀI All từ S159 — kéo vào All sẽ sinh menu-row trùng PK lúc seed");
}
// =====================================================================================
// T3 — SEED ×2 IDEMPOTENT: 56 row họ Khkk (7 nhóm-1 + 49 mới), lần 2 KHÔNG nhân đôi,
// và hình cây đúng (7 group mới treo dưới root `KeHoachKyKet`, Order = n, mỗi group
// đúng 6 con). Đếm tổng MenuItems trước/sau lần 2 để bắt cả trường hợp seeder nhân
// row Ở CHỖ KHÁC (không chỉ họ Khkk).
// =====================================================================================
[Fact]
public async Task SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var khkkAfter1 = await KhkkKeysInDbAsync(db);
var totalAfter1 = await db.MenuItems.AsNoTracking().CountAsync();
khkkAfter1.Should().HaveCount(56,
"7 row nhóm 1 (S159, không-infix) + 49 row nhóm 2..8 (K4a) — thiếu row nào là menu mất mục / Permission FK nổ");
khkkAfter1.Should().Contain(Expected49, "49 key mới phải có menu-row thật");
khkkAfter1.Should().Contain(Group1LeafKeys.Append(Group1GroupKey),
"7 row nhóm 1 KHÔNG được mất khi thêm 8-nhóm");
await SeedMenuAsync(db); // ⭐ lần 2
var khkkAfter2 = await KhkkKeysInDbAsync(db);
var totalAfter2 = await db.MenuItems.AsNoTracking().CountAsync();
khkkAfter2.Should().Equal(khkkAfter1, "idempotent — chạy lại KHÔNG thêm/bớt row họ Khkk");
totalAfter2.Should().Be(totalAfter1, "idempotent trên TOÀN bảng menu, không riêng họ Khkk");
// Hình cây: 7 group mới dưới root KeHoachKyKet, Order = số nhóm (xếp 1..8 cùng nhóm 1).
var g1 = await RowAsync(db, Group1GroupKey);
g1!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet);
g1.Order.Should().Be(1, "nhóm 1 giữ Order 1 — 8 nhóm xếp 1..8 dưới root");
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var group = await RowAsync(db, $"Khkk_G{n}");
group.Should().NotBeNull($"Khkk_G{n} phải có row");
group!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet, $"nhóm {n} treo dưới root Kế hoạch ký kết HĐ");
group.Order.Should().Be(n, $"Order nhóm = {n} (8 nhóm xếp 1..8 theo danh mục SP-002)");
group.Label.Should().NotBeNullOrWhiteSpace($"nhóm {n} phải có nhãn tiếng Việt (bảng 8 nhãn owner đã soát)");
var children = await db.MenuItems.AsNoTracking()
.Where(m => m.ParentKey == group.Key).Select(m => m.Key).ToListAsync();
children.Should().HaveCount(6, $"nhóm {n} đúng 6 leaf (WfView/List/Create/Pending/Approved/Deleted)");
}
// 8 nhãn nhóm phải KHÁC NHAU — copy-paste sót 1 dòng dictionary là 2 nhóm cùng tên trên sidebar.
var groupLabels = new List<string> { g1.Label };
foreach (var n in MenuKeys.KhkkGroupNumbers)
groupLabels.Add((await RowAsync(db, $"Khkk_G{n}"))!.Label);
groupLabels.Should().OnlyHaveUniqueItems("8 nhóm duyệt = 8 nhãn phân biệt trên sidebar");
}
// =====================================================================================
// T4 — 6 leaf nhóm 2..8 MIRROR Y nhóm 1 (nhãn + icon + Order). Đo DB↔DB: lấy row nhóm 1
// làm mốc thay vì bảng hardcode ⇒ đổi nhãn leaf mà quên đồng bộ 7 nhóm kia là ĐỎ.
// Chống vacuous: chốt trước rằng 6 nhãn nhóm 1 PHÂN BIỆT (nếu chúng trùng nhau hết
// thì phép so "khớp nhãn" đúng ở mọi thế giới).
// =====================================================================================
[Fact]
public async Task SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var g1Leaves = new List<MenuItem>();
foreach (var key in Group1LeafKeys)
{
var row = await RowAsync(db, key);
row.Should().NotBeNull($"{key} (leaf nhóm 1, S159) phải còn — K4a không được đụng key cũ");
g1Leaves.Add(row!);
}
g1Leaves.Select(x => x.Label).Should().OnlyHaveUniqueItems(
"6 nhãn leaf nhóm 1 phải phân biệt — nếu trùng nhau, phép so mirror bên dưới đúng vô nghĩa");
for (var i = 0; i < MenuKeys.KhkkLeafActions.Length; i++)
{
var action = MenuKeys.KhkkLeafActions[i];
var mirror = g1Leaves[i];
mirror.Key.Should().Be($"Khkk_{action}",
"thứ tự KhkkLeafActions phải khớp thứ tự 6 leaf nhóm 1 — lệch thứ tự là so nhầm cặp");
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var leaf = await RowAsync(db, $"Khkk_G{n}_{action}");
leaf.Should().NotBeNull($"Khkk_G{n}_{action} phải có row");
leaf!.ParentKey.Should().Be($"Khkk_G{n}", "leaf treo dưới đúng nhóm của nó");
leaf.Label.Should().Be(mirror.Label, $"nhãn leaf {action} nhóm {n} phải Y nhóm 1 ({mirror.Label})");
leaf.Icon.Should().Be(mirror.Icon, $"icon leaf {action} nhóm {n} phải Y nhóm 1");
leaf.Order.Should().Be(mirror.Order, $"Order leaf {action} nhóm {n} phải Y nhóm 1 (1..6)");
}
}
}
// =====================================================================================
// T5 — labelBackfill: đổi NHÃN nhóm 1 mà KHÔNG thêm row (gotcha #11 — sửa chữ trong
// `tree.Add` là vô tác dụng trên DB đang chạy vì upsert chỉ re-set Order).
// ⭐ MA TRẬN cùng-ruột-khác-1-biến: bôi hỏng nhãn của CẢ HAI key trong cùng 1 lần,
// rồi seed lại — `Khkk_G1` (CÓ entry backfill) tự lành, `Khkk_G2` (KHÔNG có entry)
// GIỮ NGUYÊN chữ hỏng. Khác biệt duy nhất = sự có mặt trong `labelBackfill` ⇒ chứng
// chính cơ chế backfill làm việc, không phải upsert chính "tiện tay" ghi đè Label.
// =====================================================================================
[Fact]
public async Task SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var countBefore = await db.MenuItems.AsNoTracking().CountAsync();
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
"DB trống: nhãn A1 đến thẳng từ tree initializer");
// Bôi hỏng nhãn 2 key: G1 = nhãn skeleton S159 (ca THẬT trên prod), G2 = chuỗi lạ (đối chứng).
const string dirtyG2 = "ZZZ nhãn hỏng đối chứng";
var g1 = await db.MenuItems.FirstAsync(m => m.Key == Group1GroupKey);
var g2 = await db.MenuItems.FirstAsync(m => m.Key == "Khkk_G2");
g1.Label = Group1LabelSkeletonS159;
g2.Label = dirtyG2;
await db.SaveChangesAsync(CancellationToken.None);
// Sanity: mutation ĐÃ land (nếu không, "sau seed nhãn đúng" xanh vì chưa từng hỏng).
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelSkeletonS159);
(await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2);
await SeedMenuAsync(db); // ⭐ lần 2 — labelBackfill chạy
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
"entry labelBackfill[\"Khkk_G1\"] phải kéo nhãn skeleton S159 về nhãn nhóm A1 trên DB đang chạy");
(await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2,
"ĐỐI CHỨNG: Khkk_G2 KHÔNG có entry backfill ⇒ upsert chính chỉ re-set Order, KHÔNG ghi đè Label " +
"(gotcha #11). Đổi nhãn G2..G8 sau khi đã seed thì BẮT BUỘC thêm entry backfill.");
var countAfter = await db.MenuItems.AsNoTracking().CountAsync();
countAfter.Should().Be(countBefore, "đổi nhãn KHÔNG được sinh row mới (đổi key mới sinh row + mồ côi permission)");
}
// =====================================================================================
// T6 — SITE-2 (`SeedAllRolesReviewReadPermissionsAsync`): role thường có CanRead trên 49
// key mới (điểm điều hướng của sidebar). Cờ ghi = false vì nhánh non-Pe là read-only.
// ⭐ Chống vacuous bằng ĐỐI CHỨNG CÙNG LƯỢT: cùng role đó, key `Pe_DuyetNcc_List`
// phải có CanCreate=true (nhánh isPe) ⇒ "C/U/D=false trên Khkk" không phải vì seeder
// không chạy, mà vì seeder PHÂN BIỆT 2 họ key.
// Chạy SeedMenuTree trước = smoke FK: mọi key trong grant-set phải có menu-row thật.
// =====================================================================================
[Fact]
public async Task SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var normal = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
(await rm.CreateAsync(admin)).Succeeded.Should().BeTrue();
(await rm.CreateAsync(normal)).Succeeded.Should().BeTrue();
await SeedMenuAsync(db);
await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);
var rows = (await db.Permissions.AsNoTracking()
.Where(p => p.RoleId == normal.Id).ToListAsync())
.ToDictionary(p => p.MenuKey);
// Mục được đề bài gọi tên (leaf MỚI) — nêu riêng để thông điệp lỗi nói đúng chỗ.
rows.Should().ContainKey("Khkk_G2_List", "leaf mới phải được site-2 phát quyền cho role thường");
rows["Khkk_G2_List"].CanRead.Should().BeTrue(
"menu hiện ⟺ CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass cho role thường)");
foreach (var key in Expected49)
{
rows.Should().ContainKey(key, $"{key} phải có Permission row cho role thường (site-2 grant)");
var row = rows[key];
row.CanRead.Should().BeTrue($"{key}: role thường phải đọc được mục sidebar");
row.CanCreate.Should().BeFalse($"{key}: nhánh non-Pe là read-only (cờ GHI của KHKK do site-3 phát trên root)");
row.CanUpdate.Should().BeFalse($"{key}: read-only");
row.CanDelete.Should().BeFalse($"{key}: read-only");
}
// 7 key nhóm 1 vẫn được phát (K4a `.Concat` mở rộng, KHÔNG thay thế).
foreach (var key in Group1LeafKeys.Append(Group1GroupKey))
rows[key].CanRead.Should().BeTrue($"{key} (nhóm 1) phải giữ CanRead sau khi K4a nối thêm 49 key");
// ⭐ ĐỐI CHỨNG cùng lượt seed: họ Pe_* đi nhánh isPe ⇒ CanCreate=true.
var peList = MenuKeys.PurchaseEvaluationList("DuyetNcc");
rows[peList].CanCreate.Should().BeTrue(
"chứng seeder CÓ chạy và CÓ phân biệt 2 họ key — nếu không, mọi assert 'false' bên trên đúng vô nghĩa");
}
// =====================================================================================
// T7 — [finding D, gate K4a @S166] ⭐ BẤT-BIẾN AN NINH-DỮ-LIỆU: `RevokeTemporarilyHidden-
// ModulesAsync` KHÔNG được đụng họ `Khkk_*`.
// Đây là ca ĐÃ VỠ THẬT trên prod, không phải giả định: 2026-07-29 grant 38 key bằng
// SQL tay bị CHÍNH revoker này lật ở app-restart kế (Run #423 — 447/494 row rơi,
// 11/13 role về 0; gotcha #84). Thêm 1 prefix vào bộ lọc revoke = 49 row Khkk vừa cấp
// biến mất Ở LẦN RESTART SAU, im lặng, không test nào đỏ ⇒ Fact này bịt đúng lỗ đó.
// Khuôn 2-vế mirror `AdminOnlyModulesRevokeTests` (StillHidden ∧ Reopened).
//
// 🔴 BẪY VACUOUS ĐÃ CHỐNG (đọc từ mã `:2546-2551`): thiếu role Admin thì revoker
// RETURN SỚM (no-op) ⇒ mọi assert "Khkk còn nguyên" xanh vì revoker CHƯA TỪNG CHẠY.
// Nên test này (a) seed Admin, (b) có CONTROL DƯƠNG: cùng lượt đó, key `Off_*` và
// `Personal` của CHÍNH 2 role kia PHẢI bị hạ hết cờ. Control dương đỏ ⇔ phép đo hỏng.
// Đo trên 2 role non-Admin (khẳng định là "mọi role", 1 role không đủ nói) + kiểm
// Admin được loại trừ.
// =====================================================================================
[Fact]
public async Task Revoke_DoesNotTouch_KhkkGroups()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
// Admin BẮT BUỘC tồn tại, nếu không revoker return sớm và test thành vô nghĩa.
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var role1 = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
var role2 = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager };
foreach (var r in new[] { admin, role1, role2 })
(await rm.CreateAsync(r)).Succeeded.Should().BeTrue();
await SeedMenuAsync(db);
await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);
// CONTROL DƯƠNG: 2 key NẰM TRONG tập revoke (Off* + Personal) — site-2 không phát
// 2 key này nên cấp tay. Có menu-row thật từ SeedMenuTreeAsync ⇒ FK ok.
var revokedSetKeys = new[] { MenuKeys.OffDanhBa, MenuKeys.Personal };
foreach (var roleId in new[] { admin.Id, role1.Id, role2.Id })
foreach (var key in revokedSetKeys)
db.Permissions.Add(new Permission
{
RoleId = roleId, MenuKey = key,
CanRead = true, CanCreate = false, CanUpdate = false, CanDelete = false,
});
// Mô phỏng admin tick thêm cờ GHI cho 1 mục Khkk trên ma trận Phân quyền — revoker
// (nếu bắt nhầm `Khkk_*`) sẽ xoá luôn cờ này, không chỉ CanRead.
const string tickedKey = "Khkk_G5_Create";
var ticked = await db.Permissions.FirstAsync(p => p.RoleId == role1.Id && p.MenuKey == tickedKey);
ticked.CanCreate = true;
await db.SaveChangesAsync(CancellationToken.None);
await InvokePrivateSeedAsync(db, "RevokeTemporarilyHiddenModulesAsync", rm);
var after = (await db.Permissions.AsNoTracking().ToListAsync())
.ToDictionary(p => (p.RoleId, p.MenuKey));
// ── VẾ 1 (CONTROL DƯƠNG): revoker THẬT SỰ đã chạy và có hiệu lực trên non-Admin ──
foreach (var roleId in new[] { role1.Id, role2.Id })
foreach (var key in revokedSetKeys)
{
var row = after[(roleId, key)];
row.CanRead.Should().BeFalse(
$"{key} thuộc tập ẩn (Hrm*/Off*/Personal) ⇒ PHẢI bị hạ cờ. Vế này ĐỎ = revoker không chạy " +
"(vd Admin chưa seed) ⇒ mọi khẳng định 'Khkk còn nguyên' bên dưới vô giá trị");
row.CanCreate.Should().BeFalse();
row.CanUpdate.Should().BeFalse();
row.CanDelete.Should().BeFalse();
}
// Admin được loại trừ khỏi revoke (chứng bộ lọc `RoleId != adminRoleId` còn sống).
after[(admin.Id, MenuKeys.OffDanhBa)].CanRead.Should().BeTrue(
"Admin KHÔNG bị revoke — nếu vế này đỏ thì revoker đang quét quá tay, kết luận về Khkk cũng đáng ngờ");
// ── VẾ 2 (BẤT-BIẾN CẦN BẢO VỆ): 56 key họ Khkk GIỮ NGUYÊN quyền trên MỌI role ──
var allKhkkKeys = Expected49.Concat(Group1LeafKeys).Append(Group1GroupKey).ToArray();
allKhkkKeys.Should().HaveCount(56);
var survivors = 0;
foreach (var roleId in new[] { role1.Id, role2.Id })
foreach (var key in allKhkkKeys)
{
after.Should().ContainKey((roleId, key),
$"{key}: revoke chỉ HẠ CỜ, KHÔNG xoá row — mất row là hỏng nặng hơn cả gotcha #84");
after[(roleId, key)].CanRead.Should().BeTrue(
$"{key} KHÔNG thuộc Hrm*/Off*/Personal ⇒ revoker phải THA. Đỏ ở đây = đúng ca prod Run #423 " +
"(grant xong bị lật ở restart kế) tái diễn cho sidebar KHKK 8 nhóm");
survivors++;
}
// Nói bằng ĐƠN VỊ của sự cố thật (đếm row sống sót) — 56 key × 2 role non-Admin.
survivors.Should().Be(112, "toàn bộ row Khkk của 2 role non-Admin phải sống qua revoke");
after[(role1.Id, tickedKey)].CanCreate.Should().BeTrue(
$"{tickedKey}: cờ GHI admin tự tick trên ma trận Phân quyền cũng không được revoker chạm");
}
}