оптимизация довеска
This commit is contained in:
@@ -6,6 +6,10 @@ import { IList } from '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
|
// sp.batched() — довесок по свежести уходит одним запросом на сайт, см.
|
||||||
|
// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах,
|
||||||
|
// но в рантайме не зарегистрирован.
|
||||||
|
import '@pnp/sp/batching';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
|
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
@@ -219,15 +223,29 @@ async function buildPendingIndex(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hitsByTarget =
|
// Два источника кандидатов — Search (весь архив) и REST-довесок по свежести
|
||||||
|
// (то, что индекс мог не успеть подхватить) — независимы друг от друга,
|
||||||
|
// поэтому идут параллельно, а не один за другим.
|
||||||
|
const [hitsByTarget, recentByTarget] = await Promise.all([
|
||||||
okEntries.length > 0
|
okEntries.length > 0
|
||||||
? await traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
|
? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
|
||||||
: new Map<string, number[]>();
|
: Promise.resolve(new Map<string, number[]>()),
|
||||||
|
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
||||||
|
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
||||||
|
okEntries.length > 0 && scope === 'myTurn'
|
||||||
|
? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses))
|
||||||
|
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
||||||
|
]);
|
||||||
|
|
||||||
const parts = await Promise.all(
|
const parts = await Promise.all(
|
||||||
okEntries.map((entry) =>
|
okEntries.map((entry) =>
|
||||||
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
|
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
|
||||||
finalizeApprovalTarget(context, entry, scope, statuses, hitsByTarget.get(targetKey(entry)) ?? [], tokenEmail)
|
finalizeApprovalTarget(
|
||||||
|
context,
|
||||||
|
entry,
|
||||||
|
hitsByTarget.get(targetKey(entry)) ?? [],
|
||||||
|
recentByTarget.get(targetKey(entry)) ?? []
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -510,6 +528,98 @@ async function searchAcrossApprovalLists(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST-довесок по свежести для очереди согласования: строки маршрута, изменённые
|
||||||
|
* за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить.
|
||||||
|
*
|
||||||
|
* Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая
|
||||||
|
* в архив, продолжает висеть на согласовании, а решение по ней меняет строки
|
||||||
|
* маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive).
|
||||||
|
* Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку
|
||||||
|
* он увидит не сразу, а только когда её подхватит индекс; именно эту заявку
|
||||||
|
* фича «архивные заявки тоже согласуются» и должна показывать вовремя.
|
||||||
|
*
|
||||||
|
* Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта
|
||||||
|
* уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в
|
||||||
|
* approvalList.service.ts/createSteps), поэтому число партиций больше не
|
||||||
|
* добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и
|
||||||
|
* с отдельным ensureUser перед каждым.
|
||||||
|
*/
|
||||||
|
async function fetchRecentApprovalRows(
|
||||||
|
context: WebPartContext,
|
||||||
|
entries: IApprovalSearchTarget[],
|
||||||
|
tokenEmail: string,
|
||||||
|
statuses: string[]
|
||||||
|
): Promise<Map<string, Array<Record<string, unknown>>>> {
|
||||||
|
const result = new Map<string, Array<Record<string, unknown>>>();
|
||||||
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
// Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки,
|
||||||
|
// без постфильтрации в JS. Кириллица в значении этому не мешает — проверено
|
||||||
|
// вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS.
|
||||||
|
const statusFilter =
|
||||||
|
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
|
||||||
|
|
||||||
|
// Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id
|
||||||
|
// пользователя тоже свой на каждом сайте.
|
||||||
|
const bySite = new Map<string, IApprovalSearchTarget[]>();
|
||||||
|
entries.forEach((e) => {
|
||||||
|
const key = e.target.siteUrl.toLowerCase();
|
||||||
|
const bucket = bySite.get(key) ?? [];
|
||||||
|
bucket.push(e);
|
||||||
|
bySite.set(key, bucket);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(bySite.values()).map(async (siteEntries) => {
|
||||||
|
const siteUrl = siteEntries[0].target.siteUrl;
|
||||||
|
try {
|
||||||
|
const sp = spForSite(siteUrl, context);
|
||||||
|
|
||||||
|
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
|
||||||
|
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
|
||||||
|
// POST, а не дешёвый GET.
|
||||||
|
//
|
||||||
|
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||||
|
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
||||||
|
// проекте) и фильтруем по TansuSiteApproverId.
|
||||||
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
|
const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin));
|
||||||
|
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
|
||||||
|
|
||||||
|
const [batchedSP, execute] = sp.batched();
|
||||||
|
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
|
||||||
|
// отказ по одной партиции внутри батча всплыл бы как unhandled rejection
|
||||||
|
// раньше, чем мы успели бы его перехватить.
|
||||||
|
const pending = siteEntries.map((entry) =>
|
||||||
|
batchedSP.web.lists
|
||||||
|
.getByTitle(entry.approvalListTitle)
|
||||||
|
.items.filter(filter)
|
||||||
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
|
.top(2000)()
|
||||||
|
.then((rows) => {
|
||||||
|
result.set(targetKey(entry), rows as Array<Record<string, unknown>>);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// Недоступная партиция (архив переименовали/удалили вручную) не
|
||||||
|
// должна лишать довеска остальные партиции того же сайта.
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute);
|
||||||
|
await Promise.all(pending);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
|
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
|
||||||
interface IPartitionApprovalResult {
|
interface IPartitionApprovalResult {
|
||||||
rows: IMyRequest[];
|
rows: IMyRequest[];
|
||||||
@@ -519,17 +629,21 @@ interface IPartitionApprovalResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Достраивает результат одной партиции поверх уже найденных Search'ем Id
|
* Достраивает результат одной партиции поверх уже собранных кандидатов:
|
||||||
* (searchHitIds — пусто, если для неё ничего не нашлось или комбинированный
|
* • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции
|
||||||
* Search целиком не сработал).
|
* ничего не нашлось или Search целиком не сработал);
|
||||||
|
* • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме
|
||||||
|
* текстового поиска и когда за последние RECENT_MS ничего не менялось).
|
||||||
|
*
|
||||||
|
* Сама сеть здесь только на дочитывание: собственных «поисковых» запросов
|
||||||
|
* функция больше не делает — оба источника кандидатов собираются один раз на все
|
||||||
|
* партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows).
|
||||||
*/
|
*/
|
||||||
async function finalizeApprovalTarget(
|
async function finalizeApprovalTarget(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
entry: IApprovalSearchTarget,
|
entry: IApprovalSearchTarget,
|
||||||
scope: ApprovalScope,
|
|
||||||
statuses: string[],
|
|
||||||
searchHitIds: number[],
|
searchHitIds: number[],
|
||||||
tokenEmail: string
|
recentRows: Array<Record<string, unknown>>
|
||||||
): Promise<IPartitionApprovalResult> {
|
): Promise<IPartitionApprovalResult> {
|
||||||
const { type, target, approvalList, approvalListTitle } = entry;
|
const { type, target, approvalList, approvalListTitle } = entry;
|
||||||
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
|
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
|
||||||
@@ -558,37 +672,11 @@ async function finalizeApprovalTarget(
|
|||||||
)
|
)
|
||||||
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
|
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
|
||||||
|
|
||||||
let stepRows: Array<Record<string, unknown>>;
|
// Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только
|
||||||
|
// то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее,
|
||||||
if (scope === 'myTurn') {
|
// чем в индексе, а решение «моя очередь сейчас» строится именно на статусе.
|
||||||
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
|
|
||||||
// большим): Search находит кандидатов, не сканируя список целиком, плюс
|
|
||||||
// REST по «последним N минутам» (Modified) добирает то, что 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();
|
|
||||||
const statusFilter =
|
|
||||||
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
|
|
||||||
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));
|
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||||
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||||
} else {
|
|
||||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
|
||||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
|
||||||
// свежесть, а полнота по всему архиву, поэтому только Search, без
|
|
||||||
// REST-подстраховки на «последние N минут».
|
|
||||||
stepRows = searchRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user