Поиск согласующего в списке маршрута через Search свободным термом

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