diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs
new file mode 100644
index 0000000..680f348
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs
@@ -0,0 +1,62 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.RequestManage;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+///
+/// Чтение заявок и маршрутов согласования от имени приложения (см.
+/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
+/// согласующему»: результат не зависит от прав вызывающего пользователя на
+/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
+///
+[ApiController]
+[Authorize]
+[Route("api/request-manage")]
+[Produces("application/json")]
+public sealed class RequestManageController(RequestManageService service) : ControllerBase
+{
+ /// Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.
+ [HttpGet("search")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Search(
+ [FromQuery] int requestTypeId,
+ // string? (не string) — [ApiController] с Nullable enable иначе считает
+ // query обязательным параметром модели, а IHttpClient.buildUrl (см.
+ // httpClient.ts) вырезает из URL пустые query-параметры целиком: пустое
+ // поле поиска (обычное состояние при открытии комбобокса) присылало
+ // запрос вовсе БЕЗ query, и такой запрос падал 400 ещё до контроллера.
+ [FromQuery] string? query,
+ [FromQuery] int page,
+ [FromQuery] int? pageSize,
+ CancellationToken ct) =>
+ (await service.SearchAsync(requestTypeId, query ?? string.Empty, page < 1 ? 1 : page, pageSize, ct)).ToActionResult();
+
+ /// Заявка + полный маршрут согласования (обе фазы, по StepOrder).
+ [HttpGet("matrix")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ public async Task Matrix(
+ [FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
+ (await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
+
+ ///
+ /// Активные строки согласования одного человека сразу по всем типам заявок и
+ /// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
+ ///
+ [HttpGet("by-approver")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task ByApprover(
+ [FromQuery] string approverEmail, [FromQuery] string? statuses, CancellationToken ct)
+ {
+ var activeStatuses = (statuses ?? string.Empty)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToList();
+
+ return (await service.FindActiveApprovalRowsForApproverAsync(approverEmail ?? string.Empty, activeStatuses, ct))
+ .ToActionResult();
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
index 8be9da0..5d50eff 100644
--- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
@@ -5,6 +5,7 @@ using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports;
+using Tnsu.ApprovalRoute.Application.RequestManage;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -17,6 +18,7 @@ public static class ApplicationServiceCollectionExtensions
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs
new file mode 100644
index 0000000..2cbc90e
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs
@@ -0,0 +1,76 @@
+namespace Tnsu.ApprovalRoute.Application.RequestManage;
+
+// Формы 1:1 с фронтендом (frontend/src/webparts/approvalAdmin/models/requestManage.ts,
+// manageByUser.ts) — эти DTO заменяют собой то, что раньше собирал PnP-клиент
+// НАПРЯМУЮ в SharePoint от имени текущего пользователя (см. requestManageService.ts):
+// тут это делает бэкенд от имени приложения (см. ISharePointClient), поэтому
+// результат не зависит от прав конкретного пользователя на списки SharePoint.
+
+/// Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.
+public sealed record ManagedRequestSummaryDto(
+ int Id,
+ string Title,
+ string Status,
+ /// ISO 8601 или пустая строка.
+ string Created,
+ string AuthorName,
+ string? UniqueCode,
+ /// Партиция (серверный относительный url списка данных), где физически лежит заявка.
+ string ListId);
+
+public sealed record ManagedRequestPageDto(IReadOnlyList Items, bool HasMore);
+
+/// Строка списка «… — согласование».
+public sealed record ManagedApprovalRowDto(
+ int Id,
+ int TicketId,
+ int? ApproverId,
+ string ApproverName,
+ string ApproverEmail,
+ string Position,
+ string Status,
+ /// ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.
+ string DateIso,
+ string Comment,
+ double Order,
+ bool AllowReassign,
+ string Phase,
+ int? SlaHours,
+ bool? AskOverdueReason);
+
+/// Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.
+public sealed record ManagedRequestMatrixDto(
+ ManagedRequestSummaryDto Request,
+ string SiteUrl,
+ string DataListTitle,
+ string DataListUrl,
+ string ApprovalListTitle,
+ IReadOnlyList Rows);
+
+///
+/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
+/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
+/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
+///
+public sealed record UserApprovalRowDto(
+ int Id,
+ int TicketId,
+ int? ApproverId,
+ string ApproverName,
+ string ApproverEmail,
+ string Position,
+ string Status,
+ string DateIso,
+ string Comment,
+ double Order,
+ bool AllowReassign,
+ string Phase,
+ int? SlaHours,
+ bool? AskOverdueReason,
+ int RequestTypeId,
+ string RequestTypeName,
+ string SiteUrl,
+ string ListId,
+ string ApprovalListTitle,
+ string RequestTitle,
+ string? RequestUniqueCode);
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs
new file mode 100644
index 0000000..609b1ad
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs
@@ -0,0 +1,334 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using FluentResults;
+using Microsoft.EntityFrameworkCore;
+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;
+
+///
+/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
+/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
+/// текущего пользователя.
+///
+/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
+/// (см. requestManageService.ts) от имени залогиненного администратора — и
+/// список видимых заявок молча урезался правами САМОГО администратора на списки
+/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
+/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
+/// фронтенде через PnP — сюда вынесено только чтение.
+///
+public sealed class RequestManageService(
+ ApprovalRouteDbContext db,
+ ISharePointClient sharePoint,
+ ListPartitionRegistry partitionRegistry)
+{
+ private const int DefaultPageSize = 20;
+
+ // Внутренние имена колонок списка согласования — 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);
+
+ ///
+ /// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
+ /// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
+ ///
+ public async Task> 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(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();
+ 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));
+ }
+
+ /// Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
+ public async Task> 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(new NotFoundError("Тип заявки", requestTypeId));
+
+ if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
+ return Result.Fail(
+ 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(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));
+ }
+
+ ///
+ /// Активные строки согласования данного человека сразу по всем типам заявок и
+ /// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
+ ///
+ public async Task>> FindActiveApprovalRowsForApproverAsync(
+ string approverEmail, IReadOnlyList activeStatuses, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
+ return Result.Ok>([]);
+
+ 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();
+
+ var results = await Task.WhenAll(
+ requestTypes.Select(rt => FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken)));
+
+ return Result.Ok>(results.SelectMany(r => r).ToList());
+ }
+
+ private async Task> FindForTypeSafeAsync(
+ RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken);
+ }
+ catch
+ {
+ // Один недоступный/переименованный список не должен обнулять результат по
+ // остальным типам заявок — та же терпимость, что и в ReminderService.
+ return [];
+ }
+ }
+
+ private async Task> FindForTypeAsync(
+ RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
+ {
+ var siteUrl = requestType.SiteUrl!;
+ var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
+ 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();
+ foreach (var (dataListId, dataListTitle) in partitions)
+ {
+ if (string.IsNullOrWhiteSpace(dataListTitle)) continue;
+ var approvalListTitle = ApprovalListTitleFor(dataListTitle);
+ if (approvalListTitle is null) continue;
+
+ IReadOnlyList hits;
+ try
+ {
+ hits = await sharePoint.GetItemsByListTitleAsync(
+ siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken);
+ }
+ catch
+ {
+ 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> ReadDataRowsAsync(
+ string siteUrl, string listId, IReadOnlyList ticketIds, CancellationToken cancellationToken)
+ {
+ if (ticketIds.Count == 0) return new Dictionary();
+
+ 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);
+ }
+
+ /// Все партиции типа заявок (архив + текущая), только их listId — для поиска.
+ private async Task> 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 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 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;
+ }
+}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx
index 2dadb71..fb61df3 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
import { ApiClientError } from '../../services/httpClient';
-import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
+import { REQUEST_STATUS, UserApprovalRow, UserTemplateStepRow } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor';
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes';
-import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -95,7 +94,6 @@ interface IReplaceScope {
export const ManageByUserScreen: React.FC = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api);
- const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC = ({ api, di
});
}, [employeeEmail, targetEmail]);
- const partitionsByType = React.useMemo(() => {
- const map = new Map();
- listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
- return map;
- }, [listSeries.items]);
-
const [liveRows, setLiveRows] = React.useState([]);
const [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState>(new Set());
const reloadLiveRows = React.useCallback(() => {
- if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
+ if (!employeeEmail || requestTypes.isLoading) return;
setLiveLoading(true);
setLiveError(undefined);
- requestManage
- .findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
+ api.requestManage
+ .byApprover(employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows)
- .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
+ .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
.finally(() => setLiveLoading(false));
- }, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
+ }, [api, requestTypes.isLoading, employeeEmail]);
React.useEffect(() => {
setLiveRows([]);
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx
index 30650c5..be3bc73 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC
const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status);
- // Уведомлять можно только активный шаг (флоу шлёт письмо тому,
- // чей RowID мы передали, независимо от статуса — сам он не
+ // Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
+ // RowID мы передали, независимо от статуса — сам он не
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
// слать по нему нечего: согласующий/исполнитель пока не
// должен действовать (см. request-portal/notify.service.ts,
// где по той же причине уведомляется только активный шаг).
- const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
+ //
+ // «Возвращена на доработку» — тоже уведомляемый статус, хотя
+ // и не активный: для него тот же поток шлёт письмо не
+ // согласующему строки, а АВТОРУ заявки (см.
+ // RequestManageService.notifyRow) — так интерфейс
+ // редактирования заявок получает кнопку «сообщить автору о
+ // доработке».
+ const notifiable =
+ r.status === REQUEST_STATUS.onApproval ||
+ r.status === REQUEST_STATUS.onExecution ||
+ r.status === REQUEST_STATUS.returned;
// Переключение параллельности — тоже перестановка (меняет order
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
// а не на rowEditable: у «Возвращена на доработку» кнопка
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
index 6ea90d0..0300a92 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
- if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
+ if (!requestType || !requestType.siteUrl) {
return Promise.resolve({ items: [], hasMore: false });
}
- return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
+ return api.requestManage.search(requestType.id, query, page);
},
- [requestManage, requestType, partitions.items]
+ [api, requestType]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC = ({ api
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
- requestManage
- .getMatrix(requestType.siteUrl, listId, selectedRequest.id)
+ api.requestManage
+ .matrix(requestType.id, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC = ({ api
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
- .catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
+ .catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
.finally(() => {
if (!silent) setMatrixLoading(false);
});
},
- [requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
+ [api, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
diff --git a/frontend/src/webparts/approvalAdmin/models/manageByUser.ts b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts
index 6bb6f31..cfa61b8 100644
--- a/frontend/src/webparts/approvalAdmin/models/manageByUser.ts
+++ b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts
@@ -23,6 +23,9 @@ export interface UserApprovalRow extends ManagedApprovalRow {
requestUniqueCode: string | null;
}
+/** Форма, в которой UserApprovalRow отдаёт Tnsu.ApprovalRoute.Api — см. requestManage.ts/ManagedApprovalRowApi. */
+export type UserApprovalRowApi = Omit;
+
/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */
export interface UserTemplateStepRow extends TemplateRouteStep {
requestTypeName: string;
diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts
index b9e9207..46058e7 100644
--- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts
+++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts
@@ -154,3 +154,17 @@ export interface ManagedRequestMatrix {
/** Обе фазы вместе, упорядочены по StepOrder. */
rows: ManagedApprovalRow[];
}
+
+// Формы, в которых те же данные отдаёт Tnsu.ApprovalRoute.Api
+// (backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs) —
+// сервис читает списки SharePoint от имени ПРИЛОЖЕНИЯ, а не текущего
+// пользователя (см. services/api.ts/requestManage), поэтому видимость заявок не
+// зависит от прав администратора на списки. Отличаются от ManagedApprovalRow/
+// ManagedRequestMatrix только одним полем: `date` (отформатированная
+// «дд.мм.гггг») здесь нет — она вычисляется на фронтенде из `dateIso` (см.
+// services/api.ts/toApprovalRow), бэкенду незачем знать про формат отображения.
+export type ManagedApprovalRowApi = Omit;
+
+export interface ManagedRequestMatrixApi extends Omit {
+ rows: ManagedApprovalRowApi[];
+}
diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts
index ad837d2..91e57d5 100644
--- a/frontend/src/webparts/approvalAdmin/services/api.ts
+++ b/frontend/src/webparts/approvalAdmin/services/api.ts
@@ -3,6 +3,11 @@ import {
CreateRequestTypeInput,
CreateTemplateRouteInput,
CreateTemplateRouteStepInput,
+ ManagedApprovalRow,
+ ManagedApprovalRowApi,
+ ManagedRequestMatrix,
+ ManagedRequestMatrixApi,
+ ManagedRequestPage,
PatchRequestTypeInput,
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
@@ -15,8 +20,26 @@ import {
TemplateRouteStep,
UpdateRequestTypeInput,
UpdateTemplateRouteInput,
- UpdateTemplateRouteStepInput
+ UpdateTemplateRouteStepInput,
+ UserApprovalRow,
+ UserApprovalRowApi
} from '../models';
+import { formatDateIso } from '../utils/date';
+
+// Api-формы (dateIso, без отформатированного date) → формы, которыми пользуются
+// экраны (ManageRequestsScreen/ManageByUserScreen) — см. requestManage.ts/
+// ManagedApprovalRowApi. Единственная разница — производное поле date.
+function toApprovalRow(r: ManagedApprovalRowApi): ManagedApprovalRow {
+ return { ...r, date: formatDateIso(r.dateIso) };
+}
+
+function toMatrix(m: ManagedRequestMatrixApi): ManagedRequestMatrix {
+ return { ...m, rows: m.rows.map(toApprovalRow) };
+}
+
+function toUserApprovalRow(r: UserApprovalRowApi): UserApprovalRow {
+ return { ...r, date: formatDateIso(r.dateIso) };
+}
// Тонкий 1:1 фасад над контроллерами бэкенда:
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
@@ -107,4 +130,29 @@ export class ApprovalRouteApi {
listByExecutorEmail: (executorEmail: string): Promise =>
this.http.get('/api/template-route-steps', { executorEmail })
};
+
+ // Чтение заявок и маршрутов согласования от имени ПРИЛОЖЕНИЯ (см.
+ // backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs) —
+ // в отличие от остальных групп выше, это НЕ CRUD над сущностями самого API:
+ // данные по-прежнему живут в списках SharePoint, которыми управляет
+ // request-portal (см. requestManageService.ts), только читаются они теперь
+ // не PnP-клиентом текущего пользователя, а этим бэкендом, поэтому видимость
+ // заявок не зависит от прав администратора на списки SharePoint.
+ public readonly requestManage = {
+ search: (requestTypeId: number, query: string, page: number, pageSize = 20): Promise =>
+ this.http.get('/api/request-manage/search', { requestTypeId, query, page, pageSize }),
+
+ matrix: (requestTypeId: number, listId: string, requestId: number): Promise =>
+ this.http
+ .get('/api/request-manage/matrix', { requestTypeId, listId, requestId })
+ .then(toMatrix),
+
+ byApprover: (approverEmail: string, activeStatuses: string[]): Promise =>
+ this.http
+ .get('/api/request-manage/by-approver', {
+ approverEmail,
+ statuses: activeStatuses.join(',')
+ })
+ .then(rows => rows.map(toUserApprovalRow))
+ };
}
diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
index cd183d6..28f4cd7 100644
--- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
+++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
@@ -3,29 +3,9 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
-import '@pnp/sp/search';
-// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО
-// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него
-// метод есть в типах, но в рантайме не зарегистрирован.
-import '@pnp/sp/batching';
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
-import {
- APPROVAL_PHASE,
- ManagedApprovalRow,
- ManagedRequestMatrix,
- ManagedRequestPage,
- ManagedRequestSummary,
- RequestListPartition,
- RequestType,
- UserApprovalRow
-} from '../models';
-
-// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
-// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
-// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
-// списки SharePoint, только с другого клиента).
-const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
+import { ManagedRequestMatrix, UserApprovalRow } from '../models';
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
@@ -42,79 +22,26 @@ const FLOW_NOTIFY_URL =
// буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = {
approver: 'TansuSiteApprover',
- status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже)
+ status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки
ticket: 'Ticket',
- date: 'Date',
- comment: 'Comment',
- order: 'StepOrder',
- allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase',
- // Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
- slaHours: 'SLAhours',
- askOverdueReason: 'AskOverdueReason'
+ order: 'StepOrder'
};
-function siteOrigin(siteUrl: string): string {
- const match = /^https?:\/\/[^/]+/.exec(siteUrl);
- return match ? match[0] : siteUrl;
-}
-
-function listAbsUrl(siteUrl: string, listId: string): string {
- return `${siteOrigin(siteUrl)}${listId}`;
-}
-
-// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
-// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
-// (features/myRequests/myRequests.service.ts/sanitizeForKql).
-function sanitizeForKql(text: string): string {
- return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
-}
-
-function freeTextClause(query: string): string | undefined {
- const q = sanitizeForKql(query);
- if (!q) return undefined;
- return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
-}
-
-function formatDateIso(iso: string): string {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d.getTime())) return '';
- const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
- const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
- return `${dd}.${mm}.${d.getFullYear()}`;
-}
-
-interface IRawDataRow {
- Id: number;
- Title?: string;
- /** Статус ЗАЯВКИ (список «— данные») — не путать с FIELDS.status (статус строки маршрута). */
- RequestStatus?: string;
- Created?: string;
- Author?: { Title?: string };
- // eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
- RequestUniqueCode?: string | null;
-}
-
-function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
- return {
- id: r.Id,
- title: r.Title || `#${r.Id}`,
- status: r.RequestStatus || '',
- created: r.Created || '',
- authorName: r.Author?.Title || '',
- uniqueCode: r.RequestUniqueCode || null,
- listId
- };
-}
-
/**
- * Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
- * в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
- * данные заявок и строки маршрута живут только в списках SharePoint, которыми
- * управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
- * знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
- * ему не принадлежит.
+ * Правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint, от имени
+ * ТЕКУЩЕГО пользователя — в отличие от остального этого веб-парта (который
+ * говорит с Tnsu.ApprovalRoute.Api), данные заявок и строки маршрута живут
+ * только в списках SharePoint, которыми управляет request-portal.
+ *
+ * Чтение заявок (поиск, матрица, строки по согласующему) сюда больше не входит —
+ * оно переехало на бэкенд (см. services/api.ts/requestManage,
+ * backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs):
+ * PnP-клиент читал списки от имени залогиненного администратора, и видимость
+ * заявок молча урезалась его собственными правами на списки SharePoint —
+ * бэкенд же читает от имени приложения. Правка (переназначить/удалить/
+ * переставить/уведомить) по-прежнему требует прав самого пользователя на
+ * запись в эти списки, поэтому остаётся здесь.
*/
export class RequestManageService {
constructor(private readonly context: WebPartContext) {}
@@ -123,202 +50,6 @@ export class RequestManageService {
return spfi(siteUrl).using(SPFx(this.context));
}
- // Title списка данных → Title списка согласования. undefined, если Title не
- // оканчивается на «— данные» (список заведён не по конвенции request-portal,
- // управлять маршрутом для него отсюда нельзя).
- private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
- const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
- const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
- ? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
- : undefined;
- return { dataListTitle: info.Title, approvalListTitle };
- }
-
- // К какой партиции относится хит Search — по префиксу его Path. Партиции
- // определяются вызывающей стороной (ListPartitionService.ListAsync), их
- // список короткий (единицы), линейный поиск тут не проблема.
- private partitionForPath(
- siteUrl: string,
- partitions: RequestListPartition[],
- path: string
- ): RequestListPartition | undefined {
- return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
- }
-
- /**
- * Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
- * постранично, для комбобокса выбора заявки. Сначала Search по названию
- * (свободный терм, пустой query — просто последние без фильтра), затем
- * дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
- * поля списка надёжно (та же двухшаговая схема, что в request-portal).
- *
- * Заявки, набранные ростом одного типа, физически «переезжают» на новый
- * список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
- * заявки из архивных партиций в комбобоксе просто не найти.
- */
- public async searchRequests(
- partitions: RequestListPartition[],
- siteUrl: string,
- query: string,
- page: number,
- pageSize = 20
- ): Promise {
- if (partitions.length === 0) return { items: [], hasMore: false };
-
- const sp = this.clientFor(siteUrl);
- const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
- const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
- const clauses = [pathClause];
- const text = freeTextClause(query);
- if (text) clauses.push(text);
-
- const result = await sp.search({
- Querytext: clauses.join(' AND '),
- SelectProperties: ['ListItemID', 'Path'],
- RowLimit: pageSize,
- StartRow: (page - 1) * pageSize,
- TrimDuplicates: false,
- SortList: [{ Property: 'Created', Direction: 1 }]
- });
-
- const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
- // Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
- // партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
- // одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
- // false выше выключает штатную дедупликацию Search (она иногда схлопывает
- // нужные нам заявки), и тогда индекс может отдать один и тот же элемент
- // несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
- // причина и то же лечение, что в request-portal
- // (features/myRequests/myRequests.service.ts/hitsToEntries).
- const seen = new Set();
- const hits: Array<{ id: number; partition: RequestListPartition }> = [];
- rawRows.forEach(r => {
- const id = Number(r.ListItemID);
- if (isNaN(id) || id <= 0 || !r.Path) return;
- const partition = this.partitionForPath(siteUrl, partitions, r.Path);
- if (!partition) return;
- const key = `${partition.listId}|${id}`;
- if (seen.has(key)) return;
- seen.add(key);
- hits.push({ id, partition });
- });
- if (hits.length === 0) return { items: [], hasMore: false };
-
- const idsByListId = new Map();
- hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
-
- const rowByKey = new Map();
- await Promise.all(
- Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
- const list = sp.web.getList(listId);
- const dataRows = (await list.items
- .filter(ids.map(id => `Id eq ${id}`).join(' or '))
- .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
- .expand('Author')
- .top(ids.length)()) as IRawDataRow[];
- dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
- })
- );
-
- // Порядок задаёт Search (SortList по дате), не порядок ответа REST.
- const items = hits
- .map(h => {
- const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
- return row ? toSummary(row, h.partition.listId) : undefined;
- })
- .filter((r): r is ManagedRequestSummary => !!r);
-
- const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
- return { items, hasMore: page * pageSize < totalRows };
- }
-
- /**
- * Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
- *
- * listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
- * ManagedRequestSummary.listId, отданного searchRequests/предыдущей
- * getMatrix) — не requestType.listId: та партиция может быть уже не той, где
- * лежит эта заявка (см. ТЗ по архивации).
- */
- public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise {
- const sp = this.clientFor(siteUrl);
- const dataList = sp.web.getList(listId);
-
- const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
- dataList.items
- .getById(requestId)
- .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
- .expand('Author')() as Promise,
- this.listTitles(sp, listId)
- ]);
-
- const dataListUrl = listAbsUrl(siteUrl, listId);
-
- if (!approvalListTitle) {
- return {
- request: toSummary(requestRow, listId),
- siteUrl,
- dataListTitle,
- dataListUrl,
- approvalListTitle: '',
- rows: []
- };
- }
-
- const approvalList = sp.web.lists.getByTitle(approvalListTitle);
- const rawRows = (await approvalList.items
- .filter(`${FIELDS.ticket}Id eq ${requestId}`)
- .select(
- 'Id',
- 'Title',
- FIELDS.status,
- FIELDS.date,
- FIELDS.comment,
- FIELDS.order,
- FIELDS.allowReassign,
- FIELDS.phase,
- FIELDS.slaHours,
- FIELDS.askOverdueReason,
- `${FIELDS.approver}/Id`,
- `${FIELDS.approver}/Title`,
- `${FIELDS.approver}/EMail`
- )
- .expand(FIELDS.approver)
- .orderBy(FIELDS.order, true)()) as Array>;
-
- const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i));
-
- return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
- }
-
- /**
- * Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий
- * маппинг для getMatrix (одна заявка, ticketId известен заранее) и
- * findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки).
- */
- private toApprovalRow(r: Record, ticketId: number, index: number): ManagedApprovalRow {
- const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
- return {
- id: r.Id as number,
- ticketId,
- approverId: approver?.Id ?? null,
- approverName: approver?.Title || '',
- approverEmail: approver?.EMail || '',
- position: (r.Title as string) || '',
- status: (r[FIELDS.status] as string) || '',
- date: formatDateIso((r[FIELDS.date] as string) || ''),
- dateIso: (r[FIELDS.date] as string) || '',
- comment: (r[FIELDS.comment] as string) || '',
- order: (r[FIELDS.order] as number) ?? index + 1,
- allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
- phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
- // ?? null, а не || null: ноль здесь — значимое значение («напоминать
- // сразу»), и превращать его в «не задано» нельзя.
- slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
- askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
- };
- }
-
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
@@ -326,166 +57,6 @@ export class RequestManageService {
return user.Id;
}
- /**
- * Активные строки согласования данного человека СРАЗУ по всем типам заявок и
- * всем их партициям (архив + текущая) — для экрана «Заявки по согласующему»
- * (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/
- * сменой должности, без похода в каждый тип заявки по отдельности.
- *
- * REST-only, без Search API — в отличие от request-portal/myRequests.service.ts
- * (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт
- * идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного
- * сотрудника, а задержка индексации Search — это ровно тот класс багов
- * (устаревшее назначение), ради исправления которого существует сам экран, так
- * что жертвовать точностью ради скорости здесь не место.
- *
- * partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже
- * полученная вызывающей стороной через api.requestTypes.listsAll(). Partition
- * даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle
- * выводится из него той же конвенцией имён, что и listTitles() выше.
- */
- public async findActiveApprovalRowsForApprover(
- requestTypes: RequestType[],
- partitionsByType: Map,
- approverEmail: string,
- activeStatuses: string[]
- ): Promise {
- interface ITarget {
- requestTypeId: number;
- requestTypeName: string;
- siteUrl: string;
- dataListId: string;
- approvalListTitle: string;
- }
-
- const targets: ITarget[] = [];
- requestTypes.forEach(rt => {
- (partitionsByType.get(rt.id) || []).forEach(p => {
- if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return;
- targets.push({
- requestTypeId: rt.id,
- requestTypeName: rt.name,
- siteUrl: p.siteUrl,
- dataListId: p.listId,
- approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование`
- });
- });
- });
- if (targets.length === 0) return [];
-
- const bySite = new Map();
- targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t]));
-
- const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or ');
- const rows: UserApprovalRow[] = [];
-
- await Promise.all(
- Array.from(bySite.values()).map(async siteTargets => {
- const siteUrl = siteTargets[0].siteUrl;
- try {
- const sp = this.clientFor(siteUrl);
- // Один раз на сайт — тот же приём, что в fetchRecentApprovalRows
- // request-portal: резолвить email в Id и фильтровать по approverId
- // надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по
- // Person-полю (последнее в SharePoint REST ненадёжно).
- const approverId = await this.resolveUserId(sp, approverEmail);
-
- // Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта.
- const approvalHitsByList = new Map>>();
- const [batchedApprovalSp, executeApproval] = sp.batched();
- const pendingApproval = siteTargets.map(t =>
- batchedApprovalSp.web.lists
- .getByTitle(t.approvalListTitle)
- .items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`)
- .select(
- 'Id',
- 'Title',
- FIELDS.status,
- FIELDS.date,
- FIELDS.comment,
- FIELDS.order,
- FIELDS.allowReassign,
- FIELDS.phase,
- FIELDS.slaHours,
- FIELDS.askOverdueReason,
- `${FIELDS.ticket}Id`,
- `${FIELDS.approver}/Id`,
- `${FIELDS.approver}/Title`,
- `${FIELDS.approver}/EMail`
- )
- .expand(FIELDS.approver)
- .top(2000)()
- .then(items => { approvalHitsByList.set(t.dataListId, items as Array>); })
- .catch((e: Error) => {
- // Одна сломанная/переименованная вручную партиция не должна
- // лишить результата остальные партиции того же сайта.
- console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`);
- })
- );
- await executeApproval();
- await Promise.all(pendingApproval);
-
- // Фаза B: Title/статус/уникальный код заявок — по найденным Id,
- // сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт.
- const ticketIdsByList = new Map>();
- siteTargets.forEach(t => {
- (approvalHitsByList.get(t.dataListId) || []).forEach(h => {
- const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined;
- if (!ticketId) return;
- const set = ticketIdsByList.get(t.dataListId) || new Set();
- set.add(ticketId);
- ticketIdsByList.set(t.dataListId, set);
- });
- });
-
- const dataRowsByList = new Map();
- if (ticketIdsByList.size > 0) {
- const [batchedDataSp, executeData] = sp.batched();
- const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => {
- const ids = Array.from(idsSet);
- return batchedDataSp.web
- .getList(dataListId)
- .items.filter(ids.map(id => `Id eq ${id}`).join(' or '))
- .select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode')
- .top(ids.length)()
- .then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); })
- .catch((e: Error) => {
- console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`);
- });
- });
- await executeData();
- await Promise.all(pendingData);
- }
-
- // Склейка.
- siteTargets.forEach(t => {
- const hits = approvalHitsByList.get(t.dataListId) || [];
- const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r]));
- hits.forEach((h, i) => {
- const ticketId = h[`${FIELDS.ticket}Id`] as number;
- if (!ticketId) return;
- const dataRow = dataById.get(ticketId);
- rows.push({
- ...this.toApprovalRow(h, ticketId, i),
- requestTypeId: t.requestTypeId,
- requestTypeName: t.requestTypeName,
- siteUrl: t.siteUrl,
- listId: t.dataListId,
- approvalListTitle: t.approvalListTitle,
- requestTitle: dataRow?.Title || `#${ticketId}`,
- requestUniqueCode: dataRow?.RequestUniqueCode || null
- });
- });
- });
- } catch (e) {
- console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`);
- }
- })
- );
-
- return rows;
- }
-
/**
* Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
* row переназначить на него — та же проверка, что ManageRequestsScreen делает
@@ -549,8 +120,8 @@ export class RequestManageService {
): Promise {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
- [FIELDS.slaHours]: slaHours,
- [FIELDS.askOverdueReason]: askOverdueReason
+ SLAhours: slaHours,
+ AskOverdueReason: askOverdueReason
});
}
@@ -587,7 +158,7 @@ export class RequestManageService {
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order,
- [FIELDS.allowReassign]: allowReassign,
+ AllowReassignToOtherApprovers: allowReassign,
[FIELDS.phase]: phase
});
}
@@ -618,12 +189,21 @@ export class RequestManageService {
}
/**
- * Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
- * тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
- * выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
- * письмо. В отличие от request-portal (там вызывается автоматически после
- * решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
- * ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
+ * Отправить уведомление по конкретной строке маршрута — тот же поток Power
+ * Automate, что и request-portal (см. FLOW_NOTIFY_URL выше): поток сам
+ * находит по RowID строку, её статус и заявку.
+ *
+ * Обычно (статус «На согласовании»/«На исполнении») он уведомляет
+ * согласующего/исполнителя ЭТОЙ строки — см. вызывающую сторону,
+ * ManageRequestsPhaseTable/notifiable. Для строки в статусе «Возвращена на
+ * доработку» этот же поток (по сложившейся практике портала — он уже читает
+ * статус строки, см. комментарий у FLOW_NOTIFY_URL) уведомляет автора заявки,
+ * а не согласующего, вернувшего её — так интерфейс редактирования заявок
+ * получает кнопку «сообщить автору о доработке» без отдельного потока.
+ *
+ * В отличие от request-portal (там вызывается автоматически после решения),
+ * здесь — по явному клику на кнопку в таблице маршрута, поэтому ошибку НЕ
+ * глушим — вызывающая сторона показывает её пользователю.
*/
public async notifyRow(
requestTypeId: number,
diff --git a/frontend/src/webparts/approvalAdmin/utils/date.ts b/frontend/src/webparts/approvalAdmin/utils/date.ts
new file mode 100644
index 0000000..308dd5f
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/utils/date.ts
@@ -0,0 +1,12 @@
+// ISO 8601 (или пустая строка) → «дд.мм.гггг» для отображения в таблицах
+// маршрута. Раздельно от dateIso: последняя нужна для расчёта просрочки
+// (см. utils/sla.ts) и не должна терять точность через локаль при парсинге
+// уже отформатированной строки назад.
+export function formatDateIso(iso: string): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return '';
+ const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
+ const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
+ return `${dd}.${mm}.${d.getFullYear()}`;
+}