Архивация списков #9
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user