[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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user