From cae4d84830b2278d7a957555184101b554e6b3c2 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 21:46:31 +0700 Subject: [PATCH] [CLAUDE] Domain+Infra+App+FE: dynamic workflow policy per ContractType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Đọ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) --- docs/gotchas.md | 8 +- .../src/components/WorkflowSummaryCard.tsx | 63 ++++++++ .../pages/contracts/ContractDetailPage.tsx | 23 ++- fe-admin/src/types/contracts.ts | 8 + .../src/components/WorkflowSummaryCard.tsx | 63 ++++++++ .../pages/contracts/ContractDetailPage.tsx | 21 +-- fe-user/src/types/contracts.ts | 8 + .../Contracts/ContractFeatures.cs | 15 +- .../Contracts/Dtos/ContractDtos.cs | 11 +- .../Contracts/WorkflowPolicy.cs | 139 ++++++++++++++++++ .../Services/ContractWorkflowService.cs | 71 +++------ 11 files changed, 342 insertions(+), 88 deletions(-) create mode 100644 fe-admin/src/components/WorkflowSummaryCard.tsx create mode 100644 fe-user/src/components/WorkflowSummaryCard.tsx create mode 100644 src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs diff --git a/docs/gotchas.md b/docs/gotchas.md index 12ec050..52edbbf 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -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 diff --git a/fe-admin/src/components/WorkflowSummaryCard.tsx b/fe-admin/src/components/WorkflowSummaryCard.tsx new file mode 100644 index 0000000..3814c65 --- /dev/null +++ b/fe-admin/src/components/WorkflowSummaryCard.tsx @@ -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 ( +
+

+ + Quy trình: {workflow.policyName} +

+

{workflow.policyDescription}

+ +
    + {workflow.activePhases.map((phase, idx) => { + const isDone = currentIdx >= 0 && idx < currentIdx + const isCurrent = phase === currentPhase + const isFuture = currentIdx >= 0 && idx > currentIdx + return ( +
  1. +
    + {isDone ? : isCurrent ? : idx + 1} +
    +
    +
    + {ContractPhaseLabel[phase] ?? `Phase ${phase}`} +
    +
    + {isCurrent && } +
  2. + ) + })} +
+
+ ) +} diff --git a/fe-admin/src/pages/contracts/ContractDetailPage.tsx b/fe-admin/src/pages/contracts/ContractDetailPage.tsx index 2e4e9e1..57582fd 100644 --- a/fe-admin/src/pages/contracts/ContractDetailPage.tsx +++ b/fe-admin/src/pages/contracts/ContractDetailPage.tsx @@ -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 = { - [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
Không tìm thấy HĐ.
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() { -