Files
request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
T
gorbenko.pavel a19444eb28
Tests / frontend-test (pull_request) Successful in 1m18s
рест и серч одновременно
2026-08-21 15:00:33 +05:00

1744 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SPFI } 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';
// sp.batched() — довесок по свежести уходит одним запросом на сайт, см.
// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах,
// но в рантайме не зарегистрирован.
import '@pnp/sp/batching';
import { WebPartContext } from '@microsoft/sp-webpart-base';
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';
import { perfScope, traced } from '../../shared/perfTrace';
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
// Search, и подмешиваем к результату (см. buildPendingIndex/myTurn,
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
const RECENT_MS = 10 * 60 * 1000;
// Сколько строк максимум запрашиваем у Search за ОДИН HTTP-вызов — верхняя
// граница RowLimit самого SharePoint Search REST API. Полный набор всегда
// собирается несколькими такими вызовами через StartRow (см.
// fetchAllSearchRows) — это не потолок итогового результата.
const SEARCH_PAGE_SIZE = 500;
// Абсолютный потолок на суммарное число строк, которые fetchAllSearchRows
// заберёт за все страницы, — защита от зависания на аномально большом
// индексе, а не ожидаемый рабочий предел. При достижении пишем предупреждение
// и возвращаем то, что успели собрать, вместо бесконечного перебора.
const SEARCH_HARD_CAP = 5000;
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
// при дочитывании строк маршрута/заявки по найденным через Search Id (см.
// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL —
// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного
// типа режется на такие пачки, а не уходит одним запросом целиком.
const REST_ID_FILTER_CHUNK = 100;
// Сколько последних заявок автора дочитываем через REST в довесок к Search
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
// пользователя, поэтому лимит с запасом; при упоре в него пишем предупреждение.
const OWN_REST_TOP = 200;
// Managed properties списка согласования. Имена включают группу колонок сайта
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком (см. MP_REQUEST_STATUS
// ниже).
const MP_STATUS = 'ApprovalStatusOWSCHCS';
// Статус ЗАЯВКИ (колонка RequestStatus списка ДАННЫХ) — Choice-поле, тот же
// суффикс OWSCHCS, без префикса группы колонок сайта (поле локальное для
// списка, как и ApprovalStatus выше). Раньше здесь стояло «угадали неверно,
// фильтр молча возвращает 0» — перепроверено вручную через
// /_api/search/query 2026-08-17: и голый запрос по этому свойству, и в
// связке с Path, отдают счётчики, совпадающие с REST-эталоном (см.
// getOwnRequestsByStatus). Старый вывод о поломке был артефактом кривого
// тестового Path (задвоенный сегмент /sites/…), а не реальным ограничением
// 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) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
// маршрут в форме, но собственные списки заявок.
export function currentUserEmail(context: WebPartContext): string {
return effectiveUserEmail(context).toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
// Кешированный вариант: 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;
}
// Что показывать в разделе «На согласовании»:
// 'myTurn' — только заявки, где очередь дошла до текущего пользователя
// (обычный режим, когда поиск не задан);
// 'involved' — все заявки, где он вообще есть в маршруте (уже согласовал,
// ждёт своей очереди и т.п.) — так поиск ищет по всему, в чём
// пользователь участвует или будет участвовать.
export type ApprovalScope = 'myTurn' | 'involved';
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
// Отличаются только набором статусов его строки — сам механизм (найти свои
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
// зашиты в finalizeApprovalTarget.
//
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
// оба статуса сразу); теперь это РАЗНЫЕ разделы и пересечения между ними
// нет — заявка ровно в одном из них.
// approved/rejected/executed — история уже принятых им решений
// (подвкладки «Моих заявок»).
export type ApprovalQueueKind = 'approvals' | 'execution' | 'approved' | 'rejected' | 'executed';
const QUEUE_STATUSES: Record<ApprovalQueueKind, string[]> = {
approvals: [REQUEST_STATUS.onApproval],
execution: [REQUEST_STATUS.onExecution],
approved: [REQUEST_STATUS.approved],
rejected: [REQUEST_STATUS.rejected],
executed: [REQUEST_STATUS.executed],
};
// Статусы строки, означающие «ход за пользователем» — для счётчиков в меню
// (обе очереди считаются за один проход, см. getMyTurnCounts).
const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
// ---------------------------------------------------------------------------
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
// значение колонки ApprovalStatus, поэтому здесь достаточно ОДНОГО
// отфильтрованного запроса: ApproverId (+ ApprovalStatus для 'myTurn').
// Список согласования больше НЕ сканируется целиком, независимо от его размера.
//
// Заявку (Title/RequestStatus/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).
//
// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов —
// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см.
// searchAcrossApprovalLists), и при архивации в несколько партиций это была
// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11
// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с).
// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см.
// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования.
async function buildPendingIndex(
context: WebPartContext,
scope: ApprovalScope,
statuses: string[]
): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context);
const debugByType = new Map<number, IMyRequestsTypeDebug>();
types.forEach((t) =>
debugByType.set(t.id, {
requestTypeId: t.id,
requestTypeName: t.name,
listId: t.listId,
siteUrl: t.siteUrl,
skipped: false,
})
);
if (!tokenEmail) {
return { types, index: [], debug: types.map((t) => debugByType.get(t.id)!) };
}
// Партиции ВСЕХ типов — в один плоский список: комбинированный Search строит
// Path-условие по ним разом, поэтому дальше нет смысла обходить типы по
// отдельности.
const flat = types.reduce<Array<{ type: IRequestType; target: IListTarget }>>(
(all, t) => all.concat(listTargetsForType(t).map((target) => ({ type: t, target }))),
[]
);
// Адреса списков согласования всех партиций разом: не больше ОДНОГО
// /_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[] = [];
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 = lookup.message;
}
});
// Два источника кандидатов — Search (весь архив) и REST-довесок по свежести
// (то, что индекс мог не успеть подхватить) — независимы друг от друга,
// поэтому идут параллельно, а не один за другим.
const [hitsByTarget, recentByTarget] = await Promise.all([
okEntries.length > 0
? traced('Search: очередь согласования', () =>
searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
)
: Promise.resolve(new Map<string, number[]>()),
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
okEntries.length > 0 && scope === 'myTurn'
? traced('REST-довесок по свежести (очередь)', () =>
fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
)
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
]);
const parts = await traced('дочитывание строк очереди (REST)', () =>
Promise.all(
okEntries.map((entry) =>
finalizeApprovalTarget(
context,
entry,
hitsByTarget.get(targetKey(entry)) ?? [],
recentByTarget.get(targetKey(entry)) ?? []
)
)
)
);
const rowsByType = new Map<number, IMyRequest[]>();
const titlesByType = new Map<number, string[]>();
parts.forEach((part, i) => {
const entry = okEntries[i];
const debug = debugByType.get(entry.type.id);
if (!debug) return;
const bucket = rowsByType.get(entry.type.id) ?? [];
bucket.push(...part.rows);
rowsByType.set(entry.type.id, bucket);
if (part.approvalListTitle) {
const titles = titlesByType.get(entry.type.id) ?? [];
titles.push(part.approvalListTitle);
titlesByType.set(entry.type.id, titles);
}
if (part.error && !debug.approvalError) debug.approvalError = part.error;
debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0);
});
const index: IMyRequest[] = [];
types.forEach((t) => {
const debug = debugByType.get(t.id)!;
const titles = titlesByType.get(t.id);
if (titles && titles.length > 0) debug.approvalListTitle = titles.join(', ');
const typeRows = rowsByType.get(t.id) ?? [];
debug.approvalPendingTickets = typeRows.length;
index.push(...typeRows);
});
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
index.sort((a, b) => b.created.localeCompare(a.created));
return { types, index, debug: types.map((t) => debugByType.get(t.id)!) };
}
/** Результат 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(
context: WebPartContext,
kind: ApprovalQueueKind,
page: number = 1
): Promise<IPendingApprovalPage> {
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
// выборкой по обоим статусам, а разносятся уже по 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]
);
// Отбор по СВЕЖЕМУ статусу своей строки, а не по тому, что отдал индекс.
// Индекс мог отстать, и заявка, по которой решение уже принято, не должна
// висеть во вкладке «ждут вашего согласования» — по этой же причине
// 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: rows.length, debug };
}
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
// сторона через filterPendingByQuery, а страницу и автора берёт из уже
// отфильтрованного результата (см. hydratePendingAuthors).
//
// Область поиска — та же, что и у самой вкладки (статусы очереди уходят в KQL):
// раньше при вводе текста «На согласовании» расширялась до «где я вообще есть в
// маршруте», но теперь для сквозного поиска есть отдельный раздел «Поиск», а
// внутри вкладки предсказуемее искать по тому же набору, который в ней виден.
export async function getApprovalQueueIndex(
context: WebPartContext,
kind: ApprovalQueueKind
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
const { types, index, debug } = await sharedPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
return { rows: index, types, debug };
}
// Полный индекс «где я вообще есть в маршруте», без отбора по статусу — нужен
// только разделу «Поиск» (см. searchMyRequests).
export async function getPendingApprovalIndex(
context: WebPartContext,
scope: ApprovalScope
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
const { types, index, debug } = await sharedPendingIndex(context, scope, []);
return { rows: index, types, debug };
}
/** Счётчики очередей для бокового меню. */
export interface INavCounts {
approvals: number;
execution: number;
rework: number;
}
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
// отдельным запросам значило бы дважды просканировать одни и те же списки
// согласования. Статус своей строки приходит в myStepStatus (см.
// finalizeApprovalTarget), по нему и разносим.
//
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
// вкладку: иначе счётчик и список могли бы разойтись.
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
return perfScope('счётчики меню', () => getNavCountsInner(context));
}
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
const types = await getLinkedTypes(context);
const [turn, rework] = await Promise.all([
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,
};
}
// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
export async function hydratePendingAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
await hydrateAuthors(context, types, rows);
}
// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
// ApproverId → Title/RequestStatus/Created заявки отдельным запросом к списку ДАННЫХ
// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
// произвольные поля связанного элемента, только через Person-поля вроде
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
/** Список согласования одной партиции, готовый к поиску: где искать и куда дочитывать. */
interface IApprovalSearchTarget {
type: IRequestType;
target: IListTarget;
approvalList: IList;
approvalListTitle: string;
/** Абсолютный префикс пути элементов СПИСКА СОГЛАСОВАНИЯ, со слешем на конце. */
approvalListAbsUrl: string;
}
function targetKey(entry: IApprovalSearchTarget): string {
return entry.approvalListAbsUrl.toLowerCase();
}
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
// возвращали 0.
//
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
// без него в выдачу попали бы все строки, где пользователь вообще есть в
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
//
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берёт
// СВЕЖИМИ через REST по этим Id вызывающая сторона (finalizeApprovalTarget), не
// из индекса (индекс мог устареть, а на устаревшем статусе решение «моя
// очередь» строить нельзя).
/**
* ОДИН комбинированный Search по спискам согласования ВСЕХ партиций ВСЕХ типов —
* Path через OR, approver и statuses общие для всех (см. buildPendingIndex, где
* это гарантировано одним вызовом на все типы разом). Тот же приём OR-а по Path
* уже применялся для «Своих заявок» (см. pathClauseForTypes) — раньше здесь
* вместо этого был отдельный Search-запрос НА КАЖДУЮ партицию, и при архивации
* в несколько партиций это было самой дорогой частью загрузки «На согласовании»
* (трассировка 2026-08-18: 11 партиций — 11 отдельных постраничных запросов).
*
* Ошибка одного общего запроса теперь означает «Search недоступен для всех», а
* не для одной партиции — компромисс сознательный: системный сбой Search и
* раньше задевал бы все партиции разом (это один и тот же сервис), а REST-net
* по свежести (recentRows, см. finalizeApprovalTarget) на сбой Search вообще не
* завязан и продолжает работать сам по себе.
*
* Возвращает найденные Id, сгруппированные по списку согласования (targetKey).
*/
async function searchAcrossApprovalLists(
context: WebPartContext,
targets: IApprovalSearchTarget[],
tokenEmail: string,
statuses: string[]
): Promise<Map<string, number[]>> {
const result = new Map<string, number[]>();
try {
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const pathClause = `(${targets.map((t) => `Path:"${t.approvalListAbsUrl}*"`).join(' OR ')})`;
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
if (statuses.length > 0) {
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
}
const kql = clauses.join(' AND ');
// Представитель любого сайта годится — поисковый индекс общий на тенант
// (тот же приём, что в searchOwnRequests: spForSite(types[0].siteUrl, …)).
const sp = spForSite(targets[0].target.siteUrl, context);
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Path'], 'На согласовании (все партиции)');
hits.forEach((h) => {
const id = Number(h.ListItemID);
if (isNaN(id) || id <= 0) return;
const path = String(h.Path || '').toLowerCase();
// Сравнение по границе сегмента (см. listPathPrefix) — тот же приём, что
// в hitsToEntries, но по адресам списков СОГЛАСОВАНИЯ, а не данных.
const matched = targets.filter((t) => path.indexOf(t.approvalListAbsUrl.toLowerCase()) === 0)[0];
if (!matched) return;
const key = targetKey(matched);
const arr = result.get(key) ?? [];
arr.push(id);
result.set(key, arr);
});
return result;
} catch (e) {
console.error('[Текущие заявки] Комбинированный Search по спискам согласования не сработал:', (e as Error).message);
return result;
}
}
/**
* REST-довесок по свежести для очереди согласования: строки маршрута, изменённые
* за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить.
*
* Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая
* в архив, продолжает висеть на согласовании, а решение по ней меняет строки
* маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive).
* Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку
* он увидит не сразу, а только когда её подхватит индекс; именно эту заявку
* фича «архивные заявки тоже согласуются» и должна показывать вовремя.
*
* Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта
* уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в
* approvalList.service.ts/createSteps), поэтому число партиций больше не
* добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и
* с отдельным ensureUser перед каждым.
*/
async function fetchRecentApprovalRows(
context: WebPartContext,
entries: IApprovalSearchTarget[],
tokenEmail: string,
statuses: string[]
): Promise<Map<string, Array<Record<string, unknown>>>> {
const result = new Map<string, Array<Record<string, unknown>>>();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
// Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки,
// без постфильтрации в JS. Кириллица в значении этому не мешает — проверено
// вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS.
const statusFilter =
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
// Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id
// пользователя тоже свой на каждом сайте.
const bySite = new Map<string, IApprovalSearchTarget[]>();
entries.forEach((e) => {
const key = e.target.siteUrl.toLowerCase();
const bucket = bySite.get(key) ?? [];
bucket.push(e);
bySite.set(key, bucket);
});
await Promise.all(
Array.from(bySite.values()).map(async (siteEntries) => {
const siteUrl = siteEntries[0].target.siteUrl;
try {
const sp = spForSite(siteUrl, context);
// ensureUser — один раз на сайт И один раз на сессию: результат кешируется
// (см. shared/approval/siteUser.ts). Раньше он вызывался внутри обработки
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
// POST, а не дешёвый GET; теперь его не платят даже повторные загрузки.
//
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
// проекте) и фильтруем по TansuSiteApproverId.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approverId = await ensureUserIdFor(context, siteUrl, claimsLogin);
const filter = `TansuSiteApproverId eq ${approverId} and Modified ge '${cutoffIso}'${statusFilter}`;
const [batchedSP, execute] = sp.batched();
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
// отказ по одной партиции внутри батча всплыл бы как unhandled rejection
// раньше, чем мы успели бы его перехватить.
const pending = siteEntries.map((entry) =>
batchedSP.web.lists
.getByTitle(entry.approvalListTitle)
.items.filter(filter)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()
.then((rows) => {
result.set(targetKey(entry), rows as Array<Record<string, unknown>>);
})
.catch((e) => {
// Недоступная партиция (архив переименовали/удалили вручную) не
// должна лишать довеска остальные партиции того же сайта.
console.error(
`[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`,
(e as Error).message
);
})
);
await execute();
await Promise.all(pending);
} catch (e) {
console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message);
}
})
);
return result;
}
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
interface IPartitionApprovalResult {
rows: IMyRequest[];
approvalListTitle?: string;
approvalRowCount?: number;
error?: string;
}
/**
* Достраивает результат одной партиции поверх уже собранных кандидатов:
* • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции
* ничего не нашлось или Search целиком не сработал);
* • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме
* текстового поиска и когда за последние RECENT_MS ничего не менялось).
*
* Сама сеть здесь только на дочитывание: собственных «поисковых» запросов
* функция больше не делает — оба источника кандидатов собираются один раз на все
* партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows).
*/
async function finalizeApprovalTarget(
context: WebPartContext,
entry: IApprovalSearchTarget,
searchHitIds: number[],
recentRows: Array<Record<string, unknown>>
): Promise<IPartitionApprovalResult> {
const { type, target, approvalList, approvalListTitle } = entry;
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
// TicketId/Status — СВЕЖИМИ через REST по найденным Id, не из индекса
// Search (см. doc-комментарий у searchAcrossApprovalLists).
const searchRows =
searchHitIds.length === 0
? []
: (
await Promise.all(
// Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK).
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
approvalList.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'TicketId', 'ApprovalStatus')
.top(idsChunk.length)()
)
)
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
// Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только
// то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее,
// чем в индексе, а решение «моя очередь сейчас» строится именно на статусе.
const seenIds = new Set(recentRows.map((r) => r.Id));
const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
debug.approvalRowCount = stepRows.length;
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
// несколько ваших строк (напр. вы согласующий и делегат) — заявка одна.
const ticketIds = Array.from(
new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
);
// Статус СВОЕЙ строки по заявке — нужен, чтобы разнести заявки по очередям
// одним проходом (см. getMyTurnCounts). Если строк по заявке несколько,
// берём первую подошедшую: все они уже отобраны по statuses, а очередь у
// заявки на практике одна.
const stepStatusByTicket = new Map<number, string>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
if (ticketId && !stepStatusByTicket.has(ticketId)) {
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
}
});
if (ticketIds.length === 0) return debug;
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
// комментарий у функции: через 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', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
debug.rows = ticketRows.map((r) => ({
...toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'approval', target),
myStepStatus: stepStatusByTicket.get(r.Id as number),
}));
return debug;
} catch (e) {
debug.error = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}» (${target.listId}):`,
debug.error
);
return debug;
}
}
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — только
// по переданным строкам. Мутирует переданные объекты (проставляет
// authorEmail/authorName).
//
// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо
// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget)
// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда
// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на
// случай источника строк без автора — молча показывать пустого инициатора
// хуже, чем один лишний запрос.
async function hydrateAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
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));
// Группируем по ПАРТИЦИИ строки, а не по её типу: каждая строка помнит, из
// какого списка её прочитали (IMyRequest.listId), и дочитывать автора надо
// оттуда же — в текущей партиции того Id либо нет, либо это другая заявка.
const byPartition = new Map<string, { siteUrl: string; listId: string; typeName: string; ids: number[] }>();
rows.forEach((r) => {
const bucket = byPartition.get(r.listId.toLowerCase()) || {
siteUrl: r.siteUrl,
listId: r.listId,
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
ids: [],
};
bucket.ids.push(r.id);
byPartition.set(r.listId.toLowerCase(), bucket);
});
const byKey = new Map<string, { EMail?: string; Title?: string }>();
await Promise.all(
Array.from(byPartition.values()).map(async ({ siteUrl, listId, typeName, ids }) => {
try {
const sp = spForSite(siteUrl, context);
const authorRows = (await sp.web
.getList(listId)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ids.length)()) as Array<Record<string, unknown>>;
authorRows.forEach((r) => {
byKey.set(
requestKey(listId, r.Id as number),
(r.Author as { EMail?: string; Title?: string }) || {}
);
});
} catch (e) {
console.error(
`[Текущие заявки] Не удалось дочитать автора для «${typeName}» (${listId}):`,
(e as Error).message
);
}
})
);
rows.forEach((r) => {
const a = byKey.get(rowKey(r));
r.authorEmail = a?.EMail || '';
r.authorName = a?.Title || '';
});
}
// ---------------------------------------------------------------------------
// Поиск через SharePoint Search REST API (search/query, KQL).
//
// Текст запроса идёт свободным термом — поиск по ВСЕМ проиндексированным полям
// элемента, а не только по названию. Плата за это — результаты берутся из
// поискового индекса, который переиндексируется с задержкой: только что
// созданная или изменённая заявка может найтись не сразу.
export interface IOwnRequestsPage {
rows: IMyRequest[];
totalRows: number;
}
const PAGE_SIZE = 20;
// Сколько заявок-кандидатов максимум перечисляем в ОДНОМ KQL-запросе при
// поиске среди уже отобранных (см. filterPendingByQuery) — не потолок
// итогового результата, а размер чанка: кандидатов режем на такие пачки и
// прогоняем каждую отдельным запросом, чтобы строка запроса не разрослась
// до неподъёмной, но при этом ни один кандидат не терялся.
const KQL_ID_LIMIT = 300;
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) {
result.push(items.slice(i, i + size));
}
return result;
}
// Собрать ВЕСЬ результат Search по запросу, а не одну страницу: сам
// SharePoint Search REST API ограничивает RowLimit одного вызова (см.
// SEARCH_PAGE_SIZE), поэтому при большом совпадающем наборе перебираем
// StartRow, пока индекс не перестанет отдавать новые строки или пока не
// упрёмся в SEARCH_HARD_CAP (аварийный потолок, а не ожидаемый лимит — при
// его достижении пишем предупреждение и отдаём то, что успели собрать).
async function fetchAllSearchRows(
sp: SPFI,
kql: string,
selectProperties: string[],
label: string,
sortList?: Array<{ Property: string; Direction: number }>
): Promise<Array<Record<string, unknown>>> {
const rows: Array<Record<string, unknown>> = [];
let startRow = 0;
for (;;) {
// Постраничной метки здесь больше нет: замер 2026-08-20 показал, что запрос
// ровно один (847 мс у вызывающего против 846 мс у страницы), то есть
// медленный сам Search, а не перебор StartRow. Вопрос закрыт, а метка
// занимала место в строке сводки, дублируя метку вызывающего.
const results = await sp.search({
Querytext: kql,
RowLimit: SEARCH_PAGE_SIZE,
StartRow: startRow,
SelectProperties: selectProperties,
TrimDuplicates: false,
...(sortList ? { SortList: sortList } : {}),
});
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
rows.push(...page);
const total = results.TotalRows || 0;
startRow += page.length;
if (page.length === 0 || startRow >= total) break;
if (rows.length >= SEARCH_HARD_CAP) {
console.warn(
`[Текущие заявки] ${label}: индекс отдал ${total} строк — забрали только первые ${rows.length} (аварийный потолок)`
);
break;
}
}
return rows;
}
function listAbsUrl(type: IRequestType): string {
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
}
// Префикс пути для KQL-условия Path и для сопоставления хитов с типом.
//
// Обязательно СО слешем на конце: адреса списков в этом сайте отличаются только
// номером (…/Lists/List1, …/Lists/List10), и «голый» префикс List1 совпадает с
// путями элементов List10. В KQL это притягивало чужие элементы, а при разборе
// ответа хит уезжал в первый подошедший тип — заявки «Обходного листа»
// приписывались списку List1, дальше их Id искались не в том списке и строки
// молча исчезали из выдачи.
function listPathPrefix(type: IRequestType): string {
return `${listAbsUrl(type).replace(/\/+$/, '')}/`;
}
/** Один конкретный список серии: куда идти читать и какому префиксу пути он соответствует. */
export interface IListTarget {
/** Серверный относительный url списка данных. */
listId: string;
siteUrl: string;
/** Абсолютный префикс пути элементов этого списка, со слешем на конце. */
prefix: string;
/**
* Title списка данных на момент регистрации партиции (см. IListPartition.title) —
* уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при
* старте страницы. Заполнен только когда партиция известна реестру; для
* фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда
* resolveApprovalSearchTarget сам дочитает Title через REST.
*/
title?: string;
}
/**
* ВСЕ партиции типа заявки (архив + текущая) как цели чтения.
*
* Заявки одного типа живут не в одном списке, а в нумерованной серии: когда
* текущий список добирает порог элементов, approval-route создаёт следующую пару
* и переключает запись на неё. Читать нужно по всей серии, иначе заявки из архива
* пропадут из выдачи — а RequestType.listId указывает только на ТЕКУЩУЮ партицию.
*
* Фолбэк на listId, если реестр партиций не загрузился (approval-route не
* ответил при старте): тогда всё ведёт себя как раньше — по одному списку.
*/
export function listTargetsForType(type: IRequestType): IListTarget[] {
const partitions = partitionsForTypeId(type.id);
if (partitions.length === 0) {
return [
{ listId: type.listId as string, siteUrl: type.siteUrl as string, prefix: listPathPrefix(type) }
];
}
return partitions.map((p) => ({
listId: p.listId,
siteUrl: p.siteUrl,
prefix: `${siteOrigin(p.siteUrl).replace(/\/+$/, '')}${p.listId.replace(/\/+$/, '')}/`,
title: p.title
}));
}
/** KQL-условие Path по всем партициям всех переданных типов. */
function pathClauseForTypes(types: IRequestType[]): string {
const prefixes = types.reduce<string[]>(
(all, t) => all.concat(listTargetsForType(t).map((target) => target.prefix)),
[]
);
return `(${prefixes.map((prefix) => `Path:"${prefix}*"`).join(' OR ')})`;
}
/**
* Партиция, которой принадлежит хит — по префиксу его пути.
*
* Именно партиция, а не только тип: перечитывать элемент нужно из ТОГО ЖЕ списка,
* где его нашёл Search. Один и тот же Id существует в каждой партиции серии
* (нумерация элементов в каждом списке SharePoint своя), поэтому «тип + Id» не
* идентифицирует заявку однозначно.
*/
export function targetForPath(path: string, type: IRequestType): IListTarget | undefined {
const lowerPath = path.toLowerCase();
return listTargetsForType(type).filter((t) => lowerPath.indexOf(t.prefix.toLowerCase()) === 0)[0];
}
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ одного из его
// списков — сравнение по границе сегмента, не по «голому» началу строки.
export function pathBelongsToList(path: string, type: IRequestType): boolean {
return !!targetForPath(path, type);
}
/**
* Ключ идентичности заявки — ПАРТИЦИЯ + Id, а не тип + Id.
*
* Нумерация элементов в каждом списке SharePoint своя, поэтому Id 5 существует и
* в партиции №1, и в №2 — это разные заявки. Ключ по типу схлопнул бы их в одну.
*/
function requestKey(listId: string, id: number): string {
return `${listId.toLowerCase()}-${id}`;
}
function rowKey(row: IMyRequest): string {
return requestKey(row.listId, row.id);
}
/**
* Партиция, в которую идёт ЗАПИСЬ — она же единственная, где могут появиться
* свежие заявки. Нужна довескам по свежести: перебирать ради них весь архив
* бессмысленно (там по определению ничего нового), а Search старое и так найдёт.
*
* RequestType.listId всегда указывает на текущую партицию — approval-route
* переключает его, создавая следующую.
*/
function currentTargetForType(type: IRequestType): IListTarget {
const targets = listTargetsForType(type);
const current = targets.filter((t) => t.listId.toLowerCase() === (type.listId || '').toLowerCase())[0];
return current ?? targets[targets.length - 1];
}
// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
// цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает)
function sanitizeForKql(text: string): string {
return text
.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.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 ')})`;
}
// Ответ Search → пары (Id элемента, его тип по префиксу Path).
//
// Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах
// стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии —
// стандартная дедупликация Search схлопывает по содержимому, а не по Id, и
// вместе с ними может случайно схлопнуть две по-настоящему разные, но похожие
// заявки), а без неё индекс может вернуть один и тот же элемент несколькими
// хитами (переиндексация, несколько «версий» в crawl). Раньше хиты
// превращались в строки один-в-один, и дубликат из индекса становился
// дубликатом строки в таблице — с одинаковым React-ключом, то есть видимым
// «залипанием» одной и той же заявки дважды.
function hitsToEntries(
results: { PrimarySearchResults: unknown[] },
types: IRequestType[]
): ISearchEntry[] {
const seen = new Set<string>();
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || ''), raw: r }))
.filter((h) => !isNaN(h.id) && h.id > 0)
.map((h) => {
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
// indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве.
//
// Ищем не только тип, но и КОНКРЕТНУЮ партицию: перечитывать элемент нужно
// из того же списка, где его нашёл Search (см. targetForPath).
for (const type of types) {
const target = targetForPath(h.path, type);
if (target) return { id: h.id, type, target, raw: h.raw };
}
return undefined;
})
.filter((e): e is ISearchEntry => !!e)
.filter((e) => {
const key = requestKey(e.target.listId, e.id);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/**
* Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка
* ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES
* и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно.
*/
interface ISearchEntry {
id: number;
type: IRequestType;
target: IListTarget;
raw: Record<string, unknown>;
}
/**
* Строка «Моих заявок» ПРЯМО из ответа 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-ом, в обход
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ
// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
// Только ТЕКУЩАЯ партиция: это довесок по свежести, а свежие заявки бывают
// только там, куда идёт запись. Архивные придут из Search (он ищет по всей
// серии, см. pathClauseForTypes).
const target = currentTargetForType(type);
try {
const sp = spForSite(target.siteUrl, context);
// Кешированный 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 ${myId}`)
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.orderBy('Created', false)
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
if (rows.length === OWN_REST_TOP) {
console.warn(
`[Текущие заявки] «${type.name}»: REST-довесок упёрся в ${OWN_REST_TOP} заявок — более старые черновики сюда не попадут`
);
}
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
);
} catch (e) {
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
return [];
}
}
// Свежие (созданные за последние RECENT_MS) заявки автора — довесок к Search,
// индекс мог их ещё не подхватить.
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
// черновик в «Моих заявках» пользователь ожидает сразу.
return (await getOwnRowsForType(context, type)).filter(
(r) => r.status === REQUEST_STATUS.draft || r.created >= cutoff
);
}
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
// вернуть на доработку неделю спустя после создания — по Created такую не
// найти, а Modified у неё обновится ровно в момент решения согласующего
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
// (в отличие от статуса, см. getOwnRequestsByStatus).
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
// Только текущая партиция — см. getOwnRowsForType: недавно изменённое лежит там же.
const target = currentTargetForType(type);
try {
const sp = spForSite(target.siteUrl, context);
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 ${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>>;
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
(e as Error).message
);
return [];
}
}
// Итог слияния 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,
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPromise = searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPromise;
const freshPromise = Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))).then((r) =>
r.reduce((all, x) => all.concat(x), [] as IMyRequest[])
);
return raceThenMerge(searchPromise, freshPromise, onPartial);
}
// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть:
// только что созданная заявка может найтись не сразу (задержка индексации), но
// зато результат предсказуем — не зависит от 10-минутного REST-окна.
export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`${MP_AUTHOR}:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await traced('Search: свои заявки', () =>
sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
SortList: [{ Property: 'Created', Direction: 1 }],
})
);
const totalRows = results.TotalRows || 0;
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
// здесь больше нет. Порядок задаёт релевантность поиска.
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
return { rows, totalRows };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
// Статус уходит прямо в KQL через MP_REQUEST_STATUS (проверено вручную
// 2026-08-17, см. комментарий у константы) — это настоящий серверный запрос
// с RowLimit/StartRow, как и searchOwnRequests, а не «стянуть всё и
// отфильтровать в памяти».
export async function getOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
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 };
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`${MP_AUTHOR}:"${claimsLogin}"`,
`${MP_REQUEST_STATUS}:"${status}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const searchPromise: Promise<IOwnRequestsPage> = traced('Search: свои заявки', () =>
sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
// Свежие сверху — как в searchOwnRequests.
SortList: [{ Property: 'Created', Direction: 1 }],
})
).then((results) => ({
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
totalRows: results.TotalRows || 0,
}));
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
// вернуть на доработку только что, индекс мог не успеть её подхватить.
// Запускаем СРАЗУ, не дожидаясь Search: условие включения известно из
// параметров вызова, ждать ради него ответ Search незачем — раньше довесок
// стартовал только ПОСЛЕ Search, хотя независим от его результата.
if (page !== 1 || query.trim()) return searchPromise;
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)
);
return raceThenMerge(searchPromise, freshPromise, onPartial);
}
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
const DECISION_COMMENT_ROW_LIMIT = 2000;
/**
* Проставляет decisionComment — комментарий согласующего к его решению по
* заявке: причину возврата на доработку либо отклонения (что именно искать,
* задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
* Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
* получить тем же запросом, что и список: нужен отдельный проход по спискам
* согласования.
*
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
*
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
* Мутирует переданные строки — как и hydrateAuthors рядом.
*/
export async function hydrateDecisionComments(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
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));
// По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
// согласования той же партиции, что и сама заявка. Для архивной заявки
// «список согласования её типа» — это чужой список текущей партиции, где по
// её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
rows.forEach((r) => {
const key = r.listId.toLowerCase();
const bucket = rowsByPartition.get(key) || {
target: { listId: r.listId, siteUrl: r.siteUrl, prefix: '' },
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
rows: [],
};
bucket.rows.push(r);
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 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», без скобок фильтр разобрался бы как
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ');
const stepRows = (await approvalList.items
.filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`)
.select('TicketId', 'Comment')
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
const commentByTicket = new Map<number, string>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
const comment = ((r.Comment as string) || '').trim();
// Такая строка по заявке одна (и возврат, и отклонение останавливают
// маршрут, а при переотправке он переписывается целиком — см.
// writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ,
// а не последнюю пустую.
if (ticketId && comment && !commentByTicket.has(ticketId)) {
commentByTicket.set(ticketId, comment);
}
});
typeRows.forEach((r) => {
r.decisionComment = commentByTicket.get(r.id);
});
} catch (e) {
console.error(
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать комментарий к решению:`,
(e as Error).message
);
}
})
);
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по
// всем полям), без клиентского REST-матчинга по свежести.
//
// Кандидатов режем на чанки по KQL_ID_LIMIT и гоняем каждый отдельным
// запросом (см. chunk/fetchAllSearchRows) — раньше «лишние» сверх лимита
// кандидаты молча выпадали из поиска, теперь ни один не теряется, просто
// уходит несколькими запросами вместо одного.
export async function filterPendingByQuery(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
query: string
): Promise<IMyRequest[]> {
const text = freeTextClause(query);
if (!text || rows.length === 0) return rows;
const scopeTypes = types.filter((t) => rows.some((r) => r.requestTypeId === t.id));
if (scopeTypes.length === 0) return [];
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
const pathClause = pathClauseForTypes(scopeTypes);
const found = new Set<string>();
await Promise.all(
chunk(rows, KQL_ID_LIMIT).map(async (candidates) => {
const kql = [
pathClause,
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
text,
].join(' AND ');
const hits = await fetchAllSearchRows(
sp,
kql,
['ListItemID', 'Title', 'Path'],
'Поиск среди ждущих решения'
);
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
found.add(requestKey(e.target.listId, e.id))
);
})
);
return rows.filter((r) => found.has(rowKey(r)));
}
// ---------------------------------------------------------------------------
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
// раздела одно из двух не нужно).
//
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
// единой серверной пагинации на двух источниках сразу не построить. Для
// сценария «искал что-то один раз» это приемлемо.
export interface ISearchPage {
rows: IMyRequest[];
totalRows: number;
}
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез
// (см. fetchAllSearchRows — весь, а не первые KQL_ID_LIMIT: тот лимит здесь
// больше не действует, это чанк-размер для другой функции, filterPendingByQuery).
// Только Search, без REST-подстраховки на свежесть.
export async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<IMyRequest[]> {
if (types.length === 0) return [];
const sp = spForSite(types[0].siteUrl as string, context);
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`${MP_AUTHOR}:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
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 rows = entries.map(toMyRequestFromSearch);
traceResultRows('Свои заявки', types, entries, rows);
return rows;
}
/**
* ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает
* вызывающая сторона.
*
* Именно так, а не «страницу за вызов»: набор всё равно собирается целиком
* (двумя источниками + слияние + сортировка), и раньше 95% этой работы
* выбрасывалось ради 20 строк — а при переходе на следующую страницу весь поиск
* запускался заново. Теперь SearchView кеширует список по ЗАПРОСУ, и листание
* страниц не стоит ни одного запроса.
*
* Автор здесь НЕ дочитывается: у строк 'approval' он требует отдельного запроса,
* и делать его на весь набор (до KQL_ID_LIMIT) значило бы собирать $filter из
* сотен Id — SharePoint такой длины URL не переварит. Автора дочитывает
* вызывающая сторона для видимой страницы (hydratePendingAuthors).
*/
export async function searchMyRequests(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<ISearchPage> {
const q = query.trim();
if (!q) return { rows: [], totalRows: 0 };
const [ownRows, involvedIdx] = await Promise.all([
searchOwnAll(context, types, q),
getPendingApprovalIndex(context, 'involved'),
]);
const involvedFiltered = await filterPendingByQuery(context, involvedIdx.types, involvedIdx.rows, q);
// Заявка может оказаться и своей, и involved одновременно (сам себе
// согласующий) — оставляем как 'own', не дублируем строку.
const ownKeys = new Set(ownRows.map((r) => rowKey(r)));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(rowKey(r)));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
return { rows: merged, totalRows: merged.length };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval',
target: IListTarget
): IMyRequest {
return {
id: r.Id as number,
code: (r.RequestUniqueCode as string) || '',
title: (r.Title as string) || '',
status: (r.RequestStatus as string) || '',
authorEmail: author?.EMail || '',
authorName: author?.Title || '',
created: (r.Created as string) || '',
requestTypeId: type.id,
requestTypeName: type.name,
section: type.section,
formKey: type.pageUrl,
kind,
listId: target.listId,
siteUrl: target.siteUrl,
};
}