Поиск согласующего в списке маршрута через Search свободным термом
ApproverOWSUSER (managed property для Person-поля Approver) в тенанте не индексируется — запросы стабильно возвращали 0 при заведомо непустых данных. Диагностика показала, что свободный терм по отображаемому имени согласующего находит нужные строки без managed property, поэтому involved/myTurn-поиск в getInvolvedRowsViaSearch переведён на него.
This commit is contained in:
@@ -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 [];
|
||||||
|
|||||||
Reference in New Issue
Block a user