From 68638c765e1573caf5bb8df50d2461735782f0f3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 15:05:46 +0500 Subject: [PATCH] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D1=87=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 23 +- .../features/myRequests/myRequests.service.ts | 231 ++++++++---------- .../approval/approverDecision.service.ts | 13 +- 3 files changed, 122 insertions(+), 145 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 1bf0f2d..22dc127 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -7,6 +7,7 @@ import { getPendingApproval, getPendingApprovalIndex, hydratePendingAuthors, + searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; @@ -116,12 +117,19 @@ const Table: React.FC<{ // Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // -// 'own' — Search сразу с пагинацией (RowLimit/StartRow): автор = текущий -// пользователь + текст запроса по всем полям. +// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса — +// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом — +// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше +// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого +// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости +// от того, попала ли она в окно подстраховки); эту гибридную логику убрали. // -// 'approvals' без поиска — обычный REST, отфильтрованный по ApproverId(+Status), -// страница нарезается на СЕРВЕРЕ (getPendingApproval сам берёт нужный срез -// индекса и дочитывает автора только для него). +// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в +// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow). +// +// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status), +// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает +// автора только для него). // 'approvals' с поиском — область расширяется до «где пользователь вообще есть // в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично // его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый @@ -168,7 +176,10 @@ export const RequestsTableView: React.FC = ({ const load = async (): Promise => { try { if (kind === 'own') { - const r = await getOwnRequests(context, types, query, page); + const hasOwnQuery = query.trim().length > 0; + const r = hasOwnQuery + ? await searchOwnRequests(context, types, query, page) + : await getOwnRequests(context, types, page); if (cancelled) return; setRows(r.rows); setTotalRows(r.totalRows); diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 7051a97..2da7aba 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -16,8 +16,10 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход -// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType, -// filterPendingByQuery). +// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, +// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для +// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная +// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). const RECENT_MS = 10 * 60 * 1000; export function currentUserEmail(context: WebPartContext): string { @@ -178,41 +180,42 @@ async function indexApprovalsForType( 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 || r.Status === REQUEST_STATUS.onExecution - ); - 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 - // (кастомное поле, не гарантировано, что проиндексировано — см. лог). + // 'myTurn' — гибрид для масштаба (список согласования может быть очень + // большим): Search находит кандидатов по managed property ApproverOWSUSER + // без сканирования списка целиком, плюс REST по «последним N минутам» + // (Modified) добирает то, что Search мог ещё не проиндексировать. Status + // в обоих случаях берётся СВЕЖИМ через REST по найденным Id (не из индекса + // Search) — именно это делает getInvolvedRowsViaSearch, поэтому решение + // «моя очередь сейчас» всегда точное, даже когда список кандидатов пришёл + // из Search. 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 searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); const seenIds = new Set(recentRows.map((r) => r.Id)); - stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id))); + const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); + + stepRows = allRows.filter( + (r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution + ); + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}` + ); + } else { + // 'involved' — используется ТОЛЬКО для текстового поиска (см. + // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная + // свежесть, а полнота по всему архиву, поэтому ищем чисто через Search + // по managed property ApproverOWSUSER, без REST-подстраховки на + // «последние N минут» — гибрид REST+Search приводил к неустойчивому + // результату (заявка то находилась, то нет, в зависимости от того, + // успел ли Search её проиндексировать к моменту REST-окна). + stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}` + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}` ); } @@ -251,15 +254,15 @@ async function indexApprovalsForType( } } -// Архивная (не только что созданная/изменённая) часть 'involved' — через -// Search по managed property ApproverOWSUSER (кастомное Person-поле, НЕТ -// гарантии, что проиндексировано без ручной настройки Search Schema — если -// лог покажет «найдено: 0» при заведомо непустом архиве, значит не сработало -// и стоит вернуться на чистый REST для этой ветки). +// 'involved' целиком — через Search по managed property ApproverOWSUSER +// (кастомное Person-поле, НЕТ гарантии, что проиндексировано без ручной +// настройки Search Schema — если лог покажет «найдено: 0» при заведомо +// непустом архиве, значит не сработало). // // Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём -// свежими через REST по этим Id, а не из индекса (индекс мог устареть). -async function getArchivalInvolvedRows( +// свежими через REST по этим Id (точечное чтение по известным Id — не поиск, +// а обычное дочитывание данных, как и везде в проекте для найденных Search'ом строк). +async function getInvolvedRowsViaSearch( context: WebPartContext, type: IRequestType, approvalList: IList, @@ -373,23 +376,6 @@ 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; @@ -466,15 +452,9 @@ async function loadByIds( } // Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, -// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на -// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже -// подводил на Status (см. indexApprovalsForType), поэтому в $filter только -// нечувствительные к этому условия (AuthorId/Created). -async function getFreshOwnRowsForType( - context: WebPartContext, - type: IRequestType, - query: string -): Promise { +// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса — +// матчить название не нужно, отдаём как есть. +async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { const sp = spfi(type.siteUrl as string).using(SPFx(context)); const me = await sp.web.currentUser.select('Id')(); @@ -487,24 +467,55 @@ async function getFreshOwnRowsForType( .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)); + return rows.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')); } catch (e) { console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message); return []; } } -// «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий -// пользователь, текст запроса по всем полям. Пагинация серверная -// (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок. -// -// На первой странице довешиваем то, что Search мог ещё не проиндексировать -// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и -// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так -// оказалось бы на первой странице, будь оно проиндексировано. +// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией +// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по +// «последним N минутам» добирает то, что Search мог не успеть проиндексировать +// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по +// дате там и оказалось бы, будь оно проиндексировано. export async function getOwnRequests( + context: WebPartContext, + types: IRequestType[], + page: number +): Promise { + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const searchPage = await searchOwnRequests(context, types, '', page); + + if (page !== 1) return searchPage; + + const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce( + (all, r) => all.concat(r), + [] as IMyRequest[] + ); + + const searchKeys = new Set(searchPage.rows.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(searchPage.rows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + + return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length }; +} + +// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть: +// только что созданная заявка может найтись не сразу (задержка индексации), но +// зато результат предсказуем — не зависит от 10-минутного REST-окна. +export async function searchOwnRequests( context: WebPartContext, types: IRequestType[], query: string, @@ -539,45 +550,16 @@ export async function getOwnRequests( const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. - const searchRows = entries - .map((e) => byKey.get(`${e.type.id}-${e.id}`)) - .filter((r): r is IMyRequest => !!r); + const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); - 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 }; + return { rows, totalRows: results.TotalRows || 0 }; } // «На согласовании»: список заявок уже отобран по строкам согласования -// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья -// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту. -// -// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать — -// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search. -// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст -// идёт свободным термом по всем полям. Порядок сохраняется (rows уже -// отсортированы по дате в buildPendingIndex, свежие — в начале). +// (getPendingApprovalIndex — там же определяется, чья сейчас очередь, +// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search +// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по +// всем полям), без клиентского REST-матчинга по свежести. export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -587,21 +569,15 @@ export async function filterPendingByQuery( const text = freeTextClause(query); if (!text || rows.length === 0) return rows; - 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) { + if (rows.length > KQL_ID_LIMIT) { console.warn( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${older.length}` + `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` ); } - const candidates = older.slice(0, KQL_ID_LIMIT); + const candidates = rows.slice(0, KQL_ID_LIMIT); const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); - if (scopeTypes.length === 0) return freshMatches; + if (scopeTypes.length === 0) return []; const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); const kql = [ @@ -622,9 +598,7 @@ export async function filterPendingByQuery( // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); - const olderMatches = candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); - - return freshMatches.concat(olderMatches); + return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); } // --------------------------------------------------------------------------- @@ -644,8 +618,8 @@ export interface ISearchPage { totalRows: number; } -// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до -// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST. +// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до +// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. async function searchOwnAll( context: WebPartContext, types: IRequestType[], @@ -675,16 +649,7 @@ async function searchOwnAll( const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); - const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); - - 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}`)); - - return onlyFresh.concat(searchRows); + return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); } export async function searchMyRequests( @@ -713,7 +678,7 @@ export async function searchMyRequests( const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с - // автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author). + // автором (loadByIds сам дочитывает Author). const needAuthor = pageRows.filter((r) => r.kind === 'approval'); if (needAuthor.length > 0) { await hydratePendingAuthors(context, involvedIdx.types, needAuthor); diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 207dd0e..40dcd4b 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -38,13 +38,14 @@ export class ApproverService { public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); const userId = await this.approval.getCurrentUserId(); - // Активна первая незакрытая строка по очереди: согласующий («На согласовании») - // или, когда согласование пройдено, исполнитель («На исполнении»). - const current = rows.filter( + // Активные строки текущего шага: согласующий («На согласовании») или, когда + // согласование пройдено, исполнитель («На исполнении»). При параллельном шаге + // (одинаковый StepOrder) активных строк может быть несколько сразу — ищем + // среди ВСЕХ активных, а не только среди первой по порядку. + const active = rows.filter( (r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution - )[0]; - if (current && current.approverId === userId) return current; - return undefined; + ); + return active.filter((r) => r.approverId === userId)[0]; } // Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между