Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs
T
gorbenko.pavel a46782920b
Tests / backend-test (pull_request) Successful in 2m10s
Tests / frontend-test (pull_request) Successful in 3m53s
feat: эндпоинт заявок по основанию (СТ/потребность) с проверкой прав на открытие
GET /api/basis-requests?requestTypeId=&basisKey= читает заявки от имени приложения
по всем партициям, а по каждой проверяет, может ли вызывающий её открыть.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:58:42 +05:00

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:&lt;Id&gt;»,
/// «need:&lt;oid&gt;») — для таблицы прежних оплат в «Заявке на оплату».
///
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
/// есть права: 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;
}
}