a46782920b
GET /api/basis-requests?requestTypeId=&basisKey= читает заявки от имени приложения по всем партициям, а по каждой проверяет, может ли вызывающий её открыть. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
5.8 KiB
C#
117 lines
5.8 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using FluentResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
|
|
|
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
|
|
|
/// <summary>
|
|
/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:<Id>»,
|
|
/// «need:<oid>») — для таблицы прежних оплат в «Заявке на оплату».
|
|
///
|
|
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
|
|
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
|
|
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
|
|
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
|
|
/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
|
|
/// </summary>
|
|
public sealed class BasisRequestsService(
|
|
ApprovalRouteDbContext db,
|
|
ISharePointClient sharePoint,
|
|
ListPartitionRegistry partitionRegistry,
|
|
ICurrentUser currentUser)
|
|
{
|
|
private const string BasisKeyField = "BasisKey";
|
|
private const string AmountField = "PayAmount";
|
|
|
|
// Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями.
|
|
private const int MaxItemsPerPartition = 500;
|
|
|
|
// Проверка прав — отдельный запрос на элемент; ограничиваем параллельность,
|
|
// чтобы не упереться в троттлинг SharePoint (429).
|
|
private const int MaxParallelPermissionChecks = 4;
|
|
|
|
public async Task<Result<IReadOnlyList<BasisRequestDto>>> GetByBasisAsync(
|
|
int requestTypeId, string basisKey, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(basisKey))
|
|
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new ValidationError("Не указано основание (basisKey)."));
|
|
|
|
var requestType = await db.RequestTypes.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
|
if (requestType is null)
|
|
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
|
|
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
|
return Result.Ok<IReadOnlyList<BasisRequestDto>>([]);
|
|
|
|
var siteUrl = requestType.SiteUrl;
|
|
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
|
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
|
var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
|
|
|
// Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее.
|
|
var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''"));
|
|
var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" +
|
|
$"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}";
|
|
|
|
var found = new List<(JsonElement Item, string ListId)>();
|
|
foreach (var listId in listIds)
|
|
{
|
|
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken);
|
|
found.AddRange(items.Select(i => (i, listId)));
|
|
}
|
|
|
|
var email = currentUser.Email;
|
|
using var gate = new SemaphoreSlim(MaxParallelPermissionChecks);
|
|
var result = await Task.WhenAll(found.Select(async x =>
|
|
{
|
|
var id = GetInt(x.Item, "Id") ?? 0;
|
|
var canOpen = false;
|
|
if (!string.IsNullOrWhiteSpace(email) && id > 0)
|
|
{
|
|
await gate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
}
|
|
|
|
return new BasisRequestDto(
|
|
id,
|
|
x.ListId,
|
|
GetString(x.Item, "RequestUniqueCode"),
|
|
GetString(x.Item, "RequestStatus") ?? string.Empty,
|
|
GetDouble(x.Item, AmountField) ?? 0,
|
|
canOpen);
|
|
}));
|
|
|
|
return Result.Ok<IReadOnlyList<BasisRequestDto>>(result);
|
|
}
|
|
|
|
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) =>
|
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : 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(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
|
return parsed;
|
|
return null;
|
|
}
|
|
}
|