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

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
@@ -16,6 +16,7 @@ import { initSP } from './shared/pnp/pnpjs';
import { installErrorLogging } from './shared/diagnostics';
import { setImpersonatedEmail } from './shared/impersonation';
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
import { initListPartitions } from './shared/approval/listPartitions';
import { ErrorOverlay } from './shared/ui';
export interface IRequestPortalWebPartProps {
@@ -57,7 +58,18 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
protected onInit(): Promise<void> {
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
initSP(this.context); // единый клиент PnPjs для записи в списки
return this._getEnvironmentMessage().then(message => {
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
// берутся именно оттуда. В render() он ставится снова (свойство меняется в
// панели без перезапуска), но render случается позже onInit.
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
// чанки, монтируются заведомо позже onInit. initListPartitions не бросает:
// при недоступном approval-route портал работает по конвенции имён.
return Promise.all([this._getEnvironmentMessage(), initListPartitions(this.context)]).then(([message]) => {
this._environmentMessage = message;
});
}
@@ -11,6 +11,20 @@ export interface IMyRequest {
requestTypeId: number;
requestTypeName: string;
section: string;
/**
* Список (партиция), из которого прочитана ИМЕННО эта заявка — серверный
* относительный url.
*
* Не выводится из requestTypeId: у типа заявки не один список, а нумерованная
* серия, и RequestType.listId указывает только на текущую партицию. Архивную
* заявку надо перечитывать (автор, комментарий решения, открытие формы) из её
* собственного списка — иначе элемент с таким Id там не найдётся или, хуже,
* найдётся ЧУЖАЯ заявка с тем же Id из другой партиции (нумерация элементов в
* каждом списке SharePoint своя).
*/
listId: string;
/** Сайт, где живёт listId этой заявки. */
siteUrl: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
@@ -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 };
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,
};
}
@@ -12,6 +12,8 @@ function request(over: Partial<IMyRequest> = {}): IMyRequest {
requestTypeId: 1,
requestTypeName: 'Приём на работу',
section: 'Кадры',
listId: '/sites/requests/Lists/List1',
siteUrl: 'https://tnsukz.sharepoint.com/sites/requests',
formKey: 'hire',
kind: 'own',
...over,
@@ -1,7 +1,14 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
import {
ApprovalStatus,
IApiResponse,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
IResolveFieldValue
} from './models/IApprovalRouteApi';
// Все обращения к внешнему approval-route API — только здесь.
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
@@ -24,6 +31,29 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
return body.data ?? [];
}
/**
* GET /api/request-types/lists — серии партиций сразу по ВСЕМ типам заявок.
*
* Именно bulk-эндпоинт, а не по одному на тип: партиции нужны и для записи (куда
* писать), и для поиска (по каким спискам искать), причём поиск идёт сразу по
* всем типам — запрашивать серию отдельно на каждый тип означало бы N запросов
* на каждое открытие «Моих заявок».
*
* Типы без настроенного списка SharePoint в ответ не попадают.
*/
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
return body.data ?? [];
}
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
@@ -13,13 +13,22 @@ export type { IMatrixRow } from './models/IMatrixRow';
export type { IApprovalRow } from './models/IApprovalRow';
export type {
ApprovalStatus,
IListPartition,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
IResolvedRouteStep,
IResolveFieldValue,
} from './models/IApprovalRouteApi';
export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
export { getRequestTypes, getRequestTypeIdByPageUrl, getListPartitions, resolveRoute } from './approvalRoute.service';
export {
initListPartitions,
listPartitionsLoaded,
partitionsForTypeId,
currentPartitionForTypeId,
currentPartitionForPageUrl,
} from './listPartitions';
export { useRequestTypeId } from './useRequestTypeId';
export type { IRequestTypeIdState } from './useRequestTypeId';
export { ApprovalListService } from './approvalList.service';
@@ -0,0 +1,91 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
import { IListPartition } from './models/IApprovalRouteApi';
/**
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
* ИСКАТЬ заявки каждого типа.
*
* Заполняется один раз при старте web part (initListPartitions в onInit), дальше
* читается синхронно. Синхронные аксессоры принципиальны: имена списков нужны в
* пропсах JSX и в useMemo форм (см. shared/listTitles.ts), а формы — это лениво
* подгружаемые чанки, которые монтируются заведомо позже onInit. Тот же приём,
* что у initSP/getSP и setImpersonatedEmail.
*
* Если approval-route недоступен, реестр остаётся пустым, и вызывающая сторона
* молча откатывается к прежнему поведению «одна партиция по RequestType.listId» —
* портал обязан принимать заявки даже когда админский API лежит.
*/
/** Партиции по ЧИСЛОВОМУ id типа заявки. */
const partitionsByTypeId = new Map<number, IListPartition[]>();
/** pageUrl (слаг формы, напр. 'hr-vacation') → числовой id типа заявки. */
const typeIdByPageUrl = new Map<string, number>();
let loaded = false;
/**
* Загрузить серии партиций и карту слагов. Вызывать один раз в onInit.
*
* Ошибку НЕ пробрасываем: портал должен подняться и работать по прежней
* конвенции имён, даже если approval-route не ответил. Иначе недоступность
* админского API означала бы полностью нерабочий портал.
*/
export async function initListPartitions(context: WebPartContext): Promise<void> {
try {
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
// поэтому нужен и справочник типов, чтобы связать одно с другим.
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
partitionsByTypeId.clear();
typeIdByPageUrl.clear();
types.forEach((t) => {
if (t.pageUrl) typeIdByPageUrl.set(t.pageUrl, t.id);
});
series.forEach((s) => {
// По номеру, а не в порядке ответа: номер задаёт хронологию серии, и на него
// же опирается выбор «последней» партиции ниже.
const sorted = [...s.partitions].sort((a, b) => a.number - b.number);
partitionsByTypeId.set(s.requestTypeId, sorted);
});
loaded = true;
} catch (e) {
console.warn(
'[Партиции списков] не удалось загрузить серии из approval-route — работаем по конвенции имён с партицией №1',
e
);
}
}
/** Загрузился ли реестр. false — все аксессоры ниже отдают пустоту, и вызывающий использует свой фолбэк. */
export function listPartitionsLoaded(): boolean {
return loaded;
}
/** Все партиции типа заявки (архив + текущая), по возрастанию номера. Пусто — реестр не загружен или тип без списка. */
export function partitionsForTypeId(requestTypeId: number): IListPartition[] {
return partitionsByTypeId.get(requestTypeId) ?? [];
}
/**
* Партиция, в которую идёт запись новых заявок.
*
* Если сервер по какой-то причине не отметил ни одну как текущую, берём с
* наибольшим номером — это та же партиция по смыслу, и лучше записать в неё, чем
* отказать пользователю в создании заявки.
*/
export function currentPartitionForTypeId(requestTypeId: number): IListPartition | undefined {
const partitions = partitionsForTypeId(requestTypeId);
if (partitions.length === 0) return undefined;
return partitions.filter((p) => p.isCurrent)[0] ?? partitions[partitions.length - 1];
}
/** То же, но по слагу формы (pageUrl) — в формах известен именно он, а не числовой id. */
export function currentPartitionForPageUrl(pageUrl: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? undefined : currentPartitionForTypeId(id);
}
@@ -20,6 +20,36 @@ export interface IApiResponse<T> {
data: T;
}
/**
* Одна партиция серии списков типа заявок.
*
* Списки заявок нумерованные: когда в текущем наборе элементов достигается
* порог, approval-route создаёт следующую пару списков («<Форма> <N> — данные» +
* «<Форма> <N> — согласование»), а прежняя пара остаётся архивом только для
* чтения. Писать нужно в партицию с isCurrent, а ИСКАТЬ — по всем сразу,
* иначе заявки из архива просто исчезнут из выдачи.
*/
export interface IListPartition {
/** Номер партиции в серии, с 1. */
number: number;
/** Абсолютный URL сайта, где живёт список этой партиции. */
siteUrl: string;
/** Серверный относительный URL списка данных — та же семантика, что у IRequestType.listId. */
listId: string;
/** Title списка данных на момент создания. null, если approval-route не смог его прочитать. */
title?: string;
/** true — в эту партицию идёт запись новых заявок. Ровно одна в серии. */
isCurrent: boolean;
createdAt: string;
}
// GET /api/request-types/lists → { data: IRequestTypeListSeries[] }
// Серии сразу по всем типам заявок: одним запросом, а не по одному на тип.
export interface IRequestTypeListSeries {
requestTypeId: number;
partitions: IListPartition[];
}
// Условие видимости шага маршрута (настраивается в админке маршрутов —
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
+46 -12
View File
@@ -1,4 +1,5 @@
import { RequestTypeId } from '../models/types';
import { currentPartitionForPageUrl } from './approval/listPartitions';
/**
* ЕДИНЫЙ источник имён списков SharePoint.
@@ -32,15 +33,19 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
};
/**
* Номер текущей пары списков. Пока единый для всех типов заявок — архивных пар ещё
* нет, все формы работают с парой №1.
*
* Когда архивация заработает, номер будет приходить из approval-route (эндпоинт
* «текущий список по типу заявки») и заполняться один раз при старте web part, до
* загрузки чанка любой формы. Сигнатуры функций ниже при этом не меняются — поэтому
* вызовы по формам править второй раз не придётся.
* Номер партиции, используемый КОГДА РЕЕСТР НЕДОСТУПЕН (approval-route не ответил
* при старте — см. initListPartitions). Первая пара списков существует всегда,
* поэтому это безопасный фолбэк: портал продолжает принимать заявки, просто пишет
* в партицию №1.
*/
const CURRENT_LIST_NUMBER = 1;
const FALLBACK_LIST_NUMBER = 1;
/**
* Суффикс списка данных. Копия того же регекса, что в
* features/myRequests/myRequests.service.ts и в админке approval-route — три разных
* места читают имена одних и тех же списков. Меняя здесь, менять и там.
*/
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
function listTitleBase(id: RequestTypeId): string {
const base = LIST_TITLE_BASE[id];
@@ -50,12 +55,41 @@ function listTitleBase(id: RequestTypeId): string {
return base;
}
/** Имя списка данных заявок этого типа. */
/**
* Имя списка данных, в который идёт ЗАПИСЬ новых заявок этого типа.
*
* Источник правды — реестр партиций (approval-route знает, какая партиция текущая,
* и сам переключает её, когда прежняя добирает порог). Берём именно фактический
* Title из реестра, а не собираем его из базы и номера: так имя не может разойтись
* с тем, что реально лежит в SharePoint (например, если список переименовали).
*
* Ключи LIST_TITLE_BASE совпадают с pageUrl типов заявок в approval-route — по нему
* реестр и находит нужную серию.
*/
export function dataListTitle(id: RequestTypeId): string {
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — данные`;
const currentTitle = currentPartitionForPageUrl(id)?.title;
if (currentTitle) return currentTitle;
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — данные`;
}
/** Имя списка согласования заявок этого типа. */
/**
* Имя парного списка согласования.
*
* Выводится из имени списка ДАННЫХ, а не собирается независимо: номер партиции у
* пары обязан совпадать, а список данных — единственное, что реестр знает точно
* (approval-route хранит ссылку только на него, согласование ищется по конвенции).
*/
export function approvalListTitle(id: RequestTypeId): string {
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — согласование`;
const dataTitle = dataListTitle(id);
if (DATA_LIST_SUFFIX.test(dataTitle)) {
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
}
// Имя списка данных не по конвенции (переименовали вручную) — вывести парное
// имя из него нельзя, собираем по базе, чтобы вернуть хоть что-то осмысленное.
console.warn(
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
);
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — согласование`;
}