вроде работает правильно
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { SPFI } from '@pnp/sp';
|
||||
import { spForSite } from '../../shared/pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
@@ -24,11 +25,24 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
|
||||
const RECENT_MS = 10 * 60 * 1000;
|
||||
|
||||
// Сколько строк согласования максимум забираем из Search за один запрос
|
||||
// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор
|
||||
// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении
|
||||
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
||||
const SEARCH_ROW_LIMIT = 500;
|
||||
// Сколько строк максимум запрашиваем у Search за ОДИН HTTP-вызов — верхняя
|
||||
// граница RowLimit самого SharePoint Search REST API. Полный набор всегда
|
||||
// собирается несколькими такими вызовами через StartRow (см.
|
||||
// fetchAllSearchRows) — это не потолок итогового результата.
|
||||
const SEARCH_PAGE_SIZE = 500;
|
||||
|
||||
// Абсолютный потолок на суммарное число строк, которые fetchAllSearchRows
|
||||
// заберёт за все страницы, — защита от зависания на аномально большом
|
||||
// индексе, а не ожидаемый рабочий предел. При достижении пишем предупреждение
|
||||
// и возвращаем то, что успели собрать, вместо бесконечного перебора.
|
||||
const SEARCH_HARD_CAP = 5000;
|
||||
|
||||
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
|
||||
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
|
||||
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
|
||||
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
|
||||
// а не уходит одним запросом целиком.
|
||||
const REST_ID_FILTER_CHUNK = 100;
|
||||
|
||||
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
||||
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
|
||||
@@ -421,29 +435,24 @@ async function getInvolvedRowsViaSearch(
|
||||
}
|
||||
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>>)
|
||||
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`);
|
||||
const ids = hits
|
||||
.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>>;
|
||||
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
||||
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
||||
const chunks = await Promise.all(
|
||||
chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
||||
approvalList.items
|
||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||
.top(idsChunk.length)()
|
||||
)
|
||||
);
|
||||
return (chunks as Array<Array<Record<string, unknown>>>).reduce((all, r) => all.concat(r), []);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`,
|
||||
@@ -518,10 +527,64 @@ export interface IOwnRequestsPage {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// Сколько заявок максимум перечисляем в KQL при поиске среди уже отобранных
|
||||
// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной.
|
||||
// Сколько заявок-кандидатов максимум перечисляем в ОДНОМ KQL-запросе при
|
||||
// поиске среди уже отобранных (см. filterPendingByQuery) — не потолок
|
||||
// итогового результата, а размер чанка: кандидатов режем на такие пачки и
|
||||
// прогоняем каждую отдельным запросом, чтобы строка запроса не разрослась
|
||||
// до неподъёмной, но при этом ни один кандидат не терялся.
|
||||
const KQL_ID_LIMIT = 300;
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
result.push(items.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Собрать ВЕСЬ результат Search по запросу, а не одну страницу: сам
|
||||
// SharePoint Search REST API ограничивает RowLimit одного вызова (см.
|
||||
// SEARCH_PAGE_SIZE), поэтому при большом совпадающем наборе перебираем
|
||||
// StartRow, пока индекс не перестанет отдавать новые строки или пока не
|
||||
// упрёмся в SEARCH_HARD_CAP (аварийный потолок, а не ожидаемый лимит — при
|
||||
// его достижении пишем предупреждение и отдаём то, что успели собрать).
|
||||
async function fetchAllSearchRows(
|
||||
sp: SPFI,
|
||||
kql: string,
|
||||
selectProperties: string[],
|
||||
label: string,
|
||||
sortList?: Array<{ Property: string; Direction: number }>
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
let startRow = 0;
|
||||
|
||||
for (;;) {
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: SEARCH_PAGE_SIZE,
|
||||
StartRow: startRow,
|
||||
SelectProperties: selectProperties,
|
||||
TrimDuplicates: false,
|
||||
...(sortList ? { SortList: sortList } : {}),
|
||||
});
|
||||
|
||||
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
|
||||
rows.push(...page);
|
||||
const total = results.TotalRows || 0;
|
||||
startRow += page.length;
|
||||
|
||||
if (page.length === 0 || startRow >= total) break;
|
||||
if (rows.length >= SEARCH_HARD_CAP) {
|
||||
console.warn(
|
||||
`[Текущие заявки] ${label}: индекс отдал ${total} строк — забрали только первые ${rows.length} (аварийный потолок)`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
@@ -570,11 +633,13 @@ function freeTextClause(query: string): string | undefined {
|
||||
//
|
||||
// Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах
|
||||
// стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии —
|
||||
// стандартная дедупликация Search иногда схлопывает и их), а без неё индекс
|
||||
// может вернуть один и тот же элемент несколькими хитами (переиндексация,
|
||||
// несколько «версий» в crawl). Раньше хиты превращались в строки один-в-один,
|
||||
// и дубликат из индекса становился дубликатом строки в таблице — с одинаковым
|
||||
// React-ключом, то есть видимым «залипанием» одной и той же заявки дважды.
|
||||
// стандартная дедупликация Search схлопывает по содержимому, а не по Id, и
|
||||
// вместе с ними может случайно схлопнуть две по-настоящему разные, но похожие
|
||||
// заявки), а без неё индекс может вернуть один и тот же элемент несколькими
|
||||
// хитами (переиндексация, несколько «версий» в crawl). Раньше хиты
|
||||
// превращались в строки один-в-один, и дубликат из индекса становился
|
||||
// дубликатом строки в таблице — с одинаковым React-ключом, то есть видимым
|
||||
// «залипанием» одной и той же заявки дважды.
|
||||
function hitsToEntries(
|
||||
results: { PrimarySearchResults: unknown[] },
|
||||
types: IRequestType[]
|
||||
@@ -600,6 +665,11 @@ function hitsToEntries(
|
||||
|
||||
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
||||
// кастомных полей нет гарантии, что они проиндексированы как managed property).
|
||||
//
|
||||
// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search
|
||||
// собирает результат целиком (см. fetchAllSearchRows), их может набраться
|
||||
// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в
|
||||
// предел длины URL SharePoint REST.
|
||||
async function loadByIds(
|
||||
context: WebPartContext,
|
||||
entries: Array<{ id: number; type: IRequestType }>,
|
||||
@@ -616,16 +686,21 @@ async function loadByIds(
|
||||
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));
|
||||
});
|
||||
const list = sp.web.getList(type.listId as string);
|
||||
|
||||
await Promise.all(
|
||||
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
|
||||
const rows = (await list.items
|
||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(idsChunk.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;
|
||||
@@ -929,6 +1004,11 @@ export async function hydrateDecisionComments(
|
||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||
// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по
|
||||
// всем полям), без клиентского REST-матчинга по свежести.
|
||||
//
|
||||
// Кандидатов режем на чанки по KQL_ID_LIMIT и гоняем каждый отдельным
|
||||
// запросом (см. chunk/fetchAllSearchRows) — раньше «лишние» сверх лимита
|
||||
// кандидаты молча выпадали из поиска, теперь ни один не теряется, просто
|
||||
// уходит несколькими запросами вместо одного.
|
||||
export async function filterPendingByQuery(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
@@ -938,34 +1018,35 @@ export async function filterPendingByQuery(
|
||||
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));
|
||||
const scopeTypes = types.filter((t) => rows.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:"${listPathPrefix(t)}*"`).join(' OR ')})`,
|
||||
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
||||
text,
|
||||
].join(' AND ');
|
||||
const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`;
|
||||
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: KQL_ID_LIMIT,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
});
|
||||
const found = new Set<string>();
|
||||
await Promise.all(
|
||||
chunk(rows, KQL_ID_LIMIT).map(async (candidates) => {
|
||||
const kql = [
|
||||
pathClause,
|
||||
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
||||
text,
|
||||
].join(' AND ');
|
||||
|
||||
const hits = await fetchAllSearchRows(
|
||||
sp,
|
||||
kql,
|
||||
['ListItemID', 'Title', 'Path'],
|
||||
'Поиск среди ждущих решения'
|
||||
);
|
||||
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
||||
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
|
||||
found.add(`${e.type.id}-${e.id}`)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// 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}`));
|
||||
return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -985,8 +1066,10 @@ export interface ISearchPage {
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
|
||||
// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть.
|
||||
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез
|
||||
// (см. fetchAllSearchRows — весь, а не первые KQL_ID_LIMIT: тот лимит здесь
|
||||
// больше не действует, это чанк-размер для другой функции, filterPendingByQuery).
|
||||
// Только Search, без REST-подстраховки на свежесть.
|
||||
export async function searchOwnAll(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
@@ -1004,12 +1087,8 @@ export async function searchOwnAll(
|
||||
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 hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки');
|
||||
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
|
||||
|
||||
|
||||
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
||||
|
||||
@@ -136,8 +136,8 @@ export function traceSearchHits(
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 5: сколько строк этого типа дожило до выдачи после дедупа, проверки
|
||||
* существования и дочитывания полей через REST.
|
||||
* Шаг 5: сколько строк этого типа дожило до выдачи после сопоставления с
|
||||
* адресом списка и дочитывания полей через REST.
|
||||
*
|
||||
* Расхождение «хиты были, строк нет» означает, что элементы из индекса в
|
||||
* списке уже не существуют (удалены, индекс отстал) либо REST их не отдал.
|
||||
@@ -155,7 +155,7 @@ export function traceResultRows(
|
||||
const mineEntries = entries.filter((e) => e.type.id === t.id).length;
|
||||
const mineRows = rows.filter((r) => r.requestTypeId === t.id).length;
|
||||
console.log(
|
||||
`${PREFIX} ${label}: «${t.name}» — после дедупа/проверки ${mineEntries}, в выдаче ${mineRows}`
|
||||
`${PREFIX} ${label}: «${t.name}» — сопоставлено со списком ${mineEntries}, в выдаче ${mineRows}`
|
||||
);
|
||||
if (mineEntries > 0 && mineRows === 0) {
|
||||
console.warn(
|
||||
|
||||
Reference in New Issue
Block a user