@@ -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 < Record < string , unknown > > ;
if ( scope === 'myTurn' ) {
// 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status дол жен быть
// гарантированно свежим (индекс Search обновляется с задержкой и мог бы
// показать уже решённый шаг как ожидающий). Поэтому здесь только REST,
// весь список по ApproverId, без архивного Search-довеска.
const allRows : Array < Record < string , unknown > > = [ ] ;
for await ( const p of approvalList . items
. filter ( ` ApproverId eq ${ approver . Id } ` )
. select ( 'Id' , 'TicketId' , 'Status' )
. top ( 2000 ) ) {
allRows . push ( . . . ( p as Array < Record < string , unknown > > ) ) ;
}
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 < Record < string , unknown > > ;
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 < IMyRequest [ ] > {
// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса —
// матчить название не нужно, отдаём как есть.
async function getFreshOwnRowsForType ( context : WebPartContext , type : IRequestType ) : Promise < IMyRequest [ ] > {
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 < 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 ) ) ;
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 < IOwnRequestsPage > {
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 < string , IMyRequest > ( ) ;
// Порядок задаёт релевантность поиска, а не порядок ответов 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 ;
}
// Как get OwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST .
// Как search OwnRequests, но без $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 < string , IMyRequest > ( ) ;
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 ) ;