Правки (#18)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 5m28s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/18
This commit was merged in pull request #18.
This commit is contained in:
2026-08-26 07:08:16 +00:00
parent b86e126ceb
commit 19a46b0df8
4 changed files with 196 additions and 39 deletions
@@ -221,39 +221,54 @@ 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 withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
}
/// <summary>
/// Один и тот же человек в справочнике api.tnsu.kz может иметь несколько email —
/// поэтому дедуп исполнителей (и здесь, и в CollapseByExecutorIdentity) идёт по
/// Guid сотрудника (EmployeeDirectoryInfo.Id), а не по email. Email — фолбэк на
/// случай, если Id почему-то не резолвился (сотрудник не найден по email в
/// момент BuildDtoAsync). null — нет ни того, ни другого, дедуп не имеет смысла
/// (например, исполнитель шага вовсе не резолвился).
/// </summary>
private static string? IdentityKeyOf(ResolvedStepRow row) =>
row.ExecutorId is { } id ? $"id:{id}" :
!string.IsNullOrWhiteSpace(row.Dto.ExecutorEmail) ? $"email:{row.Dto.ExecutorEmail.Trim().ToLowerInvariant()}" :
null;
/// <summary>
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
/// тот считает разные PositionCode у одного email конфликтом и пишет
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors
/// оставляет его на своём месте — в конце.
/// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
/// email), а не по email напрямую — один человек может фигурировать под
/// разными email в разных ветках резолва.
/// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
IReadOnlyList<ResolvedRouteStepDto> steps)
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
IReadOnlyList<ResolvedStepRow> steps)
{
var directorTansuEmails = steps
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
.Select(s => s.ExecutorEmail!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var directorTansuKeys = steps
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
.Select(IdentityKeyOf)
.Where(k => k is not null)
.Select(k => k!)
.ToHashSet();
if (directorTansuEmails.Count == 0)
if (directorTansuKeys.Count == 0)
return steps;
return steps
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|| !directorTansuEmails.Contains(s.ExecutorEmail))
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
.ToList();
}
@@ -336,7 +351,7 @@ public sealed class TemplateRouteService(
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning,
@@ -346,19 +361,22 @@ public sealed class TemplateRouteService(
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
{
string? resolvedPosition = null;
Guid? resolvedId = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
var resolvedEmployee = resolvedEmployees.FirstOrDefault();
resolvedPosition = resolvedEmployee?.Position;
resolvedId = resolvedEmployee?.Id;
}
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
var dto = new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
@@ -368,6 +386,8 @@ public sealed class TemplateRouteService(
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason,
step.HeadType is null or HeadType.None ? null : step.HeadType);
return new ResolvedStepRow(dto, resolvedId);
}
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
@@ -378,7 +398,7 @@ public sealed class TemplateRouteService(
if (initiatorCompanies.Count == 0)
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
var dtos = new List<ResolvedRouteStepDto>();
var dtos = new List<ResolvedStepRow>();
foreach (var company in initiatorCompanies)
{
var directorPosition = DirectorPositionFor(company);
@@ -552,33 +572,38 @@ public sealed class TemplateRouteService(
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
/// хуже, чем показать чуть неточный PositionCode с пояснением.
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
/// дедуп по email эту пару не поймал бы.
/// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
{
var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var indexByKey = new Dictionary<string, int>();
var result = new List<ResolvedRouteStepDto>();
foreach (var step in steps)
foreach (var row in steps)
{
if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
var key = IdentityKeyOf(row);
if (key is null)
{
result.Add(step);
result.Add(row.Dto);
continue;
}
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
if (!indexByKey.TryGetValue(key, out var index))
{
indexByEmail[step.ExecutorEmail] = result.Count;
result.Add(step);
indexByKey[key] = result.Count;
result.Add(row.Dto);
continue;
}
var existing = result[index];
var step = row.Dto;
if (existing.PositionCode is null && step.PositionCode is not null)
{
result[index] = existing with { PositionCode = step.PositionCode };
@@ -598,4 +623,11 @@ public sealed class TemplateRouteService(
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
/// <summary>
/// Промежуточный результат резолва одного шага — dto плюс Guid сотрудника из
/// справочника api.tnsu.kz (если резолвился), используемый только для дедупа
/// (см. IdentityKeyOf/CollapseByExecutorIdentity). Наружу отдаётся только Dto.
/// </summary>
private sealed record ResolvedStepRow(ResolvedRouteStepDto Dto, Guid? ExecutorId);
}