diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs new file mode 100644 index 0000000..fdb4fc6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestManageController.cs @@ -0,0 +1,57 @@ +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, + [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()}`; +}