Архивация списков #9
@@ -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 };
|
||||
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 — внутреннее имя колонки списка (напр.
|
||||
|
||||
@@ -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} — согласование`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user