заработало

This commit is contained in:
gorbenko.pavel
2026-08-12 17:07:21 +05:00
parent 0b2a05cd94
commit 6daecd2e18
6 changed files with 17 additions and 277 deletions
@@ -78,18 +78,3 @@ internal static class ApprovalStatuses
public const string OnApproval = "На согласовании"; public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении"; public const string OnExecution = "На исполнении";
} }
/// <summary>
/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS.
/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок
/// сайта у самого имени) — совпадает с MP_STATUS в
/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts,
/// это тот же список и та же колонка, имя проверено там же.
/// </summary>
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}\"";
}
@@ -27,10 +27,10 @@ public sealed class ReminderService(
// на большом списке получится неподъёмной длины URL. // на большом списке получится неподъёмной длины URL.
private const int IdChunkSize = 50; private const int IdChunkSize = 50;
// Верхняя граница на число одновременно активных шагов по типу заявки — // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
// запас с большим избытком (это не общий объём заявок, а только те, что // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
// прямо сейчас на согласовании/исполнении). private const string ActiveStatusFilter =
private const int SearchRowLimit = 5000; $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync( public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
DateTimeOffset now, CancellationToken cancellationToken) DateTimeOffset now, CancellationToken cancellationToken)
@@ -81,37 +81,17 @@ public sealed class ReminderService(
return []; return [];
} }
// Шаг 1. Активные строки — через Search, а не через $filter: дело не в // Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
// кириллице конкретно, а в том, что $filter по Choice-колонке в
// SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо
// ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки
// этого же списка уже проверена и используется в request-portal
// (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут.
// //
// Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ // Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
// нерешённые строки списка (queued + active), чтобы отобрать активные // тот же приём, что в request-portal (см. MP_STATUS). На практике он
// на клиенте — при большом архиве заявок это росло бы неограниченно. // строки не нашёл: индекс мог не подхватить именно эту managed property
// Search сужает набор на стороне SharePoint до тех, что реально нужны. // для этого списка (она формируется автоматически, и после провижининга
var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken); // колонок SLA переиндексация могла ещё не пройти) — сама причина не
if (string.IsNullOrWhiteSpace(relativeUrl)) // подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
{ // он не отстаёт от реального состояния списка (не зависит от индексации
logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle); // вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
return []; var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
}
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();
if (active.Count == 0) return []; if (active.Count == 0) return [];
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута — // Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
@@ -239,20 +219,6 @@ public sealed class ReminderService(
return Parse(items); return Parse(items);
} }
/// <summary>Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync).</summary>
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByIdsAsync(
string siteUrl, string listTitle, IReadOnlyList<int> ids, CancellationToken cancellationToken)
{
var rows = new List<ApprovalRow>();
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<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync( private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken) string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
{ {
@@ -12,12 +12,6 @@ public interface ISharePointClient
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary> /// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>
/// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы
/// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync).
/// </summary>
Task<string?> GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary> /// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync( Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
@@ -25,17 +19,6 @@ public interface ISharePointClient
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary> /// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync( Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
/// <summary>
/// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter,
/// сужается набор строк списка согласования по статусу (Choice-колонка) —
/// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в
/// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS,
/// тот же список, тот же managed property, подтверждено рабочим). Значения
/// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом.
/// </summary>
Task<IReadOnlyList<int>> SearchListItemIdsAsync(
string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -67,81 +50,6 @@ public sealed class SharePointClient(
return json.TryGetProperty("Title", out var title) ? title.GetString() : null; return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
} }
public async Task<string?> 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<IReadOnlyList<int>> 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<int> 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<int>();
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<JsonElement> 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<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync( public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
GetItemsAsync( GetItemsAsync(
@@ -2,13 +2,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" /> <ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Даёт тестам доступ к internal-функциям чистого разбора JSON в SharePointClient
(SharePointClientParsingTests) — сам клиент не потестировать без живого
тенанта (аутентификация по сертификату ходит в Azure AD), а разбор ответа
можно и нужно проверять без сети. -->
<InternalsVisibleTo Include="Tnsu.ApprovalRoute.IntegrationTests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). --> <!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
<PackageReference Include="Azure.Identity" /> <PackageReference Include="Azure.Identity" />
@@ -77,28 +77,15 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>())) .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle); .ReturnsAsync(DataListTitle);
sharePoint // Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
.Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny<CancellationToken>())) // отвечает так же: по значению статуса, а не по признаку решённости.
.ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl");
// Search сужает набор до активных строк (На согласовании/На исполнении) —
// мок отвечает так же, как реальный Search: по статусу строки, не по дате.
var activeRows = allRows var activeRows = allRows
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is .Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
"На согласовании" or "На исполнении") "На согласовании" or "На исполнении")
.ToList(); .ToList();
sharePoint
.Setup(x => x.SearchListItemIdsAsync(
SiteUrl, It.Is<string>(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList());
// Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет
// "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с
// "TicketId eq" (та подстрока в нём есть буквально), и два setup'а
// конфликтовали бы между собой.
sharePoint sharePoint
.Setup(x => x.GetItemsByListTitleAsync( .Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("filter=Id eq")), It.IsAny<CancellationToken>())) SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(Json).ToList()); .ReturnsAsync(activeRows.Select(Json).ToList());
sharePoint sharePoint
@@ -1,99 +0,0 @@
using System.Text.Json;
using Shouldly;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
/// <summary>
/// Разбор ответов SharePoint REST — единственная часть SharePointClient, которую
/// можно проверить без живого тенанта: сам HTTP-вызов неотделим от аутентификации
/// по сертификату (ClientCertificateCredential ходит в Azure AD за токеном перед
/// каждым запросом), а вот разбор уже полученного JSON — чистая функция.
///
/// Это самое непроверенное место всей интеграции: форма ответа Search REST
/// (_api/search/query) задокументирована, но подтвердить её точное соответствие
/// без реального тенанта нельзя — только этими тестами на образцах ответа.
/// </summary>
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();
}
}