[CLAUDE] Contract: K1 SP-002 danh muc loai HD - ContractCatalogEntries (Mig 70) + seed 86 + CQRS + FE kind-5 x2 app + 8 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m31s

Gate: reviewer PASS-WITH-FLAGS 9f (sub-reviewer-gate-k1.md). Xu ly: F4
IncludeInactive mac-dinh-loai (spec K1.c) + test T5b; F7 comment 5-kind;
F1 OG-6 doi nhan soat-SAU @PAUSE-2 (seeder skip-if-exists giu sua-tay CRUD
qua restart - update-branch co y KHONG co); F3 authz mirror khuon Catalogs,
gioi-han khai: 4 policy ContractCatalog.* sinh ra nhung chua endpoint nao
tieu thu (display-layer khac API-layer, #82); F6 ten index IX_ giu EF-default
(NIT). Suite 598/0 (45D+553I). Mig 70 ap SQL Server that OK. Drift DELTA:
MenuKeys.All 55->56, policies 220->224, bang 96->97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 15:45:50 +07:00
parent c0a96e54eb
commit 50e6d8c382
20 changed files with 8566 additions and 80 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -64,6 +64,10 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials', CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services', CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items', CatalogWorkItems: '/master/catalogs/work-items',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SP-002 — "chỗ thứ 4" Pattern 16-bis:
// BE seed leaf `ContractCatalog` dưới group Catalogs, thiếu dòng này thì
// resolvePath trả null → MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
ContractCatalog: '/master/catalogs/contract-catalog',
PurchaseEvaluations: '/purchase-evaluations', PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows', PeWorkflows: '/system/pe-workflows',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON // [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON

View File

@ -11,6 +11,10 @@ export const MenuKeys = {
CatalogMaterials: 'CatalogMaterials', CatalogMaterials: 'CatalogMaterials',
CatalogServices: 'CatalogServices', CatalogServices: 'CatalogServices',
CatalogWorkItems: 'CatalogWorkItems', CatalogWorkItems: 'CatalogWorkItems',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// Mirror BE MenuKeys.cs:28 — leaf thứ 5 dưới group `Catalogs`, page dùng chung
// CatalogsPage qua `:kind` = `contract-catalog`.
ContractCatalog: 'ContractCatalog',
Contracts: 'Contracts', Contracts: 'Contracts',
Forms: 'Forms', Forms: 'Forms',
Reports: 'Reports', Reports: 'Reports',

View File

@ -1,10 +1,15 @@
// Generic master catalogs CRUD — 1 page handle 4 kind: units / materials / // Generic master catalogs CRUD — 1 page handle 5 kind: units / materials /
// services / work-items. URL `/master/catalogs/:kind` driven, mỗi kind có // services / work-items / contract-catalog. URL `/master/catalogs/:kind` driven,
// fields config riêng. Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh. // mỗi kind có fields config riêng. Sub-tabs hiển thị 5 kind trên cùng để chuyển nhanh.
//
// [K1 — S164, Mig 70] kind thứ 5 `contract-catalog` = "Danh mục Hợp đồng"
// (SOL-CCM-SP-002, 86 dòng × 8 nhóm duyệt N1..N8). Endpoint BE
// `api/catalogs/contract-catalog` (ContractCatalogController) — cùng tiền tố
// `/catalogs/` nên tái dùng nguyên đường `/catalogs/${kind}` sẵn có, KHÔNG route mới.
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, Trash2, Search } from 'lucide-react' import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, FileSpreadsheet, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -16,9 +21,36 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
type Kind = 'units' | 'materials' | 'services' | 'work-items' type Kind = 'units' | 'materials' | 'services' | 'work-items' | 'contract-catalog'
type FieldDef = { key: string; label: string; type: 'text' | 'textarea' | 'checkbox'; required?: boolean; placeholder?: string } type FieldDef = {
key: string
label: string
type: 'text' | 'textarea' | 'checkbox' | 'number' | 'select'
required?: boolean
placeholder?: string
options?: { value: number; label: string }[]
// Ép Number khi build payload — BE nhận `int` (ApprovalGroup / SortOrder),
// gửi chuỗi "3" thì System.Text.Json bind fail.
numeric?: boolean
}
// 8 nhóm duyệt N1..N8 (SOL-CCM-SP-002 — mục "Nhóm duyệt (8)").
const APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
const APPROVAL_GROUP_OPTIONS = APPROVAL_GROUPS.map(n => ({ value: n, label: `N${n}` }))
// Pattern 14 (Tailwind JIT): KHÔNG interpolate `bg-${x}-50` — class bị purge.
// Full class literal, cycle theo (nhóm - 1) % length.
const APPROVAL_GROUP_BADGE = [
'bg-sky-50 text-sky-700 border-sky-200',
'bg-emerald-50 text-emerald-700 border-emerald-200',
'bg-amber-50 text-amber-700 border-amber-200',
'bg-violet-50 text-violet-700 border-violet-200',
'bg-rose-50 text-rose-700 border-rose-200',
'bg-teal-50 text-teal-700 border-teal-200',
'bg-indigo-50 text-indigo-700 border-indigo-200',
'bg-orange-50 text-orange-700 border-orange-200',
] as const
const KIND_CONFIG: Record<Kind, { const KIND_CONFIG: Record<Kind, {
label: string label: string
@ -76,17 +108,44 @@ const KIND_CONFIG: Record<Kind, {
], ],
columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'], columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'],
}, },
// [K1 — S164] Danh mục Hợp đồng SOL-CCM-SP-002. Shape KHÁC 4 kind cũ
// (tenVi/tenEn thay `name`, thêm groupCode + approvalGroup + 2 cột vai ký)
// ⇒ header + cell có nhánh riêng bên dưới, `columns` dưới đây là nguồn header.
'contract-catalog': {
label: 'Danh mục Hợp đồng',
icon: FileSpreadsheet,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'A1-01, B2-07, CEO-01…' },
{ key: 'groupCode', label: 'Nhóm mục *', type: 'text', required: true, placeholder: 'A1, A2, B1, C3, CEO…' },
{ key: 'tenVi', label: 'Tên tiếng Việt *', type: 'text', required: true, placeholder: 'Hợp đồng thi công xây dựng…' },
{ key: 'tenEn', label: 'Tên tiếng Anh', type: 'text', placeholder: 'Construction contract…' },
{ key: 'approvalGroup', label: 'Nhóm duyệt (N1N8) *', type: 'select', required: true, numeric: true, options: APPROVAL_GROUP_OPTIONS },
{ key: 'sortOrder', label: 'Thứ tự trong nhóm', type: 'number', numeric: true, placeholder: '0' },
{ key: 'signerRole', label: 'Vai ký chính *', type: 'text', required: true, placeholder: 'TGĐ / GĐ Khối / TP…' },
{ key: 'initialsChain', label: 'Chuỗi ký nháy', type: 'text', placeholder: 'TBP.CCM→NV.CCM→TBP.EQU→NV.EQU' },
{ key: 'nduqNote', label: 'Ghi chú NĐUQ', type: 'textarea' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên tiếng Việt', 'Tên tiếng Anh', 'Nhóm', 'Nhóm duyệt', 'Vai ký chính', 'Ký nháy', 'Trạng thái'],
},
} }
const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items'] const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items', 'contract-catalog']
type CatalogRow = Record<string, unknown> & { id: string; code: string; name: string } type CatalogRow = Record<string, unknown> & { id: string; code: string; name?: string }
// Nhãn dòng dùng cho confirm xóa — kind `contract-catalog` không có `name`.
function rowLabel(row: CatalogRow): string {
return (row.name as string) || (row.tenVi as string) || row.code
}
export function CatalogsPage() { export function CatalogsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ kind?: string }>() const params = useParams<{ kind?: string }>()
const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') as Kind const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') as Kind
const config = KIND_CONFIG[kind] const config = KIND_CONFIG[kind]
const isContractCatalog = kind === 'contract-catalog'
const colCount = (isContractCatalog ? config.columns.length : kind === 'units' ? 2 : 5) + 1
const qc = useQueryClient() const qc = useQueryClient()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -96,7 +155,10 @@ export function CatalogsPage() {
const list = useQuery({ const list = useQuery({
queryKey: ['catalogs', kind, search], queryKey: ['catalogs', kind, search],
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, { params: { q: search || undefined } })).data, // kind-5: trang quản trị cần thấy CẢ dòng ngừng hiệu lực — BE List mặc định LOẠI (spec K1.c, gate-K1 F4)
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, {
params: { q: search || undefined, includeInactive: isContractCatalog ? true : undefined },
})).data,
}) })
const save = useMutation({ const save = useMutation({
@ -125,7 +187,11 @@ export function CatalogsPage() {
function openCreate() { function openCreate() {
const init: Record<string, unknown> = {} const init: Record<string, unknown> = {}
config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' }) config.fields.forEach(f => {
// select mặc định option đầu (nhóm duyệt N1) — tránh gửi rỗng → BE
// InclusiveBetween(1,8) reject.
init[f.key] = f.type === 'checkbox' ? true : f.type === 'select' ? (f.options?.[0]?.value ?? '') : ''
})
setForm(init) setForm(init)
setOpen(true) setOpen(true)
} }
@ -145,10 +211,14 @@ export function CatalogsPage() {
Danh mục chi tiết Danh mục chi tiết
</span> </span>
} }
description="Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items." description={
isContractCatalog
? 'Danh mục Hợp đồng (SOL-CCM-SP-002) — tên VN/EN, nhóm mục và nhóm duyệt N1N8 dùng cho phiếu Kế hoạch ký kết.'
: 'Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items.'
}
/> />
{/* Sub-tabs cho 4 kind */} {/* Sub-tabs cho 5 kind */}
<div className="mb-4 flex gap-1 border-b border-slate-200"> <div className="mb-4 flex gap-1 border-b border-slate-200">
{KINDS.map(k => { {KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon const KIcon = KIND_CONFIG[k].icon
@ -191,35 +261,64 @@ export function CatalogsPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500"> <thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr> <tr>
<th className="px-3 py-2 text-left"></th> {isContractCatalog ? (
<th className="px-3 py-2 text-left">Tên</th> config.columns.map(c => <th key={c} className="px-3 py-2 text-left">{c}</th>)
{kind !== 'units' && <th className="px-3 py-2 text-left">Nhóm/Loại</th>} ) : (
{kind !== 'units' && <th className="px-3 py-2 text-left">ĐVT</th>} <>
{kind !== 'units' && <th className="px-3 py-2 text-left">Trạng thái</th>} <th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th>
{kind !== 'units' && <th className="px-3 py-2 text-left">Nhóm/Loại</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">ĐVT</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">Trạng thái</th>}
</>
)}
<th className="w-20 px-3 py-2"></th> <th className="w-20 px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{list.isLoading && ( {list.isLoading && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Đang tải</td></tr> <tr><td colSpan={colCount} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)} )}
{!list.isLoading && (list.data?.length ?? 0) === 0 && ( {!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr> <tr><td colSpan={colCount} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr>
)} )}
{list.data?.map(row => ( {list.data?.map(row => (
<tr key={row.id} className="hover:bg-slate-50"> <tr key={row.id} className="hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-xs">{row.code}</td> {isContractCatalog ? (
<td className="px-3 py-2">{row.name}</td> <>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.category as string) ?? '—'}</td>} <td className="px-3 py-2 font-mono text-xs">{row.code}</td>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.defaultUnit as string) ?? '—'}</td>} <td className="px-3 py-2">{(row.tenVi as string) || '—'}</td>
{kind !== 'units' && ( <td className="px-3 py-2 text-xs text-slate-600">{(row.tenEn as string) || '—'}</td>
<td className="px-3 py-2"> <td className="px-3 py-2 font-mono text-xs text-slate-600">{(row.groupCode as string) || '—'}</td>
{(row.isActive as boolean) ? ( <td className="px-3 py-2">
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span> <ApprovalGroupBadge value={row.approvalGroup as number} />
) : ( </td>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span> <td className="px-3 py-2 text-xs text-slate-600">{(row.signerRole as string) || '—'}</td>
<td className="px-3 py-2 text-[11px] text-slate-500">{(row.initialsChain as string) || '—'}</td>
<td className="px-3 py-2">
{(row.isActive as boolean) ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)}
</td>
</>
) : (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
<td className="px-3 py-2">{row.name}</td>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.category as string) ?? '—'}</td>}
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.defaultUnit as string) ?? '—'}</td>}
{kind !== 'units' && (
<td className="px-3 py-2">
{(row.isActive as boolean) ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)}
</td>
)} )}
</td> </>
)} )}
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -232,7 +331,7 @@ export function CatalogsPage() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm(`Xóa "${row.name}"?`)) remove.mutate(row.id) if (confirm(`Xóa "${rowLabel(row)}"?`)) remove.mutate(row.id)
}} }}
title="Xóa" title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600" className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
@ -282,6 +381,28 @@ export function CatalogsPage() {
required={f.required} required={f.required}
/> />
)} )}
{f.type === 'number' && (
<Input
type="number"
value={(form[f.key] as string | number) ?? ''}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
/>
)}
{f.type === 'select' && (
<select
value={String(form[f.key] ?? '')}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
required={f.required}
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-800 focus:border-brand-600 focus:outline-none"
>
<option value=""> Chọn </option>
{f.options?.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{f.type === 'textarea' && ( {f.type === 'textarea' && (
<Textarea <Textarea
rows={3} rows={3}
@ -310,12 +431,27 @@ export function CatalogsPage() {
function buildBody(kind: Kind, form: Record<string, unknown>): Record<string, unknown> { function buildBody(kind: Kind, form: Record<string, unknown>): Record<string, unknown> {
// Build payload tương ứng BE Create/Update command — strip empty strings → null // Build payload tương ứng BE Create/Update command — strip empty strings → null
const fields = KIND_CONFIG[kind].fields.map(f => f.key)
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
for (const k of fields) { for (const f of KIND_CONFIG[kind].fields) {
const v = form[k] const v = form[f.key]
if (v === '' || v === undefined) body[k] = null // Field `numeric` map sang `int` NON-nullable ở BE (ApprovalGroup / SortOrder)
else body[k] = v // ⇒ rỗng phải là 0, KHÔNG được null (System.Text.Json ném khi null → int).
if (f.numeric) {
const n = Number(v)
body[f.key] = v === '' || v === null || v === undefined || Number.isNaN(n) ? 0 : n
} else if (v === '' || v === undefined) body[f.key] = null
else body[f.key] = v
} }
return body return body
} }
// Badge nhóm duyệt N1..N8 — màu literal (Pattern 14), cycle theo chỉ số nhóm.
function ApprovalGroupBadge({ value }: { value: number | null | undefined }) {
if (typeof value !== 'number' || value < 1) return <span className="text-xs text-slate-400"></span>
const tone = APPROVAL_GROUP_BADGE[(value - 1) % APPROVAL_GROUP_BADGE.length]
return (
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold', tone)}>
N{value}
</span>
)
}

View File

@ -93,6 +93,10 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials', CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services', CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items', CatalogWorkItems: '/master/catalogs/work-items',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SP-002 — "chỗ thứ 4" Pattern 16-bis:
// BE seed leaf `ContractCatalog` dưới group Catalogs, thiếu dòng này thì
// resolvePath trả null → MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
ContractCatalog: '/master/catalogs/contract-catalog',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON // [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới // Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent. // — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.

View File

@ -11,6 +11,10 @@ export const MenuKeys = {
CatalogMaterials: 'CatalogMaterials', CatalogMaterials: 'CatalogMaterials',
CatalogServices: 'CatalogServices', CatalogServices: 'CatalogServices',
CatalogWorkItems: 'CatalogWorkItems', CatalogWorkItems: 'CatalogWorkItems',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// Mirror BE MenuKeys.cs:28 — leaf thứ 5 dưới group `Catalogs`, page dùng chung
// CatalogsPage qua `:kind` = `contract-catalog`.
ContractCatalog: 'ContractCatalog',
Contracts: 'Contracts', Contracts: 'Contracts',
Forms: 'Forms', Forms: 'Forms',
Reports: 'Reports', Reports: 'Reports',

View File

@ -1,10 +1,15 @@
// Generic master catalogs CRUD — 1 page handle 4 kind: units / materials / // Generic master catalogs CRUD — 1 page handle 5 kind: units / materials /
// services / work-items. URL `/master/catalogs/:kind` driven, mỗi kind có // services / work-items / contract-catalog. URL `/master/catalogs/:kind` driven,
// fields config riêng. Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh. // mỗi kind có fields config riêng. Sub-tabs hiển thị 5 kind trên cùng để chuyển nhanh.
//
// [K1 — S164, Mig 70] kind thứ 5 `contract-catalog` = "Danh mục Hợp đồng"
// (SOL-CCM-SP-002, 86 dòng × 8 nhóm duyệt N1..N8). Endpoint BE
// `api/catalogs/contract-catalog` (ContractCatalogController) — cùng tiền tố
// `/catalogs/` nên tái dùng nguyên đường `/catalogs/${kind}` sẵn có, KHÔNG route mới.
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, Trash2, Search } from 'lucide-react' import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, FileSpreadsheet, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -16,9 +21,36 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
type Kind = 'units' | 'materials' | 'services' | 'work-items' type Kind = 'units' | 'materials' | 'services' | 'work-items' | 'contract-catalog'
type FieldDef = { key: string; label: string; type: 'text' | 'textarea' | 'checkbox'; required?: boolean; placeholder?: string } type FieldDef = {
key: string
label: string
type: 'text' | 'textarea' | 'checkbox' | 'number' | 'select'
required?: boolean
placeholder?: string
options?: { value: number; label: string }[]
// Ép Number khi build payload — BE nhận `int` (ApprovalGroup / SortOrder),
// gửi chuỗi "3" thì System.Text.Json bind fail.
numeric?: boolean
}
// 8 nhóm duyệt N1..N8 (SOL-CCM-SP-002 — mục "Nhóm duyệt (8)").
const APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
const APPROVAL_GROUP_OPTIONS = APPROVAL_GROUPS.map(n => ({ value: n, label: `N${n}` }))
// Pattern 14 (Tailwind JIT): KHÔNG interpolate `bg-${x}-50` — class bị purge.
// Full class literal, cycle theo (nhóm - 1) % length.
const APPROVAL_GROUP_BADGE = [
'bg-sky-50 text-sky-700 border-sky-200',
'bg-emerald-50 text-emerald-700 border-emerald-200',
'bg-amber-50 text-amber-700 border-amber-200',
'bg-violet-50 text-violet-700 border-violet-200',
'bg-rose-50 text-rose-700 border-rose-200',
'bg-teal-50 text-teal-700 border-teal-200',
'bg-indigo-50 text-indigo-700 border-indigo-200',
'bg-orange-50 text-orange-700 border-orange-200',
] as const
const KIND_CONFIG: Record<Kind, { const KIND_CONFIG: Record<Kind, {
label: string label: string
@ -76,17 +108,44 @@ const KIND_CONFIG: Record<Kind, {
], ],
columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'], columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'],
}, },
// [K1 — S164] Danh mục Hợp đồng SOL-CCM-SP-002. Shape KHÁC 4 kind cũ
// (tenVi/tenEn thay `name`, thêm groupCode + approvalGroup + 2 cột vai ký)
// ⇒ header + cell có nhánh riêng bên dưới, `columns` dưới đây là nguồn header.
'contract-catalog': {
label: 'Danh mục Hợp đồng',
icon: FileSpreadsheet,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'A1-01, B2-07, CEO-01…' },
{ key: 'groupCode', label: 'Nhóm mục *', type: 'text', required: true, placeholder: 'A1, A2, B1, C3, CEO…' },
{ key: 'tenVi', label: 'Tên tiếng Việt *', type: 'text', required: true, placeholder: 'Hợp đồng thi công xây dựng…' },
{ key: 'tenEn', label: 'Tên tiếng Anh', type: 'text', placeholder: 'Construction contract…' },
{ key: 'approvalGroup', label: 'Nhóm duyệt (N1N8) *', type: 'select', required: true, numeric: true, options: APPROVAL_GROUP_OPTIONS },
{ key: 'sortOrder', label: 'Thứ tự trong nhóm', type: 'number', numeric: true, placeholder: '0' },
{ key: 'signerRole', label: 'Vai ký chính *', type: 'text', required: true, placeholder: 'TGĐ / GĐ Khối / TP…' },
{ key: 'initialsChain', label: 'Chuỗi ký nháy', type: 'text', placeholder: 'TBP.CCM→NV.CCM→TBP.EQU→NV.EQU' },
{ key: 'nduqNote', label: 'Ghi chú NĐUQ', type: 'textarea' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên tiếng Việt', 'Tên tiếng Anh', 'Nhóm', 'Nhóm duyệt', 'Vai ký chính', 'Ký nháy', 'Trạng thái'],
},
} }
const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items'] const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items', 'contract-catalog']
type CatalogRow = Record<string, unknown> & { id: string; code: string; name: string } type CatalogRow = Record<string, unknown> & { id: string; code: string; name?: string }
// Nhãn dòng dùng cho confirm xóa — kind `contract-catalog` không có `name`.
function rowLabel(row: CatalogRow): string {
return (row.name as string) || (row.tenVi as string) || row.code
}
export function CatalogsPage() { export function CatalogsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ kind?: string }>() const params = useParams<{ kind?: string }>()
const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') as Kind const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') as Kind
const config = KIND_CONFIG[kind] const config = KIND_CONFIG[kind]
const isContractCatalog = kind === 'contract-catalog'
const colCount = (isContractCatalog ? config.columns.length : kind === 'units' ? 2 : 5) + 1
const qc = useQueryClient() const qc = useQueryClient()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -96,7 +155,10 @@ export function CatalogsPage() {
const list = useQuery({ const list = useQuery({
queryKey: ['catalogs', kind, search], queryKey: ['catalogs', kind, search],
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, { params: { q: search || undefined } })).data, // kind-5: trang quản trị cần thấy CẢ dòng ngừng hiệu lực — BE List mặc định LOẠI (spec K1.c, gate-K1 F4)
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, {
params: { q: search || undefined, includeInactive: isContractCatalog ? true : undefined },
})).data,
}) })
const save = useMutation({ const save = useMutation({
@ -125,7 +187,11 @@ export function CatalogsPage() {
function openCreate() { function openCreate() {
const init: Record<string, unknown> = {} const init: Record<string, unknown> = {}
config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' }) config.fields.forEach(f => {
// select mặc định option đầu (nhóm duyệt N1) — tránh gửi rỗng → BE
// InclusiveBetween(1,8) reject.
init[f.key] = f.type === 'checkbox' ? true : f.type === 'select' ? (f.options?.[0]?.value ?? '') : ''
})
setForm(init) setForm(init)
setOpen(true) setOpen(true)
} }
@ -145,10 +211,14 @@ export function CatalogsPage() {
Danh mục chi tiết Danh mục chi tiết
</span> </span>
} }
description="Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items." description={
isContractCatalog
? 'Danh mục Hợp đồng (SOL-CCM-SP-002) — tên VN/EN, nhóm mục và nhóm duyệt N1N8 dùng cho phiếu Kế hoạch ký kết.'
: 'Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items.'
}
/> />
{/* Sub-tabs cho 4 kind */} {/* Sub-tabs cho 5 kind */}
<div className="mb-4 flex gap-1 border-b border-slate-200"> <div className="mb-4 flex gap-1 border-b border-slate-200">
{KINDS.map(k => { {KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon const KIcon = KIND_CONFIG[k].icon
@ -191,35 +261,64 @@ export function CatalogsPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500"> <thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr> <tr>
<th className="px-3 py-2 text-left"></th> {isContractCatalog ? (
<th className="px-3 py-2 text-left">Tên</th> config.columns.map(c => <th key={c} className="px-3 py-2 text-left">{c}</th>)
{kind !== 'units' && <th className="px-3 py-2 text-left">Nhóm/Loại</th>} ) : (
{kind !== 'units' && <th className="px-3 py-2 text-left">ĐVT</th>} <>
{kind !== 'units' && <th className="px-3 py-2 text-left">Trạng thái</th>} <th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th>
{kind !== 'units' && <th className="px-3 py-2 text-left">Nhóm/Loại</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">ĐVT</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">Trạng thái</th>}
</>
)}
<th className="w-20 px-3 py-2"></th> <th className="w-20 px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{list.isLoading && ( {list.isLoading && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Đang tải</td></tr> <tr><td colSpan={colCount} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)} )}
{!list.isLoading && (list.data?.length ?? 0) === 0 && ( {!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr> <tr><td colSpan={colCount} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr>
)} )}
{list.data?.map(row => ( {list.data?.map(row => (
<tr key={row.id} className="hover:bg-slate-50"> <tr key={row.id} className="hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-xs">{row.code}</td> {isContractCatalog ? (
<td className="px-3 py-2">{row.name}</td> <>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.category as string) ?? '—'}</td>} <td className="px-3 py-2 font-mono text-xs">{row.code}</td>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.defaultUnit as string) ?? '—'}</td>} <td className="px-3 py-2">{(row.tenVi as string) || '—'}</td>
{kind !== 'units' && ( <td className="px-3 py-2 text-xs text-slate-600">{(row.tenEn as string) || '—'}</td>
<td className="px-3 py-2"> <td className="px-3 py-2 font-mono text-xs text-slate-600">{(row.groupCode as string) || '—'}</td>
{(row.isActive as boolean) ? ( <td className="px-3 py-2">
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span> <ApprovalGroupBadge value={row.approvalGroup as number} />
) : ( </td>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span> <td className="px-3 py-2 text-xs text-slate-600">{(row.signerRole as string) || '—'}</td>
<td className="px-3 py-2 text-[11px] text-slate-500">{(row.initialsChain as string) || '—'}</td>
<td className="px-3 py-2">
{(row.isActive as boolean) ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)}
</td>
</>
) : (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
<td className="px-3 py-2">{row.name}</td>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.category as string) ?? '—'}</td>}
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.defaultUnit as string) ?? '—'}</td>}
{kind !== 'units' && (
<td className="px-3 py-2">
{(row.isActive as boolean) ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)}
</td>
)} )}
</td> </>
)} )}
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -232,7 +331,7 @@ export function CatalogsPage() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm(`Xóa "${row.name}"?`)) remove.mutate(row.id) if (confirm(`Xóa "${rowLabel(row)}"?`)) remove.mutate(row.id)
}} }}
title="Xóa" title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600" className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
@ -282,6 +381,28 @@ export function CatalogsPage() {
required={f.required} required={f.required}
/> />
)} )}
{f.type === 'number' && (
<Input
type="number"
value={(form[f.key] as string | number) ?? ''}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
/>
)}
{f.type === 'select' && (
<select
value={String(form[f.key] ?? '')}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
required={f.required}
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-800 focus:border-brand-600 focus:outline-none"
>
<option value=""> Chọn </option>
{f.options?.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{f.type === 'textarea' && ( {f.type === 'textarea' && (
<Textarea <Textarea
rows={3} rows={3}
@ -310,12 +431,27 @@ export function CatalogsPage() {
function buildBody(kind: Kind, form: Record<string, unknown>): Record<string, unknown> { function buildBody(kind: Kind, form: Record<string, unknown>): Record<string, unknown> {
// Build payload tương ứng BE Create/Update command — strip empty strings → null // Build payload tương ứng BE Create/Update command — strip empty strings → null
const fields = KIND_CONFIG[kind].fields.map(f => f.key)
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
for (const k of fields) { for (const f of KIND_CONFIG[kind].fields) {
const v = form[k] const v = form[f.key]
if (v === '' || v === undefined) body[k] = null // Field `numeric` map sang `int` NON-nullable ở BE (ApprovalGroup / SortOrder)
else body[k] = v // ⇒ rỗng phải là 0, KHÔNG được null (System.Text.Json ném khi null → int).
if (f.numeric) {
const n = Number(v)
body[f.key] = v === '' || v === null || v === undefined || Number.isNaN(n) ? 0 : n
} else if (v === '' || v === undefined) body[f.key] = null
else body[f.key] = v
} }
return body return body
} }
// Badge nhóm duyệt N1..N8 — màu literal (Pattern 14), cycle theo chỉ số nhóm.
function ApprovalGroupBadge({ value }: { value: number | null | undefined }) {
if (typeof value !== 'number' || value < 1) return <span className="text-xs text-slate-400"></span>
const tone = APPROVAL_GROUP_BADGE[(value - 1) % APPROVAL_GROUP_BADGE.length]
return (
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold', tone)}>
N{value}
</span>
)
}

View File

@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Master.Catalogs;
namespace SolutionErp.Api.Controllers;
// [K1 — S164, Mig 70] API "Danh mục Hợp đồng" (SOL-CCM-SP-002, 86 dòng × 8 nhóm duyệt).
//
// Route nằm DƯỚI `api/catalogs/` để đồng bộ với 4 catalog cũ (FE dùng chung trang
// KIND_CONFIG theo `:kind` → kind mới = `contract-catalog`), nhưng tách CONTROLLER
// riêng cho gọn (CatalogsController đã 4 kind × 4 verb).
//
// AUTHZ (QUYẾT-1 S164 — mirror y CatalogsController.cs:12-14, KHÔNG policy key mới):
// - class `[Authorize]` = mọi user đăng nhập ĐỌC được → picker chọn hạng mục ở
// màn tạo phiếu KHKK (K2) không bị 403 vì thiếu grant leaf (gotcha #44/#85).
// - mọi action GHI `[Authorize(Roles = "Admin")]` — sửa danh mục là việc quản trị,
// KHÔNG phải quyền của drafter phiếu.
[ApiController]
[Route("api/catalogs/contract-catalog")]
[Authorize]
public class ContractCatalogController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<List<ContractCatalogEntryDto>> List(
[FromQuery] string? groupCode,
[FromQuery] int? approvalGroup,
[FromQuery] bool includeInactive,
[FromQuery] string? q,
CancellationToken ct)
=> await mediator.Send(new ListContractCatalogQuery(groupCode, approvalGroup, includeInactive, q), ct);
[HttpGet("{id:guid}")]
public async Task<ContractCatalogEntryDto> Get(Guid id, CancellationToken ct)
=> await mediator.Send(new GetContractCatalogEntryQuery(id), ct);
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create([FromBody] CreateContractCatalogEntryCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractCatalogEntryCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractCatalogEntryCommand(id), ct);
return NoContent();
}
}

View File

@ -24,6 +24,8 @@ public interface IApplicationDbContext
DbSet<MaterialItem> MaterialItems { get; } DbSet<MaterialItem> MaterialItems { get; }
DbSet<ServiceItem> ServiceItems { get; } DbSet<ServiceItem> ServiceItems { get; }
DbSet<WorkItem> WorkItems { get; } DbSet<WorkItem> WorkItems { get; }
// [K1 S164 — Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt)
DbSet<ContractCatalogEntry> ContractCatalogEntries { get; }
DbSet<MenuItem> MenuItems { get; } DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; } DbSet<Permission> Permissions { get; }
DbSet<User> Users { get; } DbSet<User> Users { get; }

View File

@ -0,0 +1,226 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Master.Catalogs;
namespace SolutionErp.Application.Master.Catalogs;
// [K1 — S164, Mig 70] CRUD CQRS cho "Danh mục Hợp đồng" (SOL-CCM-SP-002, 86 dòng).
// File RIÊNG cạnh CatalogsFeatures.cs (mega-file 4 catalog cũ) vì shape khác hẳn:
// có GroupCode + ApprovalGroup (1-8 = nhóm duyệt N1..N8) + 2 cột vai-ký dạng TEXT.
//
// Endpoints (ContractCatalogController, route `api/catalogs/contract-catalog`):
// GET /api/catalogs/contract-catalog — list (filter groupCode/approvalGroup/q;
// mặc định CHỈ trả IsActive=true — spec K1.c `IncludeInactive?` mặc-định-loại;
// truyền includeInactive=true để thấy cả dòng ngừng hiệu lực — gate-K1 F4 @S165)
// GET /api/catalogs/contract-catalog/{id} — get by id
// POST /api/catalogs/contract-catalog — create (Admin)
// PUT /api/catalogs/contract-catalog/{id} — update (Admin)
// DELETE /api/catalogs/contract-catalog/{id} — soft delete (Admin)
//
// KHÔNG paging: 86 dòng, dùng chung cho trang quản trị + picker chọn hạng mục (K2).
// Dup-Code check chạy trên ACTIVE-set (query filter !IsDeleted) — khớp UNIQUE index
// filtered `[IsDeleted] = 0` ⇒ Code của row đã xoá mềm được tái sử dụng (gotcha #57).
// ===== DTO =====
public record ContractCatalogEntryDto(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder);
// ===== List =====
public record ListContractCatalogQuery(
string? GroupCode = null,
int? ApprovalGroup = null,
bool IncludeInactive = false,
string? Q = null) : IRequest<List<ContractCatalogEntryDto>>;
public class ListContractCatalogHandler(IApplicationDbContext db)
: IRequestHandler<ListContractCatalogQuery, List<ContractCatalogEntryDto>>
{
public async Task<List<ContractCatalogEntryDto>> Handle(ListContractCatalogQuery req, CancellationToken ct)
{
var q = db.ContractCatalogEntries.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.GroupCode))
q = q.Where(x => x.GroupCode == req.GroupCode);
if (req.ApprovalGroup is int g)
q = q.Where(x => x.ApprovalGroup == g);
if (!req.IncludeInactive)
q = q.Where(x => x.IsActive);
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s)
|| x.TenVi.ToLower().Contains(s)
|| (x.TenEn != null && x.TenEn.ToLower().Contains(s)));
}
return await q.OrderBy(x => x.GroupCode).ThenBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.ToListAsync(ct);
}
}
// ===== Get by id =====
public record GetContractCatalogEntryQuery(Guid Id) : IRequest<ContractCatalogEntryDto>;
public class GetContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<GetContractCatalogEntryQuery, ContractCatalogEntryDto>
{
public async Task<ContractCatalogEntryDto> Handle(GetContractCatalogEntryQuery req, CancellationToken ct)
=> await db.ContractCatalogEntries.AsNoTracking()
.Where(x => x.Id == req.Id)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
}
// ===== Create =====
public record CreateContractCatalogEntryCommand(
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest<Guid>;
// MaxLength khớp ĐÚNG EF config (ContractCatalogEntryConfiguration.cs:18-24) —
// EF là source of truth, KHÔNG lấy số từ spec.
public class CreateContractCatalogEntryValidator : AbstractValidator<CreateContractCatalogEntryCommand>
{
public CreateContractCatalogEntryValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class CreateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<CreateContractCatalogEntryCommand, Guid>
{
public async Task<Guid> Handle(CreateContractCatalogEntryCommand req, CancellationToken ct)
{
if (await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new ContractCatalogEntry
{
Code = req.Code,
TenVi = req.TenVi,
TenEn = req.TenEn,
GroupCode = req.GroupCode,
ApprovalGroup = req.ApprovalGroup,
SignerRole = req.SignerRole,
InitialsChain = req.InitialsChain,
NduqNote = req.NduqNote,
IsActive = req.IsActive,
SortOrder = req.SortOrder,
};
db.ContractCatalogEntries.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ===== Update =====
public record UpdateContractCatalogEntryCommand(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest;
public class UpdateContractCatalogEntryValidator : AbstractValidator<UpdateContractCatalogEntryCommand>
{
public UpdateContractCatalogEntryValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class UpdateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<UpdateContractCatalogEntryCommand>
{
public async Task Handle(UpdateContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
if (entity.Code != req.Code && await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.TenVi = req.TenVi;
entity.TenEn = req.TenEn;
entity.GroupCode = req.GroupCode;
entity.ApprovalGroup = req.ApprovalGroup;
entity.SignerRole = req.SignerRole;
entity.InitialsChain = req.InitialsChain;
entity.NduqNote = req.NduqNote;
entity.IsActive = req.IsActive;
entity.SortOrder = req.SortOrder;
await db.SaveChangesAsync(ct);
}
}
// ===== Delete (soft) =====
public record DeleteContractCatalogEntryCommand(Guid Id) : IRequest;
public class DeleteContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<DeleteContractCatalogEntryCommand>
{
public async Task Handle(DeleteContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
db.ContractCatalogEntries.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}

View File

@ -25,6 +25,10 @@ public static class MenuKeys
public const string CatalogMaterials = "CatalogMaterials"; public const string CatalogMaterials = "CatalogMaterials";
public const string CatalogServices = "CatalogServices"; public const string CatalogServices = "CatalogServices";
public const string CatalogWorkItems = "CatalogWorkItems"; public const string CatalogWorkItems = "CatalogWorkItems";
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// 🔴 VÀO `All` (lệnh lead S164) ⇒ sinh 4 policy ContractCatalog.{Read,Create,Update,Delete}
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +1 · Policies +4 (DERIVED |All|×|Actions|).
public const string ContractCatalog = "ContractCatalog";
// Per-contract-type menu groups + 3 action leaves each. // Per-contract-type menu groups + 3 action leaves each.
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from // Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
@ -166,6 +170,7 @@ public static class MenuKeys
Dashboard, Dashboard,
Master, Suppliers, Projects, Departments, Master, Suppliers, Projects, Departments,
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems, Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
ContractCatalog, // [K1 Mig 70 — S164] Danh mục Hợp đng SP-002
Contracts, Forms, Reports, Contracts, Forms, Reports,
PurchaseEvaluations, PurchaseEvaluations,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1 Hrm, HrmHoSo, // Mig 34 — Phase 10.1

View File

@ -0,0 +1,25 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Master.Catalogs;
// [K1 — S164, Mig 70] Master data "Danh mục Hợp đồng" theo quy trình SOL-CCM-SP-002.v01
// (bảng 5.1 A1..C3 + mục 5.2 CEO = 86 dòng). Mỗi dòng = 1 loại hạng mục hợp đồng,
// gắn 1 trong 8 NHÓM DUYỆT (N1..N8) quyết định workflow KHKK nào áp cho phiếu.
//
// Vai ký lưu dạng TEXT (SignerRole + InitialsChain) — 2 TẦNG cố ý (QĐ-4 spec S164):
// TEXT ở đây = chuỗi in trên khối ký giai đoạn 4 (bản cứng)
// ⟂ ApprovalWorkflowLevel.ApproverUserId = NGƯỜI THẬT duyệt trên hệ thống.
// Limitation đã khai: TEXT KHÔNG ràng buộc máy với người-duyệt-thật.
public class ContractCatalogEntry : AuditableEntity
{
public string Code { get; set; } = ""; // Mã dòng danh mục ("A1-01" … "CEO-01") — UNIQUE filtered [IsDeleted]=0
public string TenVi { get; set; } = ""; // Tên tiếng Việt
public string? TenEn { get; set; } // Tên tiếng Anh
public string GroupCode { get; set; } = ""; // Section SP-002: A1..A4, B1..B3, C1..C3, CEO
public int ApprovalGroup { get; set; } // 1..8 → nhóm duyệt N1..N8 (validator range, không CHECK constraint)
public string SignerRole { get; set; } = ""; // Vai KÝ CHÍNH dạng TEXT (TP.CCM / TP.PRO / PD-PM / CEO)
public string? InitialsChain { get; set; } // Chuỗi ký nháy Ô1→Ô4 ("TBP.CCM → NV.CCM → TBP.EQU → NV.EQU"), bỏ N/A
public string? NduqNote { get; set; } // Tham khảo Nghị định ủy quyền (OG-3) — seed null, nhập tay sau
public bool IsActive { get; set; } = true; // Ẩn khỏi picker mà không cần xoá
public int SortOrder { get; set; } // Thứ tự trong section (= STT trên bảng gốc)
}

View File

@ -28,6 +28,8 @@ public class ApplicationDbContext
public DbSet<MaterialItem> MaterialItems => Set<MaterialItem>(); public DbSet<MaterialItem> MaterialItems => Set<MaterialItem>();
public DbSet<ServiceItem> ServiceItems => Set<ServiceItem>(); public DbSet<ServiceItem> ServiceItems => Set<ServiceItem>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>(); public DbSet<WorkItem> WorkItems => Set<WorkItem>();
// [K1 S164 — Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt)
public DbSet<ContractCatalogEntry> ContractCatalogEntries => Set<ContractCatalogEntry>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>(); public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<Permission> Permissions => Set<Permission>(); public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>(); public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Master.Catalogs;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [K1 — S164, Mig 70] EF config cho danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng).
// File RIÊNG (không nhét vào CatalogsConfiguration.cs) — mirror khuôn PE/KHKK
// 1-entity-1-file cho entity mới. Pattern: Code unique FILTERED [IsDeleted]=0
// (gotcha #57 — soft-delete phải filter) + query filter soft-delete + index
// ApprovalGroup cho picker K2 lọc theo nhóm phiếu.
public class ContractCatalogEntryConfiguration : IEntityTypeConfiguration<ContractCatalogEntry>
{
public void Configure(EntityTypeBuilder<ContractCatalogEntry> b)
{
b.ToTable("ContractCatalogEntries");
b.HasKey(x => x.Id);
b.Property(x => x.Code).HasMaxLength(20).IsRequired();
b.Property(x => x.TenVi).HasMaxLength(500).IsRequired();
b.Property(x => x.TenEn).HasMaxLength(500);
b.Property(x => x.GroupCode).HasMaxLength(10).IsRequired();
b.Property(x => x.SignerRole).HasMaxLength(100).IsRequired();
b.Property(x => x.InitialsChain).HasMaxLength(300);
b.Property(x => x.NduqNote).HasMaxLength(500);
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.SortOrder).HasDefaultValue(0);
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0");
b.HasIndex(x => x.ApprovalGroup);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -126,6 +126,9 @@ public static class DbInitializer
// S55 (2026-06-09) — Master data thật của công ty (62 dự án + 71 hạng mục + // S55 (2026-06-09) — Master data thật của công ty (62 dự án + 71 hạng mục +
// 3 NCC). NOT gated DemoSeed — coexist với demo data qua per-code idempotent. // 3 NCC). NOT gated DemoSeed — coexist với demo data qua per-code idempotent.
await SeedRealMasterDataAsync(db, logger); await SeedRealMasterDataAsync(db, logger);
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng, 8 nhóm duyệt).
// NOT gated DemoSeed — master data thật, prod cần ngay deploy đầu (gotcha #51).
await SeedContractCatalogAsync(db, logger);
// Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create). // Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create).
// Idempotent: chỉ HĐ MaHopDong IS NULL được gen. // Idempotent: chỉ HĐ MaHopDong IS NULL được gen.
@ -1767,6 +1770,10 @@ public static class DbInitializer
(MenuKeys.CatalogMaterials,"Vật tư / SP", MenuKeys.Catalogs, 2, "Package"), (MenuKeys.CatalogMaterials,"Vật tư / SP", MenuKeys.Catalogs, 2, "Package"),
(MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"), (MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"),
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"), (MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
// [K1 Mig 70 — S164] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// Leaf thứ 5 dưới nhóm "Danh mục chi tiết". Key CÓ trong MenuKeys.All ⇒ Admin
// được grant tự động qua loop SeedAdminPermissionsAsync :2058.
(MenuKeys.ContractCatalog, "Danh mục Hợp đồng", MenuKeys.Catalogs, 5, "FileSpreadsheet"),
// [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro). // [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro).
(MenuKeys.Contracts, "Duyệt hợp đồng", null, 31, "FileText"), // [S159] rename theo 4-GĐ (owner tab strip) (MenuKeys.Contracts, "Duyệt hợp đồng", null, 31, "FileText"), // [S159] rename theo 4-GĐ (owner tab strip)
// [S159] 2 root PLACEHOLDER GĐ2/GĐ4 — menu = 4 giai đoạn (owner). FE staticMap trỏ // [S159] 2 root PLACEHOLDER GĐ2/GĐ4 — menu = 4 giai đoạn (owner). FE staticMap trỏ
@ -3137,6 +3144,185 @@ public static class DbInitializer
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
// [K1 — S164, Mig 70] Danh mục Hợp đồng theo quy trình SOL-CCM-SP-002.v01
// (bảng 5.1 mục A1..C3 = 85 dòng + mục 5.2 CEO 1 dòng = 86 row). Mỗi dòng gắn 1
// trong 8 NHÓM DUYỆT N1..N8 — nhóm quyết định workflow KHKK nào áp cho phiếu (K2/K3).
//
// NOT gated DemoSeed:Disabled — đây là INFRASTRUCTURE master data thật (mirror
// SeedRealMasterDataAsync, xem note :2893 gotcha #51), phải có mặt prod ngay deploy đầu.
//
// Idempotent PER-CODE + `IgnoreQueryFilters()` — né #75/#76 (resurrect): row đã
// xoá-mềm VẪN tính là "đã tồn tại" ⇒ KHÔNG re-create mỗi restart. Cố ý LÀM ĐÚNG
// HƠN khuôn :2970 (khuôn dò `db.Projects.Select(p => p.Code)` KHÔNG IgnoreQueryFilters
// ⇒ row soft-deleted vô hình → hồi sinh mỗi lần boot).
//
// SignerRole + InitialsChain = TEXT denorm theo nhóm (QĐ-4 spec S164: TEXT in trên
// khối ký bản cứng GĐ4 ⟂ ApprovalWorkflowLevel.ApproverUserId = người duyệt THẬT
// trên hệ thống). Lấy từ lookup 8-nhóm rồi ghi vào TỪNG row (denorm per-row) —
// chép tay cùng chuỗi 86 lần dễ lệch ký tự.
private static async Task SeedContractCatalogAsync(ApplicationDbContext db, ILogger logger)
{
// 8 nhóm duyệt — (Ký chính, Ký nháy Ô1→Ô4). Ô "N/A" trong bảng gốc đã BỎ.
var signByGroup = new Dictionary<int, (string SignerRole, string InitialsChain)>
{
[1] = ("TP.CCM", "TBP.CCM → NV.CCM → TBP.EQU → NV.EQU"),
[2] = ("TP.CCM", "TBP.CCM → PD/PM → SM → QS"),
[3] = ("TP.CCM", "TBP.CCM → TPB/TBP → TBP/NV.PB"),
[4] = ("TP.PRO", "TBP.PRO → NV.PRO"),
[5] = ("TP.PRO", "TP.CCM → TBP.PRO → PD/PM/SM → QS"),
[6] = ("TP.PRO", "TP.CCM → TBP.PRO → PD/PM/SM → MEP SM"),
[7] = ("PD/PM", "TP.CCM → SM → QS"),
[8] = ("CEO", "TP.CCM → TP.PRO"),
};
// (Code, TenVi, TenEn, GroupCode, ApprovalGroup 1-8, SortOrder = STT trong section)
// Nguồn: transcribe 7 ảnh SOL-CCM-SP-002.v01 trang 5-9 (OG-6 owner soát).
var entries = new (string Code, string TenVi, string TenEn, string GroupCode, int ApprovalGroup, int SortOrder)[]
{
// ----- A1 · Thiết bị, máy móc, vật tư phụ (5) → N1 -----
("A1-01", "Thuê thiết bị/máy móc/dụng cụ thi công/thiết bị an toàn lao động/thiết bị văn phòng (máy photo, máy in)", "Rental of construction equipment, machinery, and tools; occupational safety equipment; and office equipment (photocopiers, printers)", "A1", 1, 1),
("A1-02", "Thuê container văn phòng, văn phòng BCH CT, nhà vệ sinh, nhà bảo vệ", "Rental of office containers, site offices, toilets, and guardhouses", "A1", 1, 2),
("A1-03", "Cung cấp vật tư phụ phục vụ công tác tạm/tiện ích công trường", "Supply of materials for temporary works and site utilities", "A1", 1, 3),
("A1-04", "Cung cấp vật tư phụ thi công: nylon bảo dưỡng, Sika, khoan cấy, …", "Supply of auxiliary construction materials: curing nylon sheets, Sika products, chemical anchors, etc", "A1", 1, 4),
("A1-05", "Cung cấp vật tư biện pháp: sika, lưới mắt cáo, thanh trương nở, …", "Supply of method-related materials: Sika products, wire mesh, expansion bars, etc", "A1", 1, 5),
// ----- A2 · Kết cấu, hạ tầng, phá dỡ (1) → N2 -----
("A2-01", "Thầu phụ hạ tầng & phá dỡ", "Infrastructure and demolition subcontracting works", "A2", 2, 1),
// ----- A3 · Dịch vụ hỗ trợ thi công (9) → N2 -----
("A3-01", "Dịch vụ ca cẩu", "Crane rental services", "A3", 2, 1),
("A3-02", "Dịch vụ vận chuyển", "Transportation services", "A3", 2, 2),
("A3-03", "Trắc đạc", "Surveying services", "A3", 2, 3),
("A3-04", "Thí nghiệm", "Testing services", "A3", 2, 4),
("A3-05", "Bảo vệ", "Security services", "A3", 2, 5),
("A3-06", "Dịch vụ giấy phép, tư vấn, thiết kế (kể cả dịch vụ tư vấn MEP)", "Licensing, consultancy, and design services (including MEP consultancy services)", "A3", 2, 6),
("A3-07", "Điện tạm", "Temporary power supply", "A3", 2, 7),
("A3-08", "Cấp và thoát nước tạm", "Temporary water supply and drainage", "A3", 2, 8),
("A3-09", "Cung cấp tiện ích công trường: tủ hồ sơ, bàn ghế, internet, văn phòng phẩm, thiết bị bảo hộ lao động (PPE), nước uống, dịch vụ y tế, bảng biểu an toàn, …", "Supply of site utilities: filing cabinets, desks and chairs, internet, stationery, personal protective equipment (PPE), drinking water, medical services, safety signage, etc.", "A3", 2, 9),
// ----- A4 · Hoạt động văn phòng (5) → N3 -----
("A4-01", "Thuê văn phòng", "Office leasing", "A4", 3, 1),
("A4-02", "Văn phòng phẩm", "Stationery", "A4", 3, 2),
("A4-03", "Bảo hiểm cháy nổ văn phòng", "Office fire and explosion insurance", "A4", 3, 3),
("A4-04", "Thiết bị máy tính, phần mềm", "Computer equipment and software", "A4", 3, 4),
("A4-05", "Các hợp đồng khác liên quan đến hoạt động văn phòng", "Other contracts related to office operations", "A4", 3, 5),
// ----- B1 · Vật tư (23) → N4 -----
("B1-01", "Đá", "Stone", "B1", 4, 1),
("B1-02", "Cát", "Sand", "B1", 4, 2),
("B1-03", "Cừ tràm", "Mangrove piles", "B1", 4, 3),
("B1-04", "Bê tông", "Concrete", "B1", 4, 4),
("B1-05", "Xi măng", "Cement", "B1", 4, 5),
("B1-06", "Thép", "Steel", "B1", 4, 6),
("B1-07", "Ván phủ phim", "Film-faced plywood", "B1", 4, 7),
("B1-08", "Gạch ốp lát", "Floor and wall tiles", "B1", 4, 8),
("B1-09", "Gạch xây", "Bricks", "B1", 4, 9),
("B1-10", "Nylon", "Nylon sheets", "B1", 4, 10),
("B1-11", "Vữa grouting", "Grouting", "B1", 4, 11),
("B1-12", "Chất tăng cứng bề mặt", "Hardener", "B1", 4, 12),
("B1-13", "Sơn nước", "Emulsion paint", "B1", 4, 13),
("B1-14", "Sơn dầu", "Oil-based paint", "B1", 4, 14),
("B1-15", "Bột matic", "Matic", "B1", 4, 15),
("B1-16", "Vật liệu hoàn thiện sàn", "Floor finishing materials", "B1", 4, 16),
("B1-17", "Thanh cản nước", "Waterbar", "B1", 4, 17),
("B1-18", "Vật liệu chống thấm", "Waterproofing", "B1", 4, 18),
("B1-19", "Đá granite", "Granite stone", "B1", 4, 19),
("B1-20", "Thiết bị vệ sinh", "Sanitary equipment", "B1", 4, 20),
("B1-21", "Vật liệu hoàn thiện", "Finishing materials", "B1", 4, 21),
("B1-22", "Vật tư, thiết bị MEP (cáp điện, tủ điện, máy móc của hệ MEP)", "MEP materials and equipment (electrical cables, panels, machinery for MEP systems)", "B1", 4, 22),
("B1-23", "Vật tư chính khác", "Other main materials", "B1", 4, 23),
// ----- B2 · Thầu phụ xây dựng (14) → N5 -----
// ⚠️ Ảnh gốc đánh số 1-13 nhưng TRÙNG số "7" ×2 (kim loại + cảnh quan);
// lead transcribe đánh lại 01-14 ⇒ B2 = 14 dòng (KHÔNG phải 13). Giữ AS-IS.
("B2-01", "Ép cọc, cừ Larsen", "Pile works and sheet pile", "B2", 5, 1),
("B2-02", "Trần thạch cao", "Gypsum ceiling works", "B2", 5, 2),
("B2-03", "Đá granite", "Granite", "B2", 5, 3),
("B2-04", "Kết cấu thép", "Steel structure", "B2", 5, 4),
("B2-05", "Cửa đi, cửa sổ", "Doors and windows", "B2", 5, 5),
("B2-06", "Cửa chống cháy, thoát hiểm", "Fire-rated and emergency exit doors", "B2", 5, 6),
("B2-07", "Công tác kim loại", "Metal", "B2", 5, 7), // ảnh: số 7 thứ nhất
("B2-08", "Cảnh quan", "Landscaping", "B2", 5, 8), // ảnh: số 7 thứ hai ⚠️
("B2-09", "Công tác kính", "Glazing", "B2", 5, 9), // ảnh: số 8
("B2-10", "Rèm cửa", "Curtain", "B2", 5, 10), // ảnh: 9
("B2-11", "Nội thất", "Interior", "B2", 5, 11), // ảnh: 10
("B2-12", "Chống thấm", "Waterproofing", "B2", 5, 12), // ảnh: 11
("B2-13", "Hoàn thiện sàn nâng", "Raised floor finishing", "B2", 5, 13),// ảnh: 12
("B2-14", "Thầu phụ xây dựng khác", "Other civil subcontractors", "B2", 5, 14), // ảnh: 13
// ----- B3 · Thầu phụ MEP (15) → N6 -----
("B3-01", "Hệ thống điện trung thế", "Medium-voltage power systems", "B3", 6, 1),
("B3-02", "Máy phát điện", "Generator systems", "B3", 6, 2),
("B3-03", "Hệ thống điện hạ thế và chiếu sáng", "Low-voltage power and lighting systems", "B3", 6, 3),
("B3-04", "Hệ thống điện nhẹ ELV", "Extra Low Voltage (ELV) systems", "B3", 6, 4),
("B3-05", "Hệ thống lạnh (Máy lạnh, Chiller, Cooling Tower, Process cooling...)", "Cooling systems (air conditioning, chillers, cooling towers, process cooling, etc.)", "B3", 6, 5),
("B3-06", "Hệ thống thông gió", "Ventilation systems", "B3", 6, 6),
("B3-07", "Hệ thống cấp thoát nước", "Water supply and drainage systems", "B3", 6, 7),
("B3-08", "Hệ thống xử lý nước", "Water treatment systems", "B3", 6, 8),
("B3-09", "Hệ thống chữa cháy", "Firefighting systems", "B3", 6, 9),
("B3-10", "Hệ thống báo cháy", "Fire alarm systems", "B3", 6, 10),
("B3-11", "Hệ thống chống sét", "Lightning protection systems", "B3", 6, 11),
("B3-12", "Hệ thống Gas", "Gas systems", "B3", 6, 12),
("B3-13", "Hệ thống khí nén", "Compressed air systems", "B3", 6, 13),
("B3-14", "Thiết bị bếp", "Kitchen equipment systems", "B3", 6, 14),
("B3-15", "Thầu phụ MEP khác", "Other MEP subcontractors", "B3", 6, 15),
// ----- C1 · Kết cấu, công tác tạm (6) → N7 -----
("C1-01", "Nhân công công tác tạm, vệ sinh công nghiệp", "Labor for temporary works and industrial cleaning", "C1", 7, 1),
("C1-02", "Nhân công khoán đội, công nhật", "Construction teams and daily workers", "C1", 7, 2),
("C1-03", "Nhân công thầu phụ", "Subcontractor labor", "C1", 7, 3),
("C1-04", "Thầu phụ trọn gói", "Lump-sum subcontractors", "C1", 7, 4),
("C1-05", "Thầu phụ giàn giáo", "Scaffolding subcontractors", "C1", 7, 5),
("C1-06", "Cơ giới, ca máy", "Machinery and equipment shifts", "C1", 7, 6),
// ----- C2 · Xây tô, sơn nước, hoàn thiện, ốp lát (3) → N7 -----
("C2-01", "Thi công sơn nước", "Painting", "C2", 7, 1),
("C2-02", "Thi công công tác hoàn thiện (xây tô, ốp lát)", "Finishing works (masonry, plastering, tiling)", "C2", 7, 2),
("C2-03", "Thi công defect, bó vỉa, xoa nền, …", "Defect rectification works, kerb works, floor finishing/polishing, etc.", "C2", 7, 3),
// ----- C3 · Khác (4) → N7 -----
("C3-01", "Thuê nhà ở cho BCH CT", "House rental", "C3", 7, 1),
("C3-02", "Bảo hiểm tai nạn 24/24", "24/7 accident insurance", "C3", 7, 2),
("C3-03", "Ban quản lý khu công nghiệp", "Industrial Park Management Board", "C3", 7, 3),
("C3-04", "Mua bán điện ngoài mục đích sinh hoạt với Công ty Điện lực", "Electricity supply agreement for non-residential use with EVN", "C3", 7, 4),
// ----- 5.2 · CEO (1) → N8 -----
("CEO-01", "Tất cả Hợp đồng chưa Ủy quyền", "All Contracts Not Subject to Authorization", "CEO", 8, 1),
};
var existingCodes = await db.ContractCatalogEntries
.IgnoreQueryFilters() // né #75/#76 — row soft-deleted vẫn chặn re-create
.Select(x => x.Code)
.ToListAsync();
var added = 0;
foreach (var (code, tenVi, tenEn, groupCode, approvalGroup, sortOrder) in entries)
{
if (existingCodes.Contains(code)) continue;
var sign = signByGroup[approvalGroup];
db.ContractCatalogEntries.Add(new ContractCatalogEntry
{
Code = code,
TenVi = tenVi,
TenEn = tenEn,
GroupCode = groupCode,
ApprovalGroup = approvalGroup,
SignerRole = sign.SignerRole,
InitialsChain = sign.InitialsChain,
NduqNote = null, // [OG-3] ảnh KHÔNG có số NĐUQ per-dòng → nhập tay sau
IsActive = true,
SortOrder = sortOrder,
});
added++;
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} contract catalog entries (S164 SP-002)", added);
}
}
private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger) private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger)
{ {
// Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/. // Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/.

View File

@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractCatalogEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractCatalogEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
TenVi = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
TenEn = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
GroupCode = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
ApprovalGroup = table.Column<int>(type: "int", nullable: false),
SignerRole = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
InitialsChain = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
NduqNote = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractCatalogEntries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ContractCatalogEntries_ApprovalGroup",
table: "ContractCatalogEntries",
column: "ApprovalGroup");
migrationBuilder.CreateIndex(
name: "IX_ContractCatalogEntries_Code",
table: "ContractCatalogEntries",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractCatalogEntries");
}
}
}

View File

@ -3287,6 +3287,89 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Users", (string)null); b.ToTable("Users", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ContractCatalogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ApprovalGroup")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("GroupCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("InitialsChain")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("NduqNote")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("SignerRole")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("TenEn")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("TenVi")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalGroup");
b.HasIndex("Code")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("ContractCatalogEntries", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b => modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@ -0,0 +1,469 @@
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Catalogs;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K1 — S164 2026-07-31] Master-data "Danh mục Hợp đồng" (SOL-CCM-SP-002: 86 dòng × 8 nhóm duyệt).
// Test-AFTER (feature mới, `docs/rules.md §7`) — code BE đã land trên đĩa (Mig 70, chưa commit).
//
// Code under test (test theo CODE, KHÔNG theo spec — spec chỉ dùng để đối chiếu):
// · `DbInitializer.cs:3163-3324` SeedContractCatalogAsync — 86 tuple, per-Code idempotent,
// dò existing bằng `IgnoreQueryFilters()` (:3293-3296) né #75/#76
// · `ContractCatalogEntryConfiguration.cs:27-29` — UNIQUE Code FILTERED `[IsDeleted] = 0`
// (#57) + index ApprovalGroup + query filter soft-delete
// · `ContractCatalogFeatures.cs` List/Get/Create/Update/Delete + 2 validator (1..8)
// · `MenuKeys.cs:32 + :173` const `ContractCatalog` **VÀO `All`** ⇒ Program.cs sinh
// 4 policy; `DbInitializer.cs:1776` menu row parent `Catalogs`
//
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ───────────────────────────
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
// KHÔNG wire interceptor (`Common/SqliteDbFixture.cs:12-32`) ⇒ `Remove()` = xoá **CỨNG**
// (bài S155/S161). Ở đây thứ cần đo CHÍNH LÀ ý nghĩa của `Remove()`:
// · seeder skip-if-exists dựa vào row soft-deleted CÒN NẰM LẠI trong bảng;
// · rào UNIQUE filtered phân biệt row active ⟂ row đã xoá.
// Xoá cứng thì T2/T5 vẫn XANH kể cả khi `IgnoreQueryFilters()` bị gỡ hoặc index mất filter
// ⇒ đúng bẫy "assert rỗng nghĩa".
// (2) MỖI "lần boot"/"request" chạy trên MỘT DbContext MỚI (`NewDb()`), chung 1 connection —
// mô phỏng scoped-per-request của prod; seeder lần 2 buộc phải đọc lại ĐĨA chứ không ăn
// ké ChangeTracker của lần 1 (nếu ăn ké thì phép đo idempotent là giả).
//
// Vị trí file: `Application/` (cùng chỗ MasterCatalogFilteredUniqueTests / *SeedTests) chứ không
// `Services/` như lệnh gợi ý — `Services/` là chỗ của code-generator + workflow-service.
public class ContractCatalogTests
{
private static readonly DateTime FrozenNow = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);
// Phân bố CHUẨN theo transcribe SP-002 (nguồn: sub-invest-fable-b2-cum1.md:41).
private static readonly Dictionary<int, int> ExpectedByApprovalGroup = new()
{
[1] = 5, [2] = 10, [3] = 5, [4] = 23, [5] = 14, [6] = 15, [7] = 13, [8] = 1,
};
private static readonly Dictionary<string, int> ExpectedBySection = new()
{
["A1"] = 5, ["A2"] = 1, ["A3"] = 9, ["A4"] = 5,
["B1"] = 23, ["B2"] = 14, ["B3"] = 15,
["C1"] = 6, ["C2"] = 3, ["C3"] = 4, ["CEO"] = 1,
};
// ===================================================================
// Fixture: SQLite in-memory + AuditingInterceptor (xoá MỀM thật).
// ===================================================================
private sealed class CatalogFixture : IDisposable
{
private readonly SqliteConnection _conn;
public FixedDateTime Clock { get; } = new(FrozenNow);
public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "Quản trị danh mục", "admin@test.local", AppRoles.Admin);
public CatalogFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
using var boot = NewDb();
boot.Database.EnsureCreated();
}
/// Context MỚI cho mỗi "lần boot API" / mỗi request (xem ghi chú (2) đầu file).
public TestApplicationDbContext NewDb()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(Actor, Clock))
.Options;
return new TestApplicationDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
// `SeedContractCatalogAsync` là `private static` (ApplicationDbContext, ILogger) — gọi qua
// REFLECTION (khuôn S67), KHÔNG đổi visibility production, KHÔNG re-implement seeder trong test.
private static async Task InvokeSeedAsync(ApplicationDbContext db)
{
var mi = typeof(DbInitializer).GetMethod(
"SeedContractCatalogAsync", BindingFlags.NonPublic | BindingFlags.Static);
mi.Should().NotBeNull(
"DbInitializer.SeedContractCatalogAsync phải tồn tại (private static, (ApplicationDbContext, ILogger)) — đổi tên/signature thì cập nhật test");
await (Task)mi!.Invoke(null, new object[] { db, NullLogger.Instance })!;
}
private static ContractCatalogEntry NewEntry(string code, bool isDeleted = false) => new()
{
Id = Guid.NewGuid(),
Code = code,
TenVi = "Hạng mục " + code,
TenEn = "Item " + code,
GroupCode = "B1",
ApprovalGroup = 4,
SignerRole = "TP.PRO",
InitialsChain = "TBP.PRO → NV.PRO",
IsActive = true,
SortOrder = 1,
IsDeleted = isDeleted,
};
private static CreateContractCatalogEntryCommand CreateCmd(int approvalGroup = 4, string code = "Z-01") =>
new(code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);
private static UpdateContractCatalogEntryCommand UpdateCmd(Guid id, int approvalGroup = 4, string code = "Z-01") =>
new(id, code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);
// =====================================================================================
// T1 — Seed idempotent: 2 lần boot ⇒ vẫn ĐÚNG 86 row, không nhân đôi.
// (acceptance K1.f: "Restart API lần 2 → vẫn 86", khuôn nghiệm-thu-bằng-restart S91)
// =====================================================================================
[Fact]
public async Task Seed_RunTwice_Keeps86Rows_NoDuplicateCode()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot 1
await using (var check1 = f.NewDb())
{
(await check1.ContractCatalogEntries.CountAsync()).Should().Be(86,
"boot 1 phải nạp đủ 86 dòng SP-002 — nếu 0 thì mọi assert idempotent dưới là rỗng nghĩa");
}
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot 2 (= restart API)
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(86,
"seeder skip-if-Code-exists ⇒ lần boot thứ 2 KHÔNG thêm row nào");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
"kể cả tính row đã xoá mềm cũng chỉ 86 — không có bản sao ẩn nào sinh ra");
var duplicated = await check.ContractCatalogEntries.IgnoreQueryFilters()
.GroupBy(x => x.Code).Where(g => g.Count() > 1).Select(g => g.Key).ToListAsync();
duplicated.Should().BeEmpty("mỗi mã danh mục chỉ được tồn tại 1 lần sau N lần boot");
}
// =====================================================================================
// T1b — Chống HỒI SINH (#75/#76): admin xoá mềm 1 dòng ⇒ lần boot sau KHÔNG tạo lại.
// Đây là phép đo cho `IgnoreQueryFilters()` ở DbInitializer.cs:3294 — hit
// `IgnoreQueryFilters` ĐẦU TIÊN trong src/Backend, cố ý làm khác khuôn :2970.
// Nếu bỏ `IgnoreQueryFilters()`: row soft-deleted vô hình ⇒ seeder Add lại ⇒
// active vẫn 86 (TRÔNG NHƯ ĐÚNG!) nhưng tổng thành 87 ⇒ 2 assert dưới bắt được.
// =====================================================================================
[Fact]
public async Task Seed_AfterSoftDeleteOfOneCode_DoesNotResurrectIt()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }
// Xoá MỀM đúng đường prod đi (DeleteContractCatalogEntryHandler → Remove + interceptor).
await using (var db = f.NewDb())
{
var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "A1-01");
db.ContractCatalogEntries.Remove(row);
await db.SaveChangesAsync();
}
// Sanity CHỐNG-VACUOUS: phải là xoá MỀM. Xoá cứng thì phép đo dưới đo nhầm cơ chế.
await using (var probe = f.NewDb())
{
var raw = await probe.ContractCatalogEntries.IgnoreQueryFilters()
.SingleAsync(x => x.Code == "A1-01");
raw.IsDeleted.Should().BeTrue("AuditingInterceptor phải biến Remove() thành xoá mềm");
}
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot kế tiếp
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(85,
"dòng admin đã xoá phải Ở LẠI trạng thái đã xoá — seeder KHÔNG được kéo nó về");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
"tổng row vẫn 86 ⇒ seeder KHÔNG chèn thêm bản sao active của A1-01 (nếu thiếu IgnoreQueryFilters ⇒ 87)");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "A1-01"))
.Should().Be(1, "chỉ đúng 1 row mang mã A1-01, và nó đang ở trạng thái đã xoá");
}
// =====================================================================================
// T2 — Seed đủ 8 nhóm duyệt: phân bố 5/10/5/23/14/15/13/1 + phân bố theo section
// + tên VN/EN đủ 86/86 + denorm vai-ký nhất quán trong từng nhóm.
// =====================================================================================
[Fact]
public async Task Seed_ApprovalGroupAndSectionDistribution_MatchesSp002()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }
await using var check = f.NewDb();
var rows = await check.ContractCatalogEntries.AsNoTracking().ToListAsync();
rows.Should().HaveCount(86);
rows.GroupBy(r => r.ApprovalGroup).ToDictionary(g => g.Key, g => g.Count())
.Should().BeEquivalentTo(ExpectedByApprovalGroup,
"phân bố nhóm duyệt N1..N8 = 5/10/5/23/14/15/13/1 (acceptance K1.f) — sai 1 dòng bị gán nhầm nhóm là phiếu KHKK chạy nhầm quy trình");
rows.GroupBy(r => r.GroupCode).ToDictionary(g => g.Key, g => g.Count())
.Should().BeEquivalentTo(ExpectedBySection,
"phân bố theo section A1..CEO — bắt trường hợp tổng vẫn 86 nhưng 1 dòng bị chuyển section");
rows.Where(r => string.IsNullOrWhiteSpace(r.TenVi)).Should().BeEmpty("tên VN đủ 86/86");
rows.Where(r => string.IsNullOrWhiteSpace(r.TenEn)).Should().BeEmpty("tên EN đủ 86/86 (acceptance K1.f)");
rows.Where(r => string.IsNullOrWhiteSpace(r.SignerRole)).Should().BeEmpty("vai ký chính bắt buộc (in trên khối ký GĐ4)");
rows.Where(r => r.ApprovalGroup is < 1 or > 8).Should().BeEmpty("ApprovalGroup chỉ được nằm trong 1..8");
rows.Should().OnlyContain(r => r.IsActive, "86 dòng seed đều đang hiệu lực");
rows.Should().OnlyContain(r => r.NduqNote == null, "[OG-3] NĐUQ per-dòng chưa có trong ảnh gốc ⇒ seed null, nhập tay sau");
// Denorm: SignerRole/InitialsChain chép theo NHÓM vào từng row ⇒ trong 1 nhóm phải ĐỒNG NHẤT.
// Bắt lỗi gõ tay lệch 1 ký tự ở 1 dòng (thứ mà đếm-số-lượng không bao giờ thấy).
foreach (var g in rows.GroupBy(r => r.ApprovalGroup).OrderBy(g => g.Key))
{
g.Select(r => r.SignerRole).Distinct().Should().HaveCount(1,
$"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi vai ký chính cho cả {g.Count()} dòng");
g.Select(r => r.InitialsChain).Distinct().Should().HaveCount(1,
$"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi ký nháy cho cả {g.Count()} dòng");
}
// Neo 2 mốc đầucuối bảng gốc (chống trường hợp phân bố đúng mà nội dung trôi).
rows.Single(r => r.Code == "CEO-01").ApprovalGroup.Should().Be(8);
rows.Single(r => r.Code == "CEO-01").SignerRole.Should().Be("CEO");
rows.Single(r => r.Code == "A1-01").ApprovalGroup.Should().Be(1);
}
// =====================================================================================
// T3 — UNIQUE Code FILTERED `[IsDeleted] = 0` (gotcha #57) đo ở TẦNG DB, 2 CHIỀU:
// (a) 2 row ACTIVE cùng Code → DbUpdateException (index có tồn tại thật)
// (b) Code trùng row ĐÃ XOÁ MỀM → OK (index có mệnh đề WHERE thật)
// Thiếu chiều (a) ⇒ test xanh cả khi index chưa từng được tạo.
// Thiếu chiều (b) ⇒ test xanh cả khi index là UNIQUE trần (bug #57).
// =====================================================================================
[Fact]
public async Task UniqueCode_IsFilteredByIsDeleted_BlocksActiveDup_AllowsReuseAfterSoftDelete()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
await db.SaveChangesAsync();
}
// (a) trùng Code trên row ĐANG ACTIVE → DB phải chặn.
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
var act = async () => await db.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>(
"UNIQUE index trên Code phải chặn 2 dòng danh mục ACTIVE cùng mã");
}
// Xoá mềm dòng đang chiếm slot.
await using (var db = f.NewDb())
{
var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "X-01");
db.ContractCatalogEntries.Remove(row);
await db.SaveChangesAsync();
}
// (b) slot đã xoá mềm ⇒ tái dùng được mã.
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
var act = async () => await db.SaveChangesAsync();
await act.Should().NotThrowAsync(
"index có mệnh đề WHERE [IsDeleted] = 0 ⇒ row đã xoá mềm KHÔNG chiếm slot mã nữa (#57)");
}
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync(x => x.Code == "X-01")).Should().Be(1,
"chỉ 1 dòng active mang mã X-01");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "X-01")).Should().Be(2,
"bản đã xoá giữ lại cho vết audit + bản active mới");
}
// =====================================================================================
// T4 — Validator: ApprovalGroup ngoài 1..8 ⇒ FAIL, trong 1..8 ⇒ PASS (cả Create lẫn Update).
// Assert THÊM tên field lỗi: chống trường hợp "fail nhưng vì field khác sai".
// =====================================================================================
[Fact]
public void Validator_ApprovalGroupOutOfRange_Fails_InRange_Passes()
{
var createV = new CreateContractCatalogEntryValidator();
var updateV = new UpdateContractCatalogEntryValidator();
var id = Guid.NewGuid();
foreach (var bad in new[] { 0, 9, -1, 99 })
{
var rc = createV.Validate(CreateCmd(bad));
rc.IsValid.Should().BeFalse($"Create với ApprovalGroup={bad} nằm ngoài 1..8 (N1..N8)");
rc.Errors.Select(e => e.PropertyName).Should()
.Contain(nameof(CreateContractCatalogEntryCommand.ApprovalGroup),
$"lỗi phải rơi ĐÚNG field ApprovalGroup (giá trị {bad}), không phải field khác");
var ru = updateV.Validate(UpdateCmd(id, bad));
ru.IsValid.Should().BeFalse($"Update với ApprovalGroup={bad} nằm ngoài 1..8");
ru.Errors.Select(e => e.PropertyName).Should()
.Contain(nameof(UpdateContractCatalogEntryCommand.ApprovalGroup));
}
foreach (var ok in new[] { 1, 4, 8 })
{
createV.Validate(CreateCmd(ok)).IsValid.Should().BeTrue(
$"Create với ApprovalGroup={ok} hợp lệ — biên 1 và 8 phải LỌT (InclusiveBetween)");
updateV.Validate(UpdateCmd(id, ok)).IsValid.Should().BeTrue(
$"Update với ApprovalGroup={ok} hợp lệ");
}
}
// =====================================================================================
// T5 — Vòng đời qua HANDLER thật: Create → List thấy → Create trùng mã = Conflict →
// Delete = XOÁ MỀM (List/Get ẩn, row còn trong bảng, có DeletedAt/DeletedBy) →
// tạo lại CÙNG mã thành công (dup-check chạy trên active-set, khớp index filtered).
// =====================================================================================
[Fact]
public async Task Create_Then_Delete_IsSoft_HiddenFromListAndGet_RowRemains_CodeReusable()
{
using var f = new CatalogFixture();
var ct = CancellationToken.None;
Guid id;
await using (var db = f.NewDb())
id = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
await using (var db = f.NewDb())
{
var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
list.Should().ContainSingle(x => x.Id == id, "dòng vừa tạo phải xuất hiện trong danh sách");
}
// Trùng mã trên active-set → 409.
await using (var db = f.NewDb())
{
var act = async () => await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
await act.Should().ThrowAsync<ConflictException>("mã danh mục đang được 1 dòng active dùng");
}
await using (var db = f.NewDb())
await new DeleteContractCatalogEntryHandler(db).Handle(new DeleteContractCatalogEntryCommand(id), ct);
await using (var db = f.NewDb())
{
var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
list.Should().NotContain(x => x.Id == id, "query filter phải ẩn dòng đã xoá khỏi danh sách");
var get = async () => await new GetContractCatalogEntryHandler(db)
.Handle(new GetContractCatalogEntryQuery(id), ct);
await get.Should().ThrowAsync<NotFoundException>("Get theo id của dòng đã xoá ⇒ 404");
var raw = await db.ContractCatalogEntries.IgnoreQueryFilters().SingleAsync(x => x.Id == id);
raw.IsDeleted.Should().BeTrue("xoá MỀM — row phải còn lại trong bảng (audit + K2 Line denorm tên)");
raw.DeletedAt.Should().Be(FrozenNow, "AuditingInterceptor đóng dấu thời điểm xoá");
raw.DeletedBy.Should().Be(f.Actor.UserId, "AuditingInterceptor đóng dấu người xoá");
}
// Tạo lại CÙNG mã: dup-check của handler chạy trên active-set ⇒ phải cho qua.
await using (var db = f.NewDb())
{
var newId = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
newId.Should().NotBe(id, "là dòng MỚI, không phải hồi sinh dòng cũ");
}
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(1);
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(2);
}
// =====================================================================================
// T5b — [gate-K1 F4 @S165] List MẶC ĐỊNH loại dòng ngừng hiệu lực (spec K1.c
// `IncludeInactive?` mặc-định-loại); includeInactive=true mới thấy cả hai.
// Chốt ngữ nghĩa TRƯỚC khi picker K2 xây trên List này.
// =====================================================================================
[Fact]
public async Task List_DefaultExcludesInactive_IncludeInactiveShowsAll()
{
using var f = new CatalogFixture();
var ct = CancellationToken.None;
await using (var db = f.NewDb())
{
var off = NewEntry("Z-OFF");
off.IsActive = false;
db.ContractCatalogEntries.AddRange(NewEntry("Z-ON"), off);
await db.SaveChangesAsync(ct);
}
await using (var db = f.NewDb())
{
var defaults = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
defaults.Select(x => x.Code).Should().Contain("Z-ON")
.And.NotContain("Z-OFF", "mặc định phải LOẠI dòng IsActive=false (spec K1.c)");
var all = await new ListContractCatalogHandler(db)
.Handle(new ListContractCatalogQuery(IncludeInactive: true), ct);
all.Select(x => x.Code).Should().Contain(["Z-ON", "Z-OFF"]);
}
}
// =====================================================================================
// T6 — Menu key + grant seed. 3 mắt xích PHẢI khớp nhau, hỏng 1 là hỏng lúc BOOT:
// (i) `ContractCatalog` ∈ `MenuKeys.All` ⇒ Program.cs mới sinh 4 policy
// ContractCatalog.{Read,Create,Update,Delete}
// (ii) `SeedMenuTreeAsync` phải có row MenuItem cho key đó (FK Permission.MenuKey →
// MenuItem.Key, Cascade). Key ∈ All mà thiếu row menu ⇒ SeedAdminPermissionsAsync
// chèn Permission → nổ FK ngay lần boot đầu (SQLite Error 19 / FK prod).
// (iii) Admin được grant đủ 4 cờ CRUD qua loop `MenuKeys.All` (:2058).
// Bản thân việc CHẠY 2 seeder này = smoke-test cho invariant (ii) trên TOÀN BỘ `All`.
// =====================================================================================
[Fact]
public async Task MenuKey_ContractCatalog_InAll_SeededUnderCatalogs_AndGrantedToAdmin()
{
MenuKeys.All.Should().Contain(MenuKeys.ContractCatalog,
"Program.cs chỉ sinh policy cho key ∈ MenuKeys.All — key ngoài All thì [Authorize(Policy=...)] tương ứng sẽ 500 lúc chạy");
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var adminRole = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
(await rm.CreateAsync(adminRole)).Succeeded.Should().BeTrue();
await InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
await InvokePrivateSeedAsync(db, "SeedAdminPermissionsAsync", rm);
// (ii) row menu đúng chỗ: leaf dưới nhóm "Danh mục chi tiết".
var item = await db.MenuItems.AsNoTracking()
.FirstOrDefaultAsync(m => m.Key == MenuKeys.ContractCatalog);
item.Should().NotBeNull("SeedMenuTreeAsync phải tạo row menu cho key mới");
item!.ParentKey.Should().Be(MenuKeys.Catalogs, "leaf nằm dưới nhóm Danh mục chi tiết");
// Invariant TOÀN BỘ All (nguồn của lỗi FK lúc boot).
var menuKeysInDb = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty(
"mọi key trong MenuKeys.All phải có row MenuItem — thiếu row là Permission FK nổ ngay lần boot đầu");
// (iii) Admin có đủ 4 cờ.
var perm = await db.Permissions.AsNoTracking()
.FirstOrDefaultAsync(p => p.RoleId == adminRole.Id && p.MenuKey == MenuKeys.ContractCatalog);
perm.Should().NotBeNull("SeedAdminPermissionsAsync loop qua MenuKeys.All phải cấp cho Admin");
perm!.CanRead.Should().BeTrue();
perm.CanCreate.Should().BeTrue();
perm.CanUpdate.Should().BeTrue();
perm.CanDelete.Should().BeTrue();
}
// Reflection cho seeder private static: (db, logger) hoặc (db, roleManager, logger).
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)!;
}
}