GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors
/Employee/ByEmail на портале обновился: сотрудник может числиться в нескольких компаниях, теперь отдаётся массив записей (одна на компанию). ITnsuPortalClient.GetEmployeeByEmailAsync возвращает IReadOnlyList вместо одного объекта; вызывающие места используют первую запись (как и раньше при одиночном ответе), кроме нового HeadType.CompanyDirectors — там нужны все компании. HeadType.CompanyDirectors резолвится в директора КАЖДОЙ компании инициатора — по одной строке результата на компанию, все с одним и тем же OrderNo шага (они и так один шаг, то есть параллельны по построению). Директор ищется через GetEmployeeByPositionAsync(position, company) с захардкоженной должностью «Директор» по умолчанию и явными исключениями для компаний, где директора называют иначе (ТОО KazPromService — «Главный механик», ТОО СТ Global — «Генеральный директор»). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+14
-12
@@ -294,18 +294,20 @@ public sealed class TemplateRouteStepService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
|
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
|
||||||
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
|
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
|
||||||
/// DepartmentHead, единственный departmentId запроса (см.
|
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
|
||||||
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
|
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
|
||||||
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
|
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
|
||||||
/// в одного и того же (или неотличимо какого) руководителя — например, «два
|
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
|
||||||
/// разных директора одной и той же компании» просто не существуют. В отличие
|
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
|
||||||
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
|
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
|
||||||
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
|
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
|
||||||
/// полей шага. Исключение — условие появления шага: при разных условиях
|
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
|
||||||
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
|
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
|
||||||
/// руководителя дважды в маршруте не окажется.
|
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
|
||||||
|
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
|
||||||
|
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<bool> HasDuplicateHeadTypeAsync(
|
private async Task<bool> HasDuplicateHeadTypeAsync(
|
||||||
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||||
|
|||||||
@@ -142,18 +142,30 @@ public sealed class TemplateRouteService(
|
|||||||
if (requestType is null)
|
if (requestType is null)
|
||||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
|
||||||
if (employee is null)
|
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
|
||||||
|
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
|
||||||
|
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
|
||||||
|
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
|
||||||
|
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||||
|
if (employees.Count == 0)
|
||||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||||
|
|
||||||
|
var primaryEmployee = employees[0];
|
||||||
|
var initiatorCompanies = employees
|
||||||
|
.Select(e => e.Company)
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var byProject = request.ProjectId is not null;
|
var byProject = request.ProjectId is not null;
|
||||||
|
|
||||||
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||||||
// своего маршрута нет, берём запасной маршрут без указанной компании
|
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||||||
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||||||
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
|
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
|
||||||
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
|
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
|
||||||
|
|
||||||
if (route is null)
|
if (route is null)
|
||||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||||
@@ -200,13 +212,16 @@ public sealed class TemplateRouteService(
|
|||||||
// соответствует порядку задач, а не порядку завершения), поэтому
|
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||||
// дубликат считается первым) продолжают работать так же, как при
|
// дубликат считается первым) продолжают работать так же, как при
|
||||||
// последовательном foreach.
|
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
|
||||||
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
// строк результата (HeadType.CompanyDirectors — по одной на компанию
|
||||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
// инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
|
||||||
|
// и результат нужно сначала сгладить (SelectMany).
|
||||||
|
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||||
|
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
|
||||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||||
cancellationToken)));
|
cancellationToken)));
|
||||||
|
|
||||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
var collapsedSteps = CollapseByExecutorEmail(stepResults.SelectMany(x => x).ToList());
|
||||||
|
|
||||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||||
}
|
}
|
||||||
@@ -252,12 +267,104 @@ public sealed class TemplateRouteService(
|
|||||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||||
/// </param>
|
/// </param>
|
||||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
/// <summary>
|
||||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
|
||||||
|
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
|
||||||
|
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
|
||||||
|
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
|
||||||
|
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
|
||||||
|
/// отдельного источника правды для него в системе нет.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["ТОО KazPromService"] = "Главный механик",
|
||||||
|
["ТОО СТ Global"] = "Генеральный директор"
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string DefaultDirectorPosition = "Директор";
|
||||||
|
|
||||||
|
private static string DirectorPositionFor(string company) =>
|
||||||
|
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
|
||||||
|
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
|
||||||
|
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
|
||||||
|
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
|
||||||
|
/// им одинаковый порядок вручную.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="initiatorCompanies">
|
||||||
|
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
|
||||||
|
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
|
||||||
|
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="conditionWarning">
|
||||||
|
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||||
|
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||||
|
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||||
|
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||||
|
/// </param>
|
||||||
|
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
|
||||||
|
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,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||||
|
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||||
|
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||||
|
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||||
|
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||||
|
{
|
||||||
|
string? resolvedPosition = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||||
|
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||||
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
|
|
||||||
|
return new ResolvedRouteStepDto(
|
||||||
|
step.Id,
|
||||||
|
step.OrderNo,
|
||||||
|
resolvedEmail,
|
||||||
|
resolvedPosition,
|
||||||
|
step.AllowReassignToOtherApprovers,
|
||||||
|
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||||
|
step.SlaHours ?? requestType.SlaHours,
|
||||||
|
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||||
|
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||||
|
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
|
||||||
|
// и до общего "одна строка на шаг" пути ниже.
|
||||||
|
if (step.HeadType == HeadType.CompanyDirectors)
|
||||||
|
{
|
||||||
|
if (initiatorCompanies.Count == 0)
|
||||||
|
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||||
|
|
||||||
|
var dtos = new List<ResolvedRouteStepDto>();
|
||||||
|
foreach (var company in initiatorCompanies)
|
||||||
|
{
|
||||||
|
var directorPosition = DirectorPositionFor(company);
|
||||||
|
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
|
||||||
|
var directorEmail = employeeByPosition.Email;
|
||||||
|
string? directorWarning = null;
|
||||||
|
if (!employeeByPosition.Found)
|
||||||
|
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
|
||||||
|
else if (string.IsNullOrWhiteSpace(directorEmail))
|
||||||
|
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
|
||||||
|
|
||||||
|
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dtos;
|
||||||
|
}
|
||||||
|
|
||||||
string? resolvedEmail;
|
string? resolvedEmail;
|
||||||
string? warning = null;
|
string? warning = null;
|
||||||
|
|
||||||
@@ -318,30 +425,7 @@ public sealed class TemplateRouteService(
|
|||||||
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||||||
}
|
}
|
||||||
|
|
||||||
string? resolvedPosition = null;
|
return [await BuildDtoAsync(resolvedEmail, warning)];
|
||||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
|
||||||
{
|
|
||||||
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
|
||||||
resolvedPosition = resolvedEmployee?.Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
|
||||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
|
||||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
|
||||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
|
||||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
|
||||||
|
|
||||||
return new ResolvedRouteStepDto(
|
|
||||||
step.Id,
|
|
||||||
step.OrderNo,
|
|
||||||
resolvedEmail,
|
|
||||||
resolvedPosition,
|
|
||||||
step.AllowReassignToOtherApprovers,
|
|
||||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
|
||||||
step.SlaHours ?? requestType.SlaHours,
|
|
||||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
|
||||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -15,5 +15,15 @@ public enum HeadType
|
|||||||
/// подчинённости самого инициатора. В маршруте не может быть больше одного
|
/// подчинённости самого инициатора. В маршруте не может быть больше одного
|
||||||
/// шага с этим типом (см. TemplateRouteStepService).
|
/// шага с этим типом (см. TemplateRouteStepService).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
DepartmentHead
|
DepartmentHead,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может
|
||||||
|
/// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает
|
||||||
|
/// массив). В отличие от остальных значений этого enum'а, один такой шаг
|
||||||
|
/// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию
|
||||||
|
/// инициатора, все с одним и тем же OrderNo (то есть параллельно, см.
|
||||||
|
/// TemplateRouteService.ResolveStepAsync).
|
||||||
|
/// </summary>
|
||||||
|
CompanyDirectors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"emp:email:{email.ToLowerInvariant()}",
|
$"emp:email:{email.ToLowerInvariant()}",
|
||||||
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
|
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var employee = await GetAsync<EmployeeResponse>(
|
var employees = await GetAsync<List<EmployeeResponse>>(
|
||||||
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
||||||
if (employee is null)
|
if (employees is null)
|
||||||
return null;
|
return [];
|
||||||
|
|
||||||
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
|
return employees
|
||||||
|
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
|||||||
|
|
||||||
public interface ITnsuPortalClient
|
public interface ITnsuPortalClient
|
||||||
{
|
{
|
||||||
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
/// <summary>
|
||||||
|
/// Все записи сотрудника по email — /Employee/ByEmail отдаёт массив: сотрудник может
|
||||||
|
/// числиться в нескольких компаниях одновременно (один Id/Fio/Email, разные Company/
|
||||||
|
/// CompanyId/Section/DepartmentId/Position на каждую). Пусто, если не найден вовсе.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+56
-7
@@ -27,7 +27,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
||||||
@@ -267,7 +267,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync("dup@tnsu.kz");
|
.ReturnsAsync("dup@tnsu.kz");
|
||||||
@@ -347,7 +347,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync("head-of-department@tnsu.kz");
|
.ReturnsAsync("head-of-department@tnsu.kz");
|
||||||
@@ -387,7 +387,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((string?)null);
|
.ReturnsAsync((string?)null);
|
||||||
@@ -638,7 +638,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
|
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
|
||||||
@@ -677,7 +677,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position") });
|
||||||
portalClient
|
portalClient
|
||||||
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync("chief@tnsu.kz");
|
.ReturnsAsync("chief@tnsu.kz");
|
||||||
@@ -716,7 +716,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
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>()))
|
||||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position"));
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position") });
|
||||||
|
|
||||||
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
var routeService = services.GetRequiredService<TemplateRouteService>();
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
@@ -745,6 +745,55 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany()
|
||||||
|
{
|
||||||
|
// Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService)
|
||||||
|
// должность директора захардкожена как «Главный механик», а не «Директор».
|
||||||
|
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 request", "/company-directors-request", 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);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
|
resolved.Value.Steps.ShouldAllBe(s => s.OrderNo == 5);
|
||||||
|
resolved.Value.Steps.ShouldAllBe(s => s.PositionCode == HeadType.CompanyDirectors);
|
||||||
|
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
|
||||||
|
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
|
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
|
||||||
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
|
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export enum HeadType {
|
|||||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||||
DepartmentHead = 'DepartmentHead'
|
DepartmentHead = 'DepartmentHead',
|
||||||
|
// Единственное значение, которое резолвится в НЕСКОЛЬКО строк результата — по
|
||||||
|
// директору на каждую компанию инициатора (сотрудник может числиться в
|
||||||
|
// нескольких), все с одним и тем же orderNo (см. TemplateRouteService.ResolveStepAsync).
|
||||||
|
CompanyDirectors = 'CompanyDirectors'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ComparisonOperator {
|
export enum ComparisonOperator {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export const headTypeLabels: Record<HeadType, string> = {
|
|||||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||||
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
||||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
[HeadType.DepartmentHead]: 'Руководитель указанного отдела',
|
||||||
|
[HeadType.CompanyDirectors]: 'Директора компаний инициатора'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user