[CLAUDE] Workflow: App CQRS + API ApprovalWorkflowsV2 (Chunk B)
3 handler MediatR + Validator + Controller cho schema mới Mig 22.
Files:
- Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs
- GetAwAdminOverviewQuery (filter optional ApplicableType)
- CreateAwDefinitionCommand + Validator (auto-increment Version
theo Code, deactivate active version cùng ApplicableType)
- DeleteAwDefinitionCommand (UAT helper — chưa pin nên unconditional)
- DTO: AwDefinition/AwStep/AwLevel + TypeSummary
- Application/Common/Interfaces/IApplicationDbContext.cs (3 DbSet)
- Api/Controllers/ApprovalWorkflowsV2Controller.cs
- Route /api/approval-workflows-v2
- GET ?applicableType=N | POST | DELETE/{id}
- Reuse policy Workflows.Read/Workflows.Create
Verify: build OK 0 error, IApplicationDbContext expose 3 DbSet mới.
Next: Chunk C — FE Designer page + route + Layout resolver.
This commit is contained in:
@ -0,0 +1,38 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.ApprovalWorkflowsV2;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
||||
// Schema riêng để UAT, KHÔNG đụng WorkflowDefinition cũ.
|
||||
// Reuse policy "Workflows.Read"/"Workflows.Create" giống PE/Contract designer
|
||||
// — admin đã có quyền quản lý workflow nói chung.
|
||||
[ApiController]
|
||||
[Route("api/approval-workflows-v2")]
|
||||
[Authorize(Policy = "Workflows.Read")]
|
||||
public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<AwAdminOverviewDto>> Overview(
|
||||
[FromQuery] int? applicableType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetAwAdminOverviewQuery(applicableType), ct));
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "Workflows.Create")]
|
||||
public async Task<ActionResult<object>> Create([FromBody] CreateAwDefinitionCommand cmd, CancellationToken ct)
|
||||
{
|
||||
var id = await mediator.Send(cmd, ct);
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "Workflows.Create")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteAwDefinitionCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,279 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Application.ApprovalWorkflowsV2;
|
||||
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
||||
// Schema riêng để UAT trước khi drop legacy WorkflowDefinition cũ.
|
||||
//
|
||||
// Cấu trúc:
|
||||
// Quy trình (Code + Name + ApplicableType)
|
||||
// Bước 1 - Phòng A
|
||||
// Cấp 1 - NV X (ApproverUserId 1 user cụ thể)
|
||||
// Cấp 2 - NV Y
|
||||
//
|
||||
// Khác Mig 21: Levels match 1 NV CHÍNH XÁC qua ApproverUserId, không match
|
||||
// group Dept+PositionLevel/Role/User.
|
||||
|
||||
public record AwLevelDto(
|
||||
Guid Id,
|
||||
int Order,
|
||||
string? Name,
|
||||
Guid ApproverUserId,
|
||||
string? ApproverUserName,
|
||||
string? ApproverEmail);
|
||||
|
||||
public record AwStepDto(
|
||||
Guid Id,
|
||||
int Order,
|
||||
string Name,
|
||||
Guid? DepartmentId,
|
||||
string? DepartmentName,
|
||||
List<AwLevelDto> Levels);
|
||||
|
||||
public record AwDefinitionDto(
|
||||
Guid Id,
|
||||
string Code,
|
||||
int Version,
|
||||
int ApplicableType,
|
||||
string ApplicableTypeLabel,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool IsActive,
|
||||
DateTime? ActivatedAt,
|
||||
DateTime CreatedAt,
|
||||
List<AwStepDto> Steps);
|
||||
|
||||
public record AwTypeSummaryDto(
|
||||
int ApplicableType,
|
||||
string ApplicableTypeLabel,
|
||||
AwDefinitionDto? Active,
|
||||
List<AwDefinitionDto> History);
|
||||
|
||||
public record AwAdminOverviewDto(List<AwTypeSummaryDto> Types);
|
||||
|
||||
internal static class AwLabels
|
||||
{
|
||||
public static readonly Dictionary<ApprovalWorkflowApplicableType, string> Type = new()
|
||||
{
|
||||
[ApprovalWorkflowApplicableType.DuyetNcc] = "Duyệt NCC",
|
||||
[ApprovalWorkflowApplicableType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
|
||||
[ApprovalWorkflowApplicableType.Contract] = "Hợp đồng",
|
||||
};
|
||||
}
|
||||
|
||||
// ========== GET overview ==========
|
||||
// Filter `applicableType=null` → return tất cả, `=N` → chỉ type đó.
|
||||
|
||||
public record GetAwAdminOverviewQuery(int? ApplicableType = null) : IRequest<AwAdminOverviewDto>;
|
||||
|
||||
public class GetAwAdminOverviewQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
UserManager<User> userManager) : IRequestHandler<GetAwAdminOverviewQuery, AwAdminOverviewDto>
|
||||
{
|
||||
public async Task<AwAdminOverviewDto> Handle(GetAwAdminOverviewQuery request, CancellationToken ct)
|
||||
{
|
||||
var query = db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||
.OrderByDescending(d => d.Version)
|
||||
.AsQueryable();
|
||||
|
||||
if (request.ApplicableType is int t)
|
||||
{
|
||||
var typeEnum = (ApprovalWorkflowApplicableType)t;
|
||||
query = query.Where(d => d.ApplicableType == typeEnum);
|
||||
}
|
||||
|
||||
var definitions = await query.ToListAsync(ct);
|
||||
|
||||
// Resolve dept names
|
||||
var deptIds = definitions
|
||||
.SelectMany(d => d.Steps)
|
||||
.Where(s => s.DepartmentId != null)
|
||||
.Select(s => s.DepartmentId!.Value)
|
||||
.Distinct().ToList();
|
||||
var deptNames = deptIds.Count == 0
|
||||
? new Dictionary<Guid, string>()
|
||||
: await db.Departments.AsNoTracking()
|
||||
.Where(d => deptIds.Contains(d.Id))
|
||||
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
|
||||
|
||||
// Resolve user names
|
||||
var userIds = definitions
|
||||
.SelectMany(d => d.Steps)
|
||||
.SelectMany(s => s.Levels)
|
||||
.Select(l => l.ApproverUserId)
|
||||
.Distinct().ToList();
|
||||
var users = userIds.Count == 0
|
||||
? new Dictionary<Guid, (string FullName, string? Email)>()
|
||||
: await userManager.Users.AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
|
||||
|
||||
AwDefinitionDto ToDto(ApprovalWorkflow d) => new(
|
||||
d.Id,
|
||||
d.Code,
|
||||
d.Version,
|
||||
(int)d.ApplicableType,
|
||||
AwLabels.Type.GetValueOrDefault(d.ApplicableType, d.ApplicableType.ToString()),
|
||||
d.Name,
|
||||
d.Description,
|
||||
d.IsActive,
|
||||
d.ActivatedAt,
|
||||
d.CreatedAt,
|
||||
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
s.DepartmentId,
|
||||
s.DepartmentId != null ? deptNames.GetValueOrDefault(s.DepartmentId.Value) : null,
|
||||
s.Levels.OrderBy(l => l.Order).Select(l =>
|
||||
{
|
||||
users.TryGetValue(l.ApproverUserId, out var info);
|
||||
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email);
|
||||
}).ToList()
|
||||
)).ToList());
|
||||
|
||||
var typesToReturn = request.ApplicableType is int onlyT
|
||||
? new[] { (ApprovalWorkflowApplicableType)onlyT }
|
||||
: Enum.GetValues<ApprovalWorkflowApplicableType>();
|
||||
|
||||
var result = typesToReturn
|
||||
.Select(type =>
|
||||
{
|
||||
var versions = definitions.Where(d => d.ApplicableType == type).Select(ToDto).ToList();
|
||||
return new AwTypeSummaryDto(
|
||||
(int)type,
|
||||
AwLabels.Type.GetValueOrDefault(type, type.ToString()),
|
||||
versions.FirstOrDefault(v => v.IsActive),
|
||||
versions);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new AwAdminOverviewDto(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POST new version ==========
|
||||
|
||||
public record CreateAwLevelInput(int Order, string? Name, Guid ApproverUserId);
|
||||
|
||||
public record CreateAwStepInput(
|
||||
int Order,
|
||||
string Name,
|
||||
Guid? DepartmentId,
|
||||
List<CreateAwLevelInput> Levels);
|
||||
|
||||
public record CreateAwDefinitionCommand(
|
||||
int ApplicableType,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
List<CreateAwStepInput> Steps) : IRequest<Guid>;
|
||||
|
||||
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
||||
{
|
||||
public CreateAwDefinitionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t))
|
||||
.WithMessage("ApplicableType không hợp lệ.");
|
||||
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 =>
|
||||
{
|
||||
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
||||
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
|
||||
step.RuleFor(s => s.Levels).NotEmpty()
|
||||
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
|
||||
step.RuleForEach(s => s.Levels).ChildRules(level =>
|
||||
{
|
||||
level.RuleFor(l => l.Order).GreaterThanOrEqualTo(1);
|
||||
level.RuleFor(l => l.Name).MaximumLength(200);
|
||||
level.RuleFor(l => l.ApproverUserId).NotEmpty()
|
||||
.WithMessage("Cấp duyệt phải chỉ định 1 nhân viên cụ thể.");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<CreateAwDefinitionCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateAwDefinitionCommand request, CancellationToken ct)
|
||||
{
|
||||
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
|
||||
|
||||
// Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
|
||||
var nextVersion = await db.ApprovalWorkflows
|
||||
.Where(w => w.Code == request.Code)
|
||||
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
|
||||
nextVersion++;
|
||||
|
||||
// Deactivate active version cho ApplicableType này (only ONE active per type)
|
||||
var actives = await db.ApprovalWorkflows
|
||||
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
|
||||
.ToListAsync(ct);
|
||||
foreach (var old in actives) old.IsActive = false;
|
||||
|
||||
var def = new ApprovalWorkflow
|
||||
{
|
||||
Code = request.Code,
|
||||
Version = nextVersion,
|
||||
ApplicableType = typeEnum,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.UtcNow,
|
||||
Steps = request.Steps.OrderBy(s => s.Order)
|
||||
.Select(s => new ApprovalWorkflowStep
|
||||
{
|
||||
Order = s.Order,
|
||||
Name = s.Name,
|
||||
DepartmentId = s.DepartmentId,
|
||||
Levels = s.Levels.OrderBy(l => l.Order)
|
||||
.Select(l => new ApprovalWorkflowLevel
|
||||
{
|
||||
Order = l.Order,
|
||||
Name = l.Name,
|
||||
ApproverUserId = l.ApproverUserId,
|
||||
}).ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
db.ApprovalWorkflows.Add(def);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return def.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DELETE version (chỉ khi chưa có phiếu pin) ==========
|
||||
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
|
||||
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
|
||||
|
||||
public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
|
||||
|
||||
public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<DeleteAwDefinitionCommand>
|
||||
{
|
||||
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
|
||||
{
|
||||
var def = await db.ApprovalWorkflows
|
||||
.FirstOrDefaultAsync(d => d.Id == request.Id, ct)
|
||||
?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");
|
||||
|
||||
db.ApprovalWorkflows.Remove(def);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Budgets;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Contracts.Details;
|
||||
@ -64,6 +65,12 @@ public interface IApplicationDbContext
|
||||
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
||||
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
|
||||
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
|
||||
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
|
||||
DbSet<ApprovalWorkflow> ApprovalWorkflows { get; }
|
||||
DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps { get; }
|
||||
DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels { get; }
|
||||
|
||||
// Module Ngân sách (Phase 7)
|
||||
DbSet<Budget> Budgets { get; }
|
||||
DbSet<BudgetDetail> BudgetDetails { get; }
|
||||
|
||||
Reference in New Issue
Block a user