2b8fe77fac
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
635 lines
40 KiB
C#
635 lines
40 KiB
C#
using System.Globalization;
|
||
using FluentResults;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||
|
||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||
|
||
public sealed class TemplateRouteService(
|
||
ApprovalRouteDbContext db,
|
||
ITnsuPortalClient portalClient,
|
||
ICurrentUser currentUser,
|
||
IOptions<TnsuPortalOptions> portalOptions,
|
||
ILogger<TemplateRouteService> logger)
|
||
{
|
||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
||
{
|
||
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
||
if (requestTypeId is { } id)
|
||
query = query.Where(x => x.RequestTypeId == id);
|
||
|
||
var items = await query
|
||
.Select(x => new TemplateRouteDto(
|
||
x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
||
}
|
||
|
||
public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
|
||
{
|
||
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
|
||
return entity is null
|
||
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
|
||
: Result.Ok(ToDto(entity));
|
||
}
|
||
|
||
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||
{
|
||
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
||
if (!requestTypeExists)
|
||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
||
|
||
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
||
if (duplicate)
|
||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||
|
||
var entity = new TemplateRoute
|
||
{
|
||
RequestTypeId = r.RequestTypeId,
|
||
DepartmentId = r.DepartmentId,
|
||
CompanyId = r.CompanyId,
|
||
ByProject = r.ByProject,
|
||
RequiresControl = r.RequiresControl
|
||
};
|
||
db.TemplateRoutes.Add(entity);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email);
|
||
|
||
return Result.Ok(ToDto(entity));
|
||
}
|
||
|
||
public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||
{
|
||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (entity is null)
|
||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||
|
||
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
||
if (duplicate)
|
||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||
|
||
entity.DepartmentId = r.DepartmentId;
|
||
entity.CompanyId = r.CompanyId;
|
||
entity.ByProject = r.ByProject;
|
||
entity.RequiresControl = r.RequiresControl;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||
|
||
return Result.Ok(ToDto(entity));
|
||
}
|
||
|
||
public async Task<Result<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken)
|
||
{
|
||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (entity is null)
|
||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||
|
||
var departmentId = r.DepartmentId ?? entity.DepartmentId;
|
||
var companyId = r.CompanyId ?? entity.CompanyId;
|
||
var byProject = r.ByProject ?? entity.ByProject;
|
||
|
||
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
|
||
if (duplicate)
|
||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||
|
||
entity.DepartmentId = departmentId;
|
||
entity.CompanyId = companyId;
|
||
entity.ByProject = byProject;
|
||
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||
|
||
return Result.Ok(ToDto(entity));
|
||
}
|
||
|
||
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||
{
|
||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (entity is null)
|
||
return Result.Fail(new NotFoundError("Маршрут", id));
|
||
|
||
db.TemplateRoutes.Remove(entity);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email);
|
||
|
||
return Result.Ok();
|
||
}
|
||
|
||
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
|
||
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
|
||
{
|
||
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
|
||
if (string.IsNullOrWhiteSpace(email))
|
||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
||
|
||
// Сам тип заявки, а не только факт его существования: из него берутся
|
||
// значения SLA по умолчанию для шагов (см. ResolveStepAsync).
|
||
var requestType = await db.RequestTypes.AsNoTracking()
|
||
.FirstOrDefaultAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||
if (requestType is null)
|
||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||
|
||
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
|
||
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
|
||
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
|
||
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
|
||
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
|
||
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||
if (employees.Count == 0)
|
||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||
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;
|
||
|
||
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
|
||
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
|
||
|
||
if (route is null)
|
||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
|
||
request.RequestTypeId));
|
||
|
||
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
|
||
if (request.ProjectId is { } projectId)
|
||
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||
|
||
var fieldValues = (request.FieldValues ?? [])
|
||
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
|
||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||
|
||
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
|
||
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
|
||
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
|
||
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
|
||
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
|
||
// того, кто отправил заявку). Это свободный текст, поэтому определяем
|
||
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
|
||
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
|
||
fieldValues.TryGetValue(PositionField, out var formPosition);
|
||
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
|
||
|
||
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
|
||
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
|
||
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
|
||
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
|
||
// руководящую, — это осознанный «нет», предупреждать не о чем.
|
||
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
|
||
|
||
var matchingSteps = route.Steps
|
||
.Where(s => s.Status == request.Status)
|
||
.Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||
.OrderBy(s => s.OrderNo)
|
||
.ToList();
|
||
|
||
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
|
||
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
|
||
// (десяток+ шагов) последовательные await один за другим складываются в
|
||
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
|
||
// соответствует порядку задач, а не порядку завершения), поэтому
|
||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||
// дубликат считается первым) продолжают работать так же, как при
|
||
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
|
||
// строк результата (HeadType.CompanyDirectors — по одной на компанию
|
||
// инициатора), поэтому 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,
|
||
cancellationToken)));
|
||
|
||
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 через обычное схлопывание (см. CollapseByExecutorIdentity):
|
||
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
|
||
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
||
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
|
||
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
|
||
/// email), а не по email напрямую — один человек может фигурировать под
|
||
/// разными email в разных ветках резолва.
|
||
/// </summary>
|
||
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||
IReadOnlyList<ResolvedStepRow> steps)
|
||
{
|
||
var directorTansuKeys = steps
|
||
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
|
||
.Select(IdentityKeyOf)
|
||
.Where(k => k is not null)
|
||
.Select(k => k!)
|
||
.ToHashSet();
|
||
|
||
if (directorTansuKeys.Count == 0)
|
||
return steps;
|
||
|
||
return steps
|
||
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||
/// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает
|
||
/// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null).
|
||
/// </summary>
|
||
private async Task<TemplateRoute?> FindRouteAsync(
|
||
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
|
||
await db.TemplateRoutes.AsNoTracking()
|
||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||
.Where(x =>
|
||
x.RequestTypeId == requestTypeId &&
|
||
x.CompanyId == companyId &&
|
||
x.ByProject == byProject &&
|
||
x.DepartmentId == departmentId)
|
||
.FirstOrDefaultAsync(cancellationToken)
|
||
?? await db.TemplateRoutes.AsNoTracking()
|
||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||
.Where(x =>
|
||
x.RequestTypeId == requestTypeId &&
|
||
x.CompanyId == companyId &&
|
||
x.ByProject == byProject &&
|
||
x.DepartmentId == null)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
|
||
private Task<bool> HasDuplicateAsync(
|
||
int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
|
||
db.TemplateRoutes.AnyAsync(x =>
|
||
x.Id != excludeId &&
|
||
x.RequestTypeId == requestTypeId &&
|
||
x.DepartmentId == departmentId &&
|
||
x.CompanyId == companyId &&
|
||
x.ByProject == byProject,
|
||
cancellationToken);
|
||
|
||
/// <param name="conditionWarning">
|
||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||
/// </param>
|
||
/// <summary>
|
||
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
|
||
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
|
||
/// поэтому для 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<ResolvedStepRow>> ResolveStepAsync(
|
||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||
string? conditionWarning,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||
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);
|
||
var resolvedEmployee = resolvedEmployees.FirstOrDefault();
|
||
resolvedPosition = resolvedEmployee?.Position;
|
||
resolvedId = resolvedEmployee?.Id;
|
||
}
|
||
|
||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||
|
||
var dto = 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,
|
||
step.AutoApprove);
|
||
|
||
return new ResolvedStepRow(dto, resolvedId);
|
||
}
|
||
|
||
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
|
||
// и до общего "одна строка на шаг" пути ниже.
|
||
if (step.HeadType == HeadType.CompanyDirectors)
|
||
{
|
||
if (initiatorCompanies.Count == 0)
|
||
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||
|
||
var dtos = new List<ResolvedStepRow>();
|
||
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? warning = null;
|
||
|
||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||
{
|
||
resolvedEmail = step.ExecutorEmail;
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||
{
|
||
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||
resolvedEmail = employeeByPosition.Email;
|
||
if (!employeeByPosition.Found)
|
||
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
|
||
else if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||
warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала.";
|
||
}
|
||
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||
{
|
||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
||
}
|
||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||
// ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при
|
||
// чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя».
|
||
// Поэтому у него отдельная ветка, а не место в switch чуть ниже.
|
||
else if (step.HeadType == HeadType.DepartmentHead)
|
||
{
|
||
if (departmentId is null)
|
||
{
|
||
resolvedEmail = null;
|
||
warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе.";
|
||
}
|
||
else
|
||
{
|
||
resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken);
|
||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||
warning = $"Не найден руководитель отдела с id «{departmentId}».";
|
||
}
|
||
}
|
||
else if (step.HeadType is not (null or HeadType.None))
|
||
{
|
||
resolvedEmail = step.HeadType switch
|
||
{
|
||
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||
_ => null
|
||
};
|
||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||
warning = $"Не найден руководитель типа «{step.HeadType}».";
|
||
}
|
||
else
|
||
{
|
||
resolvedEmail = null;
|
||
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||
}
|
||
|
||
return [await BuildDtoAsync(resolvedEmail, warning)];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
|
||
/// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
|
||
/// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
|
||
/// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
|
||
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
|
||
/// </summary>
|
||
private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||
{
|
||
if (step.Conditions.Count == 0)
|
||
return true;
|
||
|
||
return step.ConditionsLogic == ConditionsLogic.Or
|
||
? step.Conditions.Any(c => MatchesCondition(c, fieldValues))
|
||
: step.Conditions.All(c => MatchesCondition(c, fieldValues));
|
||
}
|
||
|
||
private static bool MatchesCondition(TemplateRouteStepCondition condition, IReadOnlyDictionary<string, string> fieldValues)
|
||
{
|
||
if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||
return false;
|
||
|
||
var expected = condition.Value;
|
||
return condition.Operation switch
|
||
{
|
||
ComparisonOperator.Equal => IsEqual(actual, expected),
|
||
ComparisonOperator.NotEqual => !IsEqual(actual, expected),
|
||
ComparisonOperator.Contains => Contains(actual, expected),
|
||
ComparisonOperator.NotContains => !Contains(actual, expected),
|
||
ComparisonOperator.GreaterThan =>
|
||
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
|
||
ComparisonOperator.LessThan =>
|
||
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
|
||
_ => true
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа —
|
||
/// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же.
|
||
/// </summary>
|
||
private static bool IsEqual(string actual, string? expected) =>
|
||
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected);
|
||
|
||
/// <summary>«Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей.</summary>
|
||
private static bool Contains(string actual, string? expected) =>
|
||
!string.IsNullOrEmpty(expected) &&
|
||
actual.Contains(expected.Trim(), StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static bool TryParseNumber(string? value, out double number) =>
|
||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||
|
||
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
|
||
private const string PositionField = "Position";
|
||
|
||
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
|
||
private const string ManagerConditionField = "IsManager";
|
||
|
||
private const string PositionMissingWarning =
|
||
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
|
||
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||
|
||
/// <summary>
|
||
/// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
|
||
/// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
|
||
/// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
|
||
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
|
||
/// </summary>
|
||
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||
positionMissing &&
|
||
step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase));
|
||
|
||
/// <summary>
|
||
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
|
||
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
|
||
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
|
||
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
|
||
/// специалист, а не должность с подчинёнными.
|
||
/// </summary>
|
||
private static readonly string[] ManagerPositionKeywords =
|
||
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
|
||
|
||
private static bool IsManagerPosition(string? position) =>
|
||
!string.IsNullOrWhiteSpace(position) &&
|
||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||
|
||
/// <summary>
|
||
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
|
||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
|
||
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
|
||
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
|
||
/// дедуп по email эту пару не поймал бы.
|
||
/// </summary>
|
||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
|
||
{
|
||
var indexByKey = new Dictionary<string, int>();
|
||
var result = new List<ResolvedRouteStepDto>();
|
||
|
||
foreach (var row in steps)
|
||
{
|
||
var key = IdentityKeyOf(row);
|
||
if (key is null)
|
||
{
|
||
result.Add(row.Dto);
|
||
continue;
|
||
}
|
||
|
||
if (!indexByKey.TryGetValue(key, out var index))
|
||
{
|
||
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 };
|
||
}
|
||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||
{
|
||
var conflictWarning =
|
||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
|
||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||
result[index] = existing with { Warning = combinedWarning };
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
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);
|
||
}
|