оптимизация используем общий search
This commit is contained in:
@@ -20,7 +20,7 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
||||
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
||||
// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn,
|
||||
// Search, и подмешиваем к результату (см. buildPendingIndex/myTurn,
|
||||
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
|
||||
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
|
||||
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
|
||||
@@ -100,7 +100,7 @@ export type ApprovalScope = 'myTurn' | 'involved';
|
||||
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
|
||||
// Отличаются только набором статусов его строки — сам механизм (найти свои
|
||||
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
|
||||
// зашиты в indexApprovalsForType.
|
||||
// зашиты в finalizeApprovalTarget.
|
||||
//
|
||||
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
|
||||
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
|
||||
@@ -153,6 +153,14 @@ const PENDING_PAGE_SIZE = 20;
|
||||
// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
|
||||
// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
|
||||
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
||||
//
|
||||
// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов —
|
||||
// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см.
|
||||
// searchAcrossApprovalLists), и при архивации в несколько партиций это была
|
||||
// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11
|
||||
// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с).
|
||||
// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см.
|
||||
// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования.
|
||||
async function buildPendingIndex(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope,
|
||||
@@ -161,18 +169,103 @@ async function buildPendingIndex(
|
||||
const types = await getLinkedTypes(context);
|
||||
const tokenEmail = currentUserEmail(context);
|
||||
|
||||
const perType = await Promise.all(
|
||||
types.map((t) =>
|
||||
traced(`indexApprovalsForType:${t.name}`, () => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
|
||||
const debugByType = new Map<number, IMyRequestsTypeDebug>();
|
||||
types.forEach((t) =>
|
||||
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 index = perType
|
||||
.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[])
|
||||
.sort((a, b) => b.created.localeCompare(a.created));
|
||||
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;
|
||||
} 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
|
||||
@@ -226,7 +319,7 @@ export interface INavCounts {
|
||||
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
|
||||
// отдельным запросам значило бы дважды просканировать одни и те же списки
|
||||
// согласования. Статус своей строки приходит в myStepStatus (см.
|
||||
// indexApprovalsForType), по нему и разносим.
|
||||
// finalizeApprovalTarget), по нему и разносим.
|
||||
//
|
||||
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
|
||||
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||
@@ -266,125 +359,204 @@ export async function hydratePendingAuthors(
|
||||
// произвольные поля связанного элемента, только через Person-поля вроде
|
||||
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
||||
// 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,
|
||||
type: IRequestType,
|
||||
tokenEmail: string,
|
||||
scope: ApprovalScope,
|
||||
statuses: string[]
|
||||
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
||||
const debug: IMyRequestsTypeDebug = {
|
||||
requestTypeId: type.id,
|
||||
requestTypeName: type.name,
|
||||
listId: type.listId,
|
||||
siteUrl: type.siteUrl,
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
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: [] };
|
||||
|
||||
target: IListTarget
|
||||
): Promise<ApprovalTargetResolution> {
|
||||
try {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
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 =
|
||||
target.title ??
|
||||
((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ??
|
||||
'';
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
||||
return debug;
|
||||
return {
|
||||
kind: 'skip',
|
||||
type,
|
||||
target,
|
||||
reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`,
|
||||
};
|
||||
}
|
||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
debug.approvalListTitle = approvalListTitle;
|
||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||
|
||||
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
||||
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
||||
//
|
||||
// ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что
|
||||
// кириллица в значении делает такой фильтр ненадёжным, — перепроверено
|
||||
// вручную через REST 2026-08-17 (все три статуса списка согласования дали
|
||||
// точное совпадение с эталоном), теория не подтвердилась. Старый вывод о
|
||||
// поломке был артефактом кривого тестового запроса, а не реальным
|
||||
// ограничением SharePoint.
|
||||
const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () =>
|
||||
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();
|
||||
}
|
||||
|
||||
// Фильтр по согласующему — 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 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>>;
|
||||
|
||||
@@ -392,44 +564,30 @@ async function indexApprovalsForPartition(
|
||||
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
|
||||
// большим): Search находит кандидатов, не сканируя список целиком, плюс
|
||||
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
|
||||
// не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по
|
||||
// найденным Id (не из индекса Search) — именно это делает
|
||||
// getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда
|
||||
// точное, даже когда список кандидатов пришёл из Search.
|
||||
// не проиндексировать.
|
||||
//
|
||||
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||
// 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();
|
||||
// Статус — прямо в $filter (см. комментарий про ApprovalStatus выше):
|
||||
// REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS.
|
||||
const statusFilter =
|
||||
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
|
||||
// recentRows (REST) и searchRows (Search) — независимые источники
|
||||
// кандидатов, объединяются только ниже (seenIds); одно не ждёт другое,
|
||||
// поэтому запускаем их параллельно, а не по очереди.
|
||||
const [recentRows, searchRows] = (await Promise.all([
|
||||
traced(`recentRowsRest:${type.name}#${target.listId}`, () =>
|
||||
approvalList.items
|
||||
.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 recentRows = (await traced(`recentRowsRest:${type.name}#${target.listId}`, () =>
|
||||
approvalList.items
|
||||
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
|
||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||
.top(2000)()
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||
} else {
|
||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
||||
// свежесть, а полнота по всему архиву, поэтому только Search, без
|
||||
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
|
||||
// давал неустойчивый результат (заявка то находилась, то нет, в
|
||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
||||
// REST-окна).
|
||||
stepRows = await traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () =>
|
||||
getInvolvedRowsViaSearch(context, type, approvalList, statuses)
|
||||
);
|
||||
// REST-подстраховки на «последние N минут».
|
||||
stepRows = searchRows;
|
||||
}
|
||||
|
||||
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 в списке ДАННЫХ каждого типа —
|
||||
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
||||
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
||||
@@ -723,7 +804,7 @@ export interface IListTarget {
|
||||
* уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при
|
||||
* старте страницы. Заполнен только когда партиция известна реестру; для
|
||||
* фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда
|
||||
* indexApprovalsForPartition сам дочитает Title через REST.
|
||||
* resolveApprovalSearchTarget сам дочитает Title через REST.
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
@@ -1177,7 +1258,7 @@ export async function getOwnRequestsByStatus(
|
||||
}
|
||||
|
||||
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
|
||||
// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
|
||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||
async function approvalListFor(
|
||||
context: WebPartContext,
|
||||
|
||||
Reference in New Issue
Block a user