using System.Collections.Concurrent; 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; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; namespace Tnsu.ApprovalRoute.Application.RequestManage; /// /// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок» /// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не /// текущего пользователя. /// /// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде /// (см. requestManageService.ts) от имени залогиненного администратора — и /// список видимых заявок молча урезался правами САМОГО администратора на списки /// SharePoint (item-level security), хотя вкладка задумана как «видно всё». /// Правка строк маршрута (переназначить/удалить/переставить) остаётся на /// фронтенде через PnP — сюда вынесено только чтение. /// public sealed class RequestManageService( ApprovalRouteDbContext db, ISharePointClient sharePoint, 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"; private const string StatusField = "ApprovalStatus"; private const string TicketIdField = "TicketId"; private const string DateField = "Date"; private const string CommentField = "Comment"; private const string OrderField = "StepOrder"; private const string AllowReassignField = "AllowReassignToOtherApprovers"; private const string PhaseField = "Phase"; private const string SlaHoursField = "SLAhours"; private const string AskOverdueReasonField = "AskOverdueReason"; private const string ApprovalRowSelect = $"$select=Id,Title,{StatusField},{DateField},{CommentField},{OrderField},{AllowReassignField},{PhaseField}," + $"{SlaHoursField},{AskOverdueReasonField},{TicketIdField},{ApproverField}/Id,{ApproverField}/Title,{ApproverField}/EMail" + $"&$expand={ApproverField}"; private const string DataRowSelect = "$select=Id,Title,RequestStatus,Created,Author/Title,RequestUniqueCode&$expand=Author"; // Список данных «<Форма> — данные» → список согласования «<Форма> — согласование» // (см. requestManageService.ts/DATA_LIST_SUFFIX). private static readonly Regex DataListSuffix = new(@"\s*—\s*данные\s*$", RegexOptions.Compiled); /// /// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая), /// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen). /// public async Task> SearchAsync( int requestTypeId, string query, int page, int? pageSize, CancellationToken cancellationToken) { var requestType = await db.RequestTypes.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) return Result.Ok(new ManagedRequestPageDto([], false)); var size = pageSize is > 0 ? pageSize.Value : DefaultPageSize; var siteUrl = requestType.SiteUrl; var partitionIds = await GetAllPartitionListIdsAsync(requestType, cancellationToken); var all = new List(); foreach (var listId in partitionIds) { var items = await sharePoint.GetItemsByListUrlAsync( siteUrl, listId, $"{DataRowSelect}&$top=5000", cancellationToken); all.AddRange(items.Select(i => ToSummary(i, listId))); } var words = SplitQueryWords(query); var filtered = all.Where(x => MatchesQuery(x, words)) // Тот же порядок, что и раньше (Search сортировал по Created по возрастанию). .OrderBy(x => x.Created, StringComparer.Ordinal) .ToList(); var pageItems = filtered.Skip((page - 1) * size).Take(size).ToList(); return Result.Ok(new ManagedRequestPageDto(pageItems, page * size < filtered.Count)); } /// Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. public async Task> GetMatrixAsync( int requestTypeId, string listId, int requestId, CancellationToken cancellationToken) { var requestType = await db.RequestTypes.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); if (string.IsNullOrWhiteSpace(requestType.SiteUrl)) return Result.Fail( new ValidationError($"У типа заявки {requestTypeId} не указан сайт SharePoint.")); var siteUrl = requestType.SiteUrl; var rawRequestItems = await sharePoint.GetItemsByListUrlAsync( siteUrl, listId, $"{DataRowSelect}&$filter=Id eq {requestId}&$top=1", cancellationToken); var rawRequest = rawRequestItems.FirstOrDefault(); if (rawRequest.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return Result.Fail(new NotFoundError("Заявка", requestId)); var request = ToSummary(rawRequest, listId); var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken) ?? string.Empty; var dataListUrl = ListAbsUrl(siteUrl, listId); var approvalListTitle = ApprovalListTitleFor(dataListTitle); if (approvalListTitle is null) return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, string.Empty, [])); var rawRows = await sharePoint.GetItemsByListTitleAsync( siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter={TicketIdField} eq {requestId}&$orderby={OrderField}", cancellationToken); var rows = rawRows.Select(ToApprovalRow).ToList(); return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows)); } /// /// Активные строки согласования данного человека сразу по всем типам заявок и /// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему». /// public async Task>> FindActiveApprovalRowsForApproverAsync( string approverEmail, IReadOnlyList activeStatuses, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0) return Result.Ok>([]); var requestTypes = await db.RequestTypes.AsNoTracking() .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); 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); // Search не поймать и не подменить обходом списков: если он недоступен, это // видно по ошибке запроса, а не по тихо неполному результату. var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken); return Result.Ok>(rows); } 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; } // Пара «список согласования → (его заголовок, адрес парного списка данных)» // не меняется, пока список не переименуют, — запоминаем на время жизни процесса, // чтобы не тратить два запроса к 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; 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 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, dataListUrl, StringComparison.OrdinalIgnoreCase) || archivedByType[rt.Id].Any(a => string.Equals(a.ListId, dataListUrl, 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, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(), cancellationToken); return matched .Select(r => ToUserRow( r, requestType, siteUrl, dataListUrl, approvalTitle, 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> ReadDataRowsAsync( string siteUrl, string listId, IReadOnlyList ticketIds, CancellationToken cancellationToken) { if (ticketIds.Count == 0) return new Dictionary(); var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}")); var items = await sharePoint.GetItemsByListUrlAsync( siteUrl, listId, $"{DataRowSelect}&$filter={filter}&$top={ticketIds.Count}", cancellationToken); return items.Select(i => ToSummary(i, listId)).ToDictionary(x => x.Id); } /// Все партиции типа заявок (архив + текущая), только их listId — для поиска. private async Task> GetAllPartitionListIdsAsync( RequestType requestType, CancellationToken cancellationToken) { var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken); return archived.Select(x => x.ListId).Append(current.ListId).ToList(); } private static ManagedRequestSummaryDto ToSummary(JsonElement item, string listId) => new( GetInt(item, "Id") ?? 0, GetString(item, "Title") is { Length: > 0 } title ? title : $"#{GetInt(item, "Id") ?? 0}", GetString(item, "RequestStatus") ?? string.Empty, GetString(item, "Created") ?? string.Empty, item.TryGetProperty("Author", out var author) && author.ValueKind == JsonValueKind.Object ? GetString(author, "Title") ?? string.Empty : string.Empty, GetString(item, "RequestUniqueCode"), listId); private static ManagedApprovalRowDto ToApprovalRow(JsonElement item) { var approver = item.TryGetProperty(ApproverField, out var a) && a.ValueKind == JsonValueKind.Object ? a : (JsonElement?)null; return new ManagedApprovalRowDto( GetInt(item, "Id") ?? 0, GetInt(item, TicketIdField) ?? 0, approver is { } ap ? GetInt(ap, "Id") : null, approver is { } an ? GetString(an, "Title") ?? string.Empty : string.Empty, approver is { } ae ? GetString(ae, "EMail") ?? string.Empty : string.Empty, GetString(item, "Title") ?? string.Empty, GetString(item, StatusField) ?? string.Empty, GetString(item, DateField) ?? string.Empty, GetString(item, CommentField) ?? string.Empty, GetDouble(item, OrderField) ?? 0, GetBool(item, AllowReassignField) ?? false, GetString(item, PhaseField) is { Length: > 0 } phase ? phase : "Согласование", GetInt(item, SlaHoursField), GetBool(item, AskOverdueReasonField)); } private static string? ApprovalListTitleFor(string dataListTitle) => DataListSuffix.IsMatch(dataListTitle) ? $"{DataListSuffix.Replace(dataListTitle, string.Empty)} — согласование" : null; private static string SiteOrigin(string siteUrl) { var match = Regex.Match(siteUrl, "^https?://[^/]+"); return match.Success ? match.Value : siteUrl; } private static string ListAbsUrl(string siteUrl, string listId) => $"{SiteOrigin(siteUrl)}{listId}"; // KQL во фронтенде терпел только буквы/цифры/пробелы (см. sanitizeForKql в // requestManageService.ts) — здесь та же санитизация для сравнения без учёта // регистра по Title/UniqueCode, только вместо полнотекстового индекса Search // сравниваем подстроки напрямую (см. MatchesQuery). private static IReadOnlyList SplitQueryWords(string query) { var sanitized = Regex.Replace(query, @"[^a-zA-Zа-яА-ЯёЁ0-9\s]", " "); return sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries); } private static bool MatchesQuery(ManagedRequestSummaryDto summary, IReadOnlyList words) { if (words.Count == 0) return true; var haystack = $"{summary.Title} {summary.UniqueCode}".ToLowerInvariant(); return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal)); } private static string? GetString(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; private static int? GetInt(JsonElement e, string name) { if (!e.TryGetProperty(name, out var v)) return null; if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i; if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var parsed)) return parsed; return null; } private static double? GetDouble(JsonElement e, string name) { if (!e.TryGetProperty(name, out var v)) return null; if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d; if (v.ValueKind == JsonValueKind.String && double.TryParse(v.GetString(), out var parsed)) return parsed; return null; } private static bool? GetBool(JsonElement e, string name) { if (!e.TryGetProperty(name, out var v)) return null; if (v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean(); if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var parsed)) return parsed; return null; } }