# Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ Мотивация и цифры — в `proposal.md`, требования — в `specs/summary2-api/spec.md` и `specs/summary2-screen/spec.md`. Ссылки `file:line` ниже даны по вершине ветки `feat/236-aurora-1843-svod2-server-paging`: это `399234a`, вершина AURORA-1842. ## Контекст: как устроено сейчас **API.** `GET /api/v1/svod/2/{sheet}/{year}`: - обработчик — `api/src/api/v1/summary.py:93-146`. Он уже принимает `org_ids`, `offset`, `limit` (`:99-100`), `sort_by` и `sort_direction`; - `count = len(rows)` (`:143-146`); - ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`), а `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`. Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`): - через `_allowed_org_ids` (`:73-88`) пересекает запрошенные организации с разрешёнными; - для администратора `None` означает «все»; - для остальных пустое пересечение даёт `[]`. Репозиторий — `api/src/repository/summary_repository.py:34-77`, это `SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов)`. **SQL.** Актуальные версии `v3.v_svod2_go` и `v3.v_svod2_rf` лежат в `api/alembic/versions/sql/0049_summary2_related_records.sql:6-303` и `:305-595`. Каждая собрана из CTE: 1. `eligible_lines` — `DISTINCT` по 13 LEFT JOIN «наличия данных». GO берёт FORM_1 и FORM_4 (`:71`), RF — FORM_2 (`:369`). Значения вычисляются тут же: `smeta_type` из `ei.sheet`, `org_name = ou.title`; 2. `numbered_lines` — `row_number()` по `org_name`/`item_id` или `section_code`, затем `form_type_code`, `form_id`, `line_id` (`:151-168`); 3. `page AS MATERIALIZED` — `LIMIT p_limit OFFSET p_offset` (`:169-173`); 4. сборка большого `jsonb` только для строк страницы (`:179-302`). Значит, стоимость страницы — это полный отбор плюс сборка `jsonb` только для этой страницы. `v3.v_svod2_go_export` (`:597-902`) — отдельная компактная функция для Excel GO, её не трогаем. Прежние перегрузки этих функций уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре: `0022_summary2_contract_rows.sql:11-13`, `:200-202`. **Кто зовёт `GET /svod/2/...` и `get_svod2_rows`:** - фронт — только `Svod2Page` через `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`, без `offset`/`limit`); - Excel RF — `export_service.py:236-245`, `offset=None`, `limit=None`; - интеграционные тесты — `api/tests/integration/test_summary_api_smoke.py:44-140`; - стенд: `perfkit.gen.verify` (`perf/perfkit/gen/verify.py:146-148`), `perfkit.bench` (повторяет URL из сетевого лога), `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`. **Фронт.** `web/src/pages/Svod2Page/Svod2Page.jsx`: - состояние вкладок `tableStateBySheet` держит `rows` целиком (`:21-22`, `:302`, `:345-348`); - `loadSummary` (`:335-367`) берёт ответ из кэша `SvodContext` (`web/src/pages/SvodPage/SvodContext.jsx:46-70`). Ключ кэша — лист + год + организации, кэш очищается при размонтировании провайдера маршрута `/svod/2` (`web/src/app/Routes.jsx:54-59`). `SvodContext` и `SvodFilters` общие со сводом 1; - поиск — `matchesSearch` (`:283-294`) по `{...header, ...contract_summary, ...contract_detail}`, фильтрация в `filteredRows` (`:387-391`), debounce ввода 300 мс в `SvodFilters.jsx:68-77`; - три колоночных select-фильтра — `smeta_type`, `smeta_direction`, `org_name` (`:158-176`) — используют `enableFacetedValues` (`:400`). Сортировка выключена (`:411`); - «Найдено N из M» (`:502-506`) считает только поиск, колоночные фильтры не учитывает; - изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`); - данные без «Загрузить» не грузятся (`:448-455`). «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`), сервер подставляет все разрешённые; - виртуализация строк — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: `ROW_HEIGHT = 37`, `measureElement` по `offsetHeight`). Виртуализация колонок из 1842 — `:33-34`, `:407-408`; - кнопки Excel-выгрузки свода 2 на фронте нет: по `web/src` нет ни `export/summary`, ни `summary/2`. **Стенд.** `perf/e2e/scenarios/scroll.spec.ts`: - для свода 2 кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`); - ждёт `MIN_ROWS` строк `tbody tr` (`waitRows`); - `json_bytes_sheet` — максимум байтов одного ответа по `respRe` (`:176-184`); - тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`armStable`/`awaitStable`, `:113-150`, `:221-227`); - времени до первых строк сценарий не пишет; - сценария памяти для сводов нет: `memory.spec.ts` их не открывает. `perf/analysis/explain_manual.py` функции свода 2 **не** использует (запросы — только FORM_2/AHR и FORM_4/CAP, `:59-81`), поэтому смена сигнатуры его не ломает. **Тестовые данные.** В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2: единственная строка FORM_1 относится к 2027 году и без данных. Смоук-тесты свода 2 фактически проверяют пустой ответ. ## Цели / не цели **Цели:** - память страницы свода 2 ограничена окном строк и не растёт с объёмом данных; - первые строки появляются за время одной страницы, а не всего ответа; - байты и Python-время на строку уменьшаются за счёт компактного вида; - поиск и фильтры не обходят все строки в браузере; - прежние потребители API не замечают изменения. **Не цели:** - менять отбор строк свода 2 (это O-5 / AURORA-1917); - адаптировать Excel-выгрузку под экранные фильтры; - серверная сортировка по клику на заголовок (на экране её нет); - отказ от pydantic на строку (это AURORA-1848); - полнотекстовый или триграммный индекс поиска; - менять свод 1 и общий `SvodFilters`. ## Решения (согласовано 28.10) 1. Вместо аналитики — реализация, варианты C + B черновика. Вариант A (только политика кэша на фронте) не нужен. 2. Сервер. У существующих `offset`/`limit` появляются серверный поиск (по тем же полям, что `matchesSearch` сейчас), три фильтра (`smeta_type`, `smeta_direction`, организация через `org_name`/`org_ids`) и поле `total`. `count` сохраняет прежний смысл (длина `result`). 3. Отдельный лёгкий запрос вариантов для трёх фильтров (facets). 4. Компактный ответ (только поля, которые экран читает или ищет) включается параметром, например `view=compact`. Без новых параметров ответ байт-в-байт прежний. 5. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет. Выгрузку под экранные фильтры не подгоняем. 6. Фронт: та же таблица, строки догружаются страницами при прокрутке. В памяти — окно плюс запас, а не весь ответ. Поиск и фильтры уходят на сервер (debounce 300 мс уже есть), «Найдено N из M» строится из `total`. Кэш `SvodContext` перестаёт копить полные ответы. 7. SQL: новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и сценарий `scroll.spec.ts` (клик «Все ССП/РФ» → «Загрузить») обновляются вместе с UX, чтобы замер оставался сопоставимым. 8. Три PR в стеке: сервер (SQL + API + тесты), фронт (догрузка, поиск, фильтры), замер и сценарии perf. 9. AURORA-1917 (O-5) — отдельная задача. Её нельзя вливать между замерами «до» и «после» 1843. 10. Метрики успеха: память страницы после GO «все» → RF «все» → GO (нужен новый сценарий `svod2-memory.spec.ts`), время до первых строк, байты ответа, шаг прокрутки свода 2 не хуже результата 1842. Масштабы 1 и 2. ## Решения по устройству ### 1. Контракт `GET /api/v1/svod/2/{sheet}/{year}` - **Прежние параметры** без изменений: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`. - **Новые параметры, все необязательные:** | параметр | тип | ограничения | |---|---|---| | `view` | `Literal["full","compact"] \| None` | | | `search` | `str \| None` | `max_length=200`, обрезается по краям | | `smeta_type` | `Literal["АХР","КВ","Операц"] \| None` | | | `smeta_direction` | `Literal["Поддержка","Развитие"] \| None` | | - **Новый режим** включается, когда хотя бы один новый параметр не `None`, в том числе явный `view=full`. В новом режиме ответ — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). В прежнем режиме ответ тот же `BaseListResponse[SheetResponse]`, что и раньше. - Аннотация возврата сейчас задаёт `response_model`, и модель отрезала бы `total`. Поэтому у обработчика нужно явно задать `response_model=Union[...]` или `response_model=None` и вернуть нужную модель по режиму. - Обязателен тест «в прежнем режиме нет `total`». - **Значения фильтров** — те же строки, что видит экран в `data.header` (`АХР`, `КВ`, `Операц`; `Поддержка`, `Развитие`), а не коды БД. Так варианты из facets подставляются в запрос без перевода. - **Фильтр по организации** не получает нового параметра: экран сужает `org_ids` до id, выбранного в колонке «ССП», а сервер, как и раньше, пересекает его с разрешёнными (`_allowed_org_ids`). - Альтернатива — `org_name` (точное совпадение по названию, как сейчас на клиенте) — отвергнута. Названия не уникальны по схеме (`org_unit.title` без `unique`), а фильтр по `bf.org_unit_id` уже есть в SQL. - **`total`** — `count(*) OVER ()` в той же выборке, что нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, чтобы узнать `total`: это редкий путь, второго SQL на каждую страницу нет. ### 2. Эндпоинт вариантов фильтров `GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=...` возвращает: ```json { "success": true, "message": null, "result": { "total": 5563, "smeta_type": ["АХР", "КВ", "Операц"], "smeta_direction": ["Поддержка", "Развитие"], "org": [{"id": 12, "name": "…"}] } } ``` - Обёртка — `BaseSingleResponse[Svod2Facets]`. - Варианты считаются по базовому множеству (год + организации) **без** поиска и колоночных фильтров, `total` этого множества — это M в «Найдено N из M». - Права — тот же `_allowed_org_ids`. Если пересечение пусто — нули и пустые списки. - `sheet` проходит ту же валидацию, что и в основном эндпоинте (404 на неизвестный). ### 3. SQL: новая миграция **Файлы.** `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова. Номер — в разделе «Совместимость и порядок PR». **Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется (добавляется столбец), поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку. **Общий отбор `v3.svod2_lines(...)`.** Новая функция: - сигнатура `(p_year int, p_form_types varchar[], p_org_unit_ids int[], p_search text, p_smeta_type varchar, p_smeta_direction varchar)`; - возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`; - тело — `eligible_lines` из 0049 **без изменения условий отбора**, параметризованное списком типов форм (`{FORM_1,FORM_4}` для GO, `{FORM_2}` для RF), плюс три новых предиката. Они пишутся как `(p_x IS NULL OR …)`; - `smeta_direction` — тот же `CASE bl.direction`, что в `header` (0049:197-201). Функцию писать `LANGUAGE sql STABLE` **без** `SET search_path` (имена полностью квалифицированы `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивание не произошло (в EXPLAIN есть `Function Scan on svod2_lines`), это допустимо: множество строк ≤ десятков тысяч узких записей. Решение — `#3` в разделе решений без обсуждения. **Поиск.** Предикат: ```sql strpos(lower(concat_ws(E'\x1f', <поля>)), lower(p_search)) > 0 ``` - Поля: все ключи `header` (`section_code`, `item_id`, `num_group`, `name` = `COALESCE(bl.name, ei.name)`, `expense_item_id`, `justification`, `internal_order`, `vsp_id`, `project_id`, `direction`, `smeta_direction`, `source_form`, `source_id`, `source_org_unit_id`, `org_name`, `smeta_type`), все столбцы `contract_summary` и все столбцы `contract_detail`, кроме `line_id`. Таблицы уже присоединены в отборе как `presence_contract_summary` и `presence_contract_detail` (связь 1:1 по PK `line_id`, `DISTINCT` не размножается). - Разделитель `\x1f` не даёт совпасть «через границу» двух значений; `strpos` не требует экранировать `%` и `_`. - Числа приводятся `::text` (`numeric` → как в jsonb), даты — `::text` (ISO, как в JSON). - Регистр кириллицы: `lower()` зависит от `LC_CTYPE` базы. На стенде (`en_US.utf8`) работает. Интеграционный тест с «аРЕНДА» доказывает это на конкретной базе, риск — в разделе рисков. **Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.** - Аргументы — прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type varchar DEFAULT NULL`, `p_smeta_direction varchar DEFAULT NULL`, `p_compact boolean DEFAULT false`. - Результат — прежние 8 столбцов + `total_count bigint`. Порядок прежних столбцов не меняется: репозиторий выбирает их по имени. - `numbered_lines` строится поверх `v3.svod2_lines(...)` с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`. - `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF (например, `approved` у RF учитывает `ss.id`). - `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END`. - Вызов прежними 6 аргументами (Excel RF, прежний режим API) работает за счёт значений по умолчанию и даёт те же строки, `sort_order` и `data`. **Проекция `v3.svod2_compact(data jsonb) RETURNS jsonb`** (`LANGUAGE sql IMMUTABLE`): `jsonb_strip_nulls(jsonb_build_object(...))` по путям из требования «Компактное представление строки». Это те же пути, что `export_keys` в `summary_repository.py:85-140`, плюс `line_id`, `header.source_form`, `header.source_id`, которые нужны для `getRowId` (`Svod2Page.jsx:397`). - Альтернатива — массив значений без ключей, как `v_svod2_go_export.row_values`: меньше байтов, но фронту пришлось бы перекладывать строки в форму под `valueAt`, а проверка «компакт = проекция полного» стала бы сложнее. Отвергнута (`#4`). - Альтернатива — отдельная сборка компактного `jsonb` прямо из таблиц: быстрее на страницу, но второй экземпляр сложной логики `booking_amount`/`plan_revision_*` (0049:248-284) с риском расхождения. Отвергнута: страница — это сотни строк, сборка полного `jsonb` для неё дешёвая. **Варианты фильтров `v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[])`.** Возвращает строки `(kind varchar, value varchar, org_unit_id int, n bigint)`: - `kind ∈ {'total','smeta_type','smeta_direction','org'}`; - значения считаются по `v3.svod2_lines(p_year, <типы форм по листу>, p_org_unit_ids, NULL, NULL, NULL)`; - `NULL`-значения `smeta_type` и `smeta_direction` не выдаются (`#7`); - форму ответа API собирает репозиторий. **PG15.** Не использовать возможности PG16+: `any_value`, конструкторы SQL/JSON (`JSON_OBJECT`, `JSON_ARRAY`…), `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` должны быть алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `concat_ws`, `strpos` есть в PG15. **`v3.v_svod2_go_export` не трогается.** У неё остаётся своя копия `eligible_lines`. ### 4. Сервис и репозиторий - `SummaryRepository.get_svod2_rows` — без изменений сигнатуры и SQL: прежний режим и Excel RF. - Новые методы: - `SummaryRepository.get_svod2_page(...)` — новые аргументы и `total_count`, возвращает `(rows, total)`; - `SummaryRepository.get_svod2_facets(...)`; - в сервисе `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`. - Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`. - Заголовок `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах, в лог добавляются новые параметры. На этом заголовке держится `api_split.py`. ### 5. Фронт: окно страниц в той же таблице **Константы.** `PAGE_SIZE = 300`, окно — видимая страница ±1 (не больше 3 страниц в данных таблицы), кэш страниц — LRU не больше 5 страниц на вкладку (`#5`). Высота строки берётся из того же источника, что `estimateSize` виртуализатора: - сейчас 37 px; - после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()` из её `createFixedRowVirtualizerOptions`. Окно требует фиксированной высоты строки. Пока 1836 не влита, `measureElement` должен возвращать константу. **Данные таблицы.** Это не весь список, а срез окна `[windowStart, windowEnd)`: - загруженные строки окна плюс заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна; - позиция среза задаётся отступами виртуализатора строк `rowVirtualizerOptions = { ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`; - MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают `paddingStart`/`paddingEnd` (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` всегда стоит на `i * rowH`, а полоса прокрутки соответствует `total`; - объектов строк TanStack (`createRow` создаёт десятки замыканий на строку) не больше размера окна, а не `total`; - объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`) **не править** — это hunk AURORA-1836. Новые опции собираются в компоненте разворотом этого объекта. **Прокрутка.** - `muiTableContainerProps.onScroll`, сглаженный через `requestAnimationFrame`, считает `firstVisible = floor(scrollTop / rowH)` и видимую страницу; - когда видимая страница меняется, пересчитываются окно и данные, а недостающие страницы окна запрашиваются (соседние страницы окна — это и есть упреждающая загрузка); - если видимая область целиком в отступе (быстрый перенос ползунка), виртуализатор рисует строки края среза вне экрана, а пользователь видит пустое место, пока окно не сдвинется на следующем кадре и не придут страницы. Это ожидаемо. **Заглушки.** - `muiTableBodyRowProps` для строки-заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`; - правило в `SVOD2_TABLE_CONTAINER_SX` (`svod2Styles.js`) делает текст ячеек прозрачным и даёт ровный фон. Тогда `Cell`-функции колонок не меняются: на заглушке `valueAt` даёт `undefined` и `'—'`, но его не видно; - `getRowId`: у заглушки `ph-${index}`, у данных — прежний ключ. **Запросы.** В `web/src/api/summary.js`: - `SummaryApi.getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection }, { signal })` — всегда `view=compact`, пустые параметры не передаются; - `SummaryApi.getSvod2Facets(sheet, year, orgIds, { signal })`; - `SummaryApi.get` остаётся для свода 1. **Состояние вкладки** — хук загрузки страниц, например `useSvod2Pages.js` (без JSX). Держит: - `queryKey` (лист, год, организации, поиск, три фильтра); - `generation`; - `total`; - `facets`; - `pages: Map` (LRU); - `inflight: Map`; - `scrollTop`. Чистая логика — окно, сборка среза с заглушками, LRU, параметры запроса из условий, «показывать ли Найдено» — выносится в `web/src/pages/Svod2Page/svod2Paging.js` и тестируется `node --test`. Ответ, пришедший с устаревшим `generation`, отбрасывается; при смене условий текущие запросы отменяются через `AbortController`. **Что вызывает запросы:** - «Загрузить» или «Обновить»: новый `generation`, сброс страниц, прокрутка в начало, параллельно `facets` и страница 0; - смена поиска (после debounce в `SvodFilters`) или колоночного фильтра при загруженных данных: новый `generation`, сброс страниц, прокрутка в начало, только страница 0. Варианты фильтров не перезапрашиваются: они не зависят от поиска и фильтров; - смена года или организаций: как сейчас, подсказка «Нажмите «Загрузить»…» (`:448-455`); - переключение вкладки: состояние другой вкладки (окно ≤ 5 страниц) сохраняется, возврат — без запроса. **Колоночные фильтры.** - `manualFiltering: true`, `enableFacetedValues: false`; - у трёх колонок `filterSelectOptions` из `facets`: строки для `smeta_type`/`smeta_direction`, `{ label: name, value: String(id) }` для `org_name`. Опции подмешиваются в `columns` в `useMemo` поверх `getColumnConfig(sheet)`, кэш конфигурации колонок по листу остаётся; - значение фильтра «ССП» → `org_ids = [id]` вместо организаций из выбора «ССП/РФ» (они уже ограничивают варианты); - сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется. **«Найдено N из M».** Показывается, если данные загружены, загрузка не идёт, `total !== facets.total`: `Найдено ${total} из ${facets.total} строк` в `ru-RU`. **Ошибки.** - первая страница или `facets` — прежний `Alert` с «Повторить» (`:456-468`); - следующие страницы — `toast.error`, страница остаётся незагруженной и запрашивается снова, когда опять попадёт в окно. **Если `total` изменился между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез и отступы пересчитываются. Возможный сдвиг строк между страницами лечит «Обновить» (риск ниже). **`SvodContext` не меняется.** Свод 2 просто перестаёт звать `getSummary`/`invalidateSummary`, поэтому полные ответы в кэше не копятся. Кэш остаётся только у свода 1. **Запасной вариант (`#6`).** Если `paddingStart`/`paddingEnd` в MRT 3.2.1 ведут себя некорректно (проверка — первая задача фронт-PR на живом GO «все»), допускается массив длины `total` с общей заглушкой на незагруженных местах. Память тогда растёт как O(`total`) объектов строк TanStack, критерий памяти проверяется замером. Переход на запасной вариант записывается в этот файл, в раздел решений без обсуждения. ### 6. Стенд `perf/` (PR 3) **`perf/e2e/scenarios/scroll.spec.ts`, только `kind === 'svod2'`.** Код должен работать и со сборкой «до»: заглушек и новых запросов там нет, метрики просто вырождаются. - `waitRows` считает строки `tbody tr:not([data-svod2-placeholder])`. - Новая метрика `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными. - Байты: - `json_bytes_sheet` сохраняет смысл «максимум одного ответа» (у «после» это самая большая страница); - новый `json_bytes_total` — сумма всех ответов по `respRe` за тест; - новый `json_bytes_first` — сумма до появления первых строк. `respRe` свода 2 расширяется на `/facets`. - Новая метрика `placeholder_frame_share` — доля кадров прокрутки, в которых в видимой области контейнера есть заглушка (сэмплер на `requestAnimationFrame` в странице или колбэк шага в `lib/scroll.ts`). Рядом пишется `pages_requested`. - Тест фильтра: после `fill('1')` ждать ответ запроса с `search=` (до 5 с; если запроса нет — это сборка «до» с клиентским поиском), затем `awaitStable`. Новая метрика `filter_result_ms` — от ввода до стабилизации. - Подписи «ССП/РФ», «Все ССП/РФ», «Загрузить» не меняются (UX сохранён). Клики `:194-200` остаются. **Новый `perf/e2e/scenarios/svod2-memory.spec.ts`.** - Роль dfip, свежий контекст на повтор, `PERF_REPEATS` (по умолчанию 3). - Шаги: 1. `/svod/2`, GO «Все ССП/РФ» → «Загрузить» → строки → `heapUsed` (`perf/e2e/lib/cdp.ts:45`) → точка `go`; 2. вкладка RF, «Все ССП/РФ» → «Загрузить» → строки → точка `rf`; 3. по `PERF_SVOD2_MEM_SCROLL=1` (по умолчанию выключено, на масштабе 1 включить) — прокрутка RF до конца и обратно → точка `rf_scrolled`; 4. вкладка GO → строки видны → точка `final`. - Метрики: `heap_used_mb` по точкам, `nodes` (`domCounters`), `first_rows_ms` обеих загрузок. Запись `scenario: "svod2-memory"`, агрегирует `perfkit.aggregate`. **`perf/analysis/api_split.py`.** - Прежние строки (полные GO/RF) остаются: это проверка «прежний режим не медленнее». - Добавляются: - GO/RF `?view=full&offset=0&limit=300`; - GO/RF `?view=compact&offset=0&limit=300`; - GO/RF `/facets`; - RF `?view=compact&offset=0&limit=300&search=1`. **`perf/analysis/explain_manual.py`.** Новые записи: - `svod2_go_page` — `SELECT … FROM v3.v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, true)`; - `svod2_rf_page`; - `svod2_rf_search` (`p_search='1'`); - `svod2_go_facets`. Варианты — по умолчанию и `.rerun_default`. Они показывают, сколько стоит отбор строк в каждой странице. **`perf/analysis/scale_compare.py`** (`:43-44`): разделить ключи на «GET свод 2 {sheet}» (прежний полный), «… страница» (в query есть `limit`) и «… facets». **`perfkit.bench`.** После 1843 сетевой лог содержит десятки URL страниц, и бенч повторит каждый. На масштабе 1 это около 50 URL × 20 повторов × ~0,3 с ≈ 5 мин; на масштабе 2 — в ~4 раза больше. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`. **Новый `perf/analysis/svod2_paging_check.py`** — сверка на данных стенда, только чтение. Под dfip и admin для GO и RF: - склейка всех компактных страниц совпадает по `line_id` и порядку с прежним полным ответом и равна его проекции; - `total` равен длине полного; - для поисков (`аренда`, `АРЕНДА`, `1`, подстрока названия организации) и для каждого значения трёх фильтров `total` и состав совпадают с фильтром по полному ответу на Python, повторяющим требование «Серверный поиск»; - `/facets` совпадает с вычисленным по полному ответу. Запускается перед замером «после». ## Совместимость и порядок PR **Стек.** `test` ← … ← AURORA-1835 (PR #217) ← AURORA-1842 (PR #227) ← 1843: - PR 1 `feat/236-…` — сервер; - PR 2 — фронт; - PR 3 — perf. Каждый PR зелёный сам по себе: - после PR 1 старый фронт работает (прежний режим не изменился); - PR 2 использует только API из PR 1; - PR 3 меняет только `perf/`. **Номер миграции.** В ветке голова — `0049`. Номер `0050` уже заняли AURORA-1831 (`0050_project_technical_number_lock`), AURORA-1844 (`0050_upd_form_cells_full_rows`) и AURORA-1846 (`0050_form4_sheet_sum`), между собой они перенумеруются при слиянии. 1843 берёт рабочий номер `0053_svod2_server_paging` (после 0050–0052 этих трёх). При каждом rebase PR 1 нужно: - проверить `alembic heads` (должна быть одна голова); - поставить номер «голова + 1»; - переименовать `.py` и `.sql`; - поправить `revision`, `down_revision` и имя файла в `run_sql_file_migration`; - перенацелить `test_summary2_related_records_sql.py` на новое имя. Два head ломают `alembic upgrade head`, а при `DEBUG=false` — и старт приложения. **AURORA-1836 (PR #222, от `test`).** Правит `Svod2Page.jsx`: - `:26-31`: `ROW_HEIGHT` → `2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3; - `muiTableBodyRowProps.style.height`. 1843 эти строки не трогает. Новые опции виртуализатора собираются в компоненте из `ROW_VIRTUALIZER_OPTIONS`, а у `muiTableBodyRowProps` 1843 добавляет класс и атрибут заглушки. Это функция-объединение: при rebase слить со `style.height` из 1836. Высота строки для окна должна идти из того же источника, что `estimateSize`. После rebase — из 1836, иначе отступы разойдутся с позициями строк: 37 против 44 px. Если 1836 вольётся в `test` раньше замера, базу «до» переснять на ребейзнутой вершине 1842. **AURORA-1848** (тяжёлые GET без pydantic, ещё не начата) переписывает тот же `get_summary2` на готовый JSON из SQL. Договорённость: - кто вливается вторым, сохраняет оба режима 1843; - для страниц (сотни строк) выигрыш 1848 мал; - для прежнего режима (Excel RF не через HTTP, бенч, verify) 1848 вправе менять упаковку только с сохранением JSON по смыслу, как требует сама 1848. Требование «байт-в-байт» из 1843 относится к состоянию до 1848. **AURORA-1917 (O-5).** Исправление отбора RF по `is_ssp` уменьшит ответ RF и число строк. Его нельзя вливать между замером «до» и «после» 1843: либо до базового замера (тогда «до» переснимается), либо после «после». Если 1917 придёт раньше, правка отбора ляжет в `v3.svod2_lines` (одно место) и в `v_svod2_go_export`. **Выкатка и откат.** Миграция только заменяет функции и добавляет новые, данных не трогает. `downgrade()` в проекте пустые: откат PR 1 — новой миграцией, которая восстанавливает тела из 0049. Откат PR 2 безопасен сам по себе. ## Тестирование (что доказывает корректность) **PR 1, `api/tests/unit`:** - валидация параметров через `TestClient` без БД (по образцу `test_query_validation.py`): 422 на `search` > 200, на неизвестные `smeta_type`/`smeta_direction`, на прежние невалидные `offset`/`limit`/`sort_*`; - сервис: `_allowed_org_ids` применяется к страницам и вариантам; пустое пересечение даёт `[]`/0 без вызова SQL; второй вызов за `total` при пустой странице и `offset > 0` (моки репозитория, по образцу `test_export_service_query_params.py`); - выбор режима: без новых параметров зовётся `get_svod2_rows`, и в ответе нет `total`; - `test_summary2_related_records_sql.py` перенацелить на новую миграцию: отбор из `v3.svod2_lines` для GO (`FORM_1`, `FORM_4`) и RF (`FORM_2`), те же проверки, что для 0049. Добавить: поиск находит по `contract_summary`, `contract_detail` и `header`; разделитель не даёт совпасть через границу значений; фильтры `smeta_type`/`smeta_direction`. **PR 1, `api/tests/integration`.** - Фикстура (рядом с `client`) засевает в той же откатываемой сессии данные свода 2 за год, которого нет в `fixture.sql`: - 3 организации (одна с не уникальным `title`); - FORM_1, FORM_4 и FORM_2; - все три `smeta_type`, оба направления и `direction = NULL`; - `contract_summary` и `contract_detail` с кириллицей разного регистра; - строки с `plan_q1 = 0`; - пользователь-исполнитель с одной организацией. - Оракул — прежний полный ответ: - склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом; - `search` (`аРЕНДА`, подстрока контрагента, цифра) и каждый фильтр совпадают с фильтром полного ответа на Python по требованию «Серверный поиск»; - `view=compact` — проекция `view=full` без `null`, нули на месте; - `/facets` совпадает с вычисленным по полному; - исполнитель не видит чужую организацию ни в страницах, ни в вариантах; - прежний режим — ключи как раньше и без `total`; существующие тесты `test_summary_api_smoke.py` проходят без правок. - Весь набор `pytest` (зелёный только целиком): PG16 на стенде (отдельная БД `budget_test`, см. P4 «Стенд для тестов») плюс проверка контроллером на PG15.12. **PR 1, байт-в-байт на данных стенда.** До и после миграции снять `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, «все» и одна организация, `offset=0&limit=50`) и Excel RF. Сравнить sha256 распакованных тел; для xlsx — `openpyxl`-значения всех ячеек. **PR 2, `node --test web/src/pages/Svod2Page/*.test.js`:** - окно по `scrollTop`/`rowH`/`total` (край начала, края конца, `total < PAGE_SIZE`, `total = 0`); - сборка среза: число заглушек на последней неполной странице, отступы `paddingStart`/`paddingEnd`, сумма = `total * rowH`; - LRU не держит больше 5 страниц и не выбрасывает страницы окна; - параметры запроса: пустые не передаются, фильтр «ССП» заменяет `org_ids`, `view=compact` всегда; - отбрасывание ответа устаревшего `generation`; - «показывать ли Найдено»; - существующий `svod2Styles.test.js` зелёный. **PR 2, руками на стенде** (GO и RF, admin и dfip): - «Все» и выбор организаций, «Загрузить»/«Обновить»; - перенос ползунка в середину и в конец (номера строк по порядку, последняя строка у нижнего края); - поиск, три фильтра, их сочетание, «Найдено N из M»; - быстрый ввод с последующим выбором фильтра (без мигания старого результата); - переключение вкладок, единицы, «Перейти к задачам»; - горизонтальная прокрутка (закреплённые колонки, полосы, группы); - ошибка страницы (остановить API во время прокрутки); - скриншоты «до/после» первого экрана и трёх горизонтальных позиций. **PR 3.** - Сценарии сначала прогнать с `PERF_REPEATS=1` на сборке «до» и «после», чтобы метрики писались, а `incomplete` не появлялось. - `svod2_paging_check.py` — зелёный на масштабах 1 и 2. ## Замер до/после **Базы:** - «до» — вершина AURORA-1842 (`399234a` или её ребейзнутая версия, если 1836 вольётся раньше); - «после» — вершина PR 3. Код сценариев в обоих прогонах один и тот же — из PR 3. Для «до» — временный worktree на вершине 1842 и `git checkout <ветка PR 3> -- perf/` в нём, без коммита. **Порядок на одних данных:** 1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` (~12 мин) → `verify`. Своды мерить до `grow`: `grow` доращивает AHR, который попадает в своды. 2. Скопировать `results/manifest-s1.json` в оба worktree; при желании снять `pg_dump -Fc`. 3. «До»: в worktree 1842 + perf из PR 3 поднять `api`, `web-build`, `web`. 4. Остановить `api`/`web`. 5. «После»: в worktree PR 3 `alembic upgrade head` поверх тех же данных, поднять `api`, `web-build`, `web`, прогнать `svod2_paging_check.py`, затем замеры. 6. То же для масштаба 2: `reset` + генерация ~45–70 мин. **Между «до» и «после» не вливать AURORA-1917 и AURORA-1836** (или переснять «до» после rebase). **Команды** (из `perf/e2e`, затем `cd .. && .venv/bin/python -m perfkit.aggregate results/`): | что | команда | время на сторону (оценка) | |---|---|---| | скролл + время до первых строк | `PERF_RUN_ID=1843--s PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` (RF не помещается в 5 000 шагов, REPORT раздел 6) | s1: «до» ~35 мин, «после» ~15 мин; s2: ×3–4 | | фильтр | тот же запуск с `--grep filter` | ~5 мин | | память | `PERF_RUN_ID=1843-mem--s PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 только на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин «до» | | байты и время API | `.venv/bin/python analysis/api_split.py --scale ` | ~3–5 мин | | планы SQL | `.venv/bin/python analysis/explain_manual.py --scale --out results/1843-explain-s` | ~3 мин | **Пороги** — требования в `specs/`: | метрика | порог | |---|---| | `heap_used_mb` в точке `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 | | `first_rows_ms` | ≤ 0,5 × «до», GO и RF, s1 и s2 | | `bytes(compact)/bytes(full)` той же страницы | ≤ 0,40 | | первая страница + facets | ≤ 5 % прежнего полного ответа | | прежний полный запрос | медиана полного времени ≤ 1,10 × «до» | | `main_thread_ms_per_step` (s1) | ≤ 1,10 × «до» (результат 1842); рядом `placeholder_frame_share` | Если `placeholder_frame_share` > 5 %, цену шага «после» считать заниженной: заглушки дешевле строк. Разобрать, успевает ли догрузка (`explain_manual`: стоимость страницы), и при необходимости увеличить упреждение (окно ±2). Результаты записать в PR 3 и в отчёт задачи: таблица «до/после» по масштабам, ссылки на `results/…`. ## Риски / компромиссы - **[Каждая страница заново выполняет полный отбор строк — 13 LEFT JOIN + `DISTINCT` по году]** → Время страницы ≈ время отбора, а не сборки `jsonb`. `explain_manual` (`svod2_*_page`) показывает его отдельно. Упреждающая загрузка соседних страниц прячет задержку при обычной прокрутке. Если страница на масштабе 2 дольше ~1 с, следующий шаг — переписать отбор на `EXISTS`-полусоединения с тем же смыслом (сверка — тот же unit-тест отбора и `svod2_paging_check.py`) отдельным коммитом PR 1. - **[Без новых параметров поиск и фильтры добавляют работу и замедляют прежний ответ]** → Предикаты вида `(p IS NULL OR …)`. Порог +10 % в `api_split` ловит регресс. - **[`lower()` не переводит кириллицу при `LC_CTYPE=C` на проде]** → Интеграционный тест с «аРЕНДА» падает на такой базе. Обход — `lower(x COLLATE "und-x-icu")`, если PG собран с ICU. Узнать `LC_CTYPE` прод-базы до выкатки PR 1 (вопрос к эксплуатации, ответ не меняет дизайн). - **[Данные меняются между запросами страниц: строки сдвигаются, на стыке страниц строка может повториться или пропасть]** → Свод — просмотр, «Обновить» даёт согласованный снимок. `total` обновляется по последнему ответу. Ключ строки (`source_form-source_id-line_id`) не даёт React смешать строки. - **[Сдвиг среза отступами в MRT ведёт себя иначе, чем ожидается: прыжки, пустоты]** → Проверка в первой задаче PR 2. Запасной вариант `#6` — массив длины `total`. - **[Окно опирается на фиксированную высоту строки, а 1836 меняет её с 37 px на 2,75 rem]** → Единый источник высоты. Тест суммы отступов. Чек-лист rebase выше. - **[Замер шага «после» занижен заглушками]** → Метрика `placeholder_frame_share`, порог 5 % (см. «Замер»). - **[Тест фильтра свода 2 «стабилизируется» до ответа сервера (первая мутация — индикатор загрузки)]** → Ожидание ответа с `search=` перед `awaitStable`. - **[Варианты фильтров не учитывают поиск: можно выбрать значение, которое с поиском даёт 0 строк]** → Таблица покажет «Нет данных для выбранных параметров». Сейчас варианты учитывают поиск, это небольшое изменение UX (`#7`). - **[Поиск по техническим id (`source_id`, `vsp_id`, `expense_item_id`) находит «лишнее», например по «1»]** → Так ищет и сейчас, поведение сохранено. Сужение полей поиска — отдельный вопрос бизнеса. - **[`perfkit.bench` повторяет каждый URL страницы]** → `--only`/`--repeats`, описание в README. ## Что оставлено исполнителю - Имена моделей ответа, методов сервиса и репозитория, хука и файла чистой логики (предложенные выше — ориентир). - Как именно вернуть `total` при пустой странице (второй вызов или `LEFT JOIN` к счётчику) — при условии одного SQL на обычную страницу. - Оформление заглушки (фон, «бегущая» полоса или ровный цвет) — при прозрачном тексте и той же высоте. - Где сэмплировать `placeholder_frame_share`: в пробе страницы или колбэком шага `scrollThrough`. - Точные значения `PAGE_SIZE` (200–500) и окна (±1…±2), если замер покажет выигрыш. Изменение записать в решения без обсуждения. ## Decisions made without discussion 1. **2026-10-28 — новый режим API включается наличием любого из новых параметров, в том числе явного `view=full`. Поле `total` есть только в новом режиме.** Обоснование: требование «без новых параметров ответ байт-в-байт прежний». `limit` — прежний параметр, привязать `total` к нему значило бы изменить прежний ответ. Цена ошибки: если нужен `total` для прежнего полного вида, его даёт `view=full` — дёшево. 2. **2026-10-28 — фильтр «ССП» передаётся сужением `org_ids` до id организации, а не параметром `org_name`. Значения `smeta_type`/`smeta_direction` в API — подписи с экрана (`АХР`, `Поддержка`…), а не коды БД.** Обоснование: `org_unit.title` не уникален, `org_ids` уже есть и проверяется на права. Подписи совпадают с `data.header` и вариантами — перевод не нужен. Цена ошибки: при переименовании подписей в SQL нужно править валидацию API — одна строка. 3. **2026-10-28 — общий отбор строк выносится в `v3.svod2_lines` (одна копия условий для страниц GO/RF и вариантов), без `SET search_path`, чтобы функция могла встраиваться. `v_svod2_go_export` сохраняет свою копию.** Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами. Цена ошибки: если встраивание не срабатывает и отбор заметно дорожает, вернуть копии в каждую функцию — час работы. Сравнение — `explain_manual`. 4. **2026-10-28 — компактный вид — та же вложенная форма `data`, только нужные пути, без ключей `null` (`jsonb_strip_nulls`). Строится проекцией полного `jsonb` в SQL (`v3.svod2_compact`), а не массивом значений и не отдельной сборкой из таблиц.** Обоснование: фронт читает те же пути (`valueAt`); `null` и отсутствие ключа отображаются одинаково (`formatValue`, `textColumn`); у свода 2 нет WS-слияния, которое зависело бы от явных `null` (зависимость из 1848 — только у `RealtimeTable`). Проверка «компакт = проекция полного» тривиальна. Ожидаемо ~1–1,5 КБ на строку против ~5,5 КБ. Цена ошибки: если 40 % не набирается, переход на массив значений меняет только `svod2_compact` и маппинг на фронте. 5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU не больше 5 страниц на вкладку.** Обоснование: 300 строк — около 8 экранов и примерно 0,3–0,45 МБ компактного JSON. На шаг прокрутки 200 px граница страницы приходится примерно раз в 55 шагов, упреждения на соседнюю страницу хватает при ответе < 2–3 с. В памяти ≤ 1 500 строк данных и ≤ 900 строк в таблице. Цена ошибки: подбор констант по замеру, правка двух чисел. 6. **2026-10-28 — окно реализуется срезом данных и отступами `paddingStart`/`paddingEnd` виртуализатора строк. Запасной вариант — массив длины `total` с заглушками.** Обоснование: только срез даёт память O(окна) и для объектов строк TanStack, а не только для данных. Накопительная бесконечная прокрутка отвергнута: после прокрутки до конца в памяти снова весь ответ, что противоречит решению 28.10. Цена ошибки: если срез не заработает с MRT 3.2.1, запасной вариант дороже по памяти (десятки МБ объектов строк на масштабе 2) — порог памяти может не выполниться. Тогда это вопрос к человеку. 7. **2026-10-28 — варианты фильтров считаются по году и организациям без учёта поиска и других фильтров. `NULL`-значения в варианты не попадают.** Обоснование: один запрос вариантов на «Загрузить» вместо запроса на каждое изменение. M в «Найдено N из M» — тот же ответ. Цена ошибки: если бизнесу нужны «перекрёстные» варианты, `facets` получает те же `search`/фильтры, а фронт перезапрашивает его при изменениях — полдня. 8. **2026-10-28 — поиск ищет и в тех полях `contract_summary` (`counterparty`, `reference`, `deadline`, `comment`), которые на клиенте сейчас фактически не ищутся: спред `{...contract_summary, ...contract_detail}` (`Svod2Page.jsx:284-288`) перекрывает их одноимёнными полями `contract_detail`. Числа сравниваются по тексту из БД (`1234.50`), а не по JS-записи (`1234.5`).** Обоснование: «по тем же полям» понимается как «по тем же группам полей»; повторять перекрытие — значит повторять ошибку. Цена ошибки: поиск находит немного больше строк, чем раньше; сузить — одна строка SQL. 9. **2026-10-28 — «Найдено N из M» считает поиск и колоночные фильтры вместе (раньше — только поиск).** Обоснование: N приходит с сервера одним `total` по всем условиям, отдельно «только поиск» пришлось бы считать вторым запросом. Цена ошибки: подпись иначе ведёт себя при одном колоночном фильтре без поиска. Вернуть прежнее — дополнительный `total` без фильтров. 10. **2026-10-28 — пороги успеха:** - память s1 ≤ 30 % «до», s2 ≤ 1,25 × s1; - время до первых строк ≤ 50 % «до»; - компакт ≤ 40 % полного; - первая страница + варианты ≤ 5 % полного ответа; - прежний режим не медленнее +10 %; - шаг прокрутки ≤ 1,10 × результата 1842; - заглушки в кадре ≤ 5 %. Обоснование: оценки по REPORT (М2, М9, М10). Перечисленные пороги консервативны, ожидаемый эффект больше. 10 % — шум при n=2. Цена ошибки: порог окажется недостижим или слишком слаб — пересмотр по фактическим цифрам с человеком, код не меняется. 11. **2026-10-28 — стенд: код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`. `json_bytes_sheet` сохраняет смысл «максимум одного ответа», суммарные байты — новые метрики.** Обоснование: сопоставимость метрик и прежних отчётов. Цена ошибки: при расхождении кода сценариев «до» нужно переснять — около часа на масштабе 1. 12. **2026-10-28 — рабочий номер миграции `0053`; окончательный — «голова `test` + 1» при rebase.** Обоснование: 0050 занят тремя PR, их порядок неизвестен. Цена ошибки: переименование двух файлов и правка тестов. 13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2 в откатываемой сессии, а не полагаются на `fixture.sql`.** Обоснование: в фикстуре нет ни одной строки, проходящей отбор свода 2 за 2026 год. Тесты со `skip` при отсутствии данных ничего не доказывали бы. Цена ошибки: нет.