Files
gorbenko.pavel f3b0f0ad48
Tests / frontend-test (push) Successful in 1m39s
Tests / backend-test (push) Successful in 9m27s
Персист модалок: черновики, восстановление после F5, предупреждение при выходе (#30)
Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна.

**Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута)
- Черновик полей хранится в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5.
- Окно, открытое в момент F5, открывается снова (usePersistedOpen); закрытие сбрасывает только признак, черновик остаётся.
- Черновик стирается после успешного сохранения или кнопкой «Очистить» в подвале.

**Формы редактирования**
- Не персистятся — открываются с данными сервера.
- При выходе с несохранёнными правками (крестик, «Отмена», клик мимо) — диалог «Закрыть без сохранения?» (isDirty в FormModal).

**Устойчивость к старым данным в localStorage**
- coerceStored приводит сохранённое значение к форме текущего дефолта по полям: новое поле — дефолт, удалённое отбрасывается, значение другого типа и null заменяются дефолтом. Покрыто тестами.

**Прочее**
- Modal и ConfirmDialog рендерятся порталом в body поверх шапки SharePoint — на живой странице верх высокого окна уезжал под шапку.
- Убраны Sass-предупреждения mixed-decls в Card.module.scss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30
2026-10-19 04:46:16 +00:00

643 lines
41 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common;
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 = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
}
// 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))
{
var headLabel = StepLabels.InSentence(step.HeadType.Value);
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
// справочника сотрудников — там email не «не найден», а «не проставлен».
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
? $"Не проставлен {headLabel}."
: $"Не найден {headLabel}.";
}
}
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}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
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);
}