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()
{