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;
}
}