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()))