From bfe71f8c5a344aad2e41eb96b49457be4b61e74c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 17:37:44 +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=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=83=D0=B5=D0=BC=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20sea?= =?UTF-8?q?rch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 519 ++++++++++-------- 1 file changed, 300 insertions(+), 219 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index a34c168..a3f546a 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -20,7 +20,7 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход -// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, +// Search, и подмешиваем к результату (см. buildPendingIndex/myTurn, // getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для // таблиц без текстового запроса — там, где важны и масштаб, и мгновенная // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). @@ -100,7 +100,7 @@ export type ApprovalScope = 'myTurn' | 'involved'; // Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя. // Отличаются только набором статусов его строки — сам механизм (найти свои // строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не -// зашиты в indexApprovalsForType. +// зашиты в finalizeApprovalTarget. // // approvals/execution — очереди действий: ход за пользователем прямо сейчас. // Раньше обе выдавались одним разделом «На согласовании» (фильтр включал @@ -153,6 +153,14 @@ const PENDING_PAGE_SIZE = 20; // (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там // нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы — // см. filterPendingByQuery и вызывающую сторону в RequestsTableView). +// +// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов — +// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см. +// searchAcrossApprovalLists), и при архивации в несколько партиций это была +// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11 +// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с). +// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см. +// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования. async function buildPendingIndex( context: WebPartContext, scope: ApprovalScope, @@ -161,18 +169,103 @@ async function buildPendingIndex( const types = await getLinkedTypes(context); const tokenEmail = currentUserEmail(context); - const perType = await Promise.all( - types.map((t) => - traced(`indexApprovalsForType:${t.name}`, () => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) + const debugByType = new Map(); + types.forEach((t) => + debugByType.set(t.id, { + requestTypeId: t.id, + requestTypeName: t.name, + listId: t.listId, + siteUrl: t.siteUrl, + skipped: false, + }) + ); + + if (!tokenEmail) { + return { types, index: [], debug: types.map((t) => debugByType.get(t.id)!) }; + } + + // Партиции ВСЕХ типов — в один плоский список: комбинированный Search строит + // Path-условие по ним разом, поэтому дальше нет смысла обходить типы по + // отдельности. + const flat = types.reduce>( + (all, t) => all.concat(listTargetsForType(t).map((target) => ({ type: t, target }))), + [] + ); + + // Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом: + // approval-route не хранит на него ссылку (список согласования ищется только + // по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда. + // Но это лёгкий $select без постраничного перебора — не то же самое, что + // полноценный Search, который эти вызовы как раз заменяют. + const resolved = await Promise.all( + flat.map(({ type, target }) => + traced(`resolveApprovalSearchTarget:${type.name}#${target.listId}`, () => + resolveApprovalSearchTarget(context, type, target) + ) ) ); - // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. - const index = perType - .reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]) - .sort((a, b) => b.created.localeCompare(a.created)); + const okEntries: IApprovalSearchTarget[] = []; + resolved.forEach((r) => { + const debugType = r.kind === 'ok' ? r.value.type : r.type; + const debug = debugByType.get(debugType.id); + if (!debug) return; + if (r.kind === 'ok') { + okEntries.push(r.value); + } else if (r.kind === 'skip') { + if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason; + } else { + if (!debug.approvalError) debug.approvalError = r.message; + } + }); - return { types, index, debug: perType.map((r) => r.debug) }; + const hitsByTarget = + okEntries.length > 0 + ? await traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)) + : 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) + ) + ) + ); + + const rowsByType = new Map(); + const titlesByType = new Map(); + parts.forEach((part, i) => { + const entry = okEntries[i]; + const debug = debugByType.get(entry.type.id); + if (!debug) return; + + const bucket = rowsByType.get(entry.type.id) ?? []; + bucket.push(...part.rows); + rowsByType.set(entry.type.id, bucket); + + if (part.approvalListTitle) { + const titles = titlesByType.get(entry.type.id) ?? []; + titles.push(part.approvalListTitle); + titlesByType.set(entry.type.id, titles); + } + if (part.error && !debug.approvalError) debug.approvalError = part.error; + debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0); + }); + + const index: IMyRequest[] = []; + types.forEach((t) => { + const debug = debugByType.get(t.id)!; + const titles = titlesByType.get(t.id); + if (titles && titles.length > 0) debug.approvalListTitle = titles.join(', '); + const typeRows = rowsByType.get(t.id) ?? []; + debug.approvalPendingTickets = typeRows.length; + index.push(...typeRows); + }); + + // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. + index.sort((a, b) => b.created.localeCompare(a.created)); + + return { types, index, debug: types.map((t) => debugByType.get(t.id)!) }; } // Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 @@ -226,7 +319,7 @@ export interface INavCounts { // «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по // отдельным запросам значило бы дважды просканировать одни и те же списки // согласования. Статус своей строки приходит в myStepStatus (см. -// indexApprovalsForType), по нему и разносим. +// finalizeApprovalTarget), по нему и разносим. // // «Доработка» живёт в других списках (данные, по автору), поэтому считается // своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму @@ -266,125 +359,204 @@ export async function hydratePendingAuthors( // произвольные поля связанного элемента, только через Person-поля вроде // Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400). // Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы. + +/** Список согласования одной партиции, готовый к поиску: где искать и куда дочитывать. */ +interface IApprovalSearchTarget { + type: IRequestType; + target: IListTarget; + approvalList: IList; + approvalListTitle: string; + /** Абсолютный префикс пути элементов СПИСКА СОГЛАСОВАНИЯ, со слешем на конце. */ + approvalListAbsUrl: string; +} + +type ApprovalTargetResolution = + | { kind: 'ok'; value: IApprovalSearchTarget } + | { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string } + | { kind: 'error'; type: IRequestType; target: IListTarget; message: string }; + /** - * Обходит ВСЕ партиции типа заявки: у каждой партиции своя пара списков, и строки - * маршрута заявки лежат в списке согласования ТОЙ ЖЕ партиции, что и сама заявка. - * Читать только текущую партицию означало бы терять из «На согласовании» всё, что - * уехало в архив. + * Резолвит список согласования одной партиции: Title (по конвенции из Title + * списка данных) и его абсолютный адрес пути. * - * Диагностика (debug) агрегируется по серии: счётчики суммируются, заголовки - * списков согласования перечисляются через запятую. + * Title списка данных — из target.title (бесплатно, из bulk-ответа + * /api/request-types/lists, см. IListTarget.title), REST — только если реестр + * партиций был недоступен. + * + * Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него + * ссылку (список согласования ищется только по имени, см. shared/listTitles.ts), + * сам список создаётся SharePoint под произвольным номером, и его адрес нельзя + * вывести из адреса списка данных. Но это лёгкий $select без постраничного + * перебора — не то же самое, что полноценный Search, который эти вызовы + * заменяют (см. searchAcrossApprovalLists). */ -async function indexApprovalsForType( +async function resolveApprovalSearchTarget( context: WebPartContext, type: IRequestType, - tokenEmail: string, - scope: ApprovalScope, - statuses: string[] -): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { - const debug: IMyRequestsTypeDebug = { - requestTypeId: type.id, - requestTypeName: type.name, - listId: type.listId, - siteUrl: type.siteUrl, - skipped: false, - }; - - if (!tokenEmail) return { rows: [], debug }; - - // listTargetsForType сам откатывается на RequestType.listId, если реестр - // партиций не загрузился — тогда ведём себя как раньше, по одному списку. - const targets = listTargetsForType(type); - - // Партиции независимы друг от друга (разные списки, разные HTTP-запросы) — - // читаем ВСЕ параллельно, а не по очереди. Раньше это был последовательный - // for-await, и у типа с несколькими партициями (архив накопился) время росло - // линейно с их числом: 3 партиции — втрое дольше одной, хотя каждая опрашивает - // СВОЙ список и ничего не ждёт от соседних (см. трассировку 2026-08-18: - // «Обходной лист» с 3 партициями — 7.5с, тогда как каждая партиция сама по - // себе — 2-3с). - const parts = await Promise.all( - targets.map((target) => - traced(`indexApprovalsForPartition:${type.name}#${target.listId}`, () => - indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses) - ) - ) - ); - - const rows: IMyRequest[] = []; - const approvalTitles: string[] = []; - - // Порядок Promise.all совпадает с порядком targets — «первый встреченный» - // результат (approvalSkippedReason/approvalError) не изменился относительно - // прежнего последовательного перебора. - for (const part of parts) { - rows.push(...part.rows); - if (part.approvalListTitle) approvalTitles.push(part.approvalListTitle); - if (part.approvalSkippedReason && !debug.approvalSkippedReason) { - debug.approvalSkippedReason = part.approvalSkippedReason; - } - if (part.error && !debug.approvalError) debug.approvalError = part.error; - debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0); - } - - if (approvalTitles.length > 0) debug.approvalListTitle = approvalTitles.join(', '); - debug.approvalPendingTickets = rows.length; - - return { rows, debug }; -} - -/** Результат по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */ -interface IPartitionApprovalResult { - rows: IMyRequest[]; - approvalListTitle?: string; - approvalSkippedReason?: string; - approvalRowCount?: number; - error?: string; -} - -async function indexApprovalsForPartition( - context: WebPartContext, - type: IRequestType, - target: IListTarget, - tokenEmail: string, - scope: ApprovalScope, - statuses: string[] -): Promise { - const debug: IPartitionApprovalResult = { rows: [] }; - + target: IListTarget +): Promise { try { const sp = spForSite(target.siteUrl, context); const list = sp.web.getList(target.listId); - // target.title — из bulk-ответа /api/request-types/lists, уже загруженного - // при старте страницы (см. IListTarget.title): 11 партиций — это 11 лишних - // REST-запросов только за тем, что уже пришло бесплатно одним запросом. - // REST — только если реестр партиций был недоступен (see listTargetsForType), - // тогда Title взять больше неоткуда. const dataTitle = target.title ?? ((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ?? ''; if (!DATA_LIST_SUFFIX.test(dataTitle)) { - debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; - return debug; + return { + kind: 'skip', + type, + target, + reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`, + }; } const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; - debug.approvalListTitle = approvalListTitle; const approvalList = sp.web.lists.getByTitle(approvalListTitle); - // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в - // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в - // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. - // - // ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что - // кириллица в значении делает такой фильтр ненадёжным, — перепроверено - // вручную через REST 2026-08-17 (все три статуса списка согласования дали - // точное совпадение с эталоном), теория не подтвердилась. Старый вывод о - // поломке был артефактом кривого тестового запроса, а не реальным - // ограничением SharePoint. + const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () => + approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')() + )) as { RootFolder?: { ServerRelativeUrl?: string } }; + const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; + if (!relUrl) { + return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` }; + } + // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с + // путями элементов …/Lists/List10 (см. listPathPrefix). + const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`; + + return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } }; + } catch (e) { + return { kind: 'error', type, target, message: (e as Error).message }; + } +} + +function targetKey(entry: IApprovalSearchTarget): string { + return entry.approvalListAbsUrl.toLowerCase(); +} + +// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное +// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» +// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго +// возвращали 0. +// +// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима +// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: +// без него в выдачу попали бы все строки, где пользователь вообще есть в +// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. +// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). +// +// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берёт +// СВЕЖИМИ через REST по этим Id вызывающая сторона (finalizeApprovalTarget), не +// из индекса (индекс мог устареть, а на устаревшем статусе решение «моя +// очередь» строить нельзя). +/** + * ОДИН комбинированный Search по спискам согласования ВСЕХ партиций ВСЕХ типов — + * Path через OR, approver и statuses общие для всех (см. buildPendingIndex, где + * это гарантировано одним вызовом на все типы разом). Тот же приём OR-а по Path + * уже применялся для «Своих заявок» (см. pathClauseForTypes) — раньше здесь + * вместо этого был отдельный Search-запрос НА КАЖДУЮ партицию, и при архивации + * в несколько партиций это было самой дорогой частью загрузки «На согласовании» + * (трассировка 2026-08-18: 11 партиций — 11 отдельных постраничных запросов). + * + * Ошибка одного общего запроса теперь означает «Search недоступен для всех», а + * не для одной партиции — компромисс сознательный: системный сбой Search и + * раньше задевал бы все партиции разом (это один и тот же сервис), а REST-net + * по свежести (recentRows, см. finalizeApprovalTarget) на сбой Search вообще не + * завязан и продолжает работать сам по себе. + * + * Возвращает найденные Id, сгруппированные по списку согласования (targetKey). + */ +async function searchAcrossApprovalLists( + context: WebPartContext, + targets: IApprovalSearchTarget[], + tokenEmail: string, + statuses: string[] +): Promise> { + const result = new Map(); + + try { const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; - const approver = await sp.web.ensureUser(claimsLogin); + const pathClause = `(${targets.map((t) => `Path:"${t.approvalListAbsUrl}*"`).join(' OR ')})`; + const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`]; + if (statuses.length > 0) { + clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`); + } + const kql = clauses.join(' AND '); + + // Представитель любого сайта годится — поисковый индекс общий на тенант + // (тот же приём, что в searchOwnRequests: spForSite(types[0].siteUrl, …)). + const sp = spForSite(targets[0].target.siteUrl, context); + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Path'], 'На согласовании (все партиции)'); + + hits.forEach((h) => { + const id = Number(h.ListItemID); + if (isNaN(id) || id <= 0) return; + const path = String(h.Path || '').toLowerCase(); + // Сравнение по границе сегмента (см. listPathPrefix) — тот же приём, что + // в hitsToEntries, но по адресам списков СОГЛАСОВАНИЯ, а не данных. + const matched = targets.filter((t) => path.indexOf(t.approvalListAbsUrl.toLowerCase()) === 0)[0]; + if (!matched) return; + const key = targetKey(matched); + const arr = result.get(key) ?? []; + arr.push(id); + result.set(key, arr); + }); + + return result; + } catch (e) { + console.error('[Текущие заявки] Комбинированный Search по спискам согласования не сработал:', (e as Error).message); + return result; + } +} + +/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */ +interface IPartitionApprovalResult { + rows: IMyRequest[]; + approvalListTitle?: string; + approvalRowCount?: number; + error?: string; +} + +/** + * Достраивает результат одной партиции поверх уже найденных Search'ем Id + * (searchHitIds — пусто, если для неё ничего не нашлось или комбинированный + * Search целиком не сработал). + */ +async function finalizeApprovalTarget( + context: WebPartContext, + entry: IApprovalSearchTarget, + scope: ApprovalScope, + statuses: string[], + searchHitIds: number[], + tokenEmail: string +): Promise { + const { type, target, approvalList, approvalListTitle } = entry; + const debug: IPartitionApprovalResult = { rows: [], approvalListTitle }; + + try { + const sp = spForSite(target.siteUrl, context); + const list = sp.web.getList(target.listId); + + // TicketId/Status — СВЕЖИМИ через REST по найденным Id, не из индекса + // Search (см. doc-комментарий у searchAcrossApprovalLists). + const searchRows = + searchHitIds.length === 0 + ? [] + : ( + await Promise.all( + // Id режем на пачки — тот же предел длины URL REST $filter, что и в + // loadByIds (см. REST_ID_FILTER_CHUNK). + chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) => + traced(`approvalRowsRest:${type.name}#${target.listId}`, () => + approvalList.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(idsChunk.length)() + ) + ) + ) + ).reduce((all, r) => all.concat(r as Array>), [] as Array>); let stepRows: Array>; @@ -392,44 +564,30 @@ async function indexApprovalsForPartition( // 'myTurn' — гибрид для масштаба (список согласования может быть очень // большим): Search находит кандидатов, не сканируя список целиком, плюс // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё - // не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по - // найденным Id (не из индекса Search) — именно это делает - // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда - // точное, даже когда список кандидатов пришёл из 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(); - // Статус — прямо в $filter (см. комментарий про ApprovalStatus выше): - // REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS. const statusFilter = statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; - // recentRows (REST) и searchRows (Search) — независимые источники - // кандидатов, объединяются только ниже (seenIds); одно не ждёт другое, - // поэтому запускаем их параллельно, а не по очереди. - const [recentRows, searchRows] = (await Promise.all([ - traced(`recentRowsRest:${type.name}#${target.listId}`, () => - approvalList.items - .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(2000)() - ), - // Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки, - // а не всё, где пользователь есть в маршруте. - traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () => - getInvolvedRowsViaSearch(context, type, approvalList, statuses) - ), - ])) as [Array>, Array>]; + 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 минут»: гибрид REST+Search при поиске - // давал неустойчивый результат (заявка то находилась, то нет, в - // зависимости от того, успел ли Search её проиндексировать к моменту - // REST-окна). - stepRows = await traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () => - getInvolvedRowsViaSearch(context, type, approvalList, statuses) - ); + // REST-подстраховки на «последние N минут». + stepRows = searchRows; } debug.approvalRowCount = stepRows.length; @@ -479,83 +637,6 @@ async function indexApprovalsForPartition( } } -// Строки согласования текущего пользователя через Search, чтобы не сканировать -// список целиком (он растёт с каждой заявкой). -// -// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное -// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» -// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго -// возвращали 0. -// -// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима -// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: -// без него в выдачу попали бы все строки, где пользователь вообще есть в -// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. -// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). -// -// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём -// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на -// устаревшем статусе решение «моя очередь» строить нельзя). -async function getInvolvedRowsViaSearch( - context: WebPartContext, - type: IRequestType, - approvalList: IList, - statuses: string[] = [] -): Promise>> { - try { - const email = currentUserEmail(context); - if (!email) { - console.warn( - `[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен` - ); - return []; - } - const claimsLogin = `i:0#.f|membership|${email}`; - - const rootFolder = (await approvalList - .select('RootFolder/ServerRelativeUrl') - .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; - const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; - if (!relUrl) return []; - // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с - // путями элементов …/Lists/List10 (см. listPathPrefix). - const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl.replace(/\/+$/, '')}/`; - - const sp = spForSite(type.siteUrl as string, context); - const pathClause = `Path:"${approvalListAbsUrl}*"`; - const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`]; - if (statuses.length > 0) { - clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`); - } - const kql = clauses.join(' AND '); - - const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`); - const ids = hits - .map((r) => Number(r.ListItemID)) - .filter((id) => !isNaN(id) && id > 0); - - if (ids.length === 0) return []; - - // 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>>).reduce((all, r) => all.concat(r), []); - } catch (e) { - console.error( - `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, - (e as Error).message - ); - return []; - } -} - // Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — // только по переданным строкам (обычно уже нарезанная страница из ≤20), не по // всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). @@ -723,7 +804,7 @@ export interface IListTarget { * уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при * старте страницы. Заполнен только когда партиция известна реестру; для * фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда - * indexApprovalsForPartition сам дочитает Title через REST. + * resolveApprovalSearchTarget сам дочитает Title через REST. */ title?: string; } @@ -1177,7 +1258,7 @@ export async function getOwnRequestsByStatus( } // Список согласования по типу заявки: Title списка данных → Title списка -// согласования по конвенции имён (та же логика, что в indexApprovalsForType). +// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget). // undefined — список заведён не по конвенции, маршрута для него не найти. async function approvalListFor( context: WebPartContext,