[CLAUDE] App: PE workflow inner steps DTO + UpdateUserPositionLevel CQRS (Chunk B)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m42s

DTO + Validator + Handler mở rộng cho N-stage admin designer (Mig 18):

PeWorkflowAdminFeatures:
- PeWorkflowStepInnerStepDto record (Order/Dept/PositionLevel/Name/SlaDays/IsRequired)
- PeWorkflowStepDto extend +InnerSteps List
- GetPeWorkflowAdminOverviewQuery: Include InnerSteps OrderBy Order +
  resolve DeptNames cho display
- CreatePeWorkflowStepInnerStepInput record
- CreatePeWorkflowStepInput extend +InnerSteps (nullable, default null —
  backward compat existing test PeWorkflowAdminTests positional new())
- Validator child rules cho InnerSteps (Order >=1, DeptId not empty,
  PositionLevel 1-3, SlaDays >=0)
- Handler convert InnerSteps khi build entity (atomic batch insert)

UserFeatures:
- UserDto +PositionLevel int? field
- ListUsers + GetUser handlers map (int?)u.PositionLevel
- SetUserPositionLevelCommand + Validator + Handler mirror
  SetUserBypassReviewCommand pattern (admin set qua UserManager UI)

Verify:
- dotnet build SolutionErp.slnx 0 error
- dotnet test 83 pass (54+29) — no regression
- Backward compat: PeWorkflowAdminTests existing 6 test pass (named-arg
  positional record vẫn work với InnerSteps default null)

Pending Chunk C: PurchaseEvaluationWorkflowService.TransitionAsync N-stage
logic + legacy 2-stage fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 18:14:39 +07:00
parent 13ab533fe7
commit 0e56bd0a67
2 changed files with 104 additions and 6 deletions

View File

@ -18,6 +18,18 @@ public record PeWorkflowStepApproverDto(
string AssignmentValue, string AssignmentValue,
string? DisplayName); 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( public record PeWorkflowStepDto(
Guid Id, Guid Id,
int Order, int Order,
@ -25,7 +37,8 @@ public record PeWorkflowStepDto(
string PhaseLabel, string PhaseLabel,
string Name, string Name,
int? SlaDays, int? SlaDays,
List<PeWorkflowStepApproverDto> Approvers); List<PeWorkflowStepApproverDto> Approvers,
List<PeWorkflowStepInnerStepDto> InnerSteps);
public record PeWorkflowDefinitionDto( public record PeWorkflowDefinitionDto(
Guid Id, Guid Id,
@ -79,9 +92,21 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order)) .Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers) .ThenInclude(s => s.Approvers)
.Include(d => d.Steps)
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
.OrderByDescending(d => d.Version) .OrderByDescending(d => d.Version)
.ToListAsync(ct); .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<Guid, string>()
: 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 // Resolve user names cho User-kind approvers
var userIds = definitions var userIds = definitions
.SelectMany(d => d.Steps) .SelectMany(d => d.Steps)
@ -125,7 +150,16 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
s.Approvers.Select(a => new PeWorkflowStepApproverDto( s.Approvers.Select(a => new PeWorkflowStepApproverDto(
(int)a.Kind, (int)a.Kind,
a.AssignmentValue, 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()); )).ToList());
var types = Enum.GetValues<PurchaseEvaluationType>() var types = Enum.GetValues<PurchaseEvaluationType>()
@ -155,12 +189,23 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue); 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( public record CreatePeWorkflowStepInput(
int Order, int Order,
int Phase, int Phase,
string Name, string Name,
int? SlaDays, int? SlaDays,
List<CreatePeWorkflowStepApproverInput> Approvers); List<CreatePeWorkflowStepApproverInput> Approvers,
List<CreatePeWorkflowStepInnerStepInput>? InnerSteps = null);
public record CreatePeWorkflowDefinitionCommand( public record CreatePeWorkflowDefinitionCommand(
PurchaseEvaluationType EvaluationType, PurchaseEvaluationType EvaluationType,
@ -195,6 +240,16 @@ public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator<Crea
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2); app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100); 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, Kind = (WorkflowApproverKind)a.Kind,
AssignmentValue = a.AssignmentValue, AssignmentValue = a.AssignmentValue,
}).ToList(), }).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(), .ToList(),
}; };

View File

@ -21,7 +21,8 @@ public record UserDto(
Guid? DepartmentId, Guid? DepartmentId,
string? DepartmentName, string? DepartmentName,
string? Position, string? Position,
bool CanBypassReview); bool CanBypassReview,
int? PositionLevel); // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user.
// ========== LIST ========== // ========== LIST ==========
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>; public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
@ -60,7 +61,7 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IApplicationDb
var roles = await userManager.GetRolesAsync(u); var roles = await userManager.GetRolesAsync(u);
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now; var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null; 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<UserDto>(items, total, request.Page, request.PageSize); return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
@ -82,7 +83,7 @@ public class GetUserQueryHandler(UserManager<User> userManager, IApplicationDbCo
string? deptName = null; string? deptName = null;
if (u.DepartmentId is { } did) if (u.DepartmentId is { } did)
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct); 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<User> userManager)
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); 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<SetUserPositionLevelCommand>
{
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<User> userManager)
: IRequestHandler<SetUserPositionLevelCommand>
{
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)));
}
}