Оптимизация списков #12
@@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest';
|
||||
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { perfScope } from '../../shared/perfTrace';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
@@ -352,18 +353,26 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
|
||||
const q = debouncedQuery.trim();
|
||||
|
||||
const fetchRows = async (): Promise<IPage> => {
|
||||
// onPartial — Search и REST-довесок по свежести («Мои заявки»/«Доработка»,
|
||||
// см. getOwnRequests/getOwnRequestsByStatus в myRequests.service.ts) теперь
|
||||
// идут параллельно, и заранее не угадать, какой из двух ответит первым —
|
||||
// сеть шумит то в одну, то в другую сторону. onPartial зовётся с тем, что
|
||||
// пришло раньше, чтобы не держать список пустым ради заведомо более
|
||||
// медленного источника. Остальные ветки (approvals/execution/поиск) его
|
||||
// просто не принимают — там нет пары «Search + довесок» с независимо
|
||||
// валидным промежуточным результатом.
|
||||
const fetchRows = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
|
||||
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
||||
// зашитым в раздел, а не выбранным в фильтре.
|
||||
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
||||
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
|
||||
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery, onPartial);
|
||||
}
|
||||
|
||||
if (view === 'own:created') {
|
||||
return q.length > 0
|
||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||
: getOwnRequests(context, types, page);
|
||||
: getOwnRequests(context, types, page, onPartial);
|
||||
}
|
||||
|
||||
const queue = QUEUE_BY_VIEW[view];
|
||||
@@ -386,9 +395,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
|
||||
// «Отклонённые мной» приходят разными путями (по автору и по строкам
|
||||
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
|
||||
// для видимой страницы — как и автор в hydratePendingAuthors.
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
const result = await fetchRows();
|
||||
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
|
||||
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
|
||||
// ИТОГОВОГО результата, полный показ его донесёт следом.
|
||||
const fetchPage = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
|
||||
const result = await fetchRows(onPartial);
|
||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||
if (decisionStatus) {
|
||||
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
|
||||
@@ -398,7 +409,23 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
|
||||
const load = async (forceRender = false): Promise<void> => {
|
||||
try {
|
||||
const fresh = await fetchPage();
|
||||
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
|
||||
// ничего не закешировано (первый заход, экран пуст). Проверяем на
|
||||
// каждый вызов load(), а не один раз на весь эффект: тот же load()
|
||||
// переиспользуется автообновлением по таймеру и событием
|
||||
// rp-requests-changed все 10 минут жизни вкладки, и ко второму вызову
|
||||
// кеш почти наверняка уже есть — без повторной проверки список бы на
|
||||
// мгновение "усыхал" при КАЖДОМ фоновом обновлении, а не только при
|
||||
// самой первой загрузке.
|
||||
const onPartial = readPage(cacheKey)
|
||||
? undefined
|
||||
: (partial: IPage): void => {
|
||||
if (cancelled) return;
|
||||
setRows(partial.rows);
|
||||
setTotalRows(partial.totalRows);
|
||||
};
|
||||
|
||||
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial));
|
||||
if (cancelled) return;
|
||||
|
||||
// Перерисовываем, только если данные реально изменились — иначе при
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { perfScope } from '../../shared/perfTrace';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
@@ -185,7 +186,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
||||
|
||||
const load = async (forceRender = false): Promise<void> => {
|
||||
try {
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
||||
const fresh = await perfScope('вкладка «Поиск»', () =>
|
||||
searchMyRequests(context, types, debouncedQuery)
|
||||
);
|
||||
if (cancelled) return;
|
||||
applyKnownAuthors(fresh.rows, authors.current);
|
||||
const prev = readPage(cacheKey);
|
||||
|
||||
@@ -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-ом напрямую, в обход
|
||||
@@ -42,10 +49,10 @@ const SEARCH_PAGE_SIZE = 500;
|
||||
const SEARCH_HARD_CAP = 5000;
|
||||
|
||||
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
|
||||
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
|
||||
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
|
||||
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
|
||||
// а не уходит одним запросом целиком.
|
||||
// при дочитывании строк маршрута/заявки по найденным через Search Id (см.
|
||||
// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL —
|
||||
// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного
|
||||
// типа режется на такие пачки, а не уходит одним запросом целиком.
|
||||
const REST_ID_FILTER_CHUNK = 100;
|
||||
|
||||
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
||||
@@ -76,6 +83,42 @@ const MP_STATUS = 'ApprovalStatusOWSCHCS';
|
||||
// SharePoint — сама схема поиска исправна.
|
||||
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
|
||||
|
||||
// Автор заявки (Person-поле Author списка ДАННЫХ) — суффикс OWSUSER, без
|
||||
// префикса группы колонок (поле стандартное, не сайтовое, как и RequestStatus
|
||||
// выше). Значение — «<логин> | <ФИО>» (см. parseAuthorOwsUser). Уже
|
||||
// использовался в KQL (AuthorOWSUSER:"...") для отбора «своих» заявок; здесь
|
||||
// то же свойство дочитывается из ответа Search вместо REST.
|
||||
const MP_AUTHOR = 'AuthorOWSUSER';
|
||||
|
||||
// Уникальный код заявки (колонка RequestUniqueCode) — суффикс OWSTEXT, тип
|
||||
// исходного поля Text.
|
||||
const MP_CODE = 'RequestUniqueCodeOWSTEXT';
|
||||
|
||||
// Поля заявки, отдаваемые ПРЯМО индексом, без REST-дочитывания после Search —
|
||||
// подтверждено вручную (tools/sp props, 2026-08-20): Queryable ≠ Retrievable у
|
||||
// managed property, поэтому то, что раньше проверялось только на фильтрацию
|
||||
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
|
||||
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
|
||||
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
|
||||
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) отдаёт
|
||||
// готовое отображаемое имя — короче и надёжнее выковыривать его из
|
||||
// MP_AUTHOR/parseAuthorOwsUser, там он нужен только ради почты.
|
||||
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
||||
|
||||
/**
|
||||
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
|
||||
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
|
||||
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
|
||||
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
|
||||
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
|
||||
*/
|
||||
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
|
||||
const text = typeof value === 'string' ? value : '';
|
||||
const sep = text.indexOf(' | ');
|
||||
if (sep < 0) return { email: text.trim(), name: '' };
|
||||
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
|
||||
}
|
||||
|
||||
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
|
||||
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
|
||||
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
|
||||
@@ -86,7 +129,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 +242,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,16 +282,21 @@ 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(
|
||||
const parts = await traced('дочитывание строк очереди (REST)', () =>
|
||||
Promise.all(
|
||||
okEntries.map((entry) =>
|
||||
finalizeApprovalTarget(
|
||||
context,
|
||||
@@ -243,6 +305,7 @@ async function buildPendingIndex(
|
||||
recentByTarget.get(targetKey(entry)) ?? []
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const rowsByType = new Map<number, IMyRequest[]>();
|
||||
@@ -281,6 +344,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 +400,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 +437,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 +447,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,21 +467,21 @@ 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),
|
||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||
sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||
countOwnRequestsByStatus(context, types, REQUEST_STATUS.returned),
|
||||
]);
|
||||
|
||||
return {
|
||||
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
|
||||
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length,
|
||||
rework: rework.totalRows,
|
||||
rework,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,67 +512,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 +638,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(): иначе
|
||||
@@ -654,8 +723,7 @@ async function finalizeApprovalTarget(
|
||||
? []
|
||||
: (
|
||||
await Promise.all(
|
||||
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
||||
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
||||
// Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK).
|
||||
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
||||
approvalList.items
|
||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||
@@ -694,14 +762,23 @@ async function finalizeApprovalTarget(
|
||||
|
||||
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
||||
// комментарий у функции: через Lookup-expand это не отдаётся).
|
||||
//
|
||||
// Автор берётся ЗДЕСЬ ЖЕ через expand. Раньше он дочитывался отдельным
|
||||
// проходом (hydrateAuthors) и только по строкам видимой страницы — чтобы не
|
||||
// тянуть его на весь индекс. Но экономилась при этом не сеть, а payload:
|
||||
// запрос к списку данных всё равно делается, и expand не добавляет к нему
|
||||
// ни одного round-trip'а, тогда как отдельный проход стоил целый — замер
|
||||
// 2026-08-20 показал 262 мс ровно на критическом пути вкладки
|
||||
// «На согласовании» (Search → строки очереди → автор).
|
||||
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||
const ticketRows = (await list.items
|
||||
.filter(ticketFilter)
|
||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created')
|
||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
||||
|
||||
debug.rows = ticketRows.map((r) => ({
|
||||
...toMyRequest(r, undefined, type, 'approval', target),
|
||||
...toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'approval', target),
|
||||
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
||||
}));
|
||||
|
||||
@@ -716,16 +793,31 @@ async function finalizeApprovalTarget(
|
||||
}
|
||||
}
|
||||
|
||||
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
||||
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
||||
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
||||
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — только
|
||||
// по переданным строкам. Мутирует переданные объекты (проставляет
|
||||
// authorEmail/authorName).
|
||||
//
|
||||
// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо
|
||||
// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget)
|
||||
// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда
|
||||
// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на
|
||||
// случай источника строк без автора — молча показывать пустого инициатора
|
||||
// хуже, чем один лишний запрос.
|
||||
async function hydrateAuthors(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[]
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
const missing = rows.filter((r) => !r.authorName && !r.authorEmail);
|
||||
if (missing.length === 0) return;
|
||||
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, missing));
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -822,6 +914,10 @@ async function fetchAllSearchRows(
|
||||
let startRow = 0;
|
||||
|
||||
for (;;) {
|
||||
// Постраничной метки здесь больше нет: замер 2026-08-20 показал, что запрос
|
||||
// ровно один (847 мс у вызывающего против 846 мс у страницы), то есть
|
||||
// медленный сам Search, а не перебор StartRow. Вопрос закрыт, а метка
|
||||
// занимала место в строке сводки, дублируя метку вызывающего.
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: SEARCH_PAGE_SIZE,
|
||||
@@ -848,11 +944,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}`;
|
||||
}
|
||||
@@ -1003,7 +1094,7 @@ function hitsToEntries(
|
||||
): ISearchEntry[] {
|
||||
const seen = new Set<string>();
|
||||
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
||||
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
|
||||
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || ''), raw: r }))
|
||||
.filter((h) => !isNaN(h.id) && h.id > 0)
|
||||
.map((h) => {
|
||||
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
|
||||
@@ -1013,7 +1104,7 @@ function hitsToEntries(
|
||||
// из того же списка, где его нашёл Search (см. targetForPath).
|
||||
for (const type of types) {
|
||||
const target = targetForPath(h.path, type);
|
||||
if (target) return { id: h.id, type, target };
|
||||
if (target) return { id: h.id, type, target, raw: h.raw };
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
@@ -1026,72 +1117,50 @@ function hitsToEntries(
|
||||
});
|
||||
}
|
||||
|
||||
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
|
||||
/**
|
||||
* Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка
|
||||
* ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES
|
||||
* и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно.
|
||||
*/
|
||||
interface ISearchEntry {
|
||||
id: number;
|
||||
type: IRequestType;
|
||||
target: IListTarget;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
||||
// кастомных полей нет гарантии, что они проиндексированы как managed property).
|
||||
//
|
||||
// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search
|
||||
// собирает результат целиком (см. fetchAllSearchRows), их может набраться
|
||||
// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в
|
||||
// предел длины URL SharePoint REST.
|
||||
async function loadByIds(
|
||||
context: WebPartContext,
|
||||
entries: ISearchEntry[],
|
||||
kind: 'own' | 'approval'
|
||||
): Promise<Map<string, IMyRequest>> {
|
||||
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
|
||||
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
|
||||
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
|
||||
entries.forEach((e) => {
|
||||
const key = e.target.listId.toLowerCase();
|
||||
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
|
||||
bucket.ids.push(e.id);
|
||||
byPartition.set(key, bucket);
|
||||
});
|
||||
|
||||
const byKey = new Map<string, IMyRequest>();
|
||||
await Promise.all(
|
||||
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
|
||||
// Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть
|
||||
// недоступен (удалён, переименован, остался в реестре после смены списка у
|
||||
// типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции
|
||||
// один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не
|
||||
// увидел бы ничего вместо «всё, кроме одного архива».
|
||||
try {
|
||||
const sp = spForSite(target.siteUrl, context);
|
||||
const list = sp.web.getList(target.listId);
|
||||
|
||||
await Promise.all(
|
||||
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
|
||||
const rows = (await list.items
|
||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
||||
rows.forEach((r) => {
|
||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||
byKey.set(
|
||||
requestKey(target.listId, r.Id as number),
|
||||
toMyRequest(r, author, type, kind, target)
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`,
|
||||
(e as Error).message
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
return byKey;
|
||||
/**
|
||||
* Строка «Моих заявок» ПРЯМО из ответа Search — без второго прохода по REST.
|
||||
*
|
||||
* Раньше здесь был REST-запрос по найденным Id (loadByIds): считалось, что
|
||||
* поисковый индекс отдаёт не все колонки. Проверено вручную (tools/sp props,
|
||||
* 2026-08-20) — отдаёт все нужные, см. OWN_SELECT_PROPERTIES. Оставался вопрос
|
||||
* СВЕЖЕСТИ: значения теперь из индекса, который переиндексируется с задержкой,
|
||||
* а не «только что созданный элемент» напрямую. Для 'own' это уже было принятым
|
||||
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
|
||||
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
|
||||
*/
|
||||
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
|
||||
const r = entry.raw;
|
||||
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
|
||||
return {
|
||||
id: entry.id,
|
||||
code: (r[MP_CODE] as string) || '',
|
||||
title: (r.Title as string) || '',
|
||||
status: (r[MP_REQUEST_STATUS] as string) || '',
|
||||
authorEmail: author.email,
|
||||
// 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES);
|
||||
// из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст.
|
||||
authorName: ((r.Author as string) || author.name || '').trim(),
|
||||
created: (r.Created as string) || '',
|
||||
requestTypeId: entry.type.id,
|
||||
requestTypeName: entry.type.name,
|
||||
section: entry.type.section,
|
||||
formKey: entry.type.pageUrl,
|
||||
kind: 'own',
|
||||
listId: entry.target.listId,
|
||||
siteUrl: entry.target.siteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
|
||||
@@ -1106,11 +1175,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 +1230,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>>;
|
||||
@@ -1180,37 +1252,85 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
|
||||
}
|
||||
}
|
||||
|
||||
// Итог слияния Search-страницы с REST-довеском по свежести — общая логика для
|
||||
// getOwnRequests и ownRequestsByStatus. Довесок ДОПОЛНЯЕТ Search, а не заменяет:
|
||||
// то, что Search уже нашёл, в приоритете (там могло появиться более точное
|
||||
// совпадение по совместному запросу — статус/текст), довесок добавляет только
|
||||
// то, чего в найденном ещё нет.
|
||||
function mergeSearchAndFresh(searchPage: IOwnRequestsPage, freshRows: IMyRequest[]): IOwnRequestsPage {
|
||||
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
|
||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||
const merged = onlyFresh
|
||||
.concat(searchPage.rows)
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, PAGE_SIZE);
|
||||
return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Гонит Search-страницу и REST-довесок по свежести ПАРАЛЛЕЛЬНО — то один
|
||||
* отвечает быстрее, то другой, заранее не угадать, и ждать заведомо более
|
||||
* медленный, держа экран пустым, незачем.
|
||||
*
|
||||
* Если задан onPartial — зовёт его РАЗ, как только придёт ПЕРВЫЙ из двух
|
||||
* источников (та половина, что уже есть, без дедупа со вторым — его мы ещё не
|
||||
* видели). Возвращает функция в любом случае ИТОГОВЫЙ слитый результат;
|
||||
* onPartial — только для промежуточного показа, на итог не влияет.
|
||||
*
|
||||
* Без onPartial ведёт себя как раньше: ждёт оба источника и сразу отдаёт слитый
|
||||
* результат — второй показ (полный) экономить незачем, если промежуточный
|
||||
* всё равно никто не увидит.
|
||||
*/
|
||||
async function raceThenMerge(
|
||||
searchPromise: Promise<IOwnRequestsPage>,
|
||||
freshPromise: Promise<IMyRequest[]>,
|
||||
onPartial: ((page: IOwnRequestsPage) => void) | undefined
|
||||
): Promise<IOwnRequestsPage> {
|
||||
if (onPartial) {
|
||||
let searchDone = false;
|
||||
let freshDone = false;
|
||||
// Ошибку здесь не обрабатываем — её поймает Promise.all ниже; этот
|
||||
// отдельный .then только подсматривает за тем, что пришло раньше.
|
||||
searchPromise.then((r) => {
|
||||
searchDone = true;
|
||||
if (!freshDone) onPartial(r);
|
||||
}, () => undefined);
|
||||
freshPromise.then((r) => {
|
||||
freshDone = true;
|
||||
if (!searchDone) onPartial({ rows: r, totalRows: r.length });
|
||||
}, () => undefined);
|
||||
}
|
||||
|
||||
const [searchPage, freshRows] = await Promise.all([searchPromise, freshPromise]);
|
||||
return mergeSearchAndFresh(searchPage, freshRows);
|
||||
}
|
||||
|
||||
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
|
||||
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
|
||||
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
|
||||
// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по
|
||||
// дате там и оказалось бы, будь оно проиндексировано.
|
||||
//
|
||||
// Search и довесок идут ПАРАЛЛЕЛЬНО (см. raceThenMerge) — раньше довесок
|
||||
// стартовал только ПОСЛЕ ответа Search, хотя они независимы друг от друга и
|
||||
// читают разные источники; тем самым просто складывалось время обоих вместо
|
||||
// времени более медленного из двух.
|
||||
export async function getOwnRequests(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
page: number
|
||||
page: number,
|
||||
onPartial?: (page: IOwnRequestsPage) => void
|
||||
): Promise<IOwnRequestsPage> {
|
||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||
|
||||
const searchPage = await searchOwnRequests(context, types, '', page);
|
||||
const searchPromise = searchOwnRequests(context, types, '', page);
|
||||
if (page !== 1) return searchPromise;
|
||||
|
||||
if (page !== 1) return searchPage;
|
||||
const freshPromise = Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))).then((r) =>
|
||||
r.reduce((all, x) => all.concat(x), [] as IMyRequest[])
|
||||
);
|
||||
|
||||
const freshRows = (
|
||||
await Promise.all(
|
||||
types.map((t) => getFreshOwnRowsForType(context, t))
|
||||
)
|
||||
).reduce((all, r) => all.concat(r), [] as IMyRequest[]);
|
||||
|
||||
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
|
||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||
|
||||
const merged = onlyFresh
|
||||
.concat(searchPage.rows)
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, PAGE_SIZE);
|
||||
|
||||
return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length };
|
||||
return raceThenMerge(searchPromise, freshPromise, onPartial);
|
||||
}
|
||||
|
||||
// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть:
|
||||
@@ -1229,30 +1349,28 @@ export async function searchOwnRequests(
|
||||
|
||||
const clauses = [
|
||||
pathClauseForTypes(types),
|
||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||
];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const results = await sp.search({
|
||||
const results = await traced('Search: свои заявки', () =>
|
||||
sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
SelectProperties: OWN_SELECT_PROPERTIES,
|
||||
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')
|
||||
: new Map<string, IMyRequest>();
|
||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
|
||||
// здесь больше нет. Порядок задаёт релевантность поиска.
|
||||
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
|
||||
|
||||
return { rows, totalRows };
|
||||
}
|
||||
@@ -1269,7 +1387,40 @@ export async function getOwnRequestsByStatus(
|
||||
types: IRequestType[],
|
||||
status: string,
|
||||
page: number,
|
||||
query: string = ''
|
||||
query: string = '',
|
||||
onPartial?: (page: IOwnRequestsPage) => void
|
||||
): Promise<IOwnRequestsPage> {
|
||||
return ownRequestsByStatus(context, types, status, page, query, onPartial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню.
|
||||
*
|
||||
* До 2026-08-20 у этого была отдельная REST-развилка (withRows): дочитывание
|
||||
* полей найденных заявок стоило отдельного запроса, и счётчику, которому нужен
|
||||
* только totalRows, было жаль на него тратиться. Теперь поля идут прямо из
|
||||
* ответа Search (см. toMyRequestFromSearch) — построить rows настолько же
|
||||
* дёшево, насколько и посчитать totalRows, отдельная ветка была бы одной лишь
|
||||
* сложностью без выигрыша.
|
||||
*
|
||||
* Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка
|
||||
* по-прежнему не расходятся (ради чего они и считаются одним кодом).
|
||||
*/
|
||||
export async function countOwnRequestsByStatus(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
status: string
|
||||
): Promise<number> {
|
||||
return (await ownRequestsByStatus(context, types, status, 1, '')).totalRows;
|
||||
}
|
||||
|
||||
async function ownRequestsByStatus(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
status: string,
|
||||
page: number,
|
||||
query: string,
|
||||
onPartial?: (page: IOwnRequestsPage) => void
|
||||
): Promise<IOwnRequestsPage> {
|
||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||
|
||||
@@ -1277,67 +1428,41 @@ export async function getOwnRequestsByStatus(
|
||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||
const clauses = [
|
||||
pathClauseForTypes(types),
|
||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||
`${MP_REQUEST_STATUS}:"${status}"`,
|
||||
];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const results = await sp.search({
|
||||
const searchPromise: Promise<IOwnRequestsPage> = traced('Search: свои заявки', () =>
|
||||
sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: PAGE_SIZE,
|
||||
StartRow: (page - 1) * PAGE_SIZE,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
SelectProperties: OWN_SELECT_PROPERTIES,
|
||||
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')
|
||||
: new Map<string, IMyRequest>();
|
||||
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
|
||||
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
|
||||
})
|
||||
).then((results) => ({
|
||||
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
|
||||
totalRows: results.TotalRows || 0,
|
||||
}));
|
||||
|
||||
// Довесок по недавним изменениям — только на первой странице и только без
|
||||
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
|
||||
// вернуть на доработку только что, индекс мог не успеть её подхватить.
|
||||
const freshRows = (
|
||||
await Promise.all(
|
||||
types.map((t) => getRecentlyChangedOwnRowsForType(context, t))
|
||||
)
|
||||
)
|
||||
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||
.filter((r) => r.status === status);
|
||||
// Запускаем СРАЗУ, не дожидаясь Search: условие включения известно из
|
||||
// параметров вызова, ждать ради него ответ Search незачем — раньше довесок
|
||||
// стартовал только ПОСЛЕ Search, хотя независим от его результата.
|
||||
if (page !== 1 || query.trim()) return searchPromise;
|
||||
|
||||
const searchKeys = new Set(searchRows.map((r) => rowKey(r)));
|
||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||
const freshPromise = Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))).then((r) =>
|
||||
r.reduce((acc, x) => acc.concat(x), [] as IMyRequest[]).filter((x) => x.status === status)
|
||||
);
|
||||
|
||||
const merged = onlyFresh
|
||||
.concat(searchRows)
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, PAGE_SIZE);
|
||||
|
||||
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, '')} — согласование`);
|
||||
return raceThenMerge(searchPromise, freshPromise, onPartial);
|
||||
}
|
||||
|
||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||
@@ -1367,7 +1492,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 +1522,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», без скобок фильтр разобрался бы как
|
||||
@@ -1510,21 +1660,19 @@ export async function searchOwnAll(
|
||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||
const clauses = [
|
||||
pathClauseForTypes(types),
|
||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||
];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки');
|
||||
const hits = await fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Свои заявки');
|
||||
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
|
||||
|
||||
|
||||
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 rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||
const rows = entries.map(toMyRequestFromSearch);
|
||||
|
||||
traceResultRows('Свои заявки', types, entries, rows);
|
||||
return rows;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { spForSite } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
|
||||
/**
|
||||
* Где лежит список согласования каждой партиции — Title и абсолютный адрес.
|
||||
*
|
||||
* Зачем отдельный модуль. Адрес списка «X — согласование» нельзя вывести из
|
||||
* адреса списка «X — данные»: approval-route хранит ссылку только на список
|
||||
* данных, а сам список согласования SharePoint создаёт под произвольным
|
||||
* номером (…/Lists/List37). Раньше это означало ОТДЕЛЬНЫЙ REST-запрос на
|
||||
* КАЖДУЮ партицию при каждой загрузке очереди согласования, а партиций с
|
||||
* архивацией уже больше десятка (трассировка 2026-08-18: 11 партиций).
|
||||
*
|
||||
* Здесь то же самое стоит ОДИН запрос на сайт: /_api/web/lists отдаёт Title и
|
||||
* RootFolder всех списков веба разом, дальше сопоставление идёт в памяти. И
|
||||
* этот запрос делается один раз за сессию — адрес списка не меняется, пока
|
||||
* список существует (партиции только добавляются, а переименование Title не
|
||||
* трогает ServerRelativeUrl).
|
||||
*
|
||||
* Кешируются ТОЛЬКО успехи. Промах кешировать нельзя: новая партиция появляется
|
||||
* асинхронно (см. getCurrentListPartition — он ставит создание следующей пары
|
||||
* списков в очередь), и закешированное «не найдено» скрывало бы заявки нового
|
||||
* списка до перезагрузки страницы.
|
||||
*/
|
||||
|
||||
// Конвенция именования пары списков — та же, что в shared/listTitles.ts и в
|
||||
// админке approval-route. Меняя здесь, менять и там.
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
// Потолок на число списков, которое забираем с сайта за раз. Списков на сайте
|
||||
// портала примерно два на партицию плюс системные — запас многократный. При
|
||||
// упоре в потолок пишем предупреждение: молча «не найденная» партиция выглядела
|
||||
// бы как пропавшие заявки.
|
||||
const SITE_LISTS_TOP = 5000;
|
||||
|
||||
/** Список данных партиции: откуда читаем и что о нём уже известно бесплатно. */
|
||||
export interface IDataListRef {
|
||||
/** Серверный относительный url списка ДАННЫХ. */
|
||||
listId: string;
|
||||
siteUrl: string;
|
||||
/** Title списка данных из реестра партиций, если реестр загрузился. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface IApprovalListLocation {
|
||||
approvalListTitle: string;
|
||||
/** Абсолютный префикс пути элементов списка согласования, СО слешем на конце. */
|
||||
approvalListAbsUrl: string;
|
||||
}
|
||||
|
||||
export type ApprovalListLookup =
|
||||
| { kind: 'ok'; value: IApprovalListLocation }
|
||||
| { kind: 'skip'; reason: string }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
/** Ключ и кеша, и возвращаемой карты: партиция однозначна парой сайт+список. */
|
||||
export function approvalListKey(siteUrl: string, listId: string): string {
|
||||
return `${siteUrl}|${listId}`.toLowerCase();
|
||||
}
|
||||
|
||||
const cache = new Map<string, IApprovalListLocation>();
|
||||
|
||||
/** Забыть одну партицию — вызывать, когда чтение по закешированному адресу упало. */
|
||||
export function forgetApprovalList(siteUrl: string, listId: string): void {
|
||||
cache.delete(approvalListKey(siteUrl, listId));
|
||||
}
|
||||
|
||||
/** Полный сброс — на случай перезагрузки реестра партиций. */
|
||||
export function resetApprovalListCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** Схема одного списка в ответе /_api/web/lists. */
|
||||
interface ISiteListRow {
|
||||
Title?: string;
|
||||
RootFolder?: { ServerRelativeUrl?: string };
|
||||
}
|
||||
|
||||
/** Title и адреса всех списков одного веба — в обе стороны. */
|
||||
interface ISiteListMap {
|
||||
urlByTitle: Map<string, string>;
|
||||
titleByUrl: Map<string, string>;
|
||||
}
|
||||
|
||||
export function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
}
|
||||
|
||||
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
|
||||
// путями элементов …/Lists/List10 (та же причина, что у listPathPrefix
|
||||
// в features/myRequests/myRequests.service.ts).
|
||||
function absPrefix(siteUrl: string, serverRelativeUrl: string): string {
|
||||
return `${siteOrigin(siteUrl)}${serverRelativeUrl.replace(/\/+$/, '')}/`;
|
||||
}
|
||||
|
||||
async function loadSiteLists(context: WebPartContext, siteUrl: string): Promise<ISiteListMap> {
|
||||
const sp = spForSite(siteUrl, context);
|
||||
const rows = (await sp.web.lists
|
||||
.select('Title', 'RootFolder/ServerRelativeUrl')
|
||||
.expand('RootFolder')
|
||||
.top(SITE_LISTS_TOP)()) as ISiteListRow[];
|
||||
|
||||
if (rows.length === SITE_LISTS_TOP) {
|
||||
console.warn(
|
||||
`[Списки согласования] ${siteUrl}: сайт отдал ${SITE_LISTS_TOP} списков — часть партиций может не найтись`
|
||||
);
|
||||
}
|
||||
|
||||
const urlByTitle = new Map<string, string>();
|
||||
const titleByUrl = new Map<string, string>();
|
||||
rows.forEach((r) => {
|
||||
const title = r.Title || '';
|
||||
const relUrl = r.RootFolder?.ServerRelativeUrl || '';
|
||||
if (!title || !relUrl) return;
|
||||
urlByTitle.set(title.toLowerCase(), relUrl);
|
||||
// По адресу — чтобы узнать Title списка ДАННЫХ, когда реестр партиций не
|
||||
// загрузился и ref.title пуст. Раньше на это уходил ещё один REST на партицию.
|
||||
titleByUrl.set(relUrl.replace(/\/+$/, '').toLowerCase(), title);
|
||||
});
|
||||
|
||||
return { urlByTitle, titleByUrl };
|
||||
}
|
||||
|
||||
function locate(ref: IDataListRef, site: ISiteListMap): ApprovalListLookup {
|
||||
const dataTitle = ref.title ?? site.titleByUrl.get(ref.listId.replace(/\/+$/, '').toLowerCase()) ?? '';
|
||||
if (!dataTitle) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Список данных «${ref.listId}» не найден среди списков сайта ${ref.siteUrl} (удалён или нет прав)`
|
||||
};
|
||||
}
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
return { kind: 'skip', reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем` };
|
||||
}
|
||||
|
||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
const relUrl = site.urlByTitle.get(approvalListTitle.toLowerCase());
|
||||
if (!relUrl) {
|
||||
// Коллекция /_api/web/lists секьюрити-тримится, поэтому «списка нет» и
|
||||
// «нет прав» здесь неотличимы — говорим про оба случая сразу.
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Список согласования «${approvalListTitle}» не найден на ${ref.siteUrl} (нет такого списка или нет прав на него)`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ok',
|
||||
value: { approvalListTitle, approvalListAbsUrl: absPrefix(ref.siteUrl, relUrl) }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти списки согласования сразу для всех переданных партиций.
|
||||
*
|
||||
* Сеть — не больше одного запроса на САЙТ, и только за теми партициями, которых
|
||||
* ещё нет в кеше. Отказ по одному сайту не лишает результата партиции других
|
||||
* сайтов: они резолвятся независимо.
|
||||
*/
|
||||
export async function locateApprovalLists(
|
||||
context: WebPartContext,
|
||||
refs: IDataListRef[]
|
||||
): Promise<Map<string, ApprovalListLookup>> {
|
||||
const result = new Map<string, ApprovalListLookup>();
|
||||
|
||||
const missingBySite = new Map<string, IDataListRef[]>();
|
||||
refs.forEach((ref) => {
|
||||
const key = approvalListKey(ref.siteUrl, ref.listId);
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
result.set(key, { kind: 'ok', value: hit });
|
||||
return;
|
||||
}
|
||||
const bucket = missingBySite.get(ref.siteUrl.toLowerCase()) ?? [];
|
||||
bucket.push(ref);
|
||||
missingBySite.set(ref.siteUrl.toLowerCase(), bucket);
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(missingBySite.values()).map(async (siteRefs) => {
|
||||
const siteUrl = siteRefs[0].siteUrl;
|
||||
let site: ISiteListMap;
|
||||
try {
|
||||
site = await loadSiteLists(context, siteUrl);
|
||||
} catch (e) {
|
||||
const message = `Не удалось прочитать списки сайта ${siteUrl}: ${(e as Error).message}`;
|
||||
siteRefs.forEach((ref) => result.set(approvalListKey(ref.siteUrl, ref.listId), { kind: 'error', message }));
|
||||
return;
|
||||
}
|
||||
|
||||
siteRefs.forEach((ref) => {
|
||||
const key = approvalListKey(ref.siteUrl, ref.listId);
|
||||
const lookup = locate(ref, site);
|
||||
// Кешируем только успехи: партиция могла быть создана секунду назад и
|
||||
// ещё не попасть в ответ — закешированный промах спрятал бы её заявки
|
||||
// до перезагрузки страницы.
|
||||
if (lookup.kind === 'ok') cache.set(key, lookup.value);
|
||||
result.set(key, lookup);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export async function getCurrentListPartition(
|
||||
// а не молча повторять один и тот же провал до конца сессии.
|
||||
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
||||
|
||||
async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
if (!requestTypesPromise) {
|
||||
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||
requestTypesPromise = undefined;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
|
||||
export {
|
||||
getRequestTypes,
|
||||
getRequestTypesCached,
|
||||
getRequestTypeIdByPageUrl,
|
||||
getListPartitions,
|
||||
getCurrentListPartition,
|
||||
@@ -40,6 +41,9 @@ export {
|
||||
registerCurrentPartition,
|
||||
} from './listPartitions';
|
||||
export { resolveWriteListId } from './writeListId';
|
||||
export { locateApprovalLists, approvalListKey, forgetApprovalList, siteOrigin } from './approvalListLocator';
|
||||
export type { IDataListRef, IApprovalListLocation, ApprovalListLookup } from './approvalListLocator';
|
||||
export { currentUserIdFor, ensureUserIdFor } from './siteUser';
|
||||
export { useRequestTypeId } from './useRequestTypeId';
|
||||
export type { IRequestTypeIdState } from './useRequestTypeId';
|
||||
export { ApprovalListService } from './approvalList.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
||||
import { getListPartitions, getRequestTypesCached } from './approvalRoute.service';
|
||||
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,10 @@ export async function initListPartitions(context: WebPartContext): Promise<void>
|
||||
try {
|
||||
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
|
||||
// поэтому нужен и справочник типов, чтобы связать одно с другим.
|
||||
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
|
||||
// Кешированный вариант: тот же ответ нужен потом и getLinkedTypes («Мои
|
||||
// заявки»), и useRequestTypeId в каждой форме — здесь он заодно прогревает
|
||||
// общий кеш, чтобы позже за ним никто не ходил повторно.
|
||||
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypesCached(context)]);
|
||||
|
||||
partitionsByTypeId.clear();
|
||||
typeIdByPageUrl.clear();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { spForSite } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/site-users/web';
|
||||
|
||||
/**
|
||||
* Числовой Id пользователя НА КОНКРЕТНОМ САЙТЕ — с кешом на сессию.
|
||||
*
|
||||
* Зачем: почти все фильтры SharePoint REST по Person-полям строятся по числовому
|
||||
* Id (`AuthorId eq N`, `TansuSiteApproverId eq N`) — фильтр по EMail у Person-поля
|
||||
* ненадёжен. Id свой на каждом сайте, поэтому его приходится резолвить, но
|
||||
* в пределах сессии он не меняется.
|
||||
*
|
||||
* Раньше резолв стоял ВНУТРИ функций, которые вызываются в цикле по типам заявок
|
||||
* (getOwnRowsForType / getRecentlyChangedOwnRowsForType): при восьми типах это
|
||||
* восемь одинаковых GET /_api/web/currentuser подряд, и каждый — лишний
|
||||
* round-trip ПЕРЕД основным запросом. ensureUser в очереди согласования — то же
|
||||
* самое, только POST.
|
||||
*
|
||||
* Ошибку НЕ кешируем: упавший на холодном старте запрос должен повториться,
|
||||
* а не залипнуть до конца сессии (тот же принцип, что у requestTypesPromise
|
||||
* в approvalRoute.service.ts).
|
||||
*/
|
||||
|
||||
const byKey = new Map<string, Promise<number>>();
|
||||
|
||||
function remember(key: string, load: () => Promise<number>): Promise<number> {
|
||||
const pending = byKey.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = load().catch((e) => {
|
||||
byKey.delete(key);
|
||||
throw e;
|
||||
});
|
||||
byKey.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Полный сброс — на случай смены личности (отладочная подмена) или реинициализации. */
|
||||
export function resetSiteUserCache(): void {
|
||||
byKey.clear();
|
||||
}
|
||||
|
||||
/** Id ТЕКУЩЕГО (реального) пользователя на сайте. */
|
||||
export async function currentUserIdFor(context: WebPartContext, siteUrl: string): Promise<number> {
|
||||
return remember(`current|${siteUrl.toLowerCase()}`, async () => {
|
||||
const me = await spForSite(siteUrl, context).web.currentUser.select('Id')();
|
||||
return me.Id as number;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Id пользователя по claims-логину. ensureUser — это POST (он при необходимости
|
||||
* заводит пользователя в коллекции сайта), поэтому повторять его особенно жаль.
|
||||
*/
|
||||
export async function ensureUserIdFor(
|
||||
context: WebPartContext,
|
||||
siteUrl: string,
|
||||
claimsLogin: string
|
||||
): Promise<number> {
|
||||
return remember(`ensure|${siteUrl.toLowerCase()}|${claimsLogin.toLowerCase()}`, async () => {
|
||||
const user = await spForSite(siteUrl, context).web.ensureUser(claimsLogin);
|
||||
return user.Id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { TimelinePipe } from '@pnp/core';
|
||||
import { Queryable } from '@pnp/queryable';
|
||||
|
||||
/**
|
||||
* Замер загрузки: «на что именно ушло ожидание», а не просто «страница долго
|
||||
* грузится».
|
||||
*
|
||||
* Пишет ДВА среза сразу, и они дополняют друг друга:
|
||||
* • ЭТАПЫ — логические шаги, размеченные вручную через traced()/perfScope().
|
||||
* Из списка URL не видно, что десяток одинаковых `.../rootfolder` —
|
||||
* это резолв адресов списков согласования по партициям.
|
||||
* • ЗАПРОСЫ — КАЖДЫЙ HTTP-вызов PnPjs, сгруппированный по виду операции.
|
||||
* Снимается автоматически (см. PerfObserver ниже, подключён в
|
||||
* shared/pnp/pnpjs.ts), поэтому покрывает ВСЕ разделы портала —
|
||||
* включая формы и панель заявки, где ручной разметки нет.
|
||||
*
|
||||
* Складывать эти два среза между собой нельзя: запросы лежат ВНУТРИ этапов,
|
||||
* сумма получилась бы двойной. Поэтому и таблицы две, с отдельными итогами.
|
||||
*
|
||||
* Как читать: «ОЖИДАНИЕ» — сколько реально прошло от начала операции до конца,
|
||||
* то есть ровно то, что чувствует пользователь. Сумма времён отдельных шагов
|
||||
* всегда БОЛЬШЕ: шаги идут параллельно (Promise.all) и их времена накладываются.
|
||||
* Поэтому большая сумма у шага сама по себе ещё ничего не значит — важно, лежит
|
||||
* ли он на последовательной цепочке. Признак: сложил несколько шагов и получил
|
||||
* примерно ожидание — значит они идут друг за другом, и сокращать надо их.
|
||||
*
|
||||
* Включение/выключение БЕЗ пересборки — из консоли браузера:
|
||||
* window.rpPerf = true // включить
|
||||
* window.rpPerf = false // выключить
|
||||
* window.rpPerfLast // последняя сводка объектом (скопировать/отдать)
|
||||
*
|
||||
* Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после
|
||||
* перезагрузки. Разбор производительности идёт несколькими заходами с F5 между
|
||||
* ними, и переписывать флаг каждый раз — ровно та мелочь, из-за которой замером
|
||||
* перестают пользоваться. Сделано свойством-аксессором на window, а не функцией
|
||||
* setPerf(): в консоли естественно написать `window.rpPerf = true`.
|
||||
*
|
||||
* По умолчанию выключено: предыдущая версия модуля (коммит 7bd540e) писала в
|
||||
* консоль всегда, без флага, и её пришлось снимать целиком вместе со всеми
|
||||
* обёртками. С флагом обёртки живут в коде постоянно — выключенный traced() не
|
||||
* делает ничего, кроме вызова переданной функции.
|
||||
*/
|
||||
|
||||
const DEFAULT_PERF: boolean = false;
|
||||
|
||||
const PREFIX = '[Перф]';
|
||||
|
||||
// Ключ в localStorage совпадает с именем флага — чтобы в DevTools → Application
|
||||
// его можно было найти и снять, не зная про этот модуль.
|
||||
const STORAGE_KEY = 'rpPerf';
|
||||
|
||||
// Через сколько тишины считать операцию законченной и печатать сводку, если её
|
||||
// никто не обрамил perfScope. Нужно для разделов без ручной разметки (панель
|
||||
// заявки, формы): там просто идут запросы, и «загрузка закончилась» видно только
|
||||
// по тому, что новых запросов больше нет.
|
||||
const IDLE_FLUSH_MS = 900;
|
||||
|
||||
// Сколько самых тяжёлых меток показывать прямо в строке. Больше трёх — и строка
|
||||
// перестаёт читаться с одного взгляда, а ради этого всё и затевалось;
|
||||
// остальное смотрится через window.rpPerfDetail().
|
||||
const TOP_IN_LINE = 3;
|
||||
|
||||
/** Накопленное по одной метке. */
|
||||
interface IStat {
|
||||
calls: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сэмплы одной метки по всем прогонам — база для МЕДИАНЫ, а не среднего.
|
||||
*
|
||||
* Среднее одним выбросом (случайный 429 с ретраем, холодный кеш на первом
|
||||
* заходе) утаскивает за собой всю картину надолго — а именно выбросы здесь
|
||||
* основной случай, не редкость. Медиана его игнорирует: важнее «как обычно»,
|
||||
* а не «как в сумме с той единственной аномалией».
|
||||
*/
|
||||
interface ILabelSamples {
|
||||
/** totalMs метки за каждый прогон, где она встретилась (метка не обязана быть в каждом). */
|
||||
totalMs: number[];
|
||||
calls: number[];
|
||||
/** Худший случай за всё время — в отличие от totalMs, это не медиана, а running max. */
|
||||
maxMs: number;
|
||||
}
|
||||
|
||||
// Сколько последних прогонов метки хранить ради медианы. Не безлимитный рост:
|
||||
// в долгой сессии старые сэмплы устаревают быстрее, чем на них смотрят, а лимит
|
||||
// защищает от утечки памяти на вкладке, оставленной открытой на весь день.
|
||||
const MAX_SAMPLES = 50;
|
||||
|
||||
function pushSample(list: number[], value: number): void {
|
||||
list.push(value);
|
||||
if (list.length > MAX_SAMPLES) list.shift();
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Накопленное по одной операции за ВСЕ её прогоны — база для медианы. */
|
||||
interface IRunHistory {
|
||||
wallSamples: number[];
|
||||
stages: Map<string, ILabelSamples>;
|
||||
requests: Map<string, ILabelSamples>;
|
||||
}
|
||||
|
||||
// Текущий (ещё не напечатанный) прогон.
|
||||
const stages = new Map<string, IStat>();
|
||||
const requests = new Map<string, IStat>();
|
||||
|
||||
// История по операциям — живёт до перезагрузки страницы или window.rpPerfReset().
|
||||
const history = new Map<string, IRunHistory>();
|
||||
|
||||
// Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ
|
||||
// внешняя операция: шаги почти всегда идут параллельно, и печатать таблицу на
|
||||
// каждом вложенном завершении значило бы печатать её десятками кусков. Если две
|
||||
// верхнеуровневые операции наложились (типичный случай: счётчики меню и загрузка
|
||||
// таблицы стартуют вместе), их метки попадут в одну таблицу — это и нужно, там
|
||||
// как раз видны дубли.
|
||||
let openScopes = 0;
|
||||
let scopeLabels: string[] = [];
|
||||
|
||||
// Границы текущего окна накопления — для «ОЖИДАНИЯ». Начало ставит открытие
|
||||
// scope либо первая запись, конец двигает каждая следующая.
|
||||
let windowStart = 0;
|
||||
let windowEnd = 0;
|
||||
|
||||
let idleTimer: number | undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Флаг
|
||||
|
||||
function readStoredFlag(): boolean | undefined {
|
||||
// localStorage может быть недоступен (приватный режим, политика браузера,
|
||||
// ограниченный iframe) — отладочный флаг не повод падать.
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return undefined;
|
||||
return raw === 'true';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredFlag(value: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, value ? 'true' : 'false');
|
||||
} catch {
|
||||
// Значение всё равно применится — просто не переживёт перезагрузку.
|
||||
}
|
||||
}
|
||||
|
||||
let enabled: boolean = readStoredFlag() ?? DEFAULT_PERF;
|
||||
|
||||
function perfEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function installFlag(): void {
|
||||
const w = window as unknown as { rpPerf?: boolean };
|
||||
|
||||
const existing = Object.getOwnPropertyDescriptor(w, 'rpPerf');
|
||||
if (existing && existing.get) return;
|
||||
|
||||
// Значение, присвоенное ДО загрузки этого чанка, не теряем.
|
||||
const preset = typeof w.rpPerf === 'boolean' ? w.rpPerf : undefined;
|
||||
|
||||
Object.defineProperty(w, 'rpPerf', {
|
||||
configurable: true,
|
||||
get: (): boolean => enabled,
|
||||
set: (value: unknown): void => {
|
||||
enabled = !!value;
|
||||
writeStoredFlag(enabled);
|
||||
if (!enabled) {
|
||||
// Тишина СРАЗУ, а не после того, что уже накопилось. Без этого operация,
|
||||
// стартовавшая ДО выключения (perfScope с сетевым шагом внутри) или
|
||||
// ждущая debounce-таймера (см. scheduleIdleFlush), всё равно печатала
|
||||
// итог ПОСЛЕ выключения — выглядело так, будто window.rpPerf = false
|
||||
// не действует.
|
||||
if (idleTimer !== undefined) {
|
||||
window.clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
reset();
|
||||
}
|
||||
console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`);
|
||||
}
|
||||
});
|
||||
|
||||
if (preset !== undefined) {
|
||||
enabled = preset;
|
||||
writeStoredFlag(preset);
|
||||
} else if (enabled) {
|
||||
// Иначе легко забыть, что флаг остался включённым с прошлого раза, и потом
|
||||
// гадать, откуда в консоли таблицы.
|
||||
console.log(`${PREFIX} замер включён (запомнен ранее). Выключить: window.rpPerf = false`);
|
||||
}
|
||||
}
|
||||
|
||||
installFlag();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Накопление
|
||||
|
||||
function record(into: Map<string, IStat>, label: string, ms: number): void {
|
||||
const now = performance.now();
|
||||
if (windowStart === 0) windowStart = now - ms;
|
||||
windowEnd = now;
|
||||
|
||||
const stat = into.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 };
|
||||
stat.calls += 1;
|
||||
stat.totalMs += ms;
|
||||
stat.maxMs = Math.max(stat.maxMs, ms);
|
||||
into.set(label, stat);
|
||||
|
||||
scheduleIdleFlush();
|
||||
}
|
||||
|
||||
// Разделы без ручной разметки (панель заявки, формы) сводку сами не закрывают —
|
||||
// закрываем по тишине. Пока открыт perfScope, решает он.
|
||||
function scheduleIdleFlush(): void {
|
||||
if (idleTimer !== undefined) window.clearTimeout(idleTimer);
|
||||
idleTimer = window.setTimeout(() => {
|
||||
idleTimer = undefined;
|
||||
if (openScopes === 0 && perfEnabled()) print();
|
||||
}, IDLE_FLUSH_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Разметка этапов
|
||||
|
||||
/**
|
||||
* Обернуть один этап замером. Метку делай осмысленной и повторяемой: одинаковые
|
||||
* этапы разных партиций должны попадать в ОДНУ строку сводки (число вызовов
|
||||
* важнее, чем какая именно партиция), поэтому в метку не надо подставлять
|
||||
* listId — только имя этапа.
|
||||
*/
|
||||
export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!perfEnabled()) return fn();
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
record(stages, label, performance.now() - start);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верхнеуровневая операция (открытие вкладки, счётчики меню). Когда она
|
||||
* завершается, печатается сводка по всему, что накопилось внутри.
|
||||
*/
|
||||
export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!perfEnabled()) return fn();
|
||||
|
||||
if (openScopes === 0) {
|
||||
reset();
|
||||
windowStart = performance.now();
|
||||
}
|
||||
openScopes += 1;
|
||||
scopeLabels.push(label);
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
openScopes -= 1;
|
||||
// perfEnabled() — на выходе, а не только на входе: операцию могли выключить,
|
||||
// пока она ещё выполнялась (см. setter window.rpPerf выше), и тогда печатать
|
||||
// её итог уже не нужно — accumulated-данные к этому моменту сам setter уже
|
||||
// сбросил.
|
||||
if (openScopes === 0 && perfEnabled()) print();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Автоматический учёт КАЖДОГО запроса PnPjs
|
||||
|
||||
// Вид операции по url — иначе каждая партиция со своим адресом и своим $filter
|
||||
// даёт отдельную строку и таблица нечитаема. Нужна именно ОПЕРАЦИЯ, а не адрес.
|
||||
function classify(rawUrl: string): string {
|
||||
let path: string;
|
||||
let search: string;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
path = parsed.pathname;
|
||||
search = parsed.search;
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
if (/\/_api\/search\/(post)?query/i.test(path)) return 'search/query';
|
||||
if (/\/_api\/\$batch/i.test(path)) return '$batch (пакет)';
|
||||
if (/\/_api\/contextinfo/i.test(path)) return 'contextinfo (дайджест)';
|
||||
|
||||
const api = path.split(/\/_api\//i)[1];
|
||||
if (!api) return path;
|
||||
|
||||
// Аргументы (getByTitle('…'), getList('/sites/…/Lists/List7')) и id элементов
|
||||
// схлопываем — иначе каждая партиция даст свою строку.
|
||||
const op = api.replace(/\((?:[^()]|\([^()]*\))*\)/g, '()').replace(/\/\d+(?=\/|$)/g, '/N');
|
||||
|
||||
// Резолв адреса списка согласования выделяем отдельно — ради него всё и
|
||||
// затевалось, и в общей куче «web/lists()» его было бы не видно.
|
||||
if (/rootfolder/i.test(op) || /rootfolder/i.test(search)) return `адрес списка (RootFolder) — ${op}`;
|
||||
if (/^web\/lists$/i.test(op)) return 'web/lists (все списки веба)';
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
interface ITimedQueryable {
|
||||
__rpStart?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior PnPjs: засекает время каждого запроса.
|
||||
*
|
||||
* Подключается один раз в shared/pnp/pnpjs.ts и за счёт этого покрывает ВСЕ
|
||||
* разделы портала, а не только размеченные вручную. Когда замер выключен,
|
||||
* наблюдатели всё равно висят, но не делают ничего дороже чтения флага.
|
||||
*/
|
||||
export function PerfObserver(): TimelinePipe<Queryable> {
|
||||
return (instance: Queryable) => {
|
||||
instance.on.pre(async function (url, init, result) {
|
||||
if (perfEnabled()) (this as unknown as ITimedQueryable).__rpStart = performance.now();
|
||||
return [url, init, result];
|
||||
});
|
||||
|
||||
instance.on.post(async function (url, result) {
|
||||
const start = (this as unknown as ITimedQueryable).__rpStart;
|
||||
if (perfEnabled() && start !== undefined) {
|
||||
record(requests, classify(url.toString()), performance.now() - start);
|
||||
}
|
||||
return [url, result];
|
||||
});
|
||||
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Печать
|
||||
|
||||
function reset(): void {
|
||||
stages.clear();
|
||||
requests.clear();
|
||||
scopeLabels = [];
|
||||
windowStart = 0;
|
||||
windowEnd = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ, под которым прогоны копятся в историю.
|
||||
*
|
||||
* Номер страницы из метки убираем: пролистать вкладку по страницам — самый
|
||||
* удобный способ набрать несколько замеров одной и той же операции, и считать
|
||||
* медиану надо по ним ВМЕСТЕ. Иначе каждая страница завела бы свою историю из
|
||||
* одного прогона, и медиане не по чему было бы считаться.
|
||||
*/
|
||||
function historyKey(title: string): string {
|
||||
return title.replace(/\s*стр\.\d+/g, '').trim();
|
||||
}
|
||||
|
||||
function recordSamples(target: Map<string, ILabelSamples>, source: Map<string, IStat>): void {
|
||||
source.forEach((s, label) => {
|
||||
const t = target.get(label) ?? { totalMs: [], calls: [], maxMs: 0 };
|
||||
pushSample(t.totalMs, s.totalMs);
|
||||
pushSample(t.calls, s.calls);
|
||||
t.maxMs = Math.max(t.maxMs, s.maxMs);
|
||||
target.set(label, t);
|
||||
});
|
||||
}
|
||||
|
||||
function heaviest(source: Map<string, IStat>, take: number): string {
|
||||
return Array.from(source.entries())
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, take)
|
||||
.map(([label, s]) => `${label} ${Math.round(s.totalMs)}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function totalCalls(source: Map<string, IStat>): number {
|
||||
return Array.from(source.values()).reduce((acc, s) => acc + s.calls, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка на загрузку — и всё.
|
||||
*
|
||||
* Раньше здесь печатались две таблицы. Их было невозможно ни окинуть взглядом
|
||||
* при листании страниц, ни скопировать: console.table при переносе из консоли
|
||||
* превращается в кашу из колонок. Полная раскладка никуда не делась — она за
|
||||
* window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть
|
||||
* есть на что.
|
||||
*
|
||||
* Медиана в строке важнее текущего значения: одиночный сетевой замер шумит
|
||||
* настолько, что по нему нельзя сказать, стало лучше или просто повезло, а
|
||||
* среднее в этом же шуме утаскивает один выброс (холодный кеш, ретрай на 429)
|
||||
* далеко за пределы того, что происходит обычно.
|
||||
*/
|
||||
function print(): void {
|
||||
if (idleTimer !== undefined) {
|
||||
window.clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
if (stages.size === 0 && requests.size === 0) return;
|
||||
|
||||
const wallMs = (openScopes > 0 ? performance.now() : windowEnd) - windowStart;
|
||||
const title = scopeLabels.length > 0 ? scopeLabels.join(' + ') : 'без разметки';
|
||||
const key = historyKey(title);
|
||||
|
||||
const hist: IRunHistory = history.get(key) ?? {
|
||||
wallSamples: [],
|
||||
stages: new Map<string, ILabelSamples>(),
|
||||
requests: new Map<string, ILabelSamples>()
|
||||
};
|
||||
pushSample(hist.wallSamples, wallMs);
|
||||
recordSamples(hist.stages, stages);
|
||||
recordSamples(hist.requests, requests);
|
||||
history.set(key, hist);
|
||||
|
||||
// Дольше всего — по этапам, если разметка есть; иначе по видам запросов
|
||||
// (разделы без ручной разметки: панель заявки, формы).
|
||||
const top = heaviest(stages.size > 0 ? stages : requests, TOP_IN_LINE);
|
||||
|
||||
console.log(
|
||||
`${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` +
|
||||
` (мед. ${Math.round(median(hist.wallSamples))} за ${hist.wallSamples.length})` +
|
||||
` | запросов ${totalCalls(requests)}` +
|
||||
(top ? ` | дольше всего: ${top}` : '')
|
||||
);
|
||||
|
||||
reset();
|
||||
}
|
||||
|
||||
/** Строки полной раскладки по одной операции — медианы за все её прогоны. */
|
||||
function detailLines(key: string, hist: IRunHistory): string[] {
|
||||
const lines = [
|
||||
`${PREFIX} ${key} - прогонов ${hist.wallSamples.length}, ожидание медиана ${Math.round(median(hist.wallSamples))} мс`
|
||||
];
|
||||
|
||||
const dump = (name: string, source: Map<string, ILabelSamples>): void => {
|
||||
if (source.size === 0) return;
|
||||
lines.push(` ${name} (медиана за прогон)`);
|
||||
Array.from(source.entries())
|
||||
.sort((a, b) => median(b[1].totalMs) - median(a[1].totalMs))
|
||||
.forEach(([label, s]) => {
|
||||
lines.push(
|
||||
` ${Math.round(median(s.totalMs))} мс, вызовов ${Math.round(median(s.calls))}, макс ${Math.round(s.maxMs)} - ${label}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
dump('ЭТАПЫ', hist.stages);
|
||||
dump('ЗАПРОСЫ', hist.requests);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная раскладка по всем измеренным операциям — window.rpPerfDetail().
|
||||
*
|
||||
* Возвращает текст, а не только печатает: в консоли Chrome его удобно забрать
|
||||
* целиком через copy(rpPerfDetail()).
|
||||
*/
|
||||
function installDetailApi(): void {
|
||||
const w = window as unknown as { rpPerfDetail?: () => string; rpPerfReset?: () => void };
|
||||
|
||||
w.rpPerfDetail = (): string => {
|
||||
if (history.size === 0) return `${PREFIX} замеров пока нет`;
|
||||
const text = Array.from(history.entries())
|
||||
.reduce<string[]>((acc, [key, hist]) => acc.concat(detailLines(key, hist)), [])
|
||||
.join(String.fromCharCode(10));
|
||||
console.log(text);
|
||||
return text;
|
||||
};
|
||||
|
||||
// Старые прогоны остаются в окне (см. MAX_SAMPLES) и тянут медиану назад —
|
||||
// «стало быстрее» проявится в ней только после того, как их наберётся
|
||||
// больше половины окна. Перед сравнением «до/после» серию надо обнулять.
|
||||
w.rpPerfReset = (): void => {
|
||||
history.clear();
|
||||
reset();
|
||||
console.log(`${PREFIX} история замеров очищена`);
|
||||
};
|
||||
}
|
||||
|
||||
installDetailApi();
|
||||
@@ -7,6 +7,13 @@ import '@pnp/sp/site-users/web';
|
||||
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { DATA_SITE_URL } from '../config';
|
||||
import { PerfObserver } from '../perfTrace';
|
||||
|
||||
// PerfObserver подключён к ОБОИМ клиентам ниже: это единственное место, через
|
||||
// которое проходит любой SharePoint-запрос портала, поэтому отладочный замер
|
||||
// (window.rpPerf, см. shared/perfTrace.ts) покрывает сразу все разделы — и
|
||||
// списки заявок, и формы, и панель заявки, — а не только те, где расставлены
|
||||
// метки этапов вручную. Выключенный замер не делает ничего дороже чтения флага.
|
||||
|
||||
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
|
||||
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
|
||||
@@ -25,7 +32,7 @@ let _sp: SPFI | undefined;
|
||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||
*/
|
||||
export function initSP(context: WebPartContext): void {
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY), PerfObserver());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +43,7 @@ export function initSP(context: WebPartContext): void {
|
||||
* отправки (у behavior replace: true по умолчанию).
|
||||
*/
|
||||
export function spForSite(siteUrl: string, context: WebPartContext): SPFI {
|
||||
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY), PerfObserver());
|
||||
}
|
||||
|
||||
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# `tools/sp` — запросы к SharePoint портала заявок
|
||||
|
||||
Инструмент для **разбора**, а не для правки данных: задать тенанту вопрос и получить
|
||||
ответ текстом, который можно приложить к задаче.
|
||||
|
||||
Появился из-за производительности: раз за разом нужно было выяснить «а что реально
|
||||
отдаёт поисковый индекс?», «сколько на самом деле партиций у типа заявки?» — и каждый
|
||||
раз это была ручная работа со снипетом в консоли браузера и копипастой результата.
|
||||
|
||||
## Что важно понимать про доступ
|
||||
|
||||
**Секреты через инструмент не проходят.** Вход — device code: команда печатает код,
|
||||
пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у
|
||||
себя. Ни пароля, ни client secret в репозитории и в командах нет.
|
||||
|
||||
**Работаем от имени пользователя, а не приложения.** Это не удобство, а требование:
|
||||
SharePoint Search REST (`/_api/search/query`) под app-only-токеном обычно не отдаёт
|
||||
ничего, ему нужен пользовательский контекст. А Search — главное, что здесь измеряют.
|
||||
Побочный плюс: видно ровно то же, что видит пользователь в портале.
|
||||
|
||||
## Разовая подготовка
|
||||
|
||||
CLI for Microsoft 365 начиная с 7-й версии **не возит собственное приложение** — нужен
|
||||
свой Entra app. Регистрируется один раз на всю команду.
|
||||
|
||||
1. Entra admin center → **App registrations** → **New registration**
|
||||
- Name: `Portal Tools CLI`
|
||||
- Supported account types: **Single tenant**
|
||||
- Redirect URI: **Public client/native** → `http://localhost`
|
||||
2. **Authentication** → Advanced settings → **Allow public client flows** → **Yes**
|
||||
(без этого device code не работает)
|
||||
3. **API permissions** → Add a permission → **SharePoint** → **Delegated**:
|
||||
- `AllSites.Read` — чтение списков
|
||||
- `Sites.Search.All` — поисковый индекс
|
||||
4. **Grant admin consent** (нужен админ тенанта)
|
||||
5. Скопировать **Application (client) ID** и **Directory (tenant) ID**
|
||||
|
||||
Дальше — переменные окружения (PowerShell, на сессию):
|
||||
|
||||
```powershell
|
||||
$env:SP_APP_ID = "<application-client-id>"
|
||||
$env:SP_TENANT = "<directory-tenant-id>"
|
||||
```
|
||||
|
||||
`SP_SITE` по умолчанию `https://tnsukz.sharepoint.com/sites/requests` — тот же адрес,
|
||||
что в `src/webparts/requestPortal/shared/config.ts`. Задавать нужно, только если
|
||||
списки типов заявок живут на другом сайте.
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
cd tools/sp
|
||||
npm install # один раз
|
||||
|
||||
node sp.mjs login # покажет код — ввести его в браузере
|
||||
node sp.mjs status
|
||||
```
|
||||
|
||||
| команда | зачем |
|
||||
| --- | --- |
|
||||
| `node sp.mjs props` | отдаёт ли индекс поля заявки (`Retrievable`) — от этого зависит, можно ли убрать REST-дочитывание в `loadByIds` |
|
||||
| `node sp.mjs lists` | все портальные списки с размерами и адресами — сколько реально партиций |
|
||||
| `node sp.mjs search "<KQL>" --select a,b --rows N` | тот же поисковый запрос, что делает портал |
|
||||
| `node sp.mjs get "/_api/web/..."` | произвольный GET, когда отдельной команды нет |
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
# Свои заявки глазами индекса — ровно как их видит searchOwnRequests
|
||||
node sp.mjs search 'AuthorOWSUSER:"i:0#.f|membership|user@tnsu.kz"' \
|
||||
--select ListItemID,Title,RequestStatusOWSCHCS,Created --rows 5
|
||||
|
||||
# Сколько элементов в каждой партиции
|
||||
node sp.mjs lists
|
||||
```
|
||||
|
||||
## Грабли, на которые уже наступали
|
||||
|
||||
**`Queryable` ≠ `Retrievable`.** У managed property это разные флаги. То, по чему
|
||||
получается фильтровать, не обязано приходить в ответе. Проверка от 2026-08-17
|
||||
подтвердила, что по `RequestStatusOWSCHCS` можно фильтровать, и это НЕ было
|
||||
доказательством, что его можно получить (проверено отдельно 2026-08-20 — можно).
|
||||
Команда `props` печатает `<нет в ответе>` и `<null>` разными пометками именно
|
||||
поэтому: схлопывать их в пустую строку — значит потерять ответ на вопрос.
|
||||
|
||||
**Имена managed properties нестандартные.** Суффикс — по типу исходной колонки:
|
||||
`OWSTEXT` (Text), `OWSCHCS` (Choice), `OWSUSER` (Person/Group). У колонок из группы
|
||||
колонок сайта добавляется префикс группы — поэтому «ожидаемого» `ApproverOWSUSER` в
|
||||
схеме нет, а есть `TansuSiteApproverOWSUSER` (разбирались 2026-08-06). Актуальные
|
||||
имена — в константах `MP_*` в `features/myRequests/myRequests.service.ts`.
|
||||
|
||||
**`AuthorOWSUSER` — это не почта.** Приходит строкой `почта | ФИО`, а при
|
||||
claims-логине частей будет четыре. Разбирать сплитом по первому элементу нельзя.
|
||||
|
||||
**POST `postquery`, а не GET `query`.** KQL портала перечисляет через `OR` адреса всех
|
||||
партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один
|
||||
из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские,
|
||||
`odata=nometadata`. Иначе инструмент мерил бы не то, что портал.
|
||||
Generated
+7908
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "sp-tools",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Запросы к SharePoint портала заявок для разбора: Search, managed properties, партиции списков",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"login": "node sp.mjs login",
|
||||
"status": "node sp.mjs status",
|
||||
"props": "node sp.mjs props",
|
||||
"lists": "node sp.mjs lists"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnp/cli-microsoft365": "^11.10.0"
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sp — тонкая обёртка над CLI for Microsoft 365 для РАЗБОРА, а не для правки данных.
|
||||
*
|
||||
* Зачем вообще: разбирая производительность портала, всё время нужно задать
|
||||
* SharePoint один и тот же вопрос («а что реально отдаёт индекс?», «сколько
|
||||
* партиций у типа?») и получить ответ текстом, который можно приложить к задаче.
|
||||
* Через браузерную консоль это ручная работа с копипастой; здесь — команда.
|
||||
*
|
||||
* Почему device code, а не секрет приложения: пароль и секрет через этот
|
||||
* инструмент не проходят вообще. `sp login` печатает код, пользователь вводит
|
||||
* его в браузере сам, CLI дальше держит токен у себя. В репозитории не оседает
|
||||
* ничего секретного.
|
||||
*
|
||||
* Почему сырой REST через токен, а не готовые команды `m365 spo`: группа
|
||||
* `m365 search` — это Microsoft Search (Graph), совсем другой API. Нам нужен
|
||||
* ИМЕННО SharePoint Search REST с SelectProperties — тот же, что дёргает портал
|
||||
* (см. myRequests.service.ts). Тело запроса взято один в один из PnPjs
|
||||
* (@pnp/sp/search/query.js): `{ request: { ...query } }`, массивы плоские,
|
||||
* odata=nometadata. Иначе инструмент мерил бы не то, что портал.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(HERE, 'node_modules', '@pnp', 'cli-microsoft365', 'dist', 'index.js');
|
||||
|
||||
// Значения по умолчанию — те же, что в src/webparts/requestPortal/shared/config.ts.
|
||||
// Переопределяются переменными окружения, чтобы не править файл ради другого стенда.
|
||||
const SITE = process.env.SP_SITE || 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
const APP_ID = process.env.SP_APP_ID || process.env.CLIMICROSOFT365_ENTRAAPPID || '';
|
||||
const TENANT = process.env.SP_TENANT || process.env.CLIMICROSOFT365_TENANT || 'common';
|
||||
|
||||
/** Хост тенанта — именно он, а не адрес сайта, является ресурсом для токена. */
|
||||
const RESOURCE = new URL(SITE).origin;
|
||||
|
||||
function m365(args, { inherit = false } = {}) {
|
||||
return execFileSync(process.execPath, [CLI, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[sp] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let cachedToken;
|
||||
function token() {
|
||||
if (cachedToken) return cachedToken;
|
||||
try {
|
||||
cachedToken = m365(['util', 'accesstoken', 'get', '-r', RESOURCE, '-o', 'text']).trim();
|
||||
} catch (e) {
|
||||
fail(
|
||||
`не удалось получить токен для ${RESOURCE}.\n` +
|
||||
` Скорее всего нет входа — выполни: node sp.mjs login\n` +
|
||||
` ${String(e.stderr || e.message).trim().split('\n')[0]}`
|
||||
);
|
||||
}
|
||||
if (!cachedToken) fail(`пустой токен для ${RESOURCE} — выполни: node sp.mjs login`);
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
async function spFetch(path, init = {}) {
|
||||
const url = path.startsWith('http') ? path : `${SITE}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token()}`,
|
||||
accept: 'application/json;odata=nometadata',
|
||||
...(init.body ? { 'content-type': 'application/json;odata=nometadata' } : {}),
|
||||
...(init.headers || {})
|
||||
}
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) fail(`${res.status} ${res.statusText}\n${text.slice(0, 800)}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Команды
|
||||
|
||||
async function cmdLogin() {
|
||||
if (!APP_ID) {
|
||||
fail(
|
||||
'не задан SP_APP_ID.\n' +
|
||||
' CLI for Microsoft 365 с 7-й версии не возит собственное приложение —\n' +
|
||||
' нужен свой Entra app (см. README.md, раздел «Разовая подготовка»).'
|
||||
);
|
||||
}
|
||||
console.log(`[sp] вход в ${TENANT} приложением ${APP_ID}. Код появится ниже — введи его в браузере.`);
|
||||
m365(['login', '--appId', APP_ID, '--tenant', TENANT, '--authType', 'deviceCode'], { inherit: true });
|
||||
}
|
||||
|
||||
function cmdStatus() {
|
||||
console.log(m365(['status', '-o', 'text']).trim());
|
||||
console.log(`[sp] сайт: ${SITE}`);
|
||||
console.log(`[sp] ресурс токена: ${RESOURCE}`);
|
||||
}
|
||||
|
||||
function cmdLogout() {
|
||||
m365(['logout'], { inherit: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* SharePoint Search — тот же вызов, что делает портал.
|
||||
*
|
||||
* POST /_api/search/postquery, а не GET /query: KQL портала перечисляет через OR
|
||||
* адреса ВСЕХ партиций, и в query-строке это упирается в предел длины URL.
|
||||
*/
|
||||
async function cmdSearch(argv) {
|
||||
const kql = argv._[0];
|
||||
if (!kql) fail('нужен KQL: node sp.mjs search "<запрос>" [--select a,b] [--rows N]');
|
||||
|
||||
const select = (argv.select || 'ListItemID,Path,Title').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const rows = Number(argv.rows || 10);
|
||||
|
||||
const data = await spFetch('/_api/search/postquery', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request: { Querytext: kql, RowLimit: rows, SelectProperties: select, TrimDuplicates: false }
|
||||
})
|
||||
});
|
||||
|
||||
const relevant = data?.PrimaryQueryResult?.RelevantResults;
|
||||
const rawRows = relevant?.Table?.Rows ?? [];
|
||||
const parsed = rawRows.map((r) => {
|
||||
const cells = r.Cells?.results ?? r.Cells ?? [];
|
||||
return select.reduce((acc, key) => {
|
||||
const cell = cells.filter((c) => c.Key === key)[0];
|
||||
// Различаем «свойство не Retrievable» и «значение пустое» — ради этого
|
||||
// различия инструмент и писался, схлопывать их в '' нельзя.
|
||||
acc[key] = !cell ? '<нет в ответе>' : cell.Value === null ? '<null>' : cell.Value;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
console.log(`[sp] в индексе ${relevant?.TotalRows ?? 0}, показано ${parsed.length}`);
|
||||
console.log(JSON.stringify(parsed, null, 1));
|
||||
}
|
||||
|
||||
/** Произвольный GET к REST — для всего, чего нет отдельной командой. */
|
||||
async function cmdGet(argv) {
|
||||
const path = argv._[0];
|
||||
if (!path) fail('нужен путь: node sp.mjs get "/_api/web/lists?$select=Title"');
|
||||
console.log(JSON.stringify(await spFetch(path), null, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Все списки сайта с адресами — тем же запросом, которым портал резолвит
|
||||
* списки согласования (см. shared/approval/approvalListLocator.ts). Заодно
|
||||
* показывает, сколько на самом деле партиций: это и есть P из оценок.
|
||||
*/
|
||||
async function cmdLists() {
|
||||
const data = await spFetch(
|
||||
"/_api/web/lists?$select=Title,ItemCount,Hidden,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000"
|
||||
);
|
||||
const all = data.value ?? [];
|
||||
const portal = all.filter((l) => !l.Hidden && /—\s*(данные|согласование)\s*$/.test(l.Title || ''));
|
||||
|
||||
portal
|
||||
.sort((a, b) => String(a.Title).localeCompare(String(b.Title)))
|
||||
.forEach((l) => {
|
||||
console.log(`${String(l.ItemCount).padStart(6)} ${l.RootFolder?.ServerRelativeUrl} ${l.Title}`);
|
||||
});
|
||||
|
||||
const data_ = portal.filter((l) => /данные\s*$/.test(l.Title)).length;
|
||||
console.log(`[sp] всего списков на сайте ${all.length}; портальных ${portal.length} (данные: ${data_})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт ли индекс поля заявки (Retrievable) — от этого зависит, можно ли
|
||||
* заменить REST-дочитывание (loadByIds) на SelectProperties самого Search.
|
||||
*
|
||||
* Queryable и Retrievable у managed property — РАЗНЫЕ флаги: то, по чему можно
|
||||
* фильтровать, не обязано приходить в ответе. Отбираем по самому
|
||||
* RequestStatusOWSCHCS, поэтому найденное гарантированно является заявками
|
||||
* портала, а не случайными элементами сайта.
|
||||
*/
|
||||
async function cmdProps(argv) {
|
||||
const props = (
|
||||
argv.select ||
|
||||
'ListItemID,Path,Title,Created,Author,AuthorOWSUSER,RequestStatusOWSCHCS,RequestUniqueCodeOWSTEXT,LastModifiedTime'
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
|
||||
const statuses = ['На согласовании', 'Черновик', 'Согласовано', 'Возвращена на доработку'];
|
||||
const kql = `Path:"${SITE}/*" AND (${statuses.map((s) => `RequestStatusOWSCHCS:"${s}"`).join(' OR ')})`;
|
||||
|
||||
await cmdSearch({ _: [kql], select: props.join(','), rows: argv.rows || 3 });
|
||||
console.log(
|
||||
'[sp] <нет в ответе> или <null> у свойства = оно НЕ Retrievable, для этого поля REST остаётся.'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgv(args) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i];
|
||||
if (a.startsWith('--')) {
|
||||
const [key, inline] = a.slice(2).split('=');
|
||||
out[key] = inline ?? (args[i + 1] && !args[i + 1].startsWith('--') ? args[(i += 1)] : true);
|
||||
} else {
|
||||
out._.push(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const USAGE = `sp — запросы к SharePoint портала заявок (только чтение)
|
||||
|
||||
node sp.mjs login вход по device code (код вводишь в браузере сам)
|
||||
node sp.mjs status кто залогинен и куда смотрим
|
||||
node sp.mjs logout
|
||||
|
||||
node sp.mjs search "<KQL>" [--select a,b,c] [--rows N]
|
||||
node sp.mjs props [--select a,b,c] [--rows N] отдаёт ли индекс поля заявки
|
||||
node sp.mjs lists списки сайта и их размеры (партиции)
|
||||
node sp.mjs get "/_api/web/..." произвольный GET
|
||||
|
||||
Окружение: SP_SITE, SP_APP_ID, SP_TENANT (см. README.md)`;
|
||||
|
||||
const [, , command, ...rest] = process.argv;
|
||||
const argv = parseArgv(rest);
|
||||
|
||||
const commands = {
|
||||
login: cmdLogin,
|
||||
status: cmdStatus,
|
||||
logout: cmdLogout,
|
||||
search: cmdSearch,
|
||||
props: cmdProps,
|
||||
lists: cmdLists,
|
||||
get: cmdGet
|
||||
};
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.log(USAGE);
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
Promise.resolve(commands[command](argv)).catch((e) => fail(String(e.stack || e.message)));
|
||||
Reference in New Issue
Block a user