diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index c49fcde..d49b082 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -221,39 +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 в маршруте обычно идёт последним - /// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors - /// оставляет его на своём месте — в конце. + /// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity): + /// тот считает разные PositionCode у одного исполнителя конфликтом и пишет + /// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем + /// только шаг 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(); } @@ -336,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, @@ -346,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, @@ -368,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 — единственная ветка, которая резолвится в несколько строк @@ -378,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); @@ -552,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 }; @@ -598,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 0a69447..1f8b1a6 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -849,12 +849,127 @@ public sealed class TemplateRouteServiceTests : IClassFixture s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } - [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] + [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 = "Резолв: один и тот же человек под РАЗНЫМИ email (общий Id в справочнике) в CompanyDirectors схлопывается в одну строку")] + public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDifferentEmailsSameId_CollapsesToOneRow() + { + // Arrange — в справочнике api.tnsu.kz это один человек (один Guid Id), но у + // него разные email в разных компаниях. Дедуп по email это бы не поймал — + // отсюда и был реальный баг (одна и та же строка дважды на одном шаге). + var personId = Guid.NewGuid(); + var portalClient = new Mock(); + 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() { // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) - // и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — - // один и тот же человек с одним и тем же email. + // и Директор Tansu (из шага DirectorTansu, OrderNo 10) — один и тот же человек + // с одним и тем же email. var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))