трейс и первая оптимизация
This commit is contained in:
@@ -11,15 +11,22 @@ import '@pnp/sp/search';
|
||||
// но в рантайме не зарегистрирован.
|
||||
import '@pnp/sp/batching';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
|
||||
import {
|
||||
currentUserIdFor,
|
||||
ensureUserIdFor,
|
||||
getRequestTypesCached,
|
||||
IRequestType,
|
||||
approvalListKey,
|
||||
locateApprovalLists,
|
||||
partitionsForTypeId,
|
||||
REQUEST_STATUS,
|
||||
siteOrigin,
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||
|
||||
// Список данных «... — данные» → список согласования «... — согласование»
|
||||
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
import { perfScope, traced } from '../../shared/perfTrace';
|
||||
|
||||
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
||||
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
||||
@@ -86,7 +93,11 @@ export function currentUserEmail(context: WebPartContext): string {
|
||||
|
||||
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
|
||||
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||
const types = await getRequestTypes(context);
|
||||
// Кешированный вариант: getLinkedTypes зовут и счётчики меню, и каждая
|
||||
// таблица, и «Поиск» — на одно открытие портала это было 4-5 одинаковых
|
||||
// AAD-запросов к /api/request-types. Ответ меняется только когда админ
|
||||
// заводит тип заявки, то есть заведомо не в пределах загрузки страницы.
|
||||
const types = await getRequestTypesCached(context);
|
||||
const linked = types.filter((t) => !!t.listId && !!t.siteUrl);
|
||||
traceLinkedTypes(types, linked);
|
||||
return linked;
|
||||
@@ -195,28 +206,38 @@ async function buildPendingIndex(
|
||||
[]
|
||||
);
|
||||
|
||||
// Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом:
|
||||
// approval-route не хранит на него ссылку (список согласования ищется только
|
||||
// по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда.
|
||||
// Но это лёгкий $select без постраничного перебора — не то же самое, что
|
||||
// полноценный Search, который эти вызовы как раз заменяют.
|
||||
const resolved = await Promise.all(
|
||||
flat.map(({ type, target }) =>
|
||||
resolveApprovalSearchTarget(context, type, target)
|
||||
// Адреса списков согласования всех партиций разом: не больше ОДНОГО
|
||||
// /_api/web/lists на сайт, а после первого раза — вообще без сети (адреса
|
||||
// кешируются на сессию, см. shared/approval/approvalListLocator.ts). Раньше
|
||||
// здесь был отдельный REST-запрос на КАЖДУЮ партицию, и он повторялся при
|
||||
// каждой загрузке очереди — при 11 партициях это 11 запросов на проход, а
|
||||
// проходов за одно открытие вкладки два (счётчики меню + сама таблица).
|
||||
const located = await traced('адреса списков согласования', () =>
|
||||
locateApprovalLists(
|
||||
context,
|
||||
flat.map(({ target }) => ({ listId: target.listId, siteUrl: target.siteUrl, title: target.title }))
|
||||
)
|
||||
);
|
||||
|
||||
const okEntries: IApprovalSearchTarget[] = [];
|
||||
resolved.forEach((r) => {
|
||||
const debugType = r.kind === 'ok' ? r.value.type : r.type;
|
||||
const debug = debugByType.get(debugType.id);
|
||||
if (!debug) return;
|
||||
if (r.kind === 'ok') {
|
||||
okEntries.push(r.value);
|
||||
} else if (r.kind === 'skip') {
|
||||
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason;
|
||||
flat.forEach(({ type, target }) => {
|
||||
const debug = debugByType.get(type.id);
|
||||
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||
if (!debug || !lookup) return;
|
||||
|
||||
if (lookup.kind === 'ok') {
|
||||
okEntries.push({
|
||||
type,
|
||||
target,
|
||||
// getByTitle — только построение url, отдельного запроса не стоит.
|
||||
approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle),
|
||||
approvalListTitle: lookup.value.approvalListTitle,
|
||||
approvalListAbsUrl: lookup.value.approvalListAbsUrl,
|
||||
});
|
||||
} else if (lookup.kind === 'skip') {
|
||||
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = lookup.reason;
|
||||
} else {
|
||||
if (!debug.approvalError) debug.approvalError = r.message;
|
||||
if (!debug.approvalError) debug.approvalError = lookup.message;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -225,22 +246,28 @@ async function buildPendingIndex(
|
||||
// поэтому идут параллельно, а не один за другим.
|
||||
const [hitsByTarget, recentByTarget] = await Promise.all([
|
||||
okEntries.length > 0
|
||||
? searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
|
||||
? traced('Search: очередь согласования', () =>
|
||||
searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
|
||||
)
|
||||
: Promise.resolve(new Map<string, number[]>()),
|
||||
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
||||
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
||||
okEntries.length > 0 && scope === 'myTurn'
|
||||
? fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
|
||||
? traced('REST-довесок по свежести (очередь)', () =>
|
||||
fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
|
||||
)
|
||||
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
||||
]);
|
||||
|
||||
const parts = await Promise.all(
|
||||
okEntries.map((entry) =>
|
||||
finalizeApprovalTarget(
|
||||
context,
|
||||
entry,
|
||||
hitsByTarget.get(targetKey(entry)) ?? [],
|
||||
recentByTarget.get(targetKey(entry)) ?? []
|
||||
const parts = await traced('дочитывание строк очереди (REST)', () =>
|
||||
Promise.all(
|
||||
okEntries.map((entry) =>
|
||||
finalizeApprovalTarget(
|
||||
context,
|
||||
entry,
|
||||
hitsByTarget.get(targetKey(entry)) ?? [],
|
||||
recentByTarget.get(targetKey(entry)) ?? []
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -281,6 +308,55 @@ async function buildPendingIndex(
|
||||
return { types, index, debug: types.map((t) => debugByType.get(t.id)!) };
|
||||
}
|
||||
|
||||
/** Результат buildPendingIndex — вынесен в тип, потому что им теперь делятся вызывающие. */
|
||||
interface IPendingIndex {
|
||||
types: IRequestType[];
|
||||
index: IMyRequest[];
|
||||
debug: IMyRequestsTypeDebug[];
|
||||
}
|
||||
|
||||
// Уже идущие сборки индекса, по ключу «область + набор статусов».
|
||||
//
|
||||
// При открытии портала счётчики меню (getNavCounts) и сама таблица очереди
|
||||
// стартуют практически одновременно и просят ОДИН И ТОТ ЖЕ набор — раньше это
|
||||
// значило два полных прохода: два резолва адресов списков, два Search, два
|
||||
// довеска по свежести. Второй вызов теперь подхватывает промис первого.
|
||||
//
|
||||
// Держим ТОЛЬКО пока запрос в полёте: как только он завершился, запись
|
||||
// удаляется. Кешировать результат по времени нельзя — «Обновить» и событие
|
||||
// rp-requests-changed обязаны читать данные заново.
|
||||
const pendingIndexInFlight = new Map<string, Promise<IPendingIndex>>();
|
||||
|
||||
function pendingIndexKey(scope: ApprovalScope, statuses: string[]): string {
|
||||
return `${scope}|${[...statuses].sort().join(',')}`;
|
||||
}
|
||||
|
||||
async function sharedPendingIndex(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope,
|
||||
statuses: string[]
|
||||
): Promise<IPendingIndex> {
|
||||
const key = pendingIndexKey(scope, statuses);
|
||||
const running = pendingIndexInFlight.get(key);
|
||||
if (running) return running;
|
||||
|
||||
const forget = (): void => {
|
||||
pendingIndexInFlight.delete(key);
|
||||
};
|
||||
const promise = buildPendingIndex(context, scope, statuses).then(
|
||||
(r) => {
|
||||
forget();
|
||||
return r;
|
||||
},
|
||||
(e) => {
|
||||
forget();
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
pendingIndexInFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
||||
// заявок отдать), автор дочитывается только для них.
|
||||
export async function getApprovalQueue(
|
||||
@@ -288,12 +364,29 @@ export async function getApprovalQueue(
|
||||
kind: ApprovalQueueKind,
|
||||
page: number = 1
|
||||
): Promise<IPendingApprovalPage> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
|
||||
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
|
||||
// выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же,
|
||||
// как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же
|
||||
// самый набор, и просить его двумя разными запросами значит дважды пройти по
|
||||
// всем спискам согласования. История решений ('approved'/'rejected'/
|
||||
// 'executed') в эту пару не входит и берётся своим запросом.
|
||||
const shared = MY_TURN_STATUSES.indexOf(QUEUE_STATUSES[kind][0]) >= 0;
|
||||
const { types, index, debug } = await sharedPendingIndex(
|
||||
context,
|
||||
'myTurn',
|
||||
shared ? MY_TURN_STATUSES : QUEUE_STATUSES[kind]
|
||||
);
|
||||
|
||||
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||
// Отбор по СВЕЖЕМУ статусу своей строки, а не по тому, что отдал индекс.
|
||||
// Индекс мог отстать, и заявка, по которой решение уже принято, не должна
|
||||
// висеть во вкладке «ждут вашего согласования» — по этой же причине
|
||||
// finalizeApprovalTarget перечитывает статус REST-ом.
|
||||
const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index;
|
||||
|
||||
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||
await hydrateAuthors(context, types, pageRows);
|
||||
|
||||
return { rows: pageRows, totalRows: index.length, debug };
|
||||
return { rows: pageRows, totalRows: rows.length, debug };
|
||||
}
|
||||
|
||||
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
||||
@@ -308,7 +401,7 @@ export async function getApprovalQueueIndex(
|
||||
context: WebPartContext,
|
||||
kind: ApprovalQueueKind
|
||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
|
||||
const { types, index, debug } = await sharedPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
|
||||
return { rows: index, types, debug };
|
||||
}
|
||||
|
||||
@@ -318,7 +411,7 @@ export async function getPendingApprovalIndex(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope
|
||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, scope, []);
|
||||
const { types, index, debug } = await sharedPendingIndex(context, scope, []);
|
||||
return { rows: index, types, debug };
|
||||
}
|
||||
|
||||
@@ -338,14 +431,14 @@ export interface INavCounts {
|
||||
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||
// вкладку: иначе счётчик и список могли бы разойтись.
|
||||
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
||||
return getNavCountsInner(context);
|
||||
return perfScope('счётчики меню', () => getNavCountsInner(context));
|
||||
}
|
||||
|
||||
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
||||
const types = await getLinkedTypes(context);
|
||||
|
||||
const [turn, rework] = await Promise.all([
|
||||
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||
sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||
]);
|
||||
|
||||
@@ -383,67 +476,6 @@ interface IApprovalSearchTarget {
|
||||
approvalListAbsUrl: string;
|
||||
}
|
||||
|
||||
type ApprovalTargetResolution =
|
||||
| { kind: 'ok'; value: IApprovalSearchTarget }
|
||||
| { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string }
|
||||
| { kind: 'error'; type: IRequestType; target: IListTarget; message: string };
|
||||
|
||||
/**
|
||||
* Резолвит список согласования одной партиции: Title (по конвенции из Title
|
||||
* списка данных) и его абсолютный адрес пути.
|
||||
*
|
||||
* Title списка данных — из target.title (бесплатно, из bulk-ответа
|
||||
* /api/request-types/lists, см. IListTarget.title), REST — только если реестр
|
||||
* партиций был недоступен.
|
||||
*
|
||||
* Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него
|
||||
* ссылку (список согласования ищется только по имени, см. shared/listTitles.ts),
|
||||
* сам список создаётся SharePoint под произвольным номером, и его адрес нельзя
|
||||
* вывести из адреса списка данных. Но это лёгкий $select без постраничного
|
||||
* перебора — не то же самое, что полноценный Search, который эти вызовы
|
||||
* заменяют (см. searchAcrossApprovalLists).
|
||||
*/
|
||||
async function resolveApprovalSearchTarget(
|
||||
context: WebPartContext,
|
||||
type: IRequestType,
|
||||
target: IListTarget
|
||||
): Promise<ApprovalTargetResolution> {
|
||||
try {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
const list = sp.web.getList(target.listId);
|
||||
|
||||
const dataTitle =
|
||||
target.title ??
|
||||
((await list.select('Title')()).Title as string) ??
|
||||
'';
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
return {
|
||||
kind: 'skip',
|
||||
type,
|
||||
target,
|
||||
reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`,
|
||||
};
|
||||
}
|
||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||
|
||||
const rootFolder = (await approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()) as {
|
||||
RootFolder?: { ServerRelativeUrl?: string };
|
||||
};
|
||||
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
|
||||
if (!relUrl) {
|
||||
return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` };
|
||||
}
|
||||
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
|
||||
// путями элементов …/Lists/List10 (см. listPathPrefix).
|
||||
const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`;
|
||||
|
||||
return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } };
|
||||
} catch (e) {
|
||||
return { kind: 'error', type, target, message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function targetKey(entry: IApprovalSearchTarget): string {
|
||||
return entry.approvalListAbsUrl.toLowerCase();
|
||||
}
|
||||
@@ -570,16 +602,17 @@ async function fetchRecentApprovalRows(
|
||||
try {
|
||||
const sp = spForSite(siteUrl, context);
|
||||
|
||||
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
|
||||
// ensureUser — один раз на сайт И один раз на сессию: результат кешируется
|
||||
// (см. shared/approval/siteUser.ts). Раньше он вызывался внутри обработки
|
||||
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
|
||||
// POST, а не дешёвый GET.
|
||||
// POST, а не дешёвый GET; теперь его не платят даже повторные загрузки.
|
||||
//
|
||||
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
||||
// проекте) и фильтруем по TansuSiteApproverId.
|
||||
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||
const approver = await sp.web.ensureUser(claimsLogin);
|
||||
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
|
||||
const approverId = await ensureUserIdFor(context, siteUrl, claimsLogin);
|
||||
const filter = `TansuSiteApproverId eq ${approverId} and Modified ge '${cutoffIso}'${statusFilter}`;
|
||||
|
||||
const [batchedSP, execute] = sp.batched();
|
||||
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
|
||||
@@ -725,7 +758,14 @@ async function hydrateAuthors(
|
||||
rows: IMyRequest[]
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, rows));
|
||||
}
|
||||
|
||||
async function hydrateAuthorsInner(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[]
|
||||
): Promise<void> {
|
||||
const typeById = new Map<number, IRequestType>();
|
||||
types.forEach((t) => typeById.set(t.id, t));
|
||||
|
||||
@@ -848,11 +888,6 @@ async function fetchAllSearchRows(
|
||||
return rows;
|
||||
}
|
||||
|
||||
function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
}
|
||||
|
||||
function listAbsUrl(type: IRequestType): string {
|
||||
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
|
||||
}
|
||||
@@ -1106,11 +1141,14 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
|
||||
|
||||
try {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
// Кешированный Id (см. shared/approval/siteUser.ts): эта функция зовётся в
|
||||
// цикле по типам заявок, и без кеша тот же GET /_api/web/currentuser уходил
|
||||
// бы столько раз, сколько у портала типов.
|
||||
const myId = await currentUserIdFor(context, target.siteUrl);
|
||||
|
||||
const rows = (await sp.web
|
||||
.getList(target.listId)
|
||||
.items.filter(`AuthorId eq ${me.Id}`)
|
||||
.items.filter(`AuthorId eq ${myId}`)
|
||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.orderBy('Created', false)
|
||||
@@ -1158,12 +1196,12 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
|
||||
|
||||
try {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
const myId = await currentUserIdFor(context, target.siteUrl);
|
||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
|
||||
const rows = (await sp.web
|
||||
.getList(target.listId)
|
||||
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
|
||||
.items.filter(`AuthorId eq ${myId} and Modified ge '${cutoffIso}'`)
|
||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||
@@ -1235,21 +1273,23 @@ export async function searchOwnRequests(
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||
});
|
||||
const results = await traced('Search: свои заявки', () =>
|
||||
sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||
})
|
||||
);
|
||||
|
||||
const totalRows = results.TotalRows || 0;
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey =
|
||||
entries.length > 0
|
||||
? await loadByIds(context, entries, 'own')
|
||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||
: new Map<string, IMyRequest>();
|
||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
@@ -1284,21 +1324,23 @@ export async function getOwnRequestsByStatus(
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
// Свежие сверху — как в searchOwnRequests.
|
||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||
});
|
||||
const results = await traced('Search: свои заявки', () =>
|
||||
sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
// Свежие сверху — как в searchOwnRequests.
|
||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||
})
|
||||
);
|
||||
|
||||
const totalRows = results.TotalRows || 0;
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey =
|
||||
entries.length > 0
|
||||
? await loadByIds(context, entries, 'own')
|
||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||
: new Map<string, IMyRequest>();
|
||||
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
|
||||
@@ -1326,20 +1368,6 @@ export async function getOwnRequestsByStatus(
|
||||
return { rows: merged, totalRows: totalRows + onlyFresh.length };
|
||||
}
|
||||
|
||||
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||
// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
|
||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||
async function approvalListFor(
|
||||
context: WebPartContext,
|
||||
target: IListTarget
|
||||
): Promise<IList | undefined> {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
const info = (await sp.web.getList(target.listId).select('Title')()) as { Title?: string };
|
||||
const dataTitle = info.Title || '';
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
||||
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
||||
}
|
||||
|
||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||
@@ -1367,7 +1395,17 @@ export async function hydrateDecisionComments(
|
||||
decisionStatus: string
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
return traced('комментарии к решению (REST)', () =>
|
||||
hydrateDecisionCommentsInner(context, types, rows, decisionStatus)
|
||||
);
|
||||
}
|
||||
|
||||
async function hydrateDecisionCommentsInner(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[],
|
||||
decisionStatus: string
|
||||
): Promise<void> {
|
||||
const typeById = new Map<number, IRequestType>();
|
||||
types.forEach((t) => typeById.set(t.id, t));
|
||||
|
||||
@@ -1387,11 +1425,26 @@ export async function hydrateDecisionComments(
|
||||
rowsByPartition.set(key, bucket);
|
||||
});
|
||||
|
||||
// Адреса/имена списков согласования — одним резолвом на все партиции сразу и,
|
||||
// как правило, вовсе без сети: их уже сложил в кеш buildPendingIndex
|
||||
// (см. shared/approval/approvalListLocator.ts). Раньше здесь был отдельный
|
||||
// REST за Title списка данных на КАЖДУЮ партицию страницы.
|
||||
const located = await locateApprovalLists(
|
||||
context,
|
||||
Array.from(rowsByPartition.values()).map(({ target }) => ({
|
||||
listId: target.listId,
|
||||
siteUrl: target.siteUrl,
|
||||
}))
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
||||
try {
|
||||
const approvalList = await approvalListFor(context, target);
|
||||
if (!approvalList) return;
|
||||
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||
if (!lookup || lookup.kind !== 'ok') return;
|
||||
const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
|
||||
lookup.value.approvalListTitle
|
||||
);
|
||||
|
||||
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
||||
// сильнее `or», без скобок фильтр разобрался бы как
|
||||
@@ -1523,7 +1576,10 @@ export async function searchOwnAll(
|
||||
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
||||
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||
const byKey =
|
||||
entries.length > 0
|
||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||
: new Map<string, IMyRequest>();
|
||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
|
||||
traceResultRows('Свои заявки', types, entries, rows);
|
||||
|
||||
Reference in New Issue
Block a user