0aa0a60e40
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/1 Co-authored-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz> Co-committed-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
307 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|