using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; 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 logger) { public async Task>> 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>(items.Select(ToDto).ToList()); } /// /// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для /// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen): /// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не /// попадают — это не привязка к конкретному человеку. /// public async Task>> ListByExecutorEmailAsync( string executorEmail, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(executorEmail)) return Result.Ok>(Array.Empty()); 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>(items.Select(ToDto).ToList()); } public async Task> 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(new NotFoundError("Шаг маршрута", id)) : Result.Ok(ToDto(entity)); } public async Task> 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(new NotFoundError("Маршрут", routeId)); if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( "В каждом условии (field_name, operation, value) все три поля обязательны.")); if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(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(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); 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> 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(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(new NotFoundError("Шаг маршрута", id)); if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( "В каждом условии (field_name, operation, value) все три поля обязательны.")); if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(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(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); 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> 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(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(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(new ValidationError("Статус обязателен: Approval или Execution.")); if (!route.ByProject && projectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1) return Result.Fail(new ValidationError( "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); if (!TemplateRouteStepFields.AreConditionsValid(conditions)) return Result.Fail(new ValidationError( "В каждом условии (field_name, operation, value) все три поля обязательны.")); if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(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(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); 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 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? conditions) => conditions is { Count: > 0 }; private static List ToEntities(IReadOnlyList? 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? conditions) { entity.Conditions.Clear(); entity.Conditions.AddRange(ToEntities(conditions)); } private static List ToDtos(IEnumerable conditions) => conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList(); /// /// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом /// условии появления (набор условий + логика их объединения): шаги с разными /// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они /// попасть могут, а в один резолв — нет. Сравнение условий — в памяти /// (см. ): кандидатов на дубликат в пределах /// одного маршрута всегда мало, а сравнение списка условий целиком плохо /// транслируется в SQL. /// private async Task HasDuplicateAsync( int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, RouteStepStatus status, IReadOnlyList? 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)); } /// /// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors) /// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости /// инициатора, единственный departmentId запроса (для DepartmentHead, см. /// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для /// CompanyDirectors) не оставляют места для выбора. Для большинства значений это /// один конкретный человек; для CompanyDirectors — один и тот же набор людей /// (директор каждой компании инициатора), но тоже всегда один и тот же для /// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым /// HeadType всегда резолвились бы одинаково — например, «два разных директора /// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync /// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения /// HeadType — вне зависимости от статуса или остальных полей шага. Исключение — /// условие появления шага: при разных условиях в конкретный резолв попадёт только /// один из шагов, так что дублирования исполнителей в маршруте не окажется. /// private async Task HasDuplicateHeadTypeAsync( int routeId, HeadType? headType, IReadOnlyList? 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)); } /// /// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок /// влияет только на отображение в форме, не на вычисление) и, если условий два и /// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не /// влияет, поэтому не сравнивается. /// private static bool ConditionsEqual( IReadOnlyList a, ConditionsLogic aLogic, IReadOnlyList? 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 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 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); }