diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 5d85ce0..c49fcde 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -221,11 +221,42 @@ public sealed class TemplateRouteService( HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, cancellationToken))); - var collapsedSteps = CollapseByExecutorEmail(stepResults.SelectMany(x => x).ToList()); + var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); + var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } + /// + /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором + /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU + /// Construction. Оба шага в маршруте законны сами по себе, но если так, из + /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом + /// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): + /// тот считает разные PositionCode у одного email конфликтом и пишет + /// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто + /// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним + /// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors + /// оставляет его на своём месте — в конце. + /// + private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( + IReadOnlyList steps) + { + var directorTansuEmails = steps + .Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) + .Select(s => s.ExecutorEmail!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (directorTansuEmails.Count == 0) + return steps; + + return steps + .Where(s => s.PositionCode != HeadType.CompanyDirectors + || string.IsNullOrWhiteSpace(s.ExecutorEmail) + || !directorTansuEmails.Contains(s.ExecutorEmail)) + .ToList(); + } + /// /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 968d8e2..0a69447 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -849,6 +849,66 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] + public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() + { + // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) + // и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — + // один и тот же человек с одним и тем же email. + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), + new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности") + }); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS")); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("CompanyDirectors overlaps DirectorTansu request", "/company-directors-overlaps-director-tansu", null, null, "General"), + CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), + CancellationToken.None); + + // Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail, + // поэтому HeadType.DirectorTansu резолвится в дефолт из TnsuPortalOptions + // ("director.tansu@tnsu.kz") — тот же email, что вернул GetEmployeeByPositionAsync + // для ТОО TANSU Construction. + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся + // две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте. + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(2); + var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); + tansuStep.OrderNo.ShouldBe(10); + tansuStep.Warning.ShouldBeNull(); + resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); + } + [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning() {