Директор Tansu среди директоров компаний: не параллельно, а после блока

Вместо удаления совпавшей строки CompanyDirectors — обе строки (директор
компании и Директор Tansu) остаются в результате, это два разных пункта
согласования, даже если физически один человек. Строка DirectorTansu,
чей email совпал с одним из CompanyDirectors, не участвует в обычном
схлопывании по email (CollapseByExecutorEmail) — та восприняла бы это как
конфликт PositionCode одного согласующего. Вместо этого её OrderNo явно
переставляется на «сразу после блока CompanyDirectors» (максимальный
OrderNo блока + 1), так что она идёт последовательно, а не параллельно
остальным директорам компаний.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-26 10:53:41 +05:00
parent 3421922a08
commit 40d741c8c9
2 changed files with 62 additions and 31 deletions
@@ -221,8 +221,11 @@ public sealed class TemplateRouteService(
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken))); cancellationToken)));
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); var flattenedSteps = stepResults.SelectMany(x => x).ToList();
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); var (toCollapse, directorTansuAfterCompanyDirectors) = SplitDirectorTansuAfterCompanyDirectors(flattenedSteps);
var collapsedSteps = CollapseByExecutorEmail(toCollapse)
.Concat(directorTansuAfterCompanyDirectors)
.ToList();
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
} }
@@ -230,31 +233,53 @@ public sealed class TemplateRouteService(
/// <summary> /// <summary>
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из /// Construction. Оба шага в маршруте законны сами по себе, и оба должны попасть в
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом /// результат — это НЕ дубликат одного согласования, а два разных: сначала блок
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): /// директоров компаний параллельно, а затем — отдельно — Директор Tansu, даже если
/// тот считает разные PositionCode у одного email конфликтом и пишет /// физически это тот же человек. Поэтому строку DirectorTansu с таким совпадением
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто /// НЕ пускаем через обычное схлопывание по email (см. CollapseByExecutorEmail) —
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним /// оно бы решило, что это конфликт PositionCode одного согласующего, и либо
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors /// смержило бы её с строкой CompanyDirectors, либо просто выбрало одну из них.
/// оставляет его на своём месте — в конце. /// Вместо этого явно переставляем её OrderNo на «после всего блока
/// CompanyDirectors» — не параллельно ему, а следом.
/// </summary> /// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu( /// <returns>
IReadOnlyList<ResolvedRouteStepDto> steps) /// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через
/// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно
/// просто дописать в конец результата без схлопывания.
/// </returns>
private static (IReadOnlyList<ResolvedRouteStepDto> ToCollapse, IReadOnlyList<ResolvedRouteStepDto> DirectorTansuAfterCompanyDirectors)
SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList<ResolvedRouteStepDto> steps)
{ {
var directorTansuEmails = steps var companyDirectorSteps = steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList();
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) var companyDirectorEmails = companyDirectorSteps
.Where(s => !string.IsNullOrWhiteSpace(s.ExecutorEmail))
.Select(s => s.ExecutorEmail!) .Select(s => s.ExecutorEmail!)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
if (directorTansuEmails.Count == 0) if (companyDirectorEmails.Count == 0)
return steps; return (steps, []);
return steps var maxCompanyDirectorsOrderNo = companyDirectorSteps.Max(s => s.OrderNo);
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|| string.IsNullOrWhiteSpace(s.ExecutorEmail) var toCollapse = new List<ResolvedRouteStepDto>();
|| !directorTansuEmails.Contains(s.ExecutorEmail)) var afterBlock = new List<ResolvedRouteStepDto>();
.ToList();
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);
} }
/// <summary> /// <summary>
@@ -849,11 +849,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
} }
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, он идёт не параллельно блоку, а сразу после него")]
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_SequencedAfterCompanyDirectorsBlock()
{ {
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
// и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — // и Директор Tansu (из шага DirectorTansu, сам сконфигурирован с OrderNo 10) —
// один и тот же человек с одним и тем же email. // один и тот же человек с одним и тем же email.
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
@@ -898,15 +898,21 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None); CancellationToken.None);
// Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся // Assert — все три строки остаются (директор не пропадает и не схлопывается
// две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте. // молча), но Директор Tansu не параллелен блоку CompanyDirectors (OrderNo 5),
// а идёт сразу после него — OrderNo 6, а не свой сконфигурированный 10.
resolved.IsSuccess.ShouldBeTrue(); resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.Count.ShouldBe(2); resolved.Value.Steps.Count.ShouldBe(3);
var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); var tansuStep = resolved.Value.Steps.Single(s => s.PositionCode == HeadType.DirectorTansu);
tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); tansuStep.ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
tansuStep.OrderNo.ShouldBe(10); tansuStep.OrderNo.ShouldBe(6);
tansuStep.Warning.ShouldBeNull(); 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, если руководитель не найден")] [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]