diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 8b38e22..e3f7127 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [Produces("application/json")] public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { - [HttpGet("resolve")] + // POST, а не GET: запрос принимает список условий (field_name/value) переменной длины, + // который не ложится на плоскую query-строку без индексной эквилибристики. Единственный + // потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет. + [HttpPost("resolve")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => + public async Task Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) => (await service.ResolveAsync(request, ct)).ToActionResult(); [HttpGet] diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 2d13744..8492c1d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -15,8 +15,12 @@ public sealed record ResolveTemplateRouteResultDto( bool RequiresControl, IReadOnlyList Steps); +/// Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve. +public sealed record ResolveFieldValueDto(string FieldName, string Value); + public sealed record ResolveTemplateRouteRequest( int RequestTypeId, string? UserEmail, Guid? ProjectId, - RouteStepStatus Status); + RouteStepStatus Status, + IReadOnlyList? FieldValues = null); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 94e2de1..b98ed72 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -165,8 +166,14 @@ public sealed class TemplateRouteService( if (request.ProjectId is { } projectId) projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); + var fieldValues = (request.FieldValues ?? []) + .Where(f => !string.IsNullOrWhiteSpace(f.FieldName)) + .GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase); + var matchingSteps = route.Steps .Where(s => s.Status == request.Status) + .Where(s => MatchesCondition(s, fieldValues)) .OrderBy(s => s.OrderNo) .ToList(); @@ -240,6 +247,37 @@ public sealed class TemplateRouteService( return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning); } + /// + /// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. + /// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля + /// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный + /// сценарий, и отсутствие значения означает, что условие не выполняется. + /// + private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary fieldValues) + { + if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) + return true; + + if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) + return false; + + 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.GreaterThan => + TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected, + ComparisonOperator.LessThan => + TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected, + _ => true + }; + } + + private static bool TryParseNumber(string? value, out double number) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); + private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 23019e4..63b67cf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -33,5 +33,10 @@ public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + RuleForEach(x => x.FieldValues).ChildRules(v => + { + v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно."); + v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно."); + }); } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 7f0f80b..42b3c62 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -40,6 +40,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeAsync(string name) => (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id; + private async Task CreateRequestTypeWithListAsync(string name) => + (await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), + CancellationToken.None)).Value.Id; + [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() { @@ -226,6 +231,116 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", "1500")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")] + public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", "500")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")] + public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + + // Action — fieldValues не переданы вовсе + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldBeEmpty(); + } + + [Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")] + public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Priority", "URGENT")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz"); + } + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index 923c14a..cb8de43 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; -import { SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists'; +import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists'; import { RequestType } from '../../models'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useSharePointLists } from '../../hooks/useSharePointLists'; +import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { @@ -35,12 +36,16 @@ interface IFormState { pageUrl: string; section: string; siteUrl: string; - /** Заголовок сайта — только для отображения в триггере поиска, на бэкенд не уходит. */ - siteTitle: string; listName: string; } -const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', siteTitle: '', listName: '' }; +const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' }; + +/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */ +const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => { + const { title } = useSharePointSiteTitle(client, siteUrl); + return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}; +}; export const RequestTypesScreen: React.FC = ({ api, sharePoint }) => { const list = useRequestTypes(api); @@ -50,6 +55,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh const [form, setForm] = React.useState(EMPTY_FORM); const submit = useAsyncSubmit(); const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); + const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl); const searchSites = React.useCallback( (query: string, page: number) => sharePoint.searchSites(query, page), [sharePoint] @@ -70,7 +76,6 @@ export const RequestTypesScreen: React.FC = ({ api, sh pageUrl: item.pageUrl, section: item.section, siteUrl: item.siteUrl ?? '', - siteTitle: item.siteUrl ?? '', listName: item.listName ?? '' }); setShowForm(true); @@ -126,7 +131,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'section', header: texts.requestTypes.columnSection, render: r => r.section }, { key: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty }, - { key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => r.siteUrl ?? texts.common.empty }, + { key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => }, { key: 'actions', header: '', @@ -179,7 +184,8 @@ export const RequestTypesScreen: React.FC = ({ api, sh value={form.siteUrl} - selectedLabel={form.siteTitle || form.siteUrl} + selectedLabel={siteTitle.title ?? form.siteUrl} + selectedSublabel={siteTitle.title ? form.siteUrl : undefined} allowClear clearLabel={texts.select.notSelected} placeholder={texts.requestTypes.siteUrlPlaceholder} @@ -188,7 +194,10 @@ export const RequestTypesScreen: React.FC = ({ api, sh getValue={s => s.url} getLabel={s => s.title} getSublabel={s => s.url} - onChange={(siteUrl, item) => setForm({ ...form, siteUrl, siteTitle: item?.title ?? siteUrl, listName: '' })} + onChange={(siteUrl, item) => { + if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title); + setForm({ ...form, siteUrl, listName: '' }); + }} /> { value: string; /** Название выбранного значения — показываем в триггере без похода на сервер. */ selectedLabel?: string; + /** Вторая строка триггера мельче и серым — например, url под названием сайта. */ + selectedSublabel?: string; onChange: (value: string, item: T | undefined) => void; search: (query: string, page: number) => Promise>; getValue: (item: T) => string; @@ -30,6 +32,7 @@ export interface IAsyncSearchableSelectProps { export function AsyncSearchableSelect({ value, selectedLabel, + selectedSublabel, onChange, search, getValue, @@ -166,9 +169,16 @@ export function AsyncSearchableSelect({ onClick={toggle} disabled={disabled} > - - {value ? (selectedLabel ?? value) : placeholder} - + {value && selectedSublabel ? ( + + {selectedLabel ?? value} + {selectedSublabel} + + ) : ( + + {value ? (selectedLabel ?? value) : placeholder} + + )} diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index 9995862..7329cda 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -35,6 +35,31 @@ white-space: nowrap; } +// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу, +// как itemSub в списке. Отдельно от .value, у которого одна строка целиком +// участвует в ellipsis и не может параллельно нести подпись другим кеглем. +.valueStack { + display: flex; + flex-direction: column; + min-width: 0; + text-align: left; +} + +.valueMain { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.valueSub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: $gray-400; + margin-top: 1px; +} + .placeholder { color: $gray-400; } .chevron { color: $gray-400; flex-shrink: 0; } diff --git a/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts b/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts new file mode 100644 index 0000000..266e02a --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { SharePointListsClient } from '../services/sharePointLists'; + +export interface SharePointSiteTitleState { + title: string | null; + isLoading: boolean; +} + +/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */ +export function useSharePointSiteTitle( + client: SharePointListsClient | undefined, + siteUrl: string | undefined | null +): SharePointSiteTitleState { + const [title, setTitle] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + React.useEffect(() => { + if (!client || !siteUrl?.trim()) { + setTitle(null); + setIsLoading(false); + return undefined; + } + + let cancelled = false; + setIsLoading(true); + + client.getSiteTitle(siteUrl.trim()) + .then(result => { if (!cancelled) setTitle(result); }) + .catch(() => { if (!cancelled) setTitle(null); }) + .finally(() => { if (!cancelled) setIsLoading(false); }); + + return () => { cancelled = true; }; + }, [client, siteUrl]); + + return { title, isLoading }; +} diff --git a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts index e3f7c05..5914aca 100644 --- a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts +++ b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts @@ -45,6 +45,11 @@ export function isNumericFieldType(typeAsString: string): boolean { return NUMERIC_FIELD_TYPES.has(typeAsString); } +/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */ +export function formatSiteLabel(title: string | null | undefined, url: string): string { + return title ? `${title} — ${url}` : url; +} + interface IListItem { Id: string; Title: string; @@ -66,8 +71,33 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI { } export class SharePointListsClient { + // Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем + // на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы + // или переоткрытие формы с тем же значением. + private readonly siteTitleCache = new Map(); + constructor(private readonly context: WebPartContext) {} + /** Подмешать уже известный заголовок сайта (например, из результата поиска) — без похода в сеть. */ + public cacheSiteTitle(siteUrl: string, title: string): void { + this.siteTitleCache.set(siteUrl, title); + } + + /** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */ + public async getSiteTitle(siteUrl: string): Promise { + const cached = this.siteTitleCache.get(siteUrl); + if (cached !== undefined) return cached; + + try { + const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string }; + this.siteTitleCache.set(siteUrl, web.Title); + return web.Title; + } catch { + this.siteTitleCache.set(siteUrl, null); + return null; + } + } + /** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */ public async listLists(siteUrl: string): Promise { const sp = clientFor(this.context, siteUrl);