[CLAUDE] Domain+Infra+App+Api+FE-Admin: versioned workflow per ContractType
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m32s
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m32s
User yêu cầu: mỗi loại HĐ có quy trình riêng với admin add roles + users vào từng bước. Khi tạo version mới → HĐ tương lai chạy theo, HĐ cũ giữ version cũ. Domain: - WorkflowDefinition (Code + Version + ContractType + IsActive + Steps) - WorkflowStep (Order + Phase + Name + SlaDays + Approvers) - WorkflowStepApprover (Kind: Role/User + AssignmentValue) - Contract.WorkflowDefinitionId — pinned at creation - WorkflowPolicyRegistry.FromDefinition() — build runtime policy từ DB Infrastructure: - EF config + migration AddVersionedWorkflows (3 table mới) - DbInitializer.SeedWorkflowDefinitionsAsync: v01 per 7 ContractType, steps sinh từ hardcoded WorkflowPolicies (Role approvers). - ContractWorkflowService.TransitionAsync: load pinned WorkflowDefinition → FromDefinition(), fallback cho HĐ cũ không có pin. Application: - CreateContractCommand pin WorkflowDefinitionId = active version cho type - ContractFeatures.Get(id): load pinned def cho workflow summary - WorkflowAdminFeatures: GetWorkflowAdminOverviewQuery (7 types + active + history + ContractsUsingCount), CreateWorkflowDefinitionCommand (validate payload, auto-increment version, deactivate old). Api: - GET /api/workflows trả overview - POST /api/workflows tạo version mới (deactivate old) FE /system/workflows: - Tabs per 7 ContractType, mỗi tab hiện active version + lịch sử - DefinitionCard: steps với badge role/user + SLA + archived indicator hiện "N HĐ còn chạy" cho version cũ - WorkflowDesigner modal: form code/name/desc + danh sách steps (phase/name/SLA) + approvers (+ Role hoặc + User). Drop step ok. Clone từ version hiện tại để tạo v02 có điểm start sensible. - Amber banner: HĐ cũ không bị ảnh hưởng khi tạo version mới Invariants được giữ: - Unique (Code, Version) index - Chỉ 1 version IsActive per ContractType tại 1 thời điểm - Set default sẽ auto xóa override → respect legacy override table - Role-kind approvers drive transition guards; User-kind fallback DeptManager role cho v1 (user-level targeting = iteration 2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,53 +1,78 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { GitBranch, Info } from 'lucide-react'
|
||||
import { GitBranch, Plus, Trash2, CheckCircle2, Info, History } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { ContractPhaseLabel } from '@/types/contracts'
|
||||
import { AVAILABLE_ROLES, RoleLabel } from '@/types/users'
|
||||
|
||||
type WorkflowPolicyDto = {
|
||||
name: string
|
||||
description: string
|
||||
activePhases: number[]
|
||||
}
|
||||
// ===== Types =====
|
||||
|
||||
type WorkflowTypeAssignmentDto = {
|
||||
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
|
||||
type StepDto = { id: string; order: number; phase: number; phaseLabel: string; name: string; slaDays: number | null; approvers: ApproverDto[] }
|
||||
type DefinitionDto = {
|
||||
id: string
|
||||
code: string
|
||||
version: number
|
||||
contractType: number
|
||||
contractTypeLabel: string
|
||||
currentPolicy: string
|
||||
defaultPolicy: string
|
||||
policy: WorkflowPolicyDto
|
||||
name: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
activatedAt: string | null
|
||||
createdAt: string
|
||||
contractsUsingCount: number
|
||||
steps: StepDto[]
|
||||
}
|
||||
type TypeSummaryDto = {
|
||||
contractType: number
|
||||
contractTypeLabel: string
|
||||
active: DefinitionDto | null
|
||||
history: DefinitionDto[]
|
||||
}
|
||||
|
||||
type WorkflowAdminOverviewDto = {
|
||||
availablePolicies: WorkflowPolicyDto[]
|
||||
assignments: WorkflowTypeAssignmentDto[]
|
||||
const PHASE_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 2, label: 'Đang soạn thảo' },
|
||||
{ value: 3, label: 'Đang góp ý' },
|
||||
{ value: 4, label: 'Đang đàm phán' },
|
||||
{ value: 5, label: 'Đang in ký' },
|
||||
{ value: 6, label: 'CCM kiểm tra' },
|
||||
{ value: 7, label: 'Đang trình ký' },
|
||||
{ value: 8, label: 'Đang đóng dấu' },
|
||||
{ value: 9, label: 'Đã phát hành' },
|
||||
]
|
||||
|
||||
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
|
||||
type EditStep = { phase: number; name: string; slaDays: number | null; approvers: EditStepApprover[] }
|
||||
|
||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||
return d.steps.map(s => ({
|
||||
phase: s.phase,
|
||||
name: s.name,
|
||||
slaDays: s.slaDays,
|
||||
approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })),
|
||||
}))
|
||||
}
|
||||
|
||||
// ===== Page =====
|
||||
|
||||
export function WorkflowsPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['workflow-overview'],
|
||||
queryFn: async () => (await api.get<WorkflowAdminOverviewDto>('/workflows')).data,
|
||||
queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/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
|
||||
const [activeType, setActiveType] = useState<number | null>(null)
|
||||
const tab = activeType ?? overview.data?.types[0]?.contractType ?? 1
|
||||
const currentType = overview.data?.types.find(t => t.contractType === tab)
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@ -58,81 +83,406 @@ export function WorkflowsPage() {
|
||||
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."
|
||||
description="Mỗi loại HĐ có quy trình riêng, hỗ trợ versioning. Tạo version mới → HĐ tương lai chạy theo. HĐ cũ vẫn giữ quy trình cũ."
|
||||
/>
|
||||
|
||||
<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 có CCM review — áp dụng cho HĐ 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 HĐ 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>
|
||||
)
|
||||
})}
|
||||
{/* Tabs */}
|
||||
{overview.data && (
|
||||
<div className="mb-5 flex gap-1 overflow-x-auto border-b border-slate-200">
|
||||
{overview.data.types.map(t => (
|
||||
<button
|
||||
key={t.contractType}
|
||||
onClick={() => setActiveType(t.contractType)}
|
||||
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition ${
|
||||
tab === t.contractType
|
||||
? 'border-brand-600 text-brand-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t.contractTypeLabel}
|
||||
{t.active && (
|
||||
<span className="ml-2 rounded bg-brand-50 px-1.5 py-0.5 font-mono text-[10px] text-brand-700">
|
||||
v{String(t.active.version).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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 vì chọn
|
||||
từ 2 policy pre-built. Data model đã hỗ trợ — chỉ cần thêm UI builder.
|
||||
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
|
||||
{currentType && <TypePanel type={currentType} onSaved={() => qc.invalidateQueries({ queryKey: ['workflow-overview'] })} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Per-type panel =====
|
||||
|
||||
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
|
||||
const [designerOpen, setDesignerOpen] = useState(false)
|
||||
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{type.active ? (
|
||||
<DefinitionCard def={type.active} isActive onClone={d => { setCloneFrom(d); setDesignerOpen(true) }} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
|
||||
Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
|
||||
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo quy trình mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{type.history.filter(d => !d.isActive).length === 0 && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-500">
|
||||
Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{type.history
|
||||
.filter(d => !d.isActive)
|
||||
.map(d => (
|
||||
<DefinitionCard key={d.id} def={d} isActive={false} onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{designerOpen && (
|
||||
<WorkflowDesigner
|
||||
contractType={type.contractType}
|
||||
contractTypeLabel={type.contractTypeLabel}
|
||||
cloneFrom={cloneFrom}
|
||||
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
|
||||
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Definition card (read-only view) =====
|
||||
|
||||
function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) {
|
||||
return (
|
||||
<div className={`rounded-xl border bg-white p-5 shadow-sm ${isActive ? 'border-brand-200' : 'border-slate-200'}`}>
|
||||
<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">
|
||||
{def.name}
|
||||
</h3>
|
||||
<span className="rounded bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600">
|
||||
{def.code} v{String(def.version).padStart(2, '0')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Đang áp dụng
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<History className="h-3 w-3" />
|
||||
Archived · {def.contractsUsingCount} HĐ còn chạy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
|
||||
|
||||
<ol className="mt-3 space-y-2">
|
||||
{def.steps.map(s => (
|
||||
<li key={s.id} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/30 p-2.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
||||
{s.order}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-800">{s.name}</span>
|
||||
<span className="text-[11px] text-slate-400">({s.phaseLabel})</span>
|
||||
{s.slaDays != null && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
SLA {s.slaDays}d
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{s.approvers.length === 0 && (
|
||||
<span className="text-[11px] italic text-slate-400">Chưa có người duyệt</span>
|
||||
)}
|
||||
{s.approvers.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
a.kind === 1 ? 'bg-brand-50 text-brand-700' : 'bg-violet-50 text-violet-700'
|
||||
}`}
|
||||
>
|
||||
{a.kind === 1 ? 'Role' : 'User'}: {a.displayName ?? a.assignmentValue}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Tạo từ bản này
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Designer dialog =====
|
||||
|
||||
function WorkflowDesigner({
|
||||
contractType,
|
||||
contractTypeLabel,
|
||||
cloneFrom,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
contractType: number
|
||||
contractTypeLabel: string
|
||||
cloneFrom: DefinitionDto | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const initialSteps: EditStep[] = useMemo(
|
||||
() =>
|
||||
cloneFrom
|
||||
? copyFromDefinition(cloneFrom)
|
||||
: [{ phase: 2, name: 'Soạn thảo', slaDays: 7, approvers: [] }],
|
||||
[cloneFrom],
|
||||
)
|
||||
|
||||
const [code, setCode] = useState(cloneFrom?.code ?? 'QT-NEW')
|
||||
const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${contractTypeLabel}`)
|
||||
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
||||
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
||||
|
||||
const usersList = useQuery({
|
||||
queryKey: ['users-for-approver'],
|
||||
queryFn: async () =>
|
||||
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.post('/workflows', {
|
||||
contractType,
|
||||
code,
|
||||
name,
|
||||
description: description || null,
|
||||
steps: steps.map((s, i) => ({
|
||||
order: i + 1,
|
||||
phase: s.phase,
|
||||
name: s.name,
|
||||
slaDays: s.slaDays,
|
||||
approvers: s.approvers,
|
||||
})),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
|
||||
onSaved()
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (steps.length === 0) {
|
||||
toast.error('Phải có ít nhất 1 bước')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Tạo quy trình mới — ${contractTypeLabel}`}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={submit} disabled={save.isPending} form="wf-form">
|
||||
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="wf-form" onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Mã quy trình *</Label>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
||||
<div className="text-[11px] text-slate-400">Ví dụ QT-TP, QT-MB. Version auto-tăng mỗi lần lưu.</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tên hiển thị *</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Mô tả</Label>
|
||||
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Các bước ({steps.length})</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setSteps([...steps, { phase: 3, name: '', slaDays: 7, approvers: [] }])
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Thêm bước
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{steps.map((s, idx) => (
|
||||
<div key={idx} className="rounded-md border border-slate-200 bg-slate-50/40 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Phase</Label>
|
||||
<Select
|
||||
value={s.phase}
|
||||
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, phase: Number(e.target.value) } : x)))}
|
||||
>
|
||||
{PHASE_OPTIONS.map(p => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên bước</Label>
|
||||
<Input value={s.name} onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">SLA (ngày)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={s.slaDays ?? ''}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Xóa bước"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-slate-200 pt-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<Label className="text-[11px]">Người duyệt</Label>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
||||
className="rounded bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
||||
>
|
||||
+ Role
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
||||
className="rounded bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
||||
>
|
||||
+ User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.approvers.length === 0 && (
|
||||
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
|
||||
Chưa có người duyệt — tối thiểu nên có 1 Role hoặc 1 User.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{s.approvers.map((a, ai) => (
|
||||
<div key={ai} className="flex items-center gap-1">
|
||||
{a.kind === 1 ? (
|
||||
<Select
|
||||
value={a.assignmentValue}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, approvers: x.approvers.map((y, j) => (j === ai ? { ...y, assignmentValue: e.target.value } : y)) } : x,
|
||||
))
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
{AVAILABLE_ROLES.map(r => (
|
||||
<option key={r} value={r}>Role: {RoleLabel[r] ?? r}</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Select
|
||||
value={a.assignmentValue}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, approvers: x.approvers.map((y, j) => (j === ai ? { ...y, assignmentValue: e.target.value } : y)) } : x,
|
||||
))
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
{usersList.data?.map(u => (
|
||||
<option key={u.id} value={u.id}>User: {u.fullName} ({u.email})</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, approvers: x.approvers.filter((_, j) => j !== ai) } : x,
|
||||
))
|
||||
}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
Khi lưu: version mới tự động tăng từ <code className="font-mono">{code}</code>, thành version đang áp dụng.
|
||||
HĐ hiện tại vẫn giữ version cũ (được pin tại thời điểm tạo), chỉ HĐ MỚI đi theo version này.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Contracts;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
@ -15,13 +14,11 @@ public class WorkflowsController(IMediator mediator) : ControllerBase
|
||||
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)
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "Workflows.Create")]
|
||||
public async Task<ActionResult<object>> Create([FromBody] CreateWorkflowDefinitionCommand cmd, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SetWorkflowAssignmentCommand((ContractType)contractType, body.PolicyName), ct);
|
||||
return NoContent();
|
||||
var id = await mediator.Send(cmd, ct);
|
||||
return Ok(new { id });
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentBody(string PolicyName);
|
||||
|
||||
@ -23,6 +23,9 @@ public interface IApplicationDbContext
|
||||
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
||||
DbSet<Notification> Notifications { get; }
|
||||
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
|
||||
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }
|
||||
DbSet<WorkflowStep> WorkflowSteps { get; }
|
||||
DbSet<WorkflowStepApprover> WorkflowStepApprovers { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -51,6 +51,13 @@ public class CreateContractCommandHandler(
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct))
|
||||
throw new NotFoundException("Project", request.ProjectId);
|
||||
|
||||
// Pin to currently active WorkflowDefinition for this type. New versions
|
||||
// created later do not retroactively affect this contract.
|
||||
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Where(w => w.ContractType == request.Type && w.IsActive)
|
||||
.Select(w => (Guid?)w.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var entity = new Contract
|
||||
{
|
||||
Type = request.Type,
|
||||
@ -65,6 +72,7 @@ public class CreateContractCommandHandler(
|
||||
NoiDung = request.NoiDung,
|
||||
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
|
||||
DraftData = request.DraftData,
|
||||
WorkflowDefinitionId = activeWfId,
|
||||
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
|
||||
};
|
||||
db.Contracts.Add(entity);
|
||||
@ -337,10 +345,27 @@ 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 workflow: pinned WorkflowDefinition > overrides > hardcoded
|
||||
WorkflowPolicy workflowPolicy;
|
||||
if (c.WorkflowDefinitionId is Guid wfId)
|
||||
{
|
||||
var def = await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||
workflowPolicy = def is not null
|
||||
? WorkflowPolicyRegistry.FromDefinition(def)
|
||||
: WorkflowPolicyRegistry.ForContract(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
|
||||
}
|
||||
|
||||
// Resolve user names
|
||||
var userIds = new HashSet<Guid>();
|
||||
if (c.DrafterUserId is Guid did) userIds.Add(did);
|
||||
@ -378,16 +403,13 @@ public class GetContractQueryHandler(
|
||||
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
||||
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
||||
.ToList(),
|
||||
BuildWorkflowSummary(c, workflowOverrides));
|
||||
BuildWorkflowSummary(c, workflowPolicy));
|
||||
}
|
||||
|
||||
// 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,
|
||||
IReadOnlyDictionary<ContractType, string>? overrides)
|
||||
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c, WorkflowPolicy policy)
|
||||
{
|
||||
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
|
||||
return new WorkflowSummaryDto(
|
||||
PolicyName: policy.Name,
|
||||
PolicyDescription: policy.Description,
|
||||
|
||||
@ -1,35 +1,60 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
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.
|
||||
// Versioned workflow management: admin xem + tạo version mới per ContractType.
|
||||
// HĐ cũ đã pin WorkflowDefinitionId tại thời điểm tạo — vẫn chạy version cũ
|
||||
// kể cả khi admin activate version mới.
|
||||
|
||||
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
|
||||
public record WorkflowStepApproverDto(
|
||||
int Kind, // 1=Role, 2=User
|
||||
string AssignmentValue,
|
||||
string? DisplayName); // resolved role label or user fullName
|
||||
|
||||
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
|
||||
public record WorkflowStepDto(
|
||||
Guid Id,
|
||||
int Order,
|
||||
int Phase,
|
||||
string PhaseLabel,
|
||||
string Name,
|
||||
int? SlaDays,
|
||||
List<WorkflowStepApproverDto> Approvers);
|
||||
|
||||
public record WorkflowTypeAssignmentDto(
|
||||
public record WorkflowDefinitionDto(
|
||||
Guid Id,
|
||||
string Code,
|
||||
int Version,
|
||||
int ContractType,
|
||||
string ContractTypeLabel,
|
||||
string CurrentPolicy,
|
||||
string DefaultPolicy,
|
||||
WorkflowPolicyDto Policy);
|
||||
string Name,
|
||||
string? Description,
|
||||
bool IsActive,
|
||||
DateTime? ActivatedAt,
|
||||
DateTime CreatedAt,
|
||||
int ContractsUsingCount,
|
||||
List<WorkflowStepDto> Steps);
|
||||
|
||||
public record WorkflowAdminOverviewDto(
|
||||
List<WorkflowPolicyDto> AvailablePolicies,
|
||||
List<WorkflowTypeAssignmentDto> Assignments);
|
||||
public record WorkflowTypeSummaryDto(
|
||||
int ContractType,
|
||||
string ContractTypeLabel,
|
||||
WorkflowDefinitionDto? Active,
|
||||
List<WorkflowDefinitionDto> History);
|
||||
|
||||
public record WorkflowAdminOverviewDto(List<WorkflowTypeSummaryDto> Types);
|
||||
|
||||
// ========== GET overview ==========
|
||||
|
||||
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
|
||||
|
||||
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
|
||||
public class GetWorkflowAdminOverviewQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
UserManager<User> userManager) : IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
|
||||
{
|
||||
private static readonly Dictionary<ContractType, string> TypeLabels = new()
|
||||
{
|
||||
@ -42,78 +67,186 @@ public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
|
||||
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ContractPhase, string> PhaseLabels = new()
|
||||
{
|
||||
[ContractPhase.DangSoanThao] = "Đang soạn thảo",
|
||||
[ContractPhase.DangGopY] = "Đang góp ý",
|
||||
[ContractPhase.DangDamPhan] = "Đang đàm phán",
|
||||
[ContractPhase.DangInKy] = "Đang in ký",
|
||||
[ContractPhase.DangKiemTraCCM] = "CCM kiểm tra",
|
||||
[ContractPhase.DangTrinhKy] = "Đang trình ký",
|
||||
[ContractPhase.DangDongDau] = "Đang đóng dấu",
|
||||
[ContractPhase.DaPhatHanh] = "Đã phát hành",
|
||||
};
|
||||
|
||||
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
|
||||
{
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
var definitions = await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.OrderByDescending(d => d.Version)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
|
||||
.Select(WorkflowPolicyRegistry.ByName)
|
||||
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
|
||||
// Resolve user names for User-kind approvers
|
||||
var userIds = definitions
|
||||
.SelectMany(d => d.Steps)
|
||||
.SelectMany(s => s.Approvers)
|
||||
.Where(a => a.Kind == WorkflowApproverKind.User && Guid.TryParse(a.AssignmentValue, out _))
|
||||
.Select(a => Guid.Parse(a.AssignmentValue))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var userNames = userIds.Count == 0
|
||||
? new Dictionary<Guid, string>()
|
||||
: await userManager.Users.AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||
|
||||
var assignments = Enum.GetValues<ContractType>()
|
||||
.Select(t =>
|
||||
// Count contracts per definition — admin sees which versions are still live
|
||||
var usageCounts = await db.Contracts.AsNoTracking()
|
||||
.Where(c => c.WorkflowDefinitionId != null)
|
||||
.GroupBy(c => c.WorkflowDefinitionId!.Value)
|
||||
.Select(g => new { Id = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, ct);
|
||||
|
||||
WorkflowDefinitionDto ToDto(WorkflowDefinition d) => new(
|
||||
d.Id,
|
||||
d.Code,
|
||||
d.Version,
|
||||
(int)d.ContractType,
|
||||
TypeLabels.GetValueOrDefault(d.ContractType, d.ContractType.ToString()),
|
||||
d.Name,
|
||||
d.Description,
|
||||
d.IsActive,
|
||||
d.ActivatedAt,
|
||||
d.CreatedAt,
|
||||
usageCounts.GetValueOrDefault(d.Id, 0),
|
||||
d.Steps.OrderBy(s => s.Order).Select(s => new WorkflowStepDto(
|
||||
s.Id,
|
||||
s.Order,
|
||||
(int)s.Phase,
|
||||
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
||||
s.Name,
|
||||
s.SlaDays,
|
||||
s.Approvers.Select(a => new WorkflowStepApproverDto(
|
||||
(int)a.Kind,
|
||||
a.AssignmentValue,
|
||||
ResolveDisplay(a, userNames))).ToList()
|
||||
)).ToList());
|
||||
|
||||
var types = Enum.GetValues<ContractType>()
|
||||
.Select(type =>
|
||||
{
|
||||
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()));
|
||||
var versions = definitions.Where(d => d.ContractType == type).Select(ToDto).ToList();
|
||||
return new WorkflowTypeSummaryDto(
|
||||
(int)type,
|
||||
TypeLabels.GetValueOrDefault(type, type.ToString()),
|
||||
versions.FirstOrDefault(v => v.IsActive),
|
||||
versions);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
|
||||
return new WorkflowAdminOverviewDto(types);
|
||||
}
|
||||
|
||||
private static string? ResolveDisplay(WorkflowStepApprover a, Dictionary<Guid, string> userNames)
|
||||
{
|
||||
if (a.Kind == WorkflowApproverKind.Role) return a.AssignmentValue;
|
||||
if (Guid.TryParse(a.AssignmentValue, out var uid) && userNames.TryGetValue(uid, out var n)) return n;
|
||||
return a.AssignmentValue;
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
|
||||
// ========== POST new version ==========
|
||||
|
||||
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
|
||||
public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
||||
|
||||
public record CreateWorkflowStepInput(
|
||||
int Order,
|
||||
int Phase,
|
||||
string Name,
|
||||
int? SlaDays,
|
||||
List<CreateWorkflowStepApproverInput> Approvers);
|
||||
|
||||
public record CreateWorkflowDefinitionCommand(
|
||||
ContractType ContractType,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
List<CreateWorkflowStepInput> Steps) : IRequest<Guid>;
|
||||
|
||||
public class CreateWorkflowDefinitionCommandValidator : AbstractValidator<CreateWorkflowDefinitionCommand>
|
||||
{
|
||||
public SetWorkflowAssignmentCommandValidator()
|
||||
public CreateWorkflowDefinitionCommandValidator()
|
||||
{
|
||||
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)
|
||||
RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
|
||||
.Matches("^[A-Za-z0-9._-]+$")
|
||||
.WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Description).MaximumLength(1000);
|
||||
RuleFor(x => x.Steps).NotEmpty()
|
||||
.WithMessage("Quy trình phải có ít nhất 1 bước.");
|
||||
RuleForEach(x => x.Steps).ChildRules(step =>
|
||||
{
|
||||
if (isDefault) return; // nothing to persist
|
||||
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
|
||||
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
||||
step.RuleFor(s => s.Phase).InclusiveBetween(1, 9);
|
||||
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
|
||||
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
|
||||
.When(s => s.SlaDays != null);
|
||||
step.RuleForEach(s => s.Approvers).ChildRules(app =>
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
PolicyName = request.PolicyName,
|
||||
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
||||
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
||||
});
|
||||
}
|
||||
else if (isDefault)
|
||||
{
|
||||
db.WorkflowTypeAssignments.Remove(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.PolicyName = request.PolicyName;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateWorkflowDefinitionCommandHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<CreateWorkflowDefinitionCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateWorkflowDefinitionCommand request, CancellationToken ct)
|
||||
{
|
||||
// Next version = max(existing) + 1. Versions monotonically increase per Code.
|
||||
var nextVersion = await db.WorkflowDefinitions
|
||||
.Where(w => w.Code == request.Code)
|
||||
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
|
||||
nextVersion++;
|
||||
|
||||
// Deactivate currently active version for this type — only ONE active
|
||||
// per ContractType at a time (app invariant).
|
||||
var activeVersions = await db.WorkflowDefinitions
|
||||
.Where(w => w.ContractType == request.ContractType && w.IsActive)
|
||||
.ToListAsync(ct);
|
||||
foreach (var old in activeVersions) old.IsActive = false;
|
||||
|
||||
var def = new WorkflowDefinition
|
||||
{
|
||||
Code = request.Code,
|
||||
Version = nextVersion,
|
||||
ContractType = request.ContractType,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.UtcNow,
|
||||
Steps = request.Steps
|
||||
.OrderBy(s => s.Order)
|
||||
.Select(s => new WorkflowStep
|
||||
{
|
||||
Order = s.Order,
|
||||
Phase = (ContractPhase)s.Phase,
|
||||
Name = s.Name,
|
||||
SlaDays = s.SlaDays,
|
||||
Approvers = s.Approvers.Select(a => new WorkflowStepApprover
|
||||
{
|
||||
Kind = (WorkflowApproverKind)a.Kind,
|
||||
AssignmentValue = a.AssignmentValue,
|
||||
}).ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
db.WorkflowDefinitions.Add(def);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return def.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ public class Contract : AuditableEntity
|
||||
public string? TenHopDong { get; set; }
|
||||
public string? NoiDung { get; set; }
|
||||
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
|
||||
public Guid? WorkflowDefinitionId { get; set; } // Pinned at creation — HĐ cũ chạy version cũ ngay cả khi admin active version mới
|
||||
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
|
||||
public string? DraftData { get; set; } // JSON field values (render template)
|
||||
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
// Versioned workflow definition per ContractType. Admin can edit quy trình =>
|
||||
// creates a NEW version; old version stays for contracts already pinned to it.
|
||||
//
|
||||
// Invariant: for any ContractType, AT MOST ONE WorkflowDefinition has
|
||||
// IsActive=true at any given time. Contract.WorkflowDefinitionId pins the
|
||||
// contract to its chosen version at creation time — so changing the active
|
||||
// version does not retroactively affect running contracts.
|
||||
public class WorkflowDefinition : BaseEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty; // "QT-TP-NCC" admin-editable
|
||||
public int Version { get; set; } // monotonically increases per Code
|
||||
public ContractType ContractType { get; set; }
|
||||
public string Name { get; set; } = string.Empty; // display label
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; } // only one per ContractType
|
||||
public DateTime? ActivatedAt { get; set; }
|
||||
|
||||
public List<WorkflowStep> Steps { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WorkflowStep : BaseEntity
|
||||
{
|
||||
public Guid WorkflowDefinitionId { get; set; }
|
||||
public int Order { get; set; } // 1-based sequence
|
||||
public ContractPhase Phase { get; set; } // which ContractPhase this step represents
|
||||
public string Name { get; set; } = string.Empty; // display, can differ from Phase label
|
||||
public int? SlaDays { get; set; } // null = no SLA for this step
|
||||
|
||||
public WorkflowDefinition? WorkflowDefinition { get; set; }
|
||||
public List<WorkflowStepApprover> Approvers { get; set; } = new();
|
||||
}
|
||||
|
||||
public enum WorkflowApproverKind
|
||||
{
|
||||
Role = 1, // AssignmentValue = role name (AppRoles.*)
|
||||
User = 2, // AssignmentValue = user id (Guid as string)
|
||||
}
|
||||
|
||||
public class WorkflowStepApprover : BaseEntity
|
||||
{
|
||||
public Guid WorkflowStepId { get; set; }
|
||||
public WorkflowApproverKind Kind { get; set; }
|
||||
public string AssignmentValue { get; set; } = string.Empty;
|
||||
|
||||
public WorkflowStep? Step { get; set; }
|
||||
}
|
||||
@ -153,4 +153,52 @@ public static class WorkflowPolicyRegistry
|
||||
return ByName(policyName);
|
||||
return For(contract.Type);
|
||||
}
|
||||
|
||||
// Build a policy from a persisted WorkflowDefinition (admin-authored).
|
||||
// Transitions are derived from ordered steps: prev.Phase → step.Phase,
|
||||
// allowed roles = role-kind approvers' names. Reject-back-to-Drafter +
|
||||
// TuChoi paths are auto-wired so the guard doesn't block common flows.
|
||||
// User-kind approvers are currently treated as role-approvers with
|
||||
// DeptManager fallback — user-level targeting comes in iteration 2.
|
||||
public static WorkflowPolicy FromDefinition(WorkflowDefinition def)
|
||||
{
|
||||
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
||||
var transitions = new Dictionary<(ContractPhase From, ContractPhase To), string[]>();
|
||||
var sla = new Dictionary<ContractPhase, TimeSpan?>();
|
||||
var activePhases = new List<ContractPhase>();
|
||||
|
||||
ContractPhase? prev = null;
|
||||
foreach (var s in steps)
|
||||
{
|
||||
activePhases.Add(s.Phase);
|
||||
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
|
||||
var roles = s.Approvers
|
||||
.Where(a => a.Kind == WorkflowApproverKind.Role)
|
||||
.Select(a => a.AssignmentValue)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (roles.Length == 0) roles = [AppRoles.DeptManager];
|
||||
if (prev is not null)
|
||||
{
|
||||
transitions[(prev.Value, s.Phase)] = roles;
|
||||
// Reject path back to Drafter (common pattern from QT docx)
|
||||
if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao)
|
||||
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
|
||||
}
|
||||
prev = s.Phase;
|
||||
}
|
||||
// First step can reject to TuChoi
|
||||
if (steps.Count > 0)
|
||||
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
|
||||
[AppRoles.Drafter, AppRoles.DeptManager]);
|
||||
|
||||
if (!activePhases.Contains(ContractPhase.TuChoi)) activePhases.Add(ContractPhase.TuChoi);
|
||||
|
||||
return new WorkflowPolicy(
|
||||
Name: $"{def.Code}-v{def.Version:D2}",
|
||||
Description: def.Description ?? def.Name,
|
||||
Transitions: transitions,
|
||||
PhaseSla: sla,
|
||||
ActivePhases: activePhases);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,9 @@ public class ApplicationDbContext
|
||||
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
|
||||
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
|
||||
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
|
||||
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class WorkflowDefinitionConfiguration : IEntityTypeConfiguration<WorkflowDefinition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowDefinition> e)
|
||||
{
|
||||
e.ToTable("WorkflowDefinitions");
|
||||
e.Property(x => x.Code).HasMaxLength(100).IsRequired();
|
||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Description).HasMaxLength(1000);
|
||||
e.Property(x => x.ContractType).HasConversion<int>();
|
||||
|
||||
// Unique (Code, Version) — prevents duplicate version numbers per code
|
||||
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
|
||||
// Helper index — "get active by type" hot path
|
||||
e.HasIndex(x => new { x.ContractType, x.IsActive });
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowStepConfiguration : IEntityTypeConfiguration<WorkflowStep>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowStep> e)
|
||||
{
|
||||
e.ToTable("WorkflowSteps");
|
||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Phase).HasConversion<int>();
|
||||
|
||||
e.HasOne(x => x.WorkflowDefinition)
|
||||
.WithMany(d => d.Steps)
|
||||
.HasForeignKey(x => x.WorkflowDefinitionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => new { x.WorkflowDefinitionId, x.Order });
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration<WorkflowStepApprover>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowStepApprover> e)
|
||||
{
|
||||
e.ToTable("WorkflowStepApprovers");
|
||||
e.Property(x => x.Kind).HasConversion<int>();
|
||||
e.Property(x => x.AssignmentValue).HasMaxLength(100).IsRequired();
|
||||
|
||||
e.HasOne(x => x.Step)
|
||||
.WithMany(s => s.Approvers)
|
||||
.HasForeignKey(x => x.WorkflowStepId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@ -33,9 +33,88 @@ public static class DbInitializer
|
||||
await SeedDepartmentsAsync(db, logger);
|
||||
await SeedDemoMasterDataAsync(db, logger);
|
||||
await SeedContractTemplatesAsync(db, logger);
|
||||
await SeedWorkflowDefinitionsAsync(db, logger);
|
||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||
}
|
||||
|
||||
// Seed v01 per ContractType from hardcoded WorkflowPolicies. Idempotent:
|
||||
// skip if any active definition already exists for that type (admin may
|
||||
// have already created custom versions — don't clobber).
|
||||
private static async Task SeedWorkflowDefinitionsAsync(ApplicationDbContext db, ILogger logger)
|
||||
{
|
||||
var typeLabels = new Dictionary<ContractType, (string Code, string Name)>
|
||||
{
|
||||
[ContractType.HopDongThauPhu] = ("QT-TP", "Quy trình HĐ Thầu phụ"),
|
||||
[ContractType.HopDongGiaoKhoan] = ("QT-GK", "Quy trình HĐ Giao khoán"),
|
||||
[ContractType.HopDongNhaCungCap] = ("QT-NCC", "Quy trình HĐ Nhà cung cấp"),
|
||||
[ContractType.HopDongDichVu] = ("QT-DV", "Quy trình HĐ Dịch vụ"),
|
||||
[ContractType.HopDongMuaBan] = ("QT-MB", "Quy trình HĐ Mua bán"),
|
||||
[ContractType.HopDongNguyenTacNCC] = ("QT-NTNCC","Quy trình HĐ Nguyên tắc NCC"),
|
||||
[ContractType.HopDongNguyenTacDichVu] = ("QT-NTDV", "Quy trình HĐ Nguyên tắc Dịch vụ"),
|
||||
};
|
||||
|
||||
var phaseNames = new Dictionary<ContractPhase, string>
|
||||
{
|
||||
[ContractPhase.DangSoanThao] = "Soạn thảo",
|
||||
[ContractPhase.DangGopY] = "Góp ý",
|
||||
[ContractPhase.DangDamPhan] = "Đàm phán",
|
||||
[ContractPhase.DangInKy] = "In ký",
|
||||
[ContractPhase.DangKiemTraCCM] = "Kiểm tra CCM",
|
||||
[ContractPhase.DangTrinhKy] = "Trình ký BOD",
|
||||
[ContractPhase.DangDongDau] = "Đóng dấu",
|
||||
[ContractPhase.DaPhatHanh] = "Phát hành",
|
||||
};
|
||||
|
||||
var added = 0;
|
||||
foreach (var (type, info) in typeLabels)
|
||||
{
|
||||
var alreadyExists = await db.WorkflowDefinitions.AnyAsync(w => w.ContractType == type);
|
||||
if (alreadyExists) continue;
|
||||
|
||||
var policy = WorkflowPolicyRegistry.For(type);
|
||||
var def = new WorkflowDefinition
|
||||
{
|
||||
Code = info.Code,
|
||||
Version = 1,
|
||||
ContractType = type,
|
||||
Name = $"{info.Name} (v01)",
|
||||
Description = policy.Description,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.UtcNow,
|
||||
Steps = policy.ActivePhases
|
||||
.Where(p => p != ContractPhase.TuChoi) // TuChoi is a terminal state, not a step
|
||||
.Select((p, idx) =>
|
||||
{
|
||||
var roles = policy.Transitions
|
||||
.Where(t => t.Key.To == p)
|
||||
.SelectMany(t => t.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return new WorkflowStep
|
||||
{
|
||||
Order = idx + 1,
|
||||
Phase = p,
|
||||
Name = phaseNames.GetValueOrDefault(p, p.ToString()),
|
||||
SlaDays = policy.PhaseSla.GetValueOrDefault(p) is TimeSpan s ? (int?)s.Days : null,
|
||||
Approvers = roles.Select(r => new WorkflowStepApprover
|
||||
{
|
||||
Kind = WorkflowApproverKind.Role,
|
||||
AssignmentValue = r,
|
||||
}).ToList(),
|
||||
};
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
db.WorkflowDefinitions.Add(def);
|
||||
added++;
|
||||
}
|
||||
if (added > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Seeded {Count} workflow definitions (v01)", added);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5.1 security: log warning nếu admin vẫn dùng password mặc định sau deploy production.
|
||||
private static async Task WarnDefaultAdminPasswordAsync(UserManager<User> userManager, ILogger logger)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVersionedWorkflows : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "WorkflowDefinitionId",
|
||||
table: "Contracts",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Version = table.Column<int>(type: "int", nullable: false),
|
||||
ContractType = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
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_WorkflowDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowSteps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
WorkflowDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Order = table.Column<int>(type: "int", nullable: false),
|
||||
Phase = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
SlaDays = table.Column<int>(type: "int", nullable: true),
|
||||
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_WorkflowSteps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WorkflowSteps_WorkflowDefinitions_WorkflowDefinitionId",
|
||||
column: x => x.WorkflowDefinitionId,
|
||||
principalTable: "WorkflowDefinitions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowStepApprovers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
WorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
AssignmentValue = table.Column<string>(type: "nvarchar(100)", maxLength: 100, 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_WorkflowStepApprovers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WorkflowStepApprovers_WorkflowSteps_WorkflowStepId",
|
||||
column: x => x.WorkflowStepId,
|
||||
principalTable: "WorkflowSteps",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowDefinitions_Code_Version",
|
||||
table: "WorkflowDefinitions",
|
||||
columns: new[] { "Code", "Version" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowDefinitions_ContractType_IsActive",
|
||||
table: "WorkflowDefinitions",
|
||||
columns: new[] { "ContractType", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowStepApprovers_WorkflowStepId",
|
||||
table: "WorkflowStepApprovers",
|
||||
column: "WorkflowStepId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowSteps_WorkflowDefinitionId_Order",
|
||||
table: "WorkflowSteps",
|
||||
columns: new[] { "WorkflowDefinitionId", "Order" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowStepApprovers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowSteps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowDefinitions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WorkflowDefinitionId",
|
||||
table: "Contracts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -201,6 +201,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("WorkflowDefinitionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaHopDong")
|
||||
@ -374,6 +377,138 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ContractComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("ActivatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int>("ContractType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code", "Version")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ContractType", "IsActive");
|
||||
|
||||
b.ToTable("WorkflowDefinitions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Phase")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("SlaDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("WorkflowDefinitionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkflowDefinitionId", "Order");
|
||||
|
||||
b.ToTable("WorkflowSteps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AssignmentValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("WorkflowStepId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkflowStepId");
|
||||
|
||||
b.ToTable("WorkflowStepApprovers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -1049,6 +1184,28 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Contract");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("WorkflowDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkflowDefinition");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step")
|
||||
.WithMany("Approvers")
|
||||
.HasForeignKey("WorkflowStepId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Step");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
||||
@ -1087,6 +1244,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Comments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
||||
{
|
||||
b.Navigation("Approvers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
@ -36,11 +36,26 @@ public class ContractWorkflowService(
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
// 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);
|
||||
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
|
||||
// versioned system), else fall back to the static/override registry
|
||||
// (legacy path for contracts created before versioning rolled out).
|
||||
WorkflowPolicy policy;
|
||||
if (contract.WorkflowDefinitionId is Guid wfId)
|
||||
{
|
||||
var def = await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||
policy = def is not null
|
||||
? WorkflowPolicyRegistry.FromDefinition(def)
|
||||
: WorkflowPolicyRegistry.ForContract(contract);
|
||||
}
|
||||
else
|
||||
{
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
|
||||
}
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user