Несколько условий шага, объединяемых через И/ИЛИ

Раньше шаг маршрута мог нести ровно одно условие (field_name/operation/
value). Теперь условий может быть сколько угодно, а их объединение задаётся
явно — ConditionsLogic.And (все условия) или .Or (любое из условий).

Бэкенд:
- Новая сущность TemplateRouteStepCondition (дочерняя таблица
  template_route_step_conditions, FK на шаг, cascade delete) вместо трёх
  колонок на TemplateRouteStep. Миграция AddMultipleStepConditions переносит
  существующие условия шагов в новую таблицу перед удалением старых колонок,
  чтобы не потерять уже настроенные в деве/проде условия.
- TemplateRouteService.MatchesConditions: несколько условий шага объединяются
  по ConditionsLogic (Any/All); HasUnevaluatableManagerCondition проверяет
  условие на IsManager в списке условий, а не единственное поле.
- TemplateRouteStepService: дубликаты шагов теперь сравниваются по всему
  набору условий (без учёта порядка) и, если условий два и более, по логике
  их объединения — сравнение в памяти, а не в SQL, кандидатов на дубликат
  в пределах маршрута всегда мало.
- Валидация условия: раньше «все три поля или ни одного», теперь — в каждом
  элементе списка все три поля обязательны.

Фронтенд:
- RouteStepFormModal: условие — динамический список строк (добавить/удалить),
  у каждой строки свой набор допустимых операторов по типу колонки; селектор
  И/ИЛИ появляется, когда условий два и больше.
- describeStepCondition в таблице шагов склеивает все условия через « И »/« ИЛИ ».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-25 09:56:36 +05:00
parent e4a6cb8167
commit 10c3057eff
21 changed files with 1201 additions and 346 deletions
@@ -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<TemplateRoute?> 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(
}
/// <summary>
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
/// сценарий, и отсутствие значения означает, что условие не выполняется.
/// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
/// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
/// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
/// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
/// </summary>
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> 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<string, string> 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(
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
/// <summary>
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
/// предупреждением — см. ResolveAsync.
/// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
/// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
/// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
/// </summary>
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));
/// <summary>
/// Должность — свободный текст без структуры (нет отдельного флага «это