Files
request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
T
2026-08-04 12:02:11 +00:00

307 lines
13 KiB
TypeScript

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<IRequestType[]> {
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<Record<string, unknown>>;
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<number, IApprovalStep[]>();
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<Record<string, unknown>> = [];
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<Record<string, unknown>>;
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<IOwnRequestsPage> {
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<IOwnIndexEntry[]> => {
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<Record<string, unknown>>;
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<IRequestType, number[]>();
pageEntries.forEach((e) => {
const ids = idsByType.get(e.type) || [];
ids.push(e.id);
idsByType.set(e.type, ids);
});
const byKey = new Map<string, IMyRequest>();
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<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, '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<string, unknown>,
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,
};
}