From 4627675ccf6e76447aff9406a8d8ea9f73720a0c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 14 Aug 2026 17:56:17 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B2=D1=80=D0=BE=D0=B4=D0=B5=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 49 +++-- .../features/myRequests/searchTrace.ts | 167 ++++++++++++++++++ 2 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/searchTrace.ts diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 48c3d3c..b241bac 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -10,6 +10,7 @@ import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/appr import { effectiveUserEmail } from '../../shared/impersonation'; import { IMyRequest } from './models/IMyRequest'; import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; +import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace'; // Список данных «... — данные» → список согласования «... — согласование» // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). @@ -65,7 +66,9 @@ export function currentUserEmail(context: WebPartContext): string { // Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению). export async function getLinkedTypes(context: WebPartContext): Promise { const types = await getRequestTypes(context); - return types.filter((t) => !!t.listId && !!t.siteUrl); + const linked = types.filter((t) => !!t.listId && !!t.siteUrl); + traceLinkedTypes(types, linked); + return linked; } // Что показывать в разделе «На согласовании»: @@ -406,7 +409,9 @@ async function getInvolvedRowsViaSearch( .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; if (!relUrl) return []; - const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`; + // Слеш на конце обязателен: без него префикс …/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}*"`; @@ -526,6 +531,24 @@ function listAbsUrl(type: IRequestType): string { return `${siteOrigin(type.siteUrl as string)}${type.listId}`; } +// Префикс пути для KQL-условия Path и для сопоставления хитов с типом. +// +// Обязательно СО слешем на конце: адреса списков в этом сайте отличаются только +// номером (…/Lists/List1, …/Lists/List10), и «голый» префикс List1 совпадает с +// путями элементов List10. В KQL это притягивало чужие элементы, а при разборе +// ответа хит уезжал в первый подошедший тип — заявки «Обходного листа» +// приписывались списку List1, дальше их Id искались не в том списке и строки +// молча исчезали из выдачи. +function listPathPrefix(type: IRequestType): string { + return `${listAbsUrl(type).replace(/\/+$/, '')}/`; +} + +// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка — +// сравнение по границе сегмента, не по «голому» началу строки. +export function pathBelongsToList(path: string, type: IRequestType): boolean { + return path.toLowerCase().indexOf(listPathPrefix(type).toLowerCase()) === 0; +} + // KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.), // цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает) function sanitizeForKql(text: string): string { @@ -556,14 +579,15 @@ function hitsToEntries( results: { PrimarySearchResults: unknown[] }, types: IRequestType[] ): Array<{ id: number; type: IRequestType }> { - const urls = types.map((t) => ({ type: t, url: listAbsUrl(t).toLowerCase() })); const seen = new Set(); return (results.PrimarySearchResults as Array>) - .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '').toLowerCase() })) + .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') })) .filter((h) => !isNaN(h.id) && h.id > 0) .map((h) => { - const matched = urls.filter((u) => h.path.indexOf(u.url) === 0)[0]; - return matched ? { id: h.id, type: matched.type } : undefined; + // Сравнение по границе сегмента (см. listPathPrefix): при «голом» + // indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве. + const matched = types.filter((t) => pathBelongsToList(h.path, t))[0]; + return matched ? { id: h.id, type: matched } : undefined; }) .filter((e): e is { id: number; type: IRequestType } => !!e) .filter((e) => { @@ -733,7 +757,7 @@ export async function searchOwnRequests( const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ - `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, `AuthorOWSUSER:"${claimsLogin}"`, ]; const text = freeTextClause(query); @@ -926,7 +950,7 @@ export async function filterPendingByQuery( const sp = spForSite(scopeTypes[0].siteUrl as string, context); const kql = [ - `(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, text, ].join(' AND '); @@ -973,7 +997,7 @@ export async function searchOwnAll( const sp = spForSite(types[0].siteUrl as string, context); const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ - `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, `AuthorOWSUSER:"${claimsLogin}"`, ]; const text = freeTextClause(query); @@ -988,9 +1012,14 @@ export async function searchOwnAll( }); + traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); + const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); - return 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); + + traceResultRows('Свои заявки', types, entries, rows); + return rows; } /** diff --git a/src/webparts/requestPortal/features/myRequests/searchTrace.ts b/src/webparts/requestPortal/features/myRequests/searchTrace.ts new file mode 100644 index 0000000..f2a0614 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/searchTrace.ts @@ -0,0 +1,167 @@ +import { IRequestType } from '../../shared/approval'; + +/** + * Точечная отладка поиска по ОДНОМУ типу заявки. + * + * Зачем отдельный модуль: тип может выпасть из выдачи на любом из пяти шагов + * (не пришёл из API маршрутов → не попал в KQL → индекс его не отдал → Path + * хита не совпал с адресом списка → REST не вернул строку), и снаружи все пять + * выглядят одинаково — «заявок этого типа нет». Трассировка печатает каждый + * шаг отдельно, чтобы было видно, на каком именно набор становится пустым. + * + * Включение/выключение БЕЗ пересборки — из консоли браузера: + * window.rpSearchTrace = 'обходной' // подстрока имени типа или ключа формы + * window.rpSearchTrace = true // следить за всеми типами + * window.rpSearchTrace = false // выключить + * Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен. + */ +const DEFAULT_TRACE: string | boolean = 'обходной'; + +const PREFIX = '[Поиск]'; + +function traceSetting(): string | boolean { + const w = window as unknown as { rpSearchTrace?: string | boolean }; + return w.rpSearchTrace === undefined ? DEFAULT_TRACE : w.rpSearchTrace; +} + +function matches(type: IRequestType, setting: string | boolean): boolean { + if (setting === true) return true; + if (!setting) return false; + const needle = String(setting).toLowerCase(); + return ( + (type.name || '').toLowerCase().indexOf(needle) >= 0 || + (type.pageUrl || '').toLowerCase().indexOf(needle) >= 0 + ); +} + +/** Типы, за которыми сейчас следим. Пусто — трассировка выключена. */ +export function tracedTypes(types: IRequestType[]): IRequestType[] { + const setting = traceSetting(); + if (!setting) return []; + return types.filter((t) => matches(t, setting)); +} + +export function traceEnabled(): boolean { + return !!traceSetting(); +} + +/** + * Шаг 1: что вообще пришло из API маршрутов и что из этого пригодно для чтения. + * + * Тип без listId/siteUrl молча отбрасывается (см. getLinkedTypes) — до поиска + * он не доходит вообще, и это первая версия, которую надо исключить. + */ +export function traceLinkedTypes(all: IRequestType[], linked: IRequestType[]): void { + const setting = traceSetting(); + if (!setting) return; + + const watched = all.filter((t) => matches(t, setting)); + if (watched.length === 0) { + console.warn( + `${PREFIX} Тип по фильтру «${String(setting)}» не найден среди ${all.length} типов из API маршрутов. ` + + `Есть: ${all.map((t) => `${t.name} (${t.pageUrl})`).join(', ')}` + ); + return; + } + + watched.forEach((t) => { + const isLinked = linked.some((l) => l.id === t.id); + console.log(`${PREFIX} Тип «${t.name}» (id=${t.id}, ключ=${t.pageUrl}):`, { + 'дошёл до поиска': isLinked, + listId: t.listId ?? '(нет)', + siteUrl: t.siteUrl ?? '(нет)', + }); + if (!isLinked) { + console.warn( + `${PREFIX} Тип «${t.name}» в поиске НЕ участвует: у него нет listId и/или siteUrl ` + + `в approval-route API — заполните их в админке, иначе портал не знает, где лежит список.` + ); + } + }); +} + +/** + * Шаг 2–4: KQL, сырые хиты индекса и сопоставление их с адресом списка. + * + * Path каждого хита сверяется с абсолютным адресом списка (см. hitsToEntries). + * Если индекс что-то отдал, а совпадений с этим типом нет — печатаем образцы + * путей: расхождение обычно видно глазами (другой сайт, /Lists/ против + * человекочитаемого адреса, иной регистр). + */ +export function traceSearchHits( + label: string, + kql: string, + types: IRequestType[], + results: { PrimarySearchResults: unknown[]; TotalRows?: number }, + listAbsUrl: (type: IRequestType) => string +): void { + const watched = tracedTypes(types); + if (watched.length === 0) return; + + const hits = (results.PrimarySearchResults as Array>).map((r) => + String(r.Path || '').toLowerCase() + ); + + // Со слешем на конце — ровно как в боевом сопоставлении (см. listPathPrefix): + // иначе трассировка показывала бы «хиты есть» там, где код уже отдал их + // другому типу с более коротким адресом (…/Lists/List1 против …/Lists/List10). + const prefix = (t: IRequestType): string => `${listAbsUrl(t).toLowerCase().replace(/\/+$/, '')}/`; + + console.log(`${PREFIX} ${label}: запрос отправлен`, { + KQL: kql, + 'хитов получено': hits.length, + 'TotalRows индекса': results.TotalRows ?? 0, + }); + + watched.forEach((t) => { + const url = prefix(t); + const mine = hits.filter((p) => p.indexOf(url) === 0); + console.log(`${PREFIX} ${label}: «${t.name}» — хитов индекса ${mine.length}`, { + 'адрес списка (ожидаемый префикс Path)': url, + }); + + if (mine.length === 0) { + // Индекс мог не отдать НИЧЕГО (тогда дело не в путях), либо отдать + // чужие хиты (тогда почти наверняка не совпал префикс). + console.warn( + `${PREFIX} ${label}: по типу «${t.name}» индекс не вернул ни одного хита. ` + + (hits.length === 0 + ? 'Хитов нет вообще — проверьте сам запрос и права.' + : `Пути первых чужих хитов (сравните с ожидаемым префиксом выше): ${hits + .slice(0, 5) + .join(' | ')}`) + ); + } + }); +} + +/** + * Шаг 5: сколько строк этого типа дожило до выдачи после дедупа, проверки + * существования и дочитывания полей через REST. + * + * Расхождение «хиты были, строк нет» означает, что элементы из индекса в + * списке уже не существуют (удалены, индекс отстал) либо REST их не отдал. + */ +export function traceResultRows( + label: string, + types: IRequestType[], + entries: Array<{ type: IRequestType }>, + rows: Array<{ requestTypeId: number }> +): void { + const watched = tracedTypes(types); + if (watched.length === 0) return; + + watched.forEach((t) => { + const mineEntries = entries.filter((e) => e.type.id === t.id).length; + const mineRows = rows.filter((r) => r.requestTypeId === t.id).length; + console.log( + `${PREFIX} ${label}: «${t.name}» — после дедупа/проверки ${mineEntries}, в выдаче ${mineRows}` + ); + if (mineEntries > 0 && mineRows === 0) { + console.warn( + `${PREFIX} ${label}: по типу «${t.name}» элементы в индексе есть, но ни одной строки ` + + `не дочиталось через REST — вероятно, элементы удалены (индекс отстал) или список недоступен.` + ); + } + }); +}