[CLAUDE] Domain+Infra+App+FE: dynamic workflow policy per ContractType
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m42s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m42s
Đọc QT-TP-NCC.docx: quy trình 9 bước chỉ áp dụng cho Thầu phụ/NCC/Tổ đội.
Dịch vụ/Mua bán/Nguyên tắc bypass CCM. Thay hardcoded dict bằng policy
registry.
Domain — WorkflowPolicy.cs:
- Record WorkflowPolicy { Name, Description, Transitions, PhaseSla,
ActivePhases } — pure data, testable.
- WorkflowPolicies.Standard: 9-phase full (Thầu phụ/Giao khoán/NCC)
- WorkflowPolicies.SkipCcm: 7-phase (Dịch vụ/Mua bán/Nguyên tắc)
- WorkflowPolicyRegistry.For(type) map ContractType → policy
- WorkflowPolicyRegistry.ForContract(c) override nếu BypassProcurement
AndCCM=true (instance-level escape hatch)
Infrastructure — ContractWorkflowService:
- Xóa hardcoded Transitions/PhaseSla dicts → load từ policy.ForContract
- TransitionAsync: validate qua policy.Transitions thay vì dict local
- Error message include policy.Name để debug dễ hơn
- GetPhaseSla trả SLA từ Standard policy (fallback — SLA hiện tại giống
nhau giữa 2 policy)
Application — ContractDetailDto:
- Field mới `Workflow: WorkflowSummaryDto { PolicyName, Description,
ActivePhases, NextPhases }` — FE dùng để render nút chuyển phase
dynamic + timeline card.
- BuildWorkflowSummary helper trong ContractFeatures.
FE (both apps):
- Type WorkflowSummary + ContractDetail.workflow
- ContractDetailPage xóa hardcoded NEXT_PHASES — dùng
c.workflow.nextPhases từ BE (single source of truth)
- WorkflowSummaryCard: timeline của ActivePhases với check/current/
future states + policy name/description ở header
- Card hiển thị trong sidebar, phía trên "Lịch sử duyệt"
Docs:
- gotchas.md #21 marked RESOLVED (NEXT_PHASES sync không còn cần)
Foundation: sau này admin có thể edit policy qua UI khi chuyển sang DB-
backed policy — nhưng API contract (WorkflowSummaryDto) đã stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -156,13 +156,11 @@ Mỗi migration tạo: `{name}.cs` + `{name}.Designer.cs` + `ApplicationDbContex
|
||||
|
||||
**Fix:** Check `if (contract.MaHopDong is null)` trước khi gen. Đã implement trong `ContractWorkflowService.TransitionAsync`.
|
||||
|
||||
### 21. BE adjacency vs FE NEXT_PHASES sync
|
||||
### 21. ~~BE adjacency vs FE NEXT_PHASES sync~~ (RESOLVED)
|
||||
|
||||
**Triệu chứng:** FE hiển thị nút chuyển phase, click → BE 403.
|
||||
**Đã xử lý:** FE không còn hardcode `NEXT_PHASES` nữa. BE expose `contract.workflow.nextPhases` trong `ContractDetailDto` từ `WorkflowPolicyRegistry.ForContract(contract)`. FE render dynamic từ đó — single source of truth.
|
||||
|
||||
**Nguyên nhân:** FE `NEXT_PHASES` map phải khớp BE `Transitions` dict.
|
||||
|
||||
**Fix:** Khi đổi adjacency BE → sync FE `src/pages/contracts/ContractDetailPage.tsx` ngay lập tức (cả 2 app).
|
||||
Nếu đổi policy BE: chỉ cần update `WorkflowPolicies.Standard` hoặc `WorkflowPolicies.SkipCcm` trong `Domain/Contracts/WorkflowPolicy.cs`. FE tự reflect.
|
||||
|
||||
### 22. Race condition gen mã HĐ khi 2 user cùng transition tới DangDongDau
|
||||
|
||||
|
||||
63
fe-admin/src/components/WorkflowSummaryCard.tsx
Normal file
63
fe-admin/src/components/WorkflowSummaryCard.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Check, Circle, GitBranch } from 'lucide-react'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { ContractPhaseLabel } from '@/types/contracts'
|
||||
import type { WorkflowSummary } from '@/types/contracts'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Shows the active WorkflowPolicy (Standard / SkipCcm / …) as a timeline of
|
||||
// phases with the current one highlighted. Drafter + approvers see clearly
|
||||
// "where we are" and "what comes next" — no more mental mapping of enum ints.
|
||||
export function WorkflowSummaryCard({
|
||||
workflow,
|
||||
currentPhase,
|
||||
}: {
|
||||
workflow: WorkflowSummary
|
||||
currentPhase: number
|
||||
}) {
|
||||
const currentIdx = workflow.activePhases.indexOf(currentPhase)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Quy trình: {workflow.policyName}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">{workflow.policyDescription}</p>
|
||||
|
||||
<ol className="space-y-2">
|
||||
{workflow.activePhases.map((phase, idx) => {
|
||||
const isDone = currentIdx >= 0 && idx < currentIdx
|
||||
const isCurrent = phase === currentPhase
|
||||
const isFuture = currentIdx >= 0 && idx > currentIdx
|
||||
return (
|
||||
<li key={phase} className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold',
|
||||
isDone && 'bg-emerald-100 text-emerald-700',
|
||||
isCurrent && 'bg-brand-600 text-white ring-4 ring-brand-100',
|
||||
isFuture && 'bg-slate-100 text-slate-400',
|
||||
)}
|
||||
>
|
||||
{isDone ? <Check className="h-3 w-3" /> : isCurrent ? <Circle className="h-2 w-2 fill-current" /> : idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isCurrent && 'font-semibold text-slate-900',
|
||||
isFuture && 'text-slate-500',
|
||||
isDone && 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && <PhaseBadge phase={phase} className="text-[10px]" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { PageHeader } from '@/components/PageHeader'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
@ -24,16 +25,8 @@ import { ContractTypeLabel } from '@/types/forms'
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||
|
||||
// Các phase có thể chuyển đến từ phase hiện tại (match adjacency BE)
|
||||
const NEXT_PHASES: Record<number, number[]> = {
|
||||
[ContractPhase.DangSoanThao]: [ContractPhase.DangGopY, ContractPhase.TuChoi],
|
||||
[ContractPhase.DangGopY]: [ContractPhase.DangDamPhan, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDamPhan]: [ContractPhase.DangInKy],
|
||||
[ContractPhase.DangInKy]: [ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy],
|
||||
[ContractPhase.DangKiemTraCCM]: [ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangTrinhKy]: [ContractPhase.DangDongDau, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDongDau]: [ContractPhase.DaPhatHanh],
|
||||
}
|
||||
// NEXT_PHASES giờ là dynamic từ BE qua contract.workflow.nextPhases — không
|
||||
// hardcode FE (xem ContractDetailDto.Workflow).
|
||||
|
||||
export function ContractDetailPage() {
|
||||
const { id } = useParams()
|
||||
@ -83,10 +76,10 @@ export function ContractDetailPage() {
|
||||
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy HĐ.</div>
|
||||
const c = detail.data
|
||||
|
||||
const availableTargets = NEXT_PHASES[c.phase] ?? []
|
||||
const availableTargets = c.workflow?.nextPhases ?? []
|
||||
|
||||
function openAction(decisionType: number) {
|
||||
const targets = NEXT_PHASES[c.phase] ?? []
|
||||
const targets = c.workflow?.nextPhases ?? []
|
||||
// Default: approve → first target; reject → prev (find DangSoanThao)
|
||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
||||
@ -193,8 +186,10 @@ export function ContractDetailPage() {
|
||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<aside className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
Lịch sử duyệt ({c.approvals.length})
|
||||
|
||||
@ -94,6 +94,13 @@ export type ContractAttachment = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkflowSummary = {
|
||||
policyName: string
|
||||
policyDescription: string
|
||||
activePhases: number[]
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
export type ContractDetail = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
@ -119,4 +126,5 @@ export type ContractDetail = {
|
||||
approvals: ContractApproval[]
|
||||
comments: ContractComment[]
|
||||
attachments: ContractAttachment[]
|
||||
workflow: WorkflowSummary
|
||||
}
|
||||
|
||||
63
fe-user/src/components/WorkflowSummaryCard.tsx
Normal file
63
fe-user/src/components/WorkflowSummaryCard.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Check, Circle, GitBranch } from 'lucide-react'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { ContractPhaseLabel } from '@/types/contracts'
|
||||
import type { WorkflowSummary } from '@/types/contracts'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Shows the active WorkflowPolicy (Standard / SkipCcm / …) as a timeline of
|
||||
// phases with the current one highlighted. Drafter + approvers see clearly
|
||||
// "where we are" and "what comes next" — no more mental mapping of enum ints.
|
||||
export function WorkflowSummaryCard({
|
||||
workflow,
|
||||
currentPhase,
|
||||
}: {
|
||||
workflow: WorkflowSummary
|
||||
currentPhase: number
|
||||
}) {
|
||||
const currentIdx = workflow.activePhases.indexOf(currentPhase)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Quy trình: {workflow.policyName}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">{workflow.policyDescription}</p>
|
||||
|
||||
<ol className="space-y-2">
|
||||
{workflow.activePhases.map((phase, idx) => {
|
||||
const isDone = currentIdx >= 0 && idx < currentIdx
|
||||
const isCurrent = phase === currentPhase
|
||||
const isFuture = currentIdx >= 0 && idx > currentIdx
|
||||
return (
|
||||
<li key={phase} className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold',
|
||||
isDone && 'bg-emerald-100 text-emerald-700',
|
||||
isCurrent && 'bg-brand-600 text-white ring-4 ring-brand-100',
|
||||
isFuture && 'bg-slate-100 text-slate-400',
|
||||
)}
|
||||
>
|
||||
{isDone ? <Check className="h-3 w-3" /> : isCurrent ? <Circle className="h-2 w-2 fill-current" /> : idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isCurrent && 'font-semibold text-slate-900',
|
||||
isFuture && 'text-slate-500',
|
||||
isDone && 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && <PhaseBadge phase={phase} className="text-[10px]" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { PageHeader } from '@/components/PageHeader'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
@ -26,15 +27,7 @@ import { ContractTypeLabel } from '@/types/forms'
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||
|
||||
const NEXT_PHASES: Record<number, number[]> = {
|
||||
[ContractPhase.DangSoanThao]: [ContractPhase.DangGopY, ContractPhase.TuChoi],
|
||||
[ContractPhase.DangGopY]: [ContractPhase.DangDamPhan, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDamPhan]: [ContractPhase.DangInKy],
|
||||
[ContractPhase.DangInKy]: [ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy],
|
||||
[ContractPhase.DangKiemTraCCM]: [ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangTrinhKy]: [ContractPhase.DangDongDau, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDongDau]: [ContractPhase.DaPhatHanh],
|
||||
}
|
||||
// NEXT_PHASES dynamic từ BE qua contract.workflow.nextPhases.
|
||||
|
||||
export function ContractDetailPage() {
|
||||
const { id } = useParams()
|
||||
@ -82,10 +75,10 @@ export function ContractDetailPage() {
|
||||
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy HĐ.</div>
|
||||
const c = detail.data
|
||||
|
||||
const availableTargets = NEXT_PHASES[c.phase] ?? []
|
||||
const availableTargets = c.workflow?.nextPhases ?? []
|
||||
|
||||
function openAction(decisionType: number) {
|
||||
const targets = NEXT_PHASES[c.phase] ?? []
|
||||
const targets = c.workflow?.nextPhases ?? []
|
||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
||||
: targets[0]
|
||||
@ -184,8 +177,10 @@ export function ContractDetailPage() {
|
||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<aside className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
Lịch sử ({c.approvals.length})
|
||||
|
||||
@ -94,6 +94,13 @@ export type ContractAttachment = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkflowSummary = {
|
||||
policyName: string
|
||||
policyDescription: string
|
||||
activePhases: number[]
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
export type ContractDetail = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
@ -119,4 +126,5 @@ export type ContractDetail = {
|
||||
approvals: ContractApproval[]
|
||||
comments: ContractComment[]
|
||||
attachments: ContractAttachment[]
|
||||
workflow: WorkflowSummary
|
||||
}
|
||||
|
||||
@ -375,7 +375,20 @@ public class GetContractQueryHandler(
|
||||
.Select(att => new ContractAttachmentDto(
|
||||
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
||||
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
||||
.ToList());
|
||||
.ToList(),
|
||||
BuildWorkflowSummary(c));
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var policy = WorkflowPolicyRegistry.ForContract(c);
|
||||
return new WorkflowSummaryDto(
|
||||
PolicyName: policy.Name,
|
||||
PolicyDescription: policy.Description,
|
||||
ActivePhases: policy.ActivePhases.ToList(),
|
||||
NextPhases: policy.NextPhasesFrom(c.Phase).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,16 @@ public record ContractDetailDto(
|
||||
DateTime? UpdatedAt,
|
||||
List<ContractApprovalDto> Approvals,
|
||||
List<ContractCommentDto> Comments,
|
||||
List<ContractAttachmentDto> Attachments);
|
||||
List<ContractAttachmentDto> Attachments,
|
||||
WorkflowSummaryDto Workflow);
|
||||
|
||||
// Policy snapshot for the FE — lets UI render next-phase buttons dynamically
|
||||
// without hardcoding the transition map (single source of truth in BE).
|
||||
public record WorkflowSummaryDto(
|
||||
string PolicyName,
|
||||
string PolicyDescription,
|
||||
List<ContractPhase> ActivePhases,
|
||||
List<ContractPhase> NextPhases);
|
||||
|
||||
public record ContractApprovalDto(
|
||||
Guid Id,
|
||||
|
||||
139
src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs
Normal file
139
src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs
Normal file
@ -0,0 +1,139 @@
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
// Encapsulates the workflow rules for one class of contracts — which phases
|
||||
// apply, who can trigger each transition, and how long each phase gets.
|
||||
//
|
||||
// A WorkflowPolicy is paired with one or more ContractType values via
|
||||
// WorkflowPolicyRegistry. This keeps logic pure + testable and avoids the
|
||||
// "god dict" problem the hardcoded ContractWorkflowService had.
|
||||
//
|
||||
// Domain-layer class (not DB-backed yet) — admin can edit per-contract
|
||||
// overrides at the instance level via Contract.BypassProcurementAndCCM;
|
||||
// iteration 2 will make the whole thing DB-backed + admin-editable.
|
||||
public sealed record WorkflowPolicy(
|
||||
string Name,
|
||||
string Description,
|
||||
IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]> Transitions,
|
||||
IReadOnlyDictionary<ContractPhase, TimeSpan?> PhaseSla,
|
||||
IReadOnlyList<ContractPhase> ActivePhases)
|
||||
{
|
||||
public bool HasPhase(ContractPhase phase) => ActivePhases.Contains(phase);
|
||||
|
||||
public bool IsTransitionAllowed(ContractPhase from, ContractPhase to, IReadOnlyList<string> actorRoles)
|
||||
{
|
||||
if (!Transitions.TryGetValue((from, to), out var roles)) return false;
|
||||
return actorRoles.Any(r => roles.Contains(r));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ContractPhase> NextPhasesFrom(ContractPhase from) =>
|
||||
Transitions.Keys.Where(k => k.From == from).Select(k => k.To).Distinct().ToList();
|
||||
}
|
||||
|
||||
public static class WorkflowPolicies
|
||||
{
|
||||
// ===== Shared SLA defaults — same across policies. Override per-policy
|
||||
// if a class of contracts should move faster/slower. =====
|
||||
private static readonly Dictionary<ContractPhase, TimeSpan?> DefaultSla = new()
|
||||
{
|
||||
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
|
||||
[ContractPhase.DangKiemTraCCM] = TimeSpan.FromDays(3),
|
||||
[ContractPhase.DangTrinhKy] = TimeSpan.FromDays(1),
|
||||
[ContractPhase.DangDongDau] = null,
|
||||
[ContractPhase.DaPhatHanh] = null,
|
||||
[ContractPhase.TuChoi] = null,
|
||||
[ContractPhase.DangChon] = null,
|
||||
};
|
||||
|
||||
// ===== STANDARD: 9-phase formal workflow =====
|
||||
// Per QT-TP-NCC.docx: Thầu phụ / NCC / Tổ đội — full CCM review required.
|
||||
public static readonly WorkflowPolicy Standard = new(
|
||||
Name: "Standard",
|
||||
Description: "Quy trình đầy đủ 8 phase — CCM kiểm tra + BOD duyệt. Áp dụng HĐ Thầu phụ / NCC / Giao khoán.",
|
||||
Transitions: new Dictionary<(ContractPhase, ContractPhase), string[]>
|
||||
{
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
|
||||
|
||||
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
|
||||
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
|
||||
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
|
||||
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
|
||||
},
|
||||
PhaseSla: DefaultSla,
|
||||
ActivePhases:
|
||||
[
|
||||
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
|
||||
ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
|
||||
ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi,
|
||||
]);
|
||||
|
||||
// ===== SKIP-CCM: 7-phase for service / purchase contracts =====
|
||||
// Áp dụng HĐ Dịch vụ, Mua bán — không cần CCM review riêng, đi thẳng từ
|
||||
// DangInKy → DangTrinhKy (BOD vẫn duyệt).
|
||||
public static readonly WorkflowPolicy SkipCcm = new(
|
||||
Name: "SkipCcm",
|
||||
Description: "Bỏ phase CCM — DangInKy đi thẳng DangTrinhKy. Áp dụng HĐ Dịch vụ / Mua bán / HĐ Nguyên tắc.",
|
||||
Transitions: new Dictionary<(ContractPhase, ContractPhase), string[]>
|
||||
{
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
|
||||
|
||||
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
// Skip CCM — go straight to BOD
|
||||
[(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
|
||||
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
|
||||
},
|
||||
PhaseSla: DefaultSla,
|
||||
ActivePhases:
|
||||
[
|
||||
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
|
||||
ContractPhase.DangInKy, ContractPhase.DangTrinhKy,
|
||||
ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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,
|
||||
_ => WorkflowPolicies.Standard,
|
||||
};
|
||||
|
||||
// Instance-level bypass flag overrides the default: 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);
|
||||
}
|
||||
@ -9,52 +9,20 @@ using SolutionErp.Domain.Notifications;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
// Thin orchestrator — all phase/role/SLA rules live in WorkflowPolicy (Domain).
|
||||
// This class is responsible only for *applying* transitions: DB writes, code
|
||||
// generation at DangDongDau, SLA deadline computation, notification dispatch.
|
||||
public class ContractWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
IContractCodeGenerator codeGenerator,
|
||||
IDateTime dateTime,
|
||||
INotificationService notifications) : IContractWorkflowService
|
||||
{
|
||||
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
|
||||
// Admin luôn bypass (check trong Handler trước khi gọi service).
|
||||
private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> Transitions = new()
|
||||
{
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
|
||||
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
|
||||
|
||||
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
// Bypass CCM cho HĐ Chủ đầu tư — xử lý riêng trong CanTransition
|
||||
[(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
|
||||
|
||||
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
|
||||
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
|
||||
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||
|
||||
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ContractPhase, TimeSpan?> PhaseSla = new()
|
||||
{
|
||||
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
|
||||
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
|
||||
[ContractPhase.DangKiemTraCCM] = TimeSpan.FromDays(3),
|
||||
[ContractPhase.DangTrinhKy] = TimeSpan.FromDays(1),
|
||||
[ContractPhase.DangDongDau] = null,
|
||||
[ContractPhase.DaPhatHanh] = null,
|
||||
[ContractPhase.TuChoi] = null,
|
||||
[ContractPhase.DangChon] = null,
|
||||
};
|
||||
|
||||
public TimeSpan? GetPhaseSla(ContractPhase phase) => PhaseSla.GetValueOrDefault(phase);
|
||||
// Expose per-policy SLA via the contract — accepts optional contract so the
|
||||
// caller (CreateContractCommand) can ask for a specific type's SLA even
|
||||
// before the contract exists.
|
||||
public TimeSpan? GetPhaseSla(ContractPhase phase) =>
|
||||
WorkflowPolicies.Standard.PhaseSla.GetValueOrDefault(phase);
|
||||
|
||||
public async Task TransitionAsync(
|
||||
Contract contract,
|
||||
@ -68,24 +36,21 @@ public class ContractWorkflowService(
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
var policy = WorkflowPolicyRegistry.ForContract(contract);
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
if (!isAdmin && !isSystem)
|
||||
{
|
||||
if (!Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {targetPhase}.");
|
||||
|
||||
// Bypass rule: nếu BypassProcurementAndCCM + đang ở DangInKy → chỉ cho chuyển DangTrinhKy (skip CCM)
|
||||
if (!contract.BypassProcurementAndCCM
|
||||
&& contract.Phase == ContractPhase.DangInKy
|
||||
&& targetPhase == ContractPhase.DangTrinhKy)
|
||||
{
|
||||
throw new ForbiddenException("Chỉ HĐ với Chủ đầu tư mới được bỏ qua phase CCM.");
|
||||
}
|
||||
if (!policy.Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException(
|
||||
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
|
||||
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
|
||||
|
||||
if (!actorRoles.Any(r => allowedRoles.Contains(r)))
|
||||
throw new ForbiddenException($"Role của bạn ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}.");
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
||||
}
|
||||
|
||||
var fromPhase = contract.Phase;
|
||||
@ -100,11 +65,10 @@ public class ContractWorkflowService(
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
}
|
||||
|
||||
// Reset SlaWarningSent khi chuyển phase
|
||||
contract.SlaWarningSent = false;
|
||||
contract.Phase = targetPhase;
|
||||
|
||||
var sla = GetPhaseSla(targetPhase);
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
||||
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
@ -118,7 +82,6 @@ public class ContractWorkflowService(
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Notify the drafter (unless they are the actor or contract has no drafter)
|
||||
if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
||||
{
|
||||
var title = targetPhase switch
|
||||
|
||||
Reference in New Issue
Block a user