From 960875dc38922ec65e3495ce99abf22fc6b22d04 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 17:11:05 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B5=D0=B1=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=82=D1=80=D0=B0=D1=81=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=D0=BD=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 14 +- .../features/myRequests/SearchView.tsx | 9 +- .../features/myRequests/myRequests.service.ts | 171 ++++++++++++------ .../features/myRequests/perfTrace.ts | 26 +++ .../features/myRequests/searchTrace.ts | 6 +- .../forms/Clearance/ClearanceForm.tsx | 11 +- .../RecruitingHire/RecruitingHireForm.tsx | 11 +- .../RecruitingSelectionForm.tsx | 11 +- .../forms/Ticket1C/Ticket1CForm.tsx | 11 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 11 +- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 11 +- .../forms/Vacation/VacationForm.tsx | 19 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 11 +- .../requestPortal/shared/approval/index.ts | 2 + .../shared/approval/listPartitions.ts | 41 ++++- .../shared/approval/writeListId.ts | 51 ++++++ 16 files changed, 268 insertions(+), 148 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/perfTrace.ts create mode 100644 src/webparts/requestPortal/shared/approval/writeListId.ts diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 9ec124b..abf5aaf 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -392,16 +392,20 @@ export const RequestsTableView: React.FC = ({ return result; }; - const load = async (): Promise => { + const load = async (forceRender = false): Promise => { try { const fresh = await 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 +423,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.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 75bdca8..4b9cabf 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -182,13 +182,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); 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 +205,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/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 81dc6c9..f3de05a 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -12,6 +12,7 @@ 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). @@ -161,7 +162,9 @@ async function buildPendingIndex( const tokenEmail = currentUserEmail(context); const perType = await Promise.all( - types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) + types.map((t) => + traced(`indexApprovalsForType:${t.name}`, () => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) + ) ); // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. @@ -232,8 +235,8 @@ export async function getNavCounts(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 { @@ -289,12 +292,28 @@ async function indexApprovalsForType( // партиций не загрузился — тогда ведём себя как раньше, по одному списку. const targets = listTargetsForType(type); + // Партиции независимы друг от друга (разные списки, разные HTTP-запросы) — + // читаем ВСЕ параллельно, а не по очереди. Раньше это был последовательный + // for-await, и у типа с несколькими партициями (архив накопился) время росло + // линейно с их числом: 3 партиции — втрое дольше одной, хотя каждая опрашивает + // СВОЙ список и ничего не ждёт от соседних (см. трассировку 2026-08-18: + // «Обходной лист» с 3 партициями — 7.5с, тогда как каждая партиция сама по + // себе — 2-3с). + const parts = await Promise.all( + targets.map((target) => + traced(`indexApprovalsForPartition:${type.name}#${target.listId}`, () => + indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses) + ) + ) + ); + const rows: IMyRequest[] = []; const approvalTitles: string[] = []; - for (const target of targets) { - const part = await indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses); - + // Порядок Promise.all совпадает с порядком targets — «первый встреченный» + // результат (approvalSkippedReason/approvalError) не изменился относительно + // прежнего последовательного перебора. + for (const part of parts) { rows.push(...part.rows); if (part.approvalListTitle) approvalTitles.push(part.approvalListTitle); if (part.approvalSkippedReason && !debug.approvalSkippedReason) { @@ -333,7 +352,7 @@ async function indexApprovalsForPartition( const sp = spForSite(target.siteUrl, context); const list = sp.web.getList(target.listId); - const dataListInfo = await list.select('Title')(); + const dataListInfo = await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')()); const dataTitle = (dataListInfo.Title as string) || ''; if (!DATA_LIST_SUFFIX.test(dataTitle)) { debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; @@ -371,14 +390,22 @@ async function indexApprovalsForPartition( // 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); + // recentRows (REST) и searchRows (Search) — независимые источники + // кандидатов, объединяются только ниже (seenIds); одно не ждёт другое, + // поэтому запускаем их параллельно, а не по очереди. + const [recentRows, searchRows] = (await Promise.all([ + traced(`recentRowsRest:${type.name}#${target.listId}`, () => + approvalList.items + .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(2000)() + ), + // Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки, + // а не всё, где пользователь есть в маршруте. + traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () => + getInvolvedRowsViaSearch(context, type, approvalList, statuses) + ), + ])) as [Array>, Array>]; const seenIds = new Set(recentRows.map((r) => r.Id)); stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); } else { @@ -389,7 +416,9 @@ async function indexApprovalsForPartition( // давал неустойчивый результат (заявка то находилась, то нет, в // зависимости от того, успел ли Search её проиндексировать к моменту // REST-окна). - stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); + stepRows = await traced(`getInvolvedRowsViaSearch:${type.name}#${target.listId}`, () => + getInvolvedRowsViaSearch(context, type, approvalList, statuses) + ); } debug.approvalRowCount = stepRows.length; @@ -416,10 +445,12 @@ async function indexApprovalsForPartition( // Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см. // комментарий у функции: через 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', 'Title', 'RequestStatus', 'Created') + .top(ticketIds.length)() + )) as Array>; debug.rows = ticketRows.map((r) => ({ ...toMyRequest(r, undefined, type, 'approval', target), @@ -620,14 +651,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); @@ -858,11 +891,13 @@ async function loadByIds( 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>; + const rows = (await traced(`loadByIds:${kind}:${type.name}#${target.listId}`, () => + 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( @@ -981,14 +1016,15 @@ 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) => rowKey(r))); const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); @@ -1023,20 +1059,24 @@ export async function searchOwnRequests( 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(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); @@ -1070,19 +1110,24 @@ 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 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 }; @@ -1090,7 +1135,13 @@ export async function getOwnRequestsByStatus( // Довесок по недавним изменениям — только на первой странице и только без // текстового поиска (тот же принцип, что в 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); diff --git a/src/webparts/requestPortal/features/myRequests/perfTrace.ts b/src/webparts/requestPortal/features/myRequests/perfTrace.ts new file mode 100644 index 0000000..c3fd89c --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/perfTrace.ts @@ -0,0 +1,26 @@ +/** + * Замер времени отдельных этапов загрузки «Моих заявок» — что именно тормозит + * (индекс согласований, свои заявки по типу, конкретная партиция, Search), + * а не просто «страница долго грузится». + * + * Включение/выключение БЕЗ пересборки — из консоли браузера: + * window.rpPerfTrace = true // писать тайминги + * window.rpPerfTrace = false // выключить (по умолчанию) + */ +function enabled(): boolean { + return !!(window as unknown as { rpPerfTrace?: boolean }).rpPerfTrace; +} + +const PREFIX = '[Перф]'; + +/** Оборачивает async-шаг замером времени. Без включённой трассировки — просто вызывает fn(). */ +export async function traced(label: string, fn: () => Promise): Promise { + if (!enabled()) return fn(); + + 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 24b775e..80c2a6d 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -16,7 +16,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -197,14 +197,7 @@ const ClearanceForm: React.FC = ({ // списка и ставит в очередь создание следующей партиции при достижении — // без него архивация никогда не сработает. Сбой запроса не должен // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveClearanceRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 7706afd..c0e6695 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -20,7 +20,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -194,14 +194,7 @@ const RecruitingHireForm: React.FC = ({ // списка и ставит в очередь создание следующей партиции при достижении — // без него архивация никогда не сработает. Сбой запроса не должен // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveHireRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 47284df..0ef7eb0 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -20,7 +20,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -168,14 +168,7 @@ const RecruitingSelectionForm: React.FC = ({ // списка и ставит в очередь создание следующей партиции при достижении — // без него архивация никогда не сработает. Сбой запроса не должен // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveSelectionRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index 6714c90..6445df8 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -20,7 +20,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -233,14 +233,7 @@ const Ticket1CForm: React.FC = ({ // списка и ставит в очередь создание следующей партиции при достижении — // без него архивация никогда не сработает. Сбой запроса не должен // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveTicketRequest( requestId, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 3ab89a0..6eb0d39 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -27,7 +27,7 @@ import { IApproverToWrite, APPROVAL_PHASE, findUnassignedStep, - getCurrentListPartition + resolveWriteListId } from '../../shared/approval'; import { uploadAttachments, @@ -206,14 +206,7 @@ const TmcTransferForm: React.FC = ({ // Новая заявка — резолвим ТЕКУЩУЮ партицию через approval-route (заодно // выполняется проверка порога и создание следующей архивной партиции при // необходимости); при редактировании остаёмся в партиции самой заявки. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveTmcTransferRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index d277663..783255d 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -22,7 +22,7 @@ import { isEditableStatus, IApproverToWrite, findUnassignedStep, - getCurrentListPartition + resolveWriteListId } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; import { @@ -180,14 +180,7 @@ const UnpaidLeaveForm: React.FC = ({ // Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route // API непосредственно перед записью (не кеш из onInit): именно этот вызов // проверяет порог элементов и запускает архивацию следующей партиции. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveUnpaidLeaveRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index e93626d..c680c32 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -16,7 +16,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -175,20 +175,9 @@ const VacationForm: React.FC = ({ throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } // requestId уже задан → редактируем существующую заявку, пишем в ту же - // партицию (editListId). Иначе — создаём новую, и партицию для записи - // спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit): - // это единственный запрос, который проверяет порог элементов текущего - // списка и ставит в очередь создание следующей партиции при достижении — - // без него архивация никогда не сработает. Сбой запроса не должен - // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + // партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId — + // именно этот запрос проверяет порог элементов и запускает архивацию). + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveVacationRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 7e6e4e6..f4b77c6 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -20,7 +20,7 @@ import { StatusBadge, useMatrixEditor, getPlannedMatrix, - getCurrentListPartition, + resolveWriteListId, notifyActiveStep, useRequestTypeId, REQUEST_STATUS, @@ -218,14 +218,7 @@ const WorkplaceSetupForm: React.FC = ({ // списка и ставит в очередь создание следующей партиции при достижении — // без него архивация никогда не сработает. Сбой запроса не должен // блокировать подачу заявки — откатываемся на список по конвенции имён. - let writeListId = editListId; - if (!requestId) { - try { - writeListId = (await getCurrentListPartition(context, rtId)).listId; - } catch (e) { - console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e); - } - } + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); const { id, code } = await saveWorkplaceRequest( requestId, rtId, diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index fbaf754..fcaf057 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -37,7 +37,9 @@ export { currentPartitionForPageUrl, partitionForPageUrlAndListId, isConfirmedStaleListId, + registerCurrentPartition, } from './listPartitions'; +export { resolveWriteListId } from './writeListId'; export { useRequestTypeId } from './useRequestTypeId'; export type { IRequestTypeIdState } from './useRequestTypeId'; export { ApprovalListService } from './approvalList.service'; diff --git a/src/webparts/requestPortal/shared/approval/listPartitions.ts b/src/webparts/requestPortal/shared/approval/listPartitions.ts index be98070..f2107f5 100644 --- a/src/webparts/requestPortal/shared/approval/listPartitions.ts +++ b/src/webparts/requestPortal/shared/approval/listPartitions.ts @@ -1,6 +1,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getListPartitions, getRequestTypes } from './approvalRoute.service'; -import { IListPartition } from './models/IApprovalRouteApi'; +import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi'; /** * Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где @@ -90,6 +90,45 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un return id === undefined ? undefined : currentPartitionForTypeId(id); } +/** + * Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую + * (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того + * как эта партиция понадобится дальше в том же сохранении (approvalListTitleForListId + * и т.п.), а не только при следующей перезагрузке страницы. + * + * Без этого партиция, созданная провижинингом ПОСЛЕ onInit (порог элементов + * достигнут прямо во время этого запроса), была бы неизвестна isConfirmedStaleListId, + * и попытка сохранить в неё же заявку — записав данные в новый список — падала бы + * следующим шагом с «список не найден в реестре партиций», хотя ссылка совершенно + * свежая и рабочая (см. форму пользователя: данные создались, ошибка была только + * в определении имени списка согласования). + * + * Тип, о котором реестр вообще ничего не знает (initListPartitions не ответил при + * старте — typeIdByPageUrl пуст для этого pageUrl), не трогаем: мержить партицию в + * несуществующую серию нечем, а откат на конвенцию имён и так работает без реестра. + */ +export function registerCurrentPartition(pageUrl: string, partition: ICurrentListPartition): void { + const id = typeIdByPageUrl.get(pageUrl); + if (id === undefined) return; + + const lower = partition.listId.toLowerCase(); + const merged: IListPartition[] = partitionsForTypeId(id) + .filter((p) => p.listId.toLowerCase() !== lower) + .map((p) => ({ ...p, isCurrent: false })); + + merged.push({ + number: partition.number, + siteUrl: partition.siteUrl, + listId: partition.listId, + title: partition.title, + isCurrent: true, + createdAt: new Date().toISOString() + }); + merged.sort((a, b) => a.number - b.number); + + partitionsByTypeId.set(id, merged); +} + /** * Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ * заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см. 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; + } +}