Несколько условий шага, объединяемых через И/ИЛИ #16
@@ -221,11 +221,42 @@ public sealed class TemplateRouteService(
|
|||||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||||
cancellationToken)));
|
cancellationToken)));
|
||||||
|
|
||||||
var collapsedSteps = CollapseByExecutorEmail(stepResults.SelectMany(x => x).ToList());
|
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList());
|
||||||
|
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps);
|
||||||
|
|
||||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||||
|
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||||
|
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||||
|
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||||
|
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
|
||||||
|
/// тот считает разные PositionCode у одного email конфликтом и пишет
|
||||||
|
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто
|
||||||
|
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним
|
||||||
|
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors
|
||||||
|
/// оставляет его на своём месте — в конце.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||||
|
IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||||
|
{
|
||||||
|
var directorTansuEmails = steps
|
||||||
|
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
|
||||||
|
.Select(s => s.ExecutorEmail!)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (directorTansuEmails.Count == 0)
|
||||||
|
return steps;
|
||||||
|
|
||||||
|
return steps
|
||||||
|
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|
||||||
|
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|
||||||
|
|| !directorTansuEmails.Contains(s.ExecutorEmail))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||||||
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||||||
|
|||||||
+60
@@ -849,6 +849,66 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
|
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")]
|
||||||
|
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
|
||||||
|
{
|
||||||
|
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
|
||||||
|
// и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) —
|
||||||
|
// один и тот же человек с одним и тем же email.
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
||||||
|
{
|
||||||
|
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
|
||||||
|
new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
|
||||||
|
});
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("CompanyDirectors overlaps DirectorTansu request", "/company-directors-overlaps-director-tansu", 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(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
|
||||||
|
// поэтому HeadType.DirectorTansu резолвится в дефолт из TnsuPortalOptions
|
||||||
|
// ("director.tansu@tnsu.kz") — тот же email, что вернул GetEmployeeByPositionAsync
|
||||||
|
// для ТОО TANSU Construction.
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся
|
||||||
|
// две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте.
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
|
var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
|
||||||
|
tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu);
|
||||||
|
tansuStep.OrderNo.ShouldBe(10);
|
||||||
|
tansuStep.Warning.ShouldBeNull();
|
||||||
|
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
|
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
|
||||||
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
|
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user