- );
-};
-
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка —
с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
@@ -166,24 +117,49 @@ const Table: React.FC<{
);
-// Раздел с таблицей заявок: поиск по названию + постраничный вывод.
+// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
//
-// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть
-// весь маршрут заявки), поиск и пагинация — на клиенте.
-// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск —
-// серверным OData-фильтром по Title.
+// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
+// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом —
+// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше
+// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого
+// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
+// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
+//
+// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в
+// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow).
+//
+// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status),
+// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает
+// автора только для него).
+// 'approvals' с поиском — область расширяется до «где пользователь вообще есть
+// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично
+// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый
+// фильтр через Search (filterPendingByQuery), и только после фильтрации —
+// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже
+// только для видимой страницы, а не для всего отфильтрованного набора.
//
// Оба варианта перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
-export const RequestsTableView: React.FC = ({ context, kind, onOpen }) => {
+// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
+export const RequestsTableView: React.FC = ({
+ context,
+ kind,
+ onOpen,
+ onMyTurnCountChange,
+}) => {
const [types, setTypes] = React.useState(undefined);
const [rows, setRows] = React.useState(undefined);
const [totalRows, setTotalRows] = React.useState(0);
const [page, setPage] = React.useState(1);
- const [query, setQuery] = React.useState('');
+ const [query, setQuery] = usePersistedQuery(kind);
const [error, setError] = React.useState('');
+ // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
+ // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
+ // иначе поиск бил бы запросом по каждой набранной букве.
+ const debouncedQuery = useDebouncedQuery(query);
- React.useEffect(() => setPage(1), [query, kind]);
+ React.useEffect(() => setPage(1), [debouncedQuery, kind]);
React.useEffect(() => {
let cancelled = false;
@@ -202,45 +178,77 @@ export const RequestsTableView: React.FC = ({ context,
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
+ const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
- const load = (): void => {
- const request =
- kind === 'own'
- ? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows }))
- : getPendingApproval(context).then((r) => {
- console.table(r.debug);
- return { rows: r.rows, total: r.rows.length };
- });
+ // Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
+ // и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
+ // без кеша при переключении на экране оставались бы строки предыдущей
+ // вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
+ // rows становится undefined, и показывается «Загрузка…».
+ const cached = readPage(cacheKey);
+ setRows(cached ? cached.rows : undefined);
+ setTotalRows(cached ? cached.totalRows : 0);
- request
- .then((result) => {
- if (cancelled) return;
- setRows(result.rows);
- setTotalRows(result.total);
- setError('');
- })
- .catch((e) => {
- if (!cancelled) setError((e as Error).message);
- });
+ const fetchPage = async (): Promise => {
+ if (kind === 'own') {
+ return debouncedQuery.trim().length > 0
+ ? searchOwnRequests(context, types, debouncedQuery, page)
+ : getOwnRequests(context, types, page);
+ }
+
+ if (debouncedQuery.trim().length === 0) {
+ // Без поиска — страница уже нарезана и дочитана сервером.
+ const r = await getPendingApproval(context, 'myTurn', page);
+ return { rows: r.rows, totalRows: r.totalRows };
+ }
+
+ // С поиском — сначала полный список «где я есть в маршруте» (без
+ // автора), фильтруем текстом, и только потом режем на страницы и
+ // дочитываем автора — только для видимой страницы.
+ const idx = await getPendingApprovalIndex(context, 'involved');
+ const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
+ const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
+ await hydratePendingAuthors(context, idx.types, pageRows);
+ return { rows: pageRows, totalRows: filtered.length };
};
- load();
- const unsubscribe = onRequestsChanged(load);
+ const load = async (): Promise => {
+ try {
+ const fresh = await fetchPage();
+ if (cancelled) return;
+
+ // Перерисовываем, только если данные реально изменились — иначе при
+ // каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
+ // самым содержимым.
+ const prev = readPage(cacheKey);
+ if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
+ writePage(cacheKey, fresh);
+ setRows(fresh.rows);
+ setTotalRows(fresh.totalRows);
+ }
+ setError('');
+ if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
+ onMyTurnCountChange(fresh.totalRows);
+ }
+ } catch (e) {
+ if (!cancelled) setError((e as Error).message);
+ }
+ };
+
+ load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
+ const unsubscribe = onRequestsChanged(() => {
+ load().catch(() => undefined);
+ });
return () => {
cancelled = true;
unsubscribe();
};
- }, [context, types, kind, query, page]);
+ }, [context, types, kind, debouncedQuery, page]);
- // 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте.
- const q = query.trim().toLowerCase();
- let visible = rows || [];
- let total = totalRows;
- if (kind === 'approvals' && rows) {
- const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows;
- total = filtered.length;
- visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
- }
+ // Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
+ const q = debouncedQuery.trim();
+ const total = totalRows;
+ const visible = rows || [];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
@@ -255,7 +263,7 @@ export const RequestsTableView: React.FC = ({ context,
setQuery(e.target.value)}
/>
@@ -266,7 +274,7 @@ export const RequestsTableView: React.FC = ({ context,
{!error && rows && total === 0 && (
+);
+
+// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где
+// пользователь участвует как согласующий (независимо от того, дошла ли уже
+// очередь) — единым списком, с колонками Автор и Статус одновременно (см.
+// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто,
+// это не список «на экран по умолчанию», а именно поиск.
+//
+// Перечитывается по событию rp-requests-changed (после создания заявки и
+// после решения согласующего) — но т.к. это по большей части Search, свежая
+// правка может проявиться не мгновенно (частично компенсируется довеском
+// последних 10 минут через REST — см. myRequests.service.ts).
+export const SearchView: React.FC = ({ context, onOpen }) => {
+ const [types, setTypes] = React.useState(undefined);
+ const [rows, setRows] = React.useState(undefined);
+ const [totalRows, setTotalRows] = React.useState(0);
+ const [page, setPage] = React.useState(1);
+ const [query, setQuery] = usePersistedQuery('search');
+ // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
+ // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
+ // ходит сразу по двум источникам (свои заявки + где участвую), то есть
+ // каждая набранная буква стоила бы двух серий запросов.
+ const debouncedQuery = useDebouncedQuery(query);
+ const [error, setError] = React.useState('');
+
+ React.useEffect(() => setPage(1), [debouncedQuery]);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ getLinkedTypes(context)
+ .then((t) => {
+ if (!cancelled) setTypes(t);
+ })
+ .catch((e) => {
+ if (!cancelled) setError((e as Error).message);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [context]);
+
+ React.useEffect(() => {
+ if (types === undefined) return;
+ if (debouncedQuery.trim().length === 0) {
+ setRows(undefined);
+ setTotalRows(0);
+ return;
+ }
+ let cancelled = false;
+ const cacheKey = pageKey('search', [debouncedQuery.trim(), page]);
+
+ // Уже искали это раньше — показываем прежний результат сразу, обновление
+ // идёт фоном (см. RequestsTableView, там та же схема и те же причины).
+ const cached = readPage(cacheKey);
+ setRows(cached ? cached.rows : undefined);
+ setTotalRows(cached ? cached.totalRows : 0);
+
+ const load = async (): Promise => {
+ try {
+ const fresh = await searchMyRequests(context, types, debouncedQuery, page);
+ if (cancelled) return;
+ const prev = readPage(cacheKey);
+ if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
+ writePage(cacheKey, fresh);
+ setRows(fresh.rows);
+ setTotalRows(fresh.totalRows);
+ }
+ setError('');
+ } catch (e) {
+ if (!cancelled) setError((e as Error).message);
+ }
+ };
+
+ load().catch(() => undefined);
+ const unsubscribe = onRequestsChanged(() => {
+ load().catch(() => undefined);
+ });
+ return () => {
+ cancelled = true;
+ unsubscribe();
+ };
+ }, [context, types, debouncedQuery, page]);
+
+ const hasQuery = debouncedQuery.trim().length > 0;
+ const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
+
+ return (
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
index 3ac786c..9e5c5dc 100644
--- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
+++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
@@ -1,8 +1,10 @@
-import { spfi, SPFx } from '@pnp/sp';
+import { spForSite } from '../../shared/pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
+import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
+import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
@@ -12,11 +14,28 @@ import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
-interface IApprovalStep {
- status: string;
- order?: number;
- approverEmail: string;
-}
+// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
+// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
+// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn,
+// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
+// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
+// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
+const RECENT_MS = 10 * 60 * 1000;
+
+// Сколько строк согласования максимум забираем из Search за один запрос
+// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор
+// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении
+// лимита пишем предупреждение в лог, а не молча обрезаем.
+const SEARCH_ROW_LIMIT = 500;
+
+// Managed properties списка согласования. Имена включают группу колонок сайта
+// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
+// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
+// Суффикс — тип исходного поля: OWSUSER для Person/Group.
+const MP_APPROVER = 'TansuSiteApproverOWSUSER';
+// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено
+// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER).
+const MP_STATUS = 'RequestStatusOWSCHCS';
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
@@ -28,32 +47,114 @@ export async function getLinkedTypes(context: WebPartContext): Promise !!t.listId && !!t.siteUrl);
}
+// Что показывать в разделе «На согласовании»:
+// 'myTurn' — только заявки, где очередь дошла до текущего пользователя
+// (обычный режим, когда поиск не задан);
+// 'involved' — все заявки, где он вообще есть в маршруте (уже согласовал,
+// ждёт своей очереди и т.п.) — так поиск ищет по всему, в чём
+// пользователь участвует или будет участвовать.
+export type ApprovalScope = 'myTurn' | 'involved';
+
// ---------------------------------------------------------------------------
-// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять,
-// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search),
-// целиком за один раз. Пагинация по этому результату — на стороне панели
-// (клиентская нарезка по страницам), т.к. сам список обычно небольшой
-// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего).
-export async function getPendingApproval(
- context: WebPartContext
-): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
+// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
+// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
+// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
+// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
+// значение колонки Status, поэтому здесь достаточно ОДНОГО отфильтрованного
+// запроса: ApproverId (+ Status для 'myTurn'). Список согласования больше НЕ
+// сканируется целиком, независимо от его размера.
+//
+// Заявку (Title/Status/Created) читаем сразу через expand по Lookup-полю
+// Ticket — отдельный запрос к списку данных нужен только за Author
+// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то —
+// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего
+// найденного среза. Полноценный $skip/$top здесь не сделать: SharePoint REST
+// не умеет сортировать по полю через expand (Ticket/Created — поле не этого
+// списка), поэтому «свежие сверху» через несколько сайтов/типов можно получить
+// только слиянием и сортировкой в памяти. Экономим на самом дорогом шаге —
+// не дочитываем Author там, где он не попадёт на экран.
+export interface IPendingApprovalPage {
+ rows: IMyRequest[];
+ totalRows: number;
+ debug: IMyRequestsTypeDebug[];
+}
+
+const PENDING_PAGE_SIZE = 20;
+
+// Общая часть: собрать полный (не постраничный) индекс совпадающих заявок по
+// всем типам — без автора, БЕЗ дочитывания. Используется и обычным режимом
+// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
+// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
+// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
+async function buildPendingIndex(
+ context: WebPartContext,
+ scope: ApprovalScope
+): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context);
const perType = await Promise.all(
- types.map((t) => getApprovalPendingForType(context, t, tokenEmail))
+ types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope))
);
- return {
- rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]),
- debug: perType.map((r) => r.debug),
- };
+ // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
+ const index = perType
+ .reduce((all, r) => all.concat(r.rows), [] as IMyRequest[])
+ .sort((a, b) => b.created.localeCompare(a.created));
+
+ return { types, index, debug: perType.map((r) => r.debug) };
}
-async function getApprovalPendingForType(
+// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
+// заявок отдать), автор дочитывается только для них.
+export async function getPendingApproval(
+ context: WebPartContext,
+ scope: ApprovalScope = 'myTurn',
+ page: number = 1
+): Promise {
+ const { types, index, debug } = await buildPendingIndex(context, scope);
+
+ const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
+ console.log(
+ `[Текущие заявки] «На согласовании» (scope=${scope}): всего найдено ${index.length}, страница ${page} — ${pageRows.length} строк, дочитываем автора для них`
+ );
+ await hydrateAuthors(context, types, pageRows);
+
+ return { rows: pageRows, totalRows: index.length, debug };
+}
+
+// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
+// сторона через filterPendingByQuery, а страницу и автора берёт из уже
+// отфильтрованного результата (см. hydratePendingAuthors).
+export async function getPendingApprovalIndex(
+ context: WebPartContext,
+ scope: ApprovalScope
+): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
+ const { types, index, debug } = await buildPendingIndex(context, scope);
+ return { rows: index, types, debug };
+}
+
+// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
+// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
+export async function hydratePendingAuthors(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ await hydrateAuthors(context, types, rows);
+}
+
+// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
+// ApproverId → Title/Status/Created заявки отдельным запросом к списку ДАННЫХ
+// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
+// произвольные поля связанного элемента, только через Person-поля вроде
+// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
+// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
+async function indexApprovalsForType(
context: WebPartContext,
type: IRequestType,
- tokenEmail: string
+ tokenEmail: string,
+ scope: ApprovalScope
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
@@ -64,7 +165,9 @@ async function getApprovalPendingForType(
};
try {
- const sp = spfi(type.siteUrl as string).using(SPFx(context));
+ if (!tokenEmail) return { rows: [], debug };
+
+ const sp = spForSite(type.siteUrl as string, context);
const list = sp.web.getList(type.listId as string);
const dataListInfo = await list.select('Title')();
@@ -77,63 +180,91 @@ async function getApprovalPendingForType(
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
- // Phase не смотрим вообще — работаем только по Status.
- const stepRows = (await approvalList.items
- .select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
- .expand('Approver')
- .top(2000)()) as Array>;
- console.log(
- `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
- );
- console.table(
- stepRows.map((r) => ({
- Id: r.Id,
- TicketId: r.TicketId,
- StepOrder: r.StepOrder,
- Status: r.Status,
- 'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
- }))
- );
+ // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
+ // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
+ // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
+ //
+ // RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр
+ // вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча
+ // возвращает 0 строк, даже когда без него те же строки находятся с этим же
+ // значением статуса. Поэтому фильтруем только по TansuSiteApproverId
+ // (числовое значение, проблем нет), а статус сравниваем в JS после
+ // получения строк.
+ const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
+ const approver = await sp.web.ensureUser(claimsLogin);
+ let stepRows: Array>;
+
+ if (scope === 'myTurn') {
+ // 'myTurn' — гибрид для масштаба (список согласования может быть очень
+ // большим): Search находит кандидатов, не сканируя список целиком, плюс
+ // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
+ // не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по
+ // найденным Id (не из индекса Search) — именно это делает
+ // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда
+ // точное, даже когда список кандидатов пришёл из Search.
+ const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
+ const recentRows = (await approvalList.items
+ .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
+ .select('Id', 'TicketId', 'RequestStatus')
+ .top(2000)()) as Array>;
+
+ // Статусы уходят в KQL: сервер сразу отдаёт только активные строки,
+ // а не всё, где пользователь есть в маршруте.
+ const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [
+ REQUEST_STATUS.onApproval,
+ REQUEST_STATUS.onExecution,
+ ]);
+ const seenIds = new Set(recentRows.map((r) => r.Id));
+ const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
+
+ stepRows = allRows.filter(
+ (r) =>
+ r.RequestStatus === REQUEST_STATUS.onApproval ||
+ r.RequestStatus === REQUEST_STATUS.onExecution
+ );
+ console.log(
+ `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
+ );
+ } else {
+ // 'involved' — используется ТОЛЬКО для текстового поиска (см.
+ // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
+ // свежесть, а полнота по всему архиву, поэтому только Search, без
+ // REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
+ // давал неустойчивый результат (заявка то находилась, то нет, в
+ // зависимости от того, успел ли Search её проиндексировать к моменту
+ // REST-окна).
+ stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
+
+ console.log(
+ `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}`
+ );
+ }
+
+ console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus })));
debug.approvalRowCount = stepRows.length;
- const byTicket = new Map();
- stepRows.forEach((r) => {
- const ticketId = r.TicketId as number | undefined;
- if (!ticketId) return;
- const approver = r.Approver as { EMail?: string } | undefined;
- const steps = byTicket.get(ticketId) || [];
- steps.push({
- status: (r.Status as string) || '',
- order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
- approverEmail: (approver?.EMail || '').toLowerCase(),
- });
- byTicket.set(ticketId, steps);
- });
+ // Дедуп по TicketId: при scope='involved' у одной заявки может найтись
+ // несколько ваших строк (напр. вы согласующий и делегат) — заявка одна.
+ const ticketIds = Array.from(
+ new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
+ );
+ if (ticketIds.length === 0) {
+ debug.approvalPendingTickets = 0;
+ return { rows: [], debug };
+ }
- const myTicketIds: number[] = [];
- const byTicketDebug: Array> = [];
- byTicket.forEach((steps, ticketId) => {
- const mine = isMyTurn(steps, tokenEmail);
- byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
- if (mine) myTicketIds.push(ticketId);
- });
- console.table(byTicketDebug);
-
- debug.approvalPendingTickets = myTicketIds.length;
- if (myTicketIds.length === 0) return { rows: [], debug };
-
- const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
+ // Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
+ // комментарий у функции: через Lookup-expand это не отдаётся).
+ const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
- .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
- .expand('Author')
- .top(2000)()) as Array>;
+ .select('Id', 'Title', 'Status', 'Created')
+ .top(ticketIds.length)()) as Array>;
- const rows = ticketRows.map((r) => {
- const author = r.Author as { EMail?: string; Title?: string } | undefined;
- return toMyRequest(r, author, type, 'approval');
- });
+ const rows = ticketRows.map((r) => toMyRequest(r, undefined, type, 'approval'));
+
+ debug.approvalPendingTickets = rows.length;
return { rows, debug };
} catch (e) {
debug.approvalError = (e as Error).message;
@@ -145,115 +276,210 @@ async function getApprovalPendingForType(
}
}
-// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки.
-// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка
-// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании)
-// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки
-// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно).
-function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
- if (!tokenEmail) return false;
- const stopped = steps.some(
- (s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned
- );
- if (stopped) return false;
+// Строки согласования текущего пользователя через Search, чтобы не сканировать
+// список целиком (он растёт с каждой заявкой).
+//
+// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
+// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
+// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
+// возвращали 0.
+//
+// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
+// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
+// без него в выдачу попали бы все строки, где пользователь вообще есть в
+// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
+// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
+//
+// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
+// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на
+// устаревшем статусе решение «моя очередь» строить нельзя).
+async function getInvolvedRowsViaSearch(
+ context: WebPartContext,
+ type: IRequestType,
+ approvalList: IList,
+ statuses: string[] = []
+): Promise>> {
+ try {
+ const email = currentUserEmail(context);
+ if (!email) {
+ console.warn(
+ `[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен`
+ );
+ return [];
+ }
+ const claimsLogin = `i:0#.f|membership|${email}`;
- const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval);
- const noOrder = pending.filter((s) => s.order === undefined);
- const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined);
+ const rootFolder = (await approvalList
+ .select('RootFolder/ServerRelativeUrl')
+ .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } };
+ const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
+ if (!relUrl) return [];
+ const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`;
- const current = [...noOrder];
- if (ordered.length > 0) {
- const minOrder = Math.min(...ordered.map((s) => s.order));
- current.push(...ordered.filter((s) => s.order === minOrder));
+ const sp = spForSite(type.siteUrl as string, context);
+ const pathClause = `Path:"${approvalListAbsUrl}*"`;
+ const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
+ if (statuses.length > 0) {
+ clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
+ }
+ const kql = clauses.join(' AND ');
+
+ const results = await sp.search({
+ Querytext: kql,
+ RowLimit: SEARCH_ROW_LIMIT,
+ SelectProperties: ['ListItemID'],
+ TrimDuplicates: false,
+ });
+
+ const total = results.TotalRows || 0;
+ const ids = (results.PrimarySearchResults as Array>)
+ .map((r) => Number(r.ListItemID))
+ .filter((id) => !isNaN(id) && id > 0);
+
+ console.log(
+ `[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
+ );
+
+ if (total > SEARCH_ROW_LIMIT) {
+ console.warn(
+ `[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
+ );
+ }
+ if (ids.length === 0) return [];
+
+ return (await approvalList.items
+ .filter(ids.map((id) => `Id eq ${id}`).join(' or '))
+ .select('Id', 'TicketId', 'RequestStatus')
+ .top(ids.length)()) as Array>;
+ } catch (e) {
+ console.error(
+ `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`,
+ (e as Error).message
+ );
+ return [];
}
+}
- return current.some((s) => s.approverEmail === tokenEmail);
+// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
+// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
+// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
+async function hydrateAuthors(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ if (rows.length === 0) return;
+
+ const typeById = new Map();
+ types.forEach((t) => typeById.set(t.id, t));
+
+ const idsByType = new Map();
+ rows.forEach((r) => {
+ const ids = idsByType.get(r.requestTypeId) || [];
+ ids.push(r.id);
+ idsByType.set(r.requestTypeId, ids);
+ });
+
+ const byKey = new Map();
+ await Promise.all(
+ Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => {
+ const type = typeById.get(requestTypeId);
+ if (!type) return;
+ try {
+ const sp = spForSite(type.siteUrl as string, context);
+ const authorRows = (await sp.web
+ .getList(type.listId as string)
+ .items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
+ .select('Id', 'Author/EMail', 'Author/Title')
+ .expand('Author')
+ .top(ids.length)()) as Array>;
+ authorRows.forEach((r) => {
+ byKey.set(
+ `${requestTypeId}-${r.Id as number}`,
+ (r.Author as { EMail?: string; Title?: string }) || {}
+ );
+ });
+ } catch (e) {
+ console.error(`[Текущие заявки] Не удалось дочитать автора для «${type.name}»:`, (e as Error).message);
+ }
+ })
+ );
+
+ rows.forEach((r) => {
+ const a = byKey.get(`${r.requestTypeId}-${r.id}`);
+ r.authorEmail = a?.EMail || '';
+ r.authorName = a?.Title || '';
+ });
}
// ---------------------------------------------------------------------------
-// «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда
-// актуальны, только что созданная заявка видна сразу (у поискового индекса
-// была бы задержка переиндексации).
+// Поиск через SharePoint Search REST API (search/query, KQL).
//
-// Пагинация «сшивает» несколько списков в один нумерованный список: сначала по
-// каждому типу берём только лёгкие поля (Id + Created) всех заявок автора,
-// сортируем объединённый набор по дате создания и лишь для нужной страницы
-// дочитываем полные поля. Так на экран не тянется всё содержимое заявок,
-// но общее число страниц известно точно.
+// Текст запроса идёт свободным термом — поиск по ВСЕМ проиндексированным полям
+// элемента, а не только по названию. Плата за это — результаты берутся из
+// поискового индекса, который переиндексируется с задержкой: только что
+// созданная или изменённая заявка может найтись не сразу.
export interface IOwnRequestsPage {
rows: IMyRequest[];
totalRows: number;
}
const PAGE_SIZE = 20;
-// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы
-// посчитать общее число страниц. Хватает с большим запасом; если у кого-то
-// заявок окажется больше — в консоль уйдёт предупреждение.
-const INDEX_LIMIT = 5000;
+// Сколько заявок максимум перечисляем в KQL при поиске среди уже отобранных
+// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной.
+const KQL_ID_LIMIT = 300;
-// В OData-строке одинарная кавычка экранируется удвоением.
-function escapeOData(text: string): string {
- return text.replace(/'/g, "''");
+function siteOrigin(siteUrl: string): string {
+ const match = /^https?:\/\/[^/]+/.exec(siteUrl);
+ return match ? match[0] : siteUrl;
}
-interface IOwnIndexEntry {
- id: number;
- created: string;
- type: IRequestType;
+function listAbsUrl(type: IRequestType): string {
+ return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
}
-export async function getOwnRequests(
- context: WebPartContext,
- types: IRequestType[],
- query: string,
- page: number
-): Promise {
- if (types.length === 0) return { rows: [], totalRows: 0 };
+// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
+// цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает)
+function sanitizeForKql(text: string): string {
+ return text
+ .replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
- const q = query.trim();
+// Свободный терм по всем полям: каждое слово — префиксный поиск, все слова
+// обязательны (AND). Пустая строка → undefined (условие не добавляем).
+function freeTextClause(query: string): string | undefined {
+ const q = sanitizeForKql(query);
+ if (!q) return undefined;
+ return `(${q.split(' ').map((word) => `${word}*`).join(' AND ')})`;
+}
- // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора.
- const perType = await Promise.all(
- types.map(async (type): Promise => {
- try {
- const sp = spfi(type.siteUrl as string).using(SPFx(context));
- const me = await sp.web.currentUser.select('Id')();
- const filters = [`AuthorId eq ${me.Id}`];
- if (q) filters.push(`substringof('${escapeOData(q)}',Title)`);
-
- const rows = (await sp.web
- .getList(type.listId as string)
- .items.filter(filters.join(' and '))
- .select('Id', 'Created')
- .orderBy('Created', false)
- .top(INDEX_LIMIT)()) as Array>;
-
- if (rows.length === INDEX_LIMIT) {
- console.warn(
- `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна`
- );
- }
- return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type }));
- } catch (e) {
- console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
- return [];
- }
+// Ответ Search → пары (Id элемента, его тип по префиксу Path).
+function hitsToEntries(
+ results: { PrimarySearchResults: unknown[] },
+ types: IRequestType[]
+): Array<{ id: number; type: IRequestType }> {
+ const urls = types.map((t) => ({ type: t, url: listAbsUrl(t).toLowerCase() }));
+ return (results.PrimarySearchResults as Array>)
+ .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '').toLowerCase() }))
+ .filter((h) => !isNaN(h.id) && h.id > 0)
+ .map((h) => {
+ const matched = urls.filter((u) => h.path.indexOf(u.url) === 0)[0];
+ return matched ? { id: h.id, type: matched.type } : undefined;
})
- );
+ .filter((e): e is { id: number; type: IRequestType } => !!e);
+}
- // 2) Общий список всех типов, свежие сверху.
- const index = perType
- .reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[])
- .sort((a, b) => b.created.localeCompare(a.created));
-
- const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
- if (pageEntries.length === 0) {
- return { rows: [], totalRows: index.length };
- }
-
- // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип.
+// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
+// кастомных полей нет гарантии, что они проиндексированы как managed property).
+async function loadByIds(
+ context: WebPartContext,
+ entries: Array<{ id: number; type: IRequestType }>,
+ kind: 'own' | 'approval'
+): Promise