3c3c7caa27
Экран «Заявки по участнику» показывал пустой список: чтение 18 типов заявок шло разом, ошибки SharePoint глотались молча. Теперь не более 3 типов одновременно, а пропущенные типы/списки пишутся в лог. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
356 lines
18 KiB
C#
356 lines
18 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
|
||
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
|
||
/// текущего пользователя.
|
||
///
|
||
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
|
||
/// (см. requestManageService.ts) от имени залогиненного администратора — и
|
||
/// список видимых заявок молча урезался правами САМОГО администратора на списки
|
||
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
|
||
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
|
||
/// фронтенде через PnP — сюда вынесено только чтение.
|
||
/// </summary>
|
||
public sealed class RequestManageService(
|
||
ApprovalRouteDbContext db,
|
||
ISharePointClient sharePoint,
|
||
ListPartitionRegistry partitionRegistry,
|
||
ILogger<RequestManageService> 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);
|
||
|
||
/// <summary>
|
||
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
|
||
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
|
||
/// </summary>
|
||
public async Task<Result<ManagedRequestPageDto>> 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<ManagedRequestPageDto>(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<ManagedRequestSummaryDto>();
|
||
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));
|
||
}
|
||
|
||
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
|
||
public async Task<Result<ManagedRequestMatrixDto>> 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<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||
|
||
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||
return Result.Fail<ManagedRequestMatrixDto>(
|
||
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<ManagedRequestMatrixDto>(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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Активные строки согласования данного человека сразу по всем типам заявок и
|
||
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
|
||
/// </summary>
|
||
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
|
||
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
|
||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
|
||
|
||
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<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
|
||
}
|
||
|
||
private async Task<IReadOnlyList<UserApprovalRowDto>> 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<IReadOnlyList<UserApprovalRowDto>> 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<UserApprovalRowDto>();
|
||
foreach (var (dataListId, dataListTitle) in partitions)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(dataListTitle)) continue;
|
||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||
if (approvalListTitle is null) continue;
|
||
|
||
IReadOnlyList<JsonElement> 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<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||
{
|
||
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
|
||
|
||
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);
|
||
}
|
||
|
||
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
|
||
private async Task<IReadOnlyList<string>> 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<string> 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<string> 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;
|
||
}
|
||
}
|