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

375 lines
23 KiB
C#

using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class TemplateRouteStepService(
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<TemplateRouteStepService> logger)
{
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{
var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo)
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
}
/// <summary>
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
/// попадают — это не привязка к конкретному человеку.
/// </summary>
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
string executorEmail, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(executorEmail))
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
}
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
return entity is null
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
RouteId = route.Id,
OrderNo = r.OrderNo,
ExecutorEmail = r.ExecutorEmail,
Position = r.Position,
ProjectRole = r.ProjectRole,
HeadType = r.HeadType,
Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
ConditionsLogic = r.ConditionsLogic,
Conditions = ToEntities(r.Conditions),
SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason,
AutoApprove = r.AutoApprove
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
entity.Position = r.Position;
entity.ProjectRole = r.ProjectRole;
entity.HeadType = r.HeadType;
entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.ConditionsLogic = r.ConditionsLogic;
ReplaceConditions(entity, r.Conditions);
entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason;
entity.AutoApprove = r.AutoApprove;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
var position = r.Position ?? entity.Position;
var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status;
var conditions = r.Conditions ?? ToDtos(entity.Conditions);
var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.AreConditionsValid(conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status,
conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
entity.Position = position;
entity.ProjectRole = projectRole;
entity.HeadType = headType;
entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
entity.ConditionsLogic = conditionsLogic;
if (r.Conditions is not null)
ReplaceConditions(entity, r.Conditions);
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
// наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null)
return Result.Fail(new NotFoundError("Шаг маршрута", id));
db.TemplateRouteSteps.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email);
return Result.Ok();
}
private static bool RequiresList(IReadOnlyList<RouteStepConditionDto>? conditions) => conditions is { Count: > 0 };
private static List<TemplateRouteStepCondition> ToEntities(IReadOnlyList<RouteStepConditionDto>? conditions) =>
(conditions ?? []).Select((c, i) => new TemplateRouteStepCondition
{
OrderNo = i,
FieldName = c.FieldName,
Operation = c.Operation,
Value = c.Value
}).ToList();
private static void ReplaceConditions(TemplateRouteStep entity, IReadOnlyList<RouteStepConditionDto>? conditions)
{
entity.Conditions.Clear();
entity.Conditions.AddRange(ToEntities(conditions));
}
private static List<RouteStepConditionDto> ToDtos(IEnumerable<TemplateRouteStepCondition> conditions) =>
conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList();
/// <summary>
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
/// условии появления (набор условий + логика их объединения): шаги с разными
/// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они
/// попасть могут, а в один резолв — нет. Сравнение условий — в памяти
/// (см. <see cref="ConditionsEqual"/>): кандидатов на дубликат в пределах
/// одного маршрута всегда мало, а сравнение списка условий целиком плохо
/// транслируется в SQL.
/// </summary>
private async Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
int? excludeId, CancellationToken cancellationToken)
{
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
.Where(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.ExecutorEmail == executorEmail &&
x.Position == position &&
x.ProjectRole == projectRole &&
x.HeadType == headType &&
x.Status == status)
.ToListAsync(cancellationToken);
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
}
/// <summary>
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
/// </summary>
private async Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
int? excludeId, CancellationToken cancellationToken)
{
if (headType is null or HeadType.None)
return false;
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
.Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType)
.ToListAsync(cancellationToken);
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
}
/// <summary>
/// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок
/// влияет только на отображение в форме, не на вычисление) и, если условий два и
/// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не
/// влияет, поэтому не сравнивается.
/// </summary>
private static bool ConditionsEqual(
IReadOnlyList<RouteStepConditionDto> a, ConditionsLogic aLogic,
IReadOnlyList<RouteStepConditionDto>? b, ConditionsLogic bLogic)
{
var bList = b ?? [];
if (a.Count != bList.Count)
return false;
if (a.Count > 1 && aLogic != bLogic)
return false;
static IEnumerable<(string FieldName, ComparisonOperator Operation, string Value)> Normalize(
IReadOnlyList<RouteStepConditionDto> c) =>
c.Select(x => (x.FieldName, x.Operation, x.Value))
.OrderBy(x => x.FieldName, StringComparer.Ordinal)
.ThenBy(x => x.Operation)
.ThenBy(x => x.Value, StringComparer.Ordinal);
return Normalize(a).SequenceEqual(Normalize(bList));
}
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{
string? warning = null;
if (route.ByProject)
{
var hasProjectRoleStep = await db.TemplateRouteSteps
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
if (!hasProjectRoleStep)
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
}
return ToDto(entity) with { Warning = warning };
}
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
}