diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
index d156d9e..2fdb185 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
@@ -294,18 +294,20 @@ public sealed class TemplateRouteStepService(
}
///
- /// Любой 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 — вне зависимости от статуса или остальных полей шага. Исключение —
+ /// условие появления шага: при разных условиях в конкретный резолв попадёт только
+ /// один из шагов, так что дублирования исполнителей в маршруте не окажется.
///
private async Task HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, IReadOnlyList? conditions, ConditionsLogic conditionsLogic,
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
index c845f37..ef09cb3 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
@@ -142,18 +142,30 @@ public sealed class TemplateRouteService(
if (requestType is null)
return Result.Fail(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(
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(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, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
///
- private async Task ResolveStepAsync(
- TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
+ ///
+ /// Название должности директора для конкретной компании — по умолчанию «Директор»,
+ /// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
+ /// поэтому для HeadType.CompanyDirectors ищем по этому названию через
+ /// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
+ /// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
+ /// отдельного источника правды для него в системе нет.
+ ///
+ private static readonly IReadOnlyDictionary DirectorPositionByCompany =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ТОО KazPromService"] = "Главный механик",
+ ["ТОО СТ Global"] = "Генеральный директор"
+ };
+
+ private const string DefaultDirectorPosition = "Директор";
+
+ private static string DirectorPositionFor(string company) =>
+ DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
+
+ ///
+ /// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
+ /// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
+ /// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
+ /// шаге, то есть параллельны по построению, а не потому что кто-то расставил
+ /// им одинаковый порядок вручную.
+ ///
+ ///
+ /// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
+ /// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
+ /// используют одну (первую по счёту).
+ ///
+ ///
+ /// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
+ /// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
+ /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
+ /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
+ ///
+ private async Task> ResolveStepAsync(
+ TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList initiatorCompanies,
IReadOnlyList projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning,
CancellationToken cancellationToken)
{
+ // Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
+ // работает именно так, как задумано — значение шага перекрывает типовое,
+ // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
+ // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
+ async Task 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();
+ 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)];
}
///
diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs
index 6dacd1d..1ea0e54 100644
--- a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs
+++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs
@@ -15,5 +15,15 @@ public enum HeadType
/// подчинённости самого инициатора. В маршруте не может быть больше одного
/// шага с этим типом (см. TemplateRouteStepService).
///
- DepartmentHead
+ DepartmentHead,
+
+ ///
+ /// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может
+ /// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает
+ /// массив). В отличие от остальных значений этого enum'а, один такой шаг
+ /// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию
+ /// инициатора, все с одним и тем же OrderNo (то есть параллельно, см.
+ /// TemplateRouteService.ResolveStepAsync).
+ ///
+ CompanyDirectors
}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
index 785e246..6f1ade9 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
@@ -17,7 +17,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
- public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
+ public Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:email:{email.ToLowerInvariant()}",
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
index 40c5b9c..412fabf 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
@@ -12,14 +12,16 @@ public sealed class HttpTnsuPortalClient(
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
- public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
+ public async Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{
- var employee = await GetAsync(
+ var employees = await GetAsync>(
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
- if (employee is null)
- return null;
+ if (employees is null)
+ return [];
- return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
+ return employees
+ .Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position))
+ .ToList();
}
public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
index 2821d9d..731a898 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
@@ -2,7 +2,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public interface ITnsuPortalClient
{
- Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
+ ///
+ /// Все записи сотрудника по email — /Employee/ByEmail отдаёт массив: сотрудник может
+ /// числиться в нескольких компаниях одновременно (один Id/Fio/Email, разные Company/
+ /// CompanyId/Section/DepartmentId/Position на каждую). Пусто, если не найден вовсе.
+ ///
+ Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
index 031c277..fb90910 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
@@ -27,7 +27,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
@@ -267,7 +267,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient
.Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny()))
.ReturnsAsync("dup@tnsu.kz");
@@ -347,7 +347,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny()))
.ReturnsAsync("head-of-department@tnsu.kz");
@@ -387,7 +387,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny()))
.ReturnsAsync((string?)null);
@@ -638,7 +638,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") });
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny()))
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
@@ -677,7 +677,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position") });
portalClient
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny()))
.ReturnsAsync("chief@tnsu.kz");
@@ -716,7 +716,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position"));
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position") });
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService();
@@ -745,6 +745,55 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
+ [Fact(DisplayName = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")]
+ public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany()
+ {
+ // Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService)
+ // должность директора захардкожена как «Главный механик», а не «Директор».
+ var portalClient = new Mock();
+ portalClient
+ .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
+ new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
+ });
+ portalClient
+ .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny()))
+ .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
+ portalClient
+ .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny()))
+ .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
+
+ var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
+ var routeService = services.GetRequiredService();
+ var requestTypeService = services.GetRequiredService();
+ var stepService = services.GetRequiredService();
+
+ var requestTypeId = (await requestTypeService.CreateAsync(
+ new CreateRequestTypeRequest("CompanyDirectors request", "/company-directors-request", null, null, "General"), CancellationToken.None)).Value.Id;
+ var route = await routeService.CreateAsync(
+ new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
+ CancellationToken.None);
+ await stepService.CreateAsync(
+ route.Value.Id,
+ new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
+ CancellationToken.None);
+
+ // Action
+ var resolved = await routeService.ResolveAsync(
+ new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
+ CancellationToken.None);
+
+ // Assert
+ resolved.IsSuccess.ShouldBeTrue();
+ resolved.Value.Steps.Count.ShouldBe(2);
+ resolved.Value.Steps.ShouldAllBe(s => s.OrderNo == 5);
+ resolved.Value.Steps.ShouldAllBe(s => s.PositionCode == HeadType.CompanyDirectors);
+ resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
+ resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
+ }
+
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
{
diff --git a/frontend/src/webparts/approvalAdmin/models/enums.ts b/frontend/src/webparts/approvalAdmin/models/enums.ts
index 961d51f..72e0272 100644
--- a/frontend/src/webparts/approvalAdmin/models/enums.ts
+++ b/frontend/src/webparts/approvalAdmin/models/enums.ts
@@ -26,7 +26,11 @@ export enum HeadType {
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
- DepartmentHead = 'DepartmentHead'
+ DepartmentHead = 'DepartmentHead',
+ // Единственное значение, которое резолвится в НЕСКОЛЬКО строк результата — по
+ // директору на каждую компанию инициатора (сотрудник может числиться в
+ // нескольких), все с одним и тем же orderNo (см. TemplateRouteService.ResolveStepAsync).
+ CompanyDirectors = 'CompanyDirectors'
}
export enum ComparisonOperator {
diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts
index 561b3f6..3792bd2 100644
--- a/frontend/src/webparts/approvalAdmin/texts.ts
+++ b/frontend/src/webparts/approvalAdmin/texts.ts
@@ -32,7 +32,8 @@ export const headTypeLabels: Record = {
[HeadType.DirectionHead]: 'Руководитель направления',
[HeadType.DirectorTansu]: 'Директор Tansu',
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
- [HeadType.DepartmentHead]: 'Руководитель указанного отдела'
+ [HeadType.DepartmentHead]: 'Руководитель указанного отдела',
+ [HeadType.CompanyDirectors]: 'Директора компаний инициатора'
};
export const comparisonOperatorLabels: Record = {