diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index cda9ef0..d4524ff 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; -import { type IPropertyPaneConfiguration } from '@microsoft/sp-property-pane'; +import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base'; @@ -11,14 +11,23 @@ import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; import { setImpersonatedIdentity } 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'; import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser'; export interface IRequestPortalWebPartProps { - /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ + /** + * Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. + * Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть; + * ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после + * резолва заменяется собой же на найденный e-mail — отдельное поле под + * «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии. + */ debugUserQuery: string; - debugUserEmail: string; debugUserDisplayName: string; + /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ + approvalApiBaseUrl: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -29,7 +38,9 @@ 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; }); } @@ -131,13 +153,38 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { - this.properties.debugUserQuery = query; - this.properties.debugUserEmail = email; - this.properties.debugUserDisplayName = displayName; - this.render(); + // Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по + // ФИО) — свойство одно и то же используется как e-mail для + // подмены (см. render() выше), так что писать в него сырое ФИО + // нельзя. Пустой query — поле очистили, подмену выключаем. + // А вот «email пуст, но query не пуст» — ФИО ещё не нашлось + // (недописано/не существует): ничего не трогаем, иначе неудачный + // промежуточный поиск молча выключил бы уже работающую подмену. + if (email) { + this.properties.debugUserQuery = email; + this.properties.debugUserDisplayName = displayName; + this.render(); + } else if (!query) { + this.properties.debugUserQuery = ''; + this.properties.debugUserDisplayName = ''; + this.render(); + } } }) ] + }, + { + groupName: 'Approval Route API', + groupFields: [ + PropertyPaneDropdown('approvalApiBaseUrl', { + label: 'Api Base Url', + selectedKey: this.properties.approvalApiBaseUrl, + options: [ + { key: APPROVAL_API_BASE_URL_PROD, text: `Прод (${APPROVAL_API_BASE_URL_PROD})` }, + { key: APPROVAL_API_BASE_URL_LOCAL, text: `Локально (${APPROVAL_API_BASE_URL_LOCAL})` } + ] + }) + ] } ] } diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index 9aa1ad4..251ad54 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -55,21 +55,44 @@ min-height: 78vh; // «присутствие» рейла даже когда контента мало } -// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за -// чего высота рейла плавала вместе с содержимым соседней колонки — например, у -// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main -// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же -// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh): -// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте -// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже -// не действуют. +// Рейл-колонка. +// +// Здесь сходятся два требования, которые по отдельности решаются по-разному: +// +// 1. Градиент рейла не должен менять оттенок от высоты соседней колонки. Раньше +// колонка тянулась по .main (align-items: stretch), и, например, доп. фильтр +// по статусу на вкладке «Инициированные мной» делал .main чуть выше — фон +// Sidebar (градиент от своей же высоты, см. $grad-header в @tnsu/ui-kit) +// заметно менял тон. Поэтому САМ РЕЙЛ прибит к высоте окна (100vh) и от +// содержимого рядом больше не зависит. +// 2. Под рейлом не должно быть белого провала на длинных таблицах. Прежняя +// версия задавала height:100vh самой КОЛОНКЕ — и всё, что ниже первого +// экрана, оставалось непрокрашенным: на длинном списке заявок рейл выглядел +// обрезанным. Ставку на sticky тут держать нельзя: в SharePoint прокручивается +// не окно, а внутренний контейнер страницы, и sticky там нередко не +// включается вовсе — тогда рейл просто уезжает вверх вместе с контентом. +// +// Отсюда разделение: КОЛОНКА тянется во всю высоту раскладки и закрашена +// $navy-800 — это цвет, которым градиент рейла заканчивается (на 85% и до +// конца), поэтому стык с липкой частью не виден. РЕЙЛ внутри — sticky на 100vh +// поверх этой заливки. Не включился sticky — под рейлом всё равно та же +// заливка, а не белизна. +// +// На мобильном брейкпоинте сам Sidebar уходит в position:fixed (см. +// @tnsu/ui-kit) — там эти правила уже не действуют, см. @media ниже. .side { flex: 0 0 auto; display: flex; - position: sticky; - top: 0; - align-self: flex-start; - height: 100vh; + align-self: stretch; + background: $navy-800; + + // Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не + // видно, поэтому селектор по структуре, а не по имени). + > * { + position: sticky; + top: 0; + height: 100vh; + } } .main { @@ -109,6 +132,20 @@ .mobileNav { display: block; } + + // Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar + // position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при + // равной специфичности — исход решался бы порядком подключения стилей. + // Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам. + .side { + align-self: flex-start; + background: none; + + > * { + position: static; + height: auto; + } + } } // Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index f98c872..2a70890 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -228,6 +228,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, // Страница и запрос — часть того же списка под панелью: без них ссылка на @@ -378,7 +383,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.mode === 'approve' && route.formKey && route.itemId && ( - + )} @@ -391,6 +396,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..6df2bcd 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 { isConfirmedStaleListId } from '../../shared/approval'; export interface IApproveFeatureProps { context: WebPartContext; @@ -9,18 +10,29 @@ 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 (isConfirmedStaleListId). + const partitionError = + listId && isConfirmedStaleListId(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..df2a4c0 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 { isConfirmedStaleListId } 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,24 @@ const EditFeature: React.FC = ({ context, formKey, itemId, on return
Для формы «{formKey}» редактирование не настроено.
; } + // Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts) + // и реестр ТОЧНО знает, что такого listId в серии типа нет (см. + // isConfirmedStaleListId) — устаревшая/битая ссылка. Если реестр про серию + // просто ничего не знает (approval-route не ответил при старте) — это НЕ + // повод отказывать: listId доверяем как есть, ровно как listTargetsForType в + // myRequests.service.ts. Проверяем ЗДЕСЬ, ДО монтирования формы: внутри неё + // имя списка резолвится синхронно (useMemo) и там уже негде красиво показать + // ошибку без риска уронить рендер всей панели — см. vacationApprovalService и + // аналоги в остальных формах. + if (listId && isConfirmedStaleListId(formKey, listId)) { + return ( +
+ Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть — + попробуйте открыть заявку заново из списка. +
+ ); + } + return (
Загрузка формы…
}> @@ -37,6 +58,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/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 9ec124b..a8f9988 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest'; import { DataTable, IDataTableColumn } from '@tnsu/ui-kit'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { traced } from './perfTrace'; import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; @@ -208,9 +209,13 @@ const Table: React.FC<{ // комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки // (статус → StatusBadge, дата → формат) задаются через render. const columns: IDataTableColumn[] = [ - { key: 'id', title: 'ID', width: '70px' }, + // Код заявки, а не Id элемента: Id уникален только внутри своей партиции + // (в каждом списке SharePoint нумерация с единицы), поэтому после архивации + // в таблице встречались разные заявки под одним номером. Кода нет у заявок, + // созданных до появления колонки — для них показываем Id, как раньше. + { key: 'code', title: 'Код', width: '130px', render: (r) => r.code || `#${r.id}` }, { key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName }, - { key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` }, + { key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` }, { key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) } ]; if (cols.author) { @@ -392,16 +397,20 @@ export const RequestsTableView: React.FC = ({ return result; }; - const load = async (): Promise => { + const load = async (forceRender = false): Promise => { try { - const fresh = await fetchPage(); + const fresh = await traced(`ИТОГО таблица:${view}#${page}`, fetchPage); if (cancelled) return; // Перерисовываем, только если данные реально изменились — иначе при // каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же - // самым содержимым. + // самым содержимым. forceRender — исключение: строки уже спрятаны со + // экрана (см. force ниже), и их обязательно нужно вернуть, даже если + // свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица + // так и останется в состоянии «Загрузка…», хотя данные давно пришли + // (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18). const prev = readPage(cacheKey); - if (!prev || pageSignature(prev) !== pageSignature(fresh)) { + if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) { writePage(cacheKey, fresh); setRows(fresh.rows); setTotalRows(fresh.totalRows); @@ -419,12 +428,12 @@ export const RequestsTableView: React.FC = ({ const unsubscribe = onRequestsChanged((force) => { // Явный повторный клик по уже открытому пункту меню — пользователь ждёт // видимого обновления, а не молчаливой подмены данных за спиной. Прежние - // строки прячем сразу; свежие подставит load(). + // строки прячем сразу; свежие подставит load(force). if (force) { setRows(undefined); setLoadedKey(''); } - load().catch(() => undefined); + load(force).catch(() => undefined); }); // Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней // сработает ближайший тик. diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss index 966ce9d..d44d681 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss @@ -126,7 +126,9 @@ } .colId { - width: 70px; + // Под код заявки («13-2608-42»), а не под Id элемента: прежних 70px на него + // не хватало (см. IMyRequest.code). + width: 130px; color: $gray-400; white-space: nowrap; } diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 75bdca8..52ff9e9 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq import { IMyRequest } from './models/IMyRequest'; import { IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { traced } from './perfTrace'; import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; @@ -66,7 +67,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi - + @@ -91,9 +92,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi } }} > - + {/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */} + - +
IDКод Тип Название Дата{r.id}{r.code || `#${r.id}`} {r.requestTypeName}{r.title || `#${r.id}`}{r.title || r.code || `#${r.id}`} {formatDate(r.created)} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} @@ -182,13 +184,16 @@ export const SearchView: React.FC = ({ setRows(cached ? cached.rows : undefined); setTotalRows(cached ? cached.totalRows : 0); - const load = async (): Promise => { + const load = async (forceRender = false): Promise => { try { - const fresh = await searchMyRequests(context, types, debouncedQuery); + const fresh = await traced('ИТОГО поиск', () => searchMyRequests(context, types, debouncedQuery)); if (cancelled) return; applyKnownAuthors(fresh.rows, authors.current); const prev = readPage(cacheKey); - if (!prev || pageSignature(prev) !== pageSignature(fresh)) { + // forceRender — строки уже спрятаны (см. force ниже) и обязаны + // вернуться, даже если свежий ответ совпал с прежним кешем: иначе + // экран так и останется в «Ищем…» (см. тот же баг в RequestsTableView). + if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) { writePage(cacheKey, fresh); setRows(fresh.rows); setTotalRows(fresh.totalRows); @@ -202,7 +207,7 @@ export const SearchView: React.FC = ({ load().catch(() => undefined); const unsubscribe = onRequestsChanged((force) => { if (force) setRows(undefined); - load().catch(() => undefined); + load(force).catch(() => undefined); }); const timer = window.setInterval(() => { if (!document.hidden) load().catch(() => undefined); diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index d53c5ba..6df1e09 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -2,6 +2,18 @@ // Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). export interface IMyRequest { id: number; + /** + * Уникальный код заявки (колонка RequestUniqueCode) — вида «13-2608-42», см. + * shared/requestCode.ts. Именно он показывается пользователю вместо Id + * элемента: Id уникален только ВНУТРИ своей партиции (в каждом списке + * SharePoint нумерация с единицы), поэтому после архивации в одной таблице + * оказывались разные заявки с одинаковым номером. + * + * Пустая строка — заявка создана до появления колонки либо код не успел + * записаться (он проставляется вторым запросом, уже после создания элемента, + * см. saveRequestItem): тогда в таблице показывается Id, как раньше. + */ + code: string; title: string; status: string; authorEmail: string; @@ -11,6 +23,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..5fa1351 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -6,12 +6,17 @@ import { IList } from '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; +// sp.batched() — довесок по свежести уходит одним запросом на сайт, см. +// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах, +// но в рантайме не зарегистрирован. +import '@pnp/sp/batching'; 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'; import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace'; +import { traced } from './perfTrace'; // Список данных «... — данные» → список согласования «... — согласование» // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). @@ -19,7 +24,7 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход -// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, +// Search, и подмешиваем к результату (см. buildPendingIndex/myTurn, // getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для // таблиц без текстового запроса — там, где важны и масштаб, и мгновенная // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). @@ -99,7 +104,7 @@ export type ApprovalScope = 'myTurn' | 'involved'; // Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя. // Отличаются только набором статусов его строки — сам механизм (найти свои // строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не -// зашиты в indexApprovalsForType. +// зашиты в finalizeApprovalTarget. // // approvals/execution — очереди действий: ход за пользователем прямо сейчас. // Раньше обе выдавались одним разделом «На согласовании» (фильтр включал @@ -152,6 +157,14 @@ const PENDING_PAGE_SIZE = 20; // (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там // нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы — // см. filterPendingByQuery и вызывающую сторону в RequestsTableView). +// +// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов — +// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см. +// searchAcrossApprovalLists), и при архивации в несколько партиций это была +// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11 +// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с). +// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см. +// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования. async function buildPendingIndex( context: WebPartContext, scope: ApprovalScope, @@ -160,16 +173,117 @@ async function buildPendingIndex( const types = await getLinkedTypes(context); const tokenEmail = currentUserEmail(context); - const perType = await Promise.all( - types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) + const debugByType = new Map(); + types.forEach((t) => + debugByType.set(t.id, { + requestTypeId: t.id, + requestTypeName: t.name, + listId: t.listId, + siteUrl: t.siteUrl, + skipped: false, + }) ); - // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. - const index = perType - .reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]) - .sort((a, b) => b.created.localeCompare(a.created)); + if (!tokenEmail) { + return { types, index: [], debug: types.map((t) => debugByType.get(t.id)!) }; + } - return { types, index, debug: perType.map((r) => r.debug) }; + // Партиции ВСЕХ типов — в один плоский список: комбинированный Search строит + // Path-условие по ним разом, поэтому дальше нет смысла обходить типы по + // отдельности. + const flat = types.reduce>( + (all, t) => all.concat(listTargetsForType(t).map((target) => ({ type: t, target }))), + [] + ); + + // Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом: + // approval-route не хранит на него ссылку (список согласования ищется только + // по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда. + // Но это лёгкий $select без постраничного перебора — не то же самое, что + // полноценный Search, который эти вызовы как раз заменяют. + const resolved = await Promise.all( + flat.map(({ type, target }) => + traced(`resolveApprovalSearchTarget:${type.name}#${target.listId}`, () => + resolveApprovalSearchTarget(context, type, target) + ) + ) + ); + + const okEntries: IApprovalSearchTarget[] = []; + resolved.forEach((r) => { + const debugType = r.kind === 'ok' ? r.value.type : r.type; + const debug = debugByType.get(debugType.id); + if (!debug) return; + if (r.kind === 'ok') { + okEntries.push(r.value); + } else if (r.kind === 'skip') { + if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason; + } else { + if (!debug.approvalError) debug.approvalError = r.message; + } + }); + + // Два источника кандидатов — Search (весь архив) и REST-довесок по свежести + // (то, что индекс мог не успеть подхватить) — независимы друг от друга, + // поэтому идут параллельно, а не один за другим. + const [hitsByTarget, recentByTarget] = await Promise.all([ + okEntries.length > 0 + ? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)) + : Promise.resolve(new Map()), + // Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске + // важна полнота по архиву, а не мгновенная свежесть (см. scope='involved'). + okEntries.length > 0 && scope === 'myTurn' + ? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)) + : Promise.resolve(new Map>>()), + ]); + + const parts = await Promise.all( + okEntries.map((entry) => + traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () => + finalizeApprovalTarget( + context, + entry, + hitsByTarget.get(targetKey(entry)) ?? [], + recentByTarget.get(targetKey(entry)) ?? [] + ) + ) + ) + ); + + const rowsByType = new Map(); + const titlesByType = new Map(); + parts.forEach((part, i) => { + const entry = okEntries[i]; + const debug = debugByType.get(entry.type.id); + if (!debug) return; + + const bucket = rowsByType.get(entry.type.id) ?? []; + bucket.push(...part.rows); + rowsByType.set(entry.type.id, bucket); + + if (part.approvalListTitle) { + const titles = titlesByType.get(entry.type.id) ?? []; + titles.push(part.approvalListTitle); + titlesByType.set(entry.type.id, titles); + } + if (part.error && !debug.approvalError) debug.approvalError = part.error; + debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0); + }); + + const index: IMyRequest[] = []; + types.forEach((t) => { + const debug = debugByType.get(t.id)!; + const titles = titlesByType.get(t.id); + if (titles && titles.length > 0) debug.approvalListTitle = titles.join(', '); + const typeRows = rowsByType.get(t.id) ?? []; + debug.approvalPendingTickets = typeRows.length; + index.push(...typeRows); + }); + + // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. + index.sort((a, b) => b.created.localeCompare(a.created)); + + return { types, index, debug: types.map((t) => debugByType.get(t.id)!) }; } // Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 @@ -223,17 +337,21 @@ export interface INavCounts { // «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по // отдельным запросам значило бы дважды просканировать одни и те же списки // согласования. Статус своей строки приходит в myStepStatus (см. -// indexApprovalsForType), по нему и разносим. +// finalizeApprovalTarget), по нему и разносим. // // «Доработка» живёт в других списках (данные, по автору), поэтому считается // своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму // вкладку: иначе счётчик и список могли бы разойтись. export async function getNavCounts(context: WebPartContext): Promise { + return traced('ИТОГО getNavCounts', () => getNavCountsInner(context)); +} + +async function getNavCountsInner(context: WebPartContext): Promise { const types = await getLinkedTypes(context); const [turn, rework] = await Promise.all([ - buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES), - getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1), + traced('buildPendingIndex:myTurn', () => buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES)), + traced('getOwnRequestsByStatus:rework', () => getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1)), ]); return { @@ -259,85 +377,306 @@ export async function hydratePendingAuthors( // произвольные поля связанного элемента, только через Person-поля вроде // Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400). // Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы. -async function indexApprovalsForType( + +/** Список согласования одной партиции, готовый к поиску: где искать и куда дочитывать. */ +interface IApprovalSearchTarget { + type: IRequestType; + target: IListTarget; + approvalList: IList; + approvalListTitle: string; + /** Абсолютный префикс пути элементов СПИСКА СОГЛАСОВАНИЯ, со слешем на конце. */ + approvalListAbsUrl: string; +} + +type ApprovalTargetResolution = + | { kind: 'ok'; value: IApprovalSearchTarget } + | { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string } + | { kind: 'error'; type: IRequestType; target: IListTarget; message: string }; + +/** + * Резолвит список согласования одной партиции: Title (по конвенции из Title + * списка данных) и его абсолютный адрес пути. + * + * Title списка данных — из target.title (бесплатно, из bulk-ответа + * /api/request-types/lists, см. IListTarget.title), REST — только если реестр + * партиций был недоступен. + * + * Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него + * ссылку (список согласования ищется только по имени, см. shared/listTitles.ts), + * сам список создаётся SharePoint под произвольным номером, и его адрес нельзя + * вывести из адреса списка данных. Но это лёгкий $select без постраничного + * перебора — не то же самое, что полноценный Search, который эти вызовы + * заменяют (см. searchAcrossApprovalLists). + */ +async function resolveApprovalSearchTarget( context: WebPartContext, type: IRequestType, - tokenEmail: string, - scope: ApprovalScope, - statuses: string[] -): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { - const debug: IMyRequestsTypeDebug = { - requestTypeId: type.id, - requestTypeName: type.name, - listId: type.listId, - siteUrl: type.siteUrl, - skipped: false, - }; - + target: IListTarget +): Promise { try { - if (!tokenEmail) return { rows: [], debug }; + const sp = spForSite(target.siteUrl, context); + const list = sp.web.getList(target.listId); - const sp = spForSite(type.siteUrl as string, context); - const list = sp.web.getList(type.listId as string); - - const dataListInfo = await list.select('Title')(); - const dataTitle = (dataListInfo.Title as string) || ''; + const dataTitle = + target.title ?? + ((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ?? + ''; if (!DATA_LIST_SUFFIX.test(dataTitle)) { - debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; - return { rows: [], debug }; + return { + kind: 'skip', + type, + target, + reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`, + }; } const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; - debug.approvalListTitle = approvalListTitle; const approvalList = sp.web.lists.getByTitle(approvalListTitle); - // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в - // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в - // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. - // - // ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что - // кириллица в значении делает такой фильтр ненадёжным, — перепроверено - // вручную через REST 2026-08-17 (все три статуса списка согласования дали - // точное совпадение с эталоном), теория не подтвердилась. Старый вывод о - // поломке был артефактом кривого тестового запроса, а не реальным - // ограничением SharePoint. - const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; - const approver = await sp.web.ensureUser(claimsLogin); - - let stepRows: Array>; - - if (scope === 'myTurn') { - // 'myTurn' — гибрид для масштаба (список согласования может быть очень - // большим): Search находит кандидатов, не сканируя список целиком, плюс - // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё - // не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по - // найденным Id (не из индекса Search) — именно это делает - // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда - // точное, даже когда список кандидатов пришёл из Search. - const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); - // Статус — прямо в $filter (см. комментарий про ApprovalStatus выше): - // REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS. - const statusFilter = - statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; - const recentRows = (await approvalList.items - .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(2000)()) as Array>; - - // Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки, - // а не всё, где пользователь есть в маршруте. - const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); - const seenIds = new Set(recentRows.map((r) => r.Id)); - stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); - } else { - // 'involved' — используется ТОЛЬКО для текстового поиска (см. - // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная - // свежесть, а полнота по всему архиву, поэтому только Search, без - // REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске - // давал неустойчивый результат (заявка то находилась, то нет, в - // зависимости от того, успел ли Search её проиндексировать к моменту - // REST-окна). - stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); + const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () => + approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')() + )) as { RootFolder?: { ServerRelativeUrl?: string } }; + const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; + if (!relUrl) { + return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` }; } + // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с + // путями элементов …/Lists/List10 (см. listPathPrefix). + const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`; + + return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } }; + } catch (e) { + return { kind: 'error', type, target, message: (e as Error).message }; + } +} + +function targetKey(entry: IApprovalSearchTarget): string { + return entry.approvalListAbsUrl.toLowerCase(); +} + +// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное +// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» +// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго +// возвращали 0. +// +// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима +// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: +// без него в выдачу попали бы все строки, где пользователь вообще есть в +// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. +// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). +// +// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берёт +// СВЕЖИМИ через REST по этим Id вызывающая сторона (finalizeApprovalTarget), не +// из индекса (индекс мог устареть, а на устаревшем статусе решение «моя +// очередь» строить нельзя). +/** + * ОДИН комбинированный Search по спискам согласования ВСЕХ партиций ВСЕХ типов — + * Path через OR, approver и statuses общие для всех (см. buildPendingIndex, где + * это гарантировано одним вызовом на все типы разом). Тот же приём OR-а по Path + * уже применялся для «Своих заявок» (см. pathClauseForTypes) — раньше здесь + * вместо этого был отдельный Search-запрос НА КАЖДУЮ партицию, и при архивации + * в несколько партиций это было самой дорогой частью загрузки «На согласовании» + * (трассировка 2026-08-18: 11 партиций — 11 отдельных постраничных запросов). + * + * Ошибка одного общего запроса теперь означает «Search недоступен для всех», а + * не для одной партиции — компромисс сознательный: системный сбой Search и + * раньше задевал бы все партиции разом (это один и тот же сервис), а REST-net + * по свежести (recentRows, см. finalizeApprovalTarget) на сбой Search вообще не + * завязан и продолжает работать сам по себе. + * + * Возвращает найденные Id, сгруппированные по списку согласования (targetKey). + */ +async function searchAcrossApprovalLists( + context: WebPartContext, + targets: IApprovalSearchTarget[], + tokenEmail: string, + statuses: string[] +): Promise> { + const result = new Map(); + + try { + const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; + const pathClause = `(${targets.map((t) => `Path:"${t.approvalListAbsUrl}*"`).join(' OR ')})`; + const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`]; + if (statuses.length > 0) { + clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`); + } + const kql = clauses.join(' AND '); + + // Представитель любого сайта годится — поисковый индекс общий на тенант + // (тот же приём, что в searchOwnRequests: spForSite(types[0].siteUrl, …)). + const sp = spForSite(targets[0].target.siteUrl, context); + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Path'], 'На согласовании (все партиции)'); + + hits.forEach((h) => { + const id = Number(h.ListItemID); + if (isNaN(id) || id <= 0) return; + const path = String(h.Path || '').toLowerCase(); + // Сравнение по границе сегмента (см. listPathPrefix) — тот же приём, что + // в hitsToEntries, но по адресам списков СОГЛАСОВАНИЯ, а не данных. + const matched = targets.filter((t) => path.indexOf(t.approvalListAbsUrl.toLowerCase()) === 0)[0]; + if (!matched) return; + const key = targetKey(matched); + const arr = result.get(key) ?? []; + arr.push(id); + result.set(key, arr); + }); + + return result; + } catch (e) { + console.error('[Текущие заявки] Комбинированный Search по спискам согласования не сработал:', (e as Error).message); + return result; + } +} + +/** + * REST-довесок по свежести для очереди согласования: строки маршрута, изменённые + * за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить. + * + * Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая + * в архив, продолжает висеть на согласовании, а решение по ней меняет строки + * маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive). + * Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку + * он увидит не сразу, а только когда её подхватит индекс; именно эту заявку + * фича «архивные заявки тоже согласуются» и должна показывать вовремя. + * + * Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта + * уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в + * approvalList.service.ts/createSteps), поэтому число партиций больше не + * добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и + * с отдельным ensureUser перед каждым. + */ +async function fetchRecentApprovalRows( + context: WebPartContext, + entries: IApprovalSearchTarget[], + tokenEmail: string, + statuses: string[] +): Promise>>> { + const result = new Map>>(); + const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + // Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки, + // без постфильтрации в JS. Кириллица в значении этому не мешает — проверено + // вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS. + const statusFilter = + statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; + + // Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id + // пользователя тоже свой на каждом сайте. + const bySite = new Map(); + entries.forEach((e) => { + const key = e.target.siteUrl.toLowerCase(); + const bucket = bySite.get(key) ?? []; + bucket.push(e); + bySite.set(key, bucket); + }); + + await Promise.all( + Array.from(bySite.values()).map(async (siteEntries) => { + const siteUrl = siteEntries[0].target.siteUrl; + try { + const sp = spForSite(siteUrl, context); + + // ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки + // каждой партиции, то есть повторялся N раз с тем же результатом, и это + // POST, а не дешёвый GET. + // + // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в + // SharePoint REST — поэтому резолвим email в числовой Id (как и везде в + // проекте) и фильтруем по TansuSiteApproverId. + const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; + const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin)); + const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`; + + const [batchedSP, execute] = sp.batched(); + // Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе + // отказ по одной партиции внутри батча всплыл бы как unhandled rejection + // раньше, чем мы успели бы его перехватить. + const pending = siteEntries.map((entry) => + batchedSP.web.lists + .getByTitle(entry.approvalListTitle) + .items.filter(filter) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(2000)() + .then((rows) => { + result.set(targetKey(entry), rows as Array>); + }) + .catch((e) => { + // Недоступная партиция (архив переименовали/удалили вручную) не + // должна лишать довеска остальные партиции того же сайта. + console.error( + `[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`, + (e as Error).message + ); + }) + ); + + await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute); + await Promise.all(pending); + } catch (e) { + console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message); + } + }) + ); + + return result; +} + +/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */ +interface IPartitionApprovalResult { + rows: IMyRequest[]; + approvalListTitle?: string; + approvalRowCount?: number; + error?: string; +} + +/** + * Достраивает результат одной партиции поверх уже собранных кандидатов: + * • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции + * ничего не нашлось или Search целиком не сработал); + * • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме + * текстового поиска и когда за последние RECENT_MS ничего не менялось). + * + * Сама сеть здесь только на дочитывание: собственных «поисковых» запросов + * функция больше не делает — оба источника кандидатов собираются один раз на все + * партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows). + */ +async function finalizeApprovalTarget( + context: WebPartContext, + entry: IApprovalSearchTarget, + searchHitIds: number[], + recentRows: Array> +): Promise { + const { type, target, approvalList, approvalListTitle } = entry; + const debug: IPartitionApprovalResult = { rows: [], approvalListTitle }; + + try { + const sp = spForSite(target.siteUrl, context); + const list = sp.web.getList(target.listId); + + // TicketId/Status — СВЕЖИМИ через REST по найденным Id, не из индекса + // Search (см. doc-комментарий у searchAcrossApprovalLists). + const searchRows = + searchHitIds.length === 0 + ? [] + : ( + await Promise.all( + // Id режем на пачки — тот же предел длины URL REST $filter, что и в + // loadByIds (см. REST_ID_FILTER_CHUNK). + chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) => + traced(`approvalRowsRest:${type.name}#${target.listId}`, () => + approvalList.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(idsChunk.length)() + ) + ) + ) + ).reduce((all, r) => all.concat(r as Array>), [] as Array>); + + // Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только + // то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее, + // чем в индексе, а решение «моя очередь сейчас» строится именно на статусе. + const seenIds = new Set(recentRows.map((r) => r.Id)); + const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); debug.approvalRowCount = stepRows.length; @@ -358,110 +697,31 @@ 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 это не отдаётся). const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or '); - const ticketRows = (await list.items - .filter(ticketFilter) - .select('Id', 'Title', 'RequestStatus', 'Created') - .top(ticketIds.length)()) as Array>; + const ticketRows = (await traced(`ticketRowsRest:${type.name}#${target.listId}`, () => + list.items + .filter(ticketFilter) + .select('Id', 'RequestUniqueCode', '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 }; - } -} - -// Строки согласования текущего пользователя через Search, чтобы не сканировать -// список целиком (он растёт с каждой заявкой). -// -// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное -// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» -// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго -// возвращали 0. -// -// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима -// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: -// без него в выдачу попали бы все строки, где пользователь вообще есть в -// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. -// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). -// -// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём -// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на -// устаревшем статусе решение «моя очередь» строить нельзя). -async function getInvolvedRowsViaSearch( - context: WebPartContext, - type: IRequestType, - approvalList: IList, - statuses: string[] = [] -): Promise>> { - try { - const email = currentUserEmail(context); - if (!email) { - console.warn( - `[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен` - ); - return []; - } - const claimsLogin = `i:0#.f|membership|${email}`; - - const rootFolder = (await approvalList - .select('RootFolder/ServerRelativeUrl') - .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; - const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; - if (!relUrl) return []; - // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с - // путями элементов …/Lists/List10 (см. listPathPrefix). - const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl.replace(/\/+$/, '')}/`; - - const sp = spForSite(type.siteUrl as string, context); - const pathClause = `Path:"${approvalListAbsUrl}*"`; - const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`]; - if (statuses.length > 0) { - clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`); - } - const kql = clauses.join(' AND '); - - const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`); - const ids = hits - .map((r) => Number(r.ListItemID)) - .filter((id) => !isNaN(id) && id > 0); - - if (ids.length === 0) return []; - - // Id режем на пачки — тот же предел длины URL REST $filter, что и в - // loadByIds (см. REST_ID_FILTER_CHUNK). - const chunks = await Promise.all( - chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) => - approvalList.items - .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(idsChunk.length)() - ) - ); - return (chunks as Array>>).reduce((all, r) => all.concat(r), []); - } catch (e) { - console.error( - `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, - (e as Error).message - ); - return []; + return debug; } } @@ -478,40 +738,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 || ''; }); @@ -562,14 +831,16 @@ async function fetchAllSearchRows( let startRow = 0; for (;;) { - const results = await sp.search({ - Querytext: kql, - RowLimit: SEARCH_PAGE_SIZE, - StartRow: startRow, - SelectProperties: selectProperties, - TrimDuplicates: false, - ...(sortList ? { SortList: sortList } : {}), - }); + const results = await traced(`search:${label}#${startRow}`, () => + sp.search({ + Querytext: kql, + RowLimit: SEARCH_PAGE_SIZE, + StartRow: startRow, + SelectProperties: selectProperties, + TrimDuplicates: false, + ...(sortList ? { SortList: sortList } : {}), + }) + ); const page = results.PrimarySearchResults as Array>; rows.push(...page); @@ -609,10 +880,104 @@ function listPathPrefix(type: IRequestType): string { return `${listAbsUrl(type).replace(/\/+$/, '')}/`; } -// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка — -// сравнение по границе сегмента, не по «голому» началу строки. +/** Один конкретный список серии: куда идти читать и какому префиксу пути он соответствует. */ +export interface IListTarget { + /** Серверный относительный url списка данных. */ + listId: string; + siteUrl: string; + /** Абсолютный префикс пути элементов этого списка, со слешем на конце. */ + prefix: string; + /** + * Title списка данных на момент регистрации партиции (см. IListPartition.title) — + * уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при + * старте страницы. Заполнен только когда партиция известна реестру; для + * фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда + * resolveApprovalSearchTarget сам дочитает Title через REST. + */ + title?: 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(/\/+$/, '')}/`, + title: p.title + })); +} + +/** 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 +1011,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 +1019,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,35 +1053,55 @@ 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 }) => { + // Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть + // недоступен (удалён, переименован, остался в реестре после смены списка у + // типа заявки) — 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(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); - }); - }) - ); + await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => { + const rows = (await traced(`loadByIds:${kind}:${type.name}#${target.listId}`, () => + list.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'RequestUniqueCode', '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; @@ -714,14 +1112,19 @@ 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') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .orderBy('Created', false) .top(OWN_REST_TOP)()) as Array>; @@ -733,7 +1136,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 +1166,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') + .select('Id', 'RequestUniqueCode', '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( @@ -799,17 +1205,18 @@ export async function getOwnRequests( ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const searchPage = await searchOwnRequests(context, types, '', page); + const searchPage = await traced('searchOwnRequests', () => searchOwnRequests(context, types, '', page)); if (page !== 1) return searchPage; - const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce( - (all, r) => all.concat(r), - [] as IMyRequest[] - ); + const freshRows = ( + await Promise.all( + types.map((t) => traced(`getFreshOwnRowsForType:${t.name}`, () => getFreshOwnRowsForType(context, t))) + ) + ).reduce((all, r) => all.concat(r), [] 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,29 +1241,33 @@ 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); if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: PAGE_SIZE, - StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). - SortList: [{ Property: 'Created', Direction: 1 }], - }); - + const results = await traced('search:searchOwnRequests', () => + sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). + SortList: [{ Property: 'Created', Direction: 1 }], + }) + ); const totalRows = results.TotalRows || 0; const entries = hitsToEntries(results, types); - const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); + const byKey = + entries.length > 0 + ? await traced('loadByIds:own(searchOwnRequests)', () => 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 +1291,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}"`, ]; @@ -888,32 +1299,43 @@ export async function getOwnRequestsByStatus( if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: PAGE_SIZE, - StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - // Свежие сверху — как в searchOwnRequests. - SortList: [{ Property: 'Created', Direction: 1 }], - }); + const results = await traced('search:getOwnRequestsByStatus', () => + sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху — как в searchOwnRequests. + SortList: [{ Property: 'Created', Direction: 1 }], + }) + ); 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 byKey = + entries.length > 0 + ? await traced('loadByIds:own(getOwnRequestsByStatus)', () => loadByIds(context, entries, 'own')) + : new Map(); + 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 }; // Довесок по недавним изменениям — только на первой странице и только без // текстового поиска (тот же принцип, что в getOwnRequests): заявку могли // вернуть на доработку только что, индекс мог не успеть её подхватить. - const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) + const freshRows = ( + await Promise.all( + types.map((t) => + traced(`getRecentlyChangedOwnRowsForType:${t.name}`, () => getRecentlyChangedOwnRowsForType(context, t)) + ) + ) + ) .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) @@ -924,14 +1346,14 @@ export async function getOwnRequestsByStatus( } // Список согласования по типу заявки: Title списка данных → Title списка -// согласования по конвенции имён (та же логика, что в indexApprovalsForType). +// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget). // 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 +1390,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 +1439,7 @@ export async function hydrateDecisionComments( }); } catch (e) { console.error( - `[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`, + `[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать комментарий к решению:`, (e as Error).message ); } @@ -1041,7 +1470,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 +1489,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 +1528,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 +1543,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 +1581,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,10 +1592,12 @@ 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, + code: (r.RequestUniqueCode as string) || '', title: (r.Title as string) || '', status: (r.RequestStatus as string) || '', authorEmail: author?.EMail || '', @@ -1177,5 +1608,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..d76b8b0 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -4,6 +4,7 @@ import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache' function request(over: Partial = {}): IMyRequest { return { id: 1, + code: '1-2608-1', title: 'Заявка №1', status: 'Черновик', authorEmail: 'ivanov@tnsu.kz', @@ -12,6 +13,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, @@ -111,6 +114,15 @@ describe('pageSignature', () => { expect(pageSignature(before)).not.toBe(pageSignature(after)); }); + it('появление кода заявки меняет подпись — он показан колонкой «Код»', () => { + // Код проставляется вторым запросом, уже после создания элемента (см. + // saveRequestItem), поэтому первая выдача приходит без него. Не учитывать + // его в подписи — значит оставить в таблице «#1» до перезагрузки страницы. + const before = page({ rows: [request({ code: '' })] }); + const after = page({ rows: [request({ code: '1-2608-1' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + it('пустая страница имеет стабильную подпись', () => { expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); }); diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts index 7d0fbb9..f320f8b 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -36,10 +36,15 @@ export function writePage(key: string, page: IPage): void { // которые видны в таблице: если они совпали, перерисовывать нечего — иначе при // каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы, // перерисовываясь тем же самым содержимым. +// code — тоже видимое поле (колонка «Код»), и его обязательно учитывать: он +// проставляется ВТОРЫМ запросом, уже после создания элемента (см. +// saveRequestItem), поэтому первая выдача может прийти без него. Без code в +// слепке подъехавший позже код совпал бы с прежней подписью, перерисовки не +// случилось бы, и в таблице до перезагрузки страницы висело бы «#5». export function pageSignature(page: IPage): string { return `${page.totalRows}|${page.rows .map((r) => - [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') + [r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') ) .join(';')}`; } diff --git a/src/webparts/requestPortal/features/myRequests/perfTrace.ts b/src/webparts/requestPortal/features/myRequests/perfTrace.ts new file mode 100644 index 0000000..0d78e69 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/perfTrace.ts @@ -0,0 +1,21 @@ +/** + * Замер времени отдельных этапов загрузки «Моих заявок» — что именно тормозит + * (индекс согласований, свои заявки по типу, конкретная партиция, Search), + * а не просто «страница долго грузится». + * + * ВРЕМЕННО: пишет всегда, без флага-переключателя — этот модуль и все traced() + * вокруг снимаются целиком, как только разбор текущей проблемы с + * производительностью закончится (см. myRequests.service.ts, RequestsTableView.tsx, + * SearchView.tsx). + */ +const PREFIX = '[Перф]'; + +/** Оборачивает async-шаг замером времени. */ +export async function traced(label: string, fn: () => Promise): Promise { + const start = performance.now(); + try { + return await fn(); + } finally { + console.log(`${PREFIX} ${label}: ${(performance.now() - start).toFixed(0)} мс`); + } +} diff --git a/src/webparts/requestPortal/features/myRequests/searchTrace.ts b/src/webparts/requestPortal/features/myRequests/searchTrace.ts index 3759fcf..6af82ff 100644 --- a/src/webparts/requestPortal/features/myRequests/searchTrace.ts +++ b/src/webparts/requestPortal/features/myRequests/searchTrace.ts @@ -14,8 +14,12 @@ import { IRequestType } from '../../shared/approval'; * window.rpSearchTrace = true // следить за всеми типами * window.rpSearchTrace = false // выключить * Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен. + * + * По умолчанию выключена (false) — включённый DEFAULT_TRACE был точечной + * настройкой на время разбора конкретного бага с партициями «Обходного листа» + * и не должен постоянно писать в консоль всем остальным. */ -const DEFAULT_TRACE: string | boolean = 'обходной'; +const DEFAULT_TRACE: string | boolean = false; const PREFIX = '[Поиск]'; diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 0249ab0..427739e 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -16,22 +16,35 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, APPROVAL_PHASE, - findUnassignedStep + unassignedStepMessage } 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); @@ -41,7 +54,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); @@ -53,7 +69,7 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa return; } let alive = true; - loadClearanceRequest(editId) + loadClearanceRequest(editId, editListId) .then((loaded) => { if (alive) { setData(loaded.data); @@ -70,7 +86,7 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + }, [editId, editListId]); // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // обходной лист оформляют в день увольнения). При открытии на редактирование @@ -174,13 +190,22 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } + // requestId уже задан → редактируем существующую заявку, пишем в ту же + // партицию (editListId). Иначе — создаём новую, и партицию для записи + // спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit): + // это единственный запрос, который проверяет порог элементов текущего + // списка и ставит в очередь создание следующей партиции при достижении — + // без него архивация никогда не сработает. Сбой запроса не должен + // блокировать подачу заявки — откатываемся на список по конвенции имён. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveClearanceRequest( requestId, rtId, data, toApprovers(matrix.rows), toApprovers(executors.rows), - status + status, + writeListId ); setRequestId(id); setRequestStatus(status); @@ -191,11 +216,14 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записали (writeListId), + // не обязательно текущей на момент рендера формы: поток ищет строку + // маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) }); } }; @@ -208,14 +236,16 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; } - const unassigned = findUnassignedStep(matrix.rows); - if (unassigned) { - return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; - } - const unassignedExecutor = findUnassignedStep(executors.rows); - if (unassignedExecutor) { - return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; - } + const unassignedApproverError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedApproverError) return unassignedApproverError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; @@ -234,7 +264,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 fbda8b9..4f041ee 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), раздельно по // фазам: согласование и исполнение — два отдельных блока в UI (см. ниже). @@ -84,7 +93,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); @@ -103,7 +112,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // isDelegator намеренно не даёт прав: делегировавший не решает за делегата @@ -141,8 +150,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'); @@ -175,8 +184,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); }; @@ -205,7 +214,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 5d786f2..cac5365 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -1,12 +1,15 @@ -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 { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; +import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; +import { ensureEmployee } from '../../shared/employee'; import { FORM_ID, IFormData } from './model'; -/** Экземпляр сервиса списка согласования этой формы. */ -export function clearanceApprovalService(): ApprovalListService { - return new ApprovalListService(approvalListTitle(FORM_ID)); +/** + * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над + * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под + * именем формы для читаемости вызывающего кода. + */ +export function clearanceApprovalService(listId?: string): ApprovalListService { + return approvalServiceFor(FORM_ID, listId); } /** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */ @@ -20,10 +23,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,13 +41,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); -} - -/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */ -async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> { - const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`; - return getSP().web.ensureUser(full); + const list = resolveDataList(FORM_ID, listId); + await list.items.getById(id).update(item); } function pad2(n: number): string { @@ -69,16 +71,20 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee }; } -export interface ISaveResult { - id: number; - /** Задан только при создании новой заявки (существующий код не меняется). */ - code?: string; -} +export type ISaveResult = ISaveItemResult; /** * Сохранить обходной лист: содержимое + маршрут (согласование + исполнение). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * Возвращает Id заявки (+ код при создании). + * + * listId — партиция, в которую фактически пишем: при редактировании — партиция + * ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest), при создании — партиция, + * полученная от approval-route API непосредственно перед вызовом (см. + * getCurrentListPartition в ClearanceForm.tsx) — именно этот вызов, а не кеш из + * onInit, проверяет порог элементов и запускает архивацию следующей партиции. + * Не задан (сбой запроса за текущей партицией) → откат на список по конвенции + * имён, как до появления партиционирования. */ export async function saveClearanceRequest( existingId: number | undefined, @@ -86,25 +92,13 @@ export async function saveClearanceRequest( data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], - status: string + status: string, + listId?: string ): Promise { - const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const employee = await ensureEmployee(data.Employee); const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); - let id: number; - let code: string | undefined; - if (existingId) { - await list.items.getById(existingId).update(item); - id = existingId; - } else { - const added = await list.items.add(item); - id = (added as { Id: number }).Id; - // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(requestTypeId, id, new Date()); - await list.items.getById(id).update({ RequestUniqueCode: code }); - } - - await writeRoute(clearanceApprovalService(), id, approvers, executors, status); - return { id, code }; + const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); + await writeRoute(clearanceApprovalService(listId), result.id, approvers, executors, status); + return result; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index a704b0d..aa7cbb6 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -20,13 +20,14 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, APPROVAL_PHASE, - findUnassignedStep + unassignedStepMessage } from '../../shared/approval'; import { uploadAttachments, @@ -36,7 +37,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 +51,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 +80,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 +97,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 +174,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); }; @@ -171,13 +187,22 @@ const RecruitingHireForm: React.FC = ({ requestType, context, if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } + // requestId уже задан → редактируем существующую заявку, пишем в ту же + // партицию (editListId). Иначе — создаём новую, и партицию для записи + // спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit): + // это единственный запрос, который проверяет порог элементов текущего + // списка и ставит в очередь создание следующей партиции при достижении — + // без него архивация никогда не сработает. Сбой запроса не должен + // блокировать подачу заявки — откатываемся на список по конвенции имён. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveHireRequest( requestId, rtId, data, toApprovers(matrix.rows), toApprovers(executors.rows), - status + status, + writeListId ); setRequestId(id); setRequestStatus(status); @@ -185,19 +210,22 @@ const RecruitingHireForm: React.FC = ({ requestType, context, setRequestCode(code); } if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало - setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные + setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные } if (onSaved) { onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записали (writeListId), + // не обязательно текущей на момент рендера формы: поток ищет строку + // маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID), + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID), }); } }; @@ -209,14 +237,16 @@ const RecruitingHireForm: React.FC = ({ requestType, context, const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; - const unassigned = findUnassignedStep(matrix.rows); - if (unassigned) { - return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; - } - const unassignedExecutor = findUnassignedStep(executors.rows); - if (unassignedExecutor) { - return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; - } + const unassignedApproverError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedApproverError) return unassignedApproverError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; @@ -237,7 +267,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..9ff9fe1 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,15 +1,17 @@ -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 { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; +import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; 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)); +/** + * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над + * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под + * именем формы для читаемости вызывающего кода. + */ +export function hireApprovalService(listId?: string): ApprovalListService { + return approvalServiceFor(FORM_ID, listId); } /** Осмысленный заголовок элемента списка (системная колонка Title). */ @@ -33,11 +35,7 @@ function buildItem(data: IFormData, status: string): Record { return item; } -export interface ISaveResult { - id: number; - /** Задан только при создании новой заявки (существующий код не меняется). */ - code?: string; -} +export type ISaveResult = ISaveItemResult; /** * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. @@ -45,6 +43,14 @@ export interface ISaveResult { * status = «Черновик» (сохранение) или «На согласовании» (отправка). * executors может быть пустым — тогда после согласования заявка станет «Согласовано». * Возвращает Id заявки (+ код при создании). + * + * listId — партиция, в которую фактически пишем: при редактировании — партиция + * ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest), при создании — партиция, полученная + * от approval-route API непосредственно перед вызовом (см. getCurrentListPartition + * в RecruitingHireForm.tsx) — именно этот вызов, а не кеш из onInit, проверяет + * порог элементов и запускает архивацию следующей партиции. + * Не задан (сбой запроса за текущей партицией) → откат на список по конвенции + * имён, как до появления партиционирования. */ export async function saveHireRequest( existingId: number | undefined, @@ -52,24 +58,12 @@ 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 item = buildItem(data, status); - let id: number; - let code: string | undefined; - if (existingId) { - await list.items.getById(existingId).update(item); - id = existingId; - } else { - const added = await list.items.add(item); - id = (added as { Id: number }).Id; - // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(requestTypeId, id, new Date()); - await list.items.getById(id).update({ RequestUniqueCode: code }); - } - - await writeRoute(hireApprovalService(), id, approvers, executors, status); - return { id, code }; + const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); + await writeRoute(hireApprovalService(listId), result.id, approvers, executors, status); + return result; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 5aed7dc..b26b474 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -20,12 +20,13 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, - findUnassignedStep + unassignedStepMessage } from '../../shared/approval'; import { uploadAttachments, @@ -35,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 { saveSelectionRequest, selectionApprovalService } from './submit'; import { loadSelectionRequest } from './load'; @@ -45,7 +51,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 +77,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 +95,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 +148,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 +161,44 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } - const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status); + // requestId уже задан → редактируем существующую заявку, пишем в ту же + // партицию (editListId). Иначе — создаём новую, и партицию для записи + // спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit): + // это единственный запрос, который проверяет порог элементов текущего + // списка и ставит в очередь создание следующей партиции при достижении — + // без него архивация никогда не сработает. Сбой запроса не должен + // блокировать подачу заявки — откатываемся на список по конвенции имён. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + const { id, code } = await saveSelectionRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + writeListId + ); setRequestId(id); setRequestStatus(status); if (code) { setRequestCode(code); } if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало - setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); + setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); } if (onSaved) { onSaved(); } // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записали (writeListId), + // не обязательно текущей на момент рендера формы: поток ищет строку + // маршрута именно в НЕЙ. if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) }); } }; @@ -177,10 +211,11 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; } - const unassigned = findUnassignedStep(matrix.rows); - if (unassigned) { - return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; - } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedError) return unassignedError; return undefined; }; @@ -199,7 +234,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 b3879b2..5a363df 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -34,8 +34,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; @@ -47,8 +54,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, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 3d2b372..f723de7 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,7 +1,6 @@ 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 { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; +import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { FORM_ID, IFormData } from './model'; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. @@ -11,9 +10,13 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker']; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation']; -/** Экземпляр сервиса списка согласования этой формы. */ -export function selectionApprovalService(): ApprovalListService { - return new ApprovalListService(approvalListTitle(FORM_ID)); +/** + * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над + * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под + * именем формы для читаемости вызывающего кода. + */ +export function selectionApprovalService(listId?: string): ApprovalListService { + return approvalServiceFor(FORM_ID, listId); } /** Логин → числовой Id пользователя на текущем сайте. */ @@ -77,43 +80,35 @@ async function applyPeople(item: Record, data: IFormData): Prom } } -export interface ISaveResult { - id: number; - /** Задан только при создании новой заявки (существующий код не меняется). */ - code?: string; -} +export type ISaveResult = ISaveItemResult; /** * Сохранить заявку «на подбор»: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения * нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании). + * + * listId — партиция, в которую фактически пишем: при редактировании — партиция + * ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция, + * полученная от approval-route API непосредственно перед вызовом (см. + * getCurrentListPartition в RecruitingSelectionForm.tsx) — именно этот вызов, + * а не кеш из onInit, проверяет порог элементов и запускает архивацию + * следующей партиции. Не задан (сбой запроса за текущей партицией) → откат + * на список по конвенции имён, как до появления партиционирования. */ 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 item = buildScalarBody(data); item.RequestStatus = status; await applyPeople(item, data); - let id: number; - let code: string | undefined; - if (existingId) { - await list.items.getById(existingId).update(item); - id = existingId; - } else { - const added = await list.items.add(item); - id = (added as { Id: number }).Id; - // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(requestTypeId, id, new Date()); - await list.items.getById(id).update({ RequestUniqueCode: code }); - } - - await writeRoute(selectionApprovalService(), id, approvers, [], status); - return { id, code }; + const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); + await writeRoute(selectionApprovalService(listId), result.id, approvers, [], status); + return result; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index b12d540..8850622 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -20,12 +20,13 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, - findUnassignedStep + unassignedStepMessage } from '../../shared/approval'; import { uploadAttachments, @@ -35,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 { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit'; import { loadTicketRequest } from './load'; @@ -63,7 +69,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 +94,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 +112,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 +140,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 +204,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 +226,22 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan ? REQUEST_STATUS.onApproval : REQUEST_STATUS.approved; - const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus); + // requestId уже задан → редактируем существующую заявку, пишем в ту же + // партицию (editListId). Иначе — создаём новую, и партицию для записи + // спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit): + // это единственный запрос, который проверяет порог элементов текущего + // списка и ставит в очередь создание следующей партиции при достижении — + // без него архивация никогда не сработает. Сбой запроса не должен + // блокировать подачу заявки — откатываемся на список по конвенции имён. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + + const { id, code } = await saveTicketRequest( + requestId, + data, + toApprovers(matrix.rows), + finalStatus, + writeListId + ); setRequestId(id); setRequestStatus(finalStatus); if (code) { @@ -219,9 +250,9 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan let attachments = existingAttachments; if (files.length > 0) { - await uploadAttachments(dataListTitle(FORM_ID), id, files); + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); setFiles([]); - attachments = await getAttachments(dataListTitle(FORM_ID), id); + attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id); setExistingAttachments(attachments); } @@ -230,10 +261,13 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan } if (finalStatus === REQUEST_STATUS.onApproval) { + // Имена списков — той же партиции, куда фактически записали (writeListId), + // не обязательно текущей на момент рендера формы: поток ищет строку + // маршрута именно в НЕЙ. await notifyActiveStep(context, approval, id, { requestTypeId: rtId, - dataListName: dataListTitle(FORM_ID), - approvalListName: approvalListTitle(FORM_ID) + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) }); } else if (finalStatus === REQUEST_STATUS.approved) { // Согласование не требуется для этого вида работ — сразу передаём в отдел 1С. @@ -245,7 +279,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan attachments, webAbsoluteUrl: context.pageContext.web.absoluteUrl }); - await setPlannerTaskId(id, taskId); + await setPlannerTaskId(id, taskId, writeListId); } }; @@ -262,10 +296,11 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan // Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) — // незаполненный шаг заявитель исправить сам не может, только администратор // маршрутов (роль в шаге не разрешилась в конкретного человека). - const unassigned = findUnassignedStep(matrix.rows); - if (unassigned) { - return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`; - } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` + ); + if (unassignedError) return unassignedError; return undefined; }; @@ -284,7 +319,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) && (
{r.who} + {/* Причина, по которой approval-route не назначил на этот шаг + конкретного человека (роль не разрешилась и т.п., см. + IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка, + что уходит в ошибку валидации при отправке (см. + unassignedStepMessage), но видна СРАЗУ в таблице, а не только + на попытке отправить: пустая строка «Согласующий» сама по себе + не объясняет, что делать — вручную выбрать человека здесь + нельзя, шаг заведомо требует правки маршрута в админке. */} + {!r.who && r.warning && ( + {r.warning} + )} {/* Доп. согласующий, добавленный другим согласующим (с правом переназначения) — помечаем, кто его добавил. Пусто у обычных строк маршрута. Как и комментарий — inline под именем, чтобы diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index 0b5b8c5..1fb415c 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -169,6 +169,19 @@ font-weight: 500; } +// Причина, по которой approval-route не назначил человека на шаг (см. +// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута +// (в отличие от нейтрального addedByInline/commentInline): пустая строка +// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит +// вмешательства администратора». +.warningInline { + display: block; + margin-top: 3px; + font-size: $fs-caption; + color: $red-500; + line-height: 1.45; +} + // ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она // перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую // строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap. diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index 81f70ab..a125909 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -118,6 +118,25 @@ export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined { return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0]; } +/** + * Текст ошибки под первый незанятый шаг — то, что раньше было скопировано в + * validateSubmit каждой формы (findUnassignedStep + `row.warning || <шаблон>`). + * fallback принимает саму строку и решает, что писать, если approval-route не + * прислал свою причину (warning) — у большинства форм это общий шаблон «без + * назначенного человека — выберите …», но у некоторых (см. Ticket1CForm) текст + * другой, поэтому шаблон не зашит сюда, а передаётся вызывающей стороной. + * + * undefined — назначенных не хватает не для этих rows (или все шаги заняты). + */ +export function unassignedStepMessage( + rows: IMatrixRow[], + fallback: (row: IMatrixRow) => string +): string | undefined { + const row = findUnassignedStep(rows); + if (!row) return undefined; + return row.warning || fallback(row); +} + /** * Загружает матрицу согласования и (если editable) позволяет её редактировать. * @param load загрузчик строк (планируемый маршрут из resolve или строки заявки). diff --git a/src/webparts/requestPortal/shared/approval/writeListId.ts b/src/webparts/requestPortal/shared/approval/writeListId.ts new file mode 100644 index 0000000..ef3e732 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/writeListId.ts @@ -0,0 +1,51 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { RequestTypeId } from '../../models/types'; +import { getCurrentListPartition } from './approvalRoute.service'; +import { registerCurrentPartition } from './listPartitions'; + +/** + * Партиция, в которую фактически писать при сохранении заявки. + * + * existingId задан → редактируем существующую заявку, пишем в ту же партицию + * (editListId), запись новых партиций тут ни при чём. + * + * existingId не задан → создаём новую заявку, и партицию для записи спрашиваем + * у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit) — именно этот вызов + * проверяет порог элементов текущего списка и ставит в очередь создание + * следующей партиции при достижении; без него архивация никогда не сработает, + * сколько бы заявок ни создали. + * + * Свежерезолвленная партиция сразу вписывается в локальный кеш + * (registerCurrentPartition): если её создало провижинирование ПРЯМО ЭТИМ + * запросом (порог как раз достигнут), кеш из onInit о ней ещё не знает, и без + * этого следующий же шаг сохранения (обычно резолв имени списка согласования + * через approvalListTitleForListId) упал бы с «список не найден в реестре + * партиций», хотя данные уже успешно записались и ссылка совершенно рабочая — + * ровно так это и проявилось на форме «Обходной лист». + * + * Сбой запроса к approval-route не должен блокировать подачу заявки — откат на + * editListId (для новой заявки это undefined → список по конвенции имён, + * прежнее поведение до появления партиционирования). + * + * Раньше эта логика (try/catch + getCurrentListPartition + registerCurrentPartition) + * была скопирована в save() каждой из 8 форм — при первом же баге в ней пришлось + * чинить один и тот же код в 8 файлах. Вынесена сюда, чтобы чинить один раз. + */ +export async function resolveWriteListId( + context: WebPartContext, + formId: RequestTypeId, + requestTypeId: number, + existingId: number | undefined, + editListId: string | undefined +): Promise { + if (existingId) return editListId; + + try { + const partition = await getCurrentListPartition(context, requestTypeId); + registerCurrentPartition(formId, partition); + return partition.listId; + } catch (e) { + console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); + return editListId; + } +} diff --git a/src/webparts/requestPortal/shared/attachments/attachments.service.ts b/src/webparts/requestPortal/shared/attachments/attachments.service.ts index 98ffa60..5218adc 100644 --- a/src/webparts/requestPortal/shared/attachments/attachments.service.ts +++ b/src/webparts/requestPortal/shared/attachments/attachments.service.ts @@ -1,4 +1,7 @@ import { getSP } from '../pnp/pnpjs'; +import '@pnp/sp/lists'; +import { IList } from '@pnp/sp/lists'; +import '@pnp/sp/items'; import '@pnp/sp/attachments'; export interface IAttachmentInfo { @@ -7,6 +10,18 @@ export interface IAttachmentInfo { url: string; } +/** + * listRef — либо Title списка данных (текущая партиция), либо его серверный + * относительный url (конкретная, в т.ч. архивная партиция — см. shared/ + * listTitles.ts). Различаем по ведущему '/', тем же приёмом, что и + * ApproverService.dataListRef — иначе вложения заявки, лежащей в архивной + * партиции, читались/писались бы в список ТЕКУЩЕЙ (там либо нет элемента с + * таким Id, либо, хуже, есть чужой элемент с тем же Id). + */ +function resolveList(listRef: string): IList { + return listRef.startsWith('/') ? getSP().web.getList(listRef) : getSP().web.lists.getByTitle(listRef); +} + /** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */ function readAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { @@ -18,11 +33,8 @@ function readAsArrayBuffer(file: File): Promise { } /** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */ -export async function getAttachments(listTitle: string, itemId: number): Promise { - const files = await getSP() - .web.lists.getByTitle(listTitle) - .items.getById(itemId) - .attachmentFiles(); +export async function getAttachments(listRef: string, itemId: number): Promise { + const files = await resolveList(listRef).items.getById(itemId).attachmentFiles(); return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl })); } @@ -31,14 +43,14 @@ export async function getAttachments(listTitle: string, itemId: number): Promise * Вызывать ПОСЛЕ создания элемента (нужен его id). */ export async function uploadAttachments( - listTitle: string, + listRef: string, itemId: number, files: File[] ): Promise { if (files.length === 0) { return; } - const item = getSP().web.lists.getByTitle(listTitle).items.getById(itemId); + const item = resolveList(listRef).items.getById(itemId); for (let i = 0; i < files.length; i++) { const file = files[i]; const content = await readAsArrayBuffer(file); @@ -48,13 +60,9 @@ export async function uploadAttachments( /** Удалить вложение элемента по имени файла (для правки заявки). */ export async function deleteAttachment( - listTitle: string, + listRef: string, itemId: number, fileName: string ): Promise { - await getSP() - .web.lists.getByTitle(listTitle) - .items.getById(itemId) - .attachmentFiles.getByName(fileName) - .delete(); + await resolveList(listRef).items.getById(itemId).attachmentFiles.getByName(fileName).delete(); } diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 98ab180..e3dc1bd 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -16,7 +16,27 @@ 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) — тот же приём, что и у debugUserQuery/ +// setImpersonatedIdentity (shared/impersonation.ts): модульный синглтон, а не +// проброс параметром через все вызовы 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 diff --git a/src/webparts/requestPortal/shared/dataList.ts b/src/webparts/requestPortal/shared/dataList.ts new file mode 100644 index 0000000..97620e5 --- /dev/null +++ b/src/webparts/requestPortal/shared/dataList.ts @@ -0,0 +1,62 @@ +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import '@pnp/sp/items'; +import { IList } from '@pnp/sp/lists'; +import { getSP } from './pnp/pnpjs'; +import { buildRequestUniqueCode } from './requestCode'; +import { dataListTitle } from './listTitles'; +import { RequestTypeId } from '../models/types'; + +/** + * Список данных, в который фактически пишем: listId (конкретная партиция — + * либо ОБНОВЛЯЕМОЙ заявки, либо только что резолвленная текущая, см. + * getCurrentListPartition) или, если он не задан, список по конвенции имён + * (см. dataListTitle) — тот же откат, что действовал до партиционирования. + * + * Раньше эта строка (`listId ? getSP().web.getList(listId) : ...`) была + * скопирована в save/update-функции каждой формы и в submit.ts отдельных + * форм (Ticket1C — Planner, Clearance — updateApproverFields) — 8+ мест с + * одинаковым кодом. Расхождение здесь означало бы, что часть операций тихо + * пишет не в ту партицию. + */ +export function resolveDataList(formId: RequestTypeId, listId?: string): IList { + return listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(formId)); +} + +export interface ISaveItemResult { + id: number; + /** Задан только при создании новой заявки (существующий код не меняется). */ + code?: string; +} + +/** + * Общее ядро сохранения элемента списка данных: резолв списка, создание/обновление, + * генерация уникального кода заявки. Вынесено из saveXRequest каждой формы — сам + * набор полей (buildItem) и работа с маршрутом (writeRoute) остаются у формы, + * форма отличается только ими. + * + * existingId задан → обновляем ту же запись, иначе создаём новую. + * codePrefix — обычно числовой RequestTypeId, но может быть строкой-меткой + * (см. buildRequestUniqueCode, TICKET1C_CODE_PREFIX). + */ +export async function saveRequestItem( + formId: RequestTypeId, + existingId: number | undefined, + listId: string | undefined, + codePrefix: number | string, + item: Record +): Promise { + const list = resolveDataList(formId, listId); + + if (existingId) { + await list.items.getById(existingId).update(item); + return { id: existingId }; + } + + const added = await list.items.add(item); + const id = (added as { Id: number }).Id; + // Код известен только после создания элемента (нужен его Id). + const code = buildRequestUniqueCode(codePrefix, id, new Date()); + await list.items.getById(id).update({ RequestUniqueCode: code }); + return { id, code }; +} diff --git a/src/webparts/requestPortal/shared/employee.ts b/src/webparts/requestPortal/shared/employee.ts new file mode 100644 index 0000000..10826a8 --- /dev/null +++ b/src/webparts/requestPortal/shared/employee.ts @@ -0,0 +1,13 @@ +import '@pnp/sp/webs'; +import '@pnp/sp/site-users/web'; +import { getSP } from './pnp/pnpjs'; + +/** + * email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). + * Было скопировано в submit.ts нескольких форм с сотрудником-инициатором + * (Vacation, Clearance) — вынесено сюда, чтобы не разойтись при правке. + */ +export async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> { + const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`; + return getSP().web.ensureUser(full); +} diff --git a/src/webparts/requestPortal/shared/listTitles.ts b/src/webparts/requestPortal/shared/listTitles.ts index 74640b8..fa0f882 100644 --- a/src/webparts/requestPortal/shared/listTitles.ts +++ b/src/webparts/requestPortal/shared/listTitles.ts @@ -1,4 +1,9 @@ import { RequestTypeId } from '../models/types'; +import { + currentPartitionForPageUrl, + isConfirmedStaleListId, + partitionForPageUrlAndListId +} from './approval/listPartitions'; /** * ЕДИНЫЙ источник имён списков SharePoint. @@ -32,15 +37,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 +59,85 @@ 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} — согласование`; +} + +/** + * Имя списка данных КОНКРЕТНОЙ партиции по её listId — для открытия/сохранения + * заявки, которая не обязательно лежит в текущей партиции (архив). + * + * Бросает ТОЛЬКО когда реестр ТОЧНО знает, что такого listId в серии нет + * (isConfirmedStaleListId) — тогда мы рискуем прочитать/записать не тот список, + * и правильный ответ — явная ошибка, а не молчаливый откат. + * + * Если же реестр про серию просто ничего не знает (approval-route не ответил + * при старте) — это НЕ повод бросать: откатываемся на ту же конвенцию по номеру, + * что и dataListTitle() для текущей партиции без реестра. Сам listId при этом не + * используется как источник данных (данные читаются им напрямую, см. load.ts + * каждой формы) — здесь он нужен только чтобы вывести ИМЯ списка согласования + * по конвенции, а на неё влияет лишь номер партиции, не сам url. + */ +export function dataListTitleForListId(id: RequestTypeId, listId: string): string { + const partition = partitionForPageUrlAndListId(id, listId); + if (partition?.title) return partition.title; + + if (isConfirmedStaleListId(id, listId)) { + throw new Error( + `Список «${listId}» не найден в реестре партиций типа заявки «${id}» — возможно, ссылка устарела.` + ); + } + + // Партиция нашлась, но Title не закеширован (см. dataListTitle), либо реестр + // про серию вообще ничего не знает — оба случая закрываются одним фолбэком. + return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — данные`; +} + +/** Имя парного списка согласования конкретной партиции — см. dataListTitleForListId. */ +export function approvalListTitleForListId(id: RequestTypeId, listId: string): string { + const dataTitle = dataListTitleForListId(id, listId); + if (DATA_LIST_SUFFIX.test(dataTitle)) { + return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; + } + + const partition = partitionForPageUrlAndListId(id, listId); + console.warn( + `[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции` + ); + return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`; } diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index d073317..216b98c 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -52,6 +52,15 @@ export interface IRoute { formKey?: string; /** Id заявки в списке данных — для 'approve' и 'edit'. */ itemId?: number; + /** + * Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для + * 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/ + * listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто + * лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в + * тот список. Не задан — значит партиция неизвестна (старая ссылка без + * параметра), и форма/просмотр откатываются на текущую партицию. + */ + listId?: string; /** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */ sourceId?: number; /** Подвкладка «Моих заявок»; задана только при nav='own'. */ @@ -263,6 +272,7 @@ export function parseRoute(search: string = window.location.search): IRoute { const formKey = params.get('rptype') ?? undefined; const idRaw = params.get('rpid'); const itemId = idRaw ? Number(idRaw) : undefined; + const listId = params.get('rplist') ?? undefined; const sourceRaw = params.get('rpsource'); const sourceId = sourceRaw ? Number(sourceRaw) : undefined; @@ -288,11 +298,11 @@ export function parseRoute(search: string = window.location.search): IRoute { // страницей: закрыв панель, пользователь должен вернуться ровно в тот же // список и на то же место в нём. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query }; + return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query }; + return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { @@ -308,6 +318,7 @@ function buildQuery(route: IRoute): string { params.delete('rpnav'); params.delete('rptype'); params.delete('rpid'); + params.delete('rplist'); params.delete('rpsource'); params.delete('rptab'); params.delete('rpstatus'); @@ -331,6 +342,9 @@ function buildQuery(route: IRoute): string { params.set('rpmode', route.mode); params.set('rptype', route.formKey); params.set('rpid', String(route.itemId)); + // Партиция неизвестна (архивная ссылка старого формата, до этого поля) — + // ссылка всё равно валидна, форма/просмотр просто откатятся на текущую. + if (route.listId) params.set('rplist', route.listId); } else if (route.mode === 'create' && route.formKey && route.sourceId) { params.set('rptype', route.formKey); params.set('rpsource', String(route.sourceId));