Files
request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
T
2026-08-17 09:33:44 +05:00

1182 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SPFI } from '@pnp/sp';
import { spForSite } from '../../shared/pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn,
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
const RECENT_MS = 10 * 60 * 1000;
// Сколько строк максимум запрашиваем у Search за ОДИН HTTP-вызов — верхняя
// граница RowLimit самого SharePoint Search REST API. Полный набор всегда
// собирается несколькими такими вызовами через StartRow (см.
// fetchAllSearchRows) — это не потолок итогового результата.
const SEARCH_PAGE_SIZE = 500;
// Абсолютный потолок на суммарное число строк, которые fetchAllSearchRows
// заберёт за все страницы, — защита от зависания на аномально большом
// индексе, а не ожидаемый рабочий предел. При достижении пишем предупреждение
// и возвращаем то, что успели собрать, вместо бесконечного перебора.
const SEARCH_HARD_CAP = 5000;
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
// а не уходит одним запросом целиком.
const REST_ID_FILTER_CHUNK = 100;
// Сколько последних заявок автора дочитываем через REST в довесок к Search
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
// пользователя, поэтому лимит с запасом; при упоре в него пишем предупреждение.
const OWN_REST_TOP = 200;
// Managed properties списка согласования. Имена включают группу колонок сайта
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком (см. MP_REQUEST_STATUS
// ниже).
const MP_STATUS = 'ApprovalStatusOWSCHCS';
// Статус ЗАЯВКИ (колонка RequestStatus списка ДАННЫХ) — Choice-поле, тот же
// суффикс OWSCHCS, без префикса группы колонок сайта (поле локальное для
// списка, как и ApprovalStatus выше). Раньше здесь стояло «угадали неверно,
// фильтр молча возвращает 0» — перепроверено вручную через
// /_api/search/query 2026-08-17: и голый запрос по этому свойству, и в
// связке с Path, отдают счётчики, совпадающие с REST-эталоном (см.
// getOwnRequestsByStatus). Старый вывод о поломке был артефактом кривого
// тестового Path (задвоенный сегмент /sites/…), а не реальным ограничением
// SharePoint — сама схема поиска исправна.
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
// маршрут в форме, но собственные списки заявок.
export function currentUserEmail(context: WebPartContext): string {
return effectiveUserEmail(context).toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
const types = await getRequestTypes(context);
const linked = types.filter((t) => !!t.listId && !!t.siteUrl);
traceLinkedTypes(types, linked);
return linked;
}
// Что показывать в разделе «На согласовании»:
// 'myTurn' — только заявки, где очередь дошла до текущего пользователя
// (обычный режим, когда поиск не задан);
// 'involved' — все заявки, где он вообще есть в маршруте (уже согласовал,
// ждёт своей очереди и т.п.) — так поиск ищет по всему, в чём
// пользователь участвует или будет участвовать.
export type ApprovalScope = 'myTurn' | 'involved';
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
// Отличаются только набором статусов его строки — сам механизм (найти свои
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
// зашиты в indexApprovalsForType.
//
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
// оба статуса сразу); теперь это РАЗНЫЕ разделы и пересечения между ними
// нет — заявка ровно в одном из них.
// approved/rejected/executed — история уже принятых им решений
// (подвкладки «Моих заявок»).
export type ApprovalQueueKind = 'approvals' | 'execution' | 'approved' | 'rejected' | 'executed';
const QUEUE_STATUSES: Record<ApprovalQueueKind, string[]> = {
approvals: [REQUEST_STATUS.onApproval],
execution: [REQUEST_STATUS.onExecution],
approved: [REQUEST_STATUS.approved],
rejected: [REQUEST_STATUS.rejected],
executed: [REQUEST_STATUS.executed],
};
// Статусы строки, означающие «ход за пользователем» — для счётчиков в меню
// (обе очереди считаются за один проход, см. getMyTurnCounts).
const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
// ---------------------------------------------------------------------------
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
// значение колонки ApprovalStatus, поэтому здесь достаточно ОДНОГО
// отфильтрованного запроса: ApproverId (+ ApprovalStatus для 'myTurn').
// Список согласования больше НЕ сканируется целиком, независимо от его размера.
//
// Заявку (Title/RequestStatus/Created) читаем сразу через expand по Lookup-полю
// Ticket — отдельный запрос к списку данных нужен только за Author
// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то —
// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего
// найденного среза. Полноценный $skip/$top здесь не сделать: SharePoint REST
// не умеет сортировать по полю через expand (Ticket/Created — поле не этого
// списка), поэтому «свежие сверху» через несколько сайтов/типов можно получить
// только слиянием и сортировкой в памяти. Экономим на самом дорогом шаге —
// не дочитываем Author там, где он не попадёт на экран.
export interface IPendingApprovalPage {
rows: IMyRequest[];
totalRows: number;
debug: IMyRequestsTypeDebug[];
}
const PENDING_PAGE_SIZE = 20;
// Общая часть: собрать полный (не постраничный) индекс совпадающих заявок по
// всем типам — без автора, БЕЗ дочитывания. Используется и обычным режимом
// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
async function buildPendingIndex(
context: WebPartContext,
scope: ApprovalScope,
statuses: string[]
): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context);
const perType = await Promise.all(
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
);
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
const index = perType
.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[])
.sort((a, b) => b.created.localeCompare(a.created));
return { types, index, debug: perType.map((r) => r.debug) };
}
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
// заявок отдать), автор дочитывается только для них.
export async function getApprovalQueue(
context: WebPartContext,
kind: ApprovalQueueKind,
page: number = 1
): Promise<IPendingApprovalPage> {
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
await hydrateAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: index.length, debug };
}
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
// сторона через filterPendingByQuery, а страницу и автора берёт из уже
// отфильтрованного результата (см. hydratePendingAuthors).
//
// Область поиска — та же, что и у самой вкладки (статусы очереди уходят в KQL):
// раньше при вводе текста «На согласовании» расширялась до «где я вообще есть в
// маршруте», но теперь для сквозного поиска есть отдельный раздел «Поиск», а
// внутри вкладки предсказуемее искать по тому же набору, который в ней виден.
export async function getApprovalQueueIndex(
context: WebPartContext,
kind: ApprovalQueueKind
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
return { rows: index, types, debug };
}
// Полный индекс «где я вообще есть в маршруте», без отбора по статусу — нужен
// только разделу «Поиск» (см. searchMyRequests).
export async function getPendingApprovalIndex(
context: WebPartContext,
scope: ApprovalScope
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
const { types, index, debug } = await buildPendingIndex(context, scope, []);
return { rows: index, types, debug };
}
/** Счётчики очередей для бокового меню. */
export interface INavCounts {
approvals: number;
execution: number;
rework: number;
}
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
// отдельным запросам значило бы дважды просканировать одни и те же списки
// согласования. Статус своей строки приходит в myStepStatus (см.
// indexApprovalsForType), по нему и разносим.
//
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
// вкладку: иначе счётчик и список могли бы разойтись.
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
const types = await getLinkedTypes(context);
const [turn, rework] = await Promise.all([
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
]);
return {
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length,
rework: rework.totalRows,
};
}
// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
export async function hydratePendingAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
await hydrateAuthors(context, types, rows);
}
// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
// ApproverId → Title/RequestStatus/Created заявки отдельным запросом к списку ДАННЫХ
// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
// произвольные поля связанного элемента, только через Person-поля вроде
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
async function indexApprovalsForType(
context: WebPartContext,
type: IRequestType,
tokenEmail: string,
scope: ApprovalScope,
statuses: string[]
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: false,
};
try {
if (!tokenEmail) return { rows: [], debug };
const sp = spForSite(type.siteUrl as string, context);
const list = sp.web.getList(type.listId as string);
const dataListInfo = await list.select('Title')();
const dataTitle = (dataListInfo.Title as string) || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
return { rows: [], debug };
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
//
// ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что
// кириллица в значении делает такой фильтр ненадёжным, — перепроверено
// вручную через REST 2026-08-17 (все три статуса списка согласования дали
// точное совпадение с эталоном), теория не подтвердилась. Старый вывод о
// поломке был артефактом кривого тестового запроса, а не реальным
// ограничением SharePoint.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
let stepRows: Array<Record<string, unknown>>;
if (scope === 'myTurn') {
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
// большим): Search находит кандидатов, не сканируя список целиком, плюс
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
// не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по
// найденным Id (не из индекса Search) — именно это делает
// getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда
// точное, даже когда список кандидатов пришёл из Search.
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
// Статус — прямо в $filter (см. комментарий про ApprovalStatus выше):
// REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS.
const statusFilter =
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
const recentRows = (await approvalList.items
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()) as Array<Record<string, unknown>>;
// Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки,
// а не всё, где пользователь есть в маршруте.
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
const seenIds = new Set(recentRows.map((r) => r.Id));
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
} else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
// свежесть, а полнота по всему архиву, поэтому только Search, без
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
// давал неустойчивый результат (заявка то находилась, то нет, в
// зависимости от того, успел ли Search её проиндексировать к моменту
// REST-окна).
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
}
debug.approvalRowCount = stepRows.length;
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
// несколько ваших строк (напр. вы согласующий и делегат) — заявка одна.
const ticketIds = Array.from(
new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
);
// Статус СВОЕЙ строки по заявке — нужен, чтобы разнести заявки по очередям
// одним проходом (см. getMyTurnCounts). Если строк по заявке несколько,
// берём первую подошедшую: все они уже отобраны по statuses, а очередь у
// заявки на практике одна.
const stepStatusByTicket = new Map<number, string>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
if (ticketId && !stepStatusByTicket.has(ticketId)) {
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
}
});
if (ticketIds.length === 0) {
debug.approvalPendingTickets = 0;
return { rows: [], debug };
}
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
// комментарий у функции: через Lookup-expand это не отдаётся).
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
.select('Id', 'Title', 'RequestStatus', 'Created')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
const rows = ticketRows.map((r) => ({
...toMyRequest(r, undefined, type, 'approval'),
myStepStatus: stepStatusByTicket.get(r.Id as number),
}));
debug.approvalPendingTickets = rows.length;
return { rows, debug };
} catch (e) {
debug.approvalError = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return { rows: [], debug };
}
}
// Строки согласования текущего пользователя через Search, чтобы не сканировать
// список целиком (он растёт с каждой заявкой).
//
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
// возвращали 0.
//
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
// без него в выдачу попали бы все строки, где пользователь вообще есть в
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
//
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на
// устаревшем статусе решение «моя очередь» строить нельзя).
async function getInvolvedRowsViaSearch(
context: WebPartContext,
type: IRequestType,
approvalList: IList,
statuses: string[] = []
): Promise<Array<Record<string, unknown>>> {
try {
const email = currentUserEmail(context);
if (!email) {
console.warn(
`[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен`
);
return [];
}
const claimsLogin = `i:0#.f|membership|${email}`;
const rootFolder = (await approvalList
.select('RootFolder/ServerRelativeUrl')
.expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } };
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
if (!relUrl) return [];
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
// путями элементов …/Lists/List10 (см. listPathPrefix).
const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl.replace(/\/+$/, '')}/`;
const sp = spForSite(type.siteUrl as string, context);
const pathClause = `Path:"${approvalListAbsUrl}*"`;
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
if (statuses.length > 0) {
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
}
const kql = clauses.join(' AND ');
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`);
const ids = hits
.map((r) => Number(r.ListItemID))
.filter((id) => !isNaN(id) && id > 0);
if (ids.length === 0) return [];
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
// loadByIds (см. REST_ID_FILTER_CHUNK).
const chunks = await Promise.all(
chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) =>
approvalList.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'TicketId', 'ApprovalStatus')
.top(idsChunk.length)()
)
);
return (chunks as Array<Array<Record<string, unknown>>>).reduce((all, r) => all.concat(r), []);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`,
(e as Error).message
);
return [];
}
}
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
async function hydrateAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
if (rows.length === 0) return;
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const idsByType = new Map<number, number[]>();
rows.forEach((r) => {
const ids = idsByType.get(r.requestTypeId) || [];
ids.push(r.id);
idsByType.set(r.requestTypeId, ids);
});
const byKey = new Map<string, { EMail?: string; Title?: string }>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
try {
const sp = spForSite(type.siteUrl as string, context);
const authorRows = (await sp.web
.getList(type.listId as string)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ids.length)()) as Array<Record<string, unknown>>;
authorRows.forEach((r) => {
byKey.set(
`${requestTypeId}-${r.Id as number}`,
(r.Author as { EMail?: string; Title?: string }) || {}
);
});
} catch (e) {
console.error(`[Текущие заявки] Не удалось дочитать автора для «${type.name}»:`, (e as Error).message);
}
})
);
rows.forEach((r) => {
const a = byKey.get(`${r.requestTypeId}-${r.id}`);
r.authorEmail = a?.EMail || '';
r.authorName = a?.Title || '';
});
}
// ---------------------------------------------------------------------------
// Поиск через SharePoint Search REST API (search/query, KQL).
//
// Текст запроса идёт свободным термом — поиск по ВСЕМ проиндексированным полям
// элемента, а не только по названию. Плата за это — результаты берутся из
// поискового индекса, который переиндексируется с задержкой: только что
// созданная или изменённая заявка может найтись не сразу.
export interface IOwnRequestsPage {
rows: IMyRequest[];
totalRows: number;
}
const PAGE_SIZE = 20;
// Сколько заявок-кандидатов максимум перечисляем в ОДНОМ KQL-запросе при
// поиске среди уже отобранных (см. filterPendingByQuery) — не потолок
// итогового результата, а размер чанка: кандидатов режем на такие пачки и
// прогоняем каждую отдельным запросом, чтобы строка запроса не разрослась
// до неподъёмной, но при этом ни один кандидат не терялся.
const KQL_ID_LIMIT = 300;
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) {
result.push(items.slice(i, i + size));
}
return result;
}
// Собрать ВЕСЬ результат Search по запросу, а не одну страницу: сам
// SharePoint Search REST API ограничивает RowLimit одного вызова (см.
// SEARCH_PAGE_SIZE), поэтому при большом совпадающем наборе перебираем
// StartRow, пока индекс не перестанет отдавать новые строки или пока не
// упрёмся в SEARCH_HARD_CAP (аварийный потолок, а не ожидаемый лимит — при
// его достижении пишем предупреждение и отдаём то, что успели собрать).
async function fetchAllSearchRows(
sp: SPFI,
kql: string,
selectProperties: string[],
label: string,
sortList?: Array<{ Property: string; Direction: number }>
): Promise<Array<Record<string, unknown>>> {
const rows: Array<Record<string, unknown>> = [];
let startRow = 0;
for (;;) {
const results = await sp.search({
Querytext: kql,
RowLimit: SEARCH_PAGE_SIZE,
StartRow: startRow,
SelectProperties: selectProperties,
TrimDuplicates: false,
...(sortList ? { SortList: sortList } : {}),
});
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
rows.push(...page);
const total = results.TotalRows || 0;
startRow += page.length;
if (page.length === 0 || startRow >= total) break;
if (rows.length >= SEARCH_HARD_CAP) {
console.warn(
`[Текущие заявки] ${label}: индекс отдал ${total} строк — забрали только первые ${rows.length} (аварийный потолок)`
);
break;
}
}
return rows;
}
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(type: IRequestType): string {
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
}
// Префикс пути для KQL-условия Path и для сопоставления хитов с типом.
//
// Обязательно СО слешем на конце: адреса списков в этом сайте отличаются только
// номером (…/Lists/List1, …/Lists/List10), и «голый» префикс List1 совпадает с
// путями элементов List10. В KQL это притягивало чужие элементы, а при разборе
// ответа хит уезжал в первый подошедший тип — заявки «Обходного листа»
// приписывались списку List1, дальше их Id искались не в том списке и строки
// молча исчезали из выдачи.
function listPathPrefix(type: IRequestType): string {
return `${listAbsUrl(type).replace(/\/+$/, '')}/`;
}
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка —
// сравнение по границе сегмента, не по «голому» началу строки.
export function pathBelongsToList(path: string, type: IRequestType): boolean {
return path.toLowerCase().indexOf(listPathPrefix(type).toLowerCase()) === 0;
}
// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
// цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает)
function sanitizeForKql(text: string): string {
return text
.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Свободный терм по всем полям: каждое слово — префиксный поиск, все слова
// обязательны (AND). Пустая строка → undefined (условие не добавляем).
function freeTextClause(query: string): string | undefined {
const q = sanitizeForKql(query);
if (!q) return undefined;
return `(${q.split(' ').map((word) => `${word}*`).join(' AND ')})`;
}
// Ответ Search → пары (Id элемента, его тип по префиксу Path).
//
// Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах
// стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии —
// стандартная дедупликация Search схлопывает по содержимому, а не по Id, и
// вместе с ними может случайно схлопнуть две по-настоящему разные, но похожие
// заявки), а без неё индекс может вернуть один и тот же элемент несколькими
// хитами (переиндексация, несколько «версий» в crawl). Раньше хиты
// превращались в строки один-в-один, и дубликат из индекса становился
// дубликатом строки в таблице — с одинаковым React-ключом, то есть видимым
// «залипанием» одной и той же заявки дважды.
function hitsToEntries(
results: { PrimarySearchResults: unknown[] },
types: IRequestType[]
): Array<{ id: number; type: IRequestType }> {
const seen = new Set<string>();
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
.filter((h) => !isNaN(h.id) && h.id > 0)
.map((h) => {
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
// indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве.
const matched = types.filter((t) => pathBelongsToList(h.path, t))[0];
return matched ? { id: h.id, type: matched } : undefined;
})
.filter((e): e is { id: number; type: IRequestType } => !!e)
.filter((e) => {
const key = `${e.type.id}-${e.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
// кастомных полей нет гарантии, что они проиндексированы как managed property).
//
// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search
// собирает результат целиком (см. fetchAllSearchRows), их может набраться
// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в
// предел длины URL SharePoint REST.
async function loadByIds(
context: WebPartContext,
entries: Array<{ id: number; type: IRequestType }>,
kind: 'own' | 'approval'
): Promise<Map<string, IMyRequest>> {
const idsByType = new Map<IRequestType, number[]>();
entries.forEach((e) => {
const ids = idsByType.get(e.type) || [];
ids.push(e.id);
idsByType.set(e.type, ids);
});
const byKey = new Map<string, IMyRequest>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => {
const sp = spForSite(type.siteUrl as string, context);
const list = sp.web.getList(type.listId as string);
await Promise.all(
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
const rows = (await list.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind));
});
})
);
})
);
return byKey;
}
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ
// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id}`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.orderBy('Created', false)
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
if (rows.length === OWN_REST_TOP) {
console.warn(
`[Текущие заявки] «${type.name}»: REST-довесок упёрся в ${OWN_REST_TOP} заявок — более старые черновики сюда не попадут`
);
}
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
);
} catch (e) {
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
return [];
}
}
// Свежие (созданные за последние RECENT_MS) заявки автора — довесок к Search,
// индекс мог их ещё не подхватить.
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
// черновик в «Моих заявках» пользователь ожидает сразу.
return (await getOwnRowsForType(context, type)).filter(
(r) => r.status === REQUEST_STATUS.draft || r.created >= cutoff
);
}
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
// вернуть на доработку неделю спустя после создания — по Created такую не
// найти, а Modified у неё обновится ровно в момент решения согласующего
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
// (в отличие от статуса, см. getOwnRequestsByStatus).
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
(e as Error).message
);
return [];
}
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по
// дате там и оказалось бы, будь оно проиндексировано.
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPage;
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = onlyFresh
.concat(searchPage.rows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length };
}
// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть:
// только что созданная заявка может найтись не сразу (задержка индексации), но
// зато результат предсказуем — не зависит от 10-минутного REST-окна.
export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
SortList: [{ Property: 'Created', Direction: 1 }],
});
const totalRows = results.TotalRows || 0;
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
return { rows, totalRows };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
// Статус уходит прямо в KQL через MP_REQUEST_STATUS (проверено вручную
// 2026-08-17, см. комментарий у константы) — это настоящий серверный запрос
// с RowLimit/StartRow, как и searchOwnRequests, а не «стянуть всё и
// отфильтровать в памяти».
export async function getOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
query: string = ''
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_REQUEST_STATUS}:"${status}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
// Свежие сверху — как в searchOwnRequests.
SortList: [{ Property: 'Created', Direction: 1 }],
});
const totalRows = results.TotalRows || 0;
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
// вернуть на доработку только что, индекс мог не успеть её подхватить.
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = onlyFresh
.concat(searchRows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: totalRows + onlyFresh.length };
}
// Список согласования по типу заявки: Title списка данных → Title списка
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
// undefined — список заведён не по конвенции, маршрута для него не найти.
async function approvalListFor(
context: WebPartContext,
type: IRequestType
): Promise<IList | undefined> {
const sp = spForSite(type.siteUrl as string, context);
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
const dataTitle = info.Title || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
}
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
const DECISION_COMMENT_ROW_LIMIT = 2000;
/**
* Проставляет decisionComment — комментарий согласующего к его решению по
* заявке: причину возврата на доработку либо отклонения (что именно искать,
* задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
* Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
* получить тем же запросом, что и список: нужен отдельный проход по спискам
* согласования.
*
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
*
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
* Мутирует переданные строки — как и hydrateAuthors рядом.
*/
export async function hydrateDecisionComments(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
decisionStatus: string
): Promise<void> {
if (rows.length === 0) return;
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const rowsByType = new Map<number, IMyRequest[]>();
rows.forEach((r) => {
const list = rowsByType.get(r.requestTypeId) || [];
list.push(r);
rowsByType.set(r.requestTypeId, list);
});
await Promise.all(
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
try {
const approvalList = await approvalListFor(context, type);
if (!approvalList) return;
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
// сильнее `or», без скобок фильтр разобрался бы как
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ');
const stepRows = (await approvalList.items
.filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`)
.select('TicketId', 'Comment')
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
const commentByTicket = new Map<number, string>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
const comment = ((r.Comment as string) || '').trim();
// Такая строка по заявке одна (и возврат, и отклонение останавливают
// маршрут, а при переотправке он переписывается целиком — см.
// writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ,
// а не последнюю пустую.
if (ticketId && comment && !commentByTicket.has(ticketId)) {
commentByTicket.set(ticketId, comment);
}
});
typeRows.forEach((r) => {
r.decisionComment = commentByTicket.get(r.id);
});
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
(e as Error).message
);
}
})
);
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по
// всем полям), без клиентского REST-матчинга по свежести.
//
// Кандидатов режем на чанки по KQL_ID_LIMIT и гоняем каждый отдельным
// запросом (см. chunk/fetchAllSearchRows) — раньше «лишние» сверх лимита
// кандидаты молча выпадали из поиска, теперь ни один не теряется, просто
// уходит несколькими запросами вместо одного.
export async function filterPendingByQuery(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
query: string
): Promise<IMyRequest[]> {
const text = freeTextClause(query);
if (!text || rows.length === 0) return rows;
const scopeTypes = types.filter((t) => rows.some((r) => r.requestTypeId === t.id));
if (scopeTypes.length === 0) return [];
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`;
const found = new Set<string>();
await Promise.all(
chunk(rows, KQL_ID_LIMIT).map(async (candidates) => {
const kql = [
pathClause,
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
text,
].join(' AND ');
const hits = await fetchAllSearchRows(
sp,
kql,
['ListItemID', 'Title', 'Path'],
'Поиск среди ждущих решения'
);
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
found.add(`${e.type.id}-${e.id}`)
);
})
);
return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
}
// ---------------------------------------------------------------------------
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
// раздела одно из двух не нужно).
//
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
// единой серверной пагинации на двух источниках сразу не построить. Для
// сценария «искал что-то один раз» это приемлемо.
export interface ISearchPage {
rows: IMyRequest[];
totalRows: number;
}
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез
// (см. fetchAllSearchRows — весь, а не первые KQL_ID_LIMIT: тот лимит здесь
// больше не действует, это чанк-размер для другой функции, filterPendingByQuery).
// Только Search, без REST-подстраховки на свежесть.
export async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<IMyRequest[]> {
if (types.length === 0) return [];
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки');
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
traceResultRows('Свои заявки', types, entries, rows);
return rows;
}
/**
* ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает
* вызывающая сторона.
*
* Именно так, а не «страницу за вызов»: набор всё равно собирается целиком
* (двумя источниками + слияние + сортировка), и раньше 95% этой работы
* выбрасывалось ради 20 строк — а при переходе на следующую страницу весь поиск
* запускался заново. Теперь SearchView кеширует список по ЗАПРОСУ, и листание
* страниц не стоит ни одного запроса.
*
* Автор здесь НЕ дочитывается: у строк 'approval' он требует отдельного запроса,
* и делать его на весь набор (до KQL_ID_LIMIT) значило бы собирать $filter из
* сотен Id — SharePoint такой длины URL не переварит. Автора дочитывает
* вызывающая сторона для видимой страницы (hydratePendingAuthors).
*/
export async function searchMyRequests(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<ISearchPage> {
const q = query.trim();
if (!q) return { rows: [], totalRows: 0 };
const [ownRows, involvedIdx] = await Promise.all([
searchOwnAll(context, types, q),
getPendingApprovalIndex(context, 'involved'),
]);
const involvedFiltered = await filterPendingByQuery(context, involvedIdx.types, involvedIdx.rows, q);
// Заявка может оказаться и своей, и involved одновременно (сам себе
// согласующий) — оставляем как 'own', не дублируем строку.
const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
return { rows: merged, totalRows: merged.length };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval'
): IMyRequest {
return {
id: r.Id as number,
title: (r.Title as string) || '',
status: (r.RequestStatus as string) || '',
authorEmail: author?.EMail || '',
authorName: author?.Title || '',
created: (r.Created as string) || '',
requestTypeId: type.id,
requestTypeName: type.name,
section: type.section,
formKey: type.pageUrl,
kind,
};
}