дозагрузка свежих данных
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { spfi, SPFx } from '@pnp/sp';
|
import { spfi, SPFx } from '@pnp/sp';
|
||||||
import '@pnp/sp/webs';
|
import '@pnp/sp/webs';
|
||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
|
import { IList } from '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
@@ -13,6 +14,12 @@ import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
|||||||
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
|
|
||||||
|
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
||||||
|
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
||||||
|
// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType,
|
||||||
|
// filterPendingByQuery).
|
||||||
|
const RECENT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
export function currentUserEmail(context: WebPartContext): string {
|
export function currentUserEmail(context: WebPartContext): string {
|
||||||
return (context.pageContext.user.email || '').toLowerCase();
|
return (context.pageContext.user.email || '').toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -168,6 +175,13 @@ async function indexApprovalsForType(
|
|||||||
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
const approver = await sp.web.ensureUser(claimsLogin);
|
const approver = await sp.web.ensureUser(claimsLogin);
|
||||||
|
|
||||||
|
let stepRows: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
if (scope === 'myTurn') {
|
||||||
|
// 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status должен быть
|
||||||
|
// гарантированно свежим (индекс Search обновляется с задержкой и мог бы
|
||||||
|
// показать уже решённый шаг как ожидающий). Поэтому здесь только REST,
|
||||||
|
// весь список по ApproverId, без архивного Search-довеска.
|
||||||
const allRows: Array<Record<string, unknown>> = [];
|
const allRows: Array<Record<string, unknown>> = [];
|
||||||
for await (const p of approvalList.items
|
for await (const p of approvalList.items
|
||||||
.filter(`ApproverId eq ${approver.Id}`)
|
.filter(`ApproverId eq ${approver.Id}`)
|
||||||
@@ -175,13 +189,31 @@ async function indexApprovalsForType(
|
|||||||
.top(2000)) {
|
.top(2000)) {
|
||||||
allRows.push(...(p as Array<Record<string, unknown>>));
|
allRows.push(...(p as Array<Record<string, unknown>>));
|
||||||
}
|
}
|
||||||
|
stepRows = allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval);
|
||||||
|
console.log(
|
||||||
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 'involved' — просто «участвовал ли вообще», Status не важен для
|
||||||
|
// истинности результата, поэтому здесь можно рискнуть Search-ом для
|
||||||
|
// архивной (не только что созданной/изменённой) части: свежее по
|
||||||
|
// Modified — REST, остальное — Search по managed property ApproverOWSUSER
|
||||||
|
// (кастомное поле, не гарантировано, что проиндексировано — см. лог).
|
||||||
|
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 stepRows =
|
const archivalRows = await getArchivalInvolvedRows(context, type, approvalList, claimsLogin);
|
||||||
scope === 'myTurn' ? allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval) : allRows;
|
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||||
|
stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id)));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=${scope}, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status })));
|
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status })));
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
@@ -217,6 +249,59 @@ async function indexApprovalsForType(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Архивная (не только что созданная/изменённая) часть 'involved' — через
|
||||||
|
// Search по managed property ApproverOWSUSER (кастомное Person-поле, НЕТ
|
||||||
|
// гарантии, что проиндексировано без ручной настройки Search Schema — если
|
||||||
|
// лог покажет «найдено: 0» при заведомо непустом архиве, значит не сработало
|
||||||
|
// и стоит вернуться на чистый REST для этой ветки).
|
||||||
|
//
|
||||||
|
// Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём
|
||||||
|
// свежими через REST по этим Id, а не из индекса (индекс мог устареть).
|
||||||
|
async function getArchivalInvolvedRows(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
approvalList: IList,
|
||||||
|
claimsLogin: string
|
||||||
|
): Promise<Array<Record<string, unknown>>> {
|
||||||
|
try {
|
||||||
|
const rootFolder = (await approvalList
|
||||||
|
.select('RootFolder/ServerRelativeUrl')
|
||||||
|
.expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } };
|
||||||
|
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
|
||||||
|
if (!relUrl) return [];
|
||||||
|
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 results = await sp.search({
|
||||||
|
Querytext: kql,
|
||||||
|
RowLimit: 500,
|
||||||
|
SelectProperties: ['ListItemID'],
|
||||||
|
TrimDuplicates: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Текущие заявки] Search «${type.name}» (архив, involved): KQL = ${kql}, найдено: ${results.TotalRows}`);
|
||||||
|
console.table(results.PrimarySearchResults);
|
||||||
|
|
||||||
|
const ids = (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
||||||
|
.map((r) => Number(r.ListItemID))
|
||||||
|
.filter((id) => !isNaN(id) && id > 0);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
return (await approvalList.items
|
||||||
|
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
|
.select('Id', 'TicketId', 'Status')
|
||||||
|
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
||||||
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
||||||
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
||||||
@@ -286,6 +371,23 @@ const PAGE_SIZE = 20;
|
|||||||
// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной.
|
// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной.
|
||||||
const KQL_ID_LIMIT = 300;
|
const KQL_ID_LIMIT = 300;
|
||||||
|
|
||||||
|
function isRecent(createdIso: string): boolean {
|
||||||
|
if (!createdIso) return false;
|
||||||
|
const t = new Date(createdIso).getTime();
|
||||||
|
return !isNaN(t) && Date.now() - t <= RECENT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Замена свободному терму Search для случаев, когда матчим на клиенте (без
|
||||||
|
// индекса): все слова запроса должны встретиться в названии (без учёта
|
||||||
|
// регистра). Не идентично семантике Search (там — префикс, тут — подстрока),
|
||||||
|
// но для «довеска» из нескольких свежих строк разница не принципиальна.
|
||||||
|
function matchesQueryTitle(title: string, query: string): boolean {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const t = title.toLowerCase();
|
||||||
|
return q.split(/\s+/).filter(Boolean).every((word) => t.indexOf(word) !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
function siteOrigin(siteUrl: string): string {
|
function siteOrigin(siteUrl: string): string {
|
||||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||||
return match ? match[0] : siteUrl;
|
return match ? match[0] : siteUrl;
|
||||||
@@ -361,9 +463,45 @@ async function loadByIds(
|
|||||||
return byKey;
|
return byKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом,
|
||||||
|
// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на
|
||||||
|
// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже
|
||||||
|
// подводил на Status (см. indexApprovalsForType), поэтому в $filter только
|
||||||
|
// нечувствительные к этому условия (AuthorId/Created).
|
||||||
|
async function getFreshOwnRowsForType(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
query: string
|
||||||
|
): Promise<IMyRequest[]> {
|
||||||
|
try {
|
||||||
|
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
||||||
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
|
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
|
const rows = (await sp.web
|
||||||
|
.getList(type.listId as string)
|
||||||
|
.items.filter(`AuthorId eq ${me.Id} and Created ge '${cutoff}'`)
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(50)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'))
|
||||||
|
.filter((r) => matchesQueryTitle(r.title, query));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий
|
// «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий
|
||||||
// пользователь, текст запроса по всем полям. Пагинация серверная
|
// пользователь, текст запроса по всем полям. Пагинация серверная
|
||||||
// (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок.
|
// (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок.
|
||||||
|
//
|
||||||
|
// На первой странице довешиваем то, что Search мог ещё не проиндексировать
|
||||||
|
// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и
|
||||||
|
// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так
|
||||||
|
// оказалось бы на первой странице, будь оно проиндексировано.
|
||||||
export async function getOwnRequests(
|
export async function getOwnRequests(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
@@ -397,21 +535,47 @@ export async function getOwnRequests(
|
|||||||
console.table(results.PrimarySearchResults);
|
console.table(results.PrimarySearchResults);
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
if (entries.length === 0) return { rows: [], totalRows: results.TotalRows || 0 };
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
|
|
||||||
const byKey = await loadByIds(context, entries, 'own');
|
|
||||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||||
const rows = entries
|
const searchRows = entries
|
||||||
.map((e) => byKey.get(`${e.type.id}-${e.id}`))
|
.map((e) => byKey.get(`${e.type.id}-${e.id}`))
|
||||||
.filter((r): r is IMyRequest => !!r);
|
.filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
return { rows, totalRows: results.TotalRows || 0 };
|
if (page !== 1) {
|
||||||
|
return { rows: searchRows, totalRows: results.TotalRows || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce(
|
||||||
|
(all, r) => all.concat(r),
|
||||||
|
[] as IMyRequest[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||||
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
|
||||||
|
if (onlyFresh.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = onlyFresh
|
||||||
|
.concat(searchRows)
|
||||||
|
.sort((a, b) => b.created.localeCompare(a.created))
|
||||||
|
.slice(0, PAGE_SIZE);
|
||||||
|
|
||||||
|
return { rows: merged, totalRows: (results.TotalRows || 0) + onlyFresh.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||||
// (getPendingApproval — там же определяется, чья сейчас очередь). Здесь среди
|
// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья
|
||||||
// НИХ ищем по тексту через Search: KQL ограничивается ListItemID найденных
|
// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту.
|
||||||
// заявок, текст идёт свободным термом по всем полям.
|
//
|
||||||
|
// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать —
|
||||||
|
// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search.
|
||||||
|
// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст
|
||||||
|
// идёт свободным термом по всем полям. Порядок сохраняется (rows уже
|
||||||
|
// отсортированы по дате в buildPendingIndex, свежие — в начале).
|
||||||
export async function filterPendingByQuery(
|
export async function filterPendingByQuery(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
@@ -421,22 +585,26 @@ export async function filterPendingByQuery(
|
|||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
if (!text || rows.length === 0) return rows;
|
if (!text || rows.length === 0) return rows;
|
||||||
|
|
||||||
if (rows.length > KQL_ID_LIMIT) {
|
const fresh = rows.filter((r) => isRecent(r.created));
|
||||||
|
const older = rows.filter((r) => !isRecent(r.created));
|
||||||
|
const freshMatches = fresh.filter((r) => matchesQueryTitle(r.title, query));
|
||||||
|
|
||||||
|
if (older.length === 0) return freshMatches;
|
||||||
|
|
||||||
|
if (older.length > KQL_ID_LIMIT) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}`
|
`[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${older.length}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const scope = rows.slice(0, KQL_ID_LIMIT);
|
const candidates = older.slice(0, KQL_ID_LIMIT);
|
||||||
|
|
||||||
const byType = new Map<number, IRequestType>();
|
const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id));
|
||||||
types.forEach((t) => byType.set(t.id, t));
|
if (scopeTypes.length === 0) return freshMatches;
|
||||||
const scopeTypes = types.filter((t) => scope.some((r) => r.requestTypeId === t.id));
|
|
||||||
if (scopeTypes.length === 0) return [];
|
|
||||||
|
|
||||||
const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context));
|
const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context));
|
||||||
const kql = [
|
const kql = [
|
||||||
`(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
`(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
||||||
`(${scope.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
||||||
text,
|
text,
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
@@ -452,7 +620,9 @@ export async function filterPendingByQuery(
|
|||||||
|
|
||||||
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
||||||
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
||||||
return scope.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
|
const olderMatches = candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
|
||||||
|
return freshMatches.concat(olderMatches);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMyRequest(
|
function toMyRequest(
|
||||||
|
|||||||
Reference in New Issue
Block a user