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, если руководитель не найден")]