diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 2da7aba..783fe58 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -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>; - 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>> { 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>) .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>; } catch (e) { console.error( - `[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`, + `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, (e as Error).message ); return [];