Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs
T
gorbenko.pavel feb95f5621
Tests / frontend-test (pull_request) Successful in 47s
Tests / backend-test (pull_request) Successful in 1m33s
Читать партиции из БД последовательно в by-approver
DbContext не потокобезопасен: параллельные задачи по типам заявок падали
с «вторая операция на контексте», а пустой catch превращал это в пустой список.
Теперь БД читается последовательно, параллельно идёт только SharePoint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:24:57 +05:00

363 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
// контексте» падала, а пустой catch превращал это в пустой список.
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
foreach (var rt in requestTypes)
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
using var gate = new SemaphoreSlim(MaxParallelTypes);
var results = await Task.WhenAll(requestTypes.Select(async rt =>
{
await gate.WaitAsync(cancellationToken);
try
{
return await FindForTypeSafeAsync(rt, archivedByType[rt.Id], statusFilter, email, cancellationToken);
}
finally
{
gate.Release();
}
}));
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
}
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync(
RequestType requestType, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{
try
{
return await FindForTypeAsync(requestType, archived, 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, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{
var siteUrl = requestType.SiteUrl!;
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;
}
}