[CLAUDE] Domain+Infra+App+FE: dynamic workflow policy per ContractType
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:
pqhuy1987
2026-04-21 21:46:31 +07:00
parent e45909712b
commit cae4d84830
11 changed files with 342 additions and 88 deletions

View File

@ -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 .</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})