оптимизация используем общий search
This commit is contained in:
@@ -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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
|
||||||
|
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
|
||||||
|
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
|
||||||
|
// возвращали 0.
|
||||||
//
|
//
|
||||||
// ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что
|
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
|
||||||
// кириллица в значении делает такой фильтр ненадёжным, — перепроверено
|
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
|
||||||
// вручную через REST 2026-08-17 (все три статуса списка согласования дали
|
// без него в выдачу попали бы все строки, где пользователь вообще есть в
|
||||||
// точное совпадение с эталоном), теория не подтвердилась. Старый вывод о
|
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
|
||||||
// поломке был артефактом кривого тестового запроса, а не реальным
|
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
|
||||||
// ограничением SharePoint.
|
//
|
||||||
|
// 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); одно не ждёт другое,
|
|
||||||
// поэтому запускаем их параллельно, а не по очереди.
|
|
||||||
const [recentRows, searchRows] = (await Promise.all([
|
|
||||||
traced(`recentRowsRest:${type.name}#${target.listId}`, () =>
|
|
||||||
approvalList.items
|
approvalList.items
|
||||||
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
|
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
|
||||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
.top(2000)()
|
.top(2000)()
|
||||||
),
|
)) as Array<Record<string, unknown>>;
|
||||||
// Статусы уходят и в 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user