[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:
pqhuy1987
2026-05-08 12:42:03 +07:00
parent c847dc0b24
commit f6047d5218
3 changed files with 324 additions and 0 deletions

View File

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

View File

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

View File

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