как будто все готово
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user