diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index 52a390c..df56e95 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -25,7 +25,12 @@
-
+
+
diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs
index 2390684..16109f4 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Program.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs
@@ -40,7 +40,7 @@ builder.Services.Configure(options =>
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
- options.KnownNetworks.Clear();
+ options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
index 34b9dd5..2b907b3 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
@@ -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(
};
}
+ ///
+ /// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа —
+ /// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же.
+ ///
+ 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);
+
+ /// «Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей.
+ 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);
diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs
index 7bfbdce..d91522a 100644
--- a/backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs
+++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs
@@ -5,5 +5,8 @@ public enum ComparisonOperator
None,
GreaterThan,
LessThan,
- Equal
+ Equal,
+ NotEqual,
+ Contains,
+ NotContains
}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj
index 7d5e20c..7a0e4c8 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj
@@ -13,7 +13,6 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
index a33942c..d3167d6 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
@@ -332,8 +332,9 @@ 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),
+ 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()
{
@@ -690,8 +725,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture v !== ComparisonOperator.None)
.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 — вычисляется на бэкенде
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
@@ -173,20 +179,33 @@ export const RouteStepFormModal: React.FC = ({
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае.
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
- : OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
+ // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
+ // Булев 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]);
- // Строковая колонка допускает только «равно» — если пользователь сменил
- // field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
+ // Оператор, недопустимый для выбранной колонки (например, «больше» после смены
+ // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
+ const isOperationAllowed = operationOptions.some(o => o.value === form.operation);
React.useEffect(() => {
- if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
+ if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isNumericField]);
+ }, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
diff --git a/frontend/src/webparts/approvalAdmin/models/enums.ts b/frontend/src/webparts/approvalAdmin/models/enums.ts
index 58382fc..40586d7 100644
--- a/frontend/src/webparts/approvalAdmin/models/enums.ts
+++ b/frontend/src/webparts/approvalAdmin/models/enums.ts
@@ -33,5 +33,10 @@ export enum ComparisonOperator {
None = 'None',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
- Equal = 'Equal'
+ Equal = 'Equal',
+ NotEqual = 'NotEqual',
+ // «Содержит»/«Не содержит» имеют смысл только для текстовых колонок —
+ // см. ограничение операторов в RouteStepFormModal.
+ Contains = 'Contains',
+ NotContains = 'NotContains'
}
diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts
index 669ccfb..4ecda82 100644
--- a/frontend/src/webparts/approvalAdmin/texts.ts
+++ b/frontend/src/webparts/approvalAdmin/texts.ts
@@ -39,14 +39,20 @@ export const comparisonOperatorLabels: Record = {
[ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)',
- [ComparisonOperator.Equal]: 'Равно (=)'
+ [ComparisonOperator.Equal]: 'Равно (=)',
+ [ComparisonOperator.NotEqual]: 'Не равно (≠)',
+ [ComparisonOperator.Contains]: 'Содержит',
+ [ComparisonOperator.NotContains]: 'Не содержит'
};
export const comparisonOperatorSymbols: Record = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
- [ComparisonOperator.Equal]: '='
+ [ComparisonOperator.Equal]: '=',
+ [ComparisonOperator.NotEqual]: '≠',
+ [ComparisonOperator.Contains]: 'содержит',
+ [ComparisonOperator.NotContains]: 'не содержит'
};
export const texts = {
diff --git a/frontend/src/webparts/approvalAdmin/utils/sla.test.ts b/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
index 639cb35..0f3d903 100644
--- a/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
@@ -9,7 +9,10 @@ import {
slaHoursToInput,
triStateToBool
} from './sla';
-import { APPROVAL_PHASE } from '../models';
+import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
+
+/** Ровно те поля строки, которые читает activatedAt (см. его сигнатуру). */
+type ActivationRow = Pick;
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
@@ -110,7 +113,7 @@ describe('effectiveSlaHours', () => {
// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
// проверяется на тех же случаях (параллельные шаги, переход между фазами).
describe('activatedAt', () => {
- const row = (order: number, phase: string, dateIso: string) => ({ order, phase, dateIso });
+ const row = (order: number, phase: string, dateIso: string): ActivationRow => ({ order, phase, dateIso });
it('первый шаг согласования — от создания заявки', () => {
const rows = [row(1, APPROVAL_PHASE.approval, '')];
diff --git a/frontend/src/webparts/approvalAdmin/utils/sla.ts b/frontend/src/webparts/approvalAdmin/utils/sla.ts
index 675e566..e7d4410 100644
--- a/frontend/src/webparts/approvalAdmin/utils/sla.ts
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.ts
@@ -76,10 +76,11 @@ export function formatSlaHours(value: number | null | undefined): string {
* здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
* часов, а не только «от типа заявки» без цифры.
*/
-// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function effectiveSlaHours(
row: Pick,
+ // eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
requestTypeSlaHours: number | null | undefined
+ // eslint-disable-next-line @rushstack/no-new-null -- null = «SLA не задан нигде», это не 0
): number | null {
if (row.slaHours !== null) return row.slaHours;
return requestTypeSlaHours ?? null;
@@ -140,6 +141,7 @@ export function isRowOverdue(
row: Pick,
allRows: readonly Pick[],
requestCreatedIso: string,
+ // eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
requestTypeSlaHours: number | null | undefined,
now: Date
): boolean {