diff --git a/src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs b/src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs new file mode 100644 index 0000000..4dfe2a7 --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs @@ -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> Overview( + [FromQuery] int? applicableType, + CancellationToken ct) + => Ok(await mediator.Send(new GetAwAdminOverviewQuery(applicableType), ct)); + + [HttpPost] + [Authorize(Policy = "Workflows.Create")] + public async Task> 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 Delete(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteAwDefinitionCommand(id), ct); + return NoContent(); + } +} diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs new file mode 100644 index 0000000..6a7314f --- /dev/null +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -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 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 Steps); + +public record AwTypeSummaryDto( + int ApplicableType, + string ApplicableTypeLabel, + AwDefinitionDto? Active, + List History); + +public record AwAdminOverviewDto(List Types); + +internal static class AwLabels +{ + public static readonly Dictionary 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; + +public class GetAwAdminOverviewQueryHandler( + IApplicationDbContext db, + UserManager userManager) : IRequestHandler +{ + public async Task 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() + : 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() + : 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(); + + 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 Levels); + +public record CreateAwDefinitionCommand( + int ApplicableType, + string Code, + string Name, + string? Description, + List Steps) : IRequest; + +public class CreateAwDefinitionCommandValidator : AbstractValidator +{ + 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 +{ + public async Task 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 +{ + 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); + } +} diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs index 795a465..2564dc4 100644 --- a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs @@ -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 PurchaseEvaluationDepartmentOpinions { get; } DbSet 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 ApprovalWorkflows { get; } + DbSet ApprovalWorkflowSteps { get; } + DbSet ApprovalWorkflowLevels { get; } + // Module Ngân sách (Phase 7) DbSet Budgets { get; } DbSet BudgetDetails { get; }