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();
- }
-}