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 statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'")); var email = approverEmail.Trim(); 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()); } private async Task> FindForTypeSafeAsync( RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken) { try { return await FindForTypeAsync(requestType, 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, 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 .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 => { 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; } 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 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; 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; } }