Merge remote-tracking branch 'origin/main' into trynode22

This commit is contained in:
gorbenko.pavel
2026-07-29 14:54:20 +05:00
7 changed files with 134 additions and 3 deletions
@@ -7,5 +7,7 @@ public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor
{ {
public string? Email => public string? Email =>
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email) httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username"); ?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username")
?? httpContextAccessor.HttpContext?.User.FindFirstValue("upn")
?? httpContextAccessor.HttpContext?.User.FindFirstValue("unique_name");
} }
@@ -114,6 +114,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
var headType = r.HeadType ?? entity.HeadType; var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status; var status = r.Status ?? entity.Status;
if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
if (!route.ByProject && projectRole is not (null or ProjectRole.None)) if (!route.ByProject && projectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
@@ -7,6 +7,7 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
{ {
public CreateTemplateRouteStepRequestValidator() public CreateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -17,6 +18,7 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
{ {
public UpdateTemplateRouteStepRequestValidator() public UpdateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -7,7 +7,8 @@ public sealed record ResolvedRouteStepDto(
int OrderNo, int OrderNo,
string? ExecutorEmail, string? ExecutorEmail,
string? Position, string? Position,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? Warning = null);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
@@ -197,14 +197,26 @@ public sealed class TemplateRouteService(
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken) IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
{ {
string? resolvedEmail; string? resolvedEmail;
string? warning = null;
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
{
resolvedEmail = step.ExecutorEmail; resolvedEmail = step.ExecutorEmail;
}
else if (!string.IsNullOrWhiteSpace(step.Position)) else if (!string.IsNullOrWhiteSpace(step.Position))
{
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
}
else if (step.ProjectRole is not (null or ProjectRole.None)) else if (step.ProjectRole is not (null or ProjectRole.None))
{
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
}
else if (step.HeadType is not (null or HeadType.None)) else if (step.HeadType is not (null or HeadType.None))
{
resolvedEmail = step.HeadType switch resolvedEmail = step.HeadType switch
{ {
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken), HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
@@ -212,8 +224,14 @@ public sealed class TemplateRouteService(
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
_ => null _ => null
}; };
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель типа «{step.HeadType}».";
}
else else
{
resolvedEmail = null; resolvedEmail = null;
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
}
string? resolvedPosition = null; string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail)) if (!string.IsNullOrWhiteSpace(resolvedEmail))
@@ -222,7 +240,7 @@ public sealed class TemplateRouteService(
resolvedPosition = resolvedEmployee?.Position; resolvedPosition = resolvedEmployee?.Position;
} }
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers); return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
} }
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps) private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
@@ -367,6 +367,90 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Chief not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].Warning.ShouldContain("руководитель");
}
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
public async Task ResolveAsync_ByPosition_NotFound_ReturnsWarning()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Position not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].Warning.ShouldContain("должности");
}
[Fact(DisplayName = "Резолв шага с ролью в проекте возвращает warning, если исполнитель с такой ролью не найден")]
public async Task ResolveAsync_ByProjectRole_NotFound_ReturnsWarning()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project role not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].Warning.ShouldContain("роль");
}
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
{ {
@@ -226,6 +226,27 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.IsFailed.ShouldBeTrue(); patched.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Патч со статусом None возвращает неуспешный результат")]
public async Task PatchAsync_StatusNone_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
routeId,
created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null),
CancellationToken.None);
// Assert
patched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")] [Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled() public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
{ {