[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

@ -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());
}
}

View File

@ -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,

View 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);
}

View File

@ -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