Andrey Epifancev 6e607b1647 docs(openspec): aurora-1843-svod2-server-paging — proposal, spec, design
Свод 2: серверная постраничная загрузка (поиск, три фильтра, total,
варианты фильтров) и компактный ответ; фронт догружает строки окном
страниц; замеры памяти, времени до первых строк, байтов и шага скролла.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:27:17 +04:00

510 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<page, AbortController>`;
- `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/<run>`):
| что | команда | время на сторону (оценка) |
|---|---|---|
| скролл + время до первых строк | `PERF_RUN_ID=1843-<before\|after>-s<N> 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-<before\|after>-s<N> 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 <N>` | ~3–5 мин |
| планы SQL | `.venv/bin/python analysis/explain_manual.py --scale <N> --out results/1843-explain-s<N>` | ~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` при отсутствии данных ничего не доказывали бы.
Цена ошибки: нет.