diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 1a05bc7..344b76b 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -57,18 +57,21 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER'; // Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») — // Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором // 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с -// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком. -// -// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь -// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо -// знать точно, либо угадывать по конвенции — угадали неверно (или колонка -// после переименования Status → RequestStatus ещё не переиндексирована), и -// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки -// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST -// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному -// AuthorOWSUSER, статус ни при чём. +// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком (см. MP_REQUEST_STATUS +// ниже). const MP_STATUS = 'ApprovalStatusOWSCHCS'; +// Статус ЗАЯВКИ (колонка RequestStatus списка ДАННЫХ) — Choice-поле, тот же +// суффикс OWSCHCS, без префикса группы колонок сайта (поле локальное для +// списка, как и ApprovalStatus выше). Раньше здесь стояло «угадали неверно, +// фильтр молча возвращает 0» — перепроверено вручную через +// /_api/search/query 2026-08-17: и голый запрос по этому свойству, и в +// связке с Path, отдают счётчики, совпадающие с REST-эталоном (см. +// getOwnRequestsByStatus). Старый вывод о поломке был артефактом кривого +// тестового Path (задвоенный сегмент /sites/…), а не реальным ограничением +// SharePoint — сама схема поиска исправна. +const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS'; + // effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности // (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны // отбираться по подменённому сотруднику — иначе подмена показывала бы чужой @@ -291,12 +294,12 @@ async function indexApprovalsForType( // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. // - // ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр - // вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча - // возвращает 0 строк, даже когда без него те же строки находятся с этим же - // значением статуса. Поэтому фильтруем только по TansuSiteApproverId - // (числовое значение, проблем нет), а статус сравниваем в JS после - // получения строк. + // ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что + // кириллица в значении делает такой фильтр ненадёжным, — перепроверено + // вручную через REST 2026-08-17 (все три статуса списка согласования дали + // точное совпадение с эталоном), теория не подтвердилась. Старый вывод о + // поломке был артефактом кривого тестового запроса, а не реальным + // ограничением SharePoint. const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; const approver = await sp.web.ensureUser(claimsLogin); @@ -311,20 +314,20 @@ async function indexApprovalsForType( // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда // точное, даже когда список кандидатов пришёл из Search. 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 ')})` : ''; const recentRows = (await approvalList.items - .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) + .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) .select('Id', 'TicketId', 'ApprovalStatus') .top(2000)()) as Array>; - // Статусы уходят в KQL: сервер сразу отдаёт только нужные строки, + // Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки, // а не всё, где пользователь есть в маршруте. const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); const seenIds = new Set(recentRows.map((r) => r.Id)); - const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); - - // REST-довесок берётся по Modified без учёта статуса (кириллица в $filter - // ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте. - stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1); + stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); } else { // 'involved' — используется ТОЛЬКО для текстового поиска (см. // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная @@ -708,9 +711,8 @@ async function loadByIds( // Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход // Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType). -// Фильтруем только по AuthorId: статус в $filter не используем, потому что -// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор -// по статусу делает вызывающая сторона. +// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ +// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона. async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { const sp = spForSite(type.siteUrl as string, context); @@ -862,17 +864,10 @@ export async function searchOwnRequests( // Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка» // и фильтр по статусу на вкладке «Инициированные». // -// Статус НЕ уходит в KQL: единственная надёжная managed property здесь — -// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки -// имя managed property надо либо знать из схемы поиска, либо угадывать по -// конвенции — угадали неверно (или она ещё не переиндексирована после -// переименования колонки Status → RequestStatus), из-за чего фильтр по -// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору -// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до -// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по -// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds). -// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по -// надёжному полю, остальное — на клиенте. +// Статус уходит прямо в KQL через MP_REQUEST_STATUS (проверено вручную +// 2026-08-17, см. комментарий у константы) — это настоящий серверный запрос +// с RowLimit/StartRow, как и searchOwnRequests, а не «стянуть всё и +// отфильтровать в памяти». export async function getOwnRequestsByStatus( context: WebPartContext, types: IRequestType[], @@ -880,28 +875,52 @@ export async function getOwnRequestsByStatus( page: number, query: string = '' ): Promise { - const all = await searchOwnAll(context, types, query); - const searchFiltered = all.filter((r) => r.status === status); + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const sp = spForSite(types[0].siteUrl as string, context); + const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; + const clauses = [ + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, + `AuthorOWSUSER:"${claimsLogin}"`, + `${MP_REQUEST_STATUS}:"${status}"`, + ]; + const text = freeTextClause(query); + if (text) clauses.push(text); + const kql = clauses.join(' AND '); + + const results = await sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху — как в searchOwnRequests. + SortList: [{ Property: 'Created', Direction: 1 }], + }); + + const totalRows = results.TotalRows || 0; + 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); + + if (page !== 1 || query.trim()) return { rows: searchRows, totalRows }; // Довесок по недавним изменениям — только на первой странице и только без - // текстового поиска (см. getOwnRequests: тот же принцип — довесок относится - // к «показать всё, свежее по дате», а не к результату полнотекстового поиска). - let merged = searchFiltered; - if (page === 1 && !query.trim()) { - const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) - .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) - .filter((r) => r.status === status); + // текстового поиска (тот же принцип, что в getOwnRequests): заявку могли + // вернуть на доработку только что, индекс мог не успеть её подхватить. + const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) + .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) + .filter((r) => r.status === status); - const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`)); - const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`)); - merged = onlyFresh.concat(searchFiltered); - } + const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); - const sorted = merged.sort((a, b) => b.created.localeCompare(a.created)); - return { - rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), - totalRows: sorted.length, - }; + const merged = onlyFresh + .concat(searchRows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + + return { rows: merged, totalRows: totalRows + onlyFresh.length }; } // Список согласования по типу заявки: Title списка данных → Title списка @@ -964,17 +983,17 @@ export async function hydrateDecisionComments( const approvalList = await approvalListFor(context, type); if (!approvalList) return; - // Фильтруем только по TicketId (числовое поле), статус сравниваем в JS: - // кириллица в $filter по Choice-колонке молча возвращает 0 строк — - // ровно та же причина, что и в indexApprovalsForType. + // TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает + // сильнее `or», без скобок фильтр разобрался бы как + // «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)». + const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '); const stepRows = (await approvalList.items - .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) - .select('TicketId', 'ApprovalStatus', 'Comment') + .filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`) + .select('TicketId', 'Comment') .top(DECISION_COMMENT_ROW_LIMIT)()) as Array>; const commentByTicket = new Map(); stepRows.forEach((r) => { - if (r.ApprovalStatus !== decisionStatus) return; const ticketId = r.TicketId as number | undefined; const comment = ((r.Comment as string) || '').trim(); // Такая строка по заявке одна (и возврат, и отклонение останавливают