From 3c3c7caa271d762097d26158559c01994bac49d4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 14:53:54 +0500 Subject: [PATCH 1/6] =?UTF-8?q?=D0=9E=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BC=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B8=20=D0=B2=20by-approver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран «Заявки по участнику» показывал пустой список: чтение 18 типов заявок шло разом, ошибки SharePoint глотались молча. Теперь не более 3 типов одновременно, а пропущенные типы/списки пишутся в лог. Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 609b1ad..50e18aa 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Domain.Entities; @@ -25,10 +26,16 @@ namespace Tnsu.ApprovalRoute.Application.RequestManage; public sealed class RequestManageService( ApprovalRouteDbContext db, ISharePointClient sharePoint, - ListPartitionRegistry partitionRegistry) + ListPartitionRegistry partitionRegistry, + ILogger logger) { private const int DefaultPageSize = 20; + // Сколько типов заявок читаем из SharePoint одновременно. Без ограничения + // 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались + // и экран показывал пустой список. + private const int MaxParallelTypes = 3; + // Внутренние имена колонок списка согласования — 1:1 с FIELDS в // requestManageService.ts (тот же самый список SharePoint, тот же провижининг). private const string ApproverField = "TansuSiteApprover"; @@ -145,8 +152,19 @@ public sealed class RequestManageService( var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'")); var email = approverEmail.Trim(); - var results = await Task.WhenAll( - requestTypes.Select(rt => FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken))); + using var gate = new SemaphoreSlim(MaxParallelTypes); + var results = await Task.WhenAll(requestTypes.Select(async rt => + { + await gate.WaitAsync(cancellationToken); + try + { + return await FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken); + } + finally + { + gate.Release(); + } + })); return Result.Ok>(results.SelectMany(r => r).ToList()); } @@ -158,10 +176,11 @@ public sealed class RequestManageService( { return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken); } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { // Один недоступный/переименованный список не должен обнулять результат по // остальным типам заявок — та же терпимость, что и в ReminderService. + logger.LogWarning(ex, "by-approver: тип заявок {RequestTypeId} «{RequestTypeName}» пропущен", requestType.Id, requestType.Name); return []; } } @@ -190,9 +209,11 @@ public sealed class RequestManageService( hits = await sharePoint.GetItemsByListTitleAsync( siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken); } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { - continue; // партиция сломана/переименована вручную — остальные не теряем. + // партиция сломана/переименована вручную — остальные не теряем. + logger.LogWarning(ex, "by-approver: список «{ApprovalList}» пропущен", approvalListTitle); + continue; } var matched = hits -- 2.34.1 From feb95f5621306fe1341ab704ded3709a8607c577 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 15:24:57 +0500 Subject: [PATCH 2/6] =?UTF-8?q?=D0=A7=D0=B8=D1=82=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=86=D0=B8=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=91=D0=94=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=B2=20by-?= =?UTF-8?q?approver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DbContext не потокобезопасен: параллельные задачи по типам заявок падали с «вторая операция на контексте», а пустой catch превращал это в пустой список. Теперь БД читается последовательно, параллельно идёт только SharePoint. Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 50e18aa..4cc7af0 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -152,13 +152,21 @@ public sealed class RequestManageService( var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'")); var email = approverEmail.Trim(); + // DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО, + // и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше + // GetArchivedAsync вызывался из параллельных задач — «вторая операция на + // контексте» падала, а пустой catch превращал это в пустой список. + var archivedByType = new Dictionary>(); + foreach (var rt in requestTypes) + archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken); + using var gate = new SemaphoreSlim(MaxParallelTypes); var results = await Task.WhenAll(requestTypes.Select(async rt => { await gate.WaitAsync(cancellationToken); try { - return await FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken); + return await FindForTypeSafeAsync(rt, archivedByType[rt.Id], statusFilter, email, cancellationToken); } finally { @@ -170,11 +178,11 @@ public sealed class RequestManageService( } private async Task> FindForTypeSafeAsync( - RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken) + RequestType requestType, IReadOnlyList archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) { try { - return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken); + return await FindForTypeAsync(requestType, archived, statusFilter, approverEmail, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -186,10 +194,9 @@ public sealed class RequestManageService( } private async Task> FindForTypeAsync( - RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken) + RequestType requestType, IReadOnlyList archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) { var siteUrl = requestType.SiteUrl!; - var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken); var partitions = archived -- 2.34.1 From 4285a8400511fe241e7951ce87e606603f3be31c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 15:36:01 +0500 Subject: [PATCH 3/6] =?UTF-8?q?=D0=98=D1=81=D0=BA=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=81=D0=BE=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Search=20=D1=81=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=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( -- 2.34.1 From 90b58ee0916a0ce7db3dd397c5146cf7aae3210c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 15:42:23 +0500 Subject: [PATCH 4/6] =?UTF-8?q?=D0=AF=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D0=B8=D0=BD=D0=B3=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B5=20by-approver?= =?UTF-8?q?=20=D1=81=20Search=20=D0=BD=D0=B0=20REST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 7362789..3a988f7 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -171,7 +171,9 @@ public sealed class RequestManageService( catch (Exception ex) when (ex is not OperationCanceledException) { // Search недоступен/сменил формат — читаем списки согласования напрямую. - logger.LogWarning(ex, "by-approver: Search не сработал, читаем списки через REST"); + logger.LogWarning( + ex, "by-approver: Search не сработал для {ApproverEmail} — переключаюсь на чтение списков через REST (медленнее)", + email); } var results = await Task.WhenAll(requestTypes.Select(async rt => -- 2.34.1 From 99ce7ed9b2594a31acd9c4cf6e158df7cb63602a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 15:51:27 +0500 Subject: [PATCH 5/6] =?UTF-8?q?=D0=9A=D0=B5=D1=88=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D1=83=20=C2=AB=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=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=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=C2=BB=20=D0=B2=20by-approver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 3a988f7..fd9667b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using System.Text.RegularExpressions; using FluentResults; @@ -266,6 +267,29 @@ public sealed class RequestManageService( return result; } + // Пара «список согласования → (его заголовок, адрес парного списка данных)» + // не меняется, пока список не переименуют, — запоминаем на время жизни процесса, + // чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка. + private static readonly ConcurrentDictionary ListPairs = + new(StringComparer.OrdinalIgnoreCase); + + private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync( + string siteUrl, string approvalListUrl, CancellationToken cancellationToken) + { + if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached; + + var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken); + if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null; + + var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные"); + var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken); + if (dataList is null) return null; + + var pair = (approvalList.Title, dataList.ServerRelativeUrl); + ListPairs[approvalListUrl] = pair; + return pair; + } + private static string? ListUrlFromHitPath(string? path) { if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null; @@ -287,18 +311,15 @@ public sealed class RequestManageService( { 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 pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken); + if (pair is null) return []; + var (approvalTitle, dataListUrl) = pair.Value; // Какому типу заявок принадлежит эта партиция (текущая или архивная). var requestType = siteTypes.FirstOrDefault(rt => - string.Equals(rt.ListId, dataList.ServerRelativeUrl, StringComparison.OrdinalIgnoreCase) + string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase) || archivedByType[rt.Id].Any(a => - string.Equals(a.ListId, dataList.ServerRelativeUrl, StringComparison.OrdinalIgnoreCase))); + string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase))); if (requestType is null) return []; var rows = new List(); @@ -318,12 +339,12 @@ public sealed class RequestManageService( if (matched.Count == 0) return []; var dataById = await ReadDataRowsAsync( - siteUrl, dataList.ServerRelativeUrl, matched.Select(r => r.TicketId).Distinct().ToList(), + siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(), cancellationToken); return matched .Select(r => ToUserRow( - r, requestType, siteUrl, dataList.ServerRelativeUrl, approvalList.Title, + r, requestType, siteUrl, dataListUrl, approvalTitle, dataById.GetValueOrDefault(r.TicketId))) .ToList(); } -- 2.34.1 From e8a06c0b8910231523e1fb6959d9e43a8cd26d42 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 16:07:13 +0500 Subject: [PATCH 6/6] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20REST?= =?UTF-8?q?-=D0=BF=D0=BE=D0=B4=D1=81=D1=82=D1=80=D0=B0=D1=85=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20by-approver:=20=D1=82=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20Search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../RequestManage/RequestManageService.cs | 97 +------------------ 1 file changed, 4 insertions(+), 93 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index fd9667b..a9cfb4b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -150,7 +150,6 @@ public sealed class RequestManageService( .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); - var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'")); var email = approverEmail.Trim(); // DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО, @@ -163,34 +162,10 @@ public sealed class RequestManageService( 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 не сработал для {ApproverEmail} — переключаюсь на чтение списков через REST (медленнее)", - email); - } - - var results = await Task.WhenAll(requestTypes.Select(async rt => - { - await gate.WaitAsync(cancellationToken); - try - { - return await FindForTypeSafeAsync(rt, archivedByType[rt.Id], statusFilter, email, cancellationToken); - } - finally - { - gate.Release(); - } - })); - - return Result.Ok>(results.SelectMany(r => r).ToList()); + // Search не поймать и не подменить обходом списков: если он недоступен, это + // видно по ошибке запроса, а не по тихо неполному результату. + var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken); + return Result.Ok>(rows); } private const string SearchApproverProperty = "TansuSiteApproverOWSUSER"; @@ -355,68 +330,6 @@ public sealed class RequestManageService( } } - private async Task> FindForTypeSafeAsync( - RequestType requestType, IReadOnlyList archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) - { - try - { - return await FindForTypeAsync(requestType, archived, statusFilter, approverEmail, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Один недоступный/переименованный список не должен обнулять результат по - // остальным типам заявок — та же терпимость, что и в ReminderService. - logger.LogWarning(ex, "by-approver: тип заявок {RequestTypeId} «{RequestTypeName}» пропущен", requestType.Id, requestType.Name); - return []; - } - } - - private async Task> FindForTypeAsync( - RequestType requestType, IReadOnlyList archived, string statusFilter, string approverEmail, CancellationToken cancellationToken) - { - var siteUrl = requestType.SiteUrl!; - var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken); - - var partitions = archived - .Select(x => (ListId: x.ListId, Title: x.Title)) - .Append((current.ListId, current.Title)); - - var result = new List(); - foreach (var (dataListId, dataListTitle) in partitions) - { - if (string.IsNullOrWhiteSpace(dataListTitle)) continue; - var approvalListTitle = ApprovalListTitleFor(dataListTitle); - if (approvalListTitle is null) continue; - - IReadOnlyList hits; - try - { - hits = await sharePoint.GetItemsByListTitleAsync( - siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // партиция сломана/переименована вручную — остальные не теряем. - logger.LogWarning(ex, "by-approver: список «{ApprovalList}» пропущен", approvalListTitle); - continue; - } - - var matched = hits - .Select(ToApprovalRow) - .Where(r => string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (matched.Count == 0) continue; - - var ticketIds = matched.Select(r => r.TicketId).Distinct().ToList(); - var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken); - - result.AddRange(matched.Select(r => - ToUserRow(r, requestType, siteUrl, dataListId, approvalListTitle, dataById.GetValueOrDefault(r.TicketId)))); - } - - return result; - } - private async Task> ReadDataRowsAsync( string siteUrl, string listId, IReadOnlyList ticketIds, CancellationToken cancellationToken) { @@ -502,8 +415,6 @@ public sealed class RequestManageService( return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal)); } - private static string Escape(string value) => value.Replace("'", "''"); - private static string? GetString(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; -- 2.34.1