From 572749b4d7cc11bc8e1ef4184a93f18a14b710ab Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 10:52:28 +0500 Subject: [PATCH] =?UTF-8?q?GetEmployeeByEmailAsync=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B2;=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20HeadType?= =?UTF-8?q?.CompanyDirectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /Employee/ByEmail на портале обновился: сотрудник может числиться в нескольких компаниях, теперь отдаётся массив записей (одна на компанию). ITnsuPortalClient.GetEmployeeByEmailAsync возвращает IReadOnlyList вместо одного объекта; вызывающие места используют первую запись (как и раньше при одиночном ответе), кроме нового HeadType.CompanyDirectors — там нужны все компании. HeadType.CompanyDirectors резолвится в директора КАЖДОЙ компании инициатора — по одной строке результата на компанию, все с одним и тем же OrderNo шага (они и так один шаг, то есть параллельны по построению). Директор ищется через GetEmployeeByPositionAsync(position, company) с захардкоженной должностью «Директор» по умолчанию и явными исключениями для компаний, где директора называют иначе (ТОО KazPromService — «Главный механик», ТОО СТ Global — «Генеральный директор»). Co-Authored-By: Claude Sonnet 5 --- .../TemplateRouteStepService.cs | 26 +-- .../TemplateRoutes/TemplateRouteService.cs | 152 ++++++++++++++---- .../Enums/HeadType.cs | 12 +- .../TnsuPortal/CachingTnsuPortalClient.cs | 2 +- .../TnsuPortal/HttpTnsuPortalClient.cs | 12 +- .../TnsuPortal/ITnsuPortalClient.cs | 7 +- .../TemplateRouteServiceTests.cs | 63 +++++++- .../webparts/approvalAdmin/models/enums.ts | 6 +- frontend/src/webparts/approvalAdmin/texts.ts | 3 +- 9 files changed, 220 insertions(+), 63 deletions(-) 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 = {