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