Оптимизация списков (#12)
Tests / frontend-test (push) Successful in 1m18s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-21 10:01:33 +00:00
parent ebb361ab9f
commit 131e302f67
14 changed files with 9519 additions and 294 deletions
@@ -11,15 +11,22 @@ import '@pnp/sp/search';
// но в рантайме не зарегистрирован.
import '@pnp/sp/batching';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
import {
currentUserIdFor,
ensureUserIdFor,
getRequestTypesCached,
IRequestType,
approvalListKey,
locateApprovalLists,
partitionsForTypeId,
REQUEST_STATUS,
siteOrigin,
} 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*$/;
import { perfScope, traced } from '../../shared/perfTrace';
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
@@ -42,10 +49,10 @@ const SEARCH_PAGE_SIZE = 500;
const SEARCH_HARD_CAP = 5000;
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
// а не уходит одним запросом целиком.
// при дочитывании строк маршрута/заявки по найденным через Search Id (см.
// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL —
// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного
// типа режется на такие пачки, а не уходит одним запросом целиком.
const REST_ID_FILTER_CHUNK = 100;
// Сколько последних заявок автора дочитываем через REST в довесок к Search
@@ -76,6 +83,42 @@ const MP_STATUS = 'ApprovalStatusOWSCHCS';
// SharePoint — сама схема поиска исправна.
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
// Автор заявки (Person-поле Author списка ДАННЫХ) — суффикс OWSUSER, без
// префикса группы колонок (поле стандартное, не сайтовое, как и RequestStatus
// выше). Значение — «<логин> | <ФИО>» (см. parseAuthorOwsUser). Уже
// использовался в KQL (AuthorOWSUSER:"...") для отбора «своих» заявок; здесь
// то же свойство дочитывается из ответа Search вместо REST.
const MP_AUTHOR = 'AuthorOWSUSER';
// Уникальный код заявки (колонка RequestUniqueCode) — суффикс OWSTEXT, тип
// исходного поля Text.
const MP_CODE = 'RequestUniqueCodeOWSTEXT';
// Поля заявки, отдаваемые ПРЯМО индексом, без REST-дочитывания после Search —
// подтверждено вручную (tools/sp props, 2026-08-20): Queryable ≠ Retrievable у
// managed property, поэтому то, что раньше проверялось только на фильтрацию
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) отдаёт
// готовое отображаемое имя — короче и надёжнее выковыривать его из
// MP_AUTHOR/parseAuthorOwsUser, там он нужен только ради почты.
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
/**
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
*/
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
const text = typeof value === 'string' ? value : '';
const sep = text.indexOf(' | ');
if (sep < 0) return { email: text.trim(), name: '' };
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
}
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
@@ -86,7 +129,11 @@ export function currentUserEmail(context: WebPartContext): string {
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
const types = await getRequestTypes(context);
// Кешированный вариант: getLinkedTypes зовут и счётчики меню, и каждая
// таблица, и «Поиск» — на одно открытие портала это было 4-5 одинаковых
// AAD-запросов к /api/request-types. Ответ меняется только когда админ
// заводит тип заявки, то есть заведомо не в пределах загрузки страницы.
const types = await getRequestTypesCached(context);
const linked = types.filter((t) => !!t.listId && !!t.siteUrl);
traceLinkedTypes(types, linked);
return linked;
@@ -195,28 +242,38 @@ async function buildPendingIndex(
[]
);
// Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом:
// approval-route не хранит на него ссылку (список согласования ищется только
// по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда.
// Но это лёгкий $select без постраничного перебора — не то же самое, что
// полноценный Search, который эти вызовы как раз заменяют.
const resolved = await Promise.all(
flat.map(({ type, target }) =>
resolveApprovalSearchTarget(context, type, target)
// Адреса списков согласования всех партиций разом: не больше ОДНОГО
// /_api/web/lists на сайт, а после первого раза — вообще без сети (адреса
// кешируются на сессию, см. shared/approval/approvalListLocator.ts). Раньше
// здесь был отдельный REST-запрос на КАЖДУЮ партицию, и он повторялся при
// каждой загрузке очереди — при 11 партициях это 11 запросов на проход, а
// проходов за одно открытие вкладки два (счётчики меню + сама таблица).
const located = await traced('адреса списков согласования', () =>
locateApprovalLists(
context,
flat.map(({ target }) => ({ listId: target.listId, siteUrl: target.siteUrl, title: target.title }))
)
);
const okEntries: IApprovalSearchTarget[] = [];
resolved.forEach((r) => {
const debugType = r.kind === 'ok' ? r.value.type : r.type;
const debug = debugByType.get(debugType.id);
if (!debug) return;
if (r.kind === 'ok') {
okEntries.push(r.value);
} else if (r.kind === 'skip') {
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason;
flat.forEach(({ type, target }) => {
const debug = debugByType.get(type.id);
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
if (!debug || !lookup) return;
if (lookup.kind === 'ok') {
okEntries.push({
type,
target,
// getByTitle — только построение url, отдельного запроса не стоит.
approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle),
approvalListTitle: lookup.value.approvalListTitle,
approvalListAbsUrl: lookup.value.approvalListAbsUrl,
});
} else if (lookup.kind === 'skip') {
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = lookup.reason;
} else {
if (!debug.approvalError) debug.approvalError = r.message;
if (!debug.approvalError) debug.approvalError = lookup.message;
}
});
@@ -225,22 +282,28 @@ async function buildPendingIndex(
// поэтому идут параллельно, а не один за другим.
const [hitsByTarget, recentByTarget] = await Promise.all([
okEntries.length > 0
? searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
? traced('Search: очередь согласования', () =>
searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
)
: Promise.resolve(new Map<string, number[]>()),
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
okEntries.length > 0 && scope === 'myTurn'
? fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
? traced('REST-довесок по свежести (очередь)', () =>
fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
)
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
]);
const parts = await Promise.all(
okEntries.map((entry) =>
finalizeApprovalTarget(
context,
entry,
hitsByTarget.get(targetKey(entry)) ?? [],
recentByTarget.get(targetKey(entry)) ?? []
const parts = await traced('дочитывание строк очереди (REST)', () =>
Promise.all(
okEntries.map((entry) =>
finalizeApprovalTarget(
context,
entry,
hitsByTarget.get(targetKey(entry)) ?? [],
recentByTarget.get(targetKey(entry)) ?? []
)
)
)
);
@@ -281,6 +344,55 @@ async function buildPendingIndex(
return { types, index, debug: types.map((t) => debugByType.get(t.id)!) };
}
/** Результат buildPendingIndex — вынесен в тип, потому что им теперь делятся вызывающие. */
interface IPendingIndex {
types: IRequestType[];
index: IMyRequest[];
debug: IMyRequestsTypeDebug[];
}
// Уже идущие сборки индекса, по ключу «область + набор статусов».
//
// При открытии портала счётчики меню (getNavCounts) и сама таблица очереди
// стартуют практически одновременно и просят ОДИН И ТОТ ЖЕ набор — раньше это
// значило два полных прохода: два резолва адресов списков, два Search, два
// довеска по свежести. Второй вызов теперь подхватывает промис первого.
//
// Держим ТОЛЬКО пока запрос в полёте: как только он завершился, запись
// удаляется. Кешировать результат по времени нельзя — «Обновить» и событие
// rp-requests-changed обязаны читать данные заново.
const pendingIndexInFlight = new Map<string, Promise<IPendingIndex>>();
function pendingIndexKey(scope: ApprovalScope, statuses: string[]): string {
return `${scope}|${[...statuses].sort().join(',')}`;
}
async function sharedPendingIndex(
context: WebPartContext,
scope: ApprovalScope,
statuses: string[]
): Promise<IPendingIndex> {
const key = pendingIndexKey(scope, statuses);
const running = pendingIndexInFlight.get(key);
if (running) return running;
const forget = (): void => {
pendingIndexInFlight.delete(key);
};
const promise = buildPendingIndex(context, scope, statuses).then(
(r) => {
forget();
return r;
},
(e) => {
forget();
throw e;
}
);
pendingIndexInFlight.set(key, promise);
return promise;
}
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
// заявок отдать), автор дочитывается только для них.
export async function getApprovalQueue(
@@ -288,12 +400,29 @@ export async function getApprovalQueue(
kind: ApprovalQueueKind,
page: number = 1
): Promise<IPendingApprovalPage> {
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
// выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же,
// как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же
// самый набор, и просить его двумя разными запросами значит дважды пройти по
// всем спискам согласования. История решений ('approved'/'rejected'/
// 'executed') в эту пару не входит и берётся своим запросом.
const shared = MY_TURN_STATUSES.indexOf(QUEUE_STATUSES[kind][0]) >= 0;
const { types, index, debug } = await sharedPendingIndex(
context,
'myTurn',
shared ? MY_TURN_STATUSES : QUEUE_STATUSES[kind]
);
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
// Отбор по СВЕЖЕМУ статусу своей строки, а не по тому, что отдал индекс.
// Индекс мог отстать, и заявка, по которой решение уже принято, не должна
// висеть во вкладке «ждут вашего согласования» — по этой же причине
// finalizeApprovalTarget перечитывает статус REST-ом.
const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index;
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
await hydrateAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: index.length, debug };
return { rows: pageRows, totalRows: rows.length, debug };
}
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
@@ -308,7 +437,7 @@ 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]);
const { types, index, debug } = await sharedPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
return { rows: index, types, debug };
}
@@ -318,7 +447,7 @@ export async function getPendingApprovalIndex(
context: WebPartContext,
scope: ApprovalScope
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
const { types, index, debug } = await buildPendingIndex(context, scope, []);
const { types, index, debug } = await sharedPendingIndex(context, scope, []);
return { rows: index, types, debug };
}
@@ -338,21 +467,21 @@ export interface INavCounts {
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
// вкладку: иначе счётчик и список могли бы разойтись.
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
return getNavCountsInner(context);
return perfScope('счётчики меню', () => getNavCountsInner(context));
}
async function getNavCountsInner(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),
sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
countOwnRequestsByStatus(context, types, REQUEST_STATUS.returned),
]);
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,
rework,
};
}
@@ -383,67 +512,6 @@ interface IApprovalSearchTarget {
approvalListAbsUrl: string;
}
type ApprovalTargetResolution =
| { kind: 'ok'; value: IApprovalSearchTarget }
| { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string }
| { kind: 'error'; type: IRequestType; target: IListTarget; message: string };
/**
* Резолвит список согласования одной партиции: Title (по конвенции из Title
* списка данных) и его абсолютный адрес пути.
*
* Title списка данных — из target.title (бесплатно, из bulk-ответа
* /api/request-types/lists, см. IListTarget.title), REST — только если реестр
* партиций был недоступен.
*
* Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него
* ссылку (список согласования ищется только по имени, см. shared/listTitles.ts),
* сам список создаётся SharePoint под произвольным номером, и его адрес нельзя
* вывести из адреса списка данных. Но это лёгкий $select без постраничного
* перебора — не то же самое, что полноценный Search, который эти вызовы
* заменяют (см. searchAcrossApprovalLists).
*/
async function resolveApprovalSearchTarget(
context: WebPartContext,
type: IRequestType,
target: IListTarget
): Promise<ApprovalTargetResolution> {
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
const dataTitle =
target.title ??
((await list.select('Title')()).Title as string) ??
'';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
return {
kind: 'skip',
type,
target,
reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`,
};
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
const rootFolder = (await approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()) as {
RootFolder?: { ServerRelativeUrl?: string };
};
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
if (!relUrl) {
return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` };
}
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
// путями элементов …/Lists/List10 (см. listPathPrefix).
const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`;
return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } };
} catch (e) {
return { kind: 'error', type, target, message: (e as Error).message };
}
}
function targetKey(entry: IApprovalSearchTarget): string {
return entry.approvalListAbsUrl.toLowerCase();
}
@@ -570,16 +638,17 @@ async function fetchRecentApprovalRows(
try {
const sp = spForSite(siteUrl, context);
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
// ensureUser — один раз на сайт И один раз на сессию: результат кешируется
// (см. shared/approval/siteUser.ts). Раньше он вызывался внутри обработки
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
// POST, а не дешёвый GET.
// POST, а не дешёвый GET; теперь его не платят даже повторные загрузки.
//
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
// проекте) и фильтруем по TansuSiteApproverId.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
const approverId = await ensureUserIdFor(context, siteUrl, claimsLogin);
const filter = `TansuSiteApproverId eq ${approverId} and Modified ge '${cutoffIso}'${statusFilter}`;
const [batchedSP, execute] = sp.batched();
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
@@ -654,8 +723,7 @@ async function finalizeApprovalTarget(
? []
: (
await Promise.all(
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
// loadByIds (см. REST_ID_FILTER_CHUNK).
// Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK).
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
approvalList.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
@@ -694,14 +762,23 @@ async function finalizeApprovalTarget(
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
// комментарий у функции: через Lookup-expand это не отдаётся).
//
// Автор берётся ЗДЕСЬ ЖЕ через expand. Раньше он дочитывался отдельным
// проходом (hydrateAuthors) и только по строкам видимой страницы — чтобы не
// тянуть его на весь индекс. Но экономилась при этом не сеть, а payload:
// запрос к списку данных всё равно делается, и expand не добавляет к нему
// ни одного round-trip'а, тогда как отдельный проход стоил целый — замер
// 2026-08-20 показал 262 мс ровно на критическом пути вкладки
// «На согласовании» (Search → строки очереди → автор).
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created')
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
debug.rows = ticketRows.map((r) => ({
...toMyRequest(r, undefined, type, 'approval', target),
...toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'approval', target),
myStepStatus: stepStatusByTicket.get(r.Id as number),
}));
@@ -716,16 +793,31 @@ async function finalizeApprovalTarget(
}
}
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — только
// по переданным строкам. Мутирует переданные объекты (проставляет
// authorEmail/authorName).
//
// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо
// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget)
// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда
// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на
// случай источника строк без автора — молча показывать пустого инициатора
// хуже, чем один лишний запрос.
async function hydrateAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
if (rows.length === 0) return;
const missing = rows.filter((r) => !r.authorName && !r.authorEmail);
if (missing.length === 0) return;
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, missing));
}
async function hydrateAuthorsInner(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
@@ -822,6 +914,10 @@ async function fetchAllSearchRows(
let startRow = 0;
for (;;) {
// Постраничной метки здесь больше нет: замер 2026-08-20 показал, что запрос
// ровно один (847 мс у вызывающего против 846 мс у страницы), то есть
// медленный сам Search, а не перебор StartRow. Вопрос закрыт, а метка
// занимала место в строке сводки, дублируя метку вызывающего.
const results = await sp.search({
Querytext: kql,
RowLimit: SEARCH_PAGE_SIZE,
@@ -848,11 +944,6 @@ async function fetchAllSearchRows(
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}`;
}
@@ -1003,7 +1094,7 @@ function hitsToEntries(
): ISearchEntry[] {
const seen = new Set<string>();
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || ''), raw: r }))
.filter((h) => !isNaN(h.id) && h.id > 0)
.map((h) => {
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
@@ -1013,7 +1104,7 @@ function hitsToEntries(
// из того же списка, где его нашёл Search (см. targetForPath).
for (const type of types) {
const target = targetForPath(h.path, type);
if (target) return { id: h.id, type, target };
if (target) return { id: h.id, type, target, raw: h.raw };
}
return undefined;
})
@@ -1026,72 +1117,50 @@ function hitsToEntries(
});
}
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
/**
* Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка
* ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES
* и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно.
*/
interface ISearchEntry {
id: number;
type: IRequestType;
target: IListTarget;
raw: Record<string, unknown>;
}
// Дочитать поля заявок по найденным 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: ISearchEntry[],
kind: 'own' | 'approval'
): Promise<Map<string, IMyRequest>> {
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
entries.forEach((e) => {
const key = e.target.listId.toLowerCase();
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
bucket.ids.push(e.id);
byPartition.set(key, bucket);
});
const byKey = new Map<string, IMyRequest>();
await Promise.all(
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
// Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть
// недоступен (удалён, переименован, остался в реестре после смены списка у
// типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции
// один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не
// увидел бы ничего вместо «всё, кроме одного архива».
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
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', 'RequestUniqueCode', '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(
requestKey(target.listId, r.Id as number),
toMyRequest(r, author, type, kind, target)
);
});
})
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`,
(e as Error).message
);
}
})
);
return byKey;
/**
* Строка «Моих заявок» ПРЯМО из ответа Search — без второго прохода по REST.
*
* Раньше здесь был REST-запрос по найденным Id (loadByIds): считалось, что
* поисковый индекс отдаёт не все колонки. Проверено вручную (tools/sp props,
* 2026-08-20) — отдаёт все нужные, см. OWN_SELECT_PROPERTIES. Оставался вопрос
* СВЕЖЕСТИ: значения теперь из индекса, который переиндексируется с задержкой,
* а не «только что созданный элемент» напрямую. Для 'own' это уже было принятым
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
*/
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
const r = entry.raw;
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
return {
id: entry.id,
code: (r[MP_CODE] as string) || '',
title: (r.Title as string) || '',
status: (r[MP_REQUEST_STATUS] as string) || '',
authorEmail: author.email,
// 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES);
// из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст.
authorName: ((r.Author as string) || author.name || '').trim(),
created: (r.Created as string) || '',
requestTypeId: entry.type.id,
requestTypeName: entry.type.name,
section: entry.type.section,
formKey: entry.type.pageUrl,
kind: 'own',
listId: entry.target.listId,
siteUrl: entry.target.siteUrl,
};
}
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
@@ -1106,11 +1175,14 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
try {
const sp = spForSite(target.siteUrl, context);
const me = await sp.web.currentUser.select('Id')();
// Кешированный Id (см. shared/approval/siteUser.ts): эта функция зовётся в
// цикле по типам заявок, и без кеша тот же GET /_api/web/currentuser уходил
// бы столько раз, сколько у портала типов.
const myId = await currentUserIdFor(context, target.siteUrl);
const rows = (await sp.web
.getList(target.listId)
.items.filter(`AuthorId eq ${me.Id}`)
.items.filter(`AuthorId eq ${myId}`)
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.orderBy('Created', false)
@@ -1158,12 +1230,12 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
try {
const sp = spForSite(target.siteUrl, context);
const me = await sp.web.currentUser.select('Id')();
const myId = await currentUserIdFor(context, target.siteUrl);
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(target.listId)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.items.filter(`AuthorId eq ${myId} and Modified ge '${cutoffIso}'`)
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
@@ -1180,37 +1252,85 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
}
}
// Итог слияния Search-страницы с REST-довеском по свежести — общая логика для
// getOwnRequests и ownRequestsByStatus. Довесок ДОПОЛНЯЕТ Search, а не заменяет:
// то, что Search уже нашёл, в приоритете (там могло появиться более точное
// совпадение по совместному запросу — статус/текст), довесок добавляет только
// то, чего в найденном ещё нет.
function mergeSearchAndFresh(searchPage: IOwnRequestsPage, freshRows: IMyRequest[]): IOwnRequestsPage {
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
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-довесок по свежести ПАРАЛЛЕЛЬНО — то один
* отвечает быстрее, то другой, заранее не угадать, и ждать заведомо более
* медленный, держа экран пустым, незачем.
*
* Если задан onPartial — зовёт его РАЗ, как только придёт ПЕРВЫЙ из двух
* источников (та половина, что уже есть, без дедупа со вторым — его мы ещё не
* видели). Возвращает функция в любом случае ИТОГОВЫЙ слитый результат;
* onPartial — только для промежуточного показа, на итог не влияет.
*
* Без onPartial ведёт себя как раньше: ждёт оба источника и сразу отдаёт слитый
* результат — второй показ (полный) экономить незачем, если промежуточный
* всё равно никто не увидит.
*/
async function raceThenMerge(
searchPromise: Promise<IOwnRequestsPage>,
freshPromise: Promise<IMyRequest[]>,
onPartial: ((page: IOwnRequestsPage) => void) | undefined
): Promise<IOwnRequestsPage> {
if (onPartial) {
let searchDone = false;
let freshDone = false;
// Ошибку здесь не обрабатываем — её поймает Promise.all ниже; этот
// отдельный .then только подсматривает за тем, что пришло раньше.
searchPromise.then((r) => {
searchDone = true;
if (!freshDone) onPartial(r);
}, () => undefined);
freshPromise.then((r) => {
freshDone = true;
if (!searchDone) onPartial({ rows: r, totalRows: r.length });
}, () => undefined);
}
const [searchPage, freshRows] = await Promise.all([searchPromise, freshPromise]);
return mergeSearchAndFresh(searchPage, freshRows);
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по
// дате там и оказалось бы, будь оно проиндексировано.
//
// Search и довесок идут ПАРАЛЛЕЛЬНО (см. raceThenMerge) — раньше довесок
// стартовал только ПОСЛЕ ответа Search, хотя они независимы друг от друга и
// читают разные источники; тем самым просто складывалось время обоих вместо
// времени более медленного из двух.
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
page: number
page: number,
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page);
const searchPromise = searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPromise;
if (page !== 1) return searchPage;
const freshPromise = Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))).then((r) =>
r.reduce((all, x) => all.concat(x), [] as IMyRequest[])
);
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) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
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 };
return raceThenMerge(searchPromise, freshPromise, onPartial);
}
// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть:
@@ -1229,30 +1349,28 @@ export async function searchOwnRequests(
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${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 results = await traced('Search: свои заявки', () =>
sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: OWN_SELECT_PROPERTIES,
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(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
// здесь больше нет. Порядок задаёт релевантность поиска.
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
return { rows, totalRows };
}
@@ -1269,7 +1387,40 @@ export async function getOwnRequestsByStatus(
types: IRequestType[],
status: string,
page: number,
query: string = ''
query: string = '',
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> {
return ownRequestsByStatus(context, types, status, page, query, onPartial);
}
/**
* Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню.
*
* До 2026-08-20 у этого была отдельная REST-развилка (withRows): дочитывание
* полей найденных заявок стоило отдельного запроса, и счётчику, которому нужен
* только totalRows, было жаль на него тратиться. Теперь поля идут прямо из
* ответа Search (см. toMyRequestFromSearch) — построить rows настолько же
* дёшево, насколько и посчитать totalRows, отдельная ветка была бы одной лишь
* сложностью без выигрыша.
*
* Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка
* по-прежнему не расходятся (ради чего они и считаются одним кодом).
*/
export async function countOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string
): Promise<number> {
return (await ownRequestsByStatus(context, types, status, 1, '')).totalRows;
}
async function ownRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
query: string,
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -1277,67 +1428,41 @@ export async function getOwnRequestsByStatus(
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${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(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
const searchPromise: Promise<IOwnRequestsPage> = traced('Search: свои заявки', () =>
sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
// Свежие сверху — как в searchOwnRequests.
SortList: [{ Property: 'Created', Direction: 1 }],
})
).then((results) => ({
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
totalRows: results.TotalRows || 0,
}));
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (тот же принцип, что в 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);
// Запускаем СРАЗУ, не дожидаясь Search: условие включения известно из
// параметров вызова, ждать ради него ответ Search незачем — раньше довесок
// стартовал только ПОСЛЕ Search, хотя независим от его результата.
if (page !== 1 || query.trim()) return searchPromise;
const searchKeys = new Set(searchRows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
const freshPromise = Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))).then((r) =>
r.reduce((acc, x) => acc.concat(x), [] as IMyRequest[]).filter((x) => x.status === status)
);
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 списка
// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
// undefined — список заведён не по конвенции, маршрута для него не найти.
async function approvalListFor(
context: WebPartContext,
target: IListTarget
): Promise<IList | undefined> {
const sp = spForSite(target.siteUrl, context);
const info = (await sp.web.getList(target.listId).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, '')} — согласование`);
return raceThenMerge(searchPromise, freshPromise, onPartial);
}
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
@@ -1367,7 +1492,17 @@ export async function hydrateDecisionComments(
decisionStatus: string
): Promise<void> {
if (rows.length === 0) return;
return traced('комментарии к решению (REST)', () =>
hydrateDecisionCommentsInner(context, types, rows, decisionStatus)
);
}
async function hydrateDecisionCommentsInner(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
decisionStatus: string
): Promise<void> {
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
@@ -1387,11 +1522,26 @@ export async function hydrateDecisionComments(
rowsByPartition.set(key, bucket);
});
// Адреса/имена списков согласования — одним резолвом на все партиции сразу и,
// как правило, вовсе без сети: их уже сложил в кеш buildPendingIndex
// (см. shared/approval/approvalListLocator.ts). Раньше здесь был отдельный
// REST за Title списка данных на КАЖДУЮ партицию страницы.
const located = await locateApprovalLists(
context,
Array.from(rowsByPartition.values()).map(({ target }) => ({
listId: target.listId,
siteUrl: target.siteUrl,
}))
);
await Promise.all(
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
try {
const approvalList = await approvalListFor(context, target);
if (!approvalList) return;
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
if (!lookup || lookup.kind !== 'ok') return;
const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
lookup.value.approvalListTitle
);
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
// сильнее `or», без скобок фильтр разобрался бы как
@@ -1510,21 +1660,19 @@ export async function searchOwnAll(
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${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 hits = await fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Свои заявки');
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(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
const rows = entries.map(toMyRequestFromSearch);
traceResultRows('Свои заявки', types, entries, rows);
return rows;