оптимизация используем общий search

This commit is contained in:
gorbenko.pavel
2026-08-18 17:37:44 +05:00
parent fe03b9e640
commit bfe71f8c5a
@@ -20,7 +20,7 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, // Search, и подмешиваем к результату (см. buildPendingIndex/myTurn,
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для // getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная // таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
@@ -100,7 +100,7 @@ export type ApprovalScope = 'myTurn' | 'involved';
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя. // Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
// Отличаются только набором статусов его строки — сам механизм (найти свои // Отличаются только набором статусов его строки — сам механизм (найти свои
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не // строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
// зашиты в indexApprovalsForType. // зашиты в finalizeApprovalTarget.
// //
// approvals/execution — очереди действий: ход за пользователем прямо сейчас. // approvals/execution — очереди действий: ход за пользователем прямо сейчас.
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал // Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
@@ -153,6 +153,14 @@ const PENDING_PAGE_SIZE = 20;
// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там // (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы — // нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView). // см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
//
// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов —
// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см.
// searchAcrossApprovalLists), и при архивации в несколько партиций это была
// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11
// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с).
// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см.
// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования.
async function buildPendingIndex( async function buildPendingIndex(
context: WebPartContext, context: WebPartContext,
scope: ApprovalScope, scope: ApprovalScope,
@@ -161,18 +169,103 @@ async function buildPendingIndex(
const types = await getLinkedTypes(context); const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context); const tokenEmail = currentUserEmail(context);
const perType = await Promise.all( const debugByType = new Map<number, IMyRequestsTypeDebug>();
types.map((t) => types.forEach((t) =>
traced(`indexApprovalsForType:${t.name}`, () => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) debugByType.set(t.id, {
requestTypeId: t.id,
requestTypeName: t.name,
listId: t.listId,
siteUrl: t.siteUrl,
skipped: false,
})
);
if (!tokenEmail) {
return { types, index: [], debug: types.map((t) => debugByType.get(t.id)!) };
}
// Партиции ВСЕХ типов — в один плоский список: комбинированный Search строит
// Path-условие по ним разом, поэтому дальше нет смысла обходить типы по
// отдельности.
const flat = types.reduce<Array<{ type: IRequestType; target: IListTarget }>>(
(all, t) => all.concat(listTargetsForType(t).map((target) => ({ type: t, target }))),
[]
);
// Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом:
// approval-route не хранит на него ссылку (список согласования ищется только
// по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда.
// Но это лёгкий $select без постраничного перебора — не то же самое, что
// полноценный Search, который эти вызовы как раз заменяют.
const resolved = await Promise.all(
flat.map(({ type, target }) =>
traced(`resolveApprovalSearchTarget:${type.name}#${target.listId}`, () =>
resolveApprovalSearchTarget(context, type, target)
)
) )
); );
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. const okEntries: IApprovalSearchTarget[] = [];
const index = perType resolved.forEach((r) => {
.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]) const debugType = r.kind === 'ok' ? r.value.type : r.type;
.sort((a, b) => b.created.localeCompare(a.created)); 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;
} else {
if (!debug.approvalError) debug.approvalError = r.message;
}
});
return { types, index, debug: perType.map((r) => r.debug) }; const hitsByTarget =
okEntries.length > 0
? await traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
: new Map<string, number[]>();
const parts = await Promise.all(
okEntries.map((entry) =>
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
finalizeApprovalTarget(context, entry, scope, statuses, hitsByTarget.get(targetKey(entry)) ?? [], tokenEmail)
)
)
);
const rowsByType = new Map<number, IMyRequest[]>();
const titlesByType = new Map<number, string[]>();
parts.forEach((part, i) => {
const entry = okEntries[i];
const debug = debugByType.get(entry.type.id);
if (!debug) return;
const bucket = rowsByType.get(entry.type.id) ?? [];
bucket.push(...part.rows);
rowsByType.set(entry.type.id, bucket);
if (part.approvalListTitle) {
const titles = titlesByType.get(entry.type.id) ?? [];
titles.push(part.approvalListTitle);
titlesByType.set(entry.type.id, titles);
}
if (part.error && !debug.approvalError) debug.approvalError = part.error;
debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0);
});
const index: IMyRequest[] = [];
types.forEach((t) => {
const debug = debugByType.get(t.id)!;
const titles = titlesByType.get(t.id);
if (titles && titles.length > 0) debug.approvalListTitle = titles.join(', ');
const typeRows = rowsByType.get(t.id) ?? [];
debug.approvalPendingTickets = typeRows.length;
index.push(...typeRows);
});
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
index.sort((a, b) => b.created.localeCompare(a.created));
return { types, index, debug: types.map((t) => debugByType.get(t.id)!) };
} }
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 // Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
@@ -226,7 +319,7 @@ export interface INavCounts {
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по // «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
// отдельным запросам значило бы дважды просканировать одни и те же списки // отдельным запросам значило бы дважды просканировать одни и те же списки
// согласования. Статус своей строки приходит в myStepStatus (см. // согласования. Статус своей строки приходит в myStepStatus (см.
// indexApprovalsForType), по нему и разносим. // finalizeApprovalTarget), по нему и разносим.
// //
// «Доработка» живёт в других списках (данные, по автору), поэтому считается // «Доработка» живёт в других списках (данные, по автору), поэтому считается
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму // своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
@@ -266,125 +359,204 @@ export async function hydratePendingAuthors(
// произвольные поля связанного элемента, только через Person-поля вроде // произвольные поля связанного элемента, только через Person-поля вроде
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400). // Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы. // Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
/** Список согласования одной партиции, готовый к поиску: где искать и куда дочитывать. */
interface IApprovalSearchTarget {
type: IRequestType;
target: IListTarget;
approvalList: IList;
approvalListTitle: string;
/** Абсолютный префикс пути элементов СПИСКА СОГЛАСОВАНИЯ, со слешем на конце. */
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
* маршрута заявки лежат в списке согласования ТОЙ ЖЕ партиции, что и сама заявка. * списка данных) и его абсолютный адрес пути.
* Читать только текущую партицию означало бы терять из «На согласовании» всё, что
* уехало в архив.
* *
* Диагностика (debug) агрегируется по серии: счётчики суммируются, заголовки * Title списка данных — из target.title (бесплатно, из bulk-ответа
* списков согласования перечисляются через запятую. * /api/request-types/lists, см. IListTarget.title), REST — только если реестр
* партиций был недоступен.
*
* Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него
* ссылку (список согласования ищется только по имени, см. shared/listTitles.ts),
* сам список создаётся SharePoint под произвольным номером, и его адрес нельзя
* вывести из адреса списка данных. Но это лёгкий $select без постраничного
* перебора — не то же самое, что полноценный Search, который эти вызовы
* заменяют (см. searchAcrossApprovalLists).
*/ */
async function indexApprovalsForType( async function resolveApprovalSearchTarget(
context: WebPartContext, context: WebPartContext,
type: IRequestType, type: IRequestType,
tokenEmail: string, target: IListTarget
scope: ApprovalScope, ): Promise<ApprovalTargetResolution> {
statuses: string[]
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: false,
};
if (!tokenEmail) return { rows: [], debug };
// listTargetsForType сам откатывается на RequestType.listId, если реестр
// партиций не загрузился — тогда ведём себя как раньше, по одному списку.
const targets = listTargetsForType(type);
// Партиции независимы друг от друга (разные списки, разные HTTP-запросы) —
// читаем ВСЕ параллельно, а не по очереди. Раньше это был последовательный
// for-await, и у типа с несколькими партициями (архив накопился) время росло
// линейно с их числом: 3 партиции — втрое дольше одной, хотя каждая опрашивает
// СВОЙ список и ничего не ждёт от соседних (см. трассировку 2026-08-18:
// «Обходной лист» с 3 партициями — 7.5с, тогда как каждая партиция сама по
// себе — 2-3с).
const parts = await Promise.all(
targets.map((target) =>
traced(`indexApprovalsForPartition:${type.name}#${target.listId}`, () =>
indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses)
)
)
);
const rows: IMyRequest[] = [];
const approvalTitles: string[] = [];
// Порядок Promise.all совпадает с порядком targets — «первый встреченный»
// результат (approvalSkippedReason/approvalError) не изменился относительно
// прежнего последовательного перебора.
for (const part of parts) {
rows.push(...part.rows);
if (part.approvalListTitle) approvalTitles.push(part.approvalListTitle);
if (part.approvalSkippedReason && !debug.approvalSkippedReason) {
debug.approvalSkippedReason = part.approvalSkippedReason;
}
if (part.error && !debug.approvalError) debug.approvalError = part.error;
debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0);
}
if (approvalTitles.length > 0) debug.approvalListTitle = approvalTitles.join(', ');
debug.approvalPendingTickets = rows.length;
return { rows, debug };
}
/** Результат по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
interface IPartitionApprovalResult {
rows: IMyRequest[];
approvalListTitle?: string;
approvalSkippedReason?: string;
approvalRowCount?: number;
error?: string;
}
async function indexApprovalsForPartition(
context: WebPartContext,
type: IRequestType,
target: IListTarget,
tokenEmail: string,
scope: ApprovalScope,
statuses: string[]
): Promise<IPartitionApprovalResult> {
const debug: IPartitionApprovalResult = { rows: [] };
try { try {
const sp = spForSite(target.siteUrl, context); const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId); const list = sp.web.getList(target.listId);
// target.title — из bulk-ответа /api/request-types/lists, уже загруженного
// при старте страницы (см. IListTarget.title): 11 партиций — это 11 лишних
// REST-запросов только за тем, что уже пришло бесплатно одним запросом.
// REST — только если реестр партиций был недоступен (see listTargetsForType),
// тогда Title взять больше неоткуда.
const dataTitle = const dataTitle =
target.title ?? target.title ??
((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ?? ((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ??
''; '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) { if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; return {
return debug; kind: 'skip',
type,
target,
reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`,
};
} }
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle); const approvalList = sp.web.lists.getByTitle(approvalListTitle);
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () =>
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. )) as { RootFolder?: { ServerRelativeUrl?: string } };
// const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
// ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что if (!relUrl) {
// кириллица в значении делает такой фильтр ненадёжным, — перепроверено return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` };
// вручную через REST 2026-08-17 (все три статуса списка согласования дали }
// точное совпадение с эталоном), теория не подтвердилась. Старый вывод о // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
// поломке был артефактом кривого тестового запроса, а не реальным // путями элементов …/Lists/List10 (см. listPathPrefix).
// ограничением SharePoint. 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();
}
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
// возвращали 0.
//
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
// без него в выдачу попали бы все строки, где пользователь вообще есть в
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
//
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берёт
// СВЕЖИМИ через REST по этим Id вызывающая сторона (finalizeApprovalTarget), не
// из индекса (индекс мог устареть, а на устаревшем статусе решение «моя
// очередь» строить нельзя).
/**
* ОДИН комбинированный Search по спискам согласования ВСЕХ партиций ВСЕХ типов —
* Path через OR, approver и statuses общие для всех (см. buildPendingIndex, где
* это гарантировано одним вызовом на все типы разом). Тот же приём OR-а по Path
* уже применялся для «Своих заявок» (см. pathClauseForTypes) — раньше здесь
* вместо этого был отдельный Search-запрос НА КАЖДУЮ партицию, и при архивации
* в несколько партиций это было самой дорогой частью загрузки «На согласовании»
* (трассировка 2026-08-18: 11 партиций — 11 отдельных постраничных запросов).
*
* Ошибка одного общего запроса теперь означает «Search недоступен для всех», а
* не для одной партиции — компромисс сознательный: системный сбой Search и
* раньше задевал бы все партиции разом (это один и тот же сервис), а REST-net
* по свежести (recentRows, см. finalizeApprovalTarget) на сбой Search вообще не
* завязан и продолжает работать сам по себе.
*
* Возвращает найденные Id, сгруппированные по списку согласования (targetKey).
*/
async function searchAcrossApprovalLists(
context: WebPartContext,
targets: IApprovalSearchTarget[],
tokenEmail: string,
statuses: string[]
): Promise<Map<string, number[]>> {
const result = new Map<string, number[]>();
try {
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin); const pathClause = `(${targets.map((t) => `Path:"${t.approvalListAbsUrl}*"`).join(' OR ')})`;
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 ');
// Представитель любого сайта годится — поисковый индекс общий на тенант
// (тот же приём, что в searchOwnRequests: spForSite(types[0].siteUrl, …)).
const sp = spForSite(targets[0].target.siteUrl, context);
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Path'], 'На согласовании (все партиции)');
hits.forEach((h) => {
const id = Number(h.ListItemID);
if (isNaN(id) || id <= 0) return;
const path = String(h.Path || '').toLowerCase();
// Сравнение по границе сегмента (см. listPathPrefix) — тот же приём, что
// в hitsToEntries, но по адресам списков СОГЛАСОВАНИЯ, а не данных.
const matched = targets.filter((t) => path.indexOf(t.approvalListAbsUrl.toLowerCase()) === 0)[0];
if (!matched) return;
const key = targetKey(matched);
const arr = result.get(key) ?? [];
arr.push(id);
result.set(key, arr);
});
return result;
} catch (e) {
console.error('[Текущие заявки] Комбинированный Search по спискам согласования не сработал:', (e as Error).message);
return result;
}
}
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
interface IPartitionApprovalResult {
rows: IMyRequest[];
approvalListTitle?: string;
approvalRowCount?: number;
error?: string;
}
/**
* Достраивает результат одной партиции поверх уже найденных Search'ем Id
* (searchHitIds — пусто, если для неё ничего не нашлось или комбинированный
* Search целиком не сработал).
*/
async function finalizeApprovalTarget(
context: WebPartContext,
entry: IApprovalSearchTarget,
scope: ApprovalScope,
statuses: string[],
searchHitIds: number[],
tokenEmail: string
): Promise<IPartitionApprovalResult> {
const { type, target, approvalList, approvalListTitle } = entry;
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
// TicketId/Status — СВЕЖИМИ через REST по найденным Id, не из индекса
// Search (см. doc-комментарий у searchAcrossApprovalLists).
const searchRows =
searchHitIds.length === 0
? []
: (
await Promise.all(
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
// loadByIds (см. REST_ID_FILTER_CHUNK).
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
traced(`approvalRowsRest:${type.name}#${target.listId}`, () =>
approvalList.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'TicketId', 'ApprovalStatus')
.top(idsChunk.length)()
)
)
)
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
let stepRows: Array<Record<string, unknown>>; let stepRows: Array<Record<string, unknown>>;
@@ -392,44 +564,30 @@ async function indexApprovalsForPartition(
// 'myTurn' — гибрид для масштаба (список согласования может быть очень // 'myTurn' — гибрид для масштаба (список согласования может быть очень
// большим): Search находит кандидатов, не сканируя список целиком, плюс // большим): Search находит кандидатов, не сканируя список целиком, плюс
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
// не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по // не проиндексировать.
// найденным Id (не из индекса Search) — именно это делает //
// getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// точное, даже когда список кандидатов пришёл из Search. // SharePoint REST — резолвим email в числовой Id пользователя (как и везде
// в проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
// Статус — прямо в $filter (см. комментарий про ApprovalStatus выше):
// REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS.
const statusFilter = const statusFilter =
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
// recentRows (REST) и searchRows (Search) — независимые источники const recentRows = (await traced(`recentRowsRest:${type.name}#${target.listId}`, () =>
// кандидатов, объединяются только ниже (seenIds); одно не ждёт другое, approvalList.items
// поэтому запускаем их параллельно, а не по очереди. .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
const [recentRows, searchRows] = (await Promise.all([ .select('Id', 'TicketId', 'ApprovalStatus')
traced(`recentRowsRest:${type.name}#${target.listId}`, () => .top(2000)()
approvalList.items )) as Array<Record<string, unknown>>;
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()
),
// Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки,
// а не всё, где пользователь есть в маршруте.
traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () =>
getInvolvedRowsViaSearch(context, type, approvalList, statuses)
),
])) as [Array<Record<string, unknown>>, Array<Record<string, unknown>>];
const seenIds = new Set(recentRows.map((r) => r.Id)); const seenIds = new Set(recentRows.map((r) => r.Id));
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
} else { } else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см. // 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
// свежесть, а полнота по всему архиву, поэтому только Search, без // свежесть, а полнота по всему архиву, поэтому только Search, без
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске // REST-подстраховки на «последние N минут».
// давал неустойчивый результат (заявка то находилась, то нет, в stepRows = searchRows;
// зависимости от того, успел ли Search её проиндексировать к моменту
// REST-окна).
stepRows = await traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () =>
getInvolvedRowsViaSearch(context, type, approvalList, statuses)
);
} }
debug.approvalRowCount = stepRows.length; debug.approvalRowCount = stepRows.length;
@@ -479,83 +637,6 @@ async function indexApprovalsForPartition(
} }
} }
// Строки согласования текущего пользователя через 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 в списке ДАННЫХ каждого типа — // Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по // только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). // всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
@@ -723,7 +804,7 @@ export interface IListTarget {
* уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при * уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при
* старте страницы. Заполнен только когда партиция известна реестру; для * старте страницы. Заполнен только когда партиция известна реестру; для
* фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда * фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда
* indexApprovalsForPartition сам дочитает Title через REST. * resolveApprovalSearchTarget сам дочитает Title через REST.
*/ */
title?: string; title?: string;
} }
@@ -1177,7 +1258,7 @@ export async function getOwnRequestsByStatus(
} }
// Список согласования по типу заявки: Title списка данных → Title списка // Список согласования по типу заявки: Title списка данных → Title списка
// согласования по конвенции имён (та же логика, что в indexApprovalsForType). // согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
// undefined — список заведён не по конвенции, маршрута для него не найти. // undefined — список заведён не по конвенции, маршрута для него не найти.
async function approvalListFor( async function approvalListFor(
context: WebPartContext, context: WebPartContext,