From 4285a8400511fe241e7951ce87e606603f3be31c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 15:36:01 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BA=D0=B0=D1=82=D1=8C=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20Search=20=D1=81=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=20REST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один запрос Search на сайт находит кандидатов по согласующему и статусам, дальше читаются только списки, где они нашлись. При ошибке Search — прежний обход партиций по REST. Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 164 +++++++++++++++++- .../SharePoint/SharePointClient.cs | 60 +++++++ 2 files changed, 216 insertions(+), 8 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 4cc7af0..7362789 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -161,6 +161,19 @@ public sealed class RequestManageService( archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken); using var gate = new SemaphoreSlim(MaxParallelTypes); + + try + { + var viaSearch = await FindViaSearchAsync( + requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken); + return Result.Ok>(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 => { await gate.WaitAsync(cancellationToken); @@ -177,6 +190,148 @@ public sealed class RequestManageService( return Result.Ok>(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); + + /// + /// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН + /// запрос Search на сайт (по человеку и статусам, сразу по всем спискам + /// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования + /// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные + /// строки дочитываются по REST — только в тех списках, где они реально нашлись. + /// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST. + /// + private async Task> FindViaSearchAsync( + IReadOnlyList requestTypes, + IReadOnlyDictionary> archivedByType, + IReadOnlyList 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(); + 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> ReadSearchHitListAsync( + string siteUrl, + string approvalListUrl, + IReadOnlyList itemIds, + IReadOnlyList siteTypes, + IReadOnlyDictionary> archivedByType, + IReadOnlyList 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(); + 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> FindForTypeSafeAsync( RequestType requestType, IReadOnlyList archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) { @@ -233,14 +388,7 @@ public sealed class RequestManageService( var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken); result.AddRange(matched.Select(r => - { - 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); - })); + ToUserRow(r, requestType, siteUrl, dataListId, approvalListTitle, dataById.GetValueOrDefault(r.TicketId)))); } return result; diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index 5574f75..db6a2fb 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -54,6 +54,15 @@ public interface ISharePointClient Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); + /// + /// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ + /// элементы, а не только доступные пользователю. Возвращает строки результата + /// как «имя managed property → значение» (RowLimit — не больше 500). + /// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST. + /// + Task>> SearchAsync( + string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken); + /// /// Обновить поля элемента списка, найденного по заголовку. fields — анонимный /// объект «внутреннее имя колонки → значение»; перечисленные колонки @@ -220,6 +229,57 @@ public sealed class SharePointClient( $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", cancellationToken); + public async Task>> SearchAsync( + string siteUrl, string queryText, IReadOnlyList 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>(); + 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(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( string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) => MergeAsync(