Новые условия #15
@@ -25,7 +25,12 @@
|
|||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<!--
|
||||||
|
Только чтобы поднять транзитивную версию из Microsoft.AspNetCore.OpenApi (2.0.0
|
||||||
|
с уязвимостью GHSA-v5pm-xwqc-g5wc, исправлена в 2.7.5) — напрямую не используется.
|
||||||
|
Убрать, когда Microsoft.AspNetCore.OpenApi сам подтянет исправленную версию.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|||||||
options.ForwardedHeaders =
|
options.ForwardedHeaders =
|
||||||
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
|
||||||
options.KnownNetworks.Clear();
|
options.KnownIPNetworks.Clear();
|
||||||
options.KnownProxies.Clear();
|
options.KnownProxies.Clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
|
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|||||||
+48
-10
@@ -332,8 +332,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.IsSuccess.ShouldBeTrue();
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
resolved.Value.Steps[0].Warning.ShouldContain("departmentId");
|
warning.ShouldNotBeNull();
|
||||||
|
warning.ShouldContain("departmentId");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» и переданным departmentId — резолвится через портал")]
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» и переданным departmentId — резолвится через портал")]
|
||||||
@@ -413,8 +414,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.IsSuccess.ShouldBeTrue();
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
resolved.Value.Steps[0].Warning.ShouldContain(targetDepartmentId.ToString());
|
warning.ShouldNotBeNull();
|
||||||
|
warning.ShouldContain(targetDepartmentId.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
||||||
@@ -527,6 +529,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()
|
||||||
{
|
{
|
||||||
@@ -690,8 +725,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.IsSuccess.ShouldBeTrue();
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
resolved.Value.Steps[0].Warning.ShouldContain("руководитель");
|
warning.ShouldNotBeNull();
|
||||||
|
warning.ShouldContain("руководитель");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
||||||
@@ -718,8 +754,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.IsSuccess.ShouldBeTrue();
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
resolved.Value.Steps[0].Warning.ShouldContain("должности");
|
warning.ShouldNotBeNull();
|
||||||
|
warning.ShouldContain("должности");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с ролью в проекте возвращает warning, если исполнитель с такой ролью не найден")]
|
[Fact(DisplayName = "Резолв шага с ролью в проекте возвращает warning, если исполнитель с такой ролью не найден")]
|
||||||
@@ -746,8 +783,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.IsSuccess.ShouldBeTrue();
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
resolved.Value.Steps[0].Warning.ShouldContain("роль");
|
warning.ShouldNotBeNull();
|
||||||
|
warning.ShouldContain("роль");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
@import '../../../styles/design-tokens';
|
@import '../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Full-width: снимаем max-width канвы SharePoint — и в workbench (dev), и на живой
|
||||||
|
// странице. !important оправдан: перебиваем встроенные стили канвы, иначе их не снять.
|
||||||
|
// Если после обновления платформы ширина вернётся — проверить актуальные селекторы в DevTools.
|
||||||
|
// Работает даже в обычной одноколоночной секции; supportsFullBleed в манифесте
|
||||||
|
// нужен дополнительно, чтобы web part можно было ставить в полноширинную секцию.
|
||||||
|
:global {
|
||||||
|
#workbenchPageContent,
|
||||||
|
.CanvasComponent.LCS .CanvasZone {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.approvalAdmin {
|
.approvalAdmin {
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
slaHoursToInput,
|
slaHoursToInput,
|
||||||
triStateToBool
|
triStateToBool
|
||||||
} from './sla';
|
} from './sla';
|
||||||
import { APPROVAL_PHASE } from '../models';
|
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
|
||||||
|
|
||||||
|
/** Ровно те поля строки, которые читает activatedAt (см. его сигнатуру). */
|
||||||
|
type ActivationRow = Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>;
|
||||||
|
|
||||||
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
||||||
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
||||||
@@ -110,7 +113,7 @@ describe('effectiveSlaHours', () => {
|
|||||||
// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
|
// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
|
||||||
// проверяется на тех же случаях (параллельные шаги, переход между фазами).
|
// проверяется на тех же случаях (параллельные шаги, переход между фазами).
|
||||||
describe('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('первый шаг согласования — от создания заявки', () => {
|
it('первый шаг согласования — от создания заявки', () => {
|
||||||
const rows = [row(1, APPROVAL_PHASE.approval, '')];
|
const rows = [row(1, APPROVAL_PHASE.approval, '')];
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ export function formatSlaHours(value: number | null | undefined): string {
|
|||||||
* здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
|
* здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
|
||||||
* часов, а не только «от типа заявки» без цифры.
|
* часов, а не только «от типа заявки» без цифры.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
|
||||||
export function effectiveSlaHours(
|
export function effectiveSlaHours(
|
||||||
row: Pick<ManagedApprovalRow, 'slaHours'>,
|
row: Pick<ManagedApprovalRow, 'slaHours'>,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
requestTypeSlaHours: number | null | undefined
|
requestTypeSlaHours: number | null | undefined
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null = «SLA не задан нигде», это не 0
|
||||||
): number | null {
|
): number | null {
|
||||||
if (row.slaHours !== null) return row.slaHours;
|
if (row.slaHours !== null) return row.slaHours;
|
||||||
return requestTypeSlaHours ?? null;
|
return requestTypeSlaHours ?? null;
|
||||||
@@ -140,6 +141,7 @@ export function isRowOverdue(
|
|||||||
row: Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso' | 'slaHours'>,
|
row: Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso' | 'slaHours'>,
|
||||||
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
|
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
|
||||||
requestCreatedIso: string,
|
requestCreatedIso: string,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
requestTypeSlaHours: number | null | undefined,
|
requestTypeSlaHours: number | null | undefined,
|
||||||
now: Date
|
now: Date
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user