From c60726759e03e61de698b2d4f6b3dc65b19a6174 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 11:30:17 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B5=D0=B4=D1=83=D0=BF=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=20Guid=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD?= =?UTF-8?q?=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() {