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