From 6daecd2e18265ffb68bf53f91f1566ba9658eafe Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 12 Aug 2026 17:07:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reminders/ApprovalRow.cs | 15 --- .../Reminders/ReminderService.cs | 62 +++--------- .../SharePoint/SharePointClient.cs | 92 ----------------- .../Tnsu.ApprovalRoute.Infrastructure.csproj | 7 -- .../ReminderServiceTests.cs | 19 +--- .../SharePointClientParsingTests.cs | 99 ------------------- 6 files changed, 17 insertions(+), 277 deletions(-) delete mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs index 13bf646..3fcf63f 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -78,18 +78,3 @@ internal static class ApprovalStatuses public const string OnApproval = "На согласовании"; public const string OnExecution = "На исполнении"; } - -/// -/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS. -/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок -/// сайта у самого имени) — совпадает с MP_STATUS в -/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts, -/// это тот же список и та же колонка, имя проверено там же. -/// -internal static class SlaStatus -{ - private const string ManagedProperty = "ApprovalStatusOWSCHCS"; - - public static readonly string OnApprovalProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnApproval}\""; - public static readonly string OnExecutionProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnExecution}\""; -} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 85dfa60..9c68e35 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -27,10 +27,10 @@ public sealed class ReminderService( // на большом списке получится неподъёмной длины URL. private const int IdChunkSize = 50; - // Верхняя граница на число одновременно активных шагов по типу заявки — - // запас с большим избытком (это не общий объём заявок, а только те, что - // прямо сейчас на согласовании/исполнении). - private const int SearchRowLimit = 5000; + // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск, + // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов). + private const string ActiveStatusFilter = + $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; public async Task>> GetOverdueAsync( DateTimeOffset now, CancellationToken cancellationToken) @@ -81,37 +81,17 @@ public sealed class ReminderService( return []; } - // Шаг 1. Активные строки — через Search, а не через $filter: дело не в - // кириллице конкретно, а в том, что $filter по Choice-колонке в - // SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо - // ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки - // этого же списка уже проверена и используется в request-portal - // (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут. + // Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus. // - // Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ - // нерешённые строки списка (queued + active), чтобы отобрать активные - // на клиенте — при большом архиве заявок это росло бы неограниченно. - // Search сужает набор на стороне SharePoint до тех, что реально нужны. - var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken); - if (string.IsNullOrWhiteSpace(relativeUrl)) - { - logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle); - return []; - } - - var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\""; - var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})"; - var activeIds = await sharePoint.SearchListItemIdsAsync( - siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken); - if (activeIds.Count == 0) return []; - - // Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и - // т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync). - // Заодно и статус здесь берётся из свежих данных, а не из индекса, который - // мог успеть отстать (та же оговорка, что в request-portal). - var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken)) - .Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution) - .ToList(); + // Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) — + // тот же приём, что в request-portal (см. MP_STATUS). На практике он + // строки не нашёл: индекс мог не подхватить именно эту managed property + // для этого списка (она формируется автоматически, и после провижининга + // колонок SLA переиндексация могла ещё не пройти) — сама причина не + // подтверждена, а вот результат — нет. Прямой $filter по полю сработал: + // он не отстаёт от реального состояния списка (не зависит от индексации + // вовсе), и подтверждённой на практике проблемы с ним на этом списке нет. + var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken); if (active.Count == 0) return []; // Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута — @@ -239,20 +219,6 @@ public sealed class ReminderService( return Parse(items); } - /// Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync). - private async Task> ReadRowsByIdsAsync( - string siteUrl, string listTitle, IReadOnlyList ids, CancellationToken cancellationToken) - { - var rows = new List(); - foreach (var chunk in ids.Chunk(IdChunkSize)) - { - var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}")); - rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken)); - } - - return rows; - } - private async Task> ReadRowsByTicketsAsync( string siteUrl, string listTitle, IReadOnlyList ticketIds, CancellationToken cancellationToken) { diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index 8ca383f..510a7d0 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -12,12 +12,6 @@ public interface ISharePointClient /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); - /// - /// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы - /// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync). - /// - Task GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); - /// Элементы списка данных, найденного по серверному относительному URL. Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); @@ -25,17 +19,6 @@ public interface ISharePointClient /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); - - /// - /// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter, - /// сужается набор строк списка согласования по статусу (Choice-колонка) — - /// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в - /// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS, - /// тот же список, тот же managed property, подтверждено рабочим). Значения - /// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом. - /// - Task> SearchListItemIdsAsync( - string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken); } /// @@ -67,81 +50,6 @@ public sealed class SharePointClient( return json.TryGetProperty("Title", out var title) ? title.GetString() : null; } - public async Task GetListServerRelativeUrlAsync( - string siteUrl, string listTitle, CancellationToken cancellationToken) - { - var url = $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')" + - "?$select=RootFolder/ServerRelativeUrl&$expand=RootFolder"; - var json = await GetAsync(siteUrl, url, cancellationToken); - - return ParseServerRelativeUrl(json); - } - - public async Task> SearchListItemIdsAsync( - string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken) - { - var url = $"{siteUrl.TrimEnd('/')}/_api/search/query" + - $"?querytext='{Uri.EscapeDataString(queryText)}'" + - "&selectproperties='ListItemID'" + - $"&rowlimit={rowLimit}" + - "&trimduplicates=false"; - var json = await GetAsync(siteUrl, url, cancellationToken); - - return ParseSearchListItemIds(json); - } - - // Разбор вынесен в чистые статические функции — они не ходят в сеть и не - // требуют токена, поэтому их (в отличие от остального клиента) можно - // проверить тестом без живого SharePoint. Это самая непроверенная часть - // клиента: форма ответа Search REST зафиксирована документацией, но без - // реального тенанта убедиться в точном соответствии нельзя. - internal static string? ParseServerRelativeUrl(JsonElement json) => - json.TryGetProperty("RootFolder", out var rootFolder) && - rootFolder.TryGetProperty("ServerRelativeUrl", out var relUrl) - ? relUrl.GetString() - : null; - - internal static IReadOnlyList ParseSearchListItemIds(JsonElement json) - { - // Путь до строк результата — фиксированная форма ответа Search REST, - // не зависит от запроса. С odata=nometadata Cells приходит уже "голым" - // массивом (без обёртки .results) — но на случай, если сервер всё равно - // завернёт его (разные версии API отвечали по-разному на практике в этом - // проекте, см. комментарии в request-portal про managed properties), - // читаем и то, и то (см. Unwrap). - if (!json.TryGetProperty("PrimaryQueryResult", out var primary)) return []; - if (!primary.TryGetProperty("RelevantResults", out var relevant)) return []; - if (!relevant.TryGetProperty("Table", out var table)) return []; - if (!table.TryGetProperty("Rows", out var rowsElement)) return []; - - var ids = new List(); - - foreach (var row in Unwrap(rowsElement)) - { - if (!row.TryGetProperty("Cells", out var cellsElement)) continue; - - foreach (var cell in Unwrap(cellsElement)) - { - if (!cell.TryGetProperty("Key", out var key) || key.GetString() != "ListItemID") continue; - if (!cell.TryGetProperty("Value", out var value)) continue; - if (int.TryParse(value.GetString(), out var id)) ids.Add(id); - } - } - - return ids; - } - - // odata=nometadata отдаёт коллекции внутри Search-ответа "голым" массивом; - // без этого заголовка (или в старых версиях API) — обёрткой { results: [...] }. - private static IEnumerable Unwrap(JsonElement element) - { - if (element.ValueKind == JsonValueKind.Array) return element.EnumerateArray(); - - return element.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array - ? results.EnumerateArray() - : []; - } - public Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => GetItemsAsync( diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index c55b907..7d5e20c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -2,13 +2,6 @@ - - - - diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 1474824..0d32178 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -77,28 +77,15 @@ public sealed class ReminderServiceTests : IClassFixture x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .ReturnsAsync(DataListTitle); - sharePoint - .Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny())) - .ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl"); - - // Search сужает набор до активных строк (На согласовании/На исполнении) — - // мок отвечает так же, как реальный Search: по статусу строки, не по дате. + // Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок + // отвечает так же: по значению статуса, а не по признаку решённости. var activeRows = allRows .Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is "На согласовании" or "На исполнении") .ToList(); - sharePoint - .Setup(x => x.SearchListItemIdsAsync( - SiteUrl, It.Is(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny(), It.IsAny())) - .ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList()); - - // Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет - // "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с - // "TicketId eq" (та подстрока в нём есть буквально), и два setup'а - // конфликтовали бы между собой. sharePoint .Setup(x => x.GetItemsByListTitleAsync( - SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("filter=Id eq")), It.IsAny())) + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("ApprovalStatus eq")), It.IsAny())) .ReturnsAsync(activeRows.Select(Json).ToList()); sharePoint diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs deleted file mode 100644 index 945435d..0000000 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Text.Json; -using Shouldly; -using Tnsu.ApprovalRoute.Infrastructure.SharePoint; - -namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; - -/// -/// Разбор ответов SharePoint REST — единственная часть SharePointClient, которую -/// можно проверить без живого тенанта: сам HTTP-вызов неотделим от аутентификации -/// по сертификату (ClientCertificateCredential ходит в Azure AD за токеном перед -/// каждым запросом), а вот разбор уже полученного JSON — чистая функция. -/// -/// Это самое непроверенное место всей интеграции: форма ответа Search REST -/// (_api/search/query) задокументирована, но подтвердить её точное соответствие -/// без реального тенанта нельзя — только этими тестами на образцах ответа. -/// -public sealed class SharePointClientParsingTests -{ - private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement; - - [Fact(DisplayName = "Search: с odata=nometadata Rows/Cells приходят голыми массивами")] - public void ParseSearchListItemIds_BareArrays_ReturnsIds() - { - const string response = """ - { - "PrimaryQueryResult": { - "RelevantResults": { - "RowCount": 2, - "Table": { - "Rows": [ - { "Cells": [ { "Key": "Rank", "Value": "1" }, { "Key": "ListItemID", "Value": "11" } ] }, - { "Cells": [ { "Key": "ListItemID", "Value": "27" }, { "Key": "Rank", "Value": "2" } ] } - ] - } - } - } - } - """; - - var ids = SharePointClient.ParseSearchListItemIds(Parse(response)); - - ids.ShouldBe([11, 27]); - } - - [Fact(DisplayName = "Search: без odata=nometadata Rows/Cells завёрнуты в {results: [...]}")] - public void ParseSearchListItemIds_WrappedResults_ReturnsIds() - { - const string response = """ - { - "PrimaryQueryResult": { - "RelevantResults": { - "Table": { - "Rows": { - "results": [ - { "Cells": { "results": [ { "Key": "ListItemID", "Value": "42" } ] } } - ] - } - } - } - } - } - """; - - var ids = SharePointClient.ParseSearchListItemIds(Parse(response)); - - ids.ShouldBe([42]); - } - - [Fact(DisplayName = "Search: нет совпадений — пустой список, а не исключение")] - public void ParseSearchListItemIds_NoRows_ReturnsEmpty() - { - const string response = """{ "PrimaryQueryResult": { "RelevantResults": { "RowCount": 0, "Table": { "Rows": [] } } } } """; - - SharePointClient.ParseSearchListItemIds(Parse(response)).ShouldBeEmpty(); - } - - [Fact(DisplayName = "Search: неожиданная форма ответа не роняет разбор")] - public void ParseSearchListItemIds_UnexpectedShape_ReturnsEmpty() - { - SharePointClient.ParseSearchListItemIds(Parse("""{ "odata.error": "что-то не так" }""")).ShouldBeEmpty(); - } - - [Fact(DisplayName = "RootFolder/ServerRelativeUrl разбирается для условия Path в KQL")] - public void ParseServerRelativeUrl_ReturnsValue() - { - const string response = """ - { "RootFolder": { "ServerRelativeUrl": "/sites/requests/Lists/Заявка тест — согласование" } } - """; - - SharePointClient.ParseServerRelativeUrl(Parse(response)) - .ShouldBe("/sites/requests/Lists/Заявка тест — согласование"); - } - - [Fact(DisplayName = "Список без RootFolder в ответе — null, а не исключение")] - public void ParseServerRelativeUrl_Missing_ReturnsNull() - { - SharePointClient.ParseServerRelativeUrl(Parse("{}")).ShouldBeNull(); - } -}