From 80d8790ae1949309c046b39ec0028be22a5297f3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 18:08:03 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 170 +++++++++++++----- 1 file changed, 129 insertions(+), 41 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index b6be166..5fa1351 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -6,6 +6,10 @@ import { IList } from '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; +// sp.batched() — довесок по свежести уходит одним запросом на сайт, см. +// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах, +// но в рантайме не зарегистрирован. +import '@pnp/sp/batching'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; @@ -219,15 +223,29 @@ async function buildPendingIndex( } }); - const hitsByTarget = + // Два источника кандидатов — Search (весь архив) и REST-довесок по свежести + // (то, что индекс мог не успеть подхватить) — независимы друг от друга, + // поэтому идут параллельно, а не один за другим. + const [hitsByTarget, recentByTarget] = await Promise.all([ okEntries.length > 0 - ? await traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)) - : new Map(); + ? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)) + : Promise.resolve(new Map()), + // Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске + // важна полнота по архиву, а не мгновенная свежесть (см. scope='involved'). + okEntries.length > 0 && scope === 'myTurn' + ? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)) + : Promise.resolve(new Map>>()), + ]); const parts = await Promise.all( okEntries.map((entry) => traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () => - finalizeApprovalTarget(context, entry, scope, statuses, hitsByTarget.get(targetKey(entry)) ?? [], tokenEmail) + finalizeApprovalTarget( + context, + entry, + hitsByTarget.get(targetKey(entry)) ?? [], + recentByTarget.get(targetKey(entry)) ?? [] + ) ) ) ); @@ -510,6 +528,98 @@ async function searchAcrossApprovalLists( } } +/** + * REST-довесок по свежести для очереди согласования: строки маршрута, изменённые + * за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить. + * + * Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая + * в архив, продолжает висеть на согласовании, а решение по ней меняет строки + * маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive). + * Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку + * он увидит не сразу, а только когда её подхватит индекс; именно эту заявку + * фича «архивные заявки тоже согласуются» и должна показывать вовремя. + * + * Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта + * уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в + * approvalList.service.ts/createSteps), поэтому число партиций больше не + * добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и + * с отдельным ensureUser перед каждым. + */ +async function fetchRecentApprovalRows( + context: WebPartContext, + entries: IApprovalSearchTarget[], + tokenEmail: string, + statuses: string[] +): Promise>>> { + const result = new Map>>(); + const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + // Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки, + // без постфильтрации в JS. Кириллица в значении этому не мешает — проверено + // вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS. + const statusFilter = + statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; + + // Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id + // пользователя тоже свой на каждом сайте. + const bySite = new Map(); + entries.forEach((e) => { + const key = e.target.siteUrl.toLowerCase(); + const bucket = bySite.get(key) ?? []; + bucket.push(e); + bySite.set(key, bucket); + }); + + await Promise.all( + Array.from(bySite.values()).map(async (siteEntries) => { + const siteUrl = siteEntries[0].target.siteUrl; + try { + const sp = spForSite(siteUrl, context); + + // ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки + // каждой партиции, то есть повторялся N раз с тем же результатом, и это + // POST, а не дешёвый GET. + // + // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в + // SharePoint REST — поэтому резолвим email в числовой Id (как и везде в + // проекте) и фильтруем по TansuSiteApproverId. + const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; + const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin)); + const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`; + + const [batchedSP, execute] = sp.batched(); + // Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе + // отказ по одной партиции внутри батча всплыл бы как unhandled rejection + // раньше, чем мы успели бы его перехватить. + const pending = siteEntries.map((entry) => + batchedSP.web.lists + .getByTitle(entry.approvalListTitle) + .items.filter(filter) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(2000)() + .then((rows) => { + result.set(targetKey(entry), rows as Array>); + }) + .catch((e) => { + // Недоступная партиция (архив переименовали/удалили вручную) не + // должна лишать довеска остальные партиции того же сайта. + console.error( + `[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`, + (e as Error).message + ); + }) + ); + + await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute); + await Promise.all(pending); + } catch (e) { + console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message); + } + }) + ); + + return result; +} + /** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */ interface IPartitionApprovalResult { rows: IMyRequest[]; @@ -519,17 +629,21 @@ interface IPartitionApprovalResult { } /** - * Достраивает результат одной партиции поверх уже найденных Search'ем Id - * (searchHitIds — пусто, если для неё ничего не нашлось или комбинированный - * Search целиком не сработал). + * Достраивает результат одной партиции поверх уже собранных кандидатов: + * • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции + * ничего не нашлось или Search целиком не сработал); + * • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме + * текстового поиска и когда за последние RECENT_MS ничего не менялось). + * + * Сама сеть здесь только на дочитывание: собственных «поисковых» запросов + * функция больше не делает — оба источника кандидатов собираются один раз на все + * партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows). */ async function finalizeApprovalTarget( context: WebPartContext, entry: IApprovalSearchTarget, - scope: ApprovalScope, - statuses: string[], searchHitIds: number[], - tokenEmail: string + recentRows: Array> ): Promise { const { type, target, approvalList, approvalListTitle } = entry; const debug: IPartitionApprovalResult = { rows: [], approvalListTitle }; @@ -558,37 +672,11 @@ async function finalizeApprovalTarget( ) ).reduce((all, r) => all.concat(r as Array>), [] as Array>); - let stepRows: Array>; - - if (scope === 'myTurn') { - // 'myTurn' — гибрид для масштаба (список согласования может быть очень - // большим): Search находит кандидатов, не сканируя список целиком, плюс - // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё - // не проиндексировать. - // - // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в - // SharePoint REST — резолвим email в числовой Id пользователя (как и везде - // в проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. - const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; - const approver = await sp.web.ensureUser(claimsLogin); - const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); - const statusFilter = - statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; - const recentRows = (await traced(`recentRowsRest:${type.name}#${target.listId}`, () => - approvalList.items - .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(2000)() - )) as Array>; - const seenIds = new Set(recentRows.map((r) => r.Id)); - stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); - } else { - // 'involved' — используется ТОЛЬКО для текстового поиска (см. - // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная - // свежесть, а полнота по всему архиву, поэтому только Search, без - // REST-подстраховки на «последние N минут». - stepRows = searchRows; - } + // Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только + // то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее, + // чем в индексе, а решение «моя очередь сейчас» строится именно на статусе. + const seenIds = new Set(recentRows.map((r) => r.Id)); + const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); debug.approvalRowCount = stepRows.length;