diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs
new file mode 100644
index 0000000..c3011f9
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/BasisRequestsController.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Auth;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.BasisRequests;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+///
+/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо
+/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку.
+/// Только пользовательский токен: без email права проверять не для кого.
+///
+[ApiController]
+[Authorize(Policy = AuthPolicies.Delegated)]
+[Route("api/basis-requests")]
+[Produces("application/json")]
+public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase
+{
+ /// Заявки типа requestTypeId с BasisKey = basisKey по всем партициям.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ public async Task Get(
+ [FromQuery] int requestTypeId,
+ // string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search).
+ [FromQuery] string? basisKey,
+ CancellationToken ct) =>
+ (await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult();
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
index 64527fc..f556e81 100644
--- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Approvals;
+using Tnsu.ApprovalRoute.Application.BasisRequests;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports;
@@ -21,6 +22,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
diff --git a/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs
new file mode 100644
index 0000000..210152c
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestDtos.cs
@@ -0,0 +1,20 @@
+namespace Tnsu.ApprovalRoute.Application.BasisRequests;
+
+///
+/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
+/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
+/// на саму заявку фронтенд показывает только при CanOpen.
+///
+/// Id элемента списка данных.
+/// Серверный относительный URL списка партиции, где лежит заявка.
+/// RequestUniqueCode; null — номер ещё не присвоен.
+/// RequestStatus заявки.
+/// Сумма заявки (PayAmount).
+/// У вызывающего есть право просмотра элемента.
+public sealed record BasisRequestDto(
+ int Id,
+ string ListId,
+ string? UniqueCode,
+ string Status,
+ double Amount,
+ bool CanOpen);
diff --git a/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs
new file mode 100644
index 0000000..77cd100
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/BasisRequests/BasisRequestsService.cs
@@ -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;
+
+///
+/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:<Id>»,
+/// «need:<oid>») — для таблицы прежних оплат в «Заявке на оплату».
+///
+/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
+/// под своей учёткой заявитель видел бы только собственные заявки — сумма
+/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
+/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
+/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
+///
+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>> GetByBasisAsync(
+ int requestTypeId, string basisKey, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(basisKey))
+ return Result.Fail>(new ValidationError("Не указано основание (basisKey)."));
+
+ var requestType = await db.RequestTypes.AsNoTracking()
+ .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
+ if (requestType is null)
+ return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId));
+
+ if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
+ return Result.Ok>([]);
+
+ 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>(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;
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
index db6a2fb..dd5668b 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
@@ -54,6 +54,14 @@ public interface ISharePointClient
Task> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
+ ///
+ /// Может ли пользователь (по email) открыть элемент списка: эффективные права
+ /// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
+ /// Неизвестный SharePoint логин — не ошибка, а «прав нет».
+ ///
+ Task CanUserViewItemAsync(
+ string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
+
///
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
/// элементы, а не только доступные пользователю. Возвращает строки результата
@@ -229,6 +237,22 @@ public sealed class SharePointClient(
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
cancellationToken);
+ public async Task CanUserViewItemAsync(
+ string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
+ {
+ // Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
+ // @u: в пути функции «|» и «#» SharePoint не разбирает.
+ var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
+ var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
+ $"/getUserEffectivePermissions(@u)?@u='{login}'";
+ var json = await GetAsync(siteUrl, url, cancellationToken);
+
+ // BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
+ return json.TryGetProperty("Low", out var low)
+ && ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
+ && (bits & ViewListItemsMask) != 0;
+ }
+
public async Task>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken)
{
@@ -426,6 +450,8 @@ public sealed class SharePointClient(
}
}
+ private const ulong ViewListItemsMask = 0x1;
+
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
// без экранирования она сломала бы запрос.
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs
new file mode 100644
index 0000000..f41094b
--- /dev/null
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/BasisRequestsServiceTests.cs
@@ -0,0 +1,91 @@
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Shouldly;
+using Tnsu.ApprovalRoute.Application.BasisRequests;
+using Tnsu.ApprovalRoute.Application.ListPartitioning;
+using Tnsu.ApprovalRoute.Application.RequestTypes;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
+
+namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
+
+///
+/// Заявки по одному основанию: видны все (чтение от имени приложения), а
+/// CanOpen — только там, где у вызывающего есть права на элемент.
+///
+public sealed class BasisRequestsServiceTests : IClassFixture
+{
+ private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
+ private const string CallerEmail = "initiator@tnsu.kz";
+ private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464";
+
+ private readonly ServiceProvider _services;
+
+ public BasisRequestsServiceTests(PostgresDatabaseFixture fixture)
+ {
+ _services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail);
+ }
+
+ private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
+
+ private async Task<(BasisRequestsService Service, Mock SharePoint, int TypeId)> BuildAsync(
+ string name, string listUrl, IReadOnlyList