Искать строки согласования участника через Search с откатом на REST

Один запрос Search на сайт находит кандидатов по согласующему и статусам,
дальше читаются только списки, где они нашлись. При ошибке Search — прежний
обход партиций по REST.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 15:36:01 +05:00
parent feb95f5621
commit 4285a84005
2 changed files with 216 additions and 8 deletions
@@ -161,6 +161,19 @@ public sealed class RequestManageService(
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken); archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
using var gate = new SemaphoreSlim(MaxParallelTypes); using var gate = new SemaphoreSlim(MaxParallelTypes);
try
{
var viaSearch = await FindViaSearchAsync(
requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(viaSearch);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Search недоступен/сменил формат — читаем списки согласования напрямую.
logger.LogWarning(ex, "by-approver: Search не сработал, читаем списки через REST");
}
var results = await Task.WhenAll(requestTypes.Select(async rt => var results = await Task.WhenAll(requestTypes.Select(async rt =>
{ {
await gate.WaitAsync(cancellationToken); await gate.WaitAsync(cancellationToken);
@@ -177,6 +190,148 @@ public sealed class RequestManageService(
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList()); return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
} }
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
private static UserApprovalRowDto ToUserRow(
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
ManagedRequestSummaryDto? dataRow) =>
new(
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
/// <summary>
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
/// </summary>
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
IReadOnlyList<RequestType> requestTypes,
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
IReadOnlyList<string> activeStatuses,
string approverEmail,
SemaphoreSlim gate,
CancellationToken cancellationToken)
{
var email = approverEmail.Replace("\"", string.Empty);
var statusQuery = string.Join(
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
var result = new List<UserApprovalRowDto>();
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
{
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
var byList = hits
.Select(h => (
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
.Where(x => x.Url is not null && x.Id > 0)
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
.ToList();
var siteTypes = requestTypes
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
.ToList();
var perList = await Task.WhenAll(byList.Select(async group =>
{
await gate.WaitAsync(cancellationToken);
try
{
return await ReadSearchHitListAsync(
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
activeStatuses, approverEmail, cancellationToken);
}
finally
{
gate.Release();
}
}));
result.AddRange(perList.SelectMany(x => x));
}
return result;
}
private static string? ListUrlFromHitPath(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
var local = Uri.UnescapeDataString(uri.AbsolutePath);
var cut = local.LastIndexOf('/');
return cut > 0 ? local[..cut] : null;
}
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
string siteUrl,
string approvalListUrl,
IReadOnlyList<int> itemIds,
IReadOnlyList<RequestType> siteTypes,
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
IReadOnlyList<string> activeStatuses,
string approverEmail,
CancellationToken cancellationToken)
{
try
{
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return [];
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
if (dataList is null) return [];
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
var requestType = siteTypes.FirstOrDefault(rt =>
string.Equals(rt.ListId, dataList.ServerRelativeUrl, StringComparison.OrdinalIgnoreCase)
|| archivedByType[rt.Id].Any(a =>
string.Equals(a.ListId, dataList.ServerRelativeUrl, StringComparison.OrdinalIgnoreCase)));
if (requestType is null) return [];
var rows = new List<ManagedApprovalRowDto>();
foreach (var chunk in itemIds.Chunk(50))
{
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
var items = await sharePoint.GetItemsByListUrlAsync(
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
cancellationToken);
rows.AddRange(items.Select(ToApprovalRow));
}
var matched = rows
.Where(r => activeStatuses.Contains(r.Status)
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matched.Count == 0) return [];
var dataById = await ReadDataRowsAsync(
siteUrl, dataList.ServerRelativeUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
cancellationToken);
return matched
.Select(r => ToUserRow(
r, requestType, siteUrl, dataList.ServerRelativeUrl, approvalList.Title,
dataById.GetValueOrDefault(r.TicketId)))
.ToList();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
return [];
}
}
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync( private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync(
RequestType requestType, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) RequestType requestType, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{ {
@@ -233,14 +388,7 @@ public sealed class RequestManageService(
var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken); var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken);
result.AddRange(matched.Select(r => result.AddRange(matched.Select(r =>
{ ToUserRow(r, requestType, siteUrl, dataListId, approvalListTitle, dataById.GetValueOrDefault(r.TicketId))));
var dataRow = dataById.GetValueOrDefault(r.TicketId);
return new UserApprovalRowDto(
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
}));
} }
return result; return result;
@@ -54,6 +54,15 @@ public interface ISharePointClient
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>
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
/// элементы, а не только доступные пользователю. Возвращает строки результата
/// как «имя managed property → значение» (RowLimit — не больше 500).
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
/// </summary>
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный /// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
/// объект «внутреннее имя колонки → значение»; перечисленные колонки /// объект «внутреннее имя колонки → значение»; перечисленные колонки
@@ -220,6 +229,57 @@ public sealed class SharePointClient(
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
cancellationToken); cancellationToken);
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
{
var body = JsonSerializer.Serialize(new
{
request = new
{
Querytext = queryText,
RowLimit = 500,
SelectProperties = selectProperties,
TrimDuplicates = false
}
});
var json = await SendAsync(
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
allowNotFound: false, cancellationToken);
var result = new List<IReadOnlyDictionary<string, string?>>();
if (json is null
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|| !relevant.TryGetProperty("Table", out var table)
|| !table.TryGetProperty("Rows", out var rows))
return result;
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
rows = wrapped;
if (rows.ValueKind != JsonValueKind.Array) return result;
foreach (var row in rows.EnumerateArray())
{
if (!row.TryGetProperty("Cells", out var cells)) continue;
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
cells = wrappedCells;
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var cell in cells.EnumerateArray())
{
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
if (key is null) continue;
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
result.Add(dict);
}
return result;
}
public Task UpdateItemByListTitleAsync( public Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) => string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync( MergeAsync(