Директор 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,
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(
/// <summary>
/// Директор 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» — не параллельно ему, а следом.
/// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
IReadOnlyList<ResolvedRouteStepDto> steps)
/// <returns>
/// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через
/// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно
/// просто дописать в конец результата без схлопывания.
/// </returns>
private static (IReadOnlyList<ResolvedRouteStepDto> ToCollapse, IReadOnlyList<ResolvedRouteStepDto> DirectorTansuAfterCompanyDirectors)
SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList<ResolvedRouteStepDto> 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<ResolvedRouteStepDto>();
var afterBlock = new List<ResolvedRouteStepDto>();
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>