[CLAUDE] Move nested-type menu → fe-user; Admin workflow config page
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s

User clarified: menu loại HĐ 3-level (Danh sách/Thao tác/Duyệt) thuộc
fe-user. Admin có page riêng để config quy trình per loại HĐ.

fe-admin Layout:
- filterForAdmin() drops Ct_* entries (hide nested type menu).
- Admin sidebar giờ về lại đơn giản: Dashboard / Master / Hợp đồng
  (leaf) / Forms / Reports / System.

fe-user Layout:
- Dynamic menu tree từ /menus/me (thay fixed USER_MENU hardcoded).
- Recursive MenuNodeRenderer (top-level expanded, nested collapsed).
- resolvePath user-specific: Ct_*_List → /my-contracts?type=X,
  Ct_*_Create → /contracts/new?type=X, Ct_*_Pending → /inbox?type=X.
- filterForUser drops admin-only entries (Master/System/Forms/Reports).
- Static USER_FIXED_TOP prepends "Hộp thư" leaf → /inbox.
- MyContractsPage + InboxPage đọc ?type=X param, filter client-side.

Workflow config (Admin side):
- Domain: WorkflowTypeAssignment entity (ContractType → PolicyName
  override). Registry.ForContractWithOverrides() prefer DB override
  else default.
- Infrastructure: EF config + migration AddWorkflowTypeAssignments,
  unique index trên ContractType. ContractWorkflowService load
  overrides dict mỗi transition. ContractFeatures load overrides khi
  build WorkflowSummaryDto.
- Application: GetWorkflowAdminOverviewQuery returns 7 types × current
  policy + available policies. SetWorkflowAssignmentCommand validate
  policy name tồn tại; nếu = default thì delete override (no stale row).
- Api: GET /api/workflows + PUT /api/workflows/{contractType}
  với policy "Workflows.Read" + "Workflows.Update".
- Menu: new key `Workflows` dưới System, label "Quy trình HĐ".
- FE /system/workflows: 7 card per type, dropdown Standard/SkipCcm +
  'Đã override' badge khi khác default, phase sequence timeline,
  explanation banner ở top. Iteration 2 note: admin-authored custom
  policies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 22:41:05 +07:00
parent 48e91fe7ca
commit 5e0f3801a1
20 changed files with 1737 additions and 48 deletions

View File

@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
@ -35,6 +36,7 @@ function App() {
<Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/users" element={<UsersPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/system/workflows" element={<WorkflowsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} />

View File

@ -39,6 +39,7 @@ function resolvePath(key: string): string | null {
Users: '/system/users',
Roles: '/system/roles',
Permissions: '/system/permissions',
Workflows: '/system/workflows',
}
if (staticMap[key]) return staticMap[key]
@ -54,6 +55,18 @@ function resolvePath(key: string): string | null {
return null
}
// Admin side: hide the per-ContractType submenu (Ct_*) — that's a user-app
// concern. Admin manages workflow config via /system/workflows instead.
function isAdminHidden(key: string): boolean {
return key.startsWith('Ct_')
}
function filterForAdmin(nodes: MenuNode[]): MenuNode[] {
return nodes
.filter(n => !isAdminHidden(n.key))
.map(n => ({ ...n, children: filterForAdmin(n.children) }))
}
function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) {
const hasChildren = node.children.length > 0
if (hasChildren) return <MenuGroup node={node} depth={depth} />
@ -138,7 +151,7 @@ export function Layout() {
</Link>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{menu.map(n => (
{filterForAdmin(menu).map(n => (
<MenuNodeRenderer key={n.key} node={n} depth={0} />
))}
</nav>

View File

@ -0,0 +1,138 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GitBranch, Info } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { ContractPhaseLabel } from '@/types/contracts'
type WorkflowPolicyDto = {
name: string
description: string
activePhases: number[]
}
type WorkflowTypeAssignmentDto = {
contractType: number
contractTypeLabel: string
currentPolicy: string
defaultPolicy: string
policy: WorkflowPolicyDto
}
type WorkflowAdminOverviewDto = {
availablePolicies: WorkflowPolicyDto[]
assignments: WorkflowTypeAssignmentDto[]
}
export function WorkflowsPage() {
const qc = useQueryClient()
const overview = useQuery({
queryKey: ['workflow-overview'],
queryFn: async () => (await api.get<WorkflowAdminOverviewDto>('/workflows')).data,
})
const update = useMutation({
mutationFn: async ({ contractType, policyName }: { contractType: number; policyName: string }) => {
await api.put(`/workflows/${contractType}`, { policyName })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflow-overview'] })
// Invalidate contract details too — FE gets fresh policy next time user opens
qc.invalidateQueries({ queryKey: ['contract'] })
toast.success('Đã cập nhật quy trình')
},
onError: err => toast.error(getErrorMessage(err)),
})
const data = overview.data
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<GitBranch className="h-5 w-5" />
Quy trình duyệt hợp đng
</span>
}
description="Cấu hình quy trình duyệt cho từng loại HĐ. Mỗi loại có thể chọn 1 policy khác nhau."
/>
<div className="mb-5 flex items-start gap-2 rounded-md border border-brand-100 bg-brand-50/50 px-4 py-3 text-xs text-slate-700">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand-600" />
<div>
<strong>Standard:</strong> quy trình đy đ 8 phase CCM review áp dụng cho Thầu phụ/Giao khoán/NCC.
{' · '}
<strong>SkipCcm:</strong> bỏ phase CCM, đi thẳng từ 'Đang in ký' 'Đang trình ký' áp dụng Dịch vụ/Mua bán/Nguyên tắc.
{' · '}
Đt về policy mặc đnh = xóa override, registry dùng logic hardcoded.
</div>
</div>
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{data && (
<div className="space-y-3">
{data.assignments.map(a => {
const isOverridden = a.currentPolicy !== a.defaultPolicy
return (
<div key={a.contractType} className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold text-slate-900">{a.contractTypeLabel}</h3>
{isOverridden && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700">
Đã override
</span>
)}
</div>
<p className="mt-1 text-xs leading-relaxed text-slate-500">{a.policy.description}</p>
<div className="mt-3 flex flex-wrap items-center gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400">Các phase:</span>
{a.policy.activePhases
.filter(p => p !== 99) // hide TuChoi in timeline — it's a terminal error path
.map((p, idx, arr) => (
<span key={p} className="flex items-center">
<span className="rounded bg-slate-100 px-2 py-0.5 text-[11px] text-slate-700">
{ContractPhaseLabel[p] ?? p}
</span>
{idx < arr.length - 1 && <span className="mx-1 text-slate-300"></span>}
</span>
))}
</div>
</div>
<div className="shrink-0">
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wider text-slate-400">Policy</label>
<Select
value={a.currentPolicy}
onChange={e => update.mutate({ contractType: a.contractType, policyName: e.target.value })}
disabled={update.isPending}
className="w-40"
>
{data.availablePolicies.map(p => (
<option key={p.name} value={p.name}>
{p.name}
{p.name === a.defaultPolicy ? ' (mặc định)' : ''}
</option>
))}
</Select>
</div>
</div>
</div>
)
})}
</div>
)}
<div className="mt-6 rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-600">
<strong>Iteration 2:</strong> cho phép tạo policy custom (phase sequence + SLA + role-per-phase) thay chọn
từ 2 policy pre-built. Data model đã hỗ trợ chỉ cần thêm UI builder.
</div>
</div>
)
}

View File

@ -1,16 +1,167 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { Inbox, FileText, Plus } from 'lucide-react'
import { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-react'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { TopBar } from '@/components/TopBar'
import type { MenuNode } from '@/types/menu'
import { cn } from '@/lib/cn'
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
const USER_MENU = [
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
{ to: '/contracts/new', label: 'Tạo HĐ mới', icon: Plus },
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText },
function getIcon(name: string | null): LucideIcon {
if (!name) return Circle
const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
return candidate ?? Circle
}
const TYPE_CODE_TO_INT: Record<string, number> = {
ThauPhu: 1,
GiaoKhoan: 2,
NhaCungCap: 3,
DichVu: 4,
MuaBan: 5,
NguyenTacNcc: 6,
NguyenTacDv: 7,
}
// User-side menu key → route. Differs from admin: Danh sách points to
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
Dashboard: '/inbox', // user home = inbox
Contracts: '/my-contracts',
}
if (staticMap[key]) return staticMap[key]
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending)$/)
if (match) {
const [, code, action] = match
const typeInt = TYPE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/my-contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
}
return null
}
// Menu entries not applicable to user app — filtered out client-side so the
// sidebar only shows what matters to a contract drafter/approver.
const USER_HIDDEN_KEYS = new Set([
'Master', 'Suppliers', 'Projects', 'Departments',
'System', 'Users', 'Roles', 'Permissions',
'Forms', 'Reports',
])
function filterForUser(nodes: MenuNode[]): MenuNode[] {
return nodes
.filter(n => !USER_HIDDEN_KEYS.has(n.key))
.map(n => ({ ...n, children: filterForUser(n.children) }))
}
function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) {
const hasChildren = node.children.length > 0
if (hasChildren) return <MenuGroup node={node} depth={depth} />
return <MenuLeaf node={node} depth={depth} />
}
function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
const [open, setOpen] = useState(depth === 0)
const Icon = getIcon(node.icon)
const isTopLevel = depth === 0
return (
<div>
<button
onClick={() => setOpen(o => !o)}
className={cn(
'flex w-full items-center justify-between rounded-md transition',
isTopLevel
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{node.label}
</span>
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', !open && '-rotate-90')} />
</button>
{open && (
<div className={cn(
'mt-0.5 space-y-0.5',
depth === 0 ? 'pl-2' : 'ml-3 mt-1 border-l border-slate-100 pl-3',
)}>
{node.children.map(c => (
<MenuNodeRenderer key={c.key} node={c} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
const Icon = getIcon(node.icon)
const path = resolvePath(node.key)
if (!path) return null
const isDeep = depth >= 2
return (
<NavLink
to={path}
end={path.includes('?')}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md transition',
isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
{node.label}
</NavLink>
)
}
// Static entries prepended to the dynamic menu tree — these are user-app
// specific (inbox + quick create) not backed by MenuItems DB rows.
const USER_FIXED_TOP: MenuNode[] = [
{ key: '__inbox', label: 'Hộp thư', parentKey: null, order: 0, icon: 'Inbox', canRead: true, canCreate: true, canUpdate: true, canDelete: true, children: [] },
]
function staticResolvePath(key: string): string | null {
if (key === '__inbox') return '/inbox'
return null
}
function StaticLeaf({ node }: { node: MenuNode }) {
const Icon = getIcon(node.icon)
const path = staticResolvePath(node.key)
if (!path) return null
return (
<NavLink
to={path}
end
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition',
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<Icon className="h-4 w-4" />
{node.label}
</NavLink>
)
}
export function Layout() {
const { menu } = useAuth()
const filteredMenu = filterForUser(menu)
return (
<div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
@ -20,25 +171,11 @@ export function Layout() {
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
</Link>
</div>
<nav className="flex-1 space-y-1 p-3">
{USER_MENU.map(item => {
const Icon = item.icon
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<Icon className="h-4 w-4" />
{item.label}
</NavLink>
)
})}
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{USER_FIXED_TOP.map(n => <StaticLeaf key={n.key} node={n} />)}
{filteredMenu.map(n => (
<MenuNodeRenderer key={n.key} node={n} depth={0} />
))}
</nav>
</aside>
<div className="flex flex-1 flex-col overflow-hidden">

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
@ -40,13 +40,17 @@ function StatCard({
export function InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
const [searchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({
queryKey: ['inbox'],
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
})
const rows = list.data ?? []
const allRows = list.data ?? []
// Apply type filter from sidebar nested menu (?type=X)
const rows = typeFilter == null ? allRows : allRows.filter(c => c.type === typeFilter)
const stats = useMemo(() => {
const now = Date.now()

View File

@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
@ -16,12 +17,20 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({
queryKey: ['my-contracts'],
queryKey: ['my-contracts', typeFilter],
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
})
// Filter client-side by URL type param (sidebar nested menu passes it)
const rows = useMemo(() => {
const items = list.data?.items ?? []
return typeFilter == null ? items : items.filter(c => c.type === typeFilter)
}, [list.data, typeFilter])
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
@ -46,7 +55,7 @@ export function MyContractsPage() {
<DataTable
columns={columns}
rows={list.data?.items ?? []}
rows={rows}
getRowKey={c => c.id}
isLoading={list.isLoading}
empty={

View File

@ -0,0 +1,27 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/workflows")]
[Authorize(Policy = "Workflows.Read")]
public class WorkflowsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<WorkflowAdminOverviewDto>> Overview(CancellationToken ct)
=> Ok(await mediator.Send(new GetWorkflowAdminOverviewQuery(), ct));
[HttpPut("{contractType:int}")]
[Authorize(Policy = "Workflows.Update")]
public async Task<IActionResult> SetAssignment(int contractType, [FromBody] SetWorkflowAssignmentBody body, CancellationToken ct)
{
await mediator.Send(new SetWorkflowAssignmentCommand((ContractType)contractType, body.PolicyName), ct);
return NoContent();
}
}
public record SetWorkflowAssignmentBody(string PolicyName);

View File

@ -22,6 +22,7 @@ public interface IApplicationDbContext
DbSet<ContractAttachment> ContractAttachments { get; }
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -337,6 +337,8 @@ public class GetContractQueryHandler(
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve user names
@ -376,14 +378,16 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(),
BuildWorkflowSummary(c));
BuildWorkflowSummary(c, workflowOverrides));
}
// FE uses this to render next-phase buttons dynamically — no more hardcoded
// NEXT_PHASES map that silently drifts from the BE policy.
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c)
private static WorkflowSummaryDto BuildWorkflowSummary(
Contract c,
IReadOnlyDictionary<ContractType, string>? overrides)
{
var policy = WorkflowPolicyRegistry.ForContract(c);
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
return new WorkflowSummaryDto(
PolicyName: policy.Name,
PolicyDescription: policy.Description,

View File

@ -0,0 +1,119 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts;
// Admin UI /system/workflows — list current policy assignment per ContractType
// + change via dropdown. Iteration 2: let admin define custom policies; for
// now they pick from WorkflowPolicyRegistry.AvailablePolicyNames.
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
public record WorkflowTypeAssignmentDto(
int ContractType,
string ContractTypeLabel,
string CurrentPolicy,
string DefaultPolicy,
WorkflowPolicyDto Policy);
public record WorkflowAdminOverviewDto(
List<WorkflowPolicyDto> AvailablePolicies,
List<WorkflowTypeAssignmentDto> Assignments);
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
{
private static readonly Dictionary<ContractType, string> TypeLabels = new()
{
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
[ContractType.HopDongNhaCungCap] = "HĐ Nhà cung cấp",
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
};
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
{
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
.Select(WorkflowPolicyRegistry.ByName)
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
.ToList();
var assignments = Enum.GetValues<ContractType>()
.Select(t =>
{
var defaultName = WorkflowPolicyRegistry.DefaultPolicyNameFor(t);
var currentName = overrides.TryGetValue(t, out var n) ? n : defaultName;
var policy = WorkflowPolicyRegistry.ByName(currentName);
return new WorkflowTypeAssignmentDto(
(int)t,
TypeLabels.GetValueOrDefault(t, t.ToString()),
currentName,
defaultName,
new WorkflowPolicyDto(policy.Name, policy.Description, policy.ActivePhases.Select(x => (int)x).ToList()));
})
.ToList();
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
}
}
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
{
public SetWorkflowAssignmentCommandValidator()
{
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.PolicyName).NotEmpty()
.Must(name => WorkflowPolicyRegistry.AvailablePolicyNames.Contains(name))
.WithMessage(x => $"Policy '{x.PolicyName}' không tồn tại. Cho phép: {string.Join(",", WorkflowPolicyRegistry.AvailablePolicyNames)}.");
}
}
public class SetWorkflowAssignmentCommandHandler(IApplicationDbContext db)
: IRequestHandler<SetWorkflowAssignmentCommand>
{
public async Task Handle(SetWorkflowAssignmentCommand request, CancellationToken ct)
{
var existing = await db.WorkflowTypeAssignments
.FirstOrDefaultAsync(a => a.ContractType == request.ContractType, ct);
// If user sets policy back to the hardcoded default, delete the override
// row so the registry uses the code-level default (no stale DB noise).
var isDefault = request.PolicyName == WorkflowPolicyRegistry.DefaultPolicyNameFor(request.ContractType);
if (existing is null)
{
if (isDefault) return; // nothing to persist
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
{
ContractType = request.ContractType,
PolicyName = request.PolicyName,
});
}
else if (isDefault)
{
db.WorkflowTypeAssignments.Remove(existing);
}
else
{
existing.PolicyName = request.PolicyName;
}
await db.SaveChangesAsync(ct);
}
}

View File

@ -117,23 +117,40 @@ public static class WorkflowPolicies
public static class WorkflowPolicyRegistry
{
// Mapping contract type → policy. Tuned to the real business from
// QT-TP-NCC.docx: formal NTP/NCC/Giao khoán need full CCM review; service /
// purchase / framework contracts skip CCM.
public static WorkflowPolicy For(ContractType type) => type switch
// Available policy names — extend when admin-authored custom policies
// are added in iteration 2.
public static readonly string[] AvailablePolicyNames = ["Standard", "SkipCcm"];
public static WorkflowPolicy ByName(string name) => name switch
{
ContractType.HopDongThauPhu => WorkflowPolicies.Standard,
ContractType.HopDongGiaoKhoan => WorkflowPolicies.Standard,
ContractType.HopDongNhaCungCap => WorkflowPolicies.Standard,
ContractType.HopDongDichVu => WorkflowPolicies.SkipCcm,
ContractType.HopDongMuaBan => WorkflowPolicies.SkipCcm,
ContractType.HopDongNguyenTacNCC => WorkflowPolicies.SkipCcm,
ContractType.HopDongNguyenTacDichVu => WorkflowPolicies.SkipCcm,
"SkipCcm" => WorkflowPolicies.SkipCcm,
_ => WorkflowPolicies.Standard,
};
// Instance-level bypass flag overrides the default: if a contract has
// Default mapping per contract type — used when DB override missing.
// Matches business from QT-TP-NCC.docx.
public static string DefaultPolicyNameFor(ContractType type) => type switch
{
ContractType.HopDongThauPhu or ContractType.HopDongGiaoKhoan or ContractType.HopDongNhaCungCap => "Standard",
_ => "SkipCcm",
};
public static WorkflowPolicy For(ContractType type) => ByName(DefaultPolicyNameFor(type));
// Instance-level bypass flag overrides the policy — if a contract has
// BypassProcurementAndCCM=true, always use SkipCcm regardless of type.
public static WorkflowPolicy ForContract(Contract contract) =>
contract.BypassProcurementAndCCM ? WorkflowPolicies.SkipCcm : For(contract.Type);
// DB-aware resolver: prefer assignment, else default. Pass in the
// assignments dict once per request (cached from DB).
public static WorkflowPolicy ForContractWithOverrides(
Contract contract,
IReadOnlyDictionary<ContractType, string>? assignments)
{
if (contract.BypassProcurementAndCCM) return WorkflowPolicies.SkipCcm;
if (assignments is not null && assignments.TryGetValue(contract.Type, out var policyName))
return ByName(policyName);
return For(contract.Type);
}
}

View File

@ -0,0 +1,13 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Admin-configurable: which WorkflowPolicy applies to each ContractType.
// Seed with hardcoded defaults (see DbInitializer); admin can switch via
// /system/workflows UI without code changes. Later iteration will let admin
// define custom policies with bespoke phase/role/SLA.
public class WorkflowTypeAssignment : BaseEntity
{
public ContractType ContractType { get; set; }
public string PolicyName { get; set; } = string.Empty; // "Standard" | "SkipCcm" (extensible)
}

View File

@ -16,6 +16,7 @@ public static class MenuKeys
public const string Users = "Users";
public const string Roles = "Roles";
public const string Permissions = "Permissions";
public const string Workflows = "Workflows";
// Per-contract-type menu groups + 3 action leaves each.
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
@ -35,7 +36,7 @@ public static class MenuKeys
Dashboard,
Master, Suppliers, Projects, Departments,
Contracts, Forms, Reports,
System, Users, Roles, Permissions,
System, Users, Roles, Permissions, Workflows,
];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -27,6 +27,7 @@ public class ApplicationDbContext
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class WorkflowTypeAssignmentConfiguration : IEntityTypeConfiguration<WorkflowTypeAssignment>
{
public void Configure(EntityTypeBuilder<WorkflowTypeAssignment> e)
{
e.ToTable("WorkflowTypeAssignments");
e.HasKey(x => x.Id);
e.Property(x => x.ContractType).HasConversion<int>();
e.Property(x => x.PolicyName).HasMaxLength(50).IsRequired();
e.HasIndex(x => x.ContractType).IsUnique();
}
}

View File

@ -113,6 +113,7 @@ public static class DbInitializer
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each

View File

@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkflowTypeAssignments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkflowTypeAssignments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractType = table.Column<int>(type: "int", nullable: false),
PolicyName = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
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)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowTypeAssignments", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkflowTypeAssignments_ContractType",
table: "WorkflowTypeAssignments",
column: "ContractType",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkflowTypeAssignments");
}
}
}

View File

@ -374,6 +374,40 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractComments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ContractType")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("PolicyName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractType")
.IsUnique();
b.ToTable("WorkflowTypeAssignments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{
b.Property<Guid>("Id")

View File

@ -36,7 +36,11 @@ public class ContractWorkflowService(
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
var policy = WorkflowPolicyRegistry.ForContract(contract);
// Admin may override the default policy per ContractType via the
// /system/workflows page. Load all overrides once (7 rows max).
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;