[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

@ -2,7 +2,6 @@ using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
@ -15,13 +14,11 @@ public class WorkflowsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<WorkflowAdminOverviewDto>> Overview(CancellationToken ct)
=> Ok(await mediator.Send(new GetWorkflowAdminOverviewQuery(), ct));
[HttpPut("{contractType:int}")]
[Authorize(Policy = "Workflows.Update")]
public async Task<IActionResult> SetAssignment(int contractType, [FromBody] SetWorkflowAssignmentBody body, CancellationToken ct)
[HttpPost]
[Authorize(Policy = "Workflows.Create")]
public async Task<ActionResult<object>> Create([FromBody] CreateWorkflowDefinitionCommand cmd, CancellationToken ct)
{
await mediator.Send(new SetWorkflowAssignmentCommand((ContractType)contractType, body.PolicyName), ct);
return NoContent();
var id = await mediator.Send(cmd, ct);
return Ok(new { id });
}
}
public record SetWorkflowAssignmentBody(string PolicyName);

View File

@ -23,6 +23,9 @@ public interface IApplicationDbContext
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }
DbSet<WorkflowStep> WorkflowSteps { get; }
DbSet<WorkflowStepApprover> WorkflowStepApprovers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -51,6 +51,13 @@ public class CreateContractCommandHandler(
if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct))
throw new NotFoundException("Project", request.ProjectId);
// Pin to currently active WorkflowDefinition for this type. New versions
// created later do not retroactively affect this contract.
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
.Where(w => w.ContractType == request.Type && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
var entity = new Contract
{
Type = request.Type,
@ -65,6 +72,7 @@ public class CreateContractCommandHandler(
NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData,
WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
db.Contracts.Add(entity);
@ -337,10 +345,27 @@ public class GetContractQueryHandler(
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded
WorkflowPolicy workflowPolicy;
if (c.WorkflowDefinitionId is Guid wfId)
{
var def = await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
workflowPolicy = def is not null
? WorkflowPolicyRegistry.FromDefinition(def)
: WorkflowPolicyRegistry.ForContract(c);
}
else
{
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
}
// Resolve user names
var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did);
@ -378,16 +403,13 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(),
BuildWorkflowSummary(c, workflowOverrides));
BuildWorkflowSummary(c, workflowPolicy));
}
// 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,
IReadOnlyDictionary<ContractType, string>? overrides)
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c, WorkflowPolicy policy)
{
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
return new WorkflowSummaryDto(
PolicyName: policy.Name,
PolicyDescription: policy.Description,

View File

@ -1,35 +1,60 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Contracts;
// Admin UI /system/workflows — list current policy assignment per ContractType
// + change via dropdown. Iteration 2: let admin define custom policies; for
// now they pick from WorkflowPolicyRegistry.AvailablePolicyNames.
// Versioned workflow management: admin xem + tạo version mới per ContractType.
// HĐ cũ đã pin WorkflowDefinitionId tại thời điểm tạo — vẫn chạy version cũ
// kể cả khi admin activate version mới.
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
public record WorkflowStepApproverDto(
int Kind, // 1=Role, 2=User
string AssignmentValue,
string? DisplayName); // resolved role label or user fullName
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
public record WorkflowStepDto(
Guid Id,
int Order,
int Phase,
string PhaseLabel,
string Name,
int? SlaDays,
List<WorkflowStepApproverDto> Approvers);
public record WorkflowTypeAssignmentDto(
public record WorkflowDefinitionDto(
Guid Id,
string Code,
int Version,
int ContractType,
string ContractTypeLabel,
string CurrentPolicy,
string DefaultPolicy,
WorkflowPolicyDto Policy);
string Name,
string? Description,
bool IsActive,
DateTime? ActivatedAt,
DateTime CreatedAt,
int ContractsUsingCount,
List<WorkflowStepDto> Steps);
public record WorkflowAdminOverviewDto(
List<WorkflowPolicyDto> AvailablePolicies,
List<WorkflowTypeAssignmentDto> Assignments);
public record WorkflowTypeSummaryDto(
int ContractType,
string ContractTypeLabel,
WorkflowDefinitionDto? Active,
List<WorkflowDefinitionDto> History);
public record WorkflowAdminOverviewDto(List<WorkflowTypeSummaryDto> Types);
// ========== GET overview ==========
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
public class GetWorkflowAdminOverviewQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager) : IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
{
private static readonly Dictionary<ContractType, string> TypeLabels = new()
{
@ -42,78 +67,186 @@ public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
};
private static readonly Dictionary<ContractPhase, string> PhaseLabels = new()
{
[ContractPhase.DangSoanThao] = "Đang soạn thảo",
[ContractPhase.DangGopY] = "Đang góp ý",
[ContractPhase.DangDamPhan] = "Đang đàm phán",
[ContractPhase.DangInKy] = "Đang in ký",
[ContractPhase.DangKiemTraCCM] = "CCM kiểm tra",
[ContractPhase.DangTrinhKy] = "Đang trình ký",
[ContractPhase.DangDongDau] = "Đang đóng dấu",
[ContractPhase.DaPhatHanh] = "Đã phát hành",
};
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
{
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var definitions = await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.OrderByDescending(d => d.Version)
.ToListAsync(ct);
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
.Select(WorkflowPolicyRegistry.ByName)
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
// Resolve user names for User-kind approvers
var userIds = definitions
.SelectMany(d => d.Steps)
.SelectMany(s => s.Approvers)
.Where(a => a.Kind == WorkflowApproverKind.User && Guid.TryParse(a.AssignmentValue, out _))
.Select(a => Guid.Parse(a.AssignmentValue))
.Distinct()
.ToList();
var userNames = userIds.Count == 0
? new Dictionary<Guid, string>()
: await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
var assignments = Enum.GetValues<ContractType>()
.Select(t =>
// Count contracts per definition — admin sees which versions are still live
var usageCounts = await db.Contracts.AsNoTracking()
.Where(c => c.WorkflowDefinitionId != null)
.GroupBy(c => c.WorkflowDefinitionId!.Value)
.Select(g => new { Id = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count, ct);
WorkflowDefinitionDto ToDto(WorkflowDefinition d) => new(
d.Id,
d.Code,
d.Version,
(int)d.ContractType,
TypeLabels.GetValueOrDefault(d.ContractType, d.ContractType.ToString()),
d.Name,
d.Description,
d.IsActive,
d.ActivatedAt,
d.CreatedAt,
usageCounts.GetValueOrDefault(d.Id, 0),
d.Steps.OrderBy(s => s.Order).Select(s => new WorkflowStepDto(
s.Id,
s.Order,
(int)s.Phase,
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
s.Name,
s.SlaDays,
s.Approvers.Select(a => new WorkflowStepApproverDto(
(int)a.Kind,
a.AssignmentValue,
ResolveDisplay(a, userNames))).ToList()
)).ToList());
var types = Enum.GetValues<ContractType>()
.Select(type =>
{
var defaultName = WorkflowPolicyRegistry.DefaultPolicyNameFor(t);
var currentName = overrides.TryGetValue(t, out var n) ? n : defaultName;
var policy = WorkflowPolicyRegistry.ByName(currentName);
return new WorkflowTypeAssignmentDto(
(int)t,
TypeLabels.GetValueOrDefault(t, t.ToString()),
currentName,
defaultName,
new WorkflowPolicyDto(policy.Name, policy.Description, policy.ActivePhases.Select(x => (int)x).ToList()));
var versions = definitions.Where(d => d.ContractType == type).Select(ToDto).ToList();
return new WorkflowTypeSummaryDto(
(int)type,
TypeLabels.GetValueOrDefault(type, type.ToString()),
versions.FirstOrDefault(v => v.IsActive),
versions);
})
.ToList();
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
return new WorkflowAdminOverviewDto(types);
}
private static string? ResolveDisplay(WorkflowStepApprover a, Dictionary<Guid, string> userNames)
{
if (a.Kind == WorkflowApproverKind.Role) return a.AssignmentValue;
if (Guid.TryParse(a.AssignmentValue, out var uid) && userNames.TryGetValue(uid, out var n)) return n;
return a.AssignmentValue;
}
}
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
// ========== POST new version ==========
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue);
public record CreateWorkflowStepInput(
int Order,
int Phase,
string Name,
int? SlaDays,
List<CreateWorkflowStepApproverInput> Approvers);
public record CreateWorkflowDefinitionCommand(
ContractType ContractType,
string Code,
string Name,
string? Description,
List<CreateWorkflowStepInput> Steps) : IRequest<Guid>;
public class CreateWorkflowDefinitionCommandValidator : AbstractValidator<CreateWorkflowDefinitionCommand>
{
public SetWorkflowAssignmentCommandValidator()
public CreateWorkflowDefinitionCommandValidator()
{
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.PolicyName).NotEmpty()
.Must(name => WorkflowPolicyRegistry.AvailablePolicyNames.Contains(name))
.WithMessage(x => $"Policy '{x.PolicyName}' không tồn tại. Cho phép: {string.Join(",", WorkflowPolicyRegistry.AvailablePolicyNames)}.");
}
}
public class SetWorkflowAssignmentCommandHandler(IApplicationDbContext db)
: IRequestHandler<SetWorkflowAssignmentCommand>
{
public async Task Handle(SetWorkflowAssignmentCommand request, CancellationToken ct)
{
var existing = await db.WorkflowTypeAssignments
.FirstOrDefaultAsync(a => a.ContractType == request.ContractType, ct);
// If user sets policy back to the hardcoded default, delete the override
// row so the registry uses the code-level default (no stale DB noise).
var isDefault = request.PolicyName == WorkflowPolicyRegistry.DefaultPolicyNameFor(request.ContractType);
if (existing is null)
RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
.Matches("^[A-Za-z0-9._-]+$")
.WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Steps).NotEmpty()
.WithMessage("Quy trình phải có ít nhất 1 bước.");
RuleForEach(x => x.Steps).ChildRules(step =>
{
if (isDefault) return; // nothing to persist
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
step.RuleFor(s => s.Phase).InclusiveBetween(1, 9);
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
.When(s => s.SlaDays != null);
step.RuleForEach(s => s.Approvers).ChildRules(app =>
{
ContractType = request.ContractType,
PolicyName = request.PolicyName,
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
});
}
else if (isDefault)
{
db.WorkflowTypeAssignments.Remove(existing);
}
else
{
existing.PolicyName = request.PolicyName;
}
await db.SaveChangesAsync(ct);
});
}
}
public class CreateWorkflowDefinitionCommandHandler(IApplicationDbContext db)
: IRequestHandler<CreateWorkflowDefinitionCommand, Guid>
{
public async Task<Guid> Handle(CreateWorkflowDefinitionCommand request, CancellationToken ct)
{
// Next version = max(existing) + 1. Versions monotonically increase per Code.
var nextVersion = await db.WorkflowDefinitions
.Where(w => w.Code == request.Code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Deactivate currently active version for this type — only ONE active
// per ContractType at a time (app invariant).
var activeVersions = await db.WorkflowDefinitions
.Where(w => w.ContractType == request.ContractType && w.IsActive)
.ToListAsync(ct);
foreach (var old in activeVersions) old.IsActive = false;
var def = new WorkflowDefinition
{
Code = request.Code,
Version = nextVersion,
ContractType = request.ContractType,
Name = request.Name,
Description = request.Description,
IsActive = true,
ActivatedAt = DateTime.UtcNow,
Steps = request.Steps
.OrderBy(s => s.Order)
.Select(s => new WorkflowStep
{
Order = s.Order,
Phase = (ContractPhase)s.Phase,
Name = s.Name,
SlaDays = s.SlaDays,
Approvers = s.Approvers.Select(a => new WorkflowStepApprover
{
Kind = (WorkflowApproverKind)a.Kind,
AssignmentValue = a.AssignmentValue,
}).ToList(),
})
.ToList(),
};
db.WorkflowDefinitions.Add(def);
await db.SaveChangesAsync(ct);
return def.Id;
}
}

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

View File

@ -28,6 +28,9 @@ public class ApplicationDbContext
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class WorkflowDefinitionConfiguration : IEntityTypeConfiguration<WorkflowDefinition>
{
public void Configure(EntityTypeBuilder<WorkflowDefinition> e)
{
e.ToTable("WorkflowDefinitions");
e.Property(x => x.Code).HasMaxLength(100).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.ContractType).HasConversion<int>();
// Unique (Code, Version) — prevents duplicate version numbers per code
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
// Helper index — "get active by type" hot path
e.HasIndex(x => new { x.ContractType, x.IsActive });
}
}
public class WorkflowStepConfiguration : IEntityTypeConfiguration<WorkflowStep>
{
public void Configure(EntityTypeBuilder<WorkflowStep> e)
{
e.ToTable("WorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Phase).HasConversion<int>();
e.HasOne(x => x.WorkflowDefinition)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.WorkflowDefinitionId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.WorkflowDefinitionId, x.Order });
}
}
public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration<WorkflowStepApprover>
{
public void Configure(EntityTypeBuilder<WorkflowStepApprover> e)
{
e.ToTable("WorkflowStepApprovers");
e.Property(x => x.Kind).HasConversion<int>();
e.Property(x => x.AssignmentValue).HasMaxLength(100).IsRequired();
e.HasOne(x => x.Step)
.WithMany(s => s.Approvers)
.HasForeignKey(x => x.WorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -33,9 +33,88 @@ public static class DbInitializer
await SeedDepartmentsAsync(db, logger);
await SeedDemoMasterDataAsync(db, logger);
await SeedContractTemplatesAsync(db, logger);
await SeedWorkflowDefinitionsAsync(db, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger);
}
// Seed v01 per ContractType from hardcoded WorkflowPolicies. Idempotent:
// skip if any active definition already exists for that type (admin may
// have already created custom versions — don't clobber).
private static async Task SeedWorkflowDefinitionsAsync(ApplicationDbContext db, ILogger logger)
{
var typeLabels = new Dictionary<ContractType, (string Code, string Name)>
{
[ContractType.HopDongThauPhu] = ("QT-TP", "Quy trình HĐ Thầu phụ"),
[ContractType.HopDongGiaoKhoan] = ("QT-GK", "Quy trình HĐ Giao khoán"),
[ContractType.HopDongNhaCungCap] = ("QT-NCC", "Quy trình HĐ Nhà cung cấp"),
[ContractType.HopDongDichVu] = ("QT-DV", "Quy trình HĐ Dịch vụ"),
[ContractType.HopDongMuaBan] = ("QT-MB", "Quy trình HĐ Mua bán"),
[ContractType.HopDongNguyenTacNCC] = ("QT-NTNCC","Quy trình HĐ Nguyên tắc NCC"),
[ContractType.HopDongNguyenTacDichVu] = ("QT-NTDV", "Quy trình HĐ Nguyên tắc Dịch vụ"),
};
var phaseNames = new Dictionary<ContractPhase, string>
{
[ContractPhase.DangSoanThao] = "Soạn thảo",
[ContractPhase.DangGopY] = "Góp ý",
[ContractPhase.DangDamPhan] = "Đàm phán",
[ContractPhase.DangInKy] = "In ký",
[ContractPhase.DangKiemTraCCM] = "Kiểm tra CCM",
[ContractPhase.DangTrinhKy] = "Trình ký BOD",
[ContractPhase.DangDongDau] = "Đóng dấu",
[ContractPhase.DaPhatHanh] = "Phát hành",
};
var added = 0;
foreach (var (type, info) in typeLabels)
{
var alreadyExists = await db.WorkflowDefinitions.AnyAsync(w => w.ContractType == type);
if (alreadyExists) continue;
var policy = WorkflowPolicyRegistry.For(type);
var def = new WorkflowDefinition
{
Code = info.Code,
Version = 1,
ContractType = type,
Name = $"{info.Name} (v01)",
Description = policy.Description,
IsActive = true,
ActivatedAt = DateTime.UtcNow,
Steps = policy.ActivePhases
.Where(p => p != ContractPhase.TuChoi) // TuChoi is a terminal state, not a step
.Select((p, idx) =>
{
var roles = policy.Transitions
.Where(t => t.Key.To == p)
.SelectMany(t => t.Value)
.Distinct()
.ToList();
return new WorkflowStep
{
Order = idx + 1,
Phase = p,
Name = phaseNames.GetValueOrDefault(p, p.ToString()),
SlaDays = policy.PhaseSla.GetValueOrDefault(p) is TimeSpan s ? (int?)s.Days : null,
Approvers = roles.Select(r => new WorkflowStepApprover
{
Kind = WorkflowApproverKind.Role,
AssignmentValue = r,
}).ToList(),
};
})
.ToList(),
};
db.WorkflowDefinitions.Add(def);
added++;
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} workflow definitions (v01)", added);
}
}
// Phase 5.1 security: log warning nếu admin vẫn dùng password mặc định sau deploy production.
private static async Task WarnDefaultAdminPasswordAsync(UserManager<User> userManager, ILogger logger)
{

View File

@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddVersionedWorkflows : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "WorkflowDefinitionId",
table: "Contracts",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "WorkflowDefinitions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Version = table.Column<int>(type: "int", nullable: false),
ContractType = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowDefinitions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WorkflowSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WorkflowDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
SlaDays = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowSteps", x => x.Id);
table.ForeignKey(
name: "FK_WorkflowSteps_WorkflowDefinitions_WorkflowDefinitionId",
column: x => x.WorkflowDefinitionId,
principalTable: "WorkflowDefinitions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "WorkflowStepApprovers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
AssignmentValue = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowStepApprovers", x => x.Id);
table.ForeignKey(
name: "FK_WorkflowStepApprovers_WorkflowSteps_WorkflowStepId",
column: x => x.WorkflowStepId,
principalTable: "WorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_WorkflowDefinitions_Code_Version",
table: "WorkflowDefinitions",
columns: new[] { "Code", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WorkflowDefinitions_ContractType_IsActive",
table: "WorkflowDefinitions",
columns: new[] { "ContractType", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_WorkflowStepApprovers_WorkflowStepId",
table: "WorkflowStepApprovers",
column: "WorkflowStepId");
migrationBuilder.CreateIndex(
name: "IX_WorkflowSteps_WorkflowDefinitionId_Order",
table: "WorkflowSteps",
columns: new[] { "WorkflowDefinitionId", "Order" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkflowStepApprovers");
migrationBuilder.DropTable(
name: "WorkflowSteps");
migrationBuilder.DropTable(
name: "WorkflowDefinitions");
migrationBuilder.DropColumn(
name: "WorkflowDefinitionId",
table: "Contracts");
}
}
}

View File

@ -201,6 +201,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaHopDong")
@ -374,6 +377,138 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractComments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("ContractType")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Code", "Version")
.IsUnique();
b.HasIndex("ContractType", "IsActive");
b.ToTable("WorkflowDefinitions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<int?>("SlaDays")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("WorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("WorkflowDefinitionId", "Order");
b.ToTable("WorkflowSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssignmentValue")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("WorkflowStepId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("WorkflowStepId");
b.ToTable("WorkflowStepApprovers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
{
b.Property<Guid>("Id")
@ -1049,6 +1184,28 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition")
.WithMany("Steps")
.HasForeignKey("WorkflowDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkflowDefinition");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step")
.WithMany("Approvers")
.HasForeignKey("WorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
@ -1087,6 +1244,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Comments");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{
b.Navigation("Approvers");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Navigation("Children");

View File

@ -36,11 +36,26 @@ public class ContractWorkflowService(
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
// Admin may override the default policy per ContractType via the
// /system/workflows page. Load all overrides once (7 rows max).
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
// versioned system), else fall back to the static/override registry
// (legacy path for contracts created before versioning rolled out).
WorkflowPolicy policy;
if (contract.WorkflowDefinitionId is Guid wfId)
{
var def = await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = def is not null
? WorkflowPolicyRegistry.FromDefinition(def)
: WorkflowPolicyRegistry.ForContract(contract);
}
else
{
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
}
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;