diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs index 42e31cd..15797e5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs @@ -7,5 +7,7 @@ public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor { public string? 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"); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 2f6340d..9e100bb 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -114,6 +114,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; + if (status == RouteStepStatus.None) + return Result.Fail(new ValidationError("Статус обязателен: Approval или Execution.")); + if (!route.ByProject && projectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 37f1620..89357a5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -7,6 +7,7 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< { public CreateTemplateRouteStepRequestValidator() { + RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); @@ -17,6 +18,7 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< { public UpdateTemplateRouteStepRequestValidator() { + RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 7947af8..bf5b484 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -7,7 +7,8 @@ public sealed record ResolvedRouteStepDto( int OrderNo, string? ExecutorEmail, string? Position, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? Warning = null); public sealed record ResolveTemplateRouteResultDto( int RouteId, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 3acf480..a513f06 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -197,14 +197,26 @@ public sealed class TemplateRouteService( IReadOnlyList projectExecutors, CancellationToken cancellationToken) { string? resolvedEmail; + string? warning = null; if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { resolvedEmail = step.ExecutorEmail; + } else if (!string.IsNullOrWhiteSpace(step.Position)) + { 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)) + { 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)) + { resolvedEmail = step.HeadType switch { HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken), @@ -212,8 +224,14 @@ public sealed class TemplateRouteService( HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, _ => null }; + if (string.IsNullOrWhiteSpace(resolvedEmail)) + warning = $"Не найден руководитель типа «{step.HeadType}»."; + } else + { resolvedEmail = null; + warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей."; + } string? resolvedPosition = null; if (!string.IsNullOrWhiteSpace(resolvedEmail)) @@ -222,7 +240,7 @@ public sealed class TemplateRouteService( 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 CollapseByExecutorEmail(IReadOnlyList steps) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index c20f332..7bf9c42 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -367,6 +367,90 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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(); + 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(); + 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(); + 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 = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 02ea273..31daf65 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -226,6 +226,27 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture