From 2421f7ddd50ce170a70bc3d3af6772bfab8587a9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:17:21 +0400 Subject: [PATCH 01/12] =?UTF-8?q?test(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D1=8B=20=D0=B2=D0=B8=D1=80=D1=82=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pages/Svod2Page/svod2Virtualizer.test.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 web/src/pages/Svod2Page/svod2Virtualizer.test.js diff --git a/web/src/pages/Svod2Page/svod2Virtualizer.test.js b/web/src/pages/Svod2Page/svod2Virtualizer.test.js new file mode 100644 index 0000000..83c9691 --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Virtualizer.test.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Virtualizer } from '@tanstack/virtual-core'; + +test('paddingStart/paddingEnd ставят глобальную строку i на i*rowH, общий размер = total*rowH', () => { + const rowH = 37; + const total = 5563; + const windowStart = 300; + const windowEnd = 1200; + const v = new Virtualizer({ + count: windowEnd - windowStart, + getScrollElement: () => null, + estimateSize: () => rowH, + paddingStart: windowStart * rowH, + paddingEnd: (total - windowEnd) * rowH, + scrollToFn: () => undefined, + observeElementRect: () => undefined, + observeElementOffset: () => undefined, + }); + const m = v.getMeasurements(); + assert.equal(m[0].start, windowStart * rowH); + assert.equal(m.at(-1).end, windowEnd * rowH); + assert.equal(v.getTotalSize(), total * rowH); +}); -- 2.47.2 From a9a05c2360277cf50e3ba7a39d2d19bb23c6e48f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:19:00 +0400 Subject: [PATCH 02/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D1=87=D0=B8=D1=81=D1=82=D0=B0=D1=8F=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86,=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 112 ++++++++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 154 ++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 web/src/pages/Svod2Page/svod2Paging.js create mode 100644 web/src/pages/Svod2Page/svod2Paging.test.js diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js new file mode 100644 index 0000000..e0289ec --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -0,0 +1,112 @@ +// Чистая логика серверной постраничной загрузки свода 2: окно страниц, срез с заглушками, +// LRU-кэш страниц, параметры запроса, варианты фильтров. Без React, JSX и axios. + +export const PAGE_SIZE = 300; +export const WINDOW_RADIUS = 1; +export const PAGE_CACHE_LIMIT = 5; +export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']; +export const EMPTY_FACET_MAP = Object.freeze(new Map()); + +export function getPageCount(total) { + return total > 0 ? Math.ceil(total / PAGE_SIZE) : 0; +} + +export function getVisiblePage({ scrollTop, rowHeight, total }) { + const lastPage = Math.max(getPageCount(total) - 1, 0); + const firstRow = Math.floor(scrollTop / rowHeight); + const page = Math.floor(firstRow / PAGE_SIZE); + if (!Number.isFinite(page)) return 0; + return Math.min(Math.max(page, 0), lastPage); +} + +export function getWindowPages(visiblePage, total) { + const count = getPageCount(total); + const pages = []; + for (let p = visiblePage - WINDOW_RADIUS; p <= visiblePage + WINDOW_RADIUS; p++) { + if (p >= 0 && p < count) pages.push(p); + } + return pages; +} + +export function getWindowRange(pages, total) { + if (pages.length === 0) return { windowStart: 0, windowEnd: 0 }; + const first = pages[0]; + const last = pages[pages.length - 1]; + return { windowStart: first * PAGE_SIZE, windowEnd: Math.min(total, (last + 1) * PAGE_SIZE) }; +} + +export function buildWindowRows(pages, pageCache, total) { + const result = []; + for (const p of pages) { + const start = p * PAGE_SIZE; + const expected = Math.min(total, (p + 1) * PAGE_SIZE) - start; + const cached = pageCache.get(p) ?? []; + for (let i = 0; i < expected; i++) { + result.push(i < cached.length ? cached[i] : { __placeholder: true, index: start + i }); + } + } + return result; +} + +export function getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }) { + return { + paddingStart: windowStart * rowHeight, + paddingEnd: Math.max(total - windowEnd, 0) * rowHeight, + }; +} + +export function touchPage(cache, pageIndex, rows, keepPages = []) { + const next = new Map(cache); + next.delete(pageIndex); + next.set(pageIndex, rows); + const keep = new Set(keepPages); + keep.add(pageIndex); + for (const key of [...next.keys()]) { + if (next.size <= PAGE_CACHE_LIMIT) break; + if (!keep.has(key)) next.delete(key); + } + return next; +} + +export function getMissingPages(pages, cache, inflight) { + return pages.filter((p) => !cache.has(p) && !inflight.has(p)); +} + +export function buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] } = {}) { + const params = {}; + if (orgIds.length > 0) params.org_ids = orgIds.join(','); + if (search !== '') params.search = search; + for (const { id, value } of columnFilters) { + if (FILTER_COLUMNS.includes(id) && value !== '' && value !== null && value !== undefined) { + params[id] = String(value); + } + } + return params; +} + +export function buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE }) { + return { view: 'compact', offset, limit, ...buildSvod2QueryParams({ orgIds, search, columnFilters }) }; +} + +export function facetsToMaps(result) { + const maps = new Map(); + for (const column of FILTER_COLUMNS) { + const map = new Map(); + for (const { value, count } of result?.[column] ?? []) map.set(value, count); + maps.set(column, map); + } + return maps; +} + +export function shouldShowFound({ hasCurrentData, loading, facets }) { + return Boolean(hasCurrentData) && !loading && !!facets && facets.search_total !== facets.total; +} + +export function isPlaceholderRow(row) { + return Boolean(row?.__placeholder); +} + +export function getSvod2RowId(row) { + if (isPlaceholderRow(row)) return `ph-${row.index}`; + return `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`; +} diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js new file mode 100644 index 0000000..98e2fdb --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + EMPTY_FACET_MAP, + PAGE_CACHE_LIMIT, + buildSvod2PageParams, + buildSvod2QueryParams, + buildWindowRows, + facetsToMaps, + getMissingPages, + getPageCount, + getSvod2RowId, + getVirtualizerPadding, + getVisiblePage, + getWindowPages, + getWindowRange, + isPlaceholderRow, + shouldShowFound, + touchPage, +} from './svod2Paging.js'; + +test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1])); +test('окно в середине и в конце', () => { + assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]); + assert.deepEqual(getWindowPages(18, 5563), [17, 18]); +}); +test('total = 0 — пустое окно и пустой срез', () => { + assert.deepEqual(getWindowPages(0, 0), []); + assert.deepEqual(buildWindowRows([], new Map(), 0), []); + assert.equal(getPageCount(0), 0); + assert.deepEqual(getWindowRange([], 0), { windowStart: 0, windowEnd: 0 }); +}); +test('видимая страница прижимается к последней при уменьшении total', () => + assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2)); +test('видимая страница: обычный скролл и total = 0', () => { + assert.equal(getVisiblePage({ scrollTop: 0, rowHeight: 37, total: 5563 }), 0); + assert.equal(getVisiblePage({ scrollTop: 300 * 37, rowHeight: 37, total: 5563 }), 1); + assert.equal(getVisiblePage({ scrollTop: 300 * 37 - 1, rowHeight: 37, total: 5563 }), 0); + assert.equal(getVisiblePage({ scrollTop: 5000, rowHeight: 37, total: 0 }), 0); +}); +test('срез: неполная последняя страница и заглушки', () => { + const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563); + assert.equal(rows.length, 300 + 163); + assert.deepEqual(rows[0], { __placeholder: true, index: 5100 }); + assert.equal(rows[300].id, 0); +}); +test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => { + // total изменился между запросами: страница 0 пришла с 250 строками, страница 1 — с 400 + const short = Array.from({ length: 250 }, (_, i) => ({ id: i })); + const long = Array.from({ length: 400 }, (_, i) => ({ id: 1000 + i })); + const rows = buildWindowRows( + [0, 1], + new Map([ + [0, short], + [1, long], + ]), + 600, + ); + assert.equal(rows.length, 600); + assert.equal(rows[249].id, 249); + assert.deepEqual(rows[250], { __placeholder: true, index: 250 }); + assert.deepEqual(rows[299], { __placeholder: true, index: 299 }); + assert.equal(rows[300].id, 1000); + assert.equal(rows[599].id, 1299); +}); +test('сумма отступов и строк окна = total * rowH', () => { + const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563); + assert.equal(windowStart, 1200); + assert.equal(windowEnd, 2100); + const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }); + assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37); +}); +test('окно с последней неполной страницей: windowEnd = total, нижний отступ 0', () => { + const { windowStart, windowEnd } = getWindowRange([17, 18], 5563); + assert.equal(windowEnd, 5563); + assert.deepEqual(getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }).paddingEnd, 0); +}); +test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => { + let cache = new Map(); + for (let p = 0; p < 7; p++) cache = touchPage(cache, p, [{ id: p }], [0, 1]); + assert.equal(PAGE_CACHE_LIMIT, 5); + assert.equal(cache.size, 5); + assert.ok(cache.has(0)); + assert.ok(cache.has(1)); + assert.deepEqual( + [...cache.keys()].sort((a, b) => a - b), + [0, 1, 4, 5, 6], + ); +}); +test('LRU: повторное обращение делает страницу самой свежей, исходный Map не мутируется', () => { + const base = new Map([ + [1, []], + [2, []], + ]); + const next = touchPage(base, 1, [{ id: 'x' }]); + assert.notEqual(next, base); + assert.deepEqual([...base.keys()], [1, 2]); + assert.deepEqual([...next.keys()], [2, 1]); + assert.deepEqual(next.get(1), [{ id: 'x' }]); +}); +test('getMissingPages исключает загруженные и летящие', () => + assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4])); +test('параметры страницы: compact, пустые не передаются, search как введён', () => { + assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), { + view: 'compact', + offset: 300, + limit: 300, + }); + assert.deepEqual( + buildSvod2QueryParams({ + orgIds: [3, 5], + search: ' аренда ', + columnFilters: [ + { id: 'org_name', value: 'Филиал Ёлки' }, + { id: 'name', value: 'x' }, + ], + }), + { org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' }, + ); + assert.deepEqual( + buildSvod2QueryParams({ + columnFilters: [ + { id: 'smeta_type', value: '' }, + { id: 'smeta_direction', value: null }, + ], + }), + {}, + ); + assert.equal(buildSvod2PageParams({ offset: 0, limit: 50, search: 'a' }).limit, 50); +}); +test('facetsToMaps', () => { + const result = { smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] }; + const maps = facetsToMaps(result); + assert.equal(maps.get('smeta_type').get('КВ'), 3); + assert.equal(maps.get('org_name').size, 0); + assert.notEqual(facetsToMaps(result), maps); + assert.notEqual(facetsToMaps(result).get('smeta_type'), maps.get('smeta_type')); + assert.equal(facetsToMaps(null).get('org_name').size, 0); + assert.ok(Object.isFrozen(EMPTY_FACET_MAP)); +}); +test('«Найдено» — только поиск', () => { + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: false, loading: false, facets: { total: 5563, search_total: 300 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: null }), false); +}); +test('getSvod2RowId', () => { + assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7'); + assert.equal(getSvod2RowId({ data: { header: { source_form: 'f1', source_id: 2 }, line_id: 3 } }), 'f1-2-3'); + assert.equal(isPlaceholderRow({ __placeholder: true, index: 1 }), true); + assert.equal(isPlaceholderRow({ data: {} }), false); + assert.equal(isPlaceholderRow(undefined), false); +}); -- 2.47.2 From 900a3c65357eb7ff5761274a5b8cb96024974162 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:19:09 +0400 Subject: [PATCH 03/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20biome=20(pages.at(-1))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index e0289ec..f2c29d0 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -31,7 +31,7 @@ export function getWindowPages(visiblePage, total) { export function getWindowRange(pages, total) { if (pages.length === 0) return { windowStart: 0, windowEnd: 0 }; const first = pages[0]; - const last = pages[pages.length - 1]; + const last = pages.at(-1); return { windowStart: first * PAGE_SIZE, windowEnd: Math.min(total, (last + 1) * PAGE_SIZE) }; } -- 2.47.2 From 78e06583f43864b54078ffaee1fa2e928be83129 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:21:04 +0400 Subject: [PATCH 04/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=BE=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 48 +++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 104 ++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index f2c29d0..da917f3 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -110,3 +110,51 @@ export function getSvod2RowId(row) { if (isPlaceholderRow(row)) return `ph-${row.index}`; return `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`; } + +export function createSheetPagingState() { + return { + generation: 0, + dataGeneration: 0, + pendingQueryKey: '', + loadedQueryKey: '', + total: 0, + facets: null, + pages: new Map(), + loading: false, + error: '', + }; +} + +export function svod2PagingReducer(state, action) { + if (action.generation !== state.generation && action.type !== 'begin') return state; + switch (action.type) { + case 'begin': + return { + ...state, + generation: action.generation, + pendingQueryKey: action.queryKey, + error: '', + loading: action.mode === 'load' ? true : state.loading, + ...(action.mode === 'load' ? { pages: new Map(), total: 0, facets: null } : {}), + }; + case 'page': { + const first = state.dataGeneration !== action.generation; + return { + ...state, + pages: first ? new Map([[action.pageIndex, action.rows]]) : touchPage(state.pages, action.pageIndex, action.rows, action.keepPages), + dataGeneration: action.generation, + loadedQueryKey: first ? state.pendingQueryKey : state.loadedQueryKey, + total: action.total, + }; + } + case 'facets': + return { ...state, facets: action.facets }; + case 'fail': + if (action.mode === 'scroll') return state; + return { ...state, error: action.message, pages: new Map(), total: 0 }; + case 'settled': + return { ...state, loading: false }; + default: + return state; + } +} diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index 98e2fdb..02b585e 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -6,6 +6,7 @@ import { buildSvod2PageParams, buildSvod2QueryParams, buildWindowRows, + createSheetPagingState, facetsToMaps, getMissingPages, getPageCount, @@ -16,6 +17,7 @@ import { getWindowRange, isPlaceholderRow, shouldShowFound, + svod2PagingReducer, touchPage, } from './svod2Paging.js'; @@ -152,3 +154,105 @@ test('getSvod2RowId', () => { assert.equal(isPlaceholderRow({ data: {} }), false); assert.equal(isPlaceholderRow(undefined), false); }); + +const begin = (s, generation, mode, queryKey = 'k') => svod2PagingReducer(s, { type: 'begin', generation, mode, queryKey }); +const page = (s, generation, pageIndex, rows, total) => + svod2PagingReducer(s, { type: 'page', generation, pageIndex, rows, total, keepPages: [pageIndex] }); + +test('начальное состояние вкладки', () => { + const s = createSheetPagingState(); + assert.equal(s.generation, 0); + assert.equal(s.dataGeneration, 0); + assert.equal(s.total, 0); + assert.equal(s.facets, null); + assert.equal(s.loading, false); + assert.equal(s.error, ''); + assert.equal(s.pages.size, 0); +}); + +test('устаревшие page/facets/fail игнорируются', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = begin(s, 2, 'refine'); + for (const a of [ + { type: 'page', generation: 1, pageIndex: 0, rows: [], total: 9 }, + { type: 'facets', generation: 1, facets: {} }, + { type: 'fail', generation: 1, mode: 'load', message: 'x' }, + { type: 'settled', generation: 1 }, + ]) + assert.equal(svod2PagingReducer(s, a), s); +}); + +test('load ставит loading и pendingQueryKey, сбрасывает error', () => { + const s = begin(createSheetPagingState(), 1, 'load', 'q1'); + assert.equal(s.loading, true); + assert.equal(s.generation, 1); + assert.equal(s.pendingQueryKey, 'q1'); + assert.equal(s.error, ''); +}); + +test('refine оставляет прежние строки до первого ответа нового поколения', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a', 'b'], 2); + s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 2 } }); + s = svod2PagingReducer(s, { type: 'settled', generation: 1 }); + s = begin(s, 2, 'refine', 'k2'); + assert.deepEqual(s.pages.get(0), ['a', 'b']); + assert.equal(s.total, 2); + assert.deepEqual(s.facets, { total: 2 }); + assert.equal(s.loading, false); + assert.equal(s.pendingQueryKey, 'k2'); + s = page(s, 2, 0, ['c'], 1); + assert.deepEqual([...s.pages.keys()], [0]); + assert.deepEqual(s.pages.get(0), ['c']); + assert.equal(s.dataGeneration, 2); +}); + +test('первый ответ поколения ставит loadedQueryKey', () => { + let s = begin(createSheetPagingState(), 1, 'load', 'k'); + assert.equal(s.loadedQueryKey, ''); + s = page(s, 1, 0, ['a'], 1); + assert.equal(s.loadedQueryKey, 'k'); + assert.equal(s.dataGeneration, 1); +}); + +test('ошибка догрузки (scroll) не меняет состояние, страница снова среди недостающих', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a'], 600); + assert.equal(svod2PagingReducer(s, { type: 'fail', generation: s.generation, mode: 'scroll', message: 'x' }), s); + assert.deepEqual(getMissingPages([0, 1], s.pages, new Set()), [1]); +}); + +test('ошибка load очищает строки и ставит error', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a'], 5); + s = svod2PagingReducer(s, { type: 'fail', generation: 1, mode: 'load', message: 'x' }); + assert.equal(s.error, 'x'); + assert.equal(s.pages.size, 0); + assert.equal(s.total, 0); +}); + +test('ошибка refine очищает строки и ставит error', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a'], 5); + s = begin(s, 2, 'refine'); + s = svod2PagingReducer(s, { type: 'fail', generation: 2, mode: 'refine', message: 'y' }); + assert.equal(s.error, 'y'); + assert.equal(s.pages.size, 0); + assert.equal(s.total, 0); +}); + +test('последний total поколения побеждает', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a'], 5563); + s = page(s, 1, 1, ['b'], 5560); + assert.equal(s.total, 5560); + assert.equal(s.pages.size, 2); +}); + +test('facets и settled текущего поколения', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 3 } }); + assert.deepEqual(s.facets, { total: 3 }); + s = svod2PagingReducer(s, { type: 'settled', generation: 1 }); + assert.equal(s.loading, false); +}); -- 2.47.2 From 325c1c5f15e965b449a0cc8b553da832e9c8e684 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:22:43 +0400 Subject: [PATCH 05/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20begin(load)=20=D0=BD=D0=B5=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=89=D0=B0=D0=B5=D1=82=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 3 ++- web/src/pages/Svod2Page/svod2Paging.test.js | 23 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index da917f3..0ca4367 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -125,6 +125,8 @@ export function createSheetPagingState() { }; } +// Инвариант для хука: settled надо слать для КАЖДОГО поколения; settled старого поколения игнорируется, +// поэтому loading, выставленный begin(load), снимает только settled текущего поколения. export function svod2PagingReducer(state, action) { if (action.generation !== state.generation && action.type !== 'begin') return state; switch (action.type) { @@ -135,7 +137,6 @@ export function svod2PagingReducer(state, action) { pendingQueryKey: action.queryKey, error: '', loading: action.mode === 'load' ? true : state.loading, - ...(action.mode === 'load' ? { pages: new Map(), total: 0, facets: null } : {}), }; case 'page': { const first = state.dataGeneration !== action.generation; diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index 02b585e..07e9c16 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -256,3 +256,26 @@ test('facets и settled текущего поколения', () => { s = svod2PagingReducer(s, { type: 'settled', generation: 1 }); assert.equal(s.loading, false); }); + +test('begin(load) оставляет прежние pages/total/facets до первого ответа нового поколения', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = page(s, 1, 0, ['a'], 5); + s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 5 } }); + s = begin(s, 2, 'load', 'k2'); + assert.equal(s.loading, true); + assert.deepEqual(s.pages.get(0), ['a']); + assert.equal(s.total, 5); + assert.deepEqual(s.facets, { total: 5 }); + s = page(s, 2, 0, ['z'], 1); + assert.deepEqual([...s.pages.keys()], [0]); + assert.deepEqual(s.pages.get(0), ['z']); +}); + +test('refine поверх load: loading снимает settled нового поколения, старого - игнорируется', () => { + let s = begin(createSheetPagingState(), 1, 'load'); + s = begin(s, 2, 'refine'); + assert.equal(s.loading, true); + assert.equal(svod2PagingReducer(s, { type: 'settled', generation: 1 }), s); + s = svod2PagingReducer(s, { type: 'settled', generation: 2 }); + assert.equal(s.loading, false); +}); -- 2.47.2 From 1aa138a839fde194ae56c7864d09b326c1f62ed1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:26:30 +0400 Subject: [PATCH 06/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=D0=BC=D0=B8=20=D1=81?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/summary.js | 6 + web/src/pages/Svod2Page/Svod2Page.jsx | 110 +++----- web/src/pages/Svod2Page/svod2Styles.js | 7 + web/src/pages/Svod2Page/svod2Styles.test.js | 6 + web/src/pages/Svod2Page/useSvod2Pages.js | 266 ++++++++++++++++++++ 5 files changed, 317 insertions(+), 78 deletions(-) create mode 100644 web/src/pages/Svod2Page/useSvod2Pages.js diff --git a/web/src/api/summary.js b/web/src/api/summary.js index 6791b04..6b6668c 100644 --- a/web/src/api/summary.js +++ b/web/src/api/summary.js @@ -5,4 +5,10 @@ export const SummaryApi = { const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {}; return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data); }, + getSvod2Page(sheet, year, params, { signal } = {}) { + return api.get(`/svod/2/${sheet}/${year}`, { params, signal }).then((response) => response.data); + }, + getSvod2Facets(sheet, year, params, { signal } = {}) { + return api.get(`/svod/2/${sheet}/${year}/facets`, { params, signal }).then((response) => response.data); + }, }; diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 932a483..149cceb 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -2,7 +2,7 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_Localization_RU } from 'material-react-table/locales/ru'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; @@ -12,14 +12,14 @@ import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; +import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; import { SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; +import { useSvod2Pages } from './useSvod2Pages'; const SHEETS = [ { value: 'GO', label: 'ССП' }, { value: 'RF', label: 'РФ' }, ]; -const createInitialTableState = () => - Object.fromEntries(SHEETS.map(({ value }) => [value, { rows: [], loading: false, error: '', loadedQueryKey: '' }])); const QUARTERS = [1, 2, 3, 4]; const tableSize = (rem) => rem * 16; const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }); @@ -280,30 +280,14 @@ const scaleNumericColumns = (columns, divisor) => columns.map((column) => { return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) }; }); -const matchesSearch = (row, query) => { - const searchableData = { - ...row.data?.header, - ...row.data?.contract_summary, - ...row.data?.contract_detail, - }; - return Object.values(searchableData).some((value) => - String(value ?? '') - .toLowerCase() - .includes(query), - ); -}; - export default function Svod2Page() { const navigate = useNavigate(); - const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); + const { getSheetFilters, updateSheetFilters } = useSvod(); const [sheet, setSheet] = useState('GO'); const denominationStorageKey = `svod2_${sheet}_denomination`; const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey); - const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState); const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []]))); - const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0]))); const { year, selectedOrganizations, search } = getSheetFilters(sheet); - const { rows, loading, error, loadedQueryKey } = tableStateBySheet[sheet]; const columnFilters = columnFiltersBySheet[sheet]; const selectedOrganizationIds = useMemo( () => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)), @@ -313,7 +297,6 @@ export default function Svod2Page() { () => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]), [sheet, year, selectedOrganizations], ); - const hasCurrentData = loadedQueryKey === currentQueryKey; const updateCurrentSheetFilters = useCallback( (changes) => { updateSheetFilters(sheet, changes); @@ -332,69 +315,36 @@ export default function Svod2Page() { [sheet], ); - const loadSummary = useCallback(async () => { - const requestId = latestSummaryRequestRef.current[sheet] + 1; - latestSummaryRequestRef.current[sheet] = requestId; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], loading: true, error: '' }, - })); - try { - const response = await getSummary(sheet, year, selectedOrganizationIds); - if (latestSummaryRequestRef.current[sheet] !== requestId) return; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], rows: response.result || [], loadedQueryKey: currentQueryKey }, - })); - } catch (loadError) { - if (latestSummaryRequestRef.current[sheet] !== requestId) return; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { - ...current[sheet], - rows: [], - error: loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.', - }, - })); - } finally { - if (latestSummaryRequestRef.current[sheet] === requestId) { - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], loading: false }, - })); - } - } - }, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]); - - useEffect(() => { - return () => { - SHEETS.forEach(({ value }) => { - latestSummaryRequestRef.current[value] += 1; - }); - }; - }, []); - - const refreshSummary = useCallback(() => { - invalidateSummary(sheet, year, selectedOrganizationIds); - loadSummary(); - }, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]); + const tableRef = useRef(null); + const getScrollTop = useCallback(() => tableRef.current?.refs.tableContainerRef.current?.scrollTop ?? 0, []); + const { state, windowRows, padding, facetsMap, load, onScroll } = useSvod2Pages({ + sheet, + year, + orgIds: selectedOrganizationIds, + queryKey: currentQueryKey, + search, + columnFilters, + rowHeight: ROW_HEIGHT, + getScrollTop, + }); + const { loading, error } = state; + const hasCurrentData = state.loadedQueryKey === currentQueryKey; + const facetsMapRef = useRef(facetsMap); + facetsMapRef.current = facetsMap; + const refreshSummary = useCallback(() => load('refresh'), [load]); const openFilteredTasks = useCallback(() => { const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' }); if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(',')); navigate(`/tasks?${params.toString()}`); }, [navigate, selectedOrganizationIds, year]); - const filteredRows = useMemo(() => { - if (!hasCurrentData) return []; - const normalizedSearch = search.trim().toLowerCase(); - return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; - }, [hasCurrentData, rows, search]); const { columns: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]); const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]); const table = useMaterialReactTable({ columns, - data: filteredRows, - getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`, + data: hasCurrentData ? windowRows : [], + getRowId: getSvod2RowId, + manualFiltering: true, enableColumnActions: false, enableColumnFilters: true, enableFacetedValues: true, @@ -409,7 +359,8 @@ export default function Svod2Page() { enableTopToolbar: false, enableBottomToolbar: false, enableSorting: false, - rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, + getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP, + rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }, state: { isLoading: loading, columnFilters }, initialState: { columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] }, @@ -428,7 +379,9 @@ export default function Svod2Page() { flexDirection: 'column', }, }, - muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX }, + muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }, + muiTableBodyRowProps: ({ row }) => + isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}, muiTableHeadProps: { sx: { '& tr:not(:last-of-type) > th > .MuiCollapse-root': { @@ -444,6 +397,7 @@ export default function Svod2Page() { noRecordsToDisplay: 'Нет данных для выбранных параметров', }, }); + tableRef.current = table; let content = ; if (!hasCurrentData && !loading && !error) { content = ( @@ -499,9 +453,9 @@ export default function Svod2Page() { emptyOrganizationsPlaceholder='Выберите ССП/РФ' allowSelectAll additionalFilters={}> - {hasCurrentData && !loading && filteredRows.length !== rows.length && ( + {shouldShowFound({ hasCurrentData, loading, facets: state.facets }) && ( - {`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} + {`Найдено ${state.facets.search_total.toLocaleString('ru-RU')} из ${state.facets.total.toLocaleString('ru-RU')} строк`} )} diff --git a/web/src/pages/Svod2Page/svod2Styles.js b/web/src/pages/Svod2Page/svod2Styles.js index 09fffd9..cf70b7a 100644 --- a/web/src/pages/Svod2Page/svod2Styles.js +++ b/web/src/pages/Svod2Page/svod2Styles.js @@ -99,6 +99,13 @@ export const SVOD2_TABLE_CONTAINER_SX = { '& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' }, '& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, '& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' }, + // Строка-заглушка ещё не загруженной страницы: пустой вид без мигающих бейджей. + '& tbody tr.svod2-placeholder td': { color: 'transparent' }, + '& tbody tr.svod2-placeholder .svod2-badge, & tbody tr.svod2-placeholder .svod2-org-badge': { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: 'transparent', + }, '& .svod2-org-badge': { display: 'inline-block', maxWidth: '100%', diff --git a/web/src/pages/Svod2Page/svod2Styles.test.js b/web/src/pages/Svod2Page/svod2Styles.test.js index ffdb98c..13823cf 100644 --- a/web/src/pages/Svod2Page/svod2Styles.test.js +++ b/web/src/pages/Svod2Page/svod2Styles.test.js @@ -65,3 +65,9 @@ test('container styles define cell, band, group-end and pinned rules', () => { assert.ok(SVOD2_TABLE_CONTAINER_SX[key], key); } }); + +test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => { + const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td']; + assert.ok(rule, 'нет правила заглушки'); + assert.equal(rule.color, 'transparent'); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js new file mode 100644 index 0000000..4f915a8 --- /dev/null +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -0,0 +1,266 @@ +// Хук серверной постраничной загрузки свода 2: поколения запросов, окно страниц, догрузка при прокрутке. +import axios from 'axios'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; + +import { SummaryApi } from '../../api/summary'; +import { + PAGE_SIZE, + buildSvod2PageParams, + buildSvod2QueryParams, + buildWindowRows, + createSheetPagingState, + facetsToMaps, + getMissingPages, + getPageCount, + getVirtualizerPadding, + getVisiblePage, + getWindowPages, + getWindowRange, + svod2PagingReducer, +} from './svod2Paging'; + +const SHEET_KEYS = ['GO', 'RF']; +const LOAD_ERROR_FALLBACK = 'Не удалось загрузить свод. Попробуйте ещё раз.'; +const SCROLL_ERROR_MESSAGE = 'Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.'; +const NO_PADDING = { paddingStart: 0, paddingEnd: 0 }; + +const createInitialState = () => Object.fromEntries(SHEET_KEYS.map((key) => [key, createSheetPagingState()])); +const rootReducer = (state, action) => ({ ...state, [action.sheet]: svod2PagingReducer(state[action.sheet], action) }); + +const getRawPage = (scrollTop, rowHeight) => { + const page = Math.floor(scrollTop / rowHeight / PAGE_SIZE); + return Number.isFinite(page) && page > 0 ? page : 0; +}; + +const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted; + +export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) { + // Состояние хранится в ref синхронно (редьюсер вызывается в dispatch), чтобы логика догрузки видела актуальные страницы. + const stateRef = useRef(null); + if (stateRef.current === null) stateRef.current = createInitialState(); + const [state, setState] = useState(stateRef.current); + const [scrollPage, setScrollPage] = useState(0); + + const generationRef = useRef(Object.fromEntries(SHEET_KEYS.map((key) => [key, 0]))); + const ctxRef = useRef({}); + const rafRef = useRef(0); + const mountedRef = useRef(true); + const propsRef = useRef(null); + propsRef.current = { sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }; + + const dispatch = useCallback((action) => { + const next = rootReducer(stateRef.current, action); + if (next === stateRef.current) return; + stateRef.current = next; + if (mountedRef.current) setState(next); + }, []); + + const fetchPage = useCallback(async (ctx, pageIndex) => { + ctx.inflight.add(pageIndex); + try { + const response = await SummaryApi.getSvod2Page( + ctx.sheet, + ctx.year, + buildSvod2PageParams({ + orgIds: ctx.orgIds, + search: ctx.search, + columnFilters: ctx.columnFilters, + offset: pageIndex * PAGE_SIZE, + }), + { signal: ctx.controller.signal }, + ); + const rows = response.result || []; + return { rows, total: response.total ?? rows.length }; + } finally { + ctx.inflight.delete(pageIndex); + } + }, []); + + const getWindowFor = useCallback((total) => { + const { rowHeight: height, getScrollTop: readScrollTop } = propsRef.current; + const visible = getVisiblePage({ scrollTop: readScrollTop(), rowHeight: height, total }); + return getWindowPages(visible, total); + }, []); + + const start = useCallback( + (mode) => { + const current = propsRef.current; + const sheetKey = current.sheet; + ctxRef.current[sheetKey]?.controller.abort(); + const generation = generationRef.current[sheetKey] + 1; + generationRef.current[sheetKey] = generation; + const ctx = { + sheet: sheetKey, + year: current.year, + orgIds: current.orgIds, + search: current.search, + columnFilters: current.columnFilters, + generation, + controller: new AbortController(), + inflight: new Set(), + ready: false, + }; + ctxRef.current[sheetKey] = ctx; + const candidate = getRawPage(current.getScrollTop(), current.rowHeight); + setScrollPage(candidate); + dispatch({ type: 'begin', sheet: sheetKey, generation, queryKey: current.queryKey, mode: mode === 'refine' ? 'refine' : 'load' }); + + let failed = false; + const fail = (error) => { + if (isCanceled(error, ctx) || failed) return; + failed = true; + dispatch({ + type: 'fail', + sheet: sheetKey, + generation, + mode: mode === 'refine' ? 'refine' : 'load', + message: error?.response?.data?.detail || LOAD_ERROR_FALLBACK, + }); + ctx.controller.abort(); + }; + + const loadFacets = async () => { + try { + const response = await SummaryApi.getSvod2Facets(ctx.sheet, ctx.year, buildSvod2QueryParams(ctx), { + signal: ctx.controller.signal, + }); + if (ctx.controller.signal.aborted) return; + dispatch({ type: 'facets', sheet: sheetKey, generation, facets: response.result }); + } catch (error) { + fail(error); + } + }; + + const loadPages = async () => { + try { + const first = await fetchPage(ctx, candidate); + if (ctx.controller.signal.aborted) return; + const { total } = first; + const pages = getWindowFor(total); + const pageCount = getPageCount(total); + let cache = new Map(); + if (total === 0 || candidate < pageCount) { + const pageIndex = total === 0 ? 0 : candidate; + dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages }); + cache = stateRef.current[sheetKey].pages; + } + ctx.ready = true; + const missing = getMissingPages(pages, cache, ctx.inflight); + await Promise.all( + missing.map(async (pageIndex) => { + try { + const result = await fetchPage(ctx, pageIndex); + if (ctx.controller.signal.aborted) return; + dispatch({ + type: 'page', + sheet: sheetKey, + generation, + pageIndex, + rows: result.rows, + total: result.total, + keepPages: getWindowFor(result.total), + }); + } catch (error) { + fail(error); + } + }), + ); + } catch (error) { + fail(error); + } + }; + + (async () => { + try { + await Promise.all([loadFacets(), loadPages()]); + } finally { + dispatch({ type: 'settled', sheet: sheetKey, generation }); + } + })(); + }, + [dispatch, fetchPage, getWindowFor], + ); + + const load = useCallback(() => start('load'), [start]); + + // Догрузка недостающих страниц окна текущей вкладки в режиме scroll. + const syncWindow = useCallback(() => { + const current = propsRef.current; + setScrollPage(getRawPage(current.getScrollTop(), current.rowHeight)); + const ctx = ctxRef.current[current.sheet]; + const sheetState = stateRef.current[current.sheet]; + if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return; + if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return; + const pages = getWindowFor(sheetState.total); + for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) { + fetchPage(ctx, pageIndex) + .then((result) => { + if (ctx.controller.signal.aborted) return; + dispatch({ + type: 'page', + sheet: ctx.sheet, + generation: ctx.generation, + pageIndex, + rows: result.rows, + total: result.total, + keepPages: getWindowFor(result.total), + }); + }) + .catch((error) => { + if (isCanceled(error, ctx)) return; + toast.error(SCROLL_ERROR_MESSAGE, { toastId: 'svod2-scroll-error' }); + }); + } + }, [dispatch, fetchPage, getWindowFor]); + + const onScroll = useCallback(() => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + if (mountedRef.current) syncWindow(); + }); + }, [syncWindow]); + + // Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами. + const sheetState = state[sheet]; + const hasCurrentData = sheetState.loadedQueryKey === queryKey; + const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); + const previousRef = useRef({ sheet, refineKey }); + // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки + useEffect(() => { + const previous = previousRef.current; + previousRef.current = { sheet, refineKey }; + if (previous.sheet !== sheet) { + syncWindow(); + return; + } + if (previous.refineKey !== refineKey && hasCurrentData) start('refine'); + }, [sheet, refineKey]); + + useEffect(() => { + mountedRef.current = true; + const contexts = ctxRef.current; + return () => { + mountedRef.current = false; + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + for (const ctx of Object.values(contexts)) ctx.controller.abort(); + }; + }, []); + + const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total }); + const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]); + const windowRows = useMemo( + () => buildWindowRows(windowPages, sheetState.pages, sheetState.total), + [windowPages, sheetState.pages, sheetState.total], + ); + const padding = useMemo(() => { + if (!hasCurrentData) return NO_PADDING; + const { windowStart, windowEnd } = getWindowRange(windowPages, sheetState.total); + return getVirtualizerPadding({ windowStart, windowEnd, total: sheetState.total, rowHeight }); + }, [hasCurrentData, windowPages, sheetState.total, rowHeight]); + const facetsMap = useMemo(() => facetsToMaps(sheetState.facets), [sheetState.facets]); + + return { state: sheetState, windowRows, padding, facetsMap, load, onScroll }; +} -- 2.47.2 From 9ffb47d4e744e55e895dbec8cbdf31dbba0d1dea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:03:15 +0400 Subject: [PATCH 07/12] =?UTF-8?q?docs(openspec):=20aurora-1843=20=E2=80=94?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20R-9,=20R-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1843-svod2-server-paging/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index 4093c47..68f020a 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -692,3 +692,8 @@ PR создаются по готовности, merge — по команде Цена ошибки: нет. 42. **2026-10-28 (контроллер, R-8) — M-4 (символ NUL в `search`/фильтре даёт ошибку типа `text` PostgreSQL, вероятно 5xx) не правится в этой задаче, `deferred`; с экрана такой символ не вводится.** Цена ошибки: 5xx на искусственном запросе; правка — ранний возврат пустой страницы. +43. **2026-10-29 (контроллер, R-9) — высота строки окна определяется измерением первой отрисованной строки с данными (по умолчанию 37 px) и используется в `estimateSize`, `measureElement`, `rowHeight` хука и отступах. `ROW_VIRTUALIZER_OPTIONS` не правится.** + Обоснование: константа 37 px при фактической высоте ≈44 px (бейдж «Вид сметы», отступы ячеек, граница) наложила бы строки друг на друга; «до» мерил `offsetHeight`. + Цена ошибки: дрожание высоты при первом измерении; ручная проверка контроллером на стенде; после влития AURORA-1836 источник заменить на её константу. +44. **2026-10-29 (контроллер, R-10) — приняты как отступления (deferred): окно страниц запрашивается после ответа первой страницы (T12.1); при сужающем refine глубоко внизу уходит лишний запрос `offset ≥ total` (T12.2); после ошибки смена поиска запускает refine без ожидания «Повторить» (T12.4); на границе страниц после refine/«Обновить» на один ответ видны заглушки (T12.5).** + Цена ошибки: лишний запрос или кратковременные заглушки; проверяет главный контроллер вручную. -- 2.47.2 From d74f5286f90e3ee81a21dd2f259190bad5a78b52 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:03:43 +0400 Subject: [PATCH 08/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20refine=20=D0=B2=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=C2=AB=D0=9D=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4=D0=B5=D0=BD=D0=BE=C2=BB=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8,=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=B2=D1=8B=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/Svod2Page.jsx | 15 +++++++++++---- web/src/pages/Svod2Page/svod2Paging.js | 12 ++++++++++-- web/src/pages/Svod2Page/svod2Paging.test.js | 16 ++++++++++++++++ web/src/pages/Svod2Page/useSvod2Pages.js | 13 ++++++++++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 149cceb..b2144d2 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -2,7 +2,7 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_Localization_RU } from 'material-react-table/locales/ru'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; @@ -317,6 +317,7 @@ export default function Svod2Page() { const tableRef = useRef(null); const getScrollTop = useCallback(() => tableRef.current?.refs.tableContainerRef.current?.scrollTop ?? 0, []); + const [rowHeight, setRowHeight] = useState(ROW_HEIGHT); const { state, windowRows, padding, facetsMap, load, onScroll } = useSvod2Pages({ sheet, year, @@ -324,7 +325,7 @@ export default function Svod2Page() { queryKey: currentQueryKey, search, columnFilters, - rowHeight: ROW_HEIGHT, + rowHeight, getScrollTop, }); const { loading, error } = state; @@ -360,7 +361,7 @@ export default function Svod2Page() { enableBottomToolbar: false, enableSorting: false, getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP, - rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }, + rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, estimateSize: () => rowHeight, measureElement: () => rowHeight, ...padding }, state: { isLoading: loading, columnFilters }, initialState: { columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] }, @@ -398,6 +399,12 @@ export default function Svod2Page() { }, }); tableRef.current = table; + // Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT). + useLayoutEffect(() => { + const element = tableRef.current?.refs.tableContainerRef.current?.querySelector('tbody tr:not([data-svod2-placeholder])'); + const measured = element ? Math.round(element.offsetHeight) : 0; + if (measured > 0 && measured !== rowHeight) setRowHeight(measured); + }); let content = ; if (!hasCurrentData && !loading && !error) { content = ( @@ -453,7 +460,7 @@ export default function Svod2Page() { emptyOrganizationsPlaceholder='Выберите ССП/РФ' allowSelectAll additionalFilters={}> - {shouldShowFound({ hasCurrentData, loading, facets: state.facets }) && ( + {shouldShowFound({ hasCurrentData, loading, facets: state.facets, error }) && ( {`Найдено ${state.facets.search_total.toLocaleString('ru-RU')} из ${state.facets.total.toLocaleString('ru-RU')} строк`} diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 0ca4367..93c49f9 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -98,8 +98,16 @@ export function facetsToMaps(result) { return maps; } -export function shouldShowFound({ hasCurrentData, loading, facets }) { - return Boolean(hasCurrentData) && !loading && !!facets && facets.search_total !== facets.total; +export function shouldShowFound({ hasCurrentData, loading, facets, error = '' }) { + return Boolean(hasCurrentData) && !loading && !error && !!facets && facets.search_total !== facets.total; +} + +// Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных +// либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям). +export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, prevRefineKey, refineKey }) { + if (prevRefineKey === refineKey) return false; + if (hasCurrentData) return true; + return Boolean(loading) && pendingQueryKey === queryKey; } export function isPlaceholderRow(row) { diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index 07e9c16..ee19dc5 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -16,6 +16,7 @@ import { getWindowPages, getWindowRange, isPlaceholderRow, + shouldRefine, shouldShowFound, svod2PagingReducer, touchPage, @@ -279,3 +280,18 @@ test('refine поверх load: loading снимает settled нового по s = svod2PagingReducer(s, { type: 'settled', generation: 2 }); assert.equal(s.loading, false); }); + +test('«Найдено» не показывается рядом с ошибкой', () => { + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false); +}); +test('shouldRefine: на загруженных данных при смене условий', () => { + const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; + assert.equal(shouldRefine(base), true); + assert.equal(shouldRefine({ ...base, refineKey: 'a' }), false); +}); +test('shouldRefine: поиск изменён во время первой загрузки (T12.7)', () => { + const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; + assert.equal(shouldRefine(base), true); + assert.equal(shouldRefine({ ...base, pendingQueryKey: 'old' }), false); + assert.equal(shouldRefine({ ...base, loading: false }), false); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index 4f915a8..adbef98 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -17,6 +17,7 @@ import { getVisiblePage, getWindowPages, getWindowRange, + shouldRefine, svod2PagingReducer, } from './svod2Paging'; @@ -235,7 +236,17 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil syncWindow(); return; } - if (previous.refineKey !== refineKey && hasCurrentData) start('refine'); + if ( + shouldRefine({ + hasCurrentData, + loading: sheetState.loading, + pendingQueryKey: sheetState.pendingQueryKey, + queryKey, + prevRefineKey: previous.refineKey, + refineKey, + }) + ) + start('refine'); }, [sheet, refineKey]); useEffect(() => { -- 2.47.2 From fbc3601673ec0fbbbdfc8bdf5a8338218721112b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:16:41 +0400 Subject: [PATCH 09/12] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20refine=20=D0=BF=D0=BE=20=D1=83=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=8F=D0=BC=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?,=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=81=D0=BE=D1=82=D1=8B=20=D0=B2=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/Svod2Page.jsx | 11 ++++++-- web/src/pages/Svod2Page/svod2Paging.js | 5 ++-- web/src/pages/Svod2Page/svod2Paging.test.js | 16 +++++++++-- web/src/pages/Svod2Page/useSvod2Pages.js | 31 +++++++++++++-------- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index b2144d2..b739c59 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -382,7 +382,7 @@ export default function Svod2Page() { }, muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }, muiTableBodyRowProps: ({ row }) => - isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}, + isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '', style: { height: rowHeight } } : {}, muiTableHeadProps: { sx: { '& tr:not(:last-of-type) > th > .MuiCollapse-root': { @@ -400,9 +400,14 @@ export default function Svod2Page() { }); tableRef.current = table; // Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT). + // Не измеряем во время загрузки (MRT рисует скелетон другой высоты); берём максимум по строкам с данными. useLayoutEffect(() => { - const element = tableRef.current?.refs.tableContainerRef.current?.querySelector('tbody tr:not([data-svod2-placeholder])'); - const measured = element ? Math.round(element.offsetHeight) : 0; + if (loading) return; + const elements = tableRef.current?.refs.tableContainerRef.current?.querySelectorAll( + 'tbody tr[data-index]:not([data-svod2-placeholder])', + ); + let measured = 0; + for (const element of elements ?? []) measured = Math.max(measured, Math.round(element.offsetHeight)); if (measured > 0 && measured !== rowHeight) setRowHeight(measured); }); let content = ; diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 93c49f9..3f763b8 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -104,8 +104,9 @@ export function shouldShowFound({ hasCurrentData, loading, facets, error = '' }) // Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных // либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям). -export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, prevRefineKey, refineKey }) { - if (prevRefineKey === refineKey) return false; +// dataRefineKey — условия, на которых построены загруженные (или летящие) данные вкладки; нет их — нет и refine. +export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, dataRefineKey, refineKey }) { + if (dataRefineKey === undefined || dataRefineKey === null || dataRefineKey === refineKey) return false; if (hasCurrentData) return true; return Boolean(loading) && pendingQueryKey === queryKey; } diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index ee19dc5..d14a7cd 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -285,13 +285,25 @@ test('«Найдено» не показывается рядом с ошибк assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false); }); test('shouldRefine: на загруженных данных при смене условий', () => { - const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; + const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' }; assert.equal(shouldRefine(base), true); assert.equal(shouldRefine({ ...base, refineKey: 'a' }), false); }); test('shouldRefine: поиск изменён во время первой загрузки (T12.7)', () => { - const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; + const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' }; assert.equal(shouldRefine(base), true); assert.equal(shouldRefine({ ...base, pendingQueryKey: 'old' }), false); assert.equal(shouldRefine({ ...base, loading: false }), false); }); +test('shouldRefine: ключ A -> B -> A при изменённых фильтрах', () => { + // данные ключа A построены на фильтре 'a'; на B фильтры сброшены (''), возврат на A — данные устарели + const onA = { hasCurrentData: true, loading: false, pendingQueryKey: 'A', queryKey: 'A', dataRefineKey: 'a', refineKey: '' }; + assert.equal(shouldRefine(onA), true); + const onB = { ...onA, hasCurrentData: false, queryKey: 'B' }; + assert.equal(shouldRefine(onB), false); +}); +test('shouldRefine: первое монтаж и смена вкладки без расхождения не запускают refine', () => { + const base = { hasCurrentData: false, loading: false, pendingQueryKey: '', queryKey: 'q', refineKey: 'a' }; + assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false); + assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index adbef98..af0a00e 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -34,6 +34,8 @@ const getRawPage = (scrollTop, rowHeight) => { return Number.isFinite(page) && page > 0 ? page : 0; }; +const makeRefineKey = (search, columnFilters) => JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); + const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted; export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) { @@ -101,6 +103,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil controller: new AbortController(), inflight: new Set(), ready: false, + refineKey: makeRefineKey(current.search, current.columnFilters), }; ctxRef.current[sheetKey] = ctx; const candidate = getRawPage(current.getScrollTop(), current.rowHeight); @@ -194,7 +197,11 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return; if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return; const pages = getWindowFor(sheetState.total); - for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) { + for (const pageIndex of getMissingPages( + pages, + sheetState.dataGeneration === ctx.generation ? sheetState.pages : new Map(), + ctx.inflight, + )) { fetchPage(ctx, pageIndex) .then((result) => { if (ctx.controller.signal.aborted) return; @@ -226,28 +233,28 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil // Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами. const sheetState = state[sheet]; const hasCurrentData = sheetState.loadedQueryKey === queryKey; - const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); - const previousRef = useRef({ sheet, refineKey }); - // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки + const refineKey = makeRefineKey(search, columnFilters); + // Условия, на которых построены данные вкладки (или летящего поколения), хранятся в ctx: сравнение с ними, а не с прошлым рендером. + // biome-ignore lint/correctness/useExhaustiveDependencies: start и поля sheetState читаются из актуального рендера useEffect(() => { - const previous = previousRef.current; - previousRef.current = { sheet, refineKey }; - if (previous.sheet !== sheet) { - syncWindow(); - return; - } if ( shouldRefine({ hasCurrentData, loading: sheetState.loading, pendingQueryKey: sheetState.pendingQueryKey, queryKey, - prevRefineKey: previous.refineKey, + dataRefineKey: ctxRef.current[sheet]?.refineKey, refineKey, }) ) start('refine'); - }, [sheet, refineKey]); + }, [sheet, refineKey, hasCurrentData, queryKey, sheetState.loading, sheetState.pendingQueryKey]); + + // Смена вкладки или высоты строки — пересчёт окна страниц. + // biome-ignore lint/correctness/useExhaustiveDependencies: syncWindow читает актуальные props из ref + useEffect(() => { + syncWindow(); + }, [sheet, rowHeight]); useEffect(() => { mountedRef.current = true; -- 2.47.2 From eef358c56ecfdbb2750b2e9d35480e49d08f556e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 13:18:13 +0400 Subject: [PATCH 10/12] =?UTF-8?q?feat(svod2):=20=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D1=8B=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D1=85-=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=D1=85=20(Skeleton,?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20=D1=83=20MRT=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=C2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ячейки строк с data-svod2-placeholder рисуют MUI Skeleton wave высотой 20 с детерминированной шириной. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/Svod2Page.jsx | 16 +++++++++++++--- web/src/pages/Svod2Page/svod2Paging.js | 15 +++++++++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 16db1f3..5a942cd 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -1,5 +1,5 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; -import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; +import { Alert, Box, Button, Paper, Skeleton, Tab, Tabs, Typography } from '@mui/material'; import { MRT_TableHead, MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_Localization_RU } from 'material-react-table/locales/ru'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -13,7 +13,7 @@ import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; -import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; +import { EMPTY_FACET_MAP, getSkeletonWidth, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; import { SVOD2_LAYOUT_OPTIONS, SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; import { useSvod2Pages } from './useSvod2Pages'; @@ -402,7 +402,17 @@ export default function Svod2Page() { }, muiTableHeadCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), muiTableHeadRowProps: { sx: { boxShadow: 'none' } }, - muiTableBodyCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), + muiTableBodyCellProps: ({ column, row }) => ({ + className: columnClassNames.get(column.id), + // Ячейки строк-заглушек: полосы загрузки того же вида, что MRT рисует при «Обновить» (Skeleton wave, высота 20). + ...(isPlaceholderRow(row.original) + ? { + children: ( + + ), + } + : {}), + }), localization: { ...MRT_Localization_RU, noRecordsToDisplay: 'Нет данных для выбранных параметров', diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 3f763b8..6e76155 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -111,6 +111,21 @@ export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKe return Boolean(loading) && pendingQueryKey === queryKey; } +// Ширина полосы загрузки в ячейке строки-заглушки: детерминированная (без Math.random, чтобы полосы не «прыгали» +// при перерисовке окна), от трети до полной ширины колонки, как у штатных полос MRT. +export function getSkeletonWidth(columnSize, rowIndex, columnIndex) { + const hash = ((rowIndex + 1) * 2654435761 + (columnIndex + 1) * 40503) >>> 0; + const min = Math.round(columnSize / 3); + return (min + ((hash % 1000) / 1000) * (columnSize - min)) | 0; +} + +// При смене поиска/фильтров на глубине первая запрошенная страница (по прокрутке) может оказаться за концом нового, +// короткого списка: сервер ответит только total. Тогда страницу 0 и общее число берём из запасного запроса offset=0, +// не дожидаясь (и не ожидая) ответа на страницу за пределами списка. +export function shouldFetchFirstPageFallback({ candidate, total }) { + return candidate > 0 && candidate >= getPageCount(total); +} + export function isPlaceholderRow(row) { return Boolean(row?.__placeholder); } diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index d14a7cd..1e44844 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -10,12 +10,14 @@ import { facetsToMaps, getMissingPages, getPageCount, + getSkeletonWidth, getSvod2RowId, getVirtualizerPadding, getVisiblePage, getWindowPages, getWindowRange, isPlaceholderRow, + shouldFetchFirstPageFallback, shouldRefine, shouldShowFound, svod2PagingReducer, @@ -307,3 +309,22 @@ test('shouldRefine: первое монтаж и смена вкладки бе assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false); assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false); }); + +test('полосы загрузки: ширина детерминирована и в пределах колонки', () => { + for (const size of [60, 150, 400]) { + for (let i = 0; i < 20; i++) { + const w = getSkeletonWidth(size, i, 3); + assert.equal(w, getSkeletonWidth(size, i, 3)); + assert.ok(w >= Math.round(size / 3) && w <= size, `${size}:${i}:${w}`); + } + } + assert.notEqual(getSkeletonWidth(400, 1, 0), getSkeletonWidth(400, 2, 0)); +}); + +test('первая страница при смене условий: запасной offset=0, если запрошенная страница за концом нового списка', () => { + assert.equal(shouldFetchFirstPageFallback({ candidate: 0, total: 43 }), false); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 43 }), true); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 0 }), true); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 22419 }), false); + assert.equal(shouldFetchFirstPageFallback({ candidate: 75, total: 22419 }), true); +}); -- 2.47.2 From ea6915c52fe77f488ea0ad9d544488f4b72820fc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 13:18:13 +0400 Subject: [PATCH 11/12] =?UTF-8?q?fix(svod2):=20=D0=BF=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B5/=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B5=20=D0=BD=D0=B0=20=D0=B3=D0=BB=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=20total=20=D0=B1=D0=B5=D1=80=D1=91=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20offset=3D0,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=B7=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=86=D0=BE=D0=BC=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/useSvod2Pages.js | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index af0a00e..2c02353 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -17,6 +17,7 @@ import { getVisiblePage, getWindowPages, getWindowRange, + shouldFetchFirstPageFallback, shouldRefine, svod2PagingReducer, } from './svod2Paging'; @@ -59,7 +60,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil if (mountedRef.current) setState(next); }, []); - const fetchPage = useCallback(async (ctx, pageIndex) => { + const fetchPage = useCallback(async (ctx, pageIndex, signal = ctx.controller.signal) => { ctx.inflight.add(pageIndex); try { const response = await SummaryApi.getSvod2Page( @@ -71,7 +72,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil columnFilters: ctx.columnFilters, offset: pageIndex * PAGE_SIZE, }), - { signal: ctx.controller.signal }, + { signal }, ); const rows = response.result || []; return { rows, total: response.total ?? rows.length }; @@ -138,14 +139,43 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil const loadPages = async () => { try { - const first = await fetchPage(ctx, candidate); + // Страница по прокрутке и (на глубине) запасная offset=0 летят параллельно: если страница по прокрутке + // за концом нового списка, сервер отвечает на неё долго и без строк, а страница 0 уже даёт total. + const candidateController = new AbortController(); + const zeroController = new AbortController(); + const abortRequests = () => { + candidateController.abort(); + zeroController.abort(); + }; + ctx.controller.signal.addEventListener('abort', abortRequests); + const settle = (request, index) => + request.then( + (result) => ({ result, index }), + (error) => ({ error, index }), + ); + const candidateRequest = settle(fetchPage(ctx, candidate, candidateController.signal), candidate); + const zeroRequest = candidate > 0 ? settle(fetchPage(ctx, 0, zeroController.signal), 0) : null; + let answer = zeroRequest ? await Promise.race([candidateRequest, zeroRequest]) : await candidateRequest; + if ( + answer.index === 0 && + candidate > 0 && + !answer.error && + shouldFetchFirstPageFallback({ candidate, total: answer.result.total }) + ) { + candidateController.abort(); + } else { + zeroController.abort(); + answer = await candidateRequest; + if (answer.error) throw answer.error; + } if (ctx.controller.signal.aborted) return; + const first = answer.result; const { total } = first; const pages = getWindowFor(total); const pageCount = getPageCount(total); let cache = new Map(); - if (total === 0 || candidate < pageCount) { - const pageIndex = total === 0 ? 0 : candidate; + if (total === 0 || answer.index < pageCount) { + const pageIndex = total === 0 ? 0 : answer.index; dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages }); cache = stateRef.current[sheetKey].pages; } -- 2.47.2 From 1b8a1a44ad4800d8f68dd5d02fae72b86efba07c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 13:21:51 +0400 Subject: [PATCH 12/12] =?UTF-8?q?fix(svod2):=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BC=D0=B5=D0=BD=D1=8B=20=D1=83=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=B9=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B8=D1=81=D1=83=D1=8E=D1=82=D1=81=D1=8F,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B5=D1=82=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20(=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=81=D1=8B=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 8 ++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 16 ++++++++++++++++ web/src/pages/Svod2Page/useSvod2Pages.js | 10 ++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 6e76155..a7318a0 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -48,6 +48,14 @@ export function buildWindowRows(pages, pageCache, total) { return result; } +const NO_PAGES = Object.freeze(new Map()); + +// Страницы, которые можно показывать: только поколения, чьи данные уже пришли. После смены условий (поиск, фильтры, +// «Обновить») поколение вырастает сразу, а первый ответ нового приходит позже: до него все строки окна — заглушки. +export function getCurrentPages({ pages, dataGeneration, generation }) { + return dataGeneration === generation ? pages : NO_PAGES; +} + export function getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }) { return { paddingStart: windowStart * rowHeight, diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index 1e44844..bd60d39 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -8,6 +8,7 @@ import { buildWindowRows, createSheetPagingState, facetsToMaps, + getCurrentPages, getMissingPages, getPageCount, getSkeletonWidth, @@ -328,3 +329,18 @@ test('первая страница при смене условий: запас assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 22419 }), false); assert.equal(shouldFetchFirstPageFallback({ candidate: 75, total: 22419 }), true); }); + +test('смена поколения условий: пока нет ответа нового поколения, старые страницы не показываются (все строки окна — заглушки)', () => { + const pages = new Map([[0, [{ data: 1 }]]]); + assert.equal(getCurrentPages({ pages, dataGeneration: 3, generation: 3 }), pages); + const stale = getCurrentPages({ pages, dataGeneration: 3, generation: 4 }); + assert.equal(stale.size, 0); + const rows = buildWindowRows([0], stale, 3); + assert.deepEqual( + rows.map((r) => isPlaceholderRow(r)), + [true, true, true], + ); +}); +test('до первой загрузки (поколение 0) страниц нет и показывать нечего', () => { + assert.equal(getCurrentPages({ pages: new Map(), dataGeneration: 0, generation: 0 }).size, 0); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index 2c02353..201575f 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -11,6 +11,7 @@ import { buildWindowRows, createSheetPagingState, facetsToMaps, + getCurrentPages, getMissingPages, getPageCount, getVirtualizerPadding, @@ -300,8 +301,13 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total }); const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]); const windowRows = useMemo( - () => buildWindowRows(windowPages, sheetState.pages, sheetState.total), - [windowPages, sheetState.pages, sheetState.total], + () => + buildWindowRows( + windowPages, + getCurrentPages({ pages: sheetState.pages, dataGeneration: sheetState.dataGeneration, generation: sheetState.generation }), + sheetState.total, + ), + [windowPages, sheetState.pages, sheetState.dataGeneration, sheetState.generation, sheetState.total], ); const padding = useMemo(() => { if (!hasCurrentData) return NO_PADDING; -- 2.47.2