From d8737a15e375028a641e4b938b21560da12c5299 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 12:55:29 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D1=85=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 228 +++++++++++++++--- 1 file changed, 199 insertions(+), 29 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 96b6815..72a97a1 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,6 +1,7 @@ import { spfi, SPFx } from '@pnp/sp'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; +import { IList } from '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; @@ -13,6 +14,12 @@ import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; +// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может +// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход +// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType, +// filterPendingByQuery). +const RECENT_MS = 10 * 60 * 1000; + export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -168,20 +175,45 @@ async function indexApprovalsForType( const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; const approver = await sp.web.ensureUser(claimsLogin); - const allRows: Array> = []; - for await (const p of approvalList.items - .filter(`ApproverId eq ${approver.Id}`) - .select('Id', 'TicketId', 'Status') - .top(2000)) { - allRows.push(...(p as Array>)); + let stepRows: Array>; + + if (scope === 'myTurn') { + // 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status должен быть + // гарантированно свежим (индекс Search обновляется с задержкой и мог бы + // показать уже решённый шаг как ожидающий). Поэтому здесь только REST, + // весь список по ApproverId, без архивного Search-довеска. + const allRows: Array> = []; + for await (const p of approvalList.items + .filter(`ApproverId eq ${approver.Id}`) + .select('Id', 'TicketId', 'Status') + .top(2000)) { + allRows.push(...(p as Array>)); + } + 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>; + + const archivalRows = await getArchivalInvolvedRows(context, type, approvalList, claimsLogin); + const seenIds = new Set(recentRows.map((r) => r.Id)); + stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id))); + + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}` + ); } - const stepRows = - scope === 'myTurn' ? allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval) : allRows; - - console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=${scope}, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` - ); console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status }))); 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>> { + 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>) + .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>; + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`, + (e as Error).message + ); + return []; + } +} + // Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — // только по переданным строкам (обычно уже нарезанная страница из ≤20), не по // всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). @@ -286,6 +371,23 @@ const PAGE_SIZE = 20; // (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. 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 { const match = /^https?:\/\/[^/]+/.exec(siteUrl); return match ? match[0] : siteUrl; @@ -361,9 +463,45 @@ async function loadByIds( return byKey; } +// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, +// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на +// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже +// подводил на Status (см. indexApprovalsForType), поэтому в $filter только +// нечувствительные к этому условия (AuthorId/Created). +async function getFreshOwnRowsForType( + context: WebPartContext, + type: IRequestType, + query: string +): Promise { + 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>; + + 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 по спискам данных всех типов сразу — автор = текущий // пользователь, текст запроса по всем полям. Пагинация серверная // (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок. +// +// На первой странице довешиваем то, что Search мог ещё не проиндексировать +// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и +// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так +// оказалось бы на первой странице, будь оно проиндексировано. export async function getOwnRequests( context: WebPartContext, types: IRequestType[], @@ -397,21 +535,47 @@ export async function getOwnRequests( console.table(results.PrimarySearchResults); const entries = hitsToEntries(results, types); - if (entries.length === 0) return { rows: [], totalRows: results.TotalRows || 0 }; - - const byKey = await loadByIds(context, entries, 'own'); + const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. - const rows = entries + const searchRows = entries .map((e) => byKey.get(`${e.type.id}-${e.id}`)) .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 — там же определяется, чья сейчас очередь). Здесь среди -// НИХ ищем по тексту через Search: KQL ограничивается ListItemID найденных -// заявок, текст идёт свободным термом по всем полям. +// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья +// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту. +// +// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать — +// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search. +// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст +// идёт свободным термом по всем полям. Порядок сохраняется (rows уже +// отсортированы по дате в buildPendingIndex, свежие — в начале). export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -421,22 +585,26 @@ export async function filterPendingByQuery( const text = freeTextClause(query); 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( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${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(); - types.forEach((t) => byType.set(t.id, t)); - const scopeTypes = types.filter((t) => scope.some((r) => r.requestTypeId === t.id)); - if (scopeTypes.length === 0) return []; + const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); + if (scopeTypes.length === 0) return freshMatches; const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); const kql = [ `(${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, ].join(' AND '); @@ -452,7 +620,9 @@ export async function filterPendingByQuery( // ListItemID уникален только внутри своего списка — сверяем пару тип+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(