[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
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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 (N1–N8) *', 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 N1–N8 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">Mã</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">Mã</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 có 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 (N1–N8) *', 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 N1–N8 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">Mã</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">Mã</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 có 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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 đầu–cuố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)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user