Files
request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
T
2026-08-10 18:18:42 +05:00

1035 lines
56 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 { spForSite } from '../../shared/pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn,
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
const RECENT_MS = 10 * 60 * 1000;
// Сколько строк согласования максимум забираем из Search за один запрос
// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор
// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении
// лимита пишем предупреждение в лог, а не молча обрезаем.
const SEARCH_ROW_LIMIT = 500;
// Сколько последних заявок автора дочитываем через 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 списка ДАННЫХ — это статус ЗАЯВКИ целиком.
//
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
// после переименования Status → RequestStatus ещё не переиндексирована), и
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
// AuthorOWSUSER, статус ни при чём.
const MP_STATUS = 'ApprovalStatusOWSCHCS';
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';
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
// Отличаются только набором статусов его строки — сам механизм (найти свои
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
// зашиты в indexApprovalsForType.
//
// 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).
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 perType = await Promise.all(
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
);
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
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 getApprovalQueue(
context: WebPartContext,
kind: ApprovalQueueKind,
page: number = 1
): Promise<IPendingApprovalPage> {
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
await hydrateAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: index.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 buildPendingIndex(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 buildPendingIndex(context, scope, []);
return { rows: index, types, debug };
}
/** Счётчики очередей для бокового меню. */
export interface INavCounts {
approvals: number;
execution: number;
rework: number;
}
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
// отдельным запросам значило бы дважды просканировать одни и те же списки
// согласования. Статус своей строки приходит в myStepStatus (см.
// indexApprovalsForType), по нему и разносим.
//
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
// вкладку: иначе счётчик и список могли бы разойтись.
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
const types = await getLinkedTypes(context);
const [turn, rework] = await Promise.all([
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
]);
return {
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length,
rework: rework.totalRows,
};
}
// Публичная обёртка над 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), только для строк итоговой страницы.
async function indexApprovalsForType(
context: WebPartContext,
type: IRequestType,
tokenEmail: string,
scope: ApprovalScope,
statuses: string[]
): 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 = spForSite(type.siteUrl as string, 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);
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
//
// ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр
// вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча
// возвращает 0 строк, даже когда без него те же строки находятся с этим же
// значением статуса. Поэтому фильтруем только по TansuSiteApproverId
// (числовое значение, проблем нет), а статус сравниваем в JS после
// получения строк.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
let stepRows: Array<Record<string, unknown>>;
if (scope === 'myTurn') {
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
// большим): Search находит кандидатов, не сканируя список целиком, плюс
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
// не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по
// найденным Id (не из индекса Search) — именно это делает
// getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда
// точное, даже когда список кандидатов пришёл из Search.
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const recentRows = (await approvalList.items
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()) as Array<Record<string, unknown>>;
// Статусы уходят в KQL: сервер сразу отдаёт только нужные строки,
// а не всё, где пользователь есть в маршруте.
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
const seenIds = new Set(recentRows.map((r) => r.Id));
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
// REST-довесок берётся по Modified без учёта статуса (кириллица в $filter
// ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте.
stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1);
} else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
// свежесть, а полнота по всему архиву, поэтому только Search, без
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
// давал неустойчивый результат (заявка то находилась, то нет, в
// зависимости от того, успел ли Search её проиндексировать к моменту
// REST-окна).
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
}
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) {
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', 'RequestStatus', 'Created')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
const rows = ticketRows.map((r) => ({
...toMyRequest(r, undefined, type, 'approval'),
myStepStatus: stepStatusByTicket.get(r.Id as number),
}));
debug.approvalPendingTickets = rows.length;
return { rows, debug };
} catch (e) {
debug.approvalError = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return { rows: [], debug };
}
}
// Строки согласования текущего пользователя через Search, чтобы не сканировать
// список целиком (он растёт с каждой заявкой).
//
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
// возвращали 0.
//
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
// без него в выдачу попали бы все строки, где пользователь вообще есть в
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
//
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на
// устаревшем статусе решение «моя очередь» строить нельзя).
async function getInvolvedRowsViaSearch(
context: WebPartContext,
type: IRequestType,
approvalList: IList,
statuses: string[] = []
): Promise<Array<Record<string, unknown>>> {
try {
const email = currentUserEmail(context);
if (!email) {
console.warn(
`[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен`
);
return [];
}
const claimsLogin = `i:0#.f|membership|${email}`;
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 = spForSite(type.siteUrl as string, context);
const pathClause = `Path:"${approvalListAbsUrl}*"`;
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
if (statuses.length > 0) {
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
}
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: SEARCH_ROW_LIMIT,
SelectProperties: ['ListItemID'],
TrimDuplicates: false,
});
const total = results.TotalRows || 0;
const ids = (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => Number(r.ListItemID))
.filter((id) => !isNaN(id) && id > 0);
if (total > SEARCH_ROW_LIMIT) {
console.warn(
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
);
}
if (ids.length === 0) return [];
return (await approvalList.items
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'TicketId', 'ApprovalStatus')
.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 = spForSite(type.siteUrl as string, context);
const authorRows = (await sp.web
.getList(type.listId as string)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ids.length)()) as Array<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 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 = spForSite(type.siteUrl as string, context);
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', '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;
}
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
// по статусу делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id}`)
.select('Id', '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')
);
} 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[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.select('Id', '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')
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
(e as Error).message
);
return [];
}
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по
// дате там и оказалось бы, будь оно проиндексировано.
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPage;
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
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-подстраховки на свежесть:
// только что созданная заявка может найтись не сразу (задержка индексации), но
// зато результат предсказуем — не зависит от 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 = [
`(${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 }],
});
const totalRows = results.TotalRows || 0;
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
return { rows, totalRows };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
// Статус НЕ уходит в KQL: единственная надёжная managed property здесь —
// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки
// имя managed property надо либо знать из схемы поиска, либо угадывать по
// конвенции — угадали неверно (или она ещё не переиндексирована после
// переименования колонки Status → RequestStatus), из-за чего фильтр по
// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору
// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до
// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по
// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds).
// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по
// надёжному полю, остальное — на клиенте.
export async function getOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
query: string = ''
): Promise<IOwnRequestsPage> {
const all = await searchOwnAll(context, types, query);
const searchFiltered = all.filter((r) => r.status === status);
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (см. getOwnRequests: тот же принцип — довесок относится
// к «показать всё, свежее по дате», а не к результату полнотекстового поиска).
let merged = searchFiltered;
if (page === 1 && !query.trim()) {
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`));
merged = onlyFresh.concat(searchFiltered);
}
const sorted = merged.sort((a, b) => b.created.localeCompare(a.created));
return {
rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
totalRows: sorted.length,
};
}
// Список согласования по типу заявки: Title списка данных → Title списка
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
// undefined — список заведён не по конвенции, маршрута для него не найти.
async function approvalListFor(
context: WebPartContext,
type: IRequestType
): Promise<IList | undefined> {
const sp = spForSite(type.siteUrl as string, context);
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
const dataTitle = info.Title || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
}
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
const DECISION_COMMENT_ROW_LIMIT = 2000;
/**
* Проставляет decisionComment — комментарий согласующего к его решению по
* заявке: причину возврата на доработку либо отклонения (что именно искать,
* задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
* Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
* получить тем же запросом, что и список: нужен отдельный проход по спискам
* согласования.
*
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
*
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
* Мутирует переданные строки — как и hydrateAuthors рядом.
*/
export async function hydrateDecisionComments(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[],
decisionStatus: string
): Promise<void> {
if (rows.length === 0) return;
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const rowsByType = new Map<number, IMyRequest[]>();
rows.forEach((r) => {
const list = rowsByType.get(r.requestTypeId) || [];
list.push(r);
rowsByType.set(r.requestTypeId, list);
});
await Promise.all(
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
try {
const approvalList = await approvalListFor(context, type);
if (!approvalList) return;
// Фильтруем только по TicketId (числовое поле), статус сравниваем в JS:
// кириллица в $filter по Choice-колонке молча возвращает 0 строк —
// ровно та же причина, что и в indexApprovalsForType.
const stepRows = (await approvalList.items
.filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '))
.select('TicketId', 'ApprovalStatus', 'Comment')
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
const commentByTicket = new Map<number, string>();
stepRows.forEach((r) => {
if (r.ApprovalStatus !== decisionStatus) return;
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(
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
(e as Error).message
);
}
})
);
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по
// всем полям), без клиентского REST-матчинга по свежести.
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;
if (rows.length > KQL_ID_LIMIT) {
console.warn(
`[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}`
);
}
const candidates = rows.slice(0, KQL_ID_LIMIT);
const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id));
if (scopeTypes.length === 0) return [];
const sp = spForSite(scopeTypes[0].siteUrl as string, 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,
});
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
}
// ---------------------------------------------------------------------------
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
// раздела одно из двух не нужно).
//
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
// единой серверной пагинации на двух источниках сразу не построить. Для
// сценария «искал что-то один раз» это приемлемо.
export interface ISearchPage {
rows: IMyRequest[];
totalRows: number;
}
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT). Только 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 = [
`(${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,
});
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
}
/**
* ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает
* вызывающая сторона.
*
* Именно так, а не «страницу за вызов»: набор всё равно собирается целиком
* (двумя источниками + слияние + сортировка), и раньше 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) => `${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));
return { rows: merged, totalRows: merged.length };
}
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.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,
};
}