[CLAUDE] Domain+Infra+App+Api+FE-Admin: versioned workflow per ContractType
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:
pqhuy1987
2026-04-21 22:57:41 +07:00
parent 5e0f3801a1
commit e7e5f2d066
15 changed files with 2510 additions and 188 deletions

View File

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

View File

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

View File

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