Feature/request manage server load (#22)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 1m43s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/22
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 06:30:59 +00:00
parent 2b8fe77fac
commit 1bf8d4ca11
12 changed files with 612 additions and 479 deletions
@@ -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;
/// <summary>
/// Чтение заявок и маршрутов согласования от имени приложения (см.
/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
/// согласующему»: результат не зависит от прав вызывающего пользователя на
/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
/// </summary>
[ApiController]
[Authorize]
[Route("api/request-manage")]
[Produces("application/json")]
public sealed class RequestManageController(RequestManageService service) : ControllerBase
{
/// <summary>Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.</summary>
[HttpGet("search")]
[ProducesResponseType(typeof(ApiResponse<ManagedRequestPageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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();
/// <summary>Заявка + полный маршрут согласования (обе фазы, по StepOrder).</summary>
[HttpGet("matrix")]
[ProducesResponseType(typeof(ApiResponse<ManagedRequestMatrixDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Matrix(
[FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
(await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
/// <summary>
/// Активные строки согласования одного человека сразу по всем типам заявок и
/// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
/// </summary>
[HttpGet("by-approver")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserApprovalRowDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> 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();
}
}
@@ -5,6 +5,7 @@ using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports; using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.RequestManage;
using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -17,6 +18,7 @@ public static class ApplicationServiceCollectionExtensions
{ {
services.AddScoped<ReminderService>(); services.AddScoped<ReminderService>();
services.AddScoped<RequestTypeService>(); services.AddScoped<RequestTypeService>();
services.AddScoped<RequestManageService>();
services.AddScoped<ListPartitionRegistry>(); services.AddScoped<ListPartitionRegistry>();
services.AddScoped<ListPartitionService>(); services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение. // Джоба резолвится Hangfire'ом из scope на каждое выполнение.
@@ -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.
/// <summary>Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.</summary>
public sealed record ManagedRequestSummaryDto(
int Id,
string Title,
string Status,
/// <summary>ISO 8601 или пустая строка.</summary>
string Created,
string AuthorName,
string? UniqueCode,
/// <summary>Партиция (серверный относительный url списка данных), где физически лежит заявка.</summary>
string ListId);
public sealed record ManagedRequestPageDto(IReadOnlyList<ManagedRequestSummaryDto> Items, bool HasMore);
/// <summary>Строка списка «… — согласование».</summary>
public sealed record ManagedApprovalRowDto(
int Id,
int TicketId,
int? ApproverId,
string ApproverName,
string ApproverEmail,
string Position,
string Status,
/// <summary>ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.</summary>
string DateIso,
string Comment,
double Order,
bool AllowReassign,
string Phase,
int? SlaHours,
bool? AskOverdueReason);
/// <summary>Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.</summary>
public sealed record ManagedRequestMatrixDto(
ManagedRequestSummaryDto Request,
string SiteUrl,
string DataListTitle,
string DataListUrl,
string ApprovalListTitle,
IReadOnlyList<ManagedApprovalRowDto> Rows);
/// <summary>
/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
/// </summary>
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);
@@ -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;
/// <summary>
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
/// текущего пользователя.
///
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
/// (см. requestManageService.ts) от имени залогиненного администратора — и
/// список видимых заявок молча урезался правами САМОГО администратора на списки
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
/// фронтенде через PnP — сюда вынесено только чтение.
/// </summary>
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);
/// <summary>
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
/// </summary>
public async Task<Result<ManagedRequestPageDto>> 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<ManagedRequestPageDto>(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<ManagedRequestSummaryDto>();
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));
}
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
public async Task<Result<ManagedRequestMatrixDto>> 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<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Fail<ManagedRequestMatrixDto>(
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<ManagedRequestMatrixDto>(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));
}
/// <summary>
/// Активные строки согласования данного человека сразу по всем типам заявок и
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
/// </summary>
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
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<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
}
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync(
RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{
try
{
return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken);
}
catch
{
// Один недоступный/переименованный список не должен обнулять результат по
// остальным типам заявок — та же терпимость, что и в ReminderService.
return [];
}
}
private async Task<IReadOnlyList<UserApprovalRowDto>> 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<UserApprovalRowDto>();
foreach (var (dataListId, dataListTitle) in partitions)
{
if (string.IsNullOrWhiteSpace(dataListTitle)) continue;
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null) continue;
IReadOnlyList<JsonElement> 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<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
{
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
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);
}
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
private async Task<IReadOnlyList<string>> 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<string> 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<string> 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;
}
}
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService'; import { RequestManageService } from '../../services/requestManageService';
import { ApiClientError } from '../../services/httpClient'; 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 { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid'; import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor'; import { describeStepCondition } from '../../utils/stepExecutor';
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing'; import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor'; import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup'; import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -95,7 +94,6 @@ interface IReplaceScope {
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => { export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary); const dictionaries = usePortalDictionaries(dictionary);
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
}); });
}, [employeeEmail, targetEmail]); }, [employeeEmail, targetEmail]);
const partitionsByType = React.useMemo(() => {
const map = new Map<number, RequestListPartition[]>();
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
return map;
}, [listSeries.items]);
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]); const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
const [liveLoading, setLiveLoading] = React.useState(false); const [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState<string | undefined>(); const [liveError, setLiveError] = React.useState<string | undefined>();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set()); const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
const reloadLiveRows = React.useCallback(() => { const reloadLiveRows = React.useCallback(() => {
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return; if (!employeeEmail || requestTypes.isLoading) return;
setLiveLoading(true); setLiveLoading(true);
setLiveError(undefined); setLiveError(undefined);
requestManage api.requestManage
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES) .byApprover(employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows) .then(setLiveRows)
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover'))) .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
.finally(() => setLiveLoading(false)); .finally(() => setLiveLoading(false));
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]); }, [api, requestTypes.isLoading, employeeEmail]);
React.useEffect(() => { React.useEffect(() => {
setLiveRows([]); setLiveRows([]);
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isBlockStart = block.start === i; const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex; const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status); const rowEditable = isEditableRowStatus(r.status);
// Уведомлять можно только активный шаг (флоу шлёт письмо тому, // Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
// чей RowID мы передали, независимо от статуса — сам он не // RowID мы передали, независимо от статуса — сам он не
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил, // проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
// слать по нему нечего: согласующий/исполнитель пока не // слать по нему нечего: согласующий/исполнитель пока не
// должен действовать (см. request-portal/notify.service.ts, // должен действовать (см. 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 // Переключение параллельности — тоже перестановка (меняет order
// относительно соседней строки), поэтому смотрим на isMovableRowStatus, // относительно соседней строки), поэтому смотрим на isMovableRowStatus,
// а не на rowEditable: у «Возвращена на доработку» кнопка // а не на rowEditable: у «Возвращена на доработку» кнопка
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback( const searchRequests = React.useCallback(
(query: string, page: number) => { (query: string, page: number) => {
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) { if (!requestType || !requestType.siteUrl) {
return Promise.resolve({ items: [], hasMore: false }); 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 — фоновое обновление (после своего же действия, автообновление // silent=true — фоновое обновление (после своего же действия, автообновление
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
} }
if (!silent) setMatrixLoading(true); if (!silent) setMatrixLoading(true);
setMatrixError(undefined); setMatrixError(undefined);
requestManage api.requestManage
.getMatrix(requestType.siteUrl, listId, selectedRequest.id) .matrix(requestType.id, listId, selectedRequest.id)
.then(m => { .then(m => {
setMatrix(m); setMatrix(m);
// title/status/listId в комбобоксе (а значит, и в localStorage) держим // title/status/listId в комбобоксе (а значит, и в localStorage) держим
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
return same ? prev : { ...prev, selectedRequest: m.request }; return same ? prev : { ...prev, selectedRequest: m.request };
}); });
}) })
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix'))) .catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
.finally(() => { .finally(() => {
if (!silent) setMatrixLoading(false); if (!silent) setMatrixLoading(false);
}); });
}, },
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading] [api, requestType, selectedRequest, partitions.items, partitions.isLoading]
); );
React.useEffect(() => { React.useEffect(() => {
@@ -23,6 +23,9 @@ export interface UserApprovalRow extends ManagedApprovalRow {
requestUniqueCode: string | null; requestUniqueCode: string | null;
} }
/** Форма, в которой UserApprovalRow отдаёт Tnsu.ApprovalRoute.Api — см. requestManage.ts/ManagedApprovalRowApi. */
export type UserApprovalRowApi = Omit<UserApprovalRow, 'date'>;
/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */ /** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */
export interface UserTemplateStepRow extends TemplateRouteStep { export interface UserTemplateStepRow extends TemplateRouteStep {
requestTypeName: string; requestTypeName: string;
@@ -154,3 +154,17 @@ export interface ManagedRequestMatrix {
/** Обе фазы вместе, упорядочены по StepOrder. */ /** Обе фазы вместе, упорядочены по StepOrder. */
rows: ManagedApprovalRow[]; 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<ManagedApprovalRow, 'date'>;
export interface ManagedRequestMatrixApi extends Omit<ManagedRequestMatrix, 'rows'> {
rows: ManagedApprovalRowApi[];
}
@@ -3,6 +3,11 @@ import {
CreateRequestTypeInput, CreateRequestTypeInput,
CreateTemplateRouteInput, CreateTemplateRouteInput,
CreateTemplateRouteStepInput, CreateTemplateRouteStepInput,
ManagedApprovalRow,
ManagedApprovalRowApi,
ManagedRequestMatrix,
ManagedRequestMatrixApi,
ManagedRequestPage,
PatchRequestTypeInput, PatchRequestTypeInput,
PatchTemplateRouteInput, PatchTemplateRouteInput,
PatchTemplateRouteStepInput, PatchTemplateRouteStepInput,
@@ -15,8 +20,26 @@ import {
TemplateRouteStep, TemplateRouteStep,
UpdateRequestTypeInput, UpdateRequestTypeInput,
UpdateTemplateRouteInput, UpdateTemplateRouteInput,
UpdateTemplateRouteStepInput UpdateTemplateRouteStepInput,
UserApprovalRow,
UserApprovalRowApi
} from '../models'; } 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 фасад над контроллерами бэкенда: // Тонкий 1:1 фасад над контроллерами бэкенда:
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs // backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
@@ -107,4 +130,29 @@ export class ApprovalRouteApi {
listByExecutorEmail: (executorEmail: string): Promise<TemplateRouteStep[]> => listByExecutorEmail: (executorEmail: string): Promise<TemplateRouteStep[]> =>
this.http.get<TemplateRouteStep[]>('/api/template-route-steps', { executorEmail }) this.http.get<TemplateRouteStep[]>('/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<ManagedRequestPage> =>
this.http.get<ManagedRequestPage>('/api/request-manage/search', { requestTypeId, query, page, pageSize }),
matrix: (requestTypeId: number, listId: string, requestId: number): Promise<ManagedRequestMatrix> =>
this.http
.get<ManagedRequestMatrixApi>('/api/request-manage/matrix', { requestTypeId, listId, requestId })
.then(toMatrix),
byApprover: (approverEmail: string, activeStatuses: string[]): Promise<UserApprovalRow[]> =>
this.http
.get<UserApprovalRowApi[]>('/api/request-manage/by-approver', {
approverEmail,
statuses: activeStatuses.join(',')
})
.then(rows => rows.map(toUserApprovalRow))
};
} }
@@ -3,29 +3,9 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import '@pnp/sp/items'; import '@pnp/sp/items';
import '@pnp/sp/site-users/web'; 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 { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { import { ManagedRequestMatrix, UserApprovalRow } from '../models';
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*$/;
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal // Тот же поток оповещения согласующего/исполнителя, что и в request-portal
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список // (src/webparts/requestPortal/shared/config.ts) — один и тот же список
@@ -42,79 +22,26 @@ const FLOW_NOTIFY_URL =
// буквально — здесь их не выбирают заново, а просто дублируют. // буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = { const FIELDS = {
approver: 'TansuSiteApprover', approver: 'TansuSiteApprover',
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже) status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки
ticket: 'Ticket', ticket: 'Ticket',
date: 'Date',
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase', phase: 'Phase',
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными. order: 'StepOrder'
slaHours: 'SLAhours',
askOverdueReason: 'AskOverdueReason'
}; };
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 — * Правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint, от имени
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api), * ТЕКУЩЕГО пользователя — в отличие от остального этого веб-парта (который
* данные заявок и строки маршрута живут только в списках SharePoint, которыми * говорит с Tnsu.ApprovalRoute.Api), данные заявок и строки маршрута живут
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок * только в списках SharePoint, которыми управляет request-portal.
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков *
* ему не принадлежит. * Чтение заявок (поиск, матрица, строки по согласующему) сюда больше не входит —
* оно переехало на бэкенд (см. services/api.ts/requestManage,
* backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs):
* PnP-клиент читал списки от имени залогиненного администратора, и видимость
* заявок молча урезалась его собственными правами на списки SharePoint —
* бэкенд же читает от имени приложения. Правка (переназначить/удалить/
* переставить/уведомить) по-прежнему требует прав самого пользователя на
* запись в эти списки, поэтому остаётся здесь.
*/ */
export class RequestManageService { export class RequestManageService {
constructor(private readonly context: WebPartContext) {} constructor(private readonly context: WebPartContext) {}
@@ -123,202 +50,6 @@ export class RequestManageService {
return spfi(siteUrl).using(SPFx(this.context)); 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<ManagedRequestPage> {
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<string>();
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<string, number[]>();
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
const rowByKey = new Map<string, IRawDataRow>();
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<ManagedRequestMatrix> {
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<IRawDataRow>,
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<Record<string, unknown>>;
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<string, unknown>, 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). */ /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise<number> { private async resolveUserId(sp: SPFI, email: string): Promise<number> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`; const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
@@ -326,166 +57,6 @@ export class RequestManageService {
return user.Id; 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<number, RequestListPartition[]>,
approverEmail: string,
activeStatuses: string[]
): Promise<UserApprovalRow[]> {
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<string, ITarget[]>();
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<string, Array<Record<string, unknown>>>();
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<Record<string, unknown>>); })
.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<string, Set<number>>();
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<number>();
set.add(ticketId);
ticketIdsByList.set(t.dataListId, set);
});
});
const dataRowsByList = new Map<string, IRawDataRow[]>();
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, если строку * Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
* row переназначить на него — та же проверка, что ManageRequestsScreen делает * row переназначить на него — та же проверка, что ManageRequestsScreen делает
@@ -549,8 +120,8 @@ export class RequestManageService {
): Promise<void> { ): Promise<void> {
const sp = this.clientFor(matrix.siteUrl); const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({ await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
[FIELDS.slaHours]: slaHours, SLAhours: slaHours,
[FIELDS.askOverdueReason]: askOverdueReason AskOverdueReason: askOverdueReason
}); });
} }
@@ -587,7 +158,7 @@ export class RequestManageService {
[FIELDS.status]: status, [FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId, [`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order, [FIELDS.order]: order,
[FIELDS.allowReassign]: allowReassign, AllowReassignToOtherApprovers: allowReassign,
[FIELDS.phase]: phase [FIELDS.phase]: phase
}); });
} }
@@ -618,12 +189,21 @@ export class RequestManageService {
} }
/** /**
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута — * Отправить уведомление по конкретной строке маршрута — тот же поток Power
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL * Automate, что и request-portal (см. FLOW_NOTIFY_URL выше): поток сам
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт * находит по RowID строку, её статус и заявку.
* письмо. В отличие от request-portal (там вызывается автоматически после *
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому * Обычно (статус «На согласовании»/«На исполнении») он уведомляет
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю. * согласующего/исполнителя ЭТОЙ строки — см. вызывающую сторону,
* ManageRequestsPhaseTable/notifiable. Для строки в статусе «Возвращена на
* доработку» этот же поток (по сложившейся практике портала — он уже читает
* статус строки, см. комментарий у FLOW_NOTIFY_URL) уведомляет автора заявки,
* а не согласующего, вернувшего её — так интерфейс редактирования заявок
* получает кнопку «сообщить автору о доработке» без отдельного потока.
*
* В отличие от request-portal (там вызывается автоматически после решения),
* здесь — по явному клику на кнопку в таблице маршрута, поэтому ошибку НЕ
* глушим — вызывающая сторона показывает её пользователю.
*/ */
public async notifyRow( public async notifyRow(
requestTypeId: number, requestTypeId: number,
@@ -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()}`;
}