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 {