[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 Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
using SolutionErp.Domain.Budgets;
|
using SolutionErp.Domain.Budgets;
|
||||||
using SolutionErp.Domain.Contracts;
|
using SolutionErp.Domain.Contracts;
|
||||||
using SolutionErp.Domain.Contracts.Details;
|
using SolutionErp.Domain.Contracts.Details;
|
||||||
@ -64,6 +65,12 @@ public interface IApplicationDbContext
|
|||||||
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
||||||
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { 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)
|
// Module Ngân sách (Phase 7)
|
||||||
DbSet<Budget> Budgets { get; }
|
DbSet<Budget> Budgets { get; }
|
||||||
DbSet<BudgetDetail> BudgetDetails { get; }
|
DbSet<BudgetDetail> BudgetDetails { get; }
|
||||||
|
|||||||
Reference in New Issue
Block a user