Search requests #2

Merged
gorbenko.pavel merged 11 commits from SearchRequests into main 2026-08-06 09:59:07 +00:00
Showing only changes of commit 71873e0c30 - Show all commits
@@ -22,6 +22,12 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
const RECENT_MS = 10 * 60 * 1000;
// Сколько строк согласования максимум забираем из Search за один запрос
// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор
// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении
// лимита пишем предупреждение в лог, а не молча обрезаем.
const SEARCH_ROW_LIMIT = 500;
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
@@ -181,20 +187,19 @@ async function indexApprovalsForType(
if (scope === 'myTurn') {
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
// большим): Search находит кандидатов по managed property ApproverOWSUSER
// без сканирования списка целиком, плюс REST по «последним N минутам»
// (Modified) добирает то, что Search мог ещё не проиндексировать. Status
// в обоих случаях берётся СВЕЖИМ через REST по найденным Id (не из индекса
// Search) — именно это делает getInvolvedRowsViaSearch, поэтому решение
// «моя очередь сейчас» всегда точное, даже когда список кандидатов пришёл
// из Search.
// большим): 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(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'TicketId', 'Status')
.top(2000)()) as Array<Record<string, unknown>>;
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin);
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList);
const seenIds = new Set(recentRows.map((r) => r.Id));
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
@@ -207,12 +212,12 @@ async function indexApprovalsForType(
} else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
// свежесть, а полнота по всему архиву, поэтому ищем чисто через Search
// по managed property ApproverOWSUSER, без REST-подстраховки на
// «последние N минут» — гибрид REST+Search приводил к неустойчивому
// результату (заявка то находилась, то нет, в зависимости от того,
// успел ли Search её проиндексировать к моменту REST-окна).
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin);
// свежесть, а полнота по всему архиву, поэтому только Search, без
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
// давал неустойчивый результат (заявка то находилась, то нет, в
// зависимости от того, успел ли Search её проиндексировать к моменту
// REST-окна).
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}`
@@ -254,21 +259,36 @@ async function indexApprovalsForType(
}
}
// 'involved' целиком — через Search по managed property ApproverOWSUSER
// (кастомное Person-поле, НЕТ гарантии, что проиндексировано без ручной
// настройки Search Schema — если лог покажет «найдено: 0» при заведомо
// непустом архиве, значит не сработало).
// 'involved' — строки согласования текущего пользователя через Search, чтобы не
// сканировать список целиком (он растёт с каждой заявкой).
//
// Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём
// свежими через REST по этим Id (точечное чтение по известным Id — не поиск,
// а обычное дочитывание данных, как и везде в проекте для найденных Search'ом строк).
// Ищем ОТОБРАЖАЕМЫМ ИМЕНЕМ СВОБОДНЫМ ТЕРМОМ, а не через managed property
// ApproverOWSUSER. Подтверждено диагностикой 2026-08-05 в этом тенанте:
// ApproverOWSUSER отдаёт null у всех строк (автосозданного свойства не было —
// нашлось только чужое ApprooverOWSUSER с опечаткой; созданное вручную и
// смапленное на ows_Approver так и не наполнилось), а вот свободный терм
// `"Павел Горбенко"` находит именно свои 12 строк из 27. То есть Person-поле в
// полнотекстовый индекс попадает как текст, просто через managed property не
// достаётся.
//
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
// свежими через REST по этим Id, а не из индекса (индекс мог устареть).
async function getInvolvedRowsViaSearch(
context: WebPartContext,
type: IRequestType,
approvalList: IList,
claimsLogin: string
approvalList: IList
): Promise<Array<Record<string, unknown>>> {
try {
// Кавычки сломали бы фразу в KQL — убираем (в ФИО их быть не должно, но
// значение приходит извне, поэтому не доверяем).
const displayName = (context.pageContext.user.displayName || '').replace(/"/g, ' ').trim();
if (!displayName) {
console.warn(
`[Текущие заявки] «${type.name}»: у текущего пользователя нет отображаемого имени — Search по согласующему невозможен`
);
return [];
}
const rootFolder = (await approvalList
.select('RootFolder/ServerRelativeUrl')
.expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } };
@@ -277,21 +297,28 @@ async function getInvolvedRowsViaSearch(
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 kql = `Path:"${approvalListAbsUrl}*" AND "${displayName}"`;
const results = await sp.search({
Querytext: kql,
RowLimit: 500,
RowLimit: SEARCH_ROW_LIMIT,
SelectProperties: ['ListItemID'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «${type.name}» (архив, involved): KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
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);
console.log(
`[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
);
if (total > SEARCH_ROW_LIMIT) {
console.warn(
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
);
}
if (ids.length === 0) return [];
return (await approvalList.items
@@ -300,7 +327,7 @@ async function getInvolvedRowsViaSearch(
.top(ids.length)()) as Array<Record<string, unknown>>;
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`,
`[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`,
(e as Error).message
);
return [];