Поиск, колоночные фильтры, варианты фильтров и «Найдено N из M» — с точной семантикой экрана (matchesSearch, MRT equals, faceting TanStack); регистр без зависимости от локали БД; пороги — цели, а не условие слияния; решения контроллера (три фазы/ветки, prefetch страницы 1, окружение тестов). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
630 lines
64 KiB
Markdown
630 lines
64 KiB
Markdown
# 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` | `max_length=1000` |
|
||
| `smeta_type` | `str \| None` | `max_length=1000` |
|
||
| `smeta_direction` | `str \| None` | `max_length=1000` |
|
||
| `org_name` | `str \| None` | `max_length=1000` |
|
||
|
||
Значения фильтров — свободные строки, а не перечисления: семантика `equals` (регистр и пробелы по краям не важны) принимает и «кв», и «КВ ». Неизвестное значение даёт 0 строк, как на экране.
|
||
|
||
**Новый режим** включается, когда хотя бы один новый параметр не `None` (в том числе явный `view=full`). Ответ в нём — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). Прежний режим — тот же `BaseListResponse[SheetResponse]`. Аннотация возврата задаёт `response_model` и отрезала бы `total`, поэтому у обработчика нужно явно указать `response_model` (`Union` или `None`) и возвращать модель по режиму. Тест «в прежнем режиме нет `total`» обязателен.
|
||
|
||
**Фильтр «ССП»** — по `org_name` с семантикой `equals`, как сейчас: одинаковые названия отбираются вместе. Сужение `org_ids` до id отвергнуто: оно изменило бы поведение при одинаковых названиях. `org_ids` остаётся выбором «ССП/РФ» с проверкой прав.
|
||
|
||
**`total`** считается как `count(*) OVER ()` в выборке, которая нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, только чтобы узнать `total`.
|
||
|
||
### 2. Эндпоинт вариантов фильтров
|
||
|
||
`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=&search=&smeta_type=&smeta_direction=&org_name=` возвращает:
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"message": null,
|
||
"result": {
|
||
"total": 5563,
|
||
"search_total": 300,
|
||
"smeta_type": [{"value": "АХР", "count": 120}],
|
||
"smeta_direction": [{"value": "Поддержка", "count": 80}],
|
||
"org_name": [{"value": "…", "count": 12}]
|
||
}
|
||
}
|
||
```
|
||
|
||
- `total` — по году и `org_ids` (M);
|
||
- `search_total` — плюс поиск (N);
|
||
- варианты каждой колонки — по году, `org_ids`, поиску и фильтрам двух других колонок, значения различаются точно, `null` не выдаётся;
|
||
- порядок значений в ответе не важен: экран сортирует их так же, как MRT (`localeCompare`).
|
||
|
||
Права те же — через `_allowed_org_ids`; при пустом пересечении все нули и пустые списки. Неизвестный `sheet` — 404.
|
||
|
||
### 3. Семантика поиска и фильтров в SQL
|
||
|
||
**Регистр без зависимости от локали — `v3.svod2_casefold(text) RETURNS text`** (`LANGUAGE sql IMMUTABLE`): `translate(x, <прописные>, <строчные>)` по явной таблице.
|
||
- В таблице:
|
||
- ASCII `A-Z`;
|
||
- кириллица `А-Я` (U+0410–U+042F) → U+0430–U+044F;
|
||
- `Ѐ-Џ` (U+0400–U+040F, в том числе Ё, Є, І, Ї, Ў) → U+0450–U+045F;
|
||
- `Ґ` (U+0490) → U+0491;
|
||
- латиница-1 U+00C0–U+00DE, кроме `×` (U+00D7) → +0x20.
|
||
- `lower()` не используется: в `LC_CTYPE=C` он не трогает кириллицу, а в других локалях добавил бы преобразования, которых нет в таблице.
|
||
- Для символов из таблицы результат совпадает с JS `toLowerCase()`. Для прочих заглавных букв (греческие, расширенная латиница, особые случаи вроде `İ`) результат расходится, когда они есть в данных или запросе. В данных свода их не ожидается.
|
||
- Отвергнуты:
|
||
- `COLLATE "und-x-icu"` — PG может быть собран без ICU;
|
||
- `lower()` — зависит от `LC_CTYPE`.
|
||
|
||
**Обрезка как в JS — `v3.svod2_js_trim(text)`**: `btrim` по явному набору символов ECMAScript `WhiteSpace` и `LineTerminator`:
|
||
- U+0009, U+000A, U+000B, U+000C, U+000D, U+0020, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF.
|
||
|
||
Запрос поиска нормализуется на сервере в SQL так: `svod2_casefold(svod2_js_trim(p_search))`. Значения фильтров нормализуются как `svod2_js_trim(svod2_casefold(x))` — порядок как в MRT `equals`: сначала нижний регистр, потом обрезка. Пустой результат условия не накладывает.
|
||
|
||
**Число в записи JS — `v3.svod2_js_number_text(double precision) RETURNS text`** (`IMMUTABLE`, `SET extra_float_digits = 1`).
|
||
- Цифры берутся из `x::text`: начиная с PG12, при `extra_float_digits > 0` это кратчайшая точная запись (Ryu), те же цифры, что у V8. `SET` в функции нужен, чтобы настройка сессии или сервера (`extra_float_digits <= 0` даёт `%.15g`) не влияла на результат.
|
||
- Из записи PG (`1234.5` или `1.5e+16`, `1e-05`) извлекаются цифры `s` (k штук) и десятичный показатель `n`. Затем применяется правило ECMAScript `Number::toString`:
|
||
- если `k ≤ n ≤ 21` — цифры и `n−k` нулей;
|
||
- если `0 < n ≤ 21` — цифры с точкой после `n`-й;
|
||
- если `−6 < n ≤ 0` — `0.`, `−n` нулей и цифры;
|
||
- иначе — экспоненциальная запись `d[.ddd]e±(n−1)`;
|
||
- отдельно знак минус и ноль `0`.
|
||
- Вход — `jsonb`-число из ответа, приведённое к `float8`. Это то же округление до двойной точности, что `json.loads` + `JSON.parse`. Целые больше 2^53 округляются так же, как в браузере.
|
||
- Корректность доказывается таблицей векторов. Набор значений (целые, дроби с хвостовыми нулями, `0.1+0.2`-подобные, 1e-7, 1e-6, 1e15, 1e21, большие целые, отрицательные) прогоняется через `String(Number(x))` в Node.js, результат коммитится как `api/tests/fixtures/js_number_text_vectors.json`. Интеграционный тест сверяет SQL-функцию со всеми векторами.
|
||
|
||
**Текст значения для поиска** зависит от `jsonb_typeof`:
|
||
|
||
| тип | текст |
|
||
|---|---|
|
||
| `string` | `#>> '{}'` |
|
||
| `number` | `svod2_js_number_text((v #>> '{}')::float8)` |
|
||
| `boolean` | `true`/`false` |
|
||
| `null` | пустая строка (никогда не совпадает с непустым запросом) |
|
||
|
||
**Набор значений для поиска** строится из **тех же выражений, что и `data`** ответа, но только для трёх групп и только при непустом поиске:
|
||
- `header_j` — тот же `jsonb_build_object` заголовка, что в 0049:191-203 / 489-501;
|
||
- `cs_j = COALESCE(to_jsonb(cs) - 'line_id', <пустая секция>)`;
|
||
- `cd_j` — как в ответе листа: для RF с `expenses_qN = COALESCE(cd, ckk)` (решение контроллера `#16`).
|
||
|
||
Объединение — `header_j || cs_j || cd_j`. Оператор `||` у `jsonb` перекрывает одноимённые ключи правым операндом, ровно как `{...a, ...b}` в JS, и перекрытые поля `contract_summary` пропадают так же. Предикат:
|
||
|
||
```sql
|
||
EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e
|
||
WHERE strpos(svod2_casefold(<текст e.value>), q) > 0)
|
||
```
|
||
|
||
Соединения с `contract_summary`, `contract_detail` и `ckk` уже есть в отборе: `presence_*`, связь 1:1 по `line_id`.
|
||
|
||
**Фильтры** — по столбцам отбора `smeta_type`, `smeta_direction`, `org_name`. Это те же выражения, что `header.smeta_type`, `header.smeta_direction`, `header.org_name` в ответе: условие `x IS NOT NULL AND svod2_js_trim(svod2_casefold(x)) = <нормализованный параметр>`.
|
||
|
||
### 4. SQL: новая миграция
|
||
|
||
Файлы `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова; номер — в разделе «Совместимость».
|
||
|
||
**Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется, поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку.
|
||
|
||
**Общий отбор `v3.svod2_lines(p_sheet varchar, p_year int, p_org_unit_ids int[], p_search text)`.**
|
||
- Возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`.
|
||
- Тело — `eligible_lines` из 0049 **без изменения условий отбора**:
|
||
- типы форм выбираются по `p_sheet` (`GO` → FORM_1/FORM_4, `RF` → FORM_2);
|
||
- добавлены столбец `smeta_direction` (тот же `CASE bl.direction`, что в `header`) и предикат поиска из раздела 3 в виде `p_search IS NULL OR …` (`p_search` здесь уже нормализован).
|
||
- Колоночные фильтры сюда не входят: их по-разному применяют страница (все три) и варианты (все, кроме своего).
|
||
- Функцию писать `LANGUAGE sql STABLE` без `SET search_path` (имена с `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивания нет, это допустимо (`#3`).
|
||
|
||
**Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.**
|
||
- Аргументы: прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type text DEFAULT NULL`, `p_smeta_direction text DEFAULT NULL`, `p_org_name text DEFAULT NULL`, `p_compact boolean DEFAULT false`.
|
||
- Результат: прежние 8 столбцов + `total_count bigint`.
|
||
- Сначала нормализация: обрезка и регистр, пустое значение → `NULL`.
|
||
- `numbered_lines` строится по `svod2_lines(...)` с тремя фильтрами, с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`.
|
||
- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF.
|
||
- `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END`.
|
||
- Вызов прежними 6 аргументами (Excel RF, прежний режим API) даёт те же строки, `sort_order` и `data`.
|
||
|
||
**`v3.svod2_compact(data jsonb) RETURNS jsonb`** (`IMMUTABLE`) — `jsonb_strip_nulls(jsonb_build_object(...))` по путям из требования «Компактное представление строки». Это `export_keys` из `summary_repository.py:85-140` плюс `line_id`, `header.source_form`, `header.source_id` (для `getRowId`, `Svod2Page.jsx:397`).
|
||
|
||
**`v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text)`** возвращает строки `(kind varchar, value varchar, n bigint)`:
|
||
- `kind` принимает значения `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name`;
|
||
- одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска;
|
||
- три `GROUP BY` со своими условиями «поиск + фильтры двух других колонок» (`GROUP BY` по `varchar` с детерминированной сортировкой различает строки побайтно, как ключи `Map` в JS);
|
||
- два счётчика: `total` без условий и `search_total` только с поиском;
|
||
- форму ответа API собирает репозиторий.
|
||
|
||
**PG15.** Нельзя использовать возможности PG16+: `any_value`, конструкторы SQL/JSON, `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` нужны алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `jsonb_each`, `translate`, `btrim(text, text)`, `strpos` есть в PG15.
|
||
|
||
**`v3.v_svod2_go_export` не трогается.**
|
||
|
||
### 5. Сервис и репозиторий
|
||
|
||
- `SummaryRepository.get_svod2_rows` — без изменений (прежний режим и Excel RF).
|
||
- Новые методы:
|
||
- `SummaryRepository.get_svod2_page(...)` → `(rows, total)`;
|
||
- `SummaryRepository.get_svod2_facets(...)`;
|
||
- `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`.
|
||
- Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`.
|
||
- `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах.
|
||
- Нормализацию (обрезку и регистр) делает SQL, а не Python: одна реализация на значения и запрос.
|
||
|
||
### 6. Фронт: окно страниц в той же таблице
|
||
|
||
**Константы и окно.**
|
||
- `PAGE_SIZE = 300`;
|
||
- окно — видимая страница ±1, в данных таблицы не больше 3 страниц;
|
||
- кэш страниц — LRU не больше 5 страниц на вкладку (`#5`);
|
||
- в положении «в начале» окно — страницы 0 и 1, то есть страница 1 запрашивается сразу (`#15`).
|
||
|
||
**Высота строки** берётся из того же источника, что `estimateSize` виртуализатора: сейчас 37 px, после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()`. Окно требует фиксированной высоты: пока 1836 не влита, `measureElement` возвращает константу.
|
||
|
||
**Данные таблицы — срез окна `[windowStart, windowEnd)`.**
|
||
- В срезе загруженные строки и заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна.
|
||
- Позиция задаётся отступами виртуализатора строк: `{ ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`.
|
||
- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают отступы (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` стоит на `i * rowH`, а полоса прокрутки соответствует `total`.
|
||
- Объектов строк TanStack — не больше окна.
|
||
- Объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`, hunk AURORA-1836) **не править**.
|
||
|
||
**Прокрутка.**
|
||
- `muiTableContainerProps.onScroll` через `requestAnimationFrame` считает видимую страницу по `scrollTop`.
|
||
- При смене страницы пересчитываются окно и данные, недостающие страницы окна запрашиваются.
|
||
- Программно положение прокрутки не меняется (решение 9): при смене вкладки, поиска или «Загрузить» окно считается от текущего `scrollTop`, который браузер ограничит при уменьшении высоты. Так же ведёт себя таблица сейчас.
|
||
|
||
**Заглушки.**
|
||
- `muiTableBodyRowProps` для заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`. При rebase объединяется со `style.height` из 1836.
|
||
- Правило в `SVOD2_TABLE_CONTAINER_SX` делает текст прозрачным, `Cell`-функции не меняются.
|
||
- `getRowId` заглушки — `ph-${index}`.
|
||
|
||
**Запросы** (`web/src/api/summary.js`):
|
||
- `getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection, orgName }, { signal })` — всегда `view=compact`, пустые параметры не передаются, `search` уходит как введён;
|
||
- `getSvod2Facets(sheet, year, { orgIds, search, smetaType, smetaDirection, orgName }, { signal })`;
|
||
- `get` остаётся для свода 1.
|
||
|
||
**Состояние вкладки** — хук без JSX, например `useSvod2Pages.js`. Хранит `queryKey`, `generation`, `total`, `facets`, `pages` (LRU) и `inflight` (`AbortController`). Чистая логика вынесена в `web/src/pages/Svod2Page/svod2Paging.js` и покрыта `node --test`:
|
||
- окно;
|
||
- срез с заглушками;
|
||
- LRU;
|
||
- параметры запроса;
|
||
- преобразование `facets` в `Map` для MRT;
|
||
- условие показа «Найдено».
|
||
|
||
Ответ устаревшего `generation` отбрасывается, текущие запросы отменяются.
|
||
|
||
**Что вызывает запросы:**
|
||
- «Загрузить» или «Обновить» — новый `generation`, `loading = true` (как сейчас: индикатор, `Alert` при ошибке, «Найдено» скрыто), запросы `facets` и страниц окна;
|
||
- изменение поиска (после debounce) или колоночного фильтра при загруженных данных — новый `generation`, запросы `facets` и страниц окна. `loading` **не** выставляется, прежние строки остаются до ответа (`#20`);
|
||
- смена года или организаций — как сейчас, подсказка «Нажмите «Загрузить»…»;
|
||
- переключение вкладки — состояние вкладки сохраняется, запрашиваются только недостающие страницы окна.
|
||
|
||
**Колоночные фильтры и варианты.** Цель — сохранить рендер MRT без изменений:
|
||
- `manualFiltering: true`, `enableFacetedValues: true`;
|
||
- в опциях таблицы переопределяется `getFacetedUniqueValues: (table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_MAP`. Пользовательские опции перекрывают значения MRT по умолчанию (`useMRT_TableOptions`, `Object.assign(defaults, rest)`);
|
||
- MRT сам отбросит `null`, отсортирует `localeCompare` и покажет числа в скобках, как сейчас;
|
||
- `filterSelectOptions` **не** задавать: с ними MRT не показывает числа;
|
||
- значения фильтров уходят на сервер как есть (`smeta_type`, `smeta_direction`, `org_name`);
|
||
- сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется.
|
||
|
||
**«Найдено N из M»:** N = `facets.search_total`, M = `facets.total`, условие показа — прежнее: `hasCurrentData && !loading && N !== M`.
|
||
|
||
**Ошибки.**
|
||
- «Загрузить», «Обновить», поиск или фильтр — прежний `Alert` с «Повторить».
|
||
- Догрузка страницы при прокрутке — `toast.error`, страница остаётся незагруженной и запрашивается при повторном попадании в окно.
|
||
|
||
**Изменение `total` между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез пересчитывается.
|
||
|
||
**`SvodContext` не меняется.** Свод 2 перестаёт звать `getSummary`/`invalidateSummary`.
|
||
|
||
**Запасной вариант (`#6`).** Если отступы в MRT 3.2.1 работают некорректно (проверка — первая задача фазы 2), допускается массив длины `total` с общей заглушкой. Переход записывается в раздел решений без обсуждения.
|
||
|
||
### 7. Стенд `perf/` (фаза 3)
|
||
|
||
**`scroll.spec.ts`**, только `kind === 'svod2'`. Код должен работать и со сборкой «до».
|
||
- `waitRows` считает `tbody tr:not([data-svod2-placeholder])`.
|
||
- `first_rows_ms` — от «Загрузить» до `MIN_ROWS` строк с данными.
|
||
- Байты:
|
||
- `json_bytes_sheet` — прежний смысл: максимум одного ответа;
|
||
- `json_bytes_total` — новое, сумма всех ответов;
|
||
- `json_bytes_first` — новое, сумма до первых строк.
|
||
|
||
`respRe` включает `/facets`.
|
||
- `placeholder_frame_share` и `pages_requested`.
|
||
- Тест фильтра: после `fill('1')` ждать ответ с `search=` (до 5 с; если запроса нет — это сборка «до»), затем `awaitStable`, метрика `filter_result_ms`.
|
||
- Клики `:194-200` и подписи не меняются.
|
||
|
||
**Новый `svod2-memory.spec.ts`.**
|
||
- Роль dfip, свежий контекст на повтор, по умолчанию 3 повтора.
|
||
- Точки:
|
||
1. `go` — GO «Все» → «Загрузить» → строки;
|
||
2. `rf` — RF «Все» → «Загрузить» → строки;
|
||
3. `rf_scrolled` — по `PERF_SVOD2_MEM_SCROLL=1`, на масштабе 1;
|
||
4. `final` — вкладка GO.
|
||
- В каждой точке `heapUsed` после GC (`lib/cdp.ts:45`) и `domCounters`.
|
||
|
||
**`api_split.py`.** Прежние строки остаются. Добавить:
|
||
- GO/RF `view=full&offset=0&limit=300`;
|
||
- GO/RF `view=compact&offset=0&limit=300`;
|
||
- `/facets`;
|
||
- RF `view=compact&…&search=1`.
|
||
|
||
**`explain_manual.py`.** Новые записи:
|
||
- `svod2_go_page` (`v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`);
|
||
- `svod2_rf_page`;
|
||
- `svod2_rf_search` (`'1'`);
|
||
- `svod2_go_facets`.
|
||
|
||
**`scale_compare.py`** (`:43-44`): раздельные ключи для полного ответа, страницы (`limit` в query) и `facets`.
|
||
|
||
**`perfkit.bench`.** Повторит каждый URL страницы. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`.
|
||
|
||
**Сверка экрана и сервера на данных стенда** (только чтение), запускать перед замером «после»:
|
||
- `perf/analysis/svod2_paging_check.py` (Python) — склейка страниц, `total`, проекция compact;
|
||
- `perf/analysis/svod2_search_oracle.mjs` (Node) — **дословная копия** `matchesSearch`, фильтра MRT `equals` и подсчёта вариантов по правилам TanStack из кода «до»; применяется к прежнему полному ответу и сравнивается с ответами страниц и `facets` по набору поисков и фильтров.
|
||
|
||
## Найденные странности (не чиним в этой задаче)
|
||
|
||
- **Перекрытие полей поиска.** `matchesSearch` (`Svod2Page.jsx:284-288`) сливает `{...header, ...contract_summary, ...contract_detail}`. Поля `contract_summary.counterparty/reference/deadline/comment` перекрываются одноимёнными `contract_detail` и в поиске не участвуют, даже если `contract_detail` пуст. Скорее всего, это непреднамеренно. По решению 9 поведение сохраняется; кандидат в follow-up (статус `deferred`).
|
||
- **Поиск по техническим id.** `source_id`, `vsp_id`, `expense_item_id`, `source_org_unit_id` тоже ищутся, поэтому запрос «1» находит почти всё. Сохраняется; кандидат в follow-up (`deferred`).
|
||
|
||
## Совместимость и порядок PR
|
||
|
||
**Один план, три фазы и три ветки стеком под issue #236:**
|
||
- `feat/236-aurora-1843-svod2-server-paging` — PR 1, сервер, база — ветка 1842;
|
||
- `feat/236-aurora-1843-svod2-server-paging-web` — PR 2;
|
||
- `feat/236-aurora-1843-svod2-server-paging-perf` — PR 3.
|
||
|
||
PR создаются по готовности, merge — по команде человека (`#14`). Каждый PR зелёный сам по себе: после PR 1 старый фронт работает.
|
||
|
||
**Номер миграции.** Голова в ветке — `0049`. Номер `0050` заняли AURORA-1831, 1844 и 1846. Рабочий номер — `0053_svod2_server_paging`. При rebase PR 1:
|
||
- проверить `alembic heads` (должна остаться одна голова);
|
||
- поставить номер «голова + 1»;
|
||
- переименовать `.py` и `.sql`;
|
||
- поправить `revision`, `down_revision` и имя в `run_sql_file_migration`;
|
||
- перенацелить `test_summary2_related_records_sql.py`.
|
||
|
||
**AURORA-1836** правит `:26-31` (`2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3) и `muiTableBodyRowProps.style.height`. 1843 эти строки не трогает, опции собирает в компоненте. Высота строки окна — из того же источника. Если 1836 вольётся до замера, базу «до» переснять.
|
||
|
||
**AURORA-1848** переписывает тот же `get_summary2`. Кто вливается вторым, сохраняет оба режима. «Байт-в-байт» 1843 относится к состоянию до 1848.
|
||
|
||
**AURORA-1917 (O-5).** Не вливать между «до» и «после» 1843. Если придёт раньше, правка отбора ложится в `v3.svod2_lines` и `v_svod2_go_export`.
|
||
|
||
**Выкатка и откат.** Миграция только заменяет функции. Откат PR 1 — новой миграцией с телами из 0049. Откат PR 2 безопасен сам по себе.
|
||
|
||
## Тестирование (что доказывает корректность)
|
||
|
||
**Окружение (`#18`).**
|
||
- Полный `pytest` идёт на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1843`.
|
||
- Создание: `docker exec perf-pg createdb -U perf dfip_1843`.
|
||
- Запуск: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 ../perf/.venv/bin/pytest tests --init_db`; перед каждым прогоном с `--init_db` БД пересоздаётся.
|
||
- БД `perf` не трогать. Пароли `OPENBAO__TEST_*` по умолчанию `admin123` (`tests/conftest.py`).
|
||
- Совместимость с PG15.12 — отдельный шаг плана «проверка контроллером», контейнер `dfip-pg15`, порт 55434.
|
||
|
||
**Фаза 1, `api/tests/unit`:**
|
||
- валидация (по образцу `test_query_validation.py`): 422 на `search`/фильтры длиннее 1000 и на прежние невалидные параметры;
|
||
- сервис:
|
||
- `_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).**
|
||
Обоснование: защита от очень длинных URL. На экране ограничения нет, но поиск длиннее 1000 символов нереалистичен.
|
||
Цена ошибки: пользователь с таким запросом увидит ошибку вместо пустого результата. Снять ограничение — одна строка.
|