733 lines
87 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` ниже даны по вершине `399234a` — это вершина AURORA-1842, база ветки `feat/236-aurora-1843-svod2-server-paging`.
## Контекст: как устроено сейчас
### 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 аргументов)`. Числа `jsonb` asyncpg разбирает через `json.loads` во `float`/`int`, затем pydantic сериализует их обратно в JSON. Экран получает значения как `Number` двойной точности: `1234.50` в БД превращается в `1234.5`.
### 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()` (`:151-168`).
3. `page AS MATERIALIZED` с `LIMIT`/`OFFSET` (`:169-173`).
4. Сборка большого `jsonb` только для строк страницы (`:179-302`).
Значит, стоимость страницы — полный отбор плюс сборка `jsonb` только для этой страницы.
В листе RF `contract_detail` в ответе — это `to_jsonb(cd)`, дополненный `expenses_q1..4 = COALESCE(cd.expenses_qN, ck.expenses_qN)` (`:543-547`). В GO — просто `to_jsonb(cd)`.
`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`);
- `api/tests/integration/test_summary_api_smoke.py:44-140`;
- стенд: `perfkit.gen.verify` (`verify.py:146-148`), `perfkit.bench`, `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`.
### Фронт: точная текущая семантика
`web/src/pages/Svod2Page/Svod2Page.jsx` и MRT 3.2.1 с TanStack Table 8.20.5.
**Поиск — `matchesSearch` (`:283-294`).**
- Значения берутся из `Object.values({ ...row.data?.header, ...row.data?.contract_summary, ...row.data?.contract_detail })` и проверяются как `String(value ?? '').toLowerCase().includes(query)`.
- Запрос — `search.trim().toLowerCase()`; пустой не фильтрует (`:387-391`).
- Фильтрация идёт **до** таблицы: в MRT уходят уже отфильтрованные `filteredRows`.
- Ввод с задержкой 300 мс (`SvodFilters.jsx:68-77`).
- Из-за слияния объектов поля `contract_summary`, одноимённые полям `contract_detail` (`counterparty`, `reference`, `deadline`, `comment`), перекрываются и **не ищутся**. Это находка, см. раздел «Найденные странности».
**Колоночные фильтры** — три `filterVariant: 'select'` (`:158-176`): `smeta_type`, `smeta_direction`, `org_name`.
- Фильтр MRT `equals` (`material-react-table/dist/index.esm.js:663-667`): `row.getValue(id)?.toString().toLowerCase().trim() === filterValue.toString().toLowerCase().trim()`. Значение `null` не проходит, пустое значение фильтра снимает фильтр (`autoRemove`).
- Фильтр «ССП» сравнивает **названия**: у двух организаций с одинаковым `title` один вариант, и он отбирает обе.
**Варианты фильтров** — `enableFacetedValues: true` (`:400`).
- TanStack `getFacetedRowModel` (`@tanstack/table-core/build/lib/utils/getFacetedRowModel.js`) берёт строки таблицы (уже отфильтрованные поиском) и применяет фильтры всех колонок, **кроме своей**.
- `getFacetedUniqueValues` считает число строк на каждое точное значение.
- MRT (`useDropdownOptions`, `index.esm.js:203-218`) отбрасывает `null` и `undefined`, сортирует `a.localeCompare(b)` и показывает число в скобках (`:3227-3228`), но только когда у колонки нет `filterSelectOptions`.
**«Найдено N из M» (`:502-506`).** N = `filteredRows.length` (только поиск, без колоночных фильтров), M = `rows.length`. Подпись показывается при `hasCurrentData && !loading && N !== M`.
**Сброс колоночных фильтров.** Изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`).
**Прокрутка.** Контейнер таблицы один на обе вкладки. Положение прокрутки программно не меняется ни при смене вкладки, ни при поиске, ни при «Загрузить» (в `useMRT_Effects` сброса тоже нет). При уменьшении данных браузер сам ограничивает `scrollTop`.
**Загрузка.**
- Без «Загрузить» данных нет (`:448-455`).
- «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`).
- `loading` выставляется только при «Загрузить»/«Обновить» (`:335-367`).
- Ошибка — `Alert` с «Повторить» (`:456-468`).
- Ответы кэшируются в `SvodContext` (`SvodContext.jsx:46-70`, ключ «лист + год + организации», очищается при уходе с маршрута `/svod/2`, `Routes.jsx:54-59`).
- `SvodContext` и `SvodFilters` общие со сводом 1.
**Виртуализация.** Строки — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: 37 px, `measureElement` по `offsetHeight`), колонки — из 1842 (`:33-34`, `:407-408`).
**Кнопки Excel-выгрузки свода 2 на фронте нет.**
### Стенд
`perf/e2e/scenarios/scroll.spec.ts`:
- кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`);
- ждёт `MIN_ROWS` строк `tbody tr`;
- `json_bytes_sheet` — максимум байтов одного ответа (`:176-184`);
- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`:113-150`, `:221-227`);
- время до первых строк не пишется.
Сценария памяти для сводов нет. `perf/analysis/explain_manual.py` функции свода 2 не использует (`:59-81`).
### Тестовые данные
В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2.
## Цели / не цели
**Цели:**
- память страницы свода 2 ограничена окном строк;
- первые строки появляются за время одной-двух страниц;
- меньше байтов на строку;
- поиск и фильтры не обходят все строки в браузере;
- для пользователя ничего не меняется, кроме ожидания ответа сервера;
- прежние потребители API не замечают изменения.
**Не цели:**
- менять отбор строк свода 2 (O-5, AURORA-1917);
- менять семантику поиска или фильтров, включая исправление перекрытия полей поиска;
- адаптировать Excel-выгрузку под экранные фильтры;
- серверная сортировка;
- отказ от pydantic на строку (AURORA-1848);
- индекс для поиска;
- изменения свода 1 и `SvodFilters`.
## Решения (согласовано 28.10)
1. Вместо аналитики — реализация, варианты C + B черновика; вариант A не нужен.
2. **Сервер:**
- у существующих `offset`/`limit` появляются серверный поиск, три фильтра (`smeta_type`, `smeta_direction`, организация — `org_name`/`org_ids`) и поле `total`; `count` сохраняет прежний смысл;
- отдельный лёгкий запрос вариантов фильтров (facets);
- компактный ответ включается параметром (`view=compact`);
- без новых параметров ответ байт-в-байт прежний.
3. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет — выгрузку под экранные фильтры не подгоняем.
4. **Фронт:**
- та же таблица, строки догружаются страницами при прокрутке, в памяти окно плюс запас;
- поиск и фильтры уходят на сервер (debounce 300 мс уже есть);
- кэш `SvodContext` перестаёт копить полные ответы.
5. **SQL:** новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и `scroll.spec.ts` обновляются вместе с UX, чтобы замер оставался сопоставимым.
6. **Три PR в стеке:** сервер, фронт, perf.
7. **AURORA-1917 (O-5)** — отдельная задача, её нельзя вливать между замерами «до» и «после».
8. **Метрики:**
- память после GO «все» → RF «все» → GO (новый `svod2-memory.spec.ts`);
- время до первых строк;
- байты ответа;
- шаг прокрутки не хуже 1842;
- масштабы 1 и 2.
9. **Поведение свода 2 для пользователя остаётся абсолютно тем же:**
- варианты фильтров считаются как сейчас: с учётом поиска и фильтров других колонок, без своего, с числами строк;
- поиск — ровно семантика `matchesSearch`, включая перекрытие полей `contract_summary` (они не ищутся) и приведение чисел к тексту, как в JS;
- «Найдено N из M» — только по поиску;
- ввод в поиск, смена года или организаций сбрасывают колоночные фильтры.
10. Поиск без учёта регистра — **независимо от локали БД**: `LC_CTYPE` прода неизвестен.
11. **Пороги производительности не являются условием слияния.** PR вливается по зелёным тестам и доказанной корректности, цифры приводятся в PR, недобор уходит в follow-up. Жёсткие условия корректности:
- склейка страниц равна полному списку;
- варианты фильтров совпадают с вычисленными по полному ответу;
- поиск и фильтры совпадают с экраном до изменения;
- прежний режим байт-в-байт.
## Решения (согласовано 29.10)
12. **Строки, чья страница ещё не пришла, показываются серыми полосами загрузки (MUI Skeleton, тот же вид, что при «Обновить»), а не пустыми.** То же при смене поиска, фильтра или вкладки: до первого ответа нового поколения старые строки не рисуются. Повод — O-7 ночного прогона 29.10 (пустые строки при прыжке ползунком). Коммиты eef358c, 1b8a1a4.
13. **Серверный поиск ускоряется до слияния: время поиска не больше 1,5× загрузки первой страницы без поиска на том же листе (масштаб 2, серверное время).** Повод — I-1 (#39): RF «аренд» 4,7 с при первой странице 0,74 с. Коммит c8e957b: GO ≤ 1,36×, RF ≤ 1,44×; оракул поиска и paging_check — CHECK_OK.
## Решения по устройству
### 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` | без ограничения длины |
| `smeta_type` | `str \| None` | без ограничения длины |
| `smeta_direction` | `str \| None` | без ограничения длины |
| `org_name` | `str \| None` | без ограничения длины |
Значения фильтров — свободные строки, а не перечисления: семантика `equals` (регистр и пробелы по краям не важны) принимает и «кв», и «КВ ». Неизвестное значение даёт 0 строк, как на экране.
**Новый режим** включается, когда хотя бы один новый параметр не `None` (в том числе явный `view=full`). Ответ в нём — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). Прежний режим — тот же `BaseListResponse[SheetResponse]`. Аннотация возврата задаёт `response_model` и отрезала бы `total`, поэтому у обработчика нужно явно указать `response_model` (`Union` или `None`) и возвращать модель по режиму. Тест «в прежнем режиме нет `total`» обязателен.
**Фильтр «ССП»** — по `org_name` с семантикой `equals`, как сейчас: одинаковые названия отбираются вместе. Сужение `org_ids` до id отвергнуто: оно изменило бы поведение при одинаковых названиях. `org_ids` остаётся выбором «ССП/РФ» с проверкой прав.
**`total`** считается как `count(*) OVER ()` в выборке, которая нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, только чтобы узнать `total`.
### 2. Эндпоинт вариантов фильтров
`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=&search=&smeta_type=&smeta_direction=&org_name=` возвращает:
```json
{
"success": true,
"message": null,
"result": {
"total": 5563,
"search_total": 300,
"smeta_type": [{"value": "АХР", "count": 120}],
"smeta_direction": [{"value": "Поддержка", "count": 80}],
"org_name": [{"value": "…", "count": 12}]
}
}
```
- `total` — по году и `org_ids` (M);
- `search_total` — плюс поиск (N);
- варианты каждой колонки — по году, `org_ids`, поиску и фильтрам двух других колонок, значения различаются точно, `null` не выдаётся;
- порядок значений в ответе не важен: экран сортирует их так же, как MRT (`localeCompare`).
Права те же — через `_allowed_org_ids`; при пустом пересечении все нули и пустые списки. Неизвестный `sheet` — 404.
### 3. Семантика поиска и фильтров в SQL
**Регистр без зависимости от локали — `v3.svod2_casefold(text) RETURNS text`** (`LANGUAGE sql IMMUTABLE`): `translate(x, <прописные>, <строчные>)` по явной таблице.
- В таблице:
- ASCII `A-Z`;
- кириллица `А-Я` (U+0410–U+042F) → U+0430–U+044F;
- `Ѐ-Џ` (U+0400–U+040F, в том числе Ё, Є, І, Ї, Ў) → U+0450–U+045F;
- `Ґ` (U+0490) → U+0491;
- латиница-1 U+00C0–U+00DE, кроме `×` (U+00D7) → +0x20.
- `lower()` не используется: в `LC_CTYPE=C` он не трогает кириллицу, а в других локалях добавил бы преобразования, которых нет в таблице.
- Для символов из таблицы результат совпадает с JS `toLowerCase()`. Для прочих заглавных букв (греческие, расширенная латиница, особые случаи вроде `İ`) результат расходится, когда они есть в данных или запросе. В данных свода их не ожидается.
- Отвергнуты:
- `COLLATE "und-x-icu"` — PG может быть собран без ICU;
- `lower()` — зависит от `LC_CTYPE`.
**Обрезка как в JS — `v3.svod2_js_trim(text)`**: `btrim` по явному набору символов ECMAScript `WhiteSpace` и `LineTerminator`:
- U+0009, U+000A, U+000B, U+000C, U+000D, U+0020, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF.
Запрос поиска нормализуется на сервере в SQL так: `svod2_casefold(svod2_js_trim(p_search))`. Значения фильтров нормализуются как `svod2_js_trim(svod2_casefold(x))` — порядок как в MRT `equals`: сначала нижний регистр, потом обрезка. Пустой результат условия не накладывает.
**Число в записи JS — `v3.svod2_js_number_text(double precision) RETURNS text`** (`IMMUTABLE`, `SET extra_float_digits = 1`).
- Цифры берутся из `x::text`: начиная с PG12, при `extra_float_digits > 0` это кратчайшая точная запись (Ryu), те же цифры, что у V8. `SET` в функции нужен, чтобы настройка сессии или сервера (`extra_float_digits <= 0` даёт `%.15g`) не влияла на результат.
- Из записи PG (`1234.5` или `1.5e+16`, `1e-05`) извлекаются цифры `s` (k штук) и десятичный показатель `n`. Затем применяется правило ECMAScript `Number::toString`:
- если `k ≤ n ≤ 21` — цифры и `n−k` нулей;
- если `0 < n ≤ 21` — цифры с точкой после `n`-й;
- если `−6 < n ≤ 0` — `0.`, `−n` нулей и цифры;
- иначе — экспоненциальная запись `d[.ddd]e±(n−1)`;
- отдельно знак минус и ноль `0`.
- Вход — `jsonb`-число из ответа, приведённое к `float8`. Это то же округление до двойной точности, что `json.loads` + `JSON.parse`. Целые больше 2^53 округляются так же, как в браузере.
- Корректность доказывается таблицей векторов. Набор значений (целые, дроби с хвостовыми нулями, `0.1+0.2`-подобные, 1e-7, 1e-6, 1e15, 1e21, большие целые, отрицательные) прогоняется через `String(Number(x))` в Node.js, результат коммитится как `api/tests/fixtures/js_number_text_vectors.json`. Интеграционный тест сверяет SQL-функцию со всеми векторами.
**Текст значения для поиска** зависит от `jsonb_typeof`:
| тип | текст |
|---|---|
| `string` | `#>> '{}'` |
| `number` | `svod2_js_number_text((v #>> '{}')::float8)` |
| `boolean` | `true`/`false` |
| `null` | пустая строка (никогда не совпадает с непустым запросом) |
**Набор значений для поиска** строится из **тех же выражений, что и `data`** ответа, но только для трёх групп и только при непустом поиске:
- `header_j` — тот же `jsonb_build_object` заголовка, что в 0049:191-203 / 489-501;
- `cs_j = COALESCE(to_jsonb(cs) - 'line_id', <пустая секция>)`;
- `cd_j` — как в ответе листа: для RF с `expenses_qN = COALESCE(cd, ckk)` (решение контроллера `#16`).
Объединение — `header_j || cs_j || cd_j`. Оператор `||` у `jsonb` перекрывает одноимённые ключи правым операндом, ровно как `{...a, ...b}` в JS, и перекрытые поля `contract_summary` пропадают так же. Предикат:
```sql
EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e
WHERE strpos(svod2_casefold(<текст e.value>), q) > 0)
```
Соединения с `contract_summary`, `contract_detail` и `ckk` уже есть в отборе: `presence_*`, связь 1:1 по `line_id`.
**Фильтры** — по столбцам отбора `smeta_type`, `smeta_direction`, `org_name`. Это те же выражения, что `header.smeta_type`, `header.smeta_direction`, `header.org_name` в ответе: условие `x IS NOT NULL AND svod2_js_trim(svod2_casefold(x)) = <нормализованный параметр>`.
### 4. SQL: новая миграция
Файлы `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова; номер — в разделе «Совместимость».
**Удаление старых сигнатур.** `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_sheet varchar, p_year int, p_org_unit_ids int[], p_search text)`.**
- Возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`.
- Тело — `eligible_lines` из 0049 **без изменения условий отбора**:
- типы форм выбираются по `p_sheet` (`GO` → FORM_1/FORM_4, `RF` → FORM_2);
- добавлены столбец `smeta_direction` (тот же `CASE bl.direction`, что в `header`) и предикат поиска из раздела 3 в виде `p_search IS NULL OR …` (`p_search` здесь уже нормализован).
- Колоночные фильтры сюда не входят: их по-разному применяют страница (все три) и варианты (все, кроме своего).
- Функцию писать `LANGUAGE sql STABLE` без `SET search_path` (имена с `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивания нет, это допустимо (`#3`).
**Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.**
- Аргументы: прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type text DEFAULT NULL`, `p_smeta_direction text DEFAULT NULL`, `p_org_name text DEFAULT NULL`, `p_compact boolean DEFAULT false`.
- Результат: прежние 8 столбцов + `total_count bigint`.
- Сначала нормализация: обрезка и регистр, пустое значение → `NULL`.
- `numbered_lines` строится по `svod2_lines(...)` с тремя фильтрами, с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`.
- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF.
- `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`** (`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`).
**`v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text)`** возвращает строки `(kind varchar, value varchar, n bigint)`:
- `kind` принимает значения `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name`;
- одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска;
- три `GROUP BY` со своими условиями «поиск + фильтры двух других колонок» (`GROUP BY` по `varchar` с детерминированной сортировкой различает строки побайтно, как ключи `Map` в JS);
- два счётчика: `total` без условий и `search_total` только с поиском;
- форму ответа API собирает репозиторий.
**PG15.** Нельзя использовать возможности PG16+: `any_value`, конструкторы SQL/JSON, `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` нужны алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `jsonb_each`, `translate`, `btrim(text, text)`, `strpos` есть в PG15.
**`v3.v_svod2_go_export` не трогается.**
### 5. Сервис и репозиторий
- `SummaryRepository.get_svod2_rows` — без изменений (прежний режим и Excel RF).
- Новые методы:
- `SummaryRepository.get_svod2_page(...)` → `(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` ставятся в обоих режимах.
- Нормализацию (обрезку и регистр) делает SQL, а не Python: одна реализация на значения и запрос.
### 6. Фронт: окно страниц в той же таблице
**Константы и окно.**
- `PAGE_SIZE = 300`;
- окно — видимая страница ±1, в данных таблицы не больше 3 страниц;
- кэш страниц — LRU не больше 5 страниц на вкладку (`#5`);
- в положении «в начале» окно — страницы 0 и 1, то есть страница 1 запрашивается сразу (`#15`).
**Высота строки** берётся из того же источника, что `estimateSize` виртуализатора: сейчас 37 px, после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()`. Окно требует фиксированной высоты: пока 1836 не влита, `measureElement` возвращает константу.
**Данные таблицы — срез окна `[windowStart, windowEnd)`.**
- В срезе загруженные строки и заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна.
- Позиция задаётся отступами виртуализатора строк: `{ ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`.
- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают отступы (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` стоит на `i * rowH`, а полоса прокрутки соответствует `total`.
- Объектов строк TanStack — не больше окна.
- Объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`, hunk AURORA-1836) **не править**.
**Прокрутка.**
- `muiTableContainerProps.onScroll` через `requestAnimationFrame` считает видимую страницу по `scrollTop`.
- При смене страницы пересчитываются окно и данные, недостающие страницы окна запрашиваются.
- Программно положение прокрутки не меняется (решение 9): при смене вкладки, поиска или «Загрузить» окно считается от текущего `scrollTop`, который браузер ограничит при уменьшении высоты. Так же ведёт себя таблица сейчас.
**Заглушки.**
- `muiTableBodyRowProps` для заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`. При rebase объединяется со `style.height` из 1836.
- Правило в `SVOD2_TABLE_CONTAINER_SX` делает текст прозрачным, `Cell`-функции не меняются.
- `getRowId` заглушки — `ph-${index}`.
**Запросы** (`web/src/api/summary.js`):
- `getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection, orgName }, { signal })` — всегда `view=compact`, пустые параметры не передаются, `search` уходит как введён;
- `getSvod2Facets(sheet, year, { orgIds, search, smetaType, smetaDirection, orgName }, { signal })`;
- `get` остаётся для свода 1.
**Состояние вкладки** — хук без JSX, например `useSvod2Pages.js`. Хранит `queryKey`, `generation`, `total`, `facets`, `pages` (LRU) и `inflight` (`AbortController`). Чистая логика вынесена в `web/src/pages/Svod2Page/svod2Paging.js` и покрыта `node --test`:
- окно;
- срез с заглушками;
- LRU;
- параметры запроса;
- преобразование `facets` в `Map` для MRT;
- условие показа «Найдено».
Ответ устаревшего `generation` отбрасывается, текущие запросы отменяются.
**Что вызывает запросы:**
- «Загрузить» или «Обновить» — новый `generation`, `loading = true` (как сейчас: индикатор, `Alert` при ошибке, «Найдено» скрыто), запросы `facets` и страниц окна;
- изменение поиска (после debounce) или колоночного фильтра при загруженных данных — новый `generation`, запросы `facets` и страниц окна. `loading` **не** выставляется, прежние строки остаются до ответа (`#20`);
- смена года или организаций — как сейчас, подсказка «Нажмите «Загрузить»…»;
- переключение вкладки — состояние вкладки сохраняется, запрашиваются только недостающие страницы окна.
**Колоночные фильтры и варианты.** Цель — сохранить рендер MRT без изменений:
- `manualFiltering: true`, `enableFacetedValues: true`;
- в опциях таблицы переопределяется `getFacetedUniqueValues: (table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_MAP`. Пользовательские опции перекрывают значения MRT по умолчанию (`useMRT_TableOptions`, `Object.assign(defaults, rest)`);
- MRT сам отбросит `null`, отсортирует `localeCompare` и покажет числа в скобках, как сейчас;
- `filterSelectOptions` **не** задавать: с ними MRT не показывает числа;
- значения фильтров уходят на сервер как есть (`smeta_type`, `smeta_direction`, `org_name`);
- сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется.
**«Найдено N из M»:** N = `facets.search_total`, M = `facets.total`, условие показа — прежнее: `hasCurrentData && !loading && N !== M`.
**Ошибки.**
- «Загрузить», «Обновить», поиск или фильтр — прежний `Alert` с «Повторить».
- Догрузка страницы при прокрутке — `toast.error`, страница остаётся незагруженной и запрашивается при повторном попадании в окно.
**Изменение `total` между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез пересчитывается.
**`SvodContext` не меняется.** Свод 2 перестаёт звать `getSummary`/`invalidateSummary`.
**Запасной вариант (`#6`).** Если отступы в MRT 3.2.1 работают некорректно (проверка — первая задача фазы 2), допускается массив длины `total` с общей заглушкой. Переход записывается в раздел решений без обсуждения.
### 7. Стенд `perf/` (фаза 3)
**`scroll.spec.ts`**, только `kind === 'svod2'`. Код должен работать и со сборкой «до».
- `waitRows` считает `tbody tr:not([data-svod2-placeholder])`.
- `first_rows_ms` — от «Загрузить» до `MIN_ROWS` строк с данными.
- Байты:
- `json_bytes_sheet` — прежний смысл: максимум одного ответа;
- `json_bytes_total` — новое, сумма всех ответов;
- `json_bytes_first` — новое, сумма до первых строк.
`respRe` включает `/facets`.
- `placeholder_frame_share` и `pages_requested`.
- Тест фильтра: после `fill('1')` ждать ответ с `search=` (до 5 с; если запроса нет — это сборка «до»), затем `awaitStable`, метрика `filter_result_ms`.
- Клики `:194-200` и подписи не меняются.
**Новый `svod2-memory.spec.ts`.**
- Роль dfip, свежий контекст на повтор, по умолчанию 3 повтора.
- Точки:
1. `go` — GO «Все» → «Загрузить» → строки;
2. `rf` — RF «Все» → «Загрузить» → строки;
3. `rf_scrolled` — по `PERF_SVOD2_MEM_SCROLL=1`, на масштабе 1;
4. `final` — вкладка GO.
- В каждой точке `heapUsed` после GC (`lib/cdp.ts:45`) и `domCounters`.
**`api_split.py`.** Прежние строки остаются. Добавить:
- GO/RF `view=full&offset=0&limit=300`;
- GO/RF `view=compact&offset=0&limit=300`;
- `/facets`;
- RF `view=compact&…&search=1`.
**`explain_manual.py`.** Новые записи:
- `svod2_go_page` (`v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`);
- `svod2_rf_page`;
- `svod2_rf_search` (`'1'`);
- `svod2_go_facets`.
**`scale_compare.py`** (`:43-44`): раздельные ключи для полного ответа, страницы (`limit` в query) и `facets`.
**`perfkit.bench`.** Повторит каждый URL страницы. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`.
**Сверка экрана и сервера на данных стенда** (только чтение), запускать перед замером «после»:
- `perf/analysis/svod2_paging_check.py` (Python) — склейка страниц, `total`, проекция compact;
- `perf/analysis/svod2_search_oracle.mjs` (Node) — **дословная копия** `matchesSearch`, фильтра MRT `equals` и подсчёта вариантов по правилам TanStack из кода «до»; применяется к прежнему полному ответу и сравнивается с ответами страниц и `facets` по набору поисков и фильтров.
## Найденные странности (не чиним в этой задаче)
- **Перекрытие полей поиска.** `matchesSearch` (`Svod2Page.jsx:284-288`) сливает `{...header, ...contract_summary, ...contract_detail}`. Поля `contract_summary.counterparty/reference/deadline/comment` перекрываются одноимёнными `contract_detail` и в поиске не участвуют, даже если `contract_detail` пуст. Скорее всего, это непреднамеренно. По решению 9 поведение сохраняется; кандидат в follow-up (статус `deferred`).
- **Поиск по техническим id.** `source_id`, `vsp_id`, `expense_item_id`, `source_org_unit_id` тоже ищутся, поэтому запрос «1» находит почти всё. Сохраняется; кандидат в follow-up (`deferred`).
## Совместимость и порядок PR
**Один план, три фазы и три ветки стеком под issue #236:**
- `feat/236-aurora-1843-svod2-server-paging` — PR 1, сервер, база — ветка 1842;
- `feat/236-aurora-1843-svod2-server-paging-web` — PR 2;
- `feat/236-aurora-1843-svod2-server-paging-perf` — PR 3.
PR создаются по готовности, merge — по команде человека (`#14`). Каждый PR зелёный сам по себе: после PR 1 старый фронт работает.
**Номер миграции.** Голова в ветке — `0049`. Номер `0050` заняли AURORA-1831, 1844 и 1846. Рабочий номер — `0053_svod2_server_paging`. При rebase PR 1:
- проверить `alembic heads` (должна остаться одна голова);
- поставить номер «голова + 1»;
- переименовать `.py` и `.sql`;
- поправить `revision`, `down_revision` и имя в `run_sql_file_migration`;
- перенацелить `test_summary2_related_records_sql.py`.
**AURORA-1836** правит `:26-31` (`2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3) и `muiTableBodyRowProps.style.height`. 1843 эти строки не трогает, опции собирает в компоненте. Высота строки окна — из того же источника. Если 1836 вольётся до замера, базу «до» переснять.
**AURORA-1848** переписывает тот же `get_summary2`. Кто вливается вторым, сохраняет оба режима. «Байт-в-байт» 1843 относится к состоянию до 1848.
**AURORA-1917 (O-5).** Не вливать между «до» и «после» 1843. Если придёт раньше, правка отбора ложится в `v3.svod2_lines` и `v_svod2_go_export`.
**Выкатка и откат.** Миграция только заменяет функции. Откат PR 1 — новой миграцией с телами из 0049. Откат PR 2 безопасен сам по себе.
## Тестирование (что доказывает корректность)
**Окружение (`#18`).**
- Полный `pytest` идёт на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1843`.
- Создание: `docker exec perf-pg createdb -U perf dfip_1843`.
- Запуск: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 ../perf/.venv/bin/pytest tests --init_db`; перед каждым прогоном с `--init_db` БД пересоздаётся.
- БД `perf` не трогать. Пароли `OPENBAO__TEST_*` по умолчанию `admin123` (`tests/conftest.py`).
- Совместимость с PG15.12 — отдельный шаг плана «проверка контроллером», контейнер `dfip-pg15`, порт 55434.
**Фаза 1, `api/tests/unit`:**
- валидация (по образцу `test_query_validation.py`): 422 на прежние невалидные параметры; `search` и фильтры длиной 5000 символов дают 200 (без ограничения длины);
- сервис:
- `_allowed_org_ids` применяется к страницам и вариантам;
- пустое пересечение даёт пустой ответ без SQL;
- второй вызов за `total` при пустой странице;
- выбор режима: без новых параметров зовётся `get_svod2_rows`, `total` нет;
- `test_summary2_related_records_sql.py` перенацелить на новую миграцию (`svod2_lines` для `GO`/`RF`, те же проверки отбора, что для 0049).
**Фаза 1, `api/tests/integration`.** Своя фикстура засевает данные свода 2 за год, которого нет в `fixture.sql`, в откатываемой сессии рядом с `client` (`#13`). Что засевается:
- 3 организации, две из них с одинаковым `title`;
- FORM_1, FORM_4, FORM_2;
- все `smeta_type`, оба направления и `NULL`;
- `contract_summary` со значением в перекрываемом поле при пустом `contract_detail`;
- кириллица разного регистра, `Ё`;
- дробные числа с хвостовыми нулями;
- RF-строка с `expenses_q1` только в ЦКК;
- исполнитель с одной организацией.
Оракул — Python-порт правил экрана (`matchesSearch`, `equals`, варианты TanStack, `Number::toString`), применённый к прежнему полному ответу. Python-порт `Number::toString` сверяется с теми же векторами Node. Проверяется:
- склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом;
- поиск (`аРЕНДА`, `ё`, перекрытое поле, число `1234.5` и `1234.50`, пробелы и NBSP по краям) и фильтры (`кв `, одинаковые названия) совпадают с оракулом;
- `facets` совпадает с оракулом для сочетаний поиска и фильтров;
- `svod2_casefold` и `svod2_js_trim` дают одинаковый результат при `LC_CTYPE` базы (тест вызывает их и сравнивает с ожидаемыми строками);
- `svod2_js_number_text` совпадает с векторами Node;
- compact — это проекция full;
- права;
- прежний режим без `total`, существующие смоук-тесты проходят без правок.
**Фаза 1, байт-в-байт на стенде (`#17`).** До и после миграции: `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, все и одна организация, `offset=0&limit=50`) и Excel RF. Сравнение — sha256 распакованных тел; для xlsx — значения ячеек через `openpyxl`.
**Фаза 2, `node --test web/src/pages/Svod2Page/*.test.js`:**
- окно и срез: края, неполная последняя страница, `total = 0`, сумма отступов;
- LRU;
- параметры запроса;
- `generation`;
- `facets` → `Map`;
- условие «Найдено» (только поиск);
- `svod2Styles.test.js` по-прежнему зелёный.
**Фаза 2, руками на стенде** (выполняет контроллер, `#17`). Сравнение со сборкой «до» на тех же данных:
- GO и RF, admin и dfip, «Все» и выбор организаций;
- поиск, три фильтра и их сочетания, варианты фильтров с числами, «Найдено», сброс фильтров;
- вкладки и положение прокрутки;
- перенос ползунка в середину и в конец;
- быстрый ввод с последующим фильтром;
- единицы, «Перейти к задачам»;
- горизонтальная прокрутка;
- ошибка догрузки;
- скриншоты.
**Фаза 3.** Пробный прогон сценариев с `PERF_REPEATS=1` на «до» и «после». `svod2_paging_check.py` и `svod2_search_oracle.mjs` должны быть зелёными на масштабах 1 и 2.
## Замер до/после
Выполняет контроллер на стенде (`#17`). В плане это точка ожидания. PR открываются без цифр, цифры дописываются в PR по готовности. Reset стенда разрешён, но не во время чужих замеров.
**Базы:**
- «до» — вершина AURORA-1842 (или ребейзнутая, если 1836 вольётся раньше);
- «после» — вершина ветки PR 3.
Код сценариев один (из PR 3): для «до» — временный worktree 1842 и `git checkout <ветка PR 3> -- perf/` без коммита.
**Порядок на одних данных:**
1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` → `verify`. Своды мерить до `grow`.
2. Скопировать `results/manifest-s1.json` в оба worktree.
3. «До»: поднять `api`, `web-build`, `web` в worktree 1842 и снять замеры.
4. Остановить сервисы.
5. «После»: `alembic upgrade head` → `api`, `web-build`, `web` → `svod2_paging_check.py` и `svod2_search_oracle.mjs` → замеры.
6. То же на масштабе 2.
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836.
**Команды** (из `perf/e2e`, затем `perfkit.aggregate`):
| что | команда | время на сторону (оценка) |
|---|---|---|
| скролл, время до первых строк | `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` | s1: «до» ~35 мин, «после» ~15 мин; s2 ×3–4 |
| фильтр | тот же запуск с `--grep filter` | ~5 мин |
| память | `PERF_RUN_ID=1843-mem-<…>-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 мин |
**Целевые значения** (`#10`, не условие слияния — решение 11):
| метрика | цель |
|---|---|
| память `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 |
| `first_rows_ms` | ≤ 0,5 × «до» |
| compact / full | ≤ 0,40 |
| первая страница + facets | ≤ 5 % полного ответа |
| прежний запрос | ≤ 1,10 × «до» |
| шаг прокрутки | ≤ 1,10 × «до» |
| `placeholder_frame_share` | ≤ 5 % (если больше, цена шага «после» занижена) |
Недобор → follow-up.
## Риски / компромиссы
- **[Каждая страница и каждый запрос вариантов заново выполняет полный отбор строк: 13 LEFT JOIN + `DISTINCT`, плюс поиск по `jsonb_each`]** → `explain_manual` показывает цену отбора. Страница 1 и соседние страницы окна запрашиваются заранее. Если страница на масштабе 2 дольше ~1 с, отбор переписывается на `EXISTS` с тем же смыслом (сверка — те же тесты), отдельным коммитом фазы 1.
- **[Поиск строит три небольших `jsonb` на каждую отобранную строку]** → Только при непустом поиске. Цель «прежний режим ≤ 1,10» ловит регресс без поиска.
- **[`svod2_casefold` расходится с JS для заглавных букв вне таблицы]** → Таблица покрывает ASCII, кириллицу и латиницу-1. Остальное в данных свода не ожидается. Расширить таблицу — одна строка SQL.
- **[`Number::toString` в SQL расходится с V8]** → Векторный тест Node ↔ SQL. `SET extra_float_digits` внутри функции.
- **[Данные меняются между запросами страниц]** → «Обновить» даёт согласованный снимок, `total` берётся из последнего ответа.
- **[Отступы в MRT ведут себя не так, как ожидается]** → Проверка в первой задаче фазы 2, запасной вариант `#6`.
- **[1836 меняет высоту строки]** → Единый источник высоты, тест суммы отступов, чек-лист rebase.
- **[Цена шага «после» занижена заглушками]** → `placeholder_frame_share`.
- **[Тест фильтра свода 2 «стабилизируется» до ответа]** → Ожидание ответа с `search=`.
- **[Пока идёт запрос по поиску или фильтру, на экране прежние строки]** → Сейчас фильтрация мгновенная. Это допустимое отличие (spec: «Поведение для пользователя не меняется»). Индикатора нет, чтобы не менять вид (`#20`).
- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only` и `--repeats`.
## Что оставлено исполнителю
- Имена моделей, методов, хука и файла чистой логики.
- Способ получить `total` при пустой странице, если обычная страница делает один SQL-запрос.
- Оформление заглушки при прозрачном тексте и той же высоте.
- Где сэмплировать `placeholder_frame_share`.
- `PAGE_SIZE` (200–500) и окно (±1…±2) по замеру. Изменение записать в решения без обсуждения.
## Decisions made without discussion
1. **2026-10-28 — новый режим API включается наличием любого из новых параметров (в том числе `view=full`); поле `total` есть только в нём.**
Обоснование: требование «без новых параметров байт-в-байт». `limit` — прежний параметр.
Цена ошибки: `total` для полного вида даёт `view=full` — дёшево.
2. **2026-10-28 — ~~фильтр «ССП» через `org_ids`, значения фильтров — перечисления~~. Отменено 28.10 решением человека 9: фильтр «ССП» — по `org_name` с семантикой MRT `equals`, значения фильтров — свободные строки.**
3. **2026-10-28 — общий отбор строк вынесен в `v3.svod2_lines` (одна копия условий для страниц и вариантов), без `SET search_path`, чтобы функция могла встраиваться.**
Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами.
Цена ошибки: если отбор заметно дорожает, вернуть копии — час работы, сравнение через `explain_manual`.
4. **2026-10-28 — компактный вид — та же вложенная форма `data`, нужные пути, без `null`, проекцией полного `jsonb` в SQL (`v3.svod2_compact`).**
Обоснование: фронт читает те же пути; `null` и отсутствие ключа отображаются одинаково; WS-слияния у свода 2 нет; проверка «проекция полного» тривиальна.
Цена ошибки: если цель 40 % не набирается, переход на массив значений меняет `svod2_compact` и маппинг на фронте.
5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU 5 страниц.**
Обоснование: 300 строк — около 8 экранов, примерно 0,3–0,45 МБ компактного JSON; в памяти не больше 1 500 строк.
Цена ошибки: правка двух чисел по замеру.
6. **2026-10-28 — окно реализуется срезом данных и отступами виртуализатора; запасной вариант — массив длины `total` с заглушками.**
Обоснование: только срез даёт память O(окна) и для объектов строк TanStack. Накопительная бесконечная прокрутка отвергнута: в конце в памяти оказался бы весь ответ.
Цена ошибки: запасной вариант дороже по памяти — цель по памяти может не выполниться (follow-up).
7. **2026-10-28 — ~~варианты фильтров без учёта поиска и других фильтров~~. Отменено 28.10 решением человека 9: варианты — как в TanStack, с поиском и фильтрами других колонок, с числами.**
8. **2026-10-28 — ~~поиск ищет и в перекрытых полях `contract_summary`, числа — по тексту из БД~~. Отменено 28.10 решением человека 9: семантика `matchesSearch` повторяется точно, перекрытие описано как находка.**
9. **2026-10-28 — ~~«Найдено» считает поиск и фильтры вместе~~. Отменено 28.10 решением человека 9: только поиск (`search_total`).**
10. **2026-10-28 — целевые значения:**
- память s1 ≤ 30 %, s2 ≤ 1,25 × s1;
- первые строки ≤ 50 %;
- compact ≤ 40 %;
- первая страница + варианты ≤ 5 %;
- прежний режим ≤ +10 %;
- шаг прокрутки ≤ +10 %;
- заглушки ≤ 5 %.
Решением человека 11 это цели, а не условия слияния.
Обоснование: оценки по REPORT (М2, М9, М10).
Цена ошибки: пересмотр по фактическим цифрам, код не меняется.
11. **2026-10-28 — код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`; `json_bytes_sheet` сохраняет смысл, суммарные байты — новые метрики.**
Обоснование: сопоставимость.
Цена ошибки: переснять «до» — около часа на масштабе 1.
12. **2026-10-28 — рабочий номер миграции `0053`, окончательный — «голова + 1» при rebase.**
Обоснование: 0050 занят тремя PR.
Цена ошибки: переименование двух файлов.
13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2.**
Обоснование: в `fixture.sql` нет строк, проходящих отбор свода 2 за 2026 год.
Цена ошибки: нет.
14. **2026-10-28 (контроллер) — один план на три PR (три фазы); ветки `feat/236-aurora-1843-svod2-server-paging` (PR 1, база — ветка 1842), `…-web` (PR 2), `…-perf` (PR 3), стеком, все под issue #236. Остановки для человека перед созданием PR нет, PR создаются по готовности, merge — по команде человека.**
Цена ошибки: при замечаниях к PR 1 ветки PR 2 и PR 3 ребейзятся каскадом — до часа на rebase.
15. **2026-10-28 (контроллер) — сразу после страницы 0 запрашивается страница 1, чтобы при подходе к строке 300 не было заглушек (окно ±1 в начале даёт ровно это).**
Цена ошибки: лишний запрос, если пользователь не прокручивает, — ~0,3–0,5 МБ и одно выполнение отбора.
16. **2026-10-28 (контроллер) — поиск по `contract_detail.expenses_q1..4` на листе RF — по значениям ответа (`COALESCE(cd, ckk)`), как их видит экран.**
Цена ошибки: если выбрать неверно, строки с суммой только в ЦКК не находились бы, хотя видны на экране. Это закрыто тестом с RF-строкой «только ЦКК».
17. **2026-10-28 (контроллер) — замеры, ручные проверки и байт-в-байт сверку на стенде выполняет контроллер; reset стенда разрешён (стенд наш), но не во время чужих замеров. В плане — точка ожидания, PR открываются без цифр.**
Цена ошибки: цифры появляются в PR позже кода; на слияние не влияет (решение 11).
18. **2026-10-28 (контроллер) — тестовое окружение:**
- полный `pytest` на PG16 (`perf-pg`, порт 55432, БД `dfip_1843`, БД `perf` не трогать, пароли `admin123`);
- PG15.12 проверяет контроллер (`dfip-pg15`, порт 55434) отдельным шагом плана.
Цена ошибки: несовместимость с PG15 выявится на шаге контроллера, а не у исполнителя — одна итерация правки.
19. **2026-10-28 — регистр без зависимости от локали — `translate` по явной таблице (ASCII, кириллица U+0400–U+042F и U+0490, латиница-1), без `lower()` и без ICU.**
Обоснование: решение человека 10. `lower()` зависит от `LC_CTYPE`, ICU может отсутствовать в сборке PG.
Цена ошибки: расхождение с JS для заглавных букв вне таблицы (греческие, расширенная латиница). В данных не ожидается, расширение таблицы — одна строка.
20. **2026-10-28 — при поиске и колоночном фильтре до прихода ответа показываются прежние строки, без индикатора загрузки. Индикатор и `Alert` остаются только у «Загрузить»/«Обновить».**
Обоснование: сейчас поиск мгновенный и индикатора нет; это ближайшее к прежнему поведение. Так «Найдено» не мигает.
Цена ошибки: при медленном сервере пользователь не видит, что запрос идёт. Добавить тонкую полосу прогресса — полчаса.
21. **2026-10-28 — значения для поиска строятся теми же выражениями, что `data` ответа (`header`, `contract_summary`, `contract_detail` листа), объединяются `jsonb ||` (перекрытие как у JS-спреда), числа приводятся к тексту функцией `svod2_js_number_text` (правила `Number::toString` поверх кратчайшей записи `float8`), обрезка — по набору пробельных символов ECMAScript.**
Обоснование: решение человека 9 — точное повторение `matchesSearch`.
Цена ошибки: если есть расхождение, оно ловится оракулом Node на данных стенда до слияния.
22. **2026-10-28 — ~~ограничение длины `search` и значений фильтров — 1000 символов (422)~~. Отменено контроллером 28.10: противоречит решению человека 9 («поведение абсолютно такое же»).**
Новая формулировка: API не ограничивает длину `search` и значений фильтров и не отвечает ошибкой из-за длины; длинный запрос даёт тот же результат, что на экране (совпадения есть или нет). Защита от злоупотребления — только невидимая пользователю: общий предел длины строки запроса у веб-сервера (uvicorn/h11 и прокси платформы, порядка килобайт и выше) остаётся как есть, отдельного предела в API не вводим.
Цена ошибки: очень длинный URL (десятки КБ) может упереться в предел прокси до приложения — на экране это нереалистичный ввод; если понадобится, фронт переведёт запросы на POST — полдня.
23. **2026-10-28 (план) — JS-оракул `perf/analysis/svod2_search_oracle.mjs` пишется первой задачей фазы 1 и входит в PR 1, а не в PR 3. Режим сверки на стенде (`--check`) добавляется в фазе 3.**
Обоснование: требование человека — тесты-оракулы идут первыми и красными. Дословная копия правил экрана «до» должна существовать раньше SQL.
Цена ошибки: файл `perf/` в серверном PR — нет.
24. **2026-10-28 (план) — Python-оракул интеграционных тестов (`api/tests/svod2_oracle.py`) сверяется с векторами, которые порождает JS-оракул (`api/tests/fixtures/svod2_screen_vectors.json`, `js_number_text_vectors.json`).**
Обоснование: равенство Python-порта коду экрана доказывается без Node в CI.
Цена ошибки: при смене векторов — повторная генерация одной командой.
25. **2026-10-28 (план) — эталон прежнего ответа на засеянных данных (`api/tests/fixtures/svod2_legacy_golden.json`, sha256 тела) записывается один раз на коде «до» (Task 3) и после этого не перезаписывается.**
Обоснование: «байт-в-байт» проверяется в `pytest` на каждом прогоне, а не только сверкой на стенде.
Цена ошибки: при изменении засева эталон придётся переснимать на коде «до» — около 15 минут.
26. **2026-10-28 (план) — засев свода 2:**
- год 2031, явные id от 910001;
- скрипт выполняется в сессии фикстуры `client` (`client.db_session`) через `client.portal`;
- isp1 привязан к одной засеянной организации;
- добавлена четвёртая организация `«филиал ёлки »` (вариант названия по регистру и пробелу);
- добавлена строка без строки `contract_detail`.
Обоснование: Review Focus плана — различимость вариантов фильтра и слияние пустой секции договора.
Цена ошибки: нет.
27. **2026-10-28 (план) — компактное `data` содержит все группы и подгруппы путей всегда, возможно пустыми (`{}`): это ровно результат `jsonb_strip_nulls(jsonb_build_object(...))`. Python-проекция `compact_projection` повторяет это.**
Обоснование: спецификация молчит о пустых группах. Экран читает пути через `?.`, отображение то же.
Цена ошибки: если нужен пропуск пустых групп — одна обёртка в SQL и в проекции.
28. **2026-10-28 (план) — `v3.svod2_lines` устроена как `WITH eligible_lines AS (…) SELECT … WHERE p_search IS NULL OR EXISTS (…)`. Тело отбора обрамлено комментариями-маркерами, по ним SQLite-тест `test_summary2_related_records_sql.py` извлекает отбор для GO и RF.**
Цена ошибки: если планировщик хуже встраивает CTE — переписать на подзапрос, тесты те же.
29. **2026-10-28 (план) — на фронте вся логика состояния вкладки — чистый редьюсер `svod2PagingReducer` в `svod2Paging.js` (покрыт `node --test`). Хук `useSvod2Pages.js` только ходит в API. `SummaryApi.getSvod2Page/getSvod2Facets` получают готовые параметры. `measureElement` переопределяется в компоненте константой `ROW_HEIGHT`, объект `ROW_VIRTUALIZER_OPTIONS` не меняется.**
Обоснование: хук без тестового рендерера не проверить; поколения и устаревшие ответы — главный риск поведения.
Цена ошибки: нет.
30. **2026-10-28 (план) — чистые помощники стенда для свода 2 вынесены в `perf/e2e/lib/svod2.ts`, их тест — `node --test` (Node 24 стирает типы). Проверку типов и `playwright test --list` делает контроллер.**
Обоснование: у исполнителей нет `perf/e2e/node_modules`, и стенд они не запускают.
Цена ошибки: ошибка типов в сценарии ловится контроллером перед замером — одна итерация.
31. **2026-10-28 (план) — PR 1 и PR 2 ссылаются на issue как `Refs #236` (часть 1/3, 2/3), PR 3 — `Closes #236`.**
Обоснование: стековые PR вливаются не в `test`, и issue не должна закрыться до последней части.
Цена ошибки: правка описания PR.
32. **2026-10-28 (план) — проверки, которые делает главный контроллер, не блокируют следующую фазу:**
- PG15;
- стенд байт-в-байт и Excel;
- ручная проверка фазы 2;
- `playwright --list`.
Исполнение ждёт контроллера только в конце фазы 3 (замеры). Красный результат проверки становится находкой `O-n`, правка идёт в ветку своей фазы с каскадным rebase.
Обоснование: «между фазами без остановки», а слияние всё равно только по команде человека.
Цена ошибки: каскадный rebase — до часа.
33. **2026-10-28 (план) — нумерация `C/I/M` финальных ревью сквозная по трём веткам, номера задач плана сквозные (1–18).**
Обоснование: маркеры не меняются при переносе между PR и follow-up.
Цена ошибки: нет.
34. **2026-10-28 (план) — найденные странности (перекрытие полей поиска, поиск по техническим id) заносятся в таблицу находок сразу как `O-1` и `O-2` со статусом `deferred`. При закрытии они попадут в follow-up issue.**
Цена ошибки: нет.
35. **2026-10-28 (контроллер, R-1) — решения плана #23–#34 одобрены главным контроллером как есть.**
Цена ошибки: нет.
36. **2026-10-28 (контроллер, R-2) — хелпер `legacy_full` лежит в `api/tests/integration/conftest.py`, а не в `test_svod2_paging_api.py`. Task 3 создаёт, Task 4 и дальше импортируют оттуда.**
Обоснование: `test_svod2_sql.py` не должен импортировать из другого тестового модуля.
Цена ошибки: перенос функции.
37. **2026-10-28 (контроллер, R-3) — проверки разделены: PG15.12 выполняет контроллер SDD скриптом после снятия замка; сверку байт-в-байт на стенде, ручную проверку фазы 2, `playwright --list`/типы perf/e2e и замеры выполняет главный контроллер, исполнение их не ждёт.**
Цена ошибки: отложенное обнаружение, каскадный rebase.
38. **2026-10-28 (контроллер, R-4) — в коммитах исполнителей остаётся трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` из плана и правил волны 2.**
Цена ошибки: amend/rebase трейлера.
39. **2026-10-28 (контроллер, R-5) — I-1 финального ревью фазы 1 (серверный поиск медленнее экрана «до»: `translate`-casefold и plpgsql-запись чисел на каждое значение, ~1,46 с на 10 тыс. строк, предложено регулярное выражение из `svod2_search_pattern`) не правится в фазе 1, статус `deferred`. Причина: переписывание предиката меняет доказанную оракулами реализацию поиска; цифры должен дать замер главного контроллера на стенде (план, Task 18 Step 6). Миграция 0053 ещё не выкачена, поэтому правка возможна в ней же до слияния.**
Обоснование: решение 11 — цели по времени не условие слияния; корректность важнее. Требование человека (поведение прежнее) не страдает.
Цена ошибки: если замер подтвердит недобор — переписать предикат в 0053 (оракулы и векторы остаются), порядка полудня. Одновременно закрывается T5.5.
40. **2026-10-28 (контроллер, R-6) — `v_svod2_facets` считает `svod2_lines` дважды: без поиска (все строки) и `IN (hashed)` по второму вызову с поиском. Design §4 описывал одну выборку с флагом совпадения; отклонение сделано в fix-раунде Task 5 (I-1 ревью Task 5: коррелированный подзапрос давал N²).**
Цена ошибки: двойной отбор при поиске в facets; влияет на время, не на корректность. Измерит контроллер.
41. **2026-10-28 (контроллер, R-7) — значения `search`, `smeta_type`, `smeta_direction`, `org_name` не пишутся в лог `db_time` (M-2): только признаки и длина.**
Обоснование: пользовательский текст (контрагенты, договоры) без ограничения длины не должен попадать в журналы.
Цена ошибки: нет.
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).**
Цена ошибки: лишний запрос или кратковременные заглушки; проверяет главный контроллер вручную.
45. **2026-10-29 (контроллер, R-11) — в сценариях стенда «строка с данными» исключает строки-заглушки и строки со скелетоном (`SVOD2_DATA_ROW_SELECTOR` с `:not(:has(.MuiSkeleton-root))`), `settle` в `svod2-memory` ждёт нуля скелетонов; `first_rows_ms` снимает raf-наблюдатель в странице. Причина: в режиме `load` фронт держит `loading=true` до прихода вариантов и страниц окна, и MRT рисует скелетоны в ячейках даже при непустых данных — без этой правки «до/после» завышало бы выигрыш. Это видимое пользователю отличие (скелетоны вместо строк до конца загрузки окна); решает главный контроллер после ручной проверки.**
Цена ошибки: метрика «до/после» нечестна, либо переделка `loading`-индикации на фронте.
46. **2026-10-29 (главный контроллер, R-12) — исправления 1842 (b834f10, 4cb0862) влиты в стек 1843 обычными merge сверху вниз (#227 → #241 → #243 → #245), без rebase и force-push; конфликты в `Svod2Page.jsx` и `svod2Styles.test.js` разрешены объединением обеих сторон.**
Обоснование: PR на ревью у людей, история не переписывается; 1843 не рисует свой заголовок и не меняет `layoutMode`, противоречий нет.
Цена ошибки: сломанный заголовок или пагинация; проверено тестами, build и вживую (границы 26 групп ±1 px).
47. **2026-10-29 (исполнитель поиска, R-13) — значения для поиска берутся прямо из колонок `budget_line`, `expense_item`, `contract_summary`, `contract_detail`, `ckk`, без сборки jsonb; набор и правило «при совпадении ключей побеждает последний» повторяют прежнее `header || cs || cd`.**
Обоснование: jsonb стоил 10–15 мкс на строку, цель без этого недостижима.
Цена ошибки: новая колонка `contract_*` не попадёт в поиск; `test_search_columns_are_those_known_to_svod2_lines` упадёт при добавлении колонки.
48. **2026-10-29 (исполнитель поиска, R-14) — значения строки склеиваются через U+0001 и проверяются одним `strpos`; для строки поиска с U+0001 — запасной путь по каждому значению.**
Цена ошибки: ложное совпадение на стыке при ошибке в запасном пути; покрыто тестом.
49. **2026-10-29 (исполнитель поиска, R-15) — регистр данных приводится `translate` только по парам таблицы, результат которых есть в строке поиска (`svod2_casefold_for_search`); для ненормализованной строки — полная таблица. Таблица вынесена в IMMUTABLE-функции `svod2_casefold_from()`/`svod2_casefold_to()`.**
Цена ошибки: расхождение регистра для редкого символа; покрыто тестами и оракулом.
50. **2026-10-29 (исполнитель поиска, R-16) — числа переводятся в текст JS лениво и только если строка поиска может совпасть с записью числа; ноль — без вызова plpgsql (`svod2_num_text`).**
Цена ошибки: нет, результат тот же; ветки покрыты тестами (`1`, `-`, `.`, `e`, `+`, `e+25`, `1e-7`, `infinity`, `nan`).
51. **2026-10-29 (исполнитель поиска, R-17) — `SET jit TO off` на `v_svod2_go` и `v_svod2_rf`; на `v_svod2_facets` не ставится: с `SET` функция перестаёт подставляться, и `test_facets_search_subplan_is_not_correlated` это ловит.**
Цена ошибки: facets уязвим к JIT на БД без статистики.
52. **2026-10-29 (исполнитель поиска, R-18) — `facets` с поиском остаётся 1,6–1,66× первой страницы (двойной `svod2_lines`, см. #40); в цель #13 не входит. Статус `deferred`.**
Цена ошибки: варианты фильтров при поиске появляются на ~0,3–0,6 с позже страницы.
53. **2026-10-29 (исполнитель фронта, R-19) — после поиска или фильтра на глубине scrollTop программно не сбрасывается (spec «SHALL NOT менять положение прокрутки программно»); браузер прижимает его к концу короткого списка.**
Цена ошибки: если нужен сброс на начало — правка spec и `scrollTop = 0` при refine.
54. **2026-10-29 (исполнитель фронта, R-20) — при окне не у начала параллельно странице по прокрутке запрашивается `offset=0` (`shouldFetchFirstPageFallback`): страница за концом нового списка отвечала 6,5 с против 3 с.**
Цена ошибки: лишний запрос 300 строк на refine на глубине. После ускорения поиска (#13) выигрыш меньше; пересмотреть после замера, статус `deferred`.
55. **2026-10-29 (исполнитель фронта, R-21) — ширина полосы загрузки детерминирована от (колонка, строка) (`getSkeletonWidth`), а не случайна, как у MRT.**
Цена ошибки: только внешний вид.
56. **2026-10-29 (исполнитель фронта, R-22) — признак «данные устарели» — `dataGeneration !== generation` (`getCurrentPages`); срабатывает и при «Обновить», где старые строки и раньше были закрыты полосами MRT.**
Цена ошибки: если при «Обновить» нужно оставлять строки видимыми — различать режимы `load` и `refine`.
57. **2026-10-29 (главный контроллер, O-8) — на стенде масштаба 2 таблицы `contract_summary`, `contract_detail` и `ckk` были пустыми: оракул поиска и ночные замеры до 29.10 не проверяли поиск по полям договоров. Исполнитель поиска добавил синтетические договоры в БД `perf` (каждая 3-я строка `contract_detail`, каждая 4-я `contract_summary`); оракул на них — CHECK_OK. Генератор `perfkit` договоры не создаёт — `deferred`.**
Цена ошибки: без договоров на стенде регресс поиска по полям договоров не виден замером.