From e7e5f2d066a62669cbf335ef01c6c09f2c6c6813 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 22:57:41 +0700 Subject: [PATCH] [CLAUDE] Domain+Infra+App+Api+FE-Admin: versioned workflow per ContractType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fe-admin/src/pages/system/WorkflowsPage.tsx | 548 +++++-- .../Controllers/WorkflowsController.cs | 13 +- .../Interfaces/IApplicationDbContext.cs | 3 + .../Contracts/ContractFeatures.cs | 36 +- .../Contracts/WorkflowAdminFeatures.cs | 271 +++- .../SolutionErp.Domain/Contracts/Contract.cs | 1 + .../Contracts/WorkflowDefinition.cs | 50 + .../Contracts/WorkflowPolicy.cs | 48 + .../Persistence/ApplicationDbContext.cs | 3 + .../WorkflowDefinitionConfiguration.cs | 54 + .../Persistence/DbInitializer.cs | 79 + ...21155557_AddVersionedWorkflows.Designer.cs | 1269 +++++++++++++++++ .../20260421155557_AddVersionedWorkflows.cs | 131 ++ .../ApplicationDbContextModelSnapshot.cs | 167 +++ .../Services/ContractWorkflowService.cs | 25 +- 15 files changed, 2510 insertions(+), 188 deletions(-) create mode 100644 src/Backend/SolutionErp.Domain/Contracts/WorkflowDefinition.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowDefinitionConfiguration.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421155557_AddVersionedWorkflows.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421155557_AddVersionedWorkflows.cs diff --git a/fe-admin/src/pages/system/WorkflowsPage.tsx b/fe-admin/src/pages/system/WorkflowsPage.tsx index 21cd41f..73fcda5 100644 --- a/fe-admin/src/pages/system/WorkflowsPage.tsx +++ b/fe-admin/src/pages/system/WorkflowsPage.tsx @@ -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('/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(null) + const tab = activeType ?? overview.data?.types[0]?.contractType ?? 1 + const currentType = overview.data?.types.find(t => t.contractType === tab) return (
@@ -58,81 +83,406 @@ export function WorkflowsPage() { Quy trình duyệt hợp đồng } - 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ũ." /> -
- -
- Standard: quy trình đầy đủ 8 phase có CCM review — áp dụng cho HĐ Thầu phụ/Giao khoán/NCC. - {' · '} - SkipCcm: 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. -
-
- - {overview.isLoading &&
Đang tải…
} - - {data && ( -
- {data.assignments.map(a => { - const isOverridden = a.currentPolicy !== a.defaultPolicy - return ( -
-
-
-
-

{a.contractTypeLabel}

- {isOverridden && ( - - Đã override - - )} -
-

{a.policy.description}

- -
- Các phase: - {a.policy.activePhases - .filter(p => p !== 99) // hide TuChoi in timeline — it's a terminal error path - .map((p, idx, arr) => ( - - - {ContractPhaseLabel[p] ?? p} - - {idx < arr.length - 1 && } - - ))} -
-
-
- - -
-
-
- ) - })} + {/* Tabs */} + {overview.data && ( +
+ {overview.data.types.map(t => ( + + ))}
)} -
- Iteration 2: 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 &&
Đang tải…
} + + {currentType && qc.invalidateQueries({ queryKey: ['workflow-overview'] })} />} +
+ ) +} + +// ===== Per-type panel ===== + +function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) { + const [designerOpen, setDesignerOpen] = useState(false) + const [cloneFrom, setCloneFrom] = useState(null) + + return ( +
+ {type.active ? ( + { setCloneFrom(d); setDesignerOpen(true) }} /> + ) : ( +
+ Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới. +
+ )} + +
+

Lịch sử versions

+ +
+ + {type.history.filter(d => !d.isActive).length === 0 && ( +
+ Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive. +
+ )} + +
+ {type.history + .filter(d => !d.isActive) + .map(d => ( + { setCloneFrom(dd); setDesignerOpen(true) }} /> + ))} +
+ + {designerOpen && ( + { setDesignerOpen(false); setCloneFrom(null) }} + onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }} + /> + )} +
+ ) +} + +// ===== Definition card (read-only view) ===== + +function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) { + return ( +
+
+
+
+

+ {def.name} +

+ + {def.code} v{String(def.version).padStart(2, '0')} + + {isActive ? ( + + + Đang áp dụng + + ) : ( + + + Archived · {def.contractsUsingCount} HĐ còn chạy + + )} +
+ {def.description &&

{def.description}

} + +
    + {def.steps.map(s => ( +
  1. +
    + {s.order} +
    +
    +
    + {s.name} + ({s.phaseLabel}) + {s.slaDays != null && ( + + SLA {s.slaDays}d + + )} +
    +
    + {s.approvers.length === 0 && ( + Chưa có người duyệt + )} + {s.approvers.map((a, i) => ( + + {a.kind === 1 ? 'Role' : 'User'}: {a.displayName ?? a.assignmentValue} + + ))} +
    +
    +
  2. + ))} +
+
+
) } + +// ===== 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(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 ( + + + + + } + > +
+
+
+ + setCode(e.target.value)} required className="font-mono" /> +
Ví dụ QT-TP, QT-MB. Version auto-tăng mỗi lần lưu.
+
+
+ + setName(e.target.value)} required /> +
+
+ +