10c3057eff
Раньше шаг маршрута мог нести ровно одно условие (field_name/operation/ value). Теперь условий может быть сколько угодно, а их объединение задаётся явно — ConditionsLogic.And (все условия) или .Or (любое из условий). Бэкенд: - Новая сущность TemplateRouteStepCondition (дочерняя таблица template_route_step_conditions, FK на шаг, cascade delete) вместо трёх колонок на TemplateRouteStep. Миграция AddMultipleStepConditions переносит существующие условия шагов в новую таблицу перед удалением старых колонок, чтобы не потерять уже настроенные в деве/проде условия. - TemplateRouteService.MatchesConditions: несколько условий шага объединяются по ConditionsLogic (Any/All); HasUnevaluatableManagerCondition проверяет условие на IsManager в списке условий, а не единственное поле. - TemplateRouteStepService: дубликаты шагов теперь сравниваются по всему набору условий (без учёта порядка) и, если условий два и более, по логике их объединения — сравнение в памяти, а не в SQL, кандидатов на дубликат в пределах маршрута всегда мало. - Валидация условия: раньше «все три поля или ни одного», теперь — в каждом элементе списка все три поля обязательны. Фронтенд: - RouteStepFormModal: условие — динамический список строк (добавить/удалить), у каждой строки свой набор допустимых операторов по типу колонки; селектор И/ИЛИ появляется, когда условий два и больше. - describeStepCondition в таблице шагов склеивает все условия через « И »/« ИЛИ ». Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
453 lines
27 KiB
C#
453 lines
27 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));
|
||
|
||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||
if (employee is null)
|
||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||
|
||
var byProject = request.ProjectId is not null;
|
||
|
||
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
|
||
?? await FindRouteAsync(request.RequestTypeId, null, employee.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.
|
||
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||
cancellationToken)));
|
||
|
||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||
|
||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||
}
|
||
|
||
/// <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>
|
||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||
string? conditionWarning,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
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 = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||
}
|
||
|
||
string? resolvedPosition = null;
|
||
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);
|
||
}
|
||
|
||
/// <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));
|
||
|
||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||
{
|
||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var result = new List<ResolvedRouteStepDto>();
|
||
|
||
foreach (var step in steps)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||
continue;
|
||
|
||
result.Add(step);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||
}
|