From 2b3de32d3ffb1ac28216d70052d821cf4cf9ddb2 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 16:47:48 +0500 Subject: [PATCH 01/15] =?UTF-8?q?=D1=81=D0=B5=D0=BB=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/RequestPortalWebPart.ts | 19 +++++++++++++++++ .../shared/approval/approvalRoute.service.ts | 6 +++--- src/webparts/requestPortal/shared/config.ts | 21 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 34789f4..8f319e7 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -3,6 +3,7 @@ import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; import { type IPropertyPaneConfiguration, + PropertyPaneDropdown, PropertyPaneTextField } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; @@ -14,12 +15,15 @@ import { IRequestPortalProps } from './components/IRequestPortalProps'; 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 { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { description: string; /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ debugUserEmail: string; + /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ + approvalApiBaseUrl: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -31,6 +35,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { export async function getRequestTypes(context: WebPartContext): Promise { const c = await client(context); const response: HttpClientResponse = await c.get( - `${APPROVAL_API_BASE_URL}/api/request-types`, + `${getApprovalApiBaseUrl()}/api/request-types`, AadHttpClient.configurations.v1 ); if (!response.ok) { @@ -82,7 +82,7 @@ export async function resolveRoute( projectId: string | null = null ): Promise { const c = await client(context); - const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`; + const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`; // Эндпоинт сменился с GET (query) на POST (JSON-тело). // projectId (uuid проекта) — для форм, где согласующий зависит от проекта diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 98ab180..c4e6b56 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -16,7 +16,26 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests'; * в Центре администрирования SharePoint (API access) при развёртывании. */ export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0'; -export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route'; + +/** Прод — то же самое значение, что default в свойстве approvalApiBaseUrl веб-части. */ +export const APPROVAL_API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route'; +/** Локальный backend — порт из tnsu-approval-route/backend/.../launchSettings.json. */ +export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080'; + +// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств, +// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserEmail/ +// setImpersonatedEmail: модульный синглтон, а не проброс параметром через все +// вызовы approvalRoute.service.ts, потому что эти вызовы разбросаны по формам и +// не имеют общего входа, через который можно было бы прокинуть контекст. +let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD; + +export function setApprovalApiBaseUrl(url: string): void { + _approvalApiBaseUrl = url || APPROVAL_API_BASE_URL_PROD; +} + +export function getApprovalApiBaseUrl(): string { + return _approvalApiBaseUrl; +} /** * Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app -- 2.34.1 From 34cab4d03d3c66fd1e7393c595145417ac916280 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 17:50:50 +0500 Subject: [PATCH 02/15] =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B1=D1=83=D0=B4?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B2=D1=81=D0=B5=20=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/RequestPortalWebPart.ts | 14 +- .../features/myRequests/models/IMyRequest.ts | 14 + .../features/myRequests/myRequests.service.ts | 342 +++++++++++++----- .../features/myRequests/pageCache.test.ts | 2 + .../shared/approval/approvalRoute.service.ts | 32 +- .../requestPortal/shared/approval/index.ts | 11 +- .../shared/approval/listPartitions.ts | 91 +++++ .../approval/models/IApprovalRouteApi.ts | 30 ++ .../requestPortal/shared/listTitles.ts | 58 ++- 9 files changed, 498 insertions(+), 96 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/listPartitions.ts 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} — согласование`; } -- 2.34.1 From 11811d101f3fdca8fa45f157bafa0c3e5636fbd5 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 18:11:06 +0500 Subject: [PATCH 03/15] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index d615af7..81dc6c9 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -847,25 +847,37 @@ async function loadByIds( const byKey = new Map(); await Promise.all( Array.from(byPartition.values()).map(async ({ type, target, ids }) => { - const sp = spForSite(target.siteUrl, context); - const list = sp.web.getList(target.listId); + // Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть + // недоступен (удалён, переименован, остался в реестре после смены списка у + // типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции + // один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не + // увидел бы ничего вместо «всё, кроме одного архива». + try { + 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) => { - const rows = (await list.items - .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') - .expand('Author') - .top(idsChunk.length)()) as Array>; - rows.forEach((r) => { - const author = r.Author as { EMail?: string; Title?: string } | undefined; - byKey.set( - requestKey(target.listId, r.Id as number), - toMyRequest(r, author, type, kind, target) - ); - }); - }) - ); + await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => { + const rows = (await list.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(idsChunk.length)()) as Array>; + rows.forEach((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + byKey.set( + requestKey(target.listId, r.Id as number), + toMyRequest(r, author, type, kind, target) + ); + }); + }) + ); + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`, + (e as Error).message + ); + } }) ); return byKey; -- 2.34.1 From c85989230a077a9ca7427e65446004b3805c4179 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 10:02:42 +0500 Subject: [PATCH 04/15] =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B0=D1=80?= =?UTF-8?q?=D1=85=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D0=B7=D0=B0=D1=8F=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 8 ++- .../features/approve/ApproveFeature.tsx | 17 ++++-- .../features/edit/EditFeature.tsx | 20 ++++++- .../forms/Clearance/ClearanceForm.tsx | 47 ++++++++++++---- .../forms/Clearance/ClearanceView.tsx | 42 ++++++++++----- .../requestPortal/forms/Clearance/load.ts | 15 ++++-- .../requestPortal/forms/Clearance/submit.ts | 39 ++++++++++---- .../RecruitingHire/RecruitingHireForm.tsx | 47 +++++++++++----- .../RecruitingHire/RecruitingHireView.tsx | 38 +++++++++---- .../forms/RecruitingHire/load.ts | 15 ++++-- .../forms/RecruitingHire/submit.ts | 30 ++++++++--- .../RecruitingSelectionForm.tsx | 53 +++++++++++++----- .../RecruitingSelectionView.tsx | 35 ++++++++---- .../forms/RecruitingSelection/load.ts | 15 ++++-- .../forms/RecruitingSelection/submit.ts | 30 ++++++++--- .../forms/Ticket1C/Ticket1CForm.tsx | 54 ++++++++++++++----- .../forms/Ticket1C/Ticket1CView.tsx | 48 +++++++++++------ .../requestPortal/forms/Ticket1C/load.ts | 15 ++++-- .../requestPortal/forms/Ticket1C/submit.ts | 43 ++++++++++----- .../forms/TmcTransfer/TmcTransferForm.tsx | 48 ++++++++++++----- .../forms/TmcTransfer/TmcTransferView.tsx | 40 +++++++++----- .../requestPortal/forms/TmcTransfer/load.ts | 15 ++++-- .../requestPortal/forms/TmcTransfer/submit.ts | 30 ++++++++--- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 47 ++++++++++++---- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 35 ++++++++---- .../requestPortal/forms/UnpaidLeave/load.ts | 15 ++++-- .../requestPortal/forms/UnpaidLeave/submit.ts | 30 ++++++++--- .../forms/Vacation/VacationForm.tsx | 40 ++++++++++---- .../forms/Vacation/VacationView.tsx | 35 ++++++++---- .../requestPortal/forms/Vacation/load.ts | 15 ++++-- .../requestPortal/forms/Vacation/submit.ts | 30 ++++++++--- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 48 ++++++++++++----- .../WorkplaceSetup/WorkplaceSetupView.tsx | 43 ++++++++++----- .../forms/WorkplaceSetup/load.ts | 15 ++++-- .../forms/WorkplaceSetup/submit.ts | 39 ++++++++++---- .../requestPortal/forms/viewRegistry.ts | 6 +++ src/webparts/requestPortal/models/types.ts | 9 ++++ .../approval/approverDecision.service.ts | 19 ++++++- .../requestPortal/shared/approval/index.ts | 1 + .../shared/approval/listPartitions.ts | 19 +++++++ .../shared/attachments/attachments.service.ts | 34 +++++++----- .../requestPortal/shared/listTitles.ts | 38 ++++++++++++- src/webparts/requestPortal/shared/routing.ts | 18 ++++++- 43 files changed, 976 insertions(+), 304 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 19621f1..0eba3a4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -227,6 +227,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context nav: route.nav, formKey: request.formKey, itemId: request.id, + // Заявки нумерованные (см. shared/approval/listPartitions.ts) — строка + // таблицы уже знает, в каком СПИСКЕ она реально лежит (см. IMyRequest.listId + // в myRequests.service.ts), и это надо передать дальше, иначе архивная + // заявка откроется так, будто лежит в текущей партиции. + listId: request.listId, ownTab: route.ownTab, ownStatus: route.ownStatus, // Страница и запрос — часть того же списка под панелью: без них ссылка на @@ -374,7 +379,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.mode === 'approve' && route.formKey && route.itemId && ( - + )} @@ -387,6 +392,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context context={context} formKey={route.formKey} itemId={route.itemId} + listId={route.listId} onClose={closeDrawer} /> diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx index 2f674f8..49a5d0a 100644 --- a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx +++ b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './ApproveFeature.module.scss'; import { getViewComponent } from '../../forms/viewRegistry'; +import { partitionForPageUrlAndListId } from '../../shared/approval'; export interface IApproveFeatureProps { context: WebPartContext; @@ -9,18 +10,28 @@ export interface IApproveFeatureProps { formKey: string; /** RowID заявки из URL. */ itemId: number; + /** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */ + listId?: string; } // Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=). // Показывает заявку в режиме чтения; панель решения добавим следующим шагом. -const ApproveFeature: React.FC = ({ context, formKey, itemId }) => { +const ApproveFeature: React.FC = ({ context, formKey, itemId, listId }) => { const ViewComponent = getViewComponent(formKey); + // Партиция указана, но её нет в реестре — см. тот же комментарий в EditFeature. + const partitionError = + listId && !partitionForPageUrlAndListId(formKey, listId) + ? `Не удалось открыть заявку: список «${listId}» не найден в реестре партиций. Ссылка могла устареть — попробуйте открыть заявку заново из списка.` + : undefined; + return (
- {ViewComponent ? ( + {partitionError ? ( +
{partitionError}
+ ) : ViewComponent ? ( Загрузка…
}> - + ) : (
Для формы «{formKey}» режим согласования пока не настроен.
diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx index e721636..72a57b4 100644 --- a/src/webparts/requestPortal/features/edit/EditFeature.tsx +++ b/src/webparts/requestPortal/features/edit/EditFeature.tsx @@ -4,6 +4,7 @@ import styles from '../approve/ApproveFeature.module.scss'; import { getFormComponent } from '../../forms/registry'; import { REQUEST_TYPES } from '../../data/requests'; import { emitRequestsChanged } from '../../shared/requestEvents'; +import { partitionForPageUrlAndListId } from '../../shared/approval'; export interface IEditFeatureProps { context: WebPartContext; @@ -11,6 +12,8 @@ export interface IEditFeatureProps { formKey: string; /** RowID редактируемой заявки из URL. */ itemId: number; + /** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */ + listId?: string; /** Закрыть панель (после сохранения). */ onClose: () => void; } @@ -22,7 +25,7 @@ export interface IEditFeatureProps { // // Отдельно от features/submit намеренно: там форма живёт в потоке «плитки → // новая заявка», а тут — точечное открытие конкретной записи по ссылке. -const EditFeature: React.FC = ({ context, formKey, itemId, onClose }) => { +const EditFeature: React.FC = ({ context, formKey, itemId, listId, onClose }) => { const FormComponent = getFormComponent(formKey); const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0]; @@ -30,6 +33,20 @@ const EditFeature: React.FC = ({ context, formKey, itemId, on return
Для формы «{formKey}» редактирование не настроено.
; } + // Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts), + // но её нет в реестре — устаревшая/битая ссылка. Проверяем ЗДЕСЬ, ДО монтирования + // формы: внутри формы имя списка резолвится синхронно (useMemo) и там уже негде + // красиво показать ошибку без риска уронить рендер всей панели — см. + // vacationApprovalService и аналоги в остальных формах. + if (listId && !partitionForPageUrlAndListId(formKey, listId)) { + return ( +
+ Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть — + попробуйте открыть заявку заново из списка. +
+ ); + } + return (
Загрузка формы…
}> @@ -37,6 +54,7 @@ const EditFeature: React.FC = ({ context, formKey, itemId, on requestType={requestType} context={context} editId={itemId} + editListId={listId} onCancel={onClose} onSaved={() => { emitRequestsChanged(); diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 4417739..31b0bce 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -24,13 +24,25 @@ import { findUnassignedStep } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveClearanceRequest, clearanceApprovalService } from './submit'; import { loadClearanceRequest } from './load'; import { RequestSection } from './sections/RequestSection'; -const ClearanceForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { +const ClearanceForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); @@ -40,7 +52,10 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); - const approval = useMemo(() => clearanceApprovalService(), []); + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // clearanceApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => clearanceApprovalService(editListId), [editListId]); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); @@ -52,7 +67,7 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa return; } let alive = true; - loadClearanceRequest(editId) + loadClearanceRequest(editId, editListId) .then((loaded) => { if (alive) { setData(loaded.data); @@ -69,7 +84,7 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + }, [editId, editListId]); // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // обходной лист оформляют в день увольнения). При открытии на редактирование @@ -148,7 +163,14 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } - const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status); + const { id, code } = await saveClearanceRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + editListId + ); setRequestId(id); setRequestStatus(status); if (code) { @@ -158,11 +180,13 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, что и сама заявка (editListId), не текущей: + // поток ищет строку маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID), + approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID) }); } }; @@ -197,7 +221,12 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 9771ed7..4c3a59f 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -13,7 +13,12 @@ import { useRequestTypeId, APPROVAL_PHASE } from '../../shared/approval'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadClearanceRequest } from './load'; import { @@ -29,6 +34,8 @@ interface IClearanceViewProps { itemId: number; /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; } type SaveState = 'idle' | 'saving' | 'saved' | 'error'; @@ -36,7 +43,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; // Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // отдел подбора — парковочная карта (см. approverRights.ts). -const ClearanceView: React.FC = ({ context, itemId, formKey }) => { +const ClearanceView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -48,12 +55,14 @@ const ClearanceView: React.FC = ({ context, itemId, formKey const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); - const approval = useMemo(() => clearanceApprovalService(), []); + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // clearanceApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => clearanceApprovalService(listId), [listId]); const { id: requestTypeId } = useRequestTypeId(context, formKey); - const approverService = useMemo( - () => new ApproverService(approval, dataListTitle(FORM_ID)), - [approval] - ); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. @@ -66,7 +75,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey useEffect(() => { let alive = true; setLoading(true); - loadClearanceRequest(itemId) + loadClearanceRequest(itemId, listId) .then((loaded) => { if (alive) { setData(loaded.data); @@ -85,7 +94,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // isDelegator намеренно не даёт прав: делегировавший не решает за делегата @@ -123,8 +132,8 @@ const ClearanceView: React.FC = ({ context, itemId, formKey if (parkingEditable) { fields.ParkingCard = data.ParkingCard; } - return updateApproverFields(itemId, fields); - }, [itemId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]); + return updateApproverFields(itemId, fields, listId); + }, [itemId, listId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]); const handleSaveFields = async (): Promise => { setSaveState('saving'); @@ -157,8 +166,8 @@ const ClearanceView: React.FC = ({ context, itemId, formKey if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { requestTypeId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID) }).catch(() => undefined); }; @@ -187,7 +196,12 @@ const ClearanceView: React.FC = ({ context, itemId, formKey } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts index 1740103..9496198 100644 --- a/src/webparts/requestPortal/forms/Clearance/load.ts +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -11,10 +11,17 @@ export interface ILoadedRequest { code: string; } -/** Прочитать обходной лист по id: модель формы + статус (правка / просмотр). */ -export async function loadClearanceRequest(itemId: number): Promise { - const item = (await getSP() - .web.lists.getByTitle(dataListTitle(FORM_ID)) +/** + * Прочитать обходной лист по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан — + * идём напрямую по url списка, а не по имени: так не нужно резолвить, как + * называется архивный список, и запрос не зависит от реестра партиций. + */ +export async function loadClearanceRequest(itemId: number, listId?: string): Promise { + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list .items.getById(itemId) .select( 'Id', diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts index fe5c512..0745a12 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -1,12 +1,21 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { buildRequestUniqueCode } from '../../shared/requestCode'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles'; import { FORM_ID, IFormData } from './model'; -/** Экземпляр сервиса списка согласования этой формы. */ -export function clearanceApprovalService(): ApprovalListService { - return new ApprovalListService(approvalListTitle(FORM_ID)); +/** + * Экземпляр сервиса списка согласования этой формы. + * + * listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts). + * Не задан — текущая партиция, как раньше. Список согласования ищется только по + * имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для + * архивной партиции имя приходится резолвить через реестр — если реестр не + * знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку, + * а не тихо откатится на текущую партицию. + */ +export function clearanceApprovalService(listId?: string): ApprovalListService { + return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)); } /** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */ @@ -20,10 +29,14 @@ export interface IApproverEditableFields { * Обновить только те отметки, что заполняет согласующий на своём шаге. * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние * ключи затёрли бы чужие отметки значениями из формы этого согласующего. + * + * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан — + * текущая партиция, как раньше. */ export async function updateApproverFields( id: number, - fields: Partial + fields: Partial, + listId?: string ): Promise { const item: Record = {}; if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard; @@ -34,7 +47,8 @@ export async function updateApproverFields( Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null; } if (Object.keys(item).length === 0) return; - await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update(item); + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + await list.items.getById(id).update(item); } /** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */ @@ -80,15 +94,22 @@ export interface ISaveResult { * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей. * Возвращает Id заявки (+ код при создании). + * + * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest). Имеет смысл + * только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции — + * писать новые заявки в архив незачем и нельзя (approval-route переключает + * текущую партицию сам, по своему порогу элементов). */ export async function saveClearanceRequest( existingId: number | undefined, requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], - status: string + status: string, + listId?: string ): Promise { - const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const list = + existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const employee = await ensureEmployee(data.Employee); const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); @@ -105,6 +126,6 @@ export async function saveClearanceRequest( await list.items.getById(id).update({ RequestUniqueCode: code }); } - await writeRoute(clearanceApprovalService(), id, approvers, [], status); + await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, [], status); return { id, code }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index a704b0d..9cf78ef 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -36,7 +36,12 @@ import { } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveHireRequest, hireApprovalService } from './submit'; import { loadHireRequest } from './load'; @@ -45,7 +50,14 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; -const RecruitingHireForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { +const RecruitingHireForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -67,7 +79,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, return; } let alive = true; - Promise.all([loadHireRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) + Promise.all([loadHireRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) .then(([loaded, atts]) => { if (alive) { setData(loaded.data); @@ -84,13 +96,16 @@ const RecruitingHireForm: React.FC = ({ requestType, context, return () => { alive = false; }; - }, [editId]); + }, [editId, editListId]); // Маршрут согласования (планируемый) резолвится по email инициатора — текущего // пользователя (кандидата ещё нет в системе). У заявки, открытой на // редактирование, маршрут читается из уже записанных строк — планирование // затёрло бы правки, сделанные при первом сохранении. - const approval = useMemo(() => hireApprovalService(), []); + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // hireApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => hireApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. @@ -158,7 +173,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, if (requestId === undefined) { return; } - deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) + deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .catch(() => undefined); }; @@ -177,7 +192,8 @@ const RecruitingHireForm: React.FC = ({ requestType, context, data, toApprovers(matrix.rows), toApprovers(executors.rows), - status + status, + editListId ); setRequestId(id); setRequestStatus(status); @@ -185,19 +201,21 @@ const RecruitingHireForm: React.FC = ({ requestType, context, setRequestCode(code); } if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента + await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало - setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные + setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные } if (onSaved) { onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, что и сама заявка (editListId), не текущей: + // поток ищет строку маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID), + dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID), + approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID), }); } }; @@ -237,7 +255,12 @@ const RecruitingHireForm: React.FC = ({ requestType, context, } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 43a8251..0ce8804 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -15,7 +15,12 @@ import { } from '../../shared/approval'; import { navigate } from '../../shared/routing'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; @@ -30,6 +35,8 @@ interface IRecruitingHireViewProps { itemId: number; /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; } // Ключ формы «Организация рабочего места» в реестре (data/requests) — цель @@ -42,7 +49,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на прием» только для просмотра (режим согласующего). // Переиспользует те же секции, что и форма подачи; редактирование выключено // через — поля сами становятся неизменяемыми. -const RecruitingHireView: React.FC = ({ context, itemId, formKey }) => { +const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -52,10 +59,16 @@ const RecruitingHireView: React.FC = ({ context, itemI const { id: requestTypeId } = useRequestTypeId(context, formKey); + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // hireApprovalService можно звать не опасаясь исключения. + // // Сервис решений согласующего: список согласования + список данных этой формы. + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); const approverService = useMemo( - () => new ApproverService(hireApprovalService(), dataListTitle(FORM_ID)), - [] + () => new ApproverService(hireApprovalService(listId), dataListRef), + [listId, dataListRef] ); // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. @@ -66,7 +79,7 @@ const RecruitingHireView: React.FC = ({ context, itemI // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». const [decisionSeq, setDecisionSeq] = useState(0); - const approval = useMemo(() => hireApprovalService(), []); + const approval = useMemo(() => hireApprovalService(listId), [listId]); const loadApprovers = useCallback( () => approval @@ -91,7 +104,7 @@ const RecruitingHireView: React.FC = ({ context, itemI useEffect(() => { let alive = true; setLoading(true); - Promise.all([loadHireRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) + Promise.all([loadHireRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)]) .then(([loaded, files]) => { if (alive) { setData(loaded.data); @@ -111,7 +124,7 @@ const RecruitingHireView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); if (loading) { return
Загрузка заявки…
; @@ -130,8 +143,8 @@ const RecruitingHireView: React.FC = ({ context, itemI if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { requestTypeId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID), + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID), }).catch(() => undefined); }; @@ -160,7 +173,12 @@ const RecruitingHireView: React.FC = ({ context, itemI } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index cb020db..77ad94f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -16,11 +16,18 @@ export interface ILoadedRequest { code: string; } -/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ -export async function loadHireRequest(itemId: number): Promise { +/** + * Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан — + * идём напрямую по url списка, а не по имени: так не нужно резолвить, как + * называется архивный список, и запрос не зависит от реестра партиций. + */ +export async function loadHireRequest(itemId: number, listId?: string): Promise { const sp = getSP(); - const item = (await sp.web.lists - .getByTitle(dataListTitle(FORM_ID)) + const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list .items.getById(itemId) .select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 91ab1d4..af3f216 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,15 +1,24 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { buildRequestUniqueCode } from '../../shared/requestCode'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles'; import { FORM_ID, IFormData } from './model'; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; -/** Экземпляр сервиса списка согласования этой формы. */ -export function hireApprovalService(): ApprovalListService { - return new ApprovalListService(approvalListTitle(FORM_ID)); +/** + * Экземпляр сервиса списка согласования этой формы. + * + * listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts). + * Не задан — текущая партиция, как раньше. Список согласования ищется только по + * имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для + * архивной партиции имя приходится резолвить через реестр — если реестр не + * знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку, + * а не тихо откатится на текущую партицию. + */ +export function hireApprovalService(listId?: string): ApprovalListService { + return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)); } /** Осмысленный заголовок элемента списка (системная колонка Title). */ @@ -45,6 +54,11 @@ export interface ISaveResult { * status = «Черновик» (сохранение) или «На согласовании» (отправка). * executors может быть пустым — тогда после согласования заявка станет «Согласовано». * Возвращает Id заявки (+ код при создании). + * + * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest). Имеет смысл + * только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции — + * писать новые заявки в архив незачем и нельзя (approval-route переключает + * текущую партицию сам, по своему порогу элементов). */ export async function saveHireRequest( existingId: number | undefined, @@ -52,9 +66,11 @@ export async function saveHireRequest( data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], - status: string + status: string, + listId?: string ): Promise { - const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const list = + existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const item = buildItem(data, status); let id: number; @@ -70,6 +86,6 @@ export async function saveHireRequest( await list.items.getById(id).update({ RequestUniqueCode: code }); } - await writeRoute(hireApprovalService(), id, approvers, executors, status); + await writeRoute(hireApprovalService(existingId ? listId : undefined), id, approvers, executors, status); return { id, code }; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 38757a4..125622e 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -35,7 +35,12 @@ import { } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveSelectionRequest, selectionApprovalService } from './submit'; import { loadSelectionRequest } from './load'; @@ -45,7 +50,14 @@ import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; -const RecruitingSelectionForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { +const RecruitingSelectionForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -64,7 +76,7 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con return; } let alive = true; - Promise.all([loadSelectionRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) + Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) .then(([loaded, atts]) => { if (alive) { setData(loaded.data); @@ -82,12 +94,15 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + }, [editId, editListId]); // Маршрут согласования резолвится по email инициатора (текущего пользователя). // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не // затереть правки инициатора при первом сохранении. - const approval = useMemo(() => selectionApprovalService(), []); + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // selectionApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => selectionApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. @@ -132,7 +147,7 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con if (requestId === undefined) { return; } - deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) + deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .catch(() => undefined); }; @@ -145,26 +160,35 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } - const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status); + const { id, code } = await saveSelectionRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + editListId + ); setRequestId(id); setRequestStatus(status); if (code) { setRequestCode(code); } if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента + await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало - setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); + setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); } if (onSaved) { onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, что и сама заявка (editListId), не текущей: + // поток ищет строку маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID), + approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID) }); } }; @@ -199,7 +223,12 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index ac89b97..2882216 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -13,7 +13,12 @@ import { useRequestTypeId } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadSelectionRequest } from './load'; import { selectionApprovalService } from './submit'; @@ -28,6 +33,8 @@ interface IRecruitingSelectionViewProps { itemId: number; /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; } // В режиме чтения поля не редактируются, поэтому обновление — пустышка. @@ -35,7 +42,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через . -const RecruitingSelectionView: React.FC = ({ context, itemId, formKey }) => { +const RecruitingSelectionView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -43,9 +50,14 @@ const RecruitingSelectionView: React.FC = ({ cont const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const approval = useMemo(() => selectionApprovalService(), []); + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // selectionApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => selectionApprovalService(listId), [listId]); const { id: requestTypeId } = useRequestTypeId(context, formKey); - const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. @@ -58,7 +70,7 @@ const RecruitingSelectionView: React.FC = ({ cont useEffect(() => { let alive = true; setLoading(true); - Promise.all([loadSelectionRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) + Promise.all([loadSelectionRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)]) .then(([loaded, files]) => { if (alive) { setData(loaded.data); @@ -78,7 +90,7 @@ const RecruitingSelectionView: React.FC = ({ cont return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); if (loading) { return
Загрузка заявки…
; @@ -97,8 +109,8 @@ const RecruitingSelectionView: React.FC = ({ cont if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { requestTypeId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID) }).catch(() => undefined); }; @@ -112,7 +124,12 @@ const RecruitingSelectionView: React.FC = ({ cont } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index 7769da4..088be1d 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -17,8 +17,15 @@ export interface ILoadedRequest { code: string; } -/** Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). */ -export async function loadSelectionRequest(itemId: number): Promise { +/** + * Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан — + * идём напрямую по url списка, а не по имени: так не нужно резолвить, как + * называется архивный список, и запрос не зависит от реестра партиций. + */ +export async function loadSelectionRequest(itemId: number, listId?: string): Promise { const keys = Object.keys(EMPTY_DATA) as (keyof IFormData)[]; const isPerson = (k: keyof IFormData): boolean => PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1; @@ -28,8 +35,8 @@ export async function loadSelectionRequest(itemId: number): Promise `${k}/EMail`); const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS]; - const item = (await getSP() - .web.lists.getByTitle(dataListTitle(FORM_ID)) + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list .items.getById(itemId) .select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects) .expand(...expands)()) as Record; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 939c4e3..22c5854 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,7 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { buildRequestUniqueCode } from '../../shared/requestCode'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles'; import { FORM_ID, IFormData } from './model'; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. @@ -11,9 +11,18 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker']; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation']; -/** Экземпляр сервиса списка согласования этой формы. */ -export function selectionApprovalService(): ApprovalListService { - return new ApprovalListService(approvalListTitle(FORM_ID)); +/** + * Экземпляр сервиса списка согласования этой формы. + * + * listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts). + * Не задан — текущая партиция, как раньше. Список согласования ищется только по + * имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для + * архивной партиции имя приходится резолвить через реестр — если реестр не + * знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку, + * а не тихо откатится на текущую партицию. + */ +export function selectionApprovalService(listId?: string): ApprovalListService { + return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)); } /** Логин → числовой Id пользователя на текущем сайте. */ @@ -68,15 +77,22 @@ export interface ISaveResult { * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения * нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании). + * + * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest). Имеет смысл + * только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции — + * писать новые заявки в архив незачем и нельзя (approval-route переключает + * текущую партицию сам, по своему порогу элементов). */ export async function saveSelectionRequest( existingId: number | undefined, requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], - status: string + status: string, + listId?: string ): Promise { - const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const list = + existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const item = buildScalarBody(data); item.RequestStatus = status; await applyPeople(item, data); @@ -94,6 +110,6 @@ export async function saveSelectionRequest( await list.items.getById(id).update({ RequestUniqueCode: code }); } - await writeRoute(selectionApprovalService(), id, approvers, [], status); + await writeRoute(selectionApprovalService(existingId ? listId : undefined), id, approvers, [], status); return { id, code }; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index b12d540..2095c43 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -35,7 +35,12 @@ import { } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit'; import { loadTicketRequest } from './load'; @@ -63,7 +68,14 @@ function buildPlannerNotes(data: IFormData, code: string): string { return lines.join('\n'); } -const Ticket1CForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { +const Ticket1CForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -81,7 +93,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan return; } let alive = true; - Promise.all([loadTicketRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) + Promise.all([loadTicketRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) .then(([loaded, atts]) => { if (alive) { setData(loaded.data); @@ -99,7 +111,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + }, [editId, editListId]); // Предзаполнение контактов заявителя текущим пользователем — только для новой заявки. // Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком. @@ -127,7 +139,10 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - const approval = useMemo(() => ticket1cApprovalService(), []); + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // ticket1cApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => ticket1cApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. @@ -188,7 +203,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan if (requestId === undefined) { return; } - deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) + deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .catch(() => undefined); }; @@ -210,7 +225,13 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan ? REQUEST_STATUS.onApproval : REQUEST_STATUS.approved; - const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus); + const { id, code } = await saveTicketRequest( + requestId, + data, + toApprovers(matrix.rows), + finalStatus, + editListId + ); setRequestId(id); setRequestStatus(finalStatus); if (code) { @@ -219,9 +240,9 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan let attachments = existingAttachments; if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); + await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); setFiles([]); - attachments = await getAttachments(dataListTitle(FORM_ID), id); + attachments = await getAttachments(editListId || dataListTitle(FORM_ID), id); setExistingAttachments(attachments); } @@ -230,10 +251,12 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan } if (finalStatus === REQUEST_STATUS.onApproval) { + // Имена списков — той же партиции, что и сама заявка (editListId), не + // текущей: поток ищет строку маршрута именно в НЕЙ. await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID), + approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID) }); } else if (finalStatus === REQUEST_STATUS.approved) { // Согласование не требуется для этого вида работ — сразу передаём в отдел 1С. @@ -245,7 +268,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan attachments, webAbsoluteUrl: context.pageContext.web.absoluteUrl }); - await setPlannerTaskId(id, taskId); + await setPlannerTaskId(id, taskId, editListId); } }; @@ -284,7 +307,12 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index cec9b4c..1a7ef75 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -27,7 +27,12 @@ import { REQUEST_STATUS } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; -import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadTicketRequest } from './load'; import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit'; @@ -41,6 +46,8 @@ interface ITicket1CViewProps { itemId: number; /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; } /** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ @@ -72,7 +79,7 @@ function buildPlannerNotes( } // Заявка в отдел 1С только для просмотра (режим согласующего). -const Ticket1CView: React.FC = ({ context, itemId, formKey }) => { +const Ticket1CView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -83,11 +90,13 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } const { id: requestTypeId } = useRequestTypeId(context, formKey); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); - const approverService = useMemo( - () => new ApproverService(ticket1cApprovalService(), dataListTitle(FORM_ID)), - [] - ); - const approval = useMemo(() => ticket1cApprovalService(), []); + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // ticket1cApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => ticket1cApprovalService(listId), [listId]); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); // decisionSeq входит в reloadKey: после решения строки маршрута меняются на // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. @@ -100,7 +109,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } useEffect(() => { let alive = true; setLoading(true); - Promise.all([loadTicketRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) + Promise.all([loadTicketRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)]) .then(([loaded, files]) => { if (alive) { setData(loaded.data); @@ -120,14 +129,14 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита // от повторного создания карточки, если решение применяется из нескольких // вкладок/сессий одновременно (см. submit.ts). const handoffToPlanner = useCallback( async (finalStatus: string, currentData: IFormData, currentCode: string): Promise => { - const existingTaskId = await getPlannerTaskId(itemId); + const existingTaskId = await getPlannerTaskId(itemId, listId); if (existingTaskId) { return; } @@ -143,7 +152,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } const bucketName = finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; - const atts = await getAttachments(dataListTitle(FORM_ID), itemId); + const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId); const taskId = await new PlannerTicketService(context).createTask({ planId: PLANNER_PLAN_ID, bucketName, @@ -152,9 +161,9 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } attachments: atts, webAbsoluteUrl: context.pageContext.web.absoluteUrl }); - await setPlannerTaskId(itemId, taskId); + await setPlannerTaskId(itemId, taskId, listId); }, - [approval, context, itemId] + [approval, context, itemId, listId] ); if (loading) { @@ -174,8 +183,8 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { requestTypeId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID) }).catch(() => undefined); }; @@ -185,7 +194,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey } {isEditableStatus(status) && (