Дедуп исполнителей резолва — по 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,
|
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||||
cancellationToken)));
|
cancellationToken)));
|
||||||
|
|
||||||
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList());
|
var flattenedSteps = stepResults.SelectMany(x => x).ToList();
|
||||||
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps);
|
var withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
|
||||||
|
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
|
||||||
|
|
||||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
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>
|
/// <summary>
|
||||||
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||||
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||||
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||||
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||||
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
|
/// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
|
||||||
/// тот считает разные PositionCode у одного email конфликтом и пишет
|
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
|
||||||
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
||||||
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем.
|
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
|
||||||
|
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
|
||||||
|
/// email), а не по email напрямую — один человек может фигурировать под
|
||||||
|
/// разными email в разных ветках резолва.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||||
IReadOnlyList<ResolvedRouteStepDto> steps)
|
IReadOnlyList<ResolvedStepRow> steps)
|
||||||
{
|
{
|
||||||
var directorTansuEmails = steps
|
var directorTansuKeys = steps
|
||||||
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
|
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
|
||||||
.Select(s => s.ExecutorEmail!)
|
.Select(IdentityKeyOf)
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.Where(k => k is not null)
|
||||||
|
.Select(k => k!)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
if (directorTansuEmails.Count == 0)
|
if (directorTansuKeys.Count == 0)
|
||||||
return steps;
|
return steps;
|
||||||
|
|
||||||
return steps
|
return steps
|
||||||
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|
||||||
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|
|
||||||
|| !directorTansuEmails.Contains(s.ExecutorEmail))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +351,7 @@ public sealed class TemplateRouteService(
|
|||||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||||
/// </param>
|
/// </param>
|
||||||
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
|
private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
|
||||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||||
string? conditionWarning,
|
string? conditionWarning,
|
||||||
@@ -344,19 +361,22 @@ public sealed class TemplateRouteService(
|
|||||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||||
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
|
async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||||
{
|
{
|
||||||
string? resolvedPosition = null;
|
string? resolvedPosition = null;
|
||||||
|
Guid? resolvedId = null;
|
||||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
{
|
{
|
||||||
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
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 }
|
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
|
|
||||||
return new ResolvedRouteStepDto(
|
var dto = new ResolvedRouteStepDto(
|
||||||
step.Id,
|
step.Id,
|
||||||
step.OrderNo,
|
step.OrderNo,
|
||||||
resolvedEmail,
|
resolvedEmail,
|
||||||
@@ -366,6 +386,8 @@ public sealed class TemplateRouteService(
|
|||||||
step.SlaHours ?? requestType.SlaHours,
|
step.SlaHours ?? requestType.SlaHours,
|
||||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
||||||
|
|
||||||
|
return new ResolvedStepRow(dto, resolvedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||||
@@ -376,7 +398,7 @@ public sealed class TemplateRouteService(
|
|||||||
if (initiatorCompanies.Count == 0)
|
if (initiatorCompanies.Count == 0)
|
||||||
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||||
|
|
||||||
var dtos = new List<ResolvedRouteStepDto>();
|
var dtos = new List<ResolvedStepRow>();
|
||||||
foreach (var company in initiatorCompanies)
|
foreach (var company in initiatorCompanies)
|
||||||
{
|
{
|
||||||
var directorPosition = DirectorPositionFor(company);
|
var directorPosition = DirectorPositionFor(company);
|
||||||
@@ -550,33 +572,38 @@ public sealed class TemplateRouteService(
|
|||||||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
|
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
|
||||||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||||
/// хуже, чем показать чуть неточный PositionCode с пояснением.
|
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
|
||||||
|
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
|
||||||
|
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
|
||||||
|
/// дедуп по email эту пару не поймал бы.
|
||||||
/// </summary>
|
/// </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>();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
|
if (!indexByKey.TryGetValue(key, out var index))
|
||||||
{
|
{
|
||||||
indexByEmail[step.ExecutorEmail] = result.Count;
|
indexByKey[key] = result.Count;
|
||||||
result.Add(step);
|
result.Add(row.Dto);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = result[index];
|
var existing = result[index];
|
||||||
|
var step = row.Dto;
|
||||||
if (existing.PositionCode is null && step.PositionCode is not null)
|
if (existing.PositionCode is null && step.PositionCode is not null)
|
||||||
{
|
{
|
||||||
result[index] = existing with { PositionCode = step.PositionCode };
|
result[index] = existing with { PositionCode = step.PositionCode };
|
||||||
@@ -596,4 +623,11 @@ public sealed class TemplateRouteService(
|
|||||||
|
|
||||||
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
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 [];
|
||||||
|
|
||||||
return employees
|
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,8 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||||
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||||
[property: JsonPropertyName("position")] string? Position,
|
[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(
|
private sealed record ManagerResponse(
|
||||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
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);
|
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();
|
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")]
|
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors")]
|
||||||
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
|
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user