[CLAUDE] Domain+Infra+App+Api+FE-Admin: versioned workflow per ContractType
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m32s
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m32s
User yêu cầu: mỗi loại HĐ có quy trình riêng với admin add roles + users vào từng bước. Khi tạo version mới → HĐ tương lai chạy theo, HĐ cũ giữ version cũ. Domain: - WorkflowDefinition (Code + Version + ContractType + IsActive + Steps) - WorkflowStep (Order + Phase + Name + SlaDays + Approvers) - WorkflowStepApprover (Kind: Role/User + AssignmentValue) - Contract.WorkflowDefinitionId — pinned at creation - WorkflowPolicyRegistry.FromDefinition() — build runtime policy từ DB Infrastructure: - EF config + migration AddVersionedWorkflows (3 table mới) - DbInitializer.SeedWorkflowDefinitionsAsync: v01 per 7 ContractType, steps sinh từ hardcoded WorkflowPolicies (Role approvers). - ContractWorkflowService.TransitionAsync: load pinned WorkflowDefinition → FromDefinition(), fallback cho HĐ cũ không có pin. Application: - CreateContractCommand pin WorkflowDefinitionId = active version cho type - ContractFeatures.Get(id): load pinned def cho workflow summary - WorkflowAdminFeatures: GetWorkflowAdminOverviewQuery (7 types + active + history + ContractsUsingCount), CreateWorkflowDefinitionCommand (validate payload, auto-increment version, deactivate old). Api: - GET /api/workflows trả overview - POST /api/workflows tạo version mới (deactivate old) FE /system/workflows: - Tabs per 7 ContractType, mỗi tab hiện active version + lịch sử - DefinitionCard: steps với badge role/user + SLA + archived indicator hiện "N HĐ còn chạy" cho version cũ - WorkflowDesigner modal: form code/name/desc + danh sách steps (phase/name/SLA) + approvers (+ Role hoặc + User). Drop step ok. Clone từ version hiện tại để tạo v02 có điểm start sensible. - Amber banner: HĐ cũ không bị ảnh hưởng khi tạo version mới Invariants được giữ: - Unique (Code, Version) index - Chỉ 1 version IsActive per ContractType tại 1 thời điểm - Set default sẽ auto xóa override → respect legacy override table - Role-kind approvers drive transition guards; User-kind fallback DeptManager role cho v1 (user-level targeting = iteration 2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -18,6 +18,7 @@ public class Contract : AuditableEntity
|
||||
public string? TenHopDong { get; set; }
|
||||
public string? NoiDung { get; set; }
|
||||
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
|
||||
public Guid? WorkflowDefinitionId { get; set; } // Pinned at creation — HĐ cũ chạy version cũ ngay cả khi admin active version mới
|
||||
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
|
||||
public string? DraftData { get; set; } // JSON field values (render template)
|
||||
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
// Versioned workflow definition per ContractType. Admin can edit quy trình =>
|
||||
// creates a NEW version; old version stays for contracts already pinned to it.
|
||||
//
|
||||
// Invariant: for any ContractType, AT MOST ONE WorkflowDefinition has
|
||||
// IsActive=true at any given time. Contract.WorkflowDefinitionId pins the
|
||||
// contract to its chosen version at creation time — so changing the active
|
||||
// version does not retroactively affect running contracts.
|
||||
public class WorkflowDefinition : BaseEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty; // "QT-TP-NCC" admin-editable
|
||||
public int Version { get; set; } // monotonically increases per Code
|
||||
public ContractType ContractType { get; set; }
|
||||
public string Name { get; set; } = string.Empty; // display label
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; } // only one per ContractType
|
||||
public DateTime? ActivatedAt { get; set; }
|
||||
|
||||
public List<WorkflowStep> Steps { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WorkflowStep : BaseEntity
|
||||
{
|
||||
public Guid WorkflowDefinitionId { get; set; }
|
||||
public int Order { get; set; } // 1-based sequence
|
||||
public ContractPhase Phase { get; set; } // which ContractPhase this step represents
|
||||
public string Name { get; set; } = string.Empty; // display, can differ from Phase label
|
||||
public int? SlaDays { get; set; } // null = no SLA for this step
|
||||
|
||||
public WorkflowDefinition? WorkflowDefinition { get; set; }
|
||||
public List<WorkflowStepApprover> Approvers { get; set; } = new();
|
||||
}
|
||||
|
||||
public enum WorkflowApproverKind
|
||||
{
|
||||
Role = 1, // AssignmentValue = role name (AppRoles.*)
|
||||
User = 2, // AssignmentValue = user id (Guid as string)
|
||||
}
|
||||
|
||||
public class WorkflowStepApprover : BaseEntity
|
||||
{
|
||||
public Guid WorkflowStepId { get; set; }
|
||||
public WorkflowApproverKind Kind { get; set; }
|
||||
public string AssignmentValue { get; set; } = string.Empty;
|
||||
|
||||
public WorkflowStep? Step { get; set; }
|
||||
}
|
||||
@ -153,4 +153,52 @@ public static class WorkflowPolicyRegistry
|
||||
return ByName(policyName);
|
||||
return For(contract.Type);
|
||||
}
|
||||
|
||||
// Build a policy from a persisted WorkflowDefinition (admin-authored).
|
||||
// Transitions are derived from ordered steps: prev.Phase → step.Phase,
|
||||
// allowed roles = role-kind approvers' names. Reject-back-to-Drafter +
|
||||
// TuChoi paths are auto-wired so the guard doesn't block common flows.
|
||||
// User-kind approvers are currently treated as role-approvers with
|
||||
// DeptManager fallback — user-level targeting comes in iteration 2.
|
||||
public static WorkflowPolicy FromDefinition(WorkflowDefinition def)
|
||||
{
|
||||
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
||||
var transitions = new Dictionary<(ContractPhase From, ContractPhase To), string[]>();
|
||||
var sla = new Dictionary<ContractPhase, TimeSpan?>();
|
||||
var activePhases = new List<ContractPhase>();
|
||||
|
||||
ContractPhase? prev = null;
|
||||
foreach (var s in steps)
|
||||
{
|
||||
activePhases.Add(s.Phase);
|
||||
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
|
||||
var roles = s.Approvers
|
||||
.Where(a => a.Kind == WorkflowApproverKind.Role)
|
||||
.Select(a => a.AssignmentValue)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (roles.Length == 0) roles = [AppRoles.DeptManager];
|
||||
if (prev is not null)
|
||||
{
|
||||
transitions[(prev.Value, s.Phase)] = roles;
|
||||
// Reject path back to Drafter (common pattern from QT docx)
|
||||
if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao)
|
||||
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
|
||||
}
|
||||
prev = s.Phase;
|
||||
}
|
||||
// First step can reject to TuChoi
|
||||
if (steps.Count > 0)
|
||||
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
|
||||
[AppRoles.Drafter, AppRoles.DeptManager]);
|
||||
|
||||
if (!activePhases.Contains(ContractPhase.TuChoi)) activePhases.Add(ContractPhase.TuChoi);
|
||||
|
||||
return new WorkflowPolicy(
|
||||
Name: $"{def.Code}-v{def.Version:D2}",
|
||||
Description: def.Description ?? def.Name,
|
||||
Transitions: transitions,
|
||||
PhaseSla: sla,
|
||||
ActivePhases: activePhases);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user