Andrey Epifancev 100dba4a90 docs(openspec): aurora-1843 — решения фазы 1 (R-5..R-8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:10:42 +04:00

76 KiB
Raw Blame History

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= возвращает:

{
  "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 пропадают так же. Предикат:

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 на искусственном запросе; правка — ранний возврат пустой страницы.