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