76 KiB
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:
eligible_lines:DISTINCTпо 13 LEFT JOIN «наличия данных». Для GO берутся FORM_1 и FORM_4 (:71), для RF — FORM_2 (:369); здесь же вычисляютсяsmeta_typeизei.sheetиorg_name = ou.title.numbered_lines:row_number()(:151-168).page AS MATERIALIZEDсLIMIT/OFFSET(:169-173).- Сборка большого
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)
- Вместо аналитики — реализация, варианты C + B черновика; вариант A не нужен.
- Сервер:
- у существующих
offset/limitпоявляются серверный поиск, три фильтра (smeta_type,smeta_direction, организация —org_name/org_ids) и полеtotal;countсохраняет прежний смысл; - отдельный лёгкий запрос вариантов фильтров (facets);
- компактный ответ включается параметром (
view=compact); - без новых параметров ответ байт-в-байт прежний.
- у существующих
- В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет — выгрузку под экранные фильтры не подгоняем.
- Фронт:
- та же таблица, строки догружаются страницами при прокрутке, в памяти окно плюс запас;
- поиск и фильтры уходят на сервер (debounce 300 мс уже есть);
- кэш
SvodContextперестаёт копить полные ответы.
- SQL: новая сигнатура
v_svod2_go/v_svod2_rf→ новая миграция сDROPстарой иCREATEновой, совместимая с PG15.explain_manual.pyиscroll.spec.tsобновляются вместе с UX, чтобы замер оставался сопоставимым. - Три PR в стеке: сервер, фронт, perf.
- AURORA-1917 (O-5) — отдельная задача, её нельзя вливать между замерами «до» и «после».
- Метрики:
- память после GO «все» → RF «все» → GO (новый
svod2-memory.spec.ts); - время до первых строк;
- байты ответа;
- шаг прокрутки не хуже 1842;
- масштабы 1 и 2.
- память после GO «все» → RF «все» → GO (новый
- Поведение свода 2 для пользователя остаётся абсолютно тем же:
- варианты фильтров считаются как сейчас: с учётом поиска и фильтров других колонок, без своего, с числами строк;
- поиск — ровно семантика
matchesSearch, включая перекрытие полейcontract_summary(они не ищутся) и приведение чисел к тексту, как в JS; - «Найдено N из M» — только по поиску;
- ввод в поиск, смена года или организаций сбрасывают колоночные фильтры.
- Поиск без учёта регистра — независимо от локали БД:
LC_CTYPEпрода неизвестен. - Пороги производительности не являются условием слияния. 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.
- ASCII
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. Затем применяется правило ECMAScriptNumber::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-core3.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 повтора.
- Точки:
go— GO «Все» → «Загрузить» → строки;rf— RF «Все» → «Загрузить» → строки;rf_scrolled— поPERF_SVOD2_MEM_SCROLL=1, на масштабе 1;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, фильтра MRTequalsи подсчёта вариантов по правилам 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/ без коммита.
Порядок на одних данных:
perf/stand.sh reset→perfkit.gen.run --scale 1→verify. Своды мерить доgrow.- Скопировать
results/manifest-s1.jsonв оба worktree. - «До»: поднять
api,web-build,webв worktree 1842 и снять замеры. - Остановить сервисы.
- «После»:
alembic upgrade head→api,web-build,web→svod2_paging_check.pyиsvod2_search_oracle.mjs→ замеры. - То же на масштабе 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
-
2026-10-28 — новый режим API включается наличием любого из новых параметров (в том числе
view=full); полеtotalесть только в нём. Обоснование: требование «без новых параметров байт-в-байт».limit— прежний параметр. Цена ошибки:totalдля полного вида даётview=full— дёшево. -
2026-10-28 —
фильтр «ССП» через. Отменено 28.10 решением человека 9: фильтр «ССП» — поorg_ids, значения фильтров — перечисленияorg_nameс семантикой MRTequals, значения фильтров — свободные строки. -
2026-10-28 — общий отбор строк вынесен в
v3.svod2_lines(одна копия условий для страниц и вариантов), безSET search_path, чтобы функция могла встраиваться. Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами. Цена ошибки: если отбор заметно дорожает, вернуть копии — час работы, сравнение черезexplain_manual. -
2026-10-28 — компактный вид — та же вложенная форма
data, нужные пути, безnull, проекцией полногоjsonbв SQL (v3.svod2_compact). Обоснование: фронт читает те же пути;nullи отсутствие ключа отображаются одинаково; WS-слияния у свода 2 нет; проверка «проекция полного» тривиальна. Цена ошибки: если цель 40 % не набирается, переход на массив значений меняетsvod2_compactи маппинг на фронте. -
2026-10-28 —
PAGE_SIZE = 300, окно — видимая страница ±1, LRU 5 страниц. Обоснование: 300 строк — около 8 экранов, примерно 0,3–0,45 МБ компактного JSON; в памяти не больше 1 500 строк. Цена ошибки: правка двух чисел по замеру. -
2026-10-28 — окно реализуется срезом данных и отступами виртуализатора; запасной вариант — массив длины
totalс заглушками. Обоснование: только срез даёт память O(окна) и для объектов строк TanStack. Накопительная бесконечная прокрутка отвергнута: в конце в памяти оказался бы весь ответ. Цена ошибки: запасной вариант дороже по памяти — цель по памяти может не выполниться (follow-up). -
2026-10-28 —
варианты фильтров без учёта поиска и других фильтров. Отменено 28.10 решением человека 9: варианты — как в TanStack, с поиском и фильтрами других колонок, с числами. -
2026-10-28 —
поиск ищет и в перекрытых полях. Отменено 28.10 решением человека 9: семантикаcontract_summary, числа — по тексту из БДmatchesSearchповторяется точно, перекрытие описано как находка. -
2026-10-28 —
«Найдено» считает поиск и фильтры вместе. Отменено 28.10 решением человека 9: только поиск (search_total). -
2026-10-28 — целевые значения:
- память s1 ≤ 30 %, s2 ≤ 1,25 × s1;
- первые строки ≤ 50 %;
- compact ≤ 40 %;
- первая страница + варианты ≤ 5 %;
- прежний режим ≤ +10 %;
- шаг прокрутки ≤ +10 %;
- заглушки ≤ 5 %.
Решением человека 11 это цели, а не условия слияния. Обоснование: оценки по REPORT (М2, М9, М10). Цена ошибки: пересмотр по фактическим цифрам, код не меняется.
-
2026-10-28 — код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой
perf/;json_bytes_sheetсохраняет смысл, суммарные байты — новые метрики. Обоснование: сопоставимость. Цена ошибки: переснять «до» — около часа на масштабе 1. -
2026-10-28 — рабочий номер миграции
0053, окончательный — «голова + 1» при rebase. Обоснование: 0050 занят тремя PR. Цена ошибки: переименование двух файлов. -
2026-10-28 — интеграционные тесты засевают свои данные свода 2. Обоснование: в
fixture.sqlнет строк, проходящих отбор свода 2 за 2026 год. Цена ошибки: нет. -
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. -
2026-10-28 (контроллер) — сразу после страницы 0 запрашивается страница 1, чтобы при подходе к строке 300 не было заглушек (окно ±1 в начале даёт ровно это). Цена ошибки: лишний запрос, если пользователь не прокручивает, — ~0,3–0,5 МБ и одно выполнение отбора.
-
2026-10-28 (контроллер) — поиск по
contract_detail.expenses_q1..4на листе RF — по значениям ответа (COALESCE(cd, ckk)), как их видит экран. Цена ошибки: если выбрать неверно, строки с суммой только в ЦКК не находились бы, хотя видны на экране. Это закрыто тестом с RF-строкой «только ЦКК». -
2026-10-28 (контроллер) — замеры, ручные проверки и байт-в-байт сверку на стенде выполняет контроллер; reset стенда разрешён (стенд наш), но не во время чужих замеров. В плане — точка ожидания, PR открываются без цифр. Цена ошибки: цифры появляются в PR позже кода; на слияние не влияет (решение 11).
-
2026-10-28 (контроллер) — тестовое окружение:
- полный
pytestна PG16 (perf-pg, порт 55432, БДdfip_1843, БДperfне трогать, паролиadmin123); - PG15.12 проверяет контроллер (
dfip-pg15, порт 55434) отдельным шагом плана.
Цена ошибки: несовместимость с PG15 выявится на шаге контроллера, а не у исполнителя — одна итерация правки.
- полный
-
2026-10-28 — регистр без зависимости от локали —
translateпо явной таблице (ASCII, кириллица U+0400–U+042F и U+0490, латиница-1), безlower()и без ICU. Обоснование: решение человека 10.lower()зависит отLC_CTYPE, ICU может отсутствовать в сборке PG. Цена ошибки: расхождение с JS для заглавных букв вне таблицы (греческие, расширенная латиница). В данных не ожидается, расширение таблицы — одна строка. -
2026-10-28 — при поиске и колоночном фильтре до прихода ответа показываются прежние строки, без индикатора загрузки. Индикатор и
Alertостаются только у «Загрузить»/«Обновить». Обоснование: сейчас поиск мгновенный и индикатора нет; это ближайшее к прежнему поведение. Так «Найдено» не мигает. Цена ошибки: при медленном сервере пользователь не видит, что запрос идёт. Добавить тонкую полосу прогресса — полчаса. -
2026-10-28 — значения для поиска строятся теми же выражениями, что
dataответа (header,contract_summary,contract_detailлиста), объединяютсяjsonb ||(перекрытие как у JS-спреда), числа приводятся к тексту функциейsvod2_js_number_text(правилаNumber::toStringповерх кратчайшей записиfloat8), обрезка — по набору пробельных символов ECMAScript. Обоснование: решение человека 9 — точное повторениеmatchesSearch. Цена ошибки: если есть расхождение, оно ловится оракулом Node на данных стенда до слияния. -
2026-10-28 —
ограничение длины. Отменено контроллером 28.10: противоречит решению человека 9 («поведение абсолютно такое же»). Новая формулировка: API не ограничивает длинуsearchи значений фильтров — 1000 символов (422)searchи значений фильтров и не отвечает ошибкой из-за длины; длинный запрос даёт тот же результат, что на экране (совпадения есть или нет). Защита от злоупотребления — только невидимая пользователю: общий предел длины строки запроса у веб-сервера (uvicorn/h11 и прокси платформы, порядка килобайт и выше) остаётся как есть, отдельного предела в API не вводим. Цена ошибки: очень длинный URL (десятки КБ) может упереться в предел прокси до приложения — на экране это нереалистичный ввод; если понадобится, фронт переведёт запросы на POST — полдня. -
2026-10-28 (план) — JS-оракул
perf/analysis/svod2_search_oracle.mjsпишется первой задачей фазы 1 и входит в PR 1, а не в PR 3. Режим сверки на стенде (--check) добавляется в фазе 3. Обоснование: требование человека — тесты-оракулы идут первыми и красными. Дословная копия правил экрана «до» должна существовать раньше SQL. Цена ошибки: файлperf/в серверном PR — нет. -
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. Цена ошибки: при смене векторов — повторная генерация одной командой. -
2026-10-28 (план) — эталон прежнего ответа на засеянных данных (
api/tests/fixtures/svod2_legacy_golden.json, sha256 тела) записывается один раз на коде «до» (Task 3) и после этого не перезаписывается. Обоснование: «байт-в-байт» проверяется вpytestна каждом прогоне, а не только сверкой на стенде. Цена ошибки: при изменении засева эталон придётся переснимать на коде «до» — около 15 минут. -
2026-10-28 (план) — засев свода 2:
- год 2031, явные id от 910001;
- скрипт выполняется в сессии фикстуры
client(client.db_session) черезclient.portal; - isp1 привязан к одной засеянной организации;
- добавлена четвёртая организация
«филиал ёлки »(вариант названия по регистру и пробелу); - добавлена строка без строки
contract_detail.
Обоснование: Review Focus плана — различимость вариантов фильтра и слияние пустой секции договора. Цена ошибки: нет.
-
2026-10-28 (план) — компактное
dataсодержит все группы и подгруппы путей всегда, возможно пустыми ({}): это ровно результатjsonb_strip_nulls(jsonb_build_object(...)). Python-проекцияcompact_projectionповторяет это. Обоснование: спецификация молчит о пустых группах. Экран читает пути через?., отображение то же. Цена ошибки: если нужен пропуск пустых групп — одна обёртка в SQL и в проекции. -
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 — переписать на подзапрос, тесты те же. -
2026-10-28 (план) — на фронте вся логика состояния вкладки — чистый редьюсер
svod2PagingReducerвsvod2Paging.js(покрытnode --test). ХукuseSvod2Pages.jsтолько ходит в API.SummaryApi.getSvod2Page/getSvod2Facetsполучают готовые параметры.measureElementпереопределяется в компоненте константойROW_HEIGHT, объектROW_VIRTUALIZER_OPTIONSне меняется. Обоснование: хук без тестового рендерера не проверить; поколения и устаревшие ответы — главный риск поведения. Цена ошибки: нет. -
2026-10-28 (план) — чистые помощники стенда для свода 2 вынесены в
perf/e2e/lib/svod2.ts, их тест —node --test(Node 24 стирает типы). Проверку типов иplaywright test --listделает контроллер. Обоснование: у исполнителей нетperf/e2e/node_modules, и стенд они не запускают. Цена ошибки: ошибка типов в сценарии ловится контроллером перед замером — одна итерация. -
2026-10-28 (план) — PR 1 и PR 2 ссылаются на issue как
Refs #236(часть 1/3, 2/3), PR 3 —Closes #236. Обоснование: стековые PR вливаются не вtest, и issue не должна закрыться до последней части. Цена ошибки: правка описания PR. -
2026-10-28 (план) — проверки, которые делает главный контроллер, не блокируют следующую фазу:
- PG15;
- стенд байт-в-байт и Excel;
- ручная проверка фазы 2;
playwright --list.
Исполнение ждёт контроллера только в конце фазы 3 (замеры). Красный результат проверки становится находкой
O-n, правка идёт в ветку своей фазы с каскадным rebase. Обоснование: «между фазами без остановки», а слияние всё равно только по команде человека. Цена ошибки: каскадный rebase — до часа. -
2026-10-28 (план) — нумерация
C/I/Mфинальных ревью сквозная по трём веткам, номера задач плана сквозные (1–18). Обоснование: маркеры не меняются при переносе между PR и follow-up. Цена ошибки: нет. -
2026-10-28 (план) — найденные странности (перекрытие полей поиска, поиск по техническим id) заносятся в таблицу находок сразу как
O-1иO-2со статусомdeferred. При закрытии они попадут в follow-up issue. Цена ошибки: нет. -
2026-10-28 (контроллер, R-1) — решения плана #23–#34 одобрены главным контроллером как есть. Цена ошибки: нет.
-
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не должен импортировать из другого тестового модуля. Цена ошибки: перенос функции. -
2026-10-28 (контроллер, R-3) — проверки разделены: PG15.12 выполняет контроллер SDD скриптом после снятия замка; сверку байт-в-байт на стенде, ручную проверку фазы 2,
playwright --list/типы perf/e2e и замеры выполняет главный контроллер, исполнение их не ждёт. Цена ошибки: отложенное обнаружение, каскадный rebase. -
2026-10-28 (контроллер, R-4) — в коммитах исполнителей остаётся трейлер
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>из плана и правил волны 2. Цена ошибки: amend/rebase трейлера. -
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. -
2026-10-28 (контроллер, R-6) —
v_svod2_facetsсчитаетsvod2_linesдважды: без поиска (все строки) иIN (hashed)по второму вызову с поиском. Design §4 описывал одну выборку с флагом совпадения; отклонение сделано в fix-раунде Task 5 (I-1 ревью Task 5: коррелированный подзапрос давал N²). Цена ошибки: двойной отбор при поиске в facets; влияет на время, не на корректность. Измерит контроллер. -
2026-10-28 (контроллер, R-7) — значения
search,smeta_type,smeta_direction,org_nameне пишутся в логdb_time(M-2): только признаки и длина. Обоснование: пользовательский текст (контрагенты, договоры) без ограничения длины не должен попадать в журналы. Цена ошибки: нет. -
2026-10-28 (контроллер, R-8) — M-4 (символ NUL в
search/фильтре даёт ошибку типаtextPostgreSQL, вероятно 5xx) не правится в этой задаче,deferred; с экрана такой символ не вводится. Цена ошибки: 5xx на искусственном запросе; правка — ранний возврат пустой страницы.