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

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; var expected = step.Value;
return step.Operation switch return step.Operation switch
{ {
ComparisonOperator.Equal => ComparisonOperator.Equal => IsEqual(actual, expected),
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) || ComparisonOperator.NotEqual => !IsEqual(actual, expected),
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected), ComparisonOperator.Contains => Contains(actual, expected),
ComparisonOperator.NotContains => !Contains(actual, expected),
ComparisonOperator.GreaterThan => ComparisonOperator.GreaterThan =>
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected, TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
ComparisonOperator.LessThan => 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) => private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
@@ -5,5 +5,8 @@ public enum ComparisonOperator
None, None,
GreaterThan, GreaterThan,
LessThan, 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"); 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 = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{ {
@@ -86,6 +86,12 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None) .filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] })); .map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
/** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */
const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan];
/** «Содержит»/«Не содержит» — поиск подстроки, только для текстовых колонок. */
const TEXT_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.Contains, ComparisonOperator.NotContains];
/** /**
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде * Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ * (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
@@ -173,20 +179,33 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// IsManager — служебное булево поле, а не колонка списка, поэтому для него // IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае. // числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true; const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField
? 'bool'
: !selectedField
? 'unknown'
: isNumericFieldType(selectedField.typeAsString)
? 'numeric'
: 'text';
const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown';
const operationOptions = isNumericField // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
? OPERATION_OPTIONS // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем.
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal); 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]);
// Строковая колонка допускает только «равно» — если пользователь сменил // Оператор, недопустимый для выбранной колонки (например, «больше» после смены
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем. // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const isOperationAllowed = operationOptions.some(o => o.value === form.operation);
React.useEffect(() => { React.useEffect(() => {
if (!isNumericField && form.operation !== ComparisonOperator.Equal) { if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal })); setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNumericField]); }, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name. // а не произвольным текстом, который пользователь мог набрать для другого field_name.
@@ -33,5 +33,10 @@ export enum ComparisonOperator {
None = 'None', None = 'None',
GreaterThan = 'GreaterThan', GreaterThan = 'GreaterThan',
LessThan = 'LessThan', LessThan = 'LessThan',
Equal = 'Equal' Equal = 'Equal',
NotEqual = 'NotEqual',
// «Содержит»/«Не содержит» имеют смысл только для текстовых колонок —
// см. ограничение операторов в RouteStepFormModal.
Contains = 'Contains',
NotContains = 'NotContains'
} }
+8 -2
View File
@@ -39,14 +39,20 @@ export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '—', [ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)', [ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)', [ComparisonOperator.LessThan]: 'Меньше (<)',
[ComparisonOperator.Equal]: 'Равно (=)' [ComparisonOperator.Equal]: 'Равно (=)',
[ComparisonOperator.NotEqual]: 'Не равно (≠)',
[ComparisonOperator.Contains]: 'Содержит',
[ComparisonOperator.NotContains]: 'Не содержит'
}; };
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = { export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '', [ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>', [ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<', [ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '=' [ComparisonOperator.Equal]: '=',
[ComparisonOperator.NotEqual]: '≠',
[ComparisonOperator.Contains]: 'содержит',
[ComparisonOperator.NotContains]: 'не содержит'
}; };
export const texts = { export const texts = {