Схлопывание шагов резолва: PositionCode не теряется, конфликт — с предупреждением
CollapseByExecutorEmail просто выкидывал дубликаты по email, оставляя первую строку как есть — если у неё PositionCode был пуст (исполнитель задан email/должностью), а у отброшенного дубликата задан (пришёл через HeadType), тот терялся молча. Теперь: PositionCode пустой у оставленной строки — переносится с дубликата. Задан у обеих и они РАЗНЫЕ (один и тот же человек получен через два несовместимых HeadType, например Chief и DirectionHead одновременно) — оставляем первый попавшийся и добавляем предупреждение с обоими значениями, а не выбираем молча. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -516,17 +516,50 @@ public sealed class TemplateRouteService(
|
|||||||
!string.IsNullOrWhiteSpace(position) &&
|
!string.IsNullOrWhiteSpace(position) &&
|
||||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||||||
|
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||||
|
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||||
|
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||||
|
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
|
||||||
|
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||||
|
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||||
|
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||||
|
/// хуже, чем показать чуть неточный PositionCode с пояснением.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
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>();
|
var result = new List<ResolvedRouteStepDto>();
|
||||||
|
|
||||||
foreach (var step in steps)
|
foreach (var step in steps)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
continue;
|
{
|
||||||
|
|
||||||
result.Add(step);
|
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;
|
return result;
|
||||||
|
|||||||
+55
@@ -306,10 +306,65 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps.Count.ShouldBe(2);
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
||||||
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
|
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].ExecutorEmail.ShouldBe("other@tnsu.kz");
|
||||||
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
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 в запросе — маршрут формируется, шаг с предупреждением")]
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||||
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
|
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user