From 13d929eb5b8f7fb03cb610be781f15bf8ce1b3dd Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 29 Jul 2026 10:19:38 +0500 Subject: [PATCH 1/3] =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRouteStepService.cs | 3 +++ .../TemplateRouteStepValidators.cs | 2 ++ .../TemplateRouteStepServiceTests.cs | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index d43d3a0..44e821f 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/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 Date: Wed, 29 Jul 2026 10:23:53 +0500 Subject: [PATCH 2/3] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=B1=D0=B5=D1=80=D0=B5=D0=BC=20email=20=D0=B8=D0=B7=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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"); } From 5476c1bab5dcbb572dccbadc034e6d7ec11a7b01 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 29 Jul 2026 14:53:45 +0500 Subject: [PATCH 3/3] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 3 +- .../TemplateRoutes/TemplateRouteService.cs | 20 ++++- .../TemplateRouteServiceTests.cs | 84 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) 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() {