GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors
Tests / frontend-test (pull_request) Successful in 46s
Tests / backend-test (pull_request) Successful in 1m28s

/Employee/ByEmail на портале обновился: сотрудник может числиться в
нескольких компаниях, теперь отдаётся массив записей (одна на компанию).
ITnsuPortalClient.GetEmployeeByEmailAsync возвращает IReadOnlyList вместо
одного объекта; вызывающие места используют первую запись (как и раньше
при одиночном ответе), кроме нового HeadType.CompanyDirectors — там нужны
все компании.

HeadType.CompanyDirectors резолвится в директора КАЖДОЙ компании
инициатора — по одной строке результата на компанию, все с одним и тем
же OrderNo шага (они и так один шаг, то есть параллельны по построению).
Директор ищется через GetEmployeeByPositionAsync(position, company) с
захардкоженной должностью «Директор» по умолчанию и явными исключениями
для компаний, где директора называют иначе (ТОО KazPromService — «Главный
механик», ТОО СТ Global — «Генеральный директор»).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-25 10:52:28 +05:00
parent ca8de91742
commit 572749b4d7
9 changed files with 220 additions and 63 deletions
@@ -294,18 +294,20 @@ public sealed class TemplateRouteStepService(
}
/// <summary>
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
/// DepartmentHead, единственный departmentId запроса (см.
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
/// в одного и того же (или неотличимо какого) руководителя — например, «два
/// разных директора одной и той же компании» просто не существуют. В отличие
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
/// полей шага. Исключение — условие появления шага: при разных условиях
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
/// руководителя дважды в маршруте не окажется.
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
/// </summary>
private async Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
@@ -142,18 +142,30 @@ public sealed class TemplateRouteService(
if (requestType is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null)
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employees.Count == 0)
return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
var primaryEmployee = employees[0];
var initiatorCompanies = employees
.Select(e => e.Company)
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var byProject = request.ProjectId is not null;
// Приоритет — маршрут с компанией инициатора; если для этой компании
// своего маршрута нет, берём запасной маршрут без указанной компании
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -200,13 +212,16 @@ public sealed class TemplateRouteService(
// соответствует порядку задач, а не порядку завершения), поэтому
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
// дубликат считается первым) продолжают работать так же, как при
// последовательном foreach.
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
// строк результата (HeadType.CompanyDirectors — по одной на компанию
// инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
// и результат нужно сначала сгладить (SelectMany).
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken)));
var collapsedSteps = CollapseByExecutorEmail(steps);
var collapsedSteps = CollapseByExecutorEmail(stepResults.SelectMany(x => x).ToList());
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
}
@@ -252,12 +267,104 @@ public sealed class TemplateRouteService(
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
/// <summary>
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
/// отдельного источника правды для него в системе нет.
/// </summary>
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ТОО KazPromService"] = "Главный механик",
["ТОО СТ Global"] = "Генеральный директор"
};
private const string DefaultDirectorPosition = "Директор";
private static string DirectorPositionFor(string company) =>
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
/// <summary>
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
/// им одинаковый порядок вручную.
/// </summary>
/// <param name="initiatorCompanies">
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
/// </param>
/// <param name="conditionWarning">
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning,
CancellationToken cancellationToken)
{
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
{
string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
}
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason,
step.HeadType is null or HeadType.None ? null : step.HeadType);
}
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
// и до общего "одна строка на шаг" пути ниже.
if (step.HeadType == HeadType.CompanyDirectors)
{
if (initiatorCompanies.Count == 0)
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
var dtos = new List<ResolvedRouteStepDto>();
foreach (var company in initiatorCompanies)
{
var directorPosition = DirectorPositionFor(company);
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
var directorEmail = employeeByPosition.Email;
string? directorWarning = null;
if (!employeeByPosition.Found)
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
else if (string.IsNullOrWhiteSpace(directorEmail))
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
}
return dtos;
}
string? resolvedEmail;
string? warning = null;
@@ -318,30 +425,7 @@ public sealed class TemplateRouteService(
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
}
string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployee?.Position;
}
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason,
step.HeadType is null or HeadType.None ? null : step.HeadType);
return [await BuildDtoAsync(resolvedEmail, warning)];
}
/// <summary>