Условия шага: операторы «не равно», «содержит», «не содержит»

ComparisonOperator дополнен NotEqual, Contains, NotContains.
NotEqual — строгое отрицание Equal (текст без учёта регистра + сравнение
как чисел), Contains/NotContains — поиск подстроки без учёта регистра.

В форме шага набор операторов зависит от типа колонки: для числовой —
всё, кроме «содержит»/«не содержит», для текстовой — всё, кроме
«больше»/«меньше», для служебного IsManager — только «равно»/«не равно».
Недопустимый после смены поля оператор сбрасывается на «равно».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-24 16:50:53 +05:00
parent a1fa862ed4
commit cbb3efc146
6 changed files with 95 additions and 15 deletions
@@ -360,9 +360,10 @@ public sealed class TemplateRouteService(
var expected = step.Value;
return step.Operation switch
{
ComparisonOperator.Equal =>
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
ComparisonOperator.Equal => IsEqual(actual, expected),
ComparisonOperator.NotEqual => !IsEqual(actual, expected),
ComparisonOperator.Contains => Contains(actual, expected),
ComparisonOperator.NotContains => !Contains(actual, expected),
ComparisonOperator.GreaterThan =>
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
ComparisonOperator.LessThan =>
@@ -371,6 +372,19 @@ public sealed class TemplateRouteService(
};
}
/// <summary>
/// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа —
/// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же.
/// </summary>
private static bool IsEqual(string actual, string? expected) =>
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected);
/// <summary>«Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей.</summary>
private static bool Contains(string actual, string? expected) =>
!string.IsNullOrEmpty(expected) &&
actual.Contains(expected.Trim(), StringComparison.OrdinalIgnoreCase);
private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
@@ -5,5 +5,8 @@ public enum ComparisonOperator
None,
GreaterThan,
LessThan,
Equal
Equal,
NotEqual,
Contains,
NotContains
}
@@ -527,6 +527,39 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz");
}
[Theory(DisplayName = "Резолв маршрута с условиями «не равно», «содержит», «не содержит»")]
[InlineData(ComparisonOperator.NotEqual, "Urgent", "Normal", true)]
[InlineData(ComparisonOperator.NotEqual, "Urgent", "URGENT", false)]
[InlineData(ComparisonOperator.Contains, "urge", "Very Urgent", true)]
[InlineData(ComparisonOperator.Contains, "urge", "Normal", false)]
[InlineData(ComparisonOperator.NotContains, "urge", "Normal", true)]
[InlineData(ComparisonOperator.NotContains, "urge", "Very Urgent", false)]
public async Task ResolveAsync_TextConditions_IncludeStepOnlyWhenConditionHolds(
ComparisonOperator operation, string expected, string actual, bool shouldMatch)
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync($"Condition request {operation} {actual}");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", operation, expected),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
[new ResolveFieldValueDto("Priority", actual)]),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0);
}
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{