Искать строки согласования участника через Search с откатом на REST
Один запрос Search на сайт находит кандидатов по согласующему и статусам, дальше читаются только списки, где они нашлись. При ошибке Search — прежний обход партиций по REST. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user