From 40d741c8c90a345cc1a17202e016b8d79e263289 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 10:53:41 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20Tansu=20=D1=81=D1=80=D0=B5=D0=B4=D0=B8=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9:=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE,?= =?UTF-8?q?=20=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо удаления совпавшей строки CompanyDirectors — обе строки (директор компании и Директор Tansu) остаются в результате, это два разных пункта согласования, даже если физически один человек. Строка DirectorTansu, чей email совпал с одним из CompanyDirectors, не участвует в обычном схлопывании по email (CollapseByExecutorEmail) — та восприняла бы это как конфликт PositionCode одного согласующего. Вместо этого её OrderNo явно переставляется на «сразу после блока CompanyDirectors» (максимальный OrderNo блока + 1), так что она идёт последовательно, а не параллельно остальным директорам компаний. Co-Authored-By: Claude Sonnet 5 --- .../TemplateRoutes/TemplateRouteService.cs | 67 +++++++++++++------ .../TemplateRouteServiceTests.cs | 26 ++++--- 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index c49fcde..c418d4f 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -221,8 +221,11 @@ public sealed class TemplateRouteService( HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, cancellationToken))); - var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); - var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); + var flattenedSteps = stepResults.SelectMany(x => x).ToList(); + var (toCollapse, directorTansuAfterCompanyDirectors) = SplitDirectorTansuAfterCompanyDirectors(flattenedSteps); + var collapsedSteps = CollapseByExecutorEmail(toCollapse) + .Concat(directorTansuAfterCompanyDirectors) + .ToList(); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } @@ -230,31 +233,53 @@ public sealed class TemplateRouteService( /// /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU - /// Construction. Оба шага в маршруте законны сами по себе, но если так, из - /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом - /// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): - /// тот считает разные PositionCode у одного email конфликтом и пишет - /// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто - /// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним - /// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors - /// оставляет его на своём месте — в конце. + /// Construction. Оба шага в маршруте законны сами по себе, и оба должны попасть в + /// результат — это НЕ дубликат одного согласования, а два разных: сначала блок + /// директоров компаний параллельно, а затем — отдельно — Директор Tansu, даже если + /// физически это тот же человек. Поэтому строку DirectorTansu с таким совпадением + /// НЕ пускаем через обычное схлопывание по email (см. CollapseByExecutorEmail) — + /// оно бы решило, что это конфликт PositionCode одного согласующего, и либо + /// смержило бы её с строкой CompanyDirectors, либо просто выбрало одну из них. + /// Вместо этого явно переставляем её OrderNo на «после всего блока + /// CompanyDirectors» — не параллельно ему, а следом. /// - private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( - IReadOnlyList steps) + /// + /// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через + /// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно + /// просто дописать в конец результата без схлопывания. + /// + private static (IReadOnlyList ToCollapse, IReadOnlyList DirectorTansuAfterCompanyDirectors) + SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList steps) { - var directorTansuEmails = steps - .Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) + var companyDirectorSteps = steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); + var companyDirectorEmails = companyDirectorSteps + .Where(s => !string.IsNullOrWhiteSpace(s.ExecutorEmail)) .Select(s => s.ExecutorEmail!) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (directorTansuEmails.Count == 0) - return steps; + if (companyDirectorEmails.Count == 0) + return (steps, []); - return steps - .Where(s => s.PositionCode != HeadType.CompanyDirectors - || string.IsNullOrWhiteSpace(s.ExecutorEmail) - || !directorTansuEmails.Contains(s.ExecutorEmail)) - .ToList(); + var maxCompanyDirectorsOrderNo = companyDirectorSteps.Max(s => s.OrderNo); + + var toCollapse = new List(); + var afterBlock = new List(); + + foreach (var step in steps) + { + if (step.PositionCode == HeadType.DirectorTansu && + !string.IsNullOrWhiteSpace(step.ExecutorEmail) && + companyDirectorEmails.Contains(step.ExecutorEmail)) + { + afterBlock.Add(step with { OrderNo = maxCompanyDirectorsOrderNo + 1 }); + } + else + { + toCollapse.Add(step); + } + } + + return (toCollapse, afterBlock); } /// diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 0a69447..327060b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -849,11 +849,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } - [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] - public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, он идёт не параллельно блоку, а сразу после него")] + public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_SequencedAfterCompanyDirectorsBlock() { // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) - // и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — + // и Директор Tansu (из шага DirectorTansu, сам сконфигурирован с OrderNo 10) — // один и тот же человек с одним и тем же email. var portalClient = new Mock(); portalClient @@ -898,15 +898,21 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "director.tansu@tnsu.kz"); - tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); - tansuStep.OrderNo.ShouldBe(10); + resolved.Value.Steps.Count.ShouldBe(3); + var tansuStep = resolved.Value.Steps.Single(s => s.PositionCode == HeadType.DirectorTansu); + tansuStep.ExecutorEmail.ShouldBe("director.tansu@tnsu.kz"); + tansuStep.OrderNo.ShouldBe(6); tansuStep.Warning.ShouldBeNull(); - resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); + + var companyDirectorSteps = resolved.Value.Steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); + companyDirectorSteps.Count.ShouldBe(2); + companyDirectorSteps.ShouldAllBe(s => s.OrderNo == 5); + companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]