diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
index ef09cb3..5d85ce0 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
@@ -516,17 +516,50 @@ public sealed class TemplateRouteService(
!string.IsNullOrWhiteSpace(position) &&
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
+ ///
+ /// Один и тот же исполнитель, полученный из разных шагов, в результате должен
+ /// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
+ /// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
+ /// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
+ /// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
+ /// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
+ /// это не должно молча выбрать одну из них без следа: добавляем предупреждение
+ /// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
+ /// хуже, чем показать чуть неточный PositionCode с пояснением.
+ ///
private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps)
{
- var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var indexByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase);
var result = new List();
foreach (var step in steps)
{
- if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
+ if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
+ {
+ result.Add(step);
continue;
+ }
- result.Add(step);
+ if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
+ {
+ indexByEmail[step.ExecutorEmail] = result.Count;
+ result.Add(step);
+ continue;
+ }
+
+ var existing = result[index];
+ if (existing.PositionCode is null && step.PositionCode is not null)
+ {
+ result[index] = existing with { PositionCode = step.PositionCode };
+ }
+ else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
+ {
+ var conflictWarning =
+ $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
+ var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
+ .Where(w => !string.IsNullOrWhiteSpace(w)));
+ result[index] = existing with { Warning = combinedWarning };
+ }
}
return result;
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
index fb90910..968d8e2 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
@@ -306,10 +306,65 @@ public sealed class TemplateRouteServiceTests : IClassFixture();
+ portalClient
+ .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
+ portalClient
+ .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny()))
+ .ReturnsAsync("same@tnsu.kz");
+ portalClient
+ .Setup(x => x.GetManagerAsync(It.IsAny(), "directionHead", It.IsAny()))
+ .ReturnsAsync("same@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("Collapse conflicting position code request", "/collapse-conflicting-position-code", 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.Chief, RouteStepStatus.Approval, false, null),
+ CancellationToken.None);
+ await stepService.CreateAsync(
+ route.Value.Id,
+ new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, 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@tnsu.kz");
+ resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
+ var warning = resolved.Value.Steps[0].Warning;
+ warning.ShouldNotBeNull();
+ warning.ShouldContain("Chief");
+ warning.ShouldContain("DirectionHead");
+ }
+
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
{