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