From 3421922a0801b93bb157842b49a24866562e36cb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 13:37:22 +0500 Subject: [PATCH 1/5] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=81=D1=8E=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=83=20(edge-to-edge,=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20request?= =?UTF-8?q?-portal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канву SharePoint мы и раньше разжимали (тот же :global хак + supportsFullBleed в манифесте), но содержимое упиралось в max-width: 1080px у .content и центрировалось — визуально это читалось как «не открывается на всю ширину». Переношу раскладку портала (RequestPortal.module.scss): - корень .approvalAdmin — width: 100%, padding: 0; - .shell больше не карточка: без рамки, тени, скруглений и overflow: hidden (последний ломает sticky), align-items: flex-start, min-height: 78vh; - .side — заливка $navy-800 на колонке (align-self: stretch), сам Sidebar внутри sticky на 100vh: градиент не плывёт от высоты соседа, и под рейлом на длинных таблицах нет белого провала, если sticky не включился; - .content — сняты max-width и margin: auto, остались только отступы; - мобильный брейкпоинт обновлён под новую схему: десктопные правила рейла снимаются явно, иначе sticky+100vh спорил бы с position: fixed, который ui-kit ставит drawer'у, при равной специфичности. Разметка AdminApp.tsx не менялась — структура уже совпадала с портальной. Co-Authored-By: Claude Opus 5 --- .../components/ApprovalAdmin.module.scss | 10 ++++ .../components/app/AdminApp.module.scss | 50 +++++++++++++++---- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.module.scss b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.module.scss index 6083861..0b42416 100644 --- a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.module.scss @@ -14,6 +14,16 @@ .approvalAdmin { font-family: $font-sans; + box-sizing: border-box; + + // Полная ширина обеспечивается через "supportsFullBleed": true в манифесте + // + снятым max-width канвы выше. Корень занимает 100% контейнера, без + // CSS-трюков с 100vw. + width: 100%; + + // App-shell «edge-to-edge» (как в request-portal): рейл примыкает к левому + // краю, отступы — внутри main. + padding: 0; } .stateBox { diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss index f6d4810..c249088 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss @@ -2,39 +2,54 @@ // Раскладка — тот же боковой рейл + основная область, что и в request-portal // (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль -// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю -// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице -// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по -// высоте своего соседа (.main) через align-items: stretch. +// навигации во всех веб-частях Tnsu. Как и там, это не карточка на странице +// SharePoint, а edge-to-edge app-shell: без рамки/тени/скруглений и без +// ограничения ширины контента — рейл примыкает к левому краю канвы. .shell { display: flex; - align-items: stretch; - border-radius: $r-lg; - border: 1px solid $gray-200; - overflow: hidden; - box-shadow: $sh-1; + align-items: flex-start; // рейл тянется не по контенту, а по своей колонке — см. .side + width: 100%; + min-height: 78vh; // «присутствие» рейла даже когда контента мало font-family: $font-sans; font-size: $fs-body; line-height: 1.55; } +// Рейл-колонка. Ровно та же схема, что в request-portal: КОЛОНКА тянется во всю +// высоту раскладки и закрашена $navy-800 (цветом, которым градиент рейла +// заканчивается), а сам РЕЙЛ внутри — sticky на 100vh поверх этой заливки. +// Так градиент не меняет оттенок от высоты соседней колонки, а под рейлом на +// длинных таблицах нет белого провала, даже если sticky в SharePoint не +// включился (прокручивается внутренний контейнер страницы, а не окно). +// Из-за sticky у .shell не должно быть overflow: hidden. .side { flex: 0 0 auto; display: flex; + align-self: stretch; background: $navy-800; + + // Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не + // видно, поэтому селектор по структуре, а не по имени). + > * { + position: sticky; + top: 0; + height: 100vh; + } } .main { flex: 1 1 auto; min-width: 0; // чтобы контент не распирал колонку + display: flex; + flex-direction: column; background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%); color: $gray-900; } +// Отступы контента — здесь, а не на .main: без max-width/margin:auto контент +// занимает всю доступную ширину (edge-to-edge, как в портале). .content { padding: 28px 30px 36px; - max-width: 1080px; - margin: 0 auto; } // Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта. @@ -48,13 +63,26 @@ // media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё // равно нужен в потоке, чтобы при mobileOpen выехать поверх контента. @media (max-width: 768px) { + .shell { + min-height: 0; + } + .mobileNav { display: block; } + // Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar + // position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при + // равной специфичности — исход решался бы порядком подключения стилей. + // Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам. .side { align-self: flex-start; background: none; + + > * { + position: static; + height: auto; + } } } -- 2.34.1 From 40d741c8c90a345cc1a17202e016b8d79e263289 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 10:53:41 +0500 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20Tansu=20=D1=81=D1=80=D0=B5=D0=B4=D0=B8=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9:=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE,?= =?UTF-8?q?=20=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо удаления совпавшей строки CompanyDirectors — обе строки (директор компании и Директор Tansu) остаются в результате, это два разных пункта согласования, даже если физически один человек. Строка DirectorTansu, чей email совпал с одним из CompanyDirectors, не участвует в обычном схлопывании по email (CollapseByExecutorEmail) — та восприняла бы это как конфликт PositionCode одного согласующего. Вместо этого её OrderNo явно переставляется на «сразу после блока CompanyDirectors» (максимальный OrderNo блока + 1), так что она идёт последовательно, а не параллельно остальным директорам компаний. Co-Authored-By: Claude Sonnet 5 --- .../TemplateRoutes/TemplateRouteService.cs | 67 +++++++++++++------ .../TemplateRouteServiceTests.cs | 26 ++++--- 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index c49fcde..c418d4f 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -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( /// /// Директор 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» — не параллельно ему, а следом. /// - private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( - IReadOnlyList steps) + /// + /// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через + /// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно + /// просто дописать в конец результата без схлопывания. + /// + private static (IReadOnlyList ToCollapse, IReadOnlyList DirectorTansuAfterCompanyDirectors) + SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList 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(); + var afterBlock = new List(); + + 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); } /// diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 0a69447..327060b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -849,11 +849,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } - [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] - public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, он идёт не параллельно блоку, а сразу после него")] + public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_SequencedAfterCompanyDirectorsBlock() { // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) - // и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — + // и Директор Tansu (из шага DirectorTansu, сам сконфигурирован с OrderNo 10) — // один и тот же человек с одним и тем же email. var portalClient = new Mock(); portalClient @@ -898,15 +898,21 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "director.tansu@tnsu.kz"); - tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); - tansuStep.OrderNo.ShouldBe(10); + resolved.Value.Steps.Count.ShouldBe(3); + var tansuStep = resolved.Value.Steps.Single(s => s.PositionCode == HeadType.DirectorTansu); + tansuStep.ExecutorEmail.ShouldBe("director.tansu@tnsu.kz"); + tansuStep.OrderNo.ShouldBe(6); tansuStep.Warning.ShouldBeNull(); - resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); + + var companyDirectorSteps = resolved.Value.Steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); + companyDirectorSteps.Count.ShouldBe(2); + companyDirectorSteps.ShouldAllBe(s => s.OrderNo == 5); + companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] -- 2.34.1 From 0289236d1919131624e01c9ab05468dc2e82f73e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 11:05:59 +0500 Subject: [PATCH 3/5] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82:=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B4=D0=B2=D1=83=D1=85=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B8=D0=BD=D0=B8=D1=86=D0=B8=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрывает ровно тот сценарий, который выглядел как баг на скриншоте из ManageRequestsScreen (один и тот же человек дважды на одном шаге) — подтверждает, что текущий код (CollapseByExecutorEmail) схлопывает такую пару в одну строку без предупреждения. Co-Authored-By: Claude Sonnet 5 --- .../TemplateRouteServiceTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 327060b..47b0af1 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -849,6 +849,57 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } + [Fact(DisplayName = "Резолв: один и тот же директор двух разных компаний инициатора в CompanyDirectors схлопывается в одну строку")] + public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDirectorOfTwoCompanies_CollapsesToOneRow() + { + // Arrange — инициатор числится в двух компаниях, и директор у них ОДИН и тот же + // человек (например, холдинговая структура) — оба GetEmployeeByPositionAsync + // возвращают один и тот же email. + 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, "ТОО СТ Global", "Начальник отдела службы безопасности") + }); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор")); + + 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 same person request", "/company-directors-same-person", 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(1, 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.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("same.director@tnsu.kz"); + resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors); + resolved.Value.Steps[0].Warning.ShouldBeNull(); + } + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, он идёт не параллельно блоку, а сразу после него")] public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_SequencedAfterCompanyDirectorsBlock() { -- 2.34.1 From cc4ad423bcd25ed0519b85149e50fc33da8bdd78 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 11:10:06 +0500 Subject: [PATCH 4/5] =?UTF-8?q?Revert:=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20Tansu=20=E2=80=94=20=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=82=20=D0=B8=D0=B7?= =?UTF-8?q?=20CompanyDirectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возврат к удалению совпавшей строки CompanyDirectors вместо последовательной постановки после блока (см. 40d741c) — решили не показывать одного и того же человека дважды в списке согласования. Co-Authored-By: Claude Sonnet 5 --- .../TemplateRoutes/TemplateRouteService.cs | 65 ++++++------------- .../TemplateRouteServiceTests.cs | 28 ++++---- 2 files changed, 30 insertions(+), 63 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index c418d4f..321d0ca 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -221,11 +221,8 @@ public sealed class TemplateRouteService( HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, cancellationToken))); - var flattenedSteps = stepResults.SelectMany(x => x).ToList(); - var (toCollapse, directorTansuAfterCompanyDirectors) = SplitDirectorTansuAfterCompanyDirectors(flattenedSteps); - var collapsedSteps = CollapseByExecutorEmail(toCollapse) - .Concat(directorTansuAfterCompanyDirectors) - .ToList(); + var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); + var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } @@ -233,53 +230,29 @@ public sealed class TemplateRouteService( /// /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU - /// Construction. Оба шага в маршруте законны сами по себе, и оба должны попасть в - /// результат — это НЕ дубликат одного согласования, а два разных: сначала блок - /// директоров компаний параллельно, а затем — отдельно — Директор Tansu, даже если - /// физически это тот же человек. Поэтому строку DirectorTansu с таким совпадением - /// НЕ пускаем через обычное схлопывание по email (см. CollapseByExecutorEmail) — - /// оно бы решило, что это конфликт PositionCode одного согласующего, и либо - /// смержило бы её с строкой CompanyDirectors, либо просто выбрало одну из них. - /// Вместо этого явно переставляем её OrderNo на «после всего блока - /// CompanyDirectors» — не параллельно ему, а следом. + /// Construction. Оба шага в маршруте законны сами по себе, но если так, из + /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом + /// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): + /// тот считает разные PositionCode у одного email конфликтом и пишет + /// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем + /// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. /// - /// - /// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через - /// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно - /// просто дописать в конец результата без схлопывания. - /// - private static (IReadOnlyList ToCollapse, IReadOnlyList DirectorTansuAfterCompanyDirectors) - SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList steps) + private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( + IReadOnlyList steps) { - var companyDirectorSteps = steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); - var companyDirectorEmails = companyDirectorSteps - .Where(s => !string.IsNullOrWhiteSpace(s.ExecutorEmail)) + var directorTansuEmails = steps + .Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) .Select(s => s.ExecutorEmail!) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (companyDirectorEmails.Count == 0) - return (steps, []); + if (directorTansuEmails.Count == 0) + return steps; - var maxCompanyDirectorsOrderNo = companyDirectorSteps.Max(s => s.OrderNo); - - var toCollapse = new List(); - var afterBlock = new List(); - - 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); + return steps + .Where(s => s.PositionCode != HeadType.CompanyDirectors + || string.IsNullOrWhiteSpace(s.ExecutorEmail) + || !directorTansuEmails.Contains(s.ExecutorEmail)) + .ToList(); } /// diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 47b0af1..7e3d092 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -900,12 +900,12 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) @@ -949,21 +949,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.PositionCode == HeadType.DirectorTansu); - tansuStep.ExecutorEmail.ShouldBe("director.tansu@tnsu.kz"); - tansuStep.OrderNo.ShouldBe(6); + resolved.Value.Steps.Count.ShouldBe(2); + var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); + tansuStep.OrderNo.ShouldBe(10); tansuStep.Warning.ShouldBeNull(); - - var companyDirectorSteps = resolved.Value.Steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); - companyDirectorSteps.Count.ShouldBe(2); - companyDirectorSteps.ShouldAllBe(s => s.OrderNo == 5); - companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); - companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); + resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); } [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] -- 2.34.1 From c60726759e03e61de698b2d4f6b3dc65b19a6174 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 11:30:17 +0500 Subject: [PATCH 5/5] =?UTF-8?q?=D0=94=D0=B5=D0=B4=D1=83=D0=BF=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=20Guid=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0,=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реальная причина бага с дублем в списке согласования: один и тот же человек может иметь несколько email в справочнике api.tnsu.kz, поэтому схлопывание по email его не ловило. EmployeeDirectoryInfo дополнен полем Id (Guid сотрудника из /Employee/ ByEmail). ResolveStepAsync теперь параллельно с email несёт этот Id (внутренний ResolvedStepRow, наружу не отдаётся), а CollapseByExecutorIdentity и RemoveCompanyDirectorsDuplicatedByDirectorTansu дедуплицируют по identity-ключу: Id, если он резолвился, иначе email как фолбэк (на случай, если сотрудник по итоговому email не нашёлся в справочнике). Co-Authored-By: Claude Sonnet 5 --- .../TemplateRoutes/TemplateRouteService.cs | 94 +++++++++++++------ .../TnsuPortal/HttpTnsuPortalClient.cs | 5 +- .../TnsuPortal/TnsuPortalDtos.cs | 11 ++- .../TemplateRouteServiceTests.cs | 64 +++++++++++++ 4 files changed, 141 insertions(+), 33 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 321d0ca..d49b082 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -221,37 +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)); } + /// + /// Один и тот же человек в справочнике api.tnsu.kz может иметь несколько email — + /// поэтому дедуп исполнителей (и здесь, и в CollapseByExecutorIdentity) идёт по + /// Guid сотрудника (EmployeeDirectoryInfo.Id), а не по email. Email — фолбэк на + /// случай, если Id почему-то не резолвился (сотрудник не найден по email в + /// момент BuildDtoAsync). null — нет ни того, ни другого, дедуп не имеет смысла + /// (например, исполнитель шага вовсе не резолвился). + /// + private static string? IdentityKeyOf(ResolvedStepRow row) => + row.ExecutorId is { } id ? $"id:{id}" : + !string.IsNullOrWhiteSpace(row.Dto.ExecutorEmail) ? $"email:{row.Dto.ExecutorEmail.Trim().ToLowerInvariant()}" : + null; + /// /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU /// Construction. Оба шага в маршруте законны сами по себе, но если так, из /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом - /// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): - /// тот считает разные PositionCode у одного email конфликтом и пишет + /// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity): + /// тот считает разные PositionCode у одного исполнителя конфликтом и пишет /// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем - /// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. + /// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение + /// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе + /// email), а не по email напрямую — один человек может фигурировать под + /// разными email в разных ветках резолва. /// - private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( - IReadOnlyList steps) + private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( + IReadOnlyList 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(); } @@ -334,7 +351,7 @@ public sealed class TemplateRouteService( /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// - private async Task> ResolveStepAsync( + private async Task> ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList initiatorCompanies, IReadOnlyList projectExecutors, Guid? departmentId, RequestType requestType, string? conditionWarning, @@ -344,19 +361,22 @@ public sealed class TemplateRouteService( // работает именно так, как задумано — значение шага перекрывает типовое, // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours). - async Task BuildDtoAsync(string? resolvedEmail, string? warning) + async Task 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, @@ -366,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 — единственная ветка, которая резолвится в несколько строк @@ -376,7 +398,7 @@ public sealed class TemplateRouteService( if (initiatorCompanies.Count == 0) return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")]; - var dtos = new List(); + var dtos = new List(); foreach (var company in initiatorCompanies) { var directorPosition = DirectorPositionFor(company); @@ -550,33 +572,38 @@ public sealed class TemplateRouteService( /// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode /// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки /// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он - /// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две + /// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две /// несовместимые роли шага, например Chief и CompanyDirectors одновременно) — /// это не должно молча выбрать одну из них без следа: добавляем предупреждение /// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности - /// хуже, чем показать чуть неточный PositionCode с пояснением. + /// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по + /// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же + /// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними + /// дедуп по email эту пару не поймал бы. /// - private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) + private static IReadOnlyList CollapseByExecutorIdentity(IReadOnlyList steps) { - var indexByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase); + var indexByKey = new Dictionary(); var result = new List(); - 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 }; @@ -596,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); + + /// + /// Промежуточный результат резолва одного шага — dto плюс Guid сотрудника из + /// справочника api.tnsu.kz (если резолвился), используемый только для дедупа + /// (см. IdentityKeyOf/CollapseByExecutorIdentity). Наружу отдаётся только Dto. + /// + private sealed record ResolvedStepRow(ResolvedRouteStepDto Dto, Guid? ExecutorId); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 412fabf..c679c13 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -20,7 +20,7 @@ public sealed class HttpTnsuPortalClient( return []; return employees - .Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position)) + .Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position, e.Id)) .ToList(); } @@ -94,7 +94,8 @@ public sealed class HttpTnsuPortalClient( [property: JsonPropertyName("companyId")] Guid? CompanyId, [property: JsonPropertyName("departmentId")] Guid? DepartmentId, [property: JsonPropertyName("position")] string? Position, - [property: JsonPropertyName("fio")] string? Fio); + [property: JsonPropertyName("fio")] string? Fio, + [property: JsonPropertyName("id")] Guid? Id = null); private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 9a0636b..bae25ac 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -2,7 +2,16 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position); +/// +/// Guid сотрудника в справочнике api.tnsu.kz — один и тот же человек может иметь +/// несколько записей (см. массив в /Employee/ByEmail) и даже несколько разных +/// email, но Id у него один. Используется как надёжный ключ дедупликации +/// исполнителей при резолве маршрута (см. TemplateRouteService) — email для +/// этого не годится: один человек может фигурировать под разными адресами. +/// Nullable, потому что старые вызовы могли не запросить это поле явно (сейчас +/// всегда приходит) — на этот случай дедуп по Id тихо откатывается на email. +/// +public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position, Guid? Id = null); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 7e3d092..1f8b1a6 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -900,6 +900,70 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync("someone@tnsu.kz", It.IsAny())) + .ReturnsAsync(new List + { + new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), + new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности") + }); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu.email@tnsu.kz", "Общий директор")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "director.stglobal.email@tnsu.kz", "Общий директор")); + // Оба email в справочнике резолвятся в ОДНОГО и того же человека (общий Id) — + // как в реальном порталe, где у сотрудника может быть несколько адресов. + portalClient + .Setup(x => x.GetEmployeeByEmailAsync("director.tansu.email@tnsu.kz", It.IsAny())) + .ReturnsAsync(new List + { + new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Директор", personId) + }); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync("director.stglobal.email@tnsu.kz", It.IsAny())) + .ReturnsAsync(new List + { + new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Генеральный директор", personId) + }); + + 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 same person different emails request", "/company-directors-same-person-different-emails", 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(1, 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 — дедуп по Id сотрудника, а не по email: одна строка, а не две. + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors); + resolved.Value.Steps[0].Warning.ShouldBeNull(); + } + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors")] public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() { -- 2.34.1