Дедуп исполнителей резолва — по Guid сотрудника, не по email
Реальная причина бага с дублем в списке согласования: один и тот же человек может иметь несколько email в справочнике api.tnsu.kz, поэтому схлопывание по email его не ловило. EmployeeDirectoryInfo дополнен полем Id (Guid сотрудника из /Employee/ ByEmail). ResolveStepAsync теперь параллельно с email несёт этот Id (внутренний ResolvedStepRow, наружу не отдаётся), а CollapseByExecutorIdentity и RemoveCompanyDirectorsDuplicatedByDirectorTansu дедуплицируют по identity-ключу: Id, если он резолвился, иначе email как фолбэк (на случай, если сотрудник по итоговому email не нашёлся в справочнике). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -221,37 +221,54 @@ public sealed class TemplateRouteService(
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken)));
|
||||
|
||||
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList());
|
||||
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps);
|
||||
var flattenedSteps = stepResults.SelectMany(x => x).ToList();
|
||||
var withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
|
||||
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
|
||||
|
||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один и тот же человек в справочнике api.tnsu.kz может иметь несколько email —
|
||||
/// поэтому дедуп исполнителей (и здесь, и в CollapseByExecutorIdentity) идёт по
|
||||
/// Guid сотрудника (EmployeeDirectoryInfo.Id), а не по email. Email — фолбэк на
|
||||
/// случай, если Id почему-то не резолвился (сотрудник не найден по email в
|
||||
/// момент BuildDtoAsync). null — нет ни того, ни другого, дедуп не имеет смысла
|
||||
/// (например, исполнитель шага вовсе не резолвился).
|
||||
/// </summary>
|
||||
private static string? IdentityKeyOf(ResolvedStepRow row) =>
|
||||
row.ExecutorId is { } id ? $"id:{id}" :
|
||||
!string.IsNullOrWhiteSpace(row.Dto.ExecutorEmail) ? $"email:{row.Dto.ExecutorEmail.Trim().ToLowerInvariant()}" :
|
||||
null;
|
||||
|
||||
/// <summary>
|
||||
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
|
||||
/// тот считает разные PositionCode у одного email конфликтом и пишет
|
||||
/// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
|
||||
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
|
||||
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
||||
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем.
|
||||
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
|
||||
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
|
||||
/// email), а не по email напрямую — один человек может фигурировать под
|
||||
/// разными email в разных ветках резолва.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||
IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||
IReadOnlyList<ResolvedStepRow> steps)
|
||||
{
|
||||
var directorTansuEmails = steps
|
||||
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
|
||||
.Select(s => s.ExecutorEmail!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var directorTansuKeys = steps
|
||||
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
|
||||
.Select(IdentityKeyOf)
|
||||
.Where(k => k is not null)
|
||||
.Select(k => k!)
|
||||
.ToHashSet();
|
||||
|
||||
if (directorTansuEmails.Count == 0)
|
||||
if (directorTansuKeys.Count == 0)
|
||||
return steps;
|
||||
|
||||
return steps
|
||||
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|
||||
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|
||||
|| !directorTansuEmails.Contains(s.ExecutorEmail))
|
||||
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -334,7 +351,7 @@ public sealed class TemplateRouteService(
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
|
||||
private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
@@ -344,19 +361,22 @@ public sealed class TemplateRouteService(
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||
async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||
{
|
||||
string? resolvedPosition = null;
|
||||
Guid? resolvedId = null;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
{
|
||||
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
|
||||
var resolvedEmployee = resolvedEmployees.FirstOrDefault();
|
||||
resolvedPosition = resolvedEmployee?.Position;
|
||||
resolvedId = resolvedEmployee?.Id;
|
||||
}
|
||||
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
var dto = new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
@@ -366,6 +386,8 @@ public sealed class TemplateRouteService(
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
||||
|
||||
return new ResolvedStepRow(dto, resolvedId);
|
||||
}
|
||||
|
||||
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||
@@ -376,7 +398,7 @@ public sealed class TemplateRouteService(
|
||||
if (initiatorCompanies.Count == 0)
|
||||
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||
|
||||
var dtos = new List<ResolvedRouteStepDto>();
|
||||
var dtos = new List<ResolvedStepRow>();
|
||||
foreach (var company in initiatorCompanies)
|
||||
{
|
||||
var directorPosition = DirectorPositionFor(company);
|
||||
@@ -550,33 +572,38 @@ public sealed class TemplateRouteService(
|
||||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
|
||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
|
||||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||
/// хуже, чем показать чуть неточный PositionCode с пояснением.
|
||||
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
|
||||
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
|
||||
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
|
||||
/// дедуп по email эту пару не поймал бы.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
|
||||
{
|
||||
var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexByKey = new Dictionary<string, int>();
|
||||
var result = new List<ResolvedRouteStepDto>();
|
||||
|
||||
foreach (var step in steps)
|
||||
foreach (var row in steps)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||
var key = IdentityKeyOf(row);
|
||||
if (key is null)
|
||||
{
|
||||
result.Add(step);
|
||||
result.Add(row.Dto);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
|
||||
if (!indexByKey.TryGetValue(key, out var index))
|
||||
{
|
||||
indexByEmail[step.ExecutorEmail] = result.Count;
|
||||
result.Add(step);
|
||||
indexByKey[key] = result.Count;
|
||||
result.Add(row.Dto);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = result[index];
|
||||
var step = row.Dto;
|
||||
if (existing.PositionCode is null && step.PositionCode is not null)
|
||||
{
|
||||
result[index] = existing with { PositionCode = step.PositionCode };
|
||||
@@ -596,4 +623,11 @@ public sealed class TemplateRouteService(
|
||||
|
||||
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||
|
||||
/// <summary>
|
||||
/// Промежуточный результат резолва одного шага — dto плюс Guid сотрудника из
|
||||
/// справочника api.tnsu.kz (если резолвился), используемый только для дедупа
|
||||
/// (см. IdentityKeyOf/CollapseByExecutorIdentity). Наружу отдаётся только Dto.
|
||||
/// </summary>
|
||||
private sealed record ResolvedStepRow(ResolvedRouteStepDto Dto, Guid? ExecutorId);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class HttpTnsuPortalClient(
|
||||
return [];
|
||||
|
||||
return employees
|
||||
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position))
|
||||
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position, e.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ public sealed class HttpTnsuPortalClient(
|
||||
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||
[property: JsonPropertyName("position")] string? Position,
|
||||
[property: JsonPropertyName("fio")] string? Fio);
|
||||
[property: JsonPropertyName("fio")] string? Fio,
|
||||
[property: JsonPropertyName("id")] Guid? Id = null);
|
||||
|
||||
private sealed record ManagerResponse(
|
||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||
|
||||
@@ -2,7 +2,16 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
|
||||
/// <param name="Id">
|
||||
/// Guid сотрудника в справочнике api.tnsu.kz — один и тот же человек может иметь
|
||||
/// несколько записей (см. массив в /Employee/ByEmail) и даже несколько разных
|
||||
/// email, но Id у него один. Используется как надёжный ключ дедупликации
|
||||
/// исполнителей при резолве маршрута (см. TemplateRouteService) — email для
|
||||
/// этого не годится: один человек может фигурировать под разными адресами.
|
||||
/// Nullable, потому что старые вызовы могли не запросить это поле явно (сейчас
|
||||
/// всегда приходит) — на этот случай дедуп по Id тихо откатывается на email.
|
||||
/// </param>
|
||||
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position, Guid? Id = null);
|
||||
|
||||
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
||||
|
||||
|
||||
+64
@@ -900,6 +900,70 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[0].Warning.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв: один и тот же человек под РАЗНЫМИ email (общий Id в справочнике) в CompanyDirectors схлопывается в одну строку")]
|
||||
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDifferentEmailsSameId_CollapsesToOneRow()
|
||||
{
|
||||
// Arrange — в справочнике api.tnsu.kz это один человек (один Guid Id), но у
|
||||
// него разные email в разных компаниях. Дедуп по email это бы не поймал —
|
||||
// отсюда и был реальный баг (одна и та же строка дважды на одном шаге).
|
||||
var personId = Guid.NewGuid();
|
||||
var portalClient = new Mock<ITnsuPortalClient>();
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByEmailAsync("someone@tnsu.kz", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
||||
{
|
||||
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
|
||||
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности")
|
||||
});
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu.email@tnsu.kz", "Общий директор"));
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EmployeeLookupResult(true, "director.stglobal.email@tnsu.kz", "Общий директор"));
|
||||
// Оба email в справочнике резолвятся в ОДНОГО и того же человека (общий Id) —
|
||||
// как в реальном порталe, где у сотрудника может быть несколько адресов.
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByEmailAsync("director.tansu.email@tnsu.kz", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
||||
{
|
||||
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Директор", personId)
|
||||
});
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByEmailAsync("director.stglobal.email@tnsu.kz", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
||||
{
|
||||
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Генеральный директор", personId)
|
||||
});
|
||||
|
||||
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 same person different emails request", "/company-directors-same-person-different-emails", 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.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action
|
||||
var resolved = await routeService.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert — дедуп по Id сотрудника, а не по email: одна строка, а не две.
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors);
|
||||
resolved.Value.Steps[0].Warning.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors")]
|
||||
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user