как будто все готово

This commit is contained in:
gorbenko.pavel
2026-08-17 17:50:50 +05:00
parent 973a3a40af
commit 34cab4d03d
9 changed files with 498 additions and 96 deletions
@@ -7,7 +7,7 @@ import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
@@ -259,6 +259,15 @@ export async function hydratePendingAuthors(
// произвольные поля связанного элемента, только через Person-поля вроде
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
/**
* Обходит ВСЕ партиции типа заявки: у каждой партиции своя пара списков, и строки
* маршрута заявки лежат в списке согласования ТОЙ ЖЕ партиции, что и сама заявка.
* Читать только текущую партицию означало бы терять из «На согласовании» всё, что
* уехало в архив.
*
* Диагностика (debug) агрегируется по серии: счётчики суммируются, заголовки
* списков согласования перечисляются через запятую.
*/
async function indexApprovalsForType(
context: WebPartContext,
type: IRequestType,
@@ -274,17 +283,61 @@ async function indexApprovalsForType(
skipped: false,
};
try {
if (!tokenEmail) return { rows: [], debug };
if (!tokenEmail) return { rows: [], debug };
const sp = spForSite(type.siteUrl as string, context);
const list = sp.web.getList(type.listId as string);
// listTargetsForType сам откатывается на RequestType.listId, если реестр
// партиций не загрузился — тогда ведём себя как раньше, по одному списку.
const targets = listTargetsForType(type);
const rows: IMyRequest[] = [];
const approvalTitles: string[] = [];
for (const target of targets) {
const part = await indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses);
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<IPartitionApprovalResult> {
const debug: IPartitionApprovalResult = { rows: [] };
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
const dataListInfo = await list.select('Title')();
const dataTitle = (dataListInfo.Title as string) || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
return { rows: [], debug };
return debug;
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
@@ -358,10 +411,7 @@ async function indexApprovalsForType(
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
}
});
if (ticketIds.length === 0) {
debug.approvalPendingTickets = 0;
return { rows: [], debug };
}
if (ticketIds.length === 0) return debug;
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
// комментарий у функции: через Lookup-expand это не отдаётся).
@@ -371,20 +421,19 @@ async function indexApprovalsForType(
.select('Id', 'Title', 'RequestStatus', 'Created')
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
const rows = ticketRows.map((r) => ({
...toMyRequest(r, undefined, type, 'approval'),
debug.rows = ticketRows.map((r) => ({
...toMyRequest(r, undefined, type, 'approval', target),
myStepStatus: stepStatusByTicket.get(r.Id as number),
}));
debug.approvalPendingTickets = rows.length;
return { rows, debug };
return debug;
} catch (e) {
debug.approvalError = (e as Error).message;
debug.error = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
`Мои заявки: не удалось прочитать согласование для «${type.name}» (${target.listId}):`,
debug.error
);
return { rows: [], debug };
return debug;
}
}
@@ -478,40 +527,49 @@ async function hydrateAuthors(
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const idsByType = new Map<number, number[]>();
// Группируем по ПАРТИЦИИ строки, а не по её типу: каждая строка помнит, из
// какого списка её прочитали (IMyRequest.listId), и дочитывать автора надо
// оттуда же — в текущей партиции того Id либо нет, либо это другая заявка.
const byPartition = new Map<string, { siteUrl: string; listId: string; typeName: string; ids: number[] }>();
rows.forEach((r) => {
const ids = idsByType.get(r.requestTypeId) || [];
ids.push(r.id);
idsByType.set(r.requestTypeId, ids);
const bucket = byPartition.get(r.listId.toLowerCase()) || {
siteUrl: r.siteUrl,
listId: r.listId,
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
ids: [],
};
bucket.ids.push(r.id);
byPartition.set(r.listId.toLowerCase(), bucket);
});
const byKey = new Map<string, { EMail?: string; Title?: string }>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
Array.from(byPartition.values()).map(async ({ siteUrl, listId, typeName, ids }) => {
try {
const sp = spForSite(type.siteUrl as string, context);
const sp = spForSite(siteUrl, context);
const authorRows = (await sp.web
.getList(type.listId as string)
.getList(listId)
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(ids.length)()) as Array<Record<string, unknown>>;
authorRows.forEach((r) => {
byKey.set(
`${requestTypeId}-${r.Id as number}`,
requestKey(listId, r.Id as number),
(r.Author as { EMail?: string; Title?: string }) || {}
);
});
} catch (e) {
console.error(`[Текущие заявки] Не удалось дочитать автора для «${type.name}»:`, (e as Error).message);
console.error(
`[Текущие заявки] Не удалось дочитать автора для «${typeName}» (${listId}):`,
(e as Error).message
);
}
})
);
rows.forEach((r) => {
const a = byKey.get(`${r.requestTypeId}-${r.id}`);
const a = byKey.get(rowKey(r));
r.authorEmail = a?.EMail || '';
r.authorName = a?.Title || '';
});
@@ -609,10 +667,95 @@ function listPathPrefix(type: IRequestType): string {
return `${listAbsUrl(type).replace(/\/+$/, '')}/`;
}
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка —
// сравнение по границе сегмента, не по «голому» началу строки.
/** Один конкретный список серии: куда идти читать и какому префиксу пути он соответствует. */
export interface IListTarget {
/** Серверный относительный url списка данных. */
listId: string;
siteUrl: string;
/** Абсолютный префикс пути элементов этого списка, со слешем на конце. */
prefix: string;
}
/**
* ВСЕ партиции типа заявки (архив + текущая) как цели чтения.
*
* Заявки одного типа живут не в одном списке, а в нумерованной серии: когда
* текущий список добирает порог элементов, approval-route создаёт следующую пару
* и переключает запись на неё. Читать нужно по всей серии, иначе заявки из архива
* пропадут из выдачи — а RequestType.listId указывает только на ТЕКУЩУЮ партицию.
*
* Фолбэк на listId, если реестр партиций не загрузился (approval-route не
* ответил при старте): тогда всё ведёт себя как раньше — по одному списку.
*/
export function listTargetsForType(type: IRequestType): IListTarget[] {
const partitions = partitionsForTypeId(type.id);
if (partitions.length === 0) {
return [
{ listId: type.listId as string, siteUrl: type.siteUrl as string, prefix: listPathPrefix(type) }
];
}
return partitions.map((p) => ({
listId: p.listId,
siteUrl: p.siteUrl,
prefix: `${siteOrigin(p.siteUrl).replace(/\/+$/, '')}${p.listId.replace(/\/+$/, '')}/`
}));
}
/** KQL-условие Path по всем партициям всех переданных типов. */
function pathClauseForTypes(types: IRequestType[]): string {
const prefixes = types.reduce<string[]>(
(all, t) => all.concat(listTargetsForType(t).map((target) => target.prefix)),
[]
);
return `(${prefixes.map((prefix) => `Path:"${prefix}*"`).join(' OR ')})`;
}
/**
* Партиция, которой принадлежит хит — по префиксу его пути.
*
* Именно партиция, а не только тип: перечитывать элемент нужно из ТОГО ЖЕ списка,
* где его нашёл Search. Один и тот же Id существует в каждой партиции серии
* (нумерация элементов в каждом списке SharePoint своя), поэтому «тип + Id» не
* идентифицирует заявку однозначно.
*/
export function targetForPath(path: string, type: IRequestType): IListTarget | undefined {
const lowerPath = path.toLowerCase();
return listTargetsForType(type).filter((t) => lowerPath.indexOf(t.prefix.toLowerCase()) === 0)[0];
}
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ одного из его
// списков — сравнение по границе сегмента, не по «голому» началу строки.
export function pathBelongsToList(path: string, type: IRequestType): boolean {
return path.toLowerCase().indexOf(listPathPrefix(type).toLowerCase()) === 0;
return !!targetForPath(path, type);
}
/**
* Ключ идентичности заявки — ПАРТИЦИЯ + Id, а не тип + Id.
*
* Нумерация элементов в каждом списке SharePoint своя, поэтому Id 5 существует и
* в партиции №1, и в №2 — это разные заявки. Ключ по типу схлопнул бы их в одну.
*/
function requestKey(listId: string, id: number): string {
return `${listId.toLowerCase()}-${id}`;
}
function rowKey(row: IMyRequest): string {
return requestKey(row.listId, row.id);
}
/**
* Партиция, в которую идёт ЗАПИСЬ — она же единственная, где могут появиться
* свежие заявки. Нужна довескам по свежести: перебирать ради них весь архив
* бессмысленно (там по определению ничего нового), а Search старое и так найдёт.
*
* RequestType.listId всегда указывает на текущую партицию — approval-route
* переключает его, создавая следующую.
*/
function currentTargetForType(type: IRequestType): IListTarget {
const targets = listTargetsForType(type);
const current = targets.filter((t) => t.listId.toLowerCase() === (type.listId || '').toLowerCase())[0];
return current ?? targets[targets.length - 1];
}
// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
@@ -646,7 +789,7 @@ function freeTextClause(query: string): string | undefined {
function hitsToEntries(
results: { PrimarySearchResults: unknown[] },
types: IRequestType[]
): Array<{ id: number; type: IRequestType }> {
): ISearchEntry[] {
const seen = new Set<string>();
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
@@ -654,18 +797,31 @@ function hitsToEntries(
.map((h) => {
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
// indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве.
const matched = types.filter((t) => pathBelongsToList(h.path, t))[0];
return matched ? { id: h.id, type: matched } : undefined;
//
// Ищем не только тип, но и КОНКРЕТНУЮ партицию: перечитывать элемент нужно
// из того же списка, где его нашёл Search (см. targetForPath).
for (const type of types) {
const target = targetForPath(h.path, type);
if (target) return { id: h.id, type, target };
}
return undefined;
})
.filter((e): e is { id: number; type: IRequestType } => !!e)
.filter((e): e is ISearchEntry => !!e)
.filter((e) => {
const key = `${e.type.id}-${e.id}`;
const key = requestKey(e.target.listId, e.id);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
interface ISearchEntry {
id: number;
type: IRequestType;
target: IListTarget;
}
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
// кастомных полей нет гарантии, что они проиндексированы как managed property).
//
@@ -675,21 +831,24 @@ function hitsToEntries(
// предел длины URL SharePoint REST.
async function loadByIds(
context: WebPartContext,
entries: Array<{ id: number; type: IRequestType }>,
entries: ISearchEntry[],
kind: 'own' | 'approval'
): Promise<Map<string, IMyRequest>> {
const idsByType = new Map<IRequestType, number[]>();
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
entries.forEach((e) => {
const ids = idsByType.get(e.type) || [];
ids.push(e.id);
idsByType.set(e.type, ids);
const key = e.target.listId.toLowerCase();
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
bucket.ids.push(e.id);
byPartition.set(key, bucket);
});
const byKey = new Map<string, IMyRequest>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => {
const sp = spForSite(type.siteUrl as string, context);
const list = sp.web.getList(type.listId as string);
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
await Promise.all(
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
@@ -700,7 +859,10 @@ async function loadByIds(
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind));
byKey.set(
requestKey(target.listId, r.Id as number),
toMyRequest(r, author, type, kind, target)
);
});
})
);
@@ -714,12 +876,17 @@ async function loadByIds(
// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ
// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
// Только ТЕКУЩАЯ партиция: это довесок по свежести, а свежие заявки бывают
// только там, куда идёт запись. Архивные придут из Search (он ищет по всей
// серии, см. pathClauseForTypes).
const target = currentTargetForType(type);
try {
const sp = spForSite(type.siteUrl as string, context);
const sp = spForSite(target.siteUrl, context);
const me = await sp.web.currentUser.select('Id')();
const rows = (await sp.web
.getList(type.listId as string)
.getList(target.listId)
.items.filter(`AuthorId eq ${me.Id}`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
@@ -733,7 +900,7 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
}
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
);
} catch (e) {
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
@@ -763,20 +930,23 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
// (в отличие от статуса, см. getOwnRequestsByStatus).
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
// Только текущая партиция — см. getOwnRowsForType: недавно изменённое лежит там же.
const target = currentTargetForType(type);
try {
const sp = spForSite(type.siteUrl as string, context);
const sp = spForSite(target.siteUrl, context);
const me = await sp.web.currentUser.select('Id')();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.getList(target.listId)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
);
} catch (e) {
console.error(
@@ -808,8 +978,8 @@ export async function getOwnRequests(
[] 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}`));
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
const merged = onlyFresh
.concat(searchPage.rows)
@@ -834,7 +1004,7 @@ export async function searchOwnRequests(
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
@@ -856,7 +1026,7 @@ export async function searchOwnRequests(
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
return { rows, totalRows };
}
@@ -880,7 +1050,7 @@ export async function getOwnRequestsByStatus(
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 ')})`,
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_REQUEST_STATUS}:"${status}"`,
];
@@ -901,7 +1071,7 @@ export async function getOwnRequestsByStatus(
const totalRows = results.TotalRows || 0;
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 searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
@@ -912,8 +1082,8 @@ export async function getOwnRequestsByStatus(
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
const searchKeys = new Set(searchRows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
const merged = onlyFresh
.concat(searchRows)
@@ -928,10 +1098,10 @@ export async function getOwnRequestsByStatus(
// undefined — список заведён не по конвенции, маршрута для него не найти.
async function approvalListFor(
context: WebPartContext,
type: IRequestType
target: IListTarget
): Promise<IList | undefined> {
const sp = spForSite(type.siteUrl as string, context);
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
const sp = spForSite(target.siteUrl, context);
const info = (await sp.web.getList(target.listId).select('Title')()) as { Title?: string };
const dataTitle = info.Title || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
@@ -968,19 +1138,26 @@ export async function hydrateDecisionComments(
const typeById = new Map<number, IRequestType>();
types.forEach((t) => typeById.set(t.id, t));
const rowsByType = new Map<number, IMyRequest[]>();
// По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
// согласования той же партиции, что и сама заявка. Для архивной заявки
// «список согласования её типа» — это чужой список текущей партиции, где по
// её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
rows.forEach((r) => {
const list = rowsByType.get(r.requestTypeId) || [];
list.push(r);
rowsByType.set(r.requestTypeId, list);
const key = r.listId.toLowerCase();
const bucket = rowsByPartition.get(key) || {
target: { listId: r.listId, siteUrl: r.siteUrl, prefix: '' },
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
rows: [],
};
bucket.rows.push(r);
rowsByPartition.set(key, bucket);
});
await Promise.all(
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
const type = typeById.get(requestTypeId);
if (!type) return;
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
try {
const approvalList = await approvalListFor(context, type);
const approvalList = await approvalListFor(context, target);
if (!approvalList) return;
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
@@ -1010,7 +1187,7 @@ export async function hydrateDecisionComments(
});
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать комментарий к решению:`,
(e as Error).message
);
}
@@ -1041,7 +1218,7 @@ export async function filterPendingByQuery(
if (scopeTypes.length === 0) return [];
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`;
const pathClause = pathClauseForTypes(scopeTypes);
const found = new Set<string>();
await Promise.all(
@@ -1060,12 +1237,12 @@ export async function filterPendingByQuery(
);
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
found.add(`${e.type.id}-${e.id}`)
found.add(requestKey(e.target.listId, e.id))
);
})
);
return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
return rows.filter((r) => found.has(rowKey(r)));
}
// ---------------------------------------------------------------------------
@@ -1099,7 +1276,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:"${listPathPrefix(t)}*"`).join(' OR ')})`,
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
@@ -1114,7 +1291,7 @@ export async function searchOwnAll(
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
traceResultRows('Свои заявки', types, entries, rows);
return rows;
@@ -1152,8 +1329,8 @@ export async function searchMyRequests(
// Заявка может оказаться и своей, и involved одновременно (сам себе
// согласующий) — оставляем как 'own', не дублируем строку.
const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
const ownKeys = new Set(ownRows.map((r) => rowKey(r)));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(rowKey(r)));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
return { rows: merged, totalRows: merged.length };
@@ -1163,7 +1340,8 @@ function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval'
kind: 'own' | 'approval',
target: IListTarget
): IMyRequest {
return {
id: r.Id as number,
@@ -1177,5 +1355,7 @@ function toMyRequest(
section: type.section,
formKey: type.pageUrl,
kind,
listId: target.listId,
siteUrl: target.siteUrl,
};
}