заработало
This commit is contained in:
@@ -78,18 +78,3 @@ internal static class ApprovalStatuses
|
||||
public const string OnApproval = "На согласовании";
|
||||
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.
|
||||
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<Result<IReadOnlyList<ReminderDto>>> 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);
|
||||
}
|
||||
|
||||
/// <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(
|
||||
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -12,12 +12,6 @@ public interface ISharePointClient
|
||||
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||
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>
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||
@@ -25,17 +19,6 @@ public interface ISharePointClient
|
||||
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||
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>
|
||||
@@ -67,81 +50,6 @@ public sealed class SharePointClient(
|
||||
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(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||
GetItemsAsync(
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Даёт тестам доступ к internal-функциям чистого разбора JSON в SharePointClient
|
||||
(SharePointClientParsingTests) — сам клиент не потестировать без живого
|
||||
тенанта (аутентификация по сертификату ходит в Azure AD), а разбор ответа
|
||||
можно и нужно проверять без сети. -->
|
||||
<InternalsVisibleTo Include="Tnsu.ApprovalRoute.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
|
||||
+3
-16
@@ -77,28 +77,15 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DataListTitle);
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny<CancellationToken>()))
|
||||
.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<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
|
||||
.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());
|
||||
|
||||
sharePoint
|
||||
|
||||
-99
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user