Files
request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
T
gorbenko.pavel 63a610655f мердж
2026-08-05 14:08:10 +05:00

745 lines
37 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, SPFx } from '@pnp/sp';
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';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType,
// filterPendingByQuery).
const RECENT_MS = 10 * 60 * 1000;
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
const types = await getRequestTypes(context);
return types.filter((t) => !!t.listId && !!t.siteUrl);
}
// Что показывать в разделе «На согласовании»:
// 'myTurn' — только заявки, где очередь дошла до текущего пользователя
// (обычный режим, когда поиск не задан);
// 'involved' — все заявки, где он вообще есть в маршруте (уже согласовал,
// ждёт своей очереди и т.п.) — так поиск ищет по всему, в чём
// пользователь участвует или будет участвовать.
export type ApprovalScope = 'myTurn' | 'involved';
// ---------------------------------------------------------------------------
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
// проставляется при создании/решении (см. 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) => indexApprovalsForType(context, t, tokenEmail, scope))
);
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
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) };
}
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
// заявок отдать), автор дочитывается только для них.
export async function getPendingApproval(
context: WebPartContext,
scope: ApprovalScope = 'myTurn',
page: number = 1
): Promise<IPendingApprovalPage> {
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<void> {
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,
scope: ApprovalScope
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: false,
};
try {
if (!tokenEmail) return { rows: [], debug };
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const list = sp.web.getList(type.listId as string);
const dataListInfo = await list.select('Title')();
const dataTitle = (dataListInfo.Title as string) || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
return { rows: [], debug };
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
// Approver/EMail eq '...' в $filter по Person-полю ненадёжен в SharePoint
// REST — резолвим email в числовой Id пользователя (как и везде в проекте,
// см. ensureUser) и фильтруем по ApproverId.
//
// Status в $filter НЕ используем: подтверждено на практике — фильтр
// `Status eq 'На согласовании'` (кириллица в $filter) молча возвращает 0
// строк, даже когда без него те же строки находятся с этим же значением
// Status. Поэтому фильтруем только по ApproverId (числовое значение,
// проблем нет), а Status сравниваем в JS после получения строк.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
let stepRows: Array<Record<string, unknown>>;
if (scope === 'myTurn') {
// 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status должен быть
// гарантированно свежим (индекс Search обновляется с задержкой и мог бы
// показать уже решённый шаг как ожидающий). Поэтому здесь только REST,
// весь список по ApproverId, без архивного Search-довеска.
const allRows: Array<Record<string, unknown>> = [];
for await (const p of approvalList.items
.filter(`ApproverId eq ${approver.Id}`)
.select('Id', 'TicketId', 'Status')
.top(2000)) {
allRows.push(...(p as Array<Record<string, unknown>>));
}
stepRows = allRows.filter(
(r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution
);
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
);
} else {
// 'involved' — просто «участвовал ли вообще», Status не важен для
// истинности результата, поэтому здесь можно рискнуть Search-ом для
// архивной (не только что созданной/изменённой) части: свежее по
// Modified — REST, остальное — Search по managed property ApproverOWSUSER
// (кастомное поле, не гарантировано, что проиндексировано — см. лог).
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const recentRows = (await approvalList.items
.filter(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'TicketId', 'Status')
.top(2000)()) as Array<Record<string, unknown>>;
const archivalRows = await getArchivalInvolvedRows(context, type, approvalList, claimsLogin);
const seenIds = new Set(recentRows.map((r) => r.Id));
stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id)));
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}`
);
}
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status })));
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))
);
if (ticketIds.length === 0) {
debug.approvalPendingTickets = 0;
return { rows: [], debug };
}
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
// комментарий у функции: через Lookup-expand это не отдаётся).
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
.select('Id', 'Title', 'Status', 'Created')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
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;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return { rows: [], debug };
}
}
// Архивная (не только что созданная/изменённая) часть 'involved' — через
// Search по managed property ApproverOWSUSER (кастомное Person-поле, НЕТ
// гарантии, что проиндексировано без ручной настройки Search Schema — если
// лог покажет «найдено: 0» при заведомо непустом архиве, значит не сработало
// и стоит вернуться на чистый REST для этой ветки).
//
// Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём
// свежими через REST по этим Id, а не из индекса (индекс мог устареть).
async function getArchivalInvolvedRows(
context: WebPartContext,
type: IRequestType,
approvalList: IList,
claimsLogin: string
): Promise<Array<Record<string, unknown>>> {
try {
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 sp = spfi(type.siteUrl as string).using(SPFx(context));
const kql = `Path:"${approvalListAbsUrl}*" AND ApproverOWSUSER:"${claimsLogin}"`;
const results = await sp.search({
Querytext: kql,
RowLimit: 500,
SelectProperties: ['ListItemID'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «${type.name}» (архив, involved): KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const ids = (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => Number(r.ListItemID))
.filter((id) => !isNaN(id) && id > 0);
if (ids.length === 0) return [];
return (await approvalList.items
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'TicketId', 'Status')
.top(ids.length)()) as Array<Record<string, unknown>>;
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`,
(e as Error).message
);
return [];
}
}
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
async function hydrateAuthors(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
if (rows.length === 0) return;
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const idsByType = new Map<number, number[]>();
rows.forEach((r) => {
const ids = idsByType.get(r.requestTypeId) || [];
ids.push(r.id);
idsByType.set(r.requestTypeId, ids);
});
const byKey = new Map<string, { EMail?: string; Title?: string }>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
try {
const sp = spfi(type.siteUrl as string).using(SPFx(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<Record<string, unknown>>;
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 || '';
});
}
// ---------------------------------------------------------------------------
// Поиск через 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 isRecent(createdIso: string): boolean {
if (!createdIso) return false;
const t = new Date(createdIso).getTime();
return !isNaN(t) && Date.now() - t <= RECENT_MS;
}
// Замена свободному терму Search для случаев, когда матчим на клиенте (без
// индекса): все слова запроса должны встретиться в названии (без учёта
// регистра). Не идентично семантике Search (там — префикс, тут — подстрока),
// но для «довеска» из нескольких свежих строк разница не принципиальна.
function matchesQueryTitle(title: string, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const t = title.toLowerCase();
return q.split(/\s+/).filter(Boolean).every((word) => t.indexOf(word) !== -1);
}
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(type: IRequestType): string {
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
}
// 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).
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<Record<string, unknown>>)
.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);
}
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
// кастомных полей нет гарантии, что они проиндексированы как managed property).
async function loadByIds(
context: WebPartContext,
entries: Array<{ id: number; type: IRequestType }>,
kind: 'own' | 'approval'
): Promise<Map<string, IMyRequest>> {
const idsByType = new Map<IRequestType, number[]>();
entries.forEach((e) => {
const ids = idsByType.get(e.type) || [];
ids.push(e.id);
idsByType.set(e.type, ids);
});
const byKey = new Map<string, IMyRequest>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => {
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ids.length)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind));
});
})
);
return byKey;
}
// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом,
// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на
// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже
// подводил на Status (см. indexApprovalsForType), поэтому в $filter только
// нечувствительные к этому условия (AuthorId/Created).
async function getFreshOwnRowsForType(
context: WebPartContext,
type: IRequestType,
query: string
): Promise<IMyRequest[]> {
try {
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const me = await sp.web.currentUser.select('Id')();
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Created ge '${cutoff}'`)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(50)()) as Array<Record<string, unknown>>;
return rows
.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'))
.filter((r) => matchesQueryTitle(r.title, query));
} catch (e) {
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
return [];
}
}
// «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий
// пользователь, текст запроса по всем полям. Пагинация серверная
// (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок.
//
// На первой странице довешиваем то, что Search мог ещё не проиндексировать
// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и
// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так
// оказалось бы на первой странице, будь оно проиндексировано.
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
SortList: [{ Property: 'Created', Direction: 1 }],
});
console.log(`[Текущие заявки] Search «Мои заявки»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
const searchRows = entries
.map((e) => byKey.get(`${e.type.id}-${e.id}`))
.filter((r): r is IMyRequest => !!r);
if (page !== 1) {
return { rows: searchRows, totalRows: results.TotalRows || 0 };
}
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
if (onlyFresh.length > 0) {
console.log(
`[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл`
);
}
const merged = onlyFresh
.concat(searchRows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: (results.TotalRows || 0) + onlyFresh.length };
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья
// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту.
//
// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать —
// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search.
// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст
// идёт свободным термом по всем полям. Порядок сохраняется (rows уже
// отсортированы по дате в buildPendingIndex, свежие — в начале).
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 fresh = rows.filter((r) => isRecent(r.created));
const older = rows.filter((r) => !isRecent(r.created));
const freshMatches = fresh.filter((r) => matchesQueryTitle(r.title, query));
if (older.length === 0) return freshMatches;
if (older.length > KQL_ID_LIMIT) {
console.warn(
`[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${older.length}`
);
}
const candidates = older.slice(0, KQL_ID_LIMIT);
const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id));
if (scopeTypes.length === 0) return freshMatches;
const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context));
const kql = [
`(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
text,
].join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: KQL_ID_LIMIT,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «На согласовании»: KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
const olderMatches = candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
return freshMatches.concat(olderMatches);
}
// ---------------------------------------------------------------------------
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
// раздела одно из двух не нужно).
//
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
// единой серверной пагинации на двух источниках сразу не построить. Для
// сценария «искал что-то один раз» это приемлемо.
export interface ISearchPage {
rows: IMyRequest[];
totalRows: number;
}
// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST.
async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<IMyRequest[]> {
if (types.length === 0) return [];
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: KQL_ID_LIMIT,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
return onlyFresh.concat(searchRows);
}
export async function searchMyRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): 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) => `${r.requestTypeId}-${r.id}`));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
const totalRows = merged.length;
const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с
// автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author).
const needAuthor = pageRows.filter((r) => r.kind === 'approval');
if (needAuthor.length > 0) {
await hydratePendingAuthors(context, involvedIdx.types, needAuthor);
}
return { rows: pageRows, totalRows };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval'
): IMyRequest {
return {
id: r.Id as number,
title: (r.Title as string) || '',
status: (r.Status 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,
};
}