diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 8f319e7..df494cb 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -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 { 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; }); } diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index d53c5ba..fd98e0e 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -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' — ждёт его решения. */ diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 344b76b..d615af7 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -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 { + 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>; - 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(); types.forEach((t) => typeById.set(t.id, t)); - const idsByType = new Map(); + // Группируем по ПАРТИЦИИ строки, а не по её типу: каждая строка помнит, из + // какого списка её прочитали (IMyRequest.listId), и дочитывать автора надо + // оттуда же — в текущей партиции того Id либо нет, либо это другая заявка. + const byPartition = new Map(); 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(); 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>; 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( + (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(); return (results.PrimarySearchResults as Array>) .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> { - const idsByType = new Map(); + // Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем + // списке либо не найдётся, либо укажет на чужую заявку с тем же Id. + const byPartition = new Map(); 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(); 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>; 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 { + // Только ТЕКУЩАЯ партиция: это довесок по свежести, а свежие заявки бывают + // только там, куда идёт запись. Архивные придут из 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 { + // Только текущая партиция — см. 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>; 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(); // Порядок задаёт релевантность поиска, а не порядок ответов 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(); - 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 { - 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(); types.forEach((t) => typeById.set(t.id, t)); - const rowsByType = new Map(); + // По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке + // согласования той же партиции, что и сама заявка. Для архивной заявки + // «список согласования её типа» — это чужой список текущей партиции, где по + // её TicketId нашлись бы либо ничего, либо комментарий к другой заявке. + const rowsByPartition = new Map(); 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(); 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(); - 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, 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, }; } diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts index 26c4421..3e5461d 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -12,6 +12,8 @@ function request(over: Partial = {}): IMyRequest { requestTypeId: 1, requestTypeName: 'Приём на работу', section: 'Кадры', + listId: '/sites/requests/Lists/List1', + siteUrl: 'https://tnsukz.sharepoint.com/sites/requests', formKey: 'hire', kind: 'own', ...over, diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index 7f66158..fbd4cf7 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -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 { + 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 = await response.json(); + return body.data ?? []; +} + // Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет // редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали // бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип, diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 08a35bc..6648340 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/approval/listPartitions.ts b/src/webparts/requestPortal/shared/approval/listPartitions.ts new file mode 100644 index 0000000..23a9b6b --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/listPartitions.ts @@ -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(); + +/** pageUrl (слаг формы, напр. 'hr-vacation') → числовой id типа заявки. */ +const typeIdByPageUrl = new Map(); + +let loaded = false; + +/** + * Загрузить серии партиций и карту слагов. Вызывать один раз в onInit. + * + * Ошибку НЕ пробрасываем: портал должен подняться и работать по прежней + * конвенции имён, даже если approval-route не ответил. Иначе недоступность + * админского API означала бы полностью нерабочий портал. + */ +export async function initListPartitions(context: WebPartContext): Promise { + 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); +} diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts index 6dcd954..556a245 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts @@ -20,6 +20,36 @@ export interface IApiResponse { data: T; } +/** + * Одна партиция серии списков типа заявок. + * + * Списки заявок нумерованные: когда в текущем наборе элементов достигается + * порог, approval-route создаёт следующую пару списков («<Форма> — данные» + + * «<Форма> — согласование»), а прежняя пара остаётся архивом только для + * чтения. Писать нужно в партицию с 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 — внутреннее имя колонки списка (напр. diff --git a/src/webparts/requestPortal/shared/listTitles.ts b/src/webparts/requestPortal/shared/listTitles.ts index 74640b8..4fe9021 100644 --- a/src/webparts/requestPortal/shared/listTitles.ts +++ b/src/webparts/requestPortal/shared/listTitles.ts @@ -1,4 +1,5 @@ import { RequestTypeId } from '../models/types'; +import { currentPartitionForPageUrl } from './approval/listPartitions'; /** * ЕДИНЫЙ источник имён списков SharePoint. @@ -32,15 +33,19 @@ const LIST_TITLE_BASE: Record = { }; /** - * Номер текущей пары списков. Пока единый для всех типов заявок — архивных пар ещё - * нет, все формы работают с парой №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} — согласование`; }