Feature/request manage server load (#22)
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:
@@ -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.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<ReminderService>();
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<RequestManageService>();
|
||||
services.AddScoped<ListPartitionRegistry>();
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user