import { spfi, SPFx } from '@pnp/sp'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; import { IMyRequest } from './models/IMyRequest'; import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; // Список данных «... — данные» → список согласования «... — согласование» // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; interface IApprovalStep { status: string; order?: number; approverEmail: string; } export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } // Типы заявок, у которых 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); } // --------------------------------------------------------------------------- // «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять, // чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search), // целиком за один раз. Пагинация по этому результату — на стороне панели // (клиентская нарезка по страницам), т.к. сам список обычно небольшой // (сколько заявок сейчас реально ждут решения ОДНОГО согласующего). export async function getPendingApproval( context: WebPartContext ): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> { const types = await getLinkedTypes(context); const tokenEmail = currentUserEmail(context); const perType = await Promise.all( types.map((t) => getApprovalPendingForType(context, t, tokenEmail)) ); return { rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]), debug: perType.map((r) => r.debug), }; } async function getApprovalPendingForType( context: WebPartContext, type: IRequestType, tokenEmail: string ): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { const debug: IMyRequestsTypeDebug = { requestTypeId: type.id, requestTypeName: type.name, listId: type.listId, siteUrl: type.siteUrl, skipped: false, }; try { const sp = spfi(type.siteUrl as string).using(SPFx(context)); const list = sp.web.getList(type.listId as string); const dataListInfo = await list.select('Title')(); const dataTitle = (dataListInfo.Title as string) || ''; if (!DATA_LIST_SUFFIX.test(dataTitle)) { debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; return { rows: [], debug }; } const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; debug.approvalListTitle = approvalListTitle; const approvalList = sp.web.lists.getByTitle(approvalListTitle); // Phase не смотрим вообще — работаем только по Status. const stepRows = (await approvalList.items .select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail') .expand('Approver') .top(2000)()) as Array>; console.log( `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»` ); console.table( stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, StepOrder: r.StepOrder, Status: r.Status, 'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail, })) ); debug.approvalRowCount = stepRows.length; const byTicket = new Map(); stepRows.forEach((r) => { const ticketId = r.TicketId as number | undefined; if (!ticketId) return; const approver = r.Approver as { EMail?: string } | undefined; const steps = byTicket.get(ticketId) || []; steps.push({ status: (r.Status as string) || '', order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined, approverEmail: (approver?.EMail || '').toLowerCase(), }); byTicket.set(ticketId, steps); }); const myTicketIds: number[] = []; const byTicketDebug: Array> = []; byTicket.forEach((steps, ticketId) => { const mine = isMyTurn(steps, tokenEmail); byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) }); if (mine) myTicketIds.push(ticketId); }); console.table(byTicketDebug); debug.approvalPendingTickets = myTicketIds.length; if (myTicketIds.length === 0) return { rows: [], debug }; const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or '); const ticketRows = (await list.items .filter(ticketFilter) .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(2000)()) as Array>; const rows = ticketRows.map((r) => { const author = r.Author as { EMail?: string; Title?: string } | undefined; return toMyRequest(r, author, type, 'approval'); }); return { rows, debug }; } catch (e) { debug.approvalError = (e as Error).message; console.error( `Мои заявки: не удалось прочитать согласование для «${type.name}»:`, debug.approvalError ); return { rows: [], debug }; } } // Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки. // Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка // не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании) // без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки // с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно). function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean { if (!tokenEmail) return false; const stopped = steps.some( (s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned ); if (stopped) return false; const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval); const noOrder = pending.filter((s) => s.order === undefined); const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined); const current = [...noOrder]; if (ordered.length > 0) { const minOrder = Math.min(...ordered.map((s) => s.order)); current.push(...ordered.filter((s) => s.order === minOrder)); } return current.some((s) => s.approverEmail === tokenEmail); } // --------------------------------------------------------------------------- // «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда // актуальны, только что созданная заявка видна сразу (у поискового индекса // была бы задержка переиндексации). // // Пагинация «сшивает» несколько списков в один нумерованный список: сначала по // каждому типу берём только лёгкие поля (Id + Created) всех заявок автора, // сортируем объединённый набор по дате создания и лишь для нужной страницы // дочитываем полные поля. Так на экран не тянется всё содержимое заявок, // но общее число страниц известно точно. export interface IOwnRequestsPage { rows: IMyRequest[]; totalRows: number; } const PAGE_SIZE = 20; // Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы // посчитать общее число страниц. Хватает с большим запасом; если у кого-то // заявок окажется больше — в консоль уйдёт предупреждение. const INDEX_LIMIT = 5000; // В OData-строке одинарная кавычка экранируется удвоением. function escapeOData(text: string): string { return text.replace(/'/g, "''"); } interface IOwnIndexEntry { id: number; created: string; type: IRequestType; } export async function getOwnRequests( context: WebPartContext, types: IRequestType[], query: string, page: number ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; const q = query.trim(); // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора. const perType = await Promise.all( types.map(async (type): Promise => { try { const sp = spfi(type.siteUrl as string).using(SPFx(context)); const me = await sp.web.currentUser.select('Id')(); const filters = [`AuthorId eq ${me.Id}`]; if (q) filters.push(`substringof('${escapeOData(q)}',Title)`); const rows = (await sp.web .getList(type.listId as string) .items.filter(filters.join(' and ')) .select('Id', 'Created') .orderBy('Created', false) .top(INDEX_LIMIT)()) as Array>; if (rows.length === INDEX_LIMIT) { console.warn( `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна` ); } return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type })); } catch (e) { console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message); return []; } }) ); // 2) Общий список всех типов, свежие сверху. const index = perType .reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[]) .sort((a, b) => b.created.localeCompare(a.created)); const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); if (pageEntries.length === 0) { return { rows: [], totalRows: index.length }; } // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип. const idsByType = new Map(); pageEntries.forEach((e) => { const ids = idsByType.get(e.type) || []; ids.push(e.id); idsByType.set(e.type, ids); }); const byKey = new Map(); await Promise.all( Array.from(idsByType.entries()).map(async ([type, ids]) => { const sp = spfi(type.siteUrl as string).using(SPFx(context)); const rows = (await sp.web .getList(type.listId as string) .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(PAGE_SIZE)()) as Array>; 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, 'own')); }); }) ); // Порядок страницы задаёт индекс (по дате), а не порядок ответов REST. const rows = pageEntries .map((e) => byKey.get(`${e.type.id}-${e.id}`)) .filter((r): r is IMyRequest => !!r); return { rows, totalRows: index.length }; } function toMyRequest( r: Record, author: { EMail?: string; Title?: string } | undefined, type: IRequestType, kind: 'own' | 'approval' ): IMyRequest { return { id: r.Id as number, title: (r.Title as string) || '', status: (r.Status as string) || '', authorEmail: author?.EMail || '', authorName: author?.Title || '', created: (r.Created as string) || '', requestTypeId: type.id, requestTypeName: type.name, section: type.section, formKey: type.pageUrl, kind, }; }