Архивация списков #9

Merged
gorbenko.pavel merged 17 commits from archive into main 2026-08-19 09:07:53 +00:00
16 changed files with 268 additions and 148 deletions
Showing only changes of commit 960875dc38 - Show all commits
@@ -392,16 +392,20 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
return result;
};
const load = async (): Promise<void> => {
const load = async (forceRender = false): Promise<void> => {
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<IRequestsTableViewProps> = ({
const unsubscribe = onRequestsChanged((force) => {
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
// строки прячем сразу; свежие подставит load().
// строки прячем сразу; свежие подставит load(force).
if (force) {
setRows(undefined);
setLoadedKey('');
}
load().catch(() => undefined);
load(force).catch(() => undefined);
});
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
// сработает ближайший тик.
@@ -182,13 +182,16 @@ export const SearchView: React.FC<ISearchViewProps> = ({
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
const load = async (): Promise<void> => {
const load = async (forceRender = false): Promise<void> => {
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<ISearchViewProps> = ({
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);
@@ -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<INavCounts>
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<Record<string, unknown>>;
// Статусы уходят и в 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<Record<string, unknown>>, Array<Record<string, unknown>>];
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<Record<string, unknown>>;
const ticketRows = (await traced(`ticketRowsRest:${type.name}#${target.listId}`, () =>
list.items
.filter(ticketFilter)
.select('Id', 'Title', 'RequestStatus', 'Created')
.top(ticketIds.length)()
)) as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byKey.set(
@@ -981,14 +1016,15 @@ export async function getOwnRequests(
): Promise<IOwnRequestsPage> {
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<string, IMyRequest>();
const byKey =
entries.length > 0
? await traced('loadByIds:own(searchOwnRequests)', () => loadByIds(context, entries, 'own'))
: new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов 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<string, IMyRequest>();
const byKey =
entries.length > 0
? await traced('loadByIds:own(getOwnRequestsByStatus)', () => loadByIds(context, entries, 'own'))
: new Map<string, IMyRequest>();
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);
@@ -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<T>(label: string, fn: () => Promise<T>): Promise<T> {
if (!enabled()) return fn();
const start = performance.now();
try {
return await fn();
} finally {
console.log(`${PREFIX} ${label}: ${(performance.now() - start).toFixed(0)} мс`);
}
}
@@ -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 = '[Поиск]';
@@ -16,7 +16,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -197,14 +197,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
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,
@@ -20,7 +20,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -194,14 +194,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
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,
@@ -20,7 +20,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -168,14 +168,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
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,
@@ -20,7 +20,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -233,14 +233,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
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,
@@ -27,7 +27,7 @@ import {
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep,
getCurrentListPartition
resolveWriteListId
} from '../../shared/approval';
import {
uploadAttachments,
@@ -206,14 +206,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// Новая заявка — резолвим ТЕКУЩУЮ партицию через 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,
@@ -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<IRequestFormProps> = ({
// Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у 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,
@@ -16,7 +16,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -175,20 +175,9 @@ const VacationForm: React.FC<IRequestFormProps> = ({
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,
@@ -20,7 +20,7 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
getCurrentListPartition,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
@@ -218,14 +218,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
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,
@@ -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';
@@ -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 (пришёл из строки поиска, см.
@@ -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<string | undefined> {
if (existingId) return editListId;
try {
const partition = await getCurrentListPartition(context, requestTypeId);
registerCurrentPartition(formId, partition);
return partition.listId;
} catch (e) {
console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e);
return editListId;
}
}