diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs index 866394a..1454e61 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs @@ -18,6 +18,18 @@ public record PeWorkflowStepApproverDto( string AssignmentValue, string? DisplayName); +// Mig 18 — N-stage approval inner step level con. Cấu hình động trong cùng +// 1 phase: NV.A → PP.A → TP.A → NV.B → ... theo Order asc. +public record PeWorkflowStepInnerStepDto( + Guid Id, + int Order, + Guid DepartmentId, + string? DepartmentName, + int PositionLevel, // 1=NV, 2=PP, 3=TP + string? Name, + int? SlaDays, + bool IsRequired); + public record PeWorkflowStepDto( Guid Id, int Order, @@ -25,7 +37,8 @@ public record PeWorkflowStepDto( string PhaseLabel, string Name, int? SlaDays, - List Approvers); + List Approvers, + List InnerSteps); public record PeWorkflowDefinitionDto( Guid Id, @@ -79,9 +92,21 @@ public class GetPeWorkflowAdminOverviewQueryHandler( var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() .Include(d => d.Steps.OrderBy(s => s.Order)) .ThenInclude(s => s.Approvers) + .Include(d => d.Steps) + .ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order)) .OrderByDescending(d => d.Version) .ToListAsync(ct); + // Resolve dept names cho InnerStep.DepartmentName display + var deptIds = definitions + .SelectMany(d => d.Steps).SelectMany(s => s.InnerSteps) + .Select(i => i.DepartmentId).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 cho User-kind approvers var userIds = definitions .SelectMany(d => d.Steps) @@ -125,7 +150,16 @@ public class GetPeWorkflowAdminOverviewQueryHandler( s.Approvers.Select(a => new PeWorkflowStepApproverDto( (int)a.Kind, a.AssignmentValue, - ResolveDisplay(a, userNames))).ToList() + ResolveDisplay(a, userNames))).ToList(), + s.InnerSteps.OrderBy(i => i.Order).Select(i => new PeWorkflowStepInnerStepDto( + i.Id, + i.Order, + i.DepartmentId, + deptNames.GetValueOrDefault(i.DepartmentId), + (int)i.PositionLevel, + i.Name, + i.SlaDays, + i.IsRequired)).ToList() )).ToList()); var types = Enum.GetValues() @@ -155,12 +189,23 @@ public class GetPeWorkflowAdminOverviewQueryHandler( public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue); +// Mig 18 — Inner step input cho designer N-stage. InnerSteps optional empty +// list → service fallback 2-stage Review/Confirm logic legacy Mig 16. +public record CreatePeWorkflowStepInnerStepInput( + int Order, + Guid DepartmentId, + int PositionLevel, // 1=NV, 2=PP, 3=TP + string? Name, + int? SlaDays, + bool IsRequired); + public record CreatePeWorkflowStepInput( int Order, int Phase, string Name, int? SlaDays, - List Approvers); + List Approvers, + List? InnerSteps = null); public record CreatePeWorkflowDefinitionCommand( PurchaseEvaluationType EvaluationType, @@ -195,6 +240,16 @@ public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator a.Kind).InclusiveBetween(1, 2); app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100); }); + step.RuleForEach(s => s.InnerSteps!).ChildRules(inner => + { + inner.RuleFor(i => i.Order).GreaterThanOrEqualTo(1); + inner.RuleFor(i => i.DepartmentId).NotEmpty(); + inner.RuleFor(i => i.PositionLevel).InclusiveBetween(1, 3) + .WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP."); + inner.RuleFor(i => i.Name).MaximumLength(200); + inner.RuleFor(i => i.SlaDays).GreaterThanOrEqualTo(0) + .When(i => i.SlaDays != null); + }).When(s => s.InnerSteps != null); }); } } @@ -237,6 +292,15 @@ public class CreatePeWorkflowDefinitionCommandHandler(IApplicationDbContext db) Kind = (WorkflowApproverKind)a.Kind, AssignmentValue = a.AssignmentValue, }).ToList(), + InnerSteps = (s.InnerSteps ?? new()).OrderBy(i => i.Order).Select(i => new PurchaseEvaluationWorkflowStepInnerStep + { + Order = i.Order, + DepartmentId = i.DepartmentId, + PositionLevel = (PositionLevel)i.PositionLevel, + Name = i.Name, + SlaDays = i.SlaDays, + IsRequired = i.IsRequired, + }).ToList(), }) .ToList(), }; diff --git a/src/Backend/SolutionErp.Application/Users/UserFeatures.cs b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs index 7f3fc87..056abe1 100644 --- a/src/Backend/SolutionErp.Application/Users/UserFeatures.cs +++ b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs @@ -21,7 +21,8 @@ public record UserDto( Guid? DepartmentId, string? DepartmentName, string? Position, - bool CanBypassReview); + bool CanBypassReview, + int? PositionLevel); // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user. // ========== LIST ========== public record ListUsersQuery : PagedRequest, IRequest>; @@ -60,7 +61,7 @@ public class ListUsersQueryHandler(UserManager userManager, IApplicationDb var roles = await userManager.GetRolesAsync(u); var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now; string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null; - items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview)); + items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel)); } return new PagedResult(items, total, request.Page, request.PageSize); @@ -82,7 +83,7 @@ public class GetUserQueryHandler(UserManager userManager, IApplicationDbCo string? deptName = null; if (u.DepartmentId is { } did) deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct); - return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview); + return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel); } } @@ -288,3 +289,36 @@ public class SetUserBypassReviewCommandHandler(UserManager userManager) throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); } } + +// ========== SET POSITION LEVEL (Mig 18) ========== +// Admin set cấp chức danh trong phòng cho user phục vụ N-stage workflow inner +// step matching. Nullable — null cho admin/system/external user. Chỉ accept +// 1 (NV) / 2 (PP) / 3 (TP) hoặc null. Không kiểm role tương ứng — admin +// tự chịu trách nhiệm map đúng. +public record SetUserPositionLevelCommand(Guid Id, int? PositionLevel) : IRequest; + +public class SetUserPositionLevelCommandValidator : AbstractValidator +{ + public SetUserPositionLevelCommandValidator() + { + RuleFor(x => x.PositionLevel) + .Must(v => v == null || (v >= 1 && v <= 3)) + .WithMessage("PositionLevel chỉ chấp nhận null hoặc 1=NV, 2=PP, 3=TP."); + } +} + +public class SetUserPositionLevelCommandHandler(UserManager userManager) + : IRequestHandler +{ + public async Task Handle(SetUserPositionLevelCommand request, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + user.PositionLevel = request.PositionLevel == null + ? null + : (PositionLevel?)request.PositionLevel.Value; + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } +}