Compare commits
2 Commits
572749b4d7
...
84b7003ac2
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b7003ac2 | |||
| fb90d372c8 |
@@ -221,11 +221,42 @@ public sealed class TemplateRouteService(
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
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));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||||
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||||
@@ -516,17 +547,50 @@ public sealed class TemplateRouteService(
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
|
||||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||
/// хуже, чем показать чуть неточный PositionCode с пояснением.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<ResolvedRouteStepDto>();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||
continue;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||
{
|
||||
result.Add(step);
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+115
@@ -306,10 +306,65 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps.Count.ShouldBe(2);
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
||||
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
|
||||
// Схлопнутый шаг #1 задан по email (PositionCode пуст), но дубликат — шаг #3
|
||||
// по HeadType.Chief — принёс PositionCode. Он не должен потеряться при схлопывании.
|
||||
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
||||
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
|
||||
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута: один email через два разных HeadType — предупреждение, PositionCode первого из них")]
|
||||
public async Task ResolveAsync_StepsResolveToSameEmailWithDifferentPositionCode_AddsWarningAndKeepsFirst()
|
||||
{
|
||||
// Arrange — оба типа руководителя резолвятся в один и тот же 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", "Тестовая должность") });
|
||||
portalClient
|
||||
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("same@tnsu.kz");
|
||||
portalClient
|
||||
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "directionHead", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("same@tnsu.kz");
|
||||
|
||||
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("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()
|
||||
{
|
||||
@@ -794,6 +849,66 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
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, если руководитель не найден")]
|
||||
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user