diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 5736e17..9b008b3 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -7,6 +7,9 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // записей, чтобы не ломать существующие позиционные вызовы. +/// Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition. +public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value); + public sealed record TemplateRouteStepDto( int Id, int OrderNo, @@ -17,9 +20,8 @@ public sealed record TemplateRouteStepDto( RouteStepStatus Status, int RouteId, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList Conditions, + ConditionsLogic ConditionsLogic, string? Warning = null, int? SlaHours = null, bool? AskOverdueReason = null); @@ -32,9 +34,8 @@ public sealed record CreateTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic ConditionsLogic = ConditionsLogic.And, int? SlaHours = null, bool? AskOverdueReason = null); @@ -46,12 +47,16 @@ public sealed record UpdateTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic ConditionsLogic = ConditionsLogic.And, int? SlaHours = null, bool? AskOverdueReason = null); +/// +/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить +/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше +/// для FieldName) — очистка условий делается через PUT с Conditions: []. +/// public sealed record PatchTemplateRouteStepRequest( int? OrderNo, string? ExecutorEmail, @@ -60,8 +65,7 @@ public sealed record PatchTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus? Status, bool? AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic? ConditionsLogic = null, int? SlaHours = null, bool? AskOverdueReason = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 65d87d4..d156d9e 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -15,14 +15,12 @@ public sealed class TemplateRouteStepService( 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) - .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, - x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); - return Result.Ok>(items); + return Result.Ok>(items.Select(ToDto).ToList()); } /// @@ -38,19 +36,18 @@ public sealed class TemplateRouteStepService( 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) - .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, - x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); - return Result.Ok>(items); + 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 @@ -68,22 +65,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); - if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, - r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken); + r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -97,9 +94,8 @@ public sealed class TemplateRouteStepService( HeadType = r.HeadType, Status = r.Status, AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, - FieldName = r.FieldName, - Operation = r.Operation, - Value = r.Value, + ConditionsLogic = r.ConditionsLogic, + Conditions = ToEntities(r.Conditions), SlaHours = r.SlaHours, AskOverdueReason = r.AskOverdueReason }; @@ -118,7 +114,8 @@ public sealed class TemplateRouteStepService( if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + 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)); @@ -126,22 +123,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); - if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, - r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken); + r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -152,9 +149,8 @@ public sealed class TemplateRouteStepService( entity.HeadType = r.HeadType; entity.Status = r.Status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; - entity.FieldName = r.FieldName; - entity.Operation = r.Operation; - entity.Value = r.Value; + entity.ConditionsLogic = r.ConditionsLogic; + ReplaceConditions(entity, r.Conditions); entity.SlaHours = r.SlaHours; entity.AskOverdueReason = r.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); @@ -171,7 +167,8 @@ public sealed class TemplateRouteStepService( if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + 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)); @@ -180,9 +177,8 @@ public sealed class TemplateRouteStepService( var projectRole = r.ProjectRole ?? entity.ProjectRole; var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; - var fieldName = r.FieldName ?? entity.FieldName; - var operation = r.Operation ?? entity.Operation; - var value = r.Value ?? entity.Value; + var conditions = r.Conditions ?? ToDtos(entity.Conditions); + var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic; if (status == RouteStepStatus.None) return Result.Fail(new ValidationError("Статус обязателен: Approval или Execution.")); @@ -195,22 +191,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); - if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) + if (!TemplateRouteStepFields.AreConditionsValid(conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, executorEmail, position, projectRole, headType, status, - fieldName, operation, value, excludeId: entity.Id, cancellationToken); + conditions, conditionsLogic, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -221,9 +217,9 @@ public sealed class TemplateRouteStepService( entity.HeadType = headType; entity.Status = status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; - entity.FieldName = fieldName; - entity.Operation = operation; - entity.Value = value; + entity.ConditionsLogic = conditionsLogic; + if (r.Conditions is not null) + ReplaceConditions(entity, r.Conditions); // Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к // наследованию SLA от типа заявки можно через PUT. entity.SlaHours = r.SlaHours ?? entity.SlaHours; @@ -249,31 +245,53 @@ public sealed class TemplateRouteStepService( return Result.Ok(); } - private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) => - TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0; + 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(); /// /// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом - /// условии появления (field_name/operation/value): шаги с разными условиями - /// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут, - /// а в один резолв — нет. + /// условии появления (набор условий + логика их объединения): шаги с разными + /// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они + /// попасть могут, а в один резолв — нет. Сравнение условий — в памяти + /// (см. ): кандидатов на дубликат в пределах + /// одного маршрута всегда мало, а сравнение списка условий целиком плохо + /// транслируется в SQL. /// - private Task HasDuplicateAsync( + private async Task HasDuplicateAsync( int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, - RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value, - int? excludeId, CancellationToken cancellationToken) => - db.TemplateRouteSteps.AnyAsync(x => - x.Id != excludeId && - x.RouteId == routeId && - x.ExecutorEmail == executorEmail && - x.Position == position && - x.ProjectRole == projectRole && - x. HeadType == headType && - x.Status == status && - x.FieldName == fieldName && - x.Operation == operation && - x.Value == value, - cancellationToken); + 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) резолвится @@ -289,21 +307,44 @@ public sealed class TemplateRouteStepService( /// в конкретный резолв попадёт только один из шагов, так что одного и того же /// руководителя дважды в маршруте не окажется. /// - private Task HasDuplicateHeadTypeAsync( - int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value, + private async Task HasDuplicateHeadTypeAsync( + int routeId, HeadType? headType, IReadOnlyList? conditions, ConditionsLogic conditionsLogic, int? excludeId, CancellationToken cancellationToken) { if (headType is null or HeadType.None) - return Task.FromResult(false); + return false; - return db.TemplateRouteSteps.AnyAsync(x => - x.Id != excludeId && - x.RouteId == routeId && - x.HeadType == headType && - x.FieldName == fieldName && - x.Operation == operation && - x.Value == value, - cancellationToken); + 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( @@ -323,5 +364,5 @@ public sealed class TemplateRouteStepService( 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, - e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason); + ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 577edf1..2a5d42e 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -12,8 +12,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); RuleFor(x => x) - .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) - .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions)) + .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны."); // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // ноль допустим — это осознанное «напоминать сразу». RuleFor(x => x.SlaHours) @@ -32,8 +32,8 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); RuleFor(x => x) - .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) - .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions)) + .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны."); // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // ноль допустим — это осознанное «напоминать сразу». RuleFor(x => x.SlaHours) @@ -51,14 +51,14 @@ internal static class TemplateRouteStepFields (projectRole is null or ProjectRole.None ? 0 : 1) + (headType is null or HeadType.None ? 0 : 1); - public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => - (string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + - (operation is null or ComparisonOperator.None ? 0 : 1) + - (string.IsNullOrWhiteSpace(value) ? 0 : 1); - - public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) - { - var count = CountConditionFilled(fieldName, operation, value); - return count == 0 || count == 3; - } + /// + /// Условие с пустым field_name/value или operation=None — половинчатое, а не + /// «выключенное» (выключается вовсе не добавляя условие в список), поэтому + /// такое отдельное условие недопустимо, даже если остальные условия шага корректны. + /// + public static bool AreConditionsValid(IReadOnlyList? conditions) => + conditions is null || conditions.All(c => + !string.IsNullOrWhiteSpace(c.FieldName) && + c.Operation != ComparisonOperator.None && + !string.IsNullOrWhiteSpace(c.Value)); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 2b907b3..c087d9f 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -189,7 +189,7 @@ public sealed class TemplateRouteService( var matchingSteps = route.Steps .Where(s => s.Status == request.Status) - .Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing)) + .Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing)) .OrderBy(s => s.OrderNo) .ToList(); @@ -220,7 +220,7 @@ public sealed class TemplateRouteService( private async Task FindRouteAsync( int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) => await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && @@ -228,7 +228,7 @@ public sealed class TemplateRouteService( x.DepartmentId == departmentId) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && @@ -344,21 +344,29 @@ public sealed class TemplateRouteService( } /// - /// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. - /// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля - /// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный - /// сценарий, и отсутствие значения означает, что условие не выполняется. + /// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий + /// применяется всегда. Несколько условий объединяются через : + /// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано + /// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы + /// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется. /// - private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary fieldValues) + private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary fieldValues) { - if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) + if (step.Conditions.Count == 0) return true; - if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) + 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 fieldValues) + { + if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) return false; - var expected = step.Value; - return step.Operation switch + var expected = condition.Value; + return condition.Operation switch { ComparisonOperator.Equal => IsEqual(actual, expected), ComparisonOperator.NotEqual => !IsEqual(actual, expected), @@ -399,14 +407,14 @@ public sealed class TemplateRouteService( "поэтому шаг включён в маршрут, хотя условие могло и не выполниться."; /// - /// Условие шага завязано на IsManager, но вычислить его не из чего: должность - /// в заявке не передана. Такой шаг попадает в результат вопреки условию и с - /// предупреждением — см. ResolveAsync. + /// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность + /// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с + /// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага: + /// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен. /// private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => positionMissing && - string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) && - step.Operation is not (null or ComparisonOperator.None); + step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase)); /// /// Должность — свободный текст без структуры (нет отдельного флага «это diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 665bd01..0478b4d 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -31,9 +31,11 @@ public class TemplateRouteStep /// public bool? AskOverdueReason { get; set; } - public string? FieldName { get; set; } - public ComparisonOperator? Operation { get; set; } - public string? Value { get; set; } + /// Как объединяются между собой — не действует, если условий меньше двух. + public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And; + + /// Условия появления шага. Пусто — шаг применяется всегда. + public List Conditions { get; set; } = []; public TemplateRoute? Route { get; set; } } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs new file mode 100644 index 0000000..2683ff2 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs @@ -0,0 +1,24 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Domain.Entities; + +/// +/// Одно условие появления шага (см. ) — +/// сравнение значения поля заявки () с +/// через . Несколько условий одного шага объединяются +/// через (И/ИЛИ). +/// +public class TemplateRouteStepCondition +{ + public int Id { get; set; } + + public int StepId { get; set; } + public TemplateRouteStep? Step { get; set; } + + /// Порядок отображения в форме — на вычисление условия не влияет. + public int OrderNo { get; set; } + + public string FieldName { get; set; } = string.Empty; + public ComparisonOperator Operation { get; set; } + public string Value { get; set; } = string.Empty; +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs new file mode 100644 index 0000000..1f37402 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs @@ -0,0 +1,14 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +/// +/// Как объединяются условия шага () +/// между собой, если их несколько. Не действует, если условие одно или их нет вовсе. +/// +public enum ConditionsLogic +{ + /// Шаг появляется, только если выполняются ВСЕ условия. + And, + + /// Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие. + Or +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index edea59c..733319c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -10,6 +10,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt public DbSet RequestListPartitions => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); + public DbSet TemplateRouteStepConditions => Set(); public DbSet Settings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -61,11 +62,19 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); - e.Property(x => x.FieldName).HasMaxLength(128); - e.Property(x => x.Operation).HasConversion().HasMaxLength(32); - e.Property(x => x.Value).HasMaxLength(256); + e.Property(x => x.ConditionsLogic).HasConversion().HasMaxLength(16).IsRequired(); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.FieldName).HasMaxLength(128).IsRequired(); + e.Property(x => x.Operation).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.Value).HasMaxLength(256).IsRequired(); + e.HasOne(x => x.Step).WithMany(x => x.Conditions).HasForeignKey(x => x.StepId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(x => new { x.StepId, x.OrderNo }); + }); } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs new file mode 100644 index 0000000..4aed11e --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs @@ -0,0 +1,366 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260825044822_AddMultipleStepConditions")] + partial class AddMultipleStepConditions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_step_conditions"); + + b.HasIndex("StepId", "OrderNo") + .HasDatabaseName("ix_template_route_step_conditions_step_id_order_no"); + + b.ToTable("template_route_step_conditions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => + { + b.Property("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("OptionName") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("ListPartitions") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Routes") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step") + .WithMany("Conditions") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("ListPartitions"); + + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Navigation("Conditions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs new file mode 100644 index 0000000..45855b7 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMultipleStepConditions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "conditions_logic", + table: "template_route_steps", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "And"); + + migrationBuilder.CreateTable( + name: "template_route_step_conditions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + step_id = table.Column(type: "integer", nullable: false), + order_no = table.Column(type: "integer", nullable: false), + field_name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + operation = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_template_route_step_conditions", x => x.id); + table.ForeignKey( + name: "fk_template_route_step_conditions_template_route_steps_step_id", + column: x => x.step_id, + principalTable: "template_route_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_template_route_step_conditions_step_id_order_no", + table: "template_route_step_conditions", + columns: new[] { "step_id", "order_no" }); + + // Существующее одиночное условие шага (field_name/operation/value) переносим + // как первое (и единственное) условие в новую таблицу — до удаления старых + // колонок, иначе уже настроенные в деве/проде условия шагов молча пропадут. + migrationBuilder.Sql( + """ + INSERT INTO template_route_step_conditions (step_id, order_no, field_name, operation, value) + SELECT id, 0, field_name, operation, value + FROM template_route_steps + WHERE field_name IS NOT NULL AND operation IS NOT NULL AND value IS NOT NULL; + """); + + migrationBuilder.DropColumn( + name: "field_name", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "operation", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "value", + table: "template_route_steps"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "field_name", + table: "template_route_steps", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "operation", + table: "template_route_steps", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "value", + table: "template_route_steps", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + // Откат теряет условия сверх первого (по order_no) — старая схема хранила + // только одно условие на шаг, второе и далее девать некуда. + migrationBuilder.Sql( + """ + UPDATE template_route_steps s + SET field_name = c.field_name, operation = c.operation, value = c.value + FROM ( + SELECT DISTINCT ON (step_id) step_id, field_name, operation, value + FROM template_route_step_conditions + ORDER BY step_id, order_no + ) c + WHERE c.step_id = s.id; + """); + + migrationBuilder.DropTable( + name: "template_route_step_conditions"); + + migrationBuilder.DropColumn( + name: "conditions_logic", + table: "template_route_steps"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 1e95a34..520d7a5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -175,26 +175,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("ask_overdue_reason"); + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + b.Property("ExecutorEmail") .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); - b.Property("FieldName") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("field_name"); - b.Property("HeadType") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); - b.Property("Operation") - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("operation"); - b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -223,11 +219,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("status"); - b.Property("Value") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("value"); - b.HasKey("Id") .HasName("pk_template_route_steps"); @@ -237,6 +228,50 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("template_route_steps", (string)null); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_step_conditions"); + + b.HasIndex("StepId", "OrderNo") + .HasDatabaseName("ix_template_route_step_conditions_step_id_order_no"); + + b.ToTable("template_route_step_conditions", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => { b.Property("OptionName") @@ -294,6 +329,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Route"); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step") + .WithMany("Conditions") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id"); + + b.Navigation("Step"); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { b.Navigation("ListPartitions"); @@ -305,6 +352,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { b.Navigation("Steps"); }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Navigation("Conditions"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index d3167d6..b7f7201 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -204,8 +204,8 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action — departmentId в запросе не передан @@ -362,7 +362,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); // Action @@ -458,11 +458,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -489,7 +489,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); // Action — fieldValues не переданы вовсе @@ -514,7 +514,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"), + new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }), CancellationToken.None); // Action @@ -548,7 +548,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", operation, expected), + new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }), CancellationToken.None); // Action @@ -562,6 +562,47 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "multi-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, + new[] + { + new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"), + new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") + }, + logic), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", amount), new ResolveFieldValueDto("Priority", priority)]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0); + } + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { @@ -574,7 +615,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -612,7 +653,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -742,7 +783,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -771,7 +812,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index ab4241e..a6aa592 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture ({ value: v, label: routeStepStatusLabels[v] })); @@ -86,6 +96,8 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator) .filter(v => v !== ComparisonOperator.None) .map(v => ({ value: v, label: comparisonOperatorLabels[v] })); +const CONDITIONS_LOGIC_OPTIONS = Object.values(ConditionsLogic).map(v => ({ value: v, label: conditionsLogicLabels[v] })); + /** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */ const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan]; @@ -118,9 +130,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number headType: HeadType.Chief, allowReassignToOtherApprovers: false, hasCondition: false, - fieldName: '', - operation: ComparisonOperator.GreaterThan, - value: '', + conditions: [EMPTY_CONDITION], + conditionsLogic: ConditionsLogic.And, slaHours: '', askOverdueReason: '' }; @@ -134,10 +145,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers, - hasCondition: Boolean(editing.fieldName), - fieldName: editing.fieldName ?? '', - operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, - value: editing.value ?? '', + hasCondition: editing.conditions.length > 0, + conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION], + conditionsLogic: editing.conditionsLogic, slaHours: slaHoursToInput(editing.slaHours), askOverdueReason: boolToTriState(editing.askOverdueReason) }; @@ -162,59 +172,61 @@ export const RouteStepFormModal: React.FC = ({ const hasListId = Boolean(requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); - const fieldNameOptions = React.useMemo(() => { + // currentValue — значение конкретной строки условия: если оно не входит в + // подгруженные поля списка (набрано до появления поля в списке или после его + // удаления), добавляем его отдельным пунктом, чтобы не сбрасывать выбор молча. + const fieldNameOptionsFor = (currentValue: string): { value: string; label: string }[] => { const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions]; - if (form.fieldName && !options.some(o => o.value === form.fieldName)) { - options.push({ value: form.fieldName, label: form.fieldName }); + if (currentValue && !options.some(o => o.value === currentValue)) { + options.push({ value: currentValue, label: currentValue }); } return options; - }, [sharePointFields.items, form.fieldName]); - - const isManagerField = form.fieldName === IS_MANAGER_FIELD; + }; // Тип столбца известен, только если он есть среди подгруженных полей списка — // для значения, введённого до появления поля в списке (или после его удаления), // ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные. // IsManager — служебное булево поле, а не колонка списка, поэтому для него // числовые ограничения не действуют в любом случае. - const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); - const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField - ? 'bool' - : !selectedField - ? 'unknown' - : isNumericFieldType(selectedField.typeAsString) - ? 'numeric' - : 'text'; - const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown'; + const fieldKindOf = (fieldName: string): 'bool' | 'numeric' | 'text' | 'unknown' => { + if (fieldName === IS_MANAGER_FIELD) return 'bool'; + const field = sharePointFields.items.find(f => f.internalName === fieldName); + if (!field) return 'unknown'; + return isNumericFieldType(field.typeAsString) ? 'numeric' : 'text'; + }; + const isNumericFieldKind = (kind: ReturnType): boolean => kind === 'numeric' || kind === 'unknown'; // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше». // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем. - const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => { - if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; - if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; - if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; - return true; - }), [fieldKind]); + const operationOptionsFor = (kind: ReturnType): typeof OPERATION_OPTIONS => + OPERATION_OPTIONS.filter(o => { + if (kind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; + if (kind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; + if (kind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; + return true; + }); - // Оператор, недопустимый для выбранной колонки (например, «больше» после смены - // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. - const isOperationAllowed = operationOptions.some(o => o.value === form.operation); - React.useEffect(() => { - if (!isOperationAllowed) { - setForm(f => ({ ...f, operation: ComparisonOperator.Equal })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOperationAllowed]); - - // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, - // а не произвольным текстом, который пользователь мог набрать для другого field_name. - React.useEffect(() => { - if (isManagerField && form.value !== 'true' && form.value !== 'false') { - setForm(f => ({ ...f, value: 'true' })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isManagerField]); + const updateCondition = (index: number, patch: Partial): void => { + setForm(f => ({ + ...f, + conditions: f.conditions.map((c, i) => { + if (i !== index) return c; + const next = { ...c, ...patch }; + // Оператор, недопустимый для новой колонки (например, «больше» после смены + // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. + const kind = fieldKindOf(next.fieldName); + if (!operationOptionsFor(kind).some(o => o.value === next.operation)) next.operation = ComparisonOperator.Equal; + // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, + // а не произвольным текстом, который пользователь мог набрать для другого field_name. + if (kind === 'bool' && next.value !== 'true' && next.value !== 'false') next.value = 'true'; + return next; + }) + })); + }; + const addCondition = (): void => setForm(f => ({ ...f, conditions: [...f.conditions, EMPTY_CONDITION] })); + const removeCondition = (index: number): void => + setForm(f => ({ ...f, conditions: f.conditions.filter((_, i) => i !== index) })); // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо @@ -336,13 +348,18 @@ export const RouteStepFormModal: React.FC = ({ submit.fail(texts.routeSteps.validationPosition); return; } - if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) { - submit.fail(texts.routeSteps.validationCondition); - return; - } - if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) { - submit.fail(texts.routeSteps.validationConditionNumber); - return; + if (form.hasCondition) { + for (const c of form.conditions) { + if (!c.fieldName.trim() || !c.value.trim()) { + submit.fail(texts.routeSteps.validationCondition); + return; + } + const kind = fieldKindOf(c.fieldName); + if (isNumericFieldKind(kind) && !Number.isFinite(Number(c.value.trim()))) { + submit.fail(texts.routeSteps.validationConditionNumber(c.fieldName)); + return; + } + } } if (!isValidSlaHours(form.slaHours)) { submit.fail(texts.routeSteps.validationSlaHours); @@ -357,9 +374,10 @@ export const RouteStepFormModal: React.FC = ({ position: form.kind === 'position' ? form.position.trim() : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null, headType: form.kind === 'headType' ? form.headType : null, - fieldName: form.hasCondition ? form.fieldName.trim() : null, - operation: form.hasCondition ? form.operation : null, - value: form.hasCondition ? form.value.trim() : null, + conditions: form.hasCondition + ? form.conditions.map(c => ({ fieldName: c.fieldName.trim(), operation: c.operation, value: c.value.trim() })) + : null, + conditionsLogic: form.conditionsLogic, slaHours: parseSlaHours(form.slaHours), askOverdueReason: triStateToBool(form.askOverdueReason) }; @@ -495,54 +513,75 @@ export const RouteStepFormModal: React.FC = ({ /> - {form.hasCondition && ( - <> - - setForm({ ...form, operation: e.target.value as ComparisonOperator })} - /> - - - {isManagerField ? ( - setForm({ ...form, conditionsLogic: e.target.value as ConditionsLogic })} + /> + + )} + + {form.conditions.map((condition, index) => { + const kind = fieldKindOf(condition.fieldName); + const fieldNameOptions = fieldNameOptionsFor(condition.fieldName); + return ( + // eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом +
+ updateCondition(index, { operation: e.target.value as ComparisonOperator })} + /> + {kind === 'bool' ? ( +