Правки #18

Merged
gorbenko.pavel merged 6 commits from fixes into main 2026-08-26 07:08:16 +00:00
4 changed files with 196 additions and 39 deletions
@@ -221,39 +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 в маршруте обычно идёт последним /// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
/// (после всех CompanyDirectors), поэтому удаление дубликата из 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();
} }
@@ -336,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,
@@ -346,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,
@@ -368,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 — единственная ветка, которая резолвится в несколько строк
@@ -378,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);
@@ -552,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 };
@@ -598,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);
@@ -849,12 +849,127 @@ 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, а не конфликтует")] [Fact(DisplayName = "Резолв: один и тот же директор двух разных компаний инициатора в CompanyDirectors схлопывается в одну строку")]
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDirectorOfTwoCompanies_CollapsesToOneRow()
{
// Arrange — инициатор числится в двух компаниях, и директор у них ОДИН и тот же
// человек (например, холдинговая структура) — оба GetEmployeeByPositionAsync
// возвращают один и тот же 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, "ТОО СТ Global", "Начальник отдела службы безопасности")
});
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@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("CompanyDirectors same person request", "/company-directors-same-person", 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 — один человек не должен согласовывать одну и ту же заявку дважды
// только из-за того, что он директор двух компаний инициатора одновременно.
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("same.director@tnsu.kz");
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors);
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() public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
{ {
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
// и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — // и Директор Tansu (из шага DirectorTansu, OrderNo 10) — один и тот же человек
// один и тот же человек с одним и тем же email. // с одним и тем же email.
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))