feat: эндпоинт заявок по основанию (СТ/потребность) с проверкой прав на открытие
GET /api/basis-requests?requestTypeId=&basisKey= читает заявки от имени приложения по всем партициям, а по каждой проверяет, может ли вызывающий её открыть. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
|
||||
/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
|
||||
/// на саму заявку фронтенд показывает только при CanOpen.
|
||||
/// </summary>
|
||||
/// <param name="Id">Id элемента списка данных.</param>
|
||||
/// <param name="ListId">Серверный относительный URL списка партиции, где лежит заявка.</param>
|
||||
/// <param name="UniqueCode">RequestUniqueCode; null — номер ещё не присвоен.</param>
|
||||
/// <param name="Status">RequestStatus заявки.</param>
|
||||
/// <param name="Amount">Сумма заявки (PayAmount).</param>
|
||||
/// <param name="CanOpen">У вызывающего есть право просмотра элемента.</param>
|
||||
public sealed record BasisRequestDto(
|
||||
int Id,
|
||||
string ListId,
|
||||
string? UniqueCode,
|
||||
string Status,
|
||||
double Amount,
|
||||
bool CanOpen);
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user