From 84b7003ac2547d8430310c5768343b53bc371c28 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 11:46:34 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2:=20=D0=94?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20Tansu=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BF=D0=B0=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=20=D0=94=D0=B8?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Директор Tansu (HeadType.DirectorTansu) нередко совпадает с директором ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно ТОО TANSU Construction. Раньше такое совпадение попадало в обычное схлопывание по email (CollapseByExecutorEmail) и утреннее исправление трактовало его как конфликт PositionCode (Chief/DirectionHead и т.п.) — с предупреждением и произвольным выбором одной из строк. Для этой конкретной пары это не конфликт, а один и тот же человек, увиденный с двух сторон: теперь строка CompanyDirectors с email, совпавшим с одним из DirectorTansu, убирается из результата ДО схлопывания — без предупреждения. Шаг DirectorTansu в маршруте обычно идёт последним, поэтому итоговый список не теряет исполнителя, просто перестаёт дублировать его через CompanyDirectors. Co-Authored-By: Claude Sonnet 5 --- .../TemplateRoutes/TemplateRouteService.cs | 33 +++++++++- .../TemplateRouteServiceTests.cs | 60 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) 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() {