# 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. Жёсткие условия корректности: - склейка страниц равна полному списку; - варианты фильтров совпадают с вычисленными по полному ответу; - поиск и фильтры совпадают с экраном до изменения; - прежний режим байт-в-байт. ## Решения по устройству ### 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--s PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` | s1: «до» ~35 мин, «после» ~15 мин; s2 ×3–4 | | фильтр | тот же запуск с `--grep filter` | ~5 мин | | память | `PERF_RUN_ID=1843-mem-<…>-s PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин | | байты и время API | `.venv/bin/python analysis/api_split.py --scale ` | ~3–5 мин | | планы SQL | `.venv/bin/python analysis/explain_manual.py --scale --out results/1843-explain-s` | ~3 мин | **Целевые значения** (`#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 ` из плана и правил волны 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).** Цена ошибки: лишний запрос или кратковременные заглушки; проверяет главный контроллер вручную.