docs(openspec): aurora-1843 — поведение свода 2 без изменений, решения 28.10
Поиск, колоночные фильтры, варианты фильтров и «Найдено N из M» — с точной семантикой экрана (matchesSearch, MRT equals, faceting TanStack); регистр без зависимости от локали БД; пороги — цели, а не условие слияния; решения контроллера (три фазы/ветки, prefetch страницы 1, окружение тестов). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6e607b1647
commit
9d68cd5e1a
@ -1,113 +1,171 @@
|
|||||||
# Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ
|
# Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ
|
||||||
|
|
||||||
Мотивация и цифры — в `proposal.md`, требования — в `specs/summary2-api/spec.md` и `specs/summary2-screen/spec.md`. Ссылки `file:line` ниже даны по вершине ветки `feat/236-aurora-1843-svod2-server-paging`: это `399234a`, вершина AURORA-1842.
|
Мотивация и цифры — в `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
|
||||||
|
`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`;
|
- обработчик — `api/src/api/v1/summary.py:93-146`. Он уже принимает `org_ids`, `offset`, `limit` (`:99-100`), `sort_by` и `sort_direction`;
|
||||||
- `count = len(rows)` (`:143-146`);
|
- `count = len(rows)` (`:143-146`);
|
||||||
- ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`), а `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`.
|
- ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`);
|
||||||
|
- `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`.
|
||||||
|
|
||||||
Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`):
|
Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`) пересекает запрошенные организации с разрешёнными через `_allowed_org_ids` (`:73-88`):
|
||||||
- через `_allowed_org_ids` (`:73-88`) пересекает запрошенные организации с разрешёнными;
|
- у администратора `None` означает «все»;
|
||||||
- для администратора `None` означает «все»;
|
- у остальных пустое пересечение даёт `[]`.
|
||||||
- для остальных пустое пересечение даёт `[]`.
|
|
||||||
|
|
||||||
Репозиторий — `api/src/repository/summary_repository.py:34-77`, это `SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов)`.
|
Репозиторий (`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:
|
### SQL
|
||||||
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`;
|
Актуальные `v3.v_svod2_go` и `v3.v_svod2_rf` лежат в `api/alembic/versions/sql/0049_summary2_related_records.sql`: `:6-303` и `:305-595`. Каждая функция собрана из четырёх CTE:
|
||||||
2. `numbered_lines` — `row_number()` по `org_name`/`item_id` или `section_code`, затем `form_type_code`, `form_id`, `line_id` (`:151-168`);
|
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`.
|
||||||
3. `page AS MATERIALIZED` — `LIMIT p_limit OFFSET p_offset` (`:169-173`);
|
2. `numbered_lines`: `row_number()` (`:151-168`).
|
||||||
4. сборка большого `jsonb` только для строк страницы (`:179-302`).
|
3. `page AS MATERIALIZED` с `LIMIT`/`OFFSET` (`:169-173`).
|
||||||
|
4. Сборка большого `jsonb` только для строк страницы (`:179-302`).
|
||||||
|
|
||||||
Значит, стоимость страницы — это полный отбор плюс сборка `jsonb` только для этой страницы. `v3.v_svod2_go_export` (`:597-902`) — отдельная компактная функция для Excel GO, её не трогаем. Прежние перегрузки этих функций уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре: `0022_summary2_contract_rows.sql:11-13`, `:200-202`.
|
Значит, стоимость страницы — полный отбор плюс сборка `jsonb` только для этой страницы.
|
||||||
|
|
||||||
**Кто зовёт `GET /svod/2/...` и `get_svod2_rows`:**
|
В листе RF `contract_detail` в ответе — это `to_jsonb(cd)`, дополненный `expenses_q1..4 = COALESCE(cd.expenses_qN, ck.expenses_qN)` (`:543-547`). В GO — просто `to_jsonb(cd)`.
|
||||||
- фронт — только `Svod2Page` через `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`, без `offset`/`limit`);
|
|
||||||
- Excel RF — `export_service.py:236-245`, `offset=None`, `limit=None`;
|
|
||||||
- интеграционные тесты — `api/tests/integration/test_summary_api_smoke.py:44-140`;
|
|
||||||
- стенд: `perfkit.gen.verify` (`perf/perfkit/gen/verify.py:146-148`), `perfkit.bench` (повторяет URL из сетевого лога), `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`.
|
|
||||||
|
|
||||||
**Фронт.** `web/src/pages/Svod2Page/Svod2Page.jsx`:
|
`v3.v_svod2_go_export` (`:597-902`) — отдельная функция для Excel GO, её не трогаем. Прежние перегрузки уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре (`0022_summary2_contract_rows.sql:11-13`, `:200-202`).
|
||||||
- состояние вкладок `tableStateBySheet` держит `rows` целиком (`:21-22`, `:302`, `:345-348`);
|
|
||||||
- `loadSummary` (`:335-367`) берёт ответ из кэша `SvodContext` (`web/src/pages/SvodPage/SvodContext.jsx:46-70`). Ключ кэша — лист + год + организации, кэш очищается при размонтировании провайдера маршрута `/svod/2` (`web/src/app/Routes.jsx:54-59`). `SvodContext` и `SvodFilters` общие со сводом 1;
|
|
||||||
- поиск — `matchesSearch` (`:283-294`) по `{...header, ...contract_summary, ...contract_detail}`, фильтрация в `filteredRows` (`:387-391`), debounce ввода 300 мс в `SvodFilters.jsx:68-77`;
|
|
||||||
- три колоночных select-фильтра — `smeta_type`, `smeta_direction`, `org_name` (`:158-176`) — используют `enableFacetedValues` (`:400`). Сортировка выключена (`:411`);
|
|
||||||
- «Найдено N из M» (`:502-506`) считает только поиск, колоночные фильтры не учитывает;
|
|
||||||
- изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`);
|
|
||||||
- данные без «Загрузить» не грузятся (`:448-455`). «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`), сервер подставляет все разрешённые;
|
|
||||||
- виртуализация строк — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: `ROW_HEIGHT = 37`, `measureElement` по `offsetHeight`). Виртуализация колонок из 1842 — `:33-34`, `:407-408`;
|
|
||||||
- кнопки Excel-выгрузки свода 2 на фронте нет: по `web/src` нет ни `export/summary`, ни `summary/2`.
|
|
||||||
|
|
||||||
**Стенд.** `perf/e2e/scenarios/scroll.spec.ts`:
|
### Кто зовёт `GET /svod/2/...` и `get_svod2_rows`
|
||||||
- для свода 2 кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`);
|
- фронт — только `Svod2Page` → `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`), без `offset` и `limit`;
|
||||||
- ждёт `MIN_ROWS` строк `tbody tr` (`waitRows`);
|
- Excel RF (`export_service.py:236-245`);
|
||||||
- `json_bytes_sheet` — максимум байтов одного ответа по `respRe` (`:176-184`);
|
- `api/tests/integration/test_summary_api_smoke.py:44-140`;
|
||||||
- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`armStable`/`awaitStable`, `:113-150`, `:221-227`);
|
- стенд: `perfkit.gen.verify` (`verify.py:146-148`), `perfkit.bench`, `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`.
|
||||||
- времени до первых строк сценарий не пишет;
|
|
||||||
- сценария памяти для сводов нет: `memory.spec.ts` их не открывает.
|
|
||||||
|
|
||||||
`perf/analysis/explain_manual.py` функции свода 2 **не** использует (запросы — только FORM_2/AHR и FORM_4/CAP, `:59-81`), поэтому смена сигнатуры его не ломает.
|
### Фронт: точная текущая семантика
|
||||||
|
`web/src/pages/Svod2Page/Svod2Page.jsx` и MRT 3.2.1 с TanStack Table 8.20.5.
|
||||||
|
|
||||||
**Тестовые данные.** В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2: единственная строка FORM_1 относится к 2027 году и без данных. Смоук-тесты свода 2 фактически проверяют пустой ответ.
|
**Поиск — `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 ограничена окном строк и не растёт с объёмом данных;
|
- память страницы свода 2 ограничена окном строк;
|
||||||
- первые строки появляются за время одной страницы, а не всего ответа;
|
- первые строки появляются за время одной-двух страниц;
|
||||||
- байты и Python-время на строку уменьшаются за счёт компактного вида;
|
- меньше байтов на строку;
|
||||||
- поиск и фильтры не обходят все строки в браузере;
|
- поиск и фильтры не обходят все строки в браузере;
|
||||||
|
- для пользователя ничего не меняется, кроме ожидания ответа сервера;
|
||||||
- прежние потребители API не замечают изменения.
|
- прежние потребители API не замечают изменения.
|
||||||
|
|
||||||
**Не цели:**
|
**Не цели:**
|
||||||
- менять отбор строк свода 2 (это O-5 / AURORA-1917);
|
- менять отбор строк свода 2 (O-5, AURORA-1917);
|
||||||
|
- менять семантику поиска или фильтров, включая исправление перекрытия полей поиска;
|
||||||
- адаптировать Excel-выгрузку под экранные фильтры;
|
- адаптировать Excel-выгрузку под экранные фильтры;
|
||||||
- серверная сортировка по клику на заголовок (на экране её нет);
|
- серверная сортировка;
|
||||||
- отказ от pydantic на строку (это AURORA-1848);
|
- отказ от pydantic на строку (AURORA-1848);
|
||||||
- полнотекстовый или триграммный индекс поиска;
|
- индекс для поиска;
|
||||||
- менять свод 1 и общий `SvodFilters`.
|
- изменения свода 1 и `SvodFilters`.
|
||||||
|
|
||||||
## Решения (согласовано 28.10)
|
## Решения (согласовано 28.10)
|
||||||
|
|
||||||
1. Вместо аналитики — реализация, варианты C + B черновика. Вариант A (только политика кэша на фронте) не нужен.
|
1. Вместо аналитики — реализация, варианты C + B черновика; вариант A не нужен.
|
||||||
2. Сервер. У существующих `offset`/`limit` появляются серверный поиск (по тем же полям, что `matchesSearch` сейчас), три фильтра (`smeta_type`, `smeta_direction`, организация через `org_name`/`org_ids`) и поле `total`. `count` сохраняет прежний смысл (длина `result`).
|
2. **Сервер:**
|
||||||
3. Отдельный лёгкий запрос вариантов для трёх фильтров (facets).
|
- у существующих `offset`/`limit` появляются серверный поиск, три фильтра (`smeta_type`, `smeta_direction`, организация — `org_name`/`org_ids`) и поле `total`; `count` сохраняет прежний смысл;
|
||||||
4. Компактный ответ (только поля, которые экран читает или ищет) включается параметром, например `view=compact`. Без новых параметров ответ байт-в-байт прежний.
|
- отдельный лёгкий запрос вариантов фильтров (facets);
|
||||||
5. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет. Выгрузку под экранные фильтры не подгоняем.
|
- компактный ответ включается параметром (`view=compact`);
|
||||||
6. Фронт: та же таблица, строки догружаются страницами при прокрутке. В памяти — окно плюс запас, а не весь ответ. Поиск и фильтры уходят на сервер (debounce 300 мс уже есть), «Найдено N из M» строится из `total`. Кэш `SvodContext` перестаёт копить полные ответы.
|
- без новых параметров ответ байт-в-байт прежний.
|
||||||
7. SQL: новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и сценарий `scroll.spec.ts` (клик «Все ССП/РФ» → «Загрузить») обновляются вместе с UX, чтобы замер оставался сопоставимым.
|
3. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет — выгрузку под экранные фильтры не подгоняем.
|
||||||
8. Три PR в стеке: сервер (SQL + API + тесты), фронт (догрузка, поиск, фильтры), замер и сценарии perf.
|
4. **Фронт:**
|
||||||
9. AURORA-1917 (O-5) — отдельная задача. Её нельзя вливать между замерами «до» и «после» 1843.
|
- та же таблица, строки догружаются страницами при прокрутке, в памяти окно плюс запас;
|
||||||
10. Метрики успеха: память страницы после GO «все» → RF «все» → GO (нужен новый сценарий `svod2-memory.spec.ts`), время до первых строк, байты ответа, шаг прокрутки свода 2 не хуже результата 1842. Масштабы 1 и 2.
|
- поиск и фильтры уходят на сервер (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}`
|
### 1. Контракт `GET /api/v1/svod/2/{sheet}/{year}`
|
||||||
|
|
||||||
- **Прежние параметры** без изменений: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`.
|
**Прежние параметры** не меняются: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`.
|
||||||
- **Новые параметры, все необязательные:**
|
|
||||||
|
|
||||||
| параметр | тип | ограничения |
|
**Новые параметры** (все необязательные):
|
||||||
|
|
||||||
|
| параметр | тип | ограничение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `view` | `Literal["full","compact"] \| None` | |
|
| `view` | `Literal["full","compact"] \| None` | |
|
||||||
| `search` | `str \| None` | `max_length=200`, обрезается по краям |
|
| `search` | `str \| None` | `max_length=1000` |
|
||||||
| `smeta_type` | `Literal["АХР","КВ","Операц"] \| None` | |
|
| `smeta_type` | `str \| None` | `max_length=1000` |
|
||||||
| `smeta_direction` | `Literal["Поддержка","Развитие"] \| None` | |
|
| `smeta_direction` | `str \| None` | `max_length=1000` |
|
||||||
|
| `org_name` | `str \| None` | `max_length=1000` |
|
||||||
|
|
||||||
- **Новый режим** включается, когда хотя бы один новый параметр не `None`, в том числе явный `view=full`. В новом режиме ответ — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). В прежнем режиме ответ тот же `BaseListResponse[SheetResponse]`, что и раньше.
|
Значения фильтров — свободные строки, а не перечисления: семантика `equals` (регистр и пробелы по краям не важны) принимает и «кв», и «КВ ». Неизвестное значение даёт 0 строк, как на экране.
|
||||||
- Аннотация возврата сейчас задаёт `response_model`, и модель отрезала бы `total`. Поэтому у обработчика нужно явно задать `response_model=Union[...]` или `response_model=None` и вернуть нужную модель по режиму.
|
|
||||||
- Обязателен тест «в прежнем режиме нет `total`».
|
**Новый режим** включается, когда хотя бы один новый параметр не `None` (в том числе явный `view=full`). Ответ в нём — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). Прежний режим — тот же `BaseListResponse[SheetResponse]`. Аннотация возврата задаёт `response_model` и отрезала бы `total`, поэтому у обработчика нужно явно указать `response_model` (`Union` или `None`) и возвращать модель по режиму. Тест «в прежнем режиме нет `total`» обязателен.
|
||||||
- **Значения фильтров** — те же строки, что видит экран в `data.header` (`АХР`, `КВ`, `Операц`; `Поддержка`, `Развитие`), а не коды БД. Так варианты из facets подставляются в запрос без перевода.
|
|
||||||
- **Фильтр по организации** не получает нового параметра: экран сужает `org_ids` до id, выбранного в колонке «ССП», а сервер, как и раньше, пересекает его с разрешёнными (`_allowed_org_ids`).
|
**Фильтр «ССП»** — по `org_name` с семантикой `equals`, как сейчас: одинаковые названия отбираются вместе. Сужение `org_ids` до id отвергнуто: оно изменило бы поведение при одинаковых названиях. `org_ids` остаётся выбором «ССП/РФ» с проверкой прав.
|
||||||
- Альтернатива — `org_name` (точное совпадение по названию, как сейчас на клиенте) — отвергнута. Названия не уникальны по схеме (`org_unit.title` без `unique`), а фильтр по `bf.org_unit_id` уже есть в SQL.
|
|
||||||
- **`total`** — `count(*) OVER ()` в той же выборке, что нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, чтобы узнать `total`: это редкий путь, второго SQL на каждую страницу нет.
|
**`total`** считается как `count(*) OVER ()` в выборке, которая нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, только чтобы узнать `total`.
|
||||||
|
|
||||||
### 2. Эндпоинт вариантов фильтров
|
### 2. Эндпоинт вариантов фильтров
|
||||||
|
|
||||||
`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=...` возвращает:
|
`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=&search=&smeta_type=&smeta_direction=&org_name=` возвращает:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -115,395 +173,457 @@
|
|||||||
"message": null,
|
"message": null,
|
||||||
"result": {
|
"result": {
|
||||||
"total": 5563,
|
"total": 5563,
|
||||||
"smeta_type": ["АХР", "КВ", "Операц"],
|
"search_total": 300,
|
||||||
"smeta_direction": ["Поддержка", "Развитие"],
|
"smeta_type": [{"value": "АХР", "count": 120}],
|
||||||
"org": [{"id": 12, "name": "…"}]
|
"smeta_direction": [{"value": "Поддержка", "count": 80}],
|
||||||
|
"org_name": [{"value": "…", "count": 12}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Обёртка — `BaseSingleResponse[Svod2Facets]`.
|
- `total` — по году и `org_ids` (M);
|
||||||
- Варианты считаются по базовому множеству (год + организации) **без** поиска и колоночных фильтров, `total` этого множества — это M в «Найдено N из M».
|
- `search_total` — плюс поиск (N);
|
||||||
- Права — тот же `_allowed_org_ids`. Если пересечение пусто — нули и пустые списки.
|
- варианты каждой колонки — по году, `org_ids`, поиску и фильтрам двух других колонок, значения различаются точно, `null` не выдаётся;
|
||||||
- `sheet` проходит ту же валидацию, что и в основном эндпоинте (404 на неизвестный).
|
- порядок значений в ответе не важен: экран сортирует их так же, как MRT (`localeCompare`).
|
||||||
|
|
||||||
### 3. SQL: новая миграция
|
Права те же — через `_allowed_org_ids`; при пустом пересечении все нули и пустые списки. Неизвестный `sheet` — 404.
|
||||||
|
|
||||||
**Файлы.** `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова. Номер — в разделе «Совместимость и порядок PR».
|
### 3. Семантика поиска и фильтров в SQL
|
||||||
|
|
||||||
**Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется (добавляется столбец), поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку.
|
**Регистр без зависимости от локали — `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`.
|
||||||
|
|
||||||
**Общий отбор `v3.svod2_lines(...)`.** Новая функция:
|
**Обрезка как в JS — `v3.svod2_js_trim(text)`**: `btrim` по явному набору символов ECMAScript `WhiteSpace` и `LineTerminator`:
|
||||||
- сигнатура `(p_year int, p_form_types varchar[], p_org_unit_ids int[], p_search text, p_smeta_type varchar, p_smeta_direction varchar)`;
|
- 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.
|
||||||
- возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`;
|
|
||||||
- тело — `eligible_lines` из 0049 **без изменения условий отбора**, параметризованное списком типов форм (`{FORM_1,FORM_4}` для GO, `{FORM_2}` для RF), плюс три новых предиката. Они пишутся как `(p_x IS NULL OR …)`;
|
|
||||||
- `smeta_direction` — тот же `CASE bl.direction`, что в `header` (0049:197-201).
|
|
||||||
|
|
||||||
Функцию писать `LANGUAGE sql STABLE` **без** `SET search_path` (имена полностью квалифицированы `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивание не произошло (в EXPLAIN есть `Function Scan on svod2_lines`), это допустимо: множество строк ≤ десятков тысяч узких записей. Решение — `#3` в разделе решений без обсуждения.
|
Запрос поиска нормализуется на сервере в 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
|
```sql
|
||||||
strpos(lower(concat_ws(E'\x1f', <поля>)), lower(p_search)) > 0
|
EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e
|
||||||
|
WHERE strpos(svod2_casefold(<текст e.value>), q) > 0)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Поля: все ключи `header` (`section_code`, `item_id`, `num_group`, `name` = `COALESCE(bl.name, ei.name)`, `expense_item_id`, `justification`, `internal_order`, `vsp_id`, `project_id`, `direction`, `smeta_direction`, `source_form`, `source_id`, `source_org_unit_id`, `org_name`, `smeta_type`), все столбцы `contract_summary` и все столбцы `contract_detail`, кроме `line_id`. Таблицы уже присоединены в отборе как `presence_contract_summary` и `presence_contract_detail` (связь 1:1 по PK `line_id`, `DISTINCT` не размножается).
|
Соединения с `contract_summary`, `contract_detail` и `ckk` уже есть в отборе: `presence_*`, связь 1:1 по `line_id`.
|
||||||
- Разделитель `\x1f` не даёт совпасть «через границу» двух значений; `strpos` не требует экранировать `%` и `_`.
|
|
||||||
- Числа приводятся `::text` (`numeric` → как в jsonb), даты — `::text` (ISO, как в JSON).
|
**Фильтры** — по столбцам отбора `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)) = <нормализованный параметр>`.
|
||||||
- Регистр кириллицы: `lower()` зависит от `LC_CTYPE` базы. На стенде (`en_US.utf8`) работает. Интеграционный тест с «аРЕНДА» доказывает это на конкретной базе, риск — в разделе рисков.
|
|
||||||
|
### 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`.**
|
**Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.**
|
||||||
- Аргументы — прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type varchar DEFAULT NULL`, `p_smeta_direction varchar DEFAULT NULL`, `p_compact boolean DEFAULT false`.
|
- Аргументы: прежние 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`. Порядок прежних столбцов не меняется: репозиторий выбирает их по имени.
|
- Результат: прежние 8 столбцов + `total_count bigint`.
|
||||||
- `numbered_lines` строится поверх `v3.svod2_lines(...)` с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`.
|
- Сначала нормализация: обрезка и регистр, пустое значение → `NULL`.
|
||||||
- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF (например, `approved` у RF учитывает `ss.id`).
|
- `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`.
|
- `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END`.
|
||||||
- Вызов прежними 6 аргументами (Excel RF, прежний режим API) работает за счёт значений по умолчанию и даёт те же строки, `sort_order` и `data`.
|
- Вызов прежними 6 аргументами (Excel RF, прежний режим API) даёт те же строки, `sort_order` и `data`.
|
||||||
|
|
||||||
**Проекция `v3.svod2_compact(data jsonb) RETURNS jsonb`** (`LANGUAGE sql 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.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`).
|
||||||
- Альтернатива — массив значений без ключей, как `v_svod2_go_export.row_values`: меньше байтов, но фронту пришлось бы перекладывать строки в форму под `valueAt`, а проверка «компакт = проекция полного» стала бы сложнее. Отвергнута (`#4`).
|
|
||||||
- Альтернатива — отдельная сборка компактного `jsonb` прямо из таблиц: быстрее на страницу, но второй экземпляр сложной логики `booking_amount`/`plan_revision_*` (0049:248-284) с риском расхождения. Отвергнута: страница — это сотни строк, сборка полного `jsonb` для неё дешёвая.
|
|
||||||
|
|
||||||
**Варианты фильтров `v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[])`.** Возвращает строки `(kind varchar, value varchar, org_unit_id int, n bigint)`:
|
**`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','smeta_type','smeta_direction','org'}`;
|
- `kind` принимает значения `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name`;
|
||||||
- значения считаются по `v3.svod2_lines(p_year, <типы форм по листу>, p_org_unit_ids, NULL, NULL, NULL)`;
|
- одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска;
|
||||||
- `NULL`-значения `smeta_type` и `smeta_direction` не выдаются (`#7`);
|
- три `GROUP BY` со своими условиями «поиск + фильтры двух других колонок» (`GROUP BY` по `varchar` с детерминированной сортировкой различает строки побайтно, как ключи `Map` в JS);
|
||||||
|
- два счётчика: `total` без условий и `search_total` только с поиском;
|
||||||
- форму ответа API собирает репозиторий.
|
- форму ответа API собирает репозиторий.
|
||||||
|
|
||||||
**PG15.** Не использовать возможности PG16+: `any_value`, конструкторы SQL/JSON (`JSON_OBJECT`, `JSON_ARRAY`…), `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` должны быть алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `concat_ws`, `strpos` есть в PG15.
|
**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` не трогается.** У неё остаётся своя копия `eligible_lines`.
|
**`v3.v_svod2_go_export` не трогается.**
|
||||||
|
|
||||||
### 4. Сервис и репозиторий
|
### 5. Сервис и репозиторий
|
||||||
|
|
||||||
- `SummaryRepository.get_svod2_rows` — без изменений сигнатуры и SQL: прежний режим и Excel RF.
|
- `SummaryRepository.get_svod2_rows` — без изменений (прежний режим и Excel RF).
|
||||||
- Новые методы:
|
- Новые методы:
|
||||||
- `SummaryRepository.get_svod2_page(...)` — новые аргументы и `total_count`, возвращает `(rows, total)`;
|
- `SummaryRepository.get_svod2_page(...)` → `(rows, total)`;
|
||||||
- `SummaryRepository.get_svod2_facets(...)`;
|
- `SummaryRepository.get_svod2_facets(...)`;
|
||||||
- в сервисе `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`.
|
- `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`.
|
||||||
- Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`.
|
- Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`.
|
||||||
- Заголовок `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах, в лог добавляются новые параметры. На этом заголовке держится `api_split.py`.
|
- `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах.
|
||||||
|
- Нормализацию (обрезку и регистр) делает SQL, а не Python: одна реализация на значения и запрос.
|
||||||
|
|
||||||
### 5. Фронт: окно страниц в той же таблице
|
### 6. Фронт: окно страниц в той же таблице
|
||||||
|
|
||||||
**Константы.** `PAGE_SIZE = 300`, окно — видимая страница ±1 (не больше 3 страниц в данных таблицы), кэш страниц — LRU не больше 5 страниц на вкладку (`#5`). Высота строки берётся из того же источника, что `estimateSize` виртуализатора:
|
**Константы и окно.**
|
||||||
- сейчас 37 px;
|
- `PAGE_SIZE = 300`;
|
||||||
- после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()` из её `createFixedRowVirtualizerOptions`.
|
- окно — видимая страница ±1, в данных таблицы не больше 3 страниц;
|
||||||
|
- кэш страниц — LRU не больше 5 страниц на вкладку (`#5`);
|
||||||
|
- в положении «в начале» окно — страницы 0 и 1, то есть страница 1 запрашивается сразу (`#15`).
|
||||||
|
|
||||||
Окно требует фиксированной высоты строки. Пока 1836 не влита, `measureElement` должен возвращать константу.
|
**Высота строки** берётся из того же источника, что `estimateSize` виртуализатора: сейчас 37 px, после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()`. Окно требует фиксированной высоты: пока 1836 не влита, `measureElement` возвращает константу.
|
||||||
|
|
||||||
**Данные таблицы.** Это не весь список, а срез окна `[windowStart, windowEnd)`:
|
**Данные таблицы — срез окна `[windowStart, windowEnd)`.**
|
||||||
- загруженные строки окна плюс заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна;
|
- В срезе загруженные строки и заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна.
|
||||||
- позиция среза задаётся отступами виртуализатора строк `rowVirtualizerOptions = { ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`;
|
- Позиция задаётся отступами виртуализатора строк: `{ ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`.
|
||||||
- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают `paddingStart`/`paddingEnd` (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` всегда стоит на `i * rowH`, а полоса прокрутки соответствует `total`;
|
- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают отступы (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` стоит на `i * rowH`, а полоса прокрутки соответствует `total`.
|
||||||
- объектов строк TanStack (`createRow` создаёт десятки замыканий на строку) не больше размера окна, а не `total`;
|
- Объектов строк TanStack — не больше окна.
|
||||||
- объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`) **не править** — это hunk AURORA-1836. Новые опции собираются в компоненте разворотом этого объекта.
|
- Объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`, hunk AURORA-1836) **не править**.
|
||||||
|
|
||||||
**Прокрутка.**
|
**Прокрутка.**
|
||||||
- `muiTableContainerProps.onScroll`, сглаженный через `requestAnimationFrame`, считает `firstVisible = floor(scrollTop / rowH)` и видимую страницу;
|
- `muiTableContainerProps.onScroll` через `requestAnimationFrame` считает видимую страницу по `scrollTop`.
|
||||||
- когда видимая страница меняется, пересчитываются окно и данные, а недостающие страницы окна запрашиваются (соседние страницы окна — это и есть упреждающая загрузка);
|
- При смене страницы пересчитываются окно и данные, недостающие страницы окна запрашиваются.
|
||||||
- если видимая область целиком в отступе (быстрый перенос ползунка), виртуализатор рисует строки края среза вне экрана, а пользователь видит пустое место, пока окно не сдвинется на следующем кадре и не придут страницы. Это ожидаемо.
|
- Программно положение прокрутки не меняется (решение 9): при смене вкладки, поиска или «Загрузить» окно считается от текущего `scrollTop`, который браузер ограничит при уменьшении высоты. Так же ведёт себя таблица сейчас.
|
||||||
|
|
||||||
**Заглушки.**
|
**Заглушки.**
|
||||||
- `muiTableBodyRowProps` для строки-заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`;
|
- `muiTableBodyRowProps` для заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`. При rebase объединяется со `style.height` из 1836.
|
||||||
- правило в `SVOD2_TABLE_CONTAINER_SX` (`svod2Styles.js`) делает текст ячеек прозрачным и даёт ровный фон. Тогда `Cell`-функции колонок не меняются: на заглушке `valueAt` даёт `undefined` и `'—'`, но его не видно;
|
- Правило в `SVOD2_TABLE_CONTAINER_SX` делает текст прозрачным, `Cell`-функции не меняются.
|
||||||
- `getRowId`: у заглушки `ph-${index}`, у данных — прежний ключ.
|
- `getRowId` заглушки — `ph-${index}`.
|
||||||
|
|
||||||
**Запросы.** В `web/src/api/summary.js`:
|
**Запросы** (`web/src/api/summary.js`):
|
||||||
- `SummaryApi.getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection }, { signal })` — всегда `view=compact`, пустые параметры не передаются;
|
- `getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection, orgName }, { signal })` — всегда `view=compact`, пустые параметры не передаются, `search` уходит как введён;
|
||||||
- `SummaryApi.getSvod2Facets(sheet, year, orgIds, { signal })`;
|
- `getSvod2Facets(sheet, year, { orgIds, search, smetaType, smetaDirection, orgName }, { signal })`;
|
||||||
- `SummaryApi.get` остаётся для свода 1.
|
- `get` остаётся для свода 1.
|
||||||
|
|
||||||
**Состояние вкладки** — хук загрузки страниц, например `useSvod2Pages.js` (без JSX). Держит:
|
**Состояние вкладки** — хук без JSX, например `useSvod2Pages.js`. Хранит `queryKey`, `generation`, `total`, `facets`, `pages` (LRU) и `inflight` (`AbortController`). Чистая логика вынесена в `web/src/pages/Svod2Page/svod2Paging.js` и покрыта `node --test`:
|
||||||
- `queryKey` (лист, год, организации, поиск, три фильтра);
|
- окно;
|
||||||
- `generation`;
|
- срез с заглушками;
|
||||||
- `total`;
|
- LRU;
|
||||||
- `facets`;
|
- параметры запроса;
|
||||||
- `pages: Map` (LRU);
|
- преобразование `facets` в `Map` для MRT;
|
||||||
- `inflight: Map<page, AbortController>`;
|
- условие показа «Найдено».
|
||||||
- `scrollTop`.
|
|
||||||
|
|
||||||
Чистая логика — окно, сборка среза с заглушками, LRU, параметры запроса из условий, «показывать ли Найдено» — выносится в `web/src/pages/Svod2Page/svod2Paging.js` и тестируется `node --test`. Ответ, пришедший с устаревшим `generation`, отбрасывается; при смене условий текущие запросы отменяются через `AbortController`.
|
Ответ устаревшего `generation` отбрасывается, текущие запросы отменяются.
|
||||||
|
|
||||||
**Что вызывает запросы:**
|
**Что вызывает запросы:**
|
||||||
- «Загрузить» или «Обновить»: новый `generation`, сброс страниц, прокрутка в начало, параллельно `facets` и страница 0;
|
- «Загрузить» или «Обновить» — новый `generation`, `loading = true` (как сейчас: индикатор, `Alert` при ошибке, «Найдено» скрыто), запросы `facets` и страниц окна;
|
||||||
- смена поиска (после debounce в `SvodFilters`) или колоночного фильтра при загруженных данных: новый `generation`, сброс страниц, прокрутка в начало, только страница 0. Варианты фильтров не перезапрашиваются: они не зависят от поиска и фильтров;
|
- изменение поиска (после debounce) или колоночного фильтра при загруженных данных — новый `generation`, запросы `facets` и страниц окна. `loading` **не** выставляется, прежние строки остаются до ответа (`#20`);
|
||||||
- смена года или организаций: как сейчас, подсказка «Нажмите «Загрузить»…» (`:448-455`);
|
- смена года или организаций — как сейчас, подсказка «Нажмите «Загрузить»…»;
|
||||||
- переключение вкладки: состояние другой вкладки (окно ≤ 5 страниц) сохраняется, возврат — без запроса.
|
- переключение вкладки — состояние вкладки сохраняется, запрашиваются только недостающие страницы окна.
|
||||||
|
|
||||||
**Колоночные фильтры.**
|
**Колоночные фильтры и варианты.** Цель — сохранить рендер MRT без изменений:
|
||||||
- `manualFiltering: true`, `enableFacetedValues: false`;
|
- `manualFiltering: true`, `enableFacetedValues: true`;
|
||||||
- у трёх колонок `filterSelectOptions` из `facets`: строки для `smeta_type`/`smeta_direction`, `{ label: name, value: String(id) }` для `org_name`. Опции подмешиваются в `columns` в `useMemo` поверх `getColumnConfig(sheet)`, кэш конфигурации колонок по листу остаётся;
|
- в опциях таблицы переопределяется `getFacetedUniqueValues: (table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_MAP`. Пользовательские опции перекрывают значения MRT по умолчанию (`useMRT_TableOptions`, `Object.assign(defaults, rest)`);
|
||||||
- значение фильтра «ССП» → `org_ids = [id]` вместо организаций из выбора «ССП/РФ» (они уже ограничивают варианты);
|
- MRT сам отбросит `null`, отсортирует `localeCompare` и покажет числа в скобках, как сейчас;
|
||||||
|
- `filterSelectOptions` **не** задавать: с ними MRT не показывает числа;
|
||||||
|
- значения фильтров уходят на сервер как есть (`smeta_type`, `smeta_direction`, `org_name`);
|
||||||
- сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется.
|
- сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется.
|
||||||
|
|
||||||
**«Найдено N из M».** Показывается, если данные загружены, загрузка не идёт, `total !== facets.total`: `Найдено ${total} из ${facets.total} строк` в `ru-RU`.
|
**«Найдено N из M»:** N = `facets.search_total`, M = `facets.total`, условие показа — прежнее: `hasCurrentData && !loading && N !== M`.
|
||||||
|
|
||||||
**Ошибки.**
|
**Ошибки.**
|
||||||
- первая страница или `facets` — прежний `Alert` с «Повторить» (`:456-468`);
|
- «Загрузить», «Обновить», поиск или фильтр — прежний `Alert` с «Повторить».
|
||||||
- следующие страницы — `toast.error`, страница остаётся незагруженной и запрашивается снова, когда опять попадёт в окно.
|
- Догрузка страницы при прокрутке — `toast.error`, страница остаётся незагруженной и запрашивается при повторном попадании в окно.
|
||||||
|
|
||||||
**Если `total` изменился между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез и отступы пересчитываются. Возможный сдвиг строк между страницами лечит «Обновить» (риск ниже).
|
**Изменение `total` между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез пересчитывается.
|
||||||
|
|
||||||
**`SvodContext` не меняется.** Свод 2 просто перестаёт звать `getSummary`/`invalidateSummary`, поэтому полные ответы в кэше не копятся. Кэш остаётся только у свода 1.
|
**`SvodContext` не меняется.** Свод 2 перестаёт звать `getSummary`/`invalidateSummary`.
|
||||||
|
|
||||||
**Запасной вариант (`#6`).** Если `paddingStart`/`paddingEnd` в MRT 3.2.1 ведут себя некорректно (проверка — первая задача фронт-PR на живом GO «все»), допускается массив длины `total` с общей заглушкой на незагруженных местах. Память тогда растёт как O(`total`) объектов строк TanStack, критерий памяти проверяется замером. Переход на запасной вариант записывается в этот файл, в раздел решений без обсуждения.
|
**Запасной вариант (`#6`).** Если отступы в MRT 3.2.1 работают некорректно (проверка — первая задача фазы 2), допускается массив длины `total` с общей заглушкой. Переход записывается в раздел решений без обсуждения.
|
||||||
|
|
||||||
### 6. Стенд `perf/` (PR 3)
|
### 7. Стенд `perf/` (фаза 3)
|
||||||
|
|
||||||
**`perf/e2e/scenarios/scroll.spec.ts`, только `kind === 'svod2'`.** Код должен работать и со сборкой «до»: заглушек и новых запросов там нет, метрики просто вырождаются.
|
**`scroll.spec.ts`**, только `kind === 'svod2'`. Код должен работать и со сборкой «до».
|
||||||
- `waitRows` считает строки `tbody tr:not([data-svod2-placeholder])`.
|
- `waitRows` считает `tbody tr:not([data-svod2-placeholder])`.
|
||||||
- Новая метрика `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными.
|
- `first_rows_ms` — от «Загрузить» до `MIN_ROWS` строк с данными.
|
||||||
- Байты:
|
- Байты:
|
||||||
- `json_bytes_sheet` сохраняет смысл «максимум одного ответа» (у «после» это самая большая страница);
|
- `json_bytes_sheet` — прежний смысл: максимум одного ответа;
|
||||||
- новый `json_bytes_total` — сумма всех ответов по `respRe` за тест;
|
- `json_bytes_total` — новое, сумма всех ответов;
|
||||||
- новый `json_bytes_first` — сумма до появления первых строк.
|
- `json_bytes_first` — новое, сумма до первых строк.
|
||||||
|
|
||||||
`respRe` свода 2 расширяется на `/facets`.
|
`respRe` включает `/facets`.
|
||||||
- Новая метрика `placeholder_frame_share` — доля кадров прокрутки, в которых в видимой области контейнера есть заглушка (сэмплер на `requestAnimationFrame` в странице или колбэк шага в `lib/scroll.ts`). Рядом пишется `pages_requested`.
|
- `placeholder_frame_share` и `pages_requested`.
|
||||||
- Тест фильтра: после `fill('1')` ждать ответ запроса с `search=` (до 5 с; если запроса нет — это сборка «до» с клиентским поиском), затем `awaitStable`. Новая метрика `filter_result_ms` — от ввода до стабилизации.
|
- Тест фильтра: после `fill('1')` ждать ответ с `search=` (до 5 с; если запроса нет — это сборка «до»), затем `awaitStable`, метрика `filter_result_ms`.
|
||||||
- Подписи «ССП/РФ», «Все ССП/РФ», «Загрузить» не меняются (UX сохранён). Клики `:194-200` остаются.
|
- Клики `:194-200` и подписи не меняются.
|
||||||
|
|
||||||
**Новый `perf/e2e/scenarios/svod2-memory.spec.ts`.**
|
**Новый `svod2-memory.spec.ts`.**
|
||||||
- Роль dfip, свежий контекст на повтор, `PERF_REPEATS` (по умолчанию 3).
|
- Роль dfip, свежий контекст на повтор, по умолчанию 3 повтора.
|
||||||
- Шаги:
|
- Точки:
|
||||||
1. `/svod/2`, GO «Все ССП/РФ» → «Загрузить» → строки → `heapUsed` (`perf/e2e/lib/cdp.ts:45`) → точка `go`;
|
1. `go` — GO «Все» → «Загрузить» → строки;
|
||||||
2. вкладка RF, «Все ССП/РФ» → «Загрузить» → строки → точка `rf`;
|
2. `rf` — RF «Все» → «Загрузить» → строки;
|
||||||
3. по `PERF_SVOD2_MEM_SCROLL=1` (по умолчанию выключено, на масштабе 1 включить) — прокрутка RF до конца и обратно → точка `rf_scrolled`;
|
3. `rf_scrolled` — по `PERF_SVOD2_MEM_SCROLL=1`, на масштабе 1;
|
||||||
4. вкладка GO → строки видны → точка `final`.
|
4. `final` — вкладка GO.
|
||||||
- Метрики: `heap_used_mb` по точкам, `nodes` (`domCounters`), `first_rows_ms` обеих загрузок. Запись `scenario: "svod2-memory"`, агрегирует `perfkit.aggregate`.
|
- В каждой точке `heapUsed` после GC (`lib/cdp.ts:45`) и `domCounters`.
|
||||||
|
|
||||||
**`perf/analysis/api_split.py`.**
|
**`api_split.py`.** Прежние строки остаются. Добавить:
|
||||||
- Прежние строки (полные GO/RF) остаются: это проверка «прежний режим не медленнее».
|
- GO/RF `view=full&offset=0&limit=300`;
|
||||||
- Добавляются:
|
- GO/RF `view=compact&offset=0&limit=300`;
|
||||||
- GO/RF `?view=full&offset=0&limit=300`;
|
- `/facets`;
|
||||||
- GO/RF `?view=compact&offset=0&limit=300`;
|
- RF `view=compact&…&search=1`.
|
||||||
- GO/RF `/facets`;
|
|
||||||
- RF `?view=compact&offset=0&limit=300&search=1`.
|
|
||||||
|
|
||||||
**`perf/analysis/explain_manual.py`.** Новые записи:
|
**`explain_manual.py`.** Новые записи:
|
||||||
- `svod2_go_page` — `SELECT … FROM v3.v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, true)`;
|
- `svod2_go_page` (`v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`);
|
||||||
- `svod2_rf_page`;
|
- `svod2_rf_page`;
|
||||||
- `svod2_rf_search` (`p_search='1'`);
|
- `svod2_rf_search` (`'1'`);
|
||||||
- `svod2_go_facets`.
|
- `svod2_go_facets`.
|
||||||
|
|
||||||
Варианты — по умолчанию и `.rerun_default`. Они показывают, сколько стоит отбор строк в каждой странице.
|
**`scale_compare.py`** (`:43-44`): раздельные ключи для полного ответа, страницы (`limit` в query) и `facets`.
|
||||||
|
|
||||||
**`perf/analysis/scale_compare.py`** (`:43-44`): разделить ключи на «GET свод 2 {sheet}» (прежний полный), «… страница» (в query есть `limit`) и «… facets».
|
**`perfkit.bench`.** Повторит каждый URL страницы. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`.
|
||||||
|
|
||||||
**`perfkit.bench`.** После 1843 сетевой лог содержит десятки URL страниц, и бенч повторит каждый. На масштабе 1 это около 50 URL × 20 повторов × ~0,3 с ≈ 5 мин; на масштабе 2 — в ~4 раза больше. Ограничивать через `--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` по набору поисков и фильтров.
|
||||||
|
|
||||||
**Новый `perf/analysis/svod2_paging_check.py`** — сверка на данных стенда, только чтение. Под dfip и admin для GO и RF:
|
## Найденные странности (не чиним в этой задаче)
|
||||||
- склейка всех компактных страниц совпадает по `line_id` и порядку с прежним полным ответом и равна его проекции;
|
|
||||||
- `total` равен длине полного;
|
|
||||||
- для поисков (`аренда`, `АРЕНДА`, `1`, подстрока названия организации) и для каждого значения трёх фильтров `total` и состав совпадают с фильтром по полному ответу на Python, повторяющим требование «Серверный поиск»;
|
|
||||||
- `/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
|
## Совместимость и порядок PR
|
||||||
|
|
||||||
**Стек.** `test` ← … ← AURORA-1835 (PR #217) ← AURORA-1842 (PR #227) ← 1843:
|
**Один план, три фазы и три ветки стеком под issue #236:**
|
||||||
- PR 1 `feat/236-…` — сервер;
|
- `feat/236-aurora-1843-svod2-server-paging` — PR 1, сервер, база — ветка 1842;
|
||||||
- PR 2 — фронт;
|
- `feat/236-aurora-1843-svod2-server-paging-web` — PR 2;
|
||||||
- PR 3 — perf.
|
- `feat/236-aurora-1843-svod2-server-paging-perf` — PR 3.
|
||||||
|
|
||||||
Каждый PR зелёный сам по себе:
|
PR создаются по готовности, merge — по команде человека (`#14`). Каждый PR зелёный сам по себе: после PR 1 старый фронт работает.
|
||||||
- после PR 1 старый фронт работает (прежний режим не изменился);
|
|
||||||
- PR 2 использует только API из PR 1;
|
|
||||||
- PR 3 меняет только `perf/`.
|
|
||||||
|
|
||||||
**Номер миграции.** В ветке голова — `0049`. Номер `0050` уже заняли AURORA-1831 (`0050_project_technical_number_lock`), AURORA-1844 (`0050_upd_form_cells_full_rows`) и AURORA-1846 (`0050_form4_sheet_sum`), между собой они перенумеруются при слиянии. 1843 берёт рабочий номер `0053_svod2_server_paging` (после 0050–0052 этих трёх). При каждом rebase PR 1 нужно:
|
**Номер миграции.** Голова в ветке — `0049`. Номер `0050` заняли AURORA-1831, 1844 и 1846. Рабочий номер — `0053_svod2_server_paging`. При rebase PR 1:
|
||||||
- проверить `alembic heads` (должна быть одна голова);
|
- проверить `alembic heads` (должна остаться одна голова);
|
||||||
- поставить номер «голова + 1»;
|
- поставить номер «голова + 1»;
|
||||||
- переименовать `.py` и `.sql`;
|
- переименовать `.py` и `.sql`;
|
||||||
- поправить `revision`, `down_revision` и имя файла в `run_sql_file_migration`;
|
- поправить `revision`, `down_revision` и имя в `run_sql_file_migration`;
|
||||||
- перенацелить `test_summary2_related_records_sql.py` на новое имя.
|
- перенацелить `test_summary2_related_records_sql.py`.
|
||||||
|
|
||||||
Два head ломают `alembic upgrade head`, а при `DEBUG=false` — и старт приложения.
|
**AURORA-1836** правит `:26-31` (`2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3) и `muiTableBodyRowProps.style.height`. 1843 эти строки не трогает, опции собирает в компоненте. Высота строки окна — из того же источника. Если 1836 вольётся до замера, базу «до» переснять.
|
||||||
|
|
||||||
**AURORA-1836 (PR #222, от `test`).** Правит `Svod2Page.jsx`:
|
**AURORA-1848** переписывает тот же `get_summary2`. Кто вливается вторым, сохраняет оба режима. «Байт-в-байт» 1843 относится к состоянию до 1848.
|
||||||
- `:26-31`: `ROW_HEIGHT` → `2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3;
|
|
||||||
- `muiTableBodyRowProps.style.height`.
|
|
||||||
|
|
||||||
1843 эти строки не трогает. Новые опции виртуализатора собираются в компоненте из `ROW_VIRTUALIZER_OPTIONS`, а у `muiTableBodyRowProps` 1843 добавляет класс и атрибут заглушки. Это функция-объединение: при rebase слить со `style.height` из 1836.
|
**AURORA-1917 (O-5).** Не вливать между «до» и «после» 1843. Если придёт раньше, правка отбора ложится в `v3.svod2_lines` и `v_svod2_go_export`.
|
||||||
|
|
||||||
Высота строки для окна должна идти из того же источника, что `estimateSize`. После rebase — из 1836, иначе отступы разойдутся с позициями строк: 37 против 44 px.
|
**Выкатка и откат.** Миграция только заменяет функции. Откат PR 1 — новой миграцией с телами из 0049. Откат PR 2 безопасен сам по себе.
|
||||||
|
|
||||||
Если 1836 вольётся в `test` раньше замера, базу «до» переснять на ребейзнутой вершине 1842.
|
|
||||||
|
|
||||||
**AURORA-1848** (тяжёлые GET без pydantic, ещё не начата) переписывает тот же `get_summary2` на готовый JSON из SQL. Договорённость:
|
|
||||||
- кто вливается вторым, сохраняет оба режима 1843;
|
|
||||||
- для страниц (сотни строк) выигрыш 1848 мал;
|
|
||||||
- для прежнего режима (Excel RF не через HTTP, бенч, verify) 1848 вправе менять упаковку только с сохранением JSON по смыслу, как требует сама 1848.
|
|
||||||
|
|
||||||
Требование «байт-в-байт» из 1843 относится к состоянию до 1848.
|
|
||||||
|
|
||||||
**AURORA-1917 (O-5).** Исправление отбора RF по `is_ssp` уменьшит ответ RF и число строк. Его нельзя вливать между замером «до» и «после» 1843: либо до базового замера (тогда «до» переснимается), либо после «после». Если 1917 придёт раньше, правка отбора ляжет в `v3.svod2_lines` (одно место) и в `v_svod2_go_export`.
|
|
||||||
|
|
||||||
**Выкатка и откат.** Миграция только заменяет функции и добавляет новые, данных не трогает. `downgrade()` в проекте пустые: откат PR 1 — новой миграцией, которая восстанавливает тела из 0049. Откат PR 2 безопасен сам по себе.
|
|
||||||
|
|
||||||
## Тестирование (что доказывает корректность)
|
## Тестирование (что доказывает корректность)
|
||||||
|
|
||||||
**PR 1, `api/tests/unit`:**
|
**Окружение (`#18`).**
|
||||||
- валидация параметров через `TestClient` без БД (по образцу `test_query_validation.py`): 422 на `search` > 200, на неизвестные `smeta_type`/`smeta_direction`, на прежние невалидные `offset`/`limit`/`sort_*`;
|
- Полный `pytest` идёт на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1843`.
|
||||||
- сервис: `_allowed_org_ids` применяется к страницам и вариантам; пустое пересечение даёт `[]`/0 без вызова SQL; второй вызов за `total` при пустой странице и `offset > 0` (моки репозитория, по образцу `test_export_service_query_params.py`);
|
- Создание: `docker exec perf-pg createdb -U perf dfip_1843`.
|
||||||
- выбор режима: без новых параметров зовётся `get_svod2_rows`, и в ответе нет `total`;
|
- Запуск: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 ../perf/.venv/bin/pytest tests --init_db`; перед каждым прогоном с `--init_db` БД пересоздаётся.
|
||||||
- `test_summary2_related_records_sql.py` перенацелить на новую миграцию: отбор из `v3.svod2_lines` для GO (`FORM_1`, `FORM_4`) и RF (`FORM_2`), те же проверки, что для 0049. Добавить: поиск находит по `contract_summary`, `contract_detail` и `header`; разделитель не даёт совпасть через границу значений; фильтры `smeta_type`/`smeta_direction`.
|
- БД `perf` не трогать. Пароли `OPENBAO__TEST_*` по умолчанию `admin123` (`tests/conftest.py`).
|
||||||
|
- Совместимость с PG15.12 — отдельный шаг плана «проверка контроллером», контейнер `dfip-pg15`, порт 55434.
|
||||||
|
|
||||||
**PR 1, `api/tests/integration`.**
|
**Фаза 1, `api/tests/unit`:**
|
||||||
- Фикстура (рядом с `client`) засевает в той же откатываемой сессии данные свода 2 за год, которого нет в `fixture.sql`:
|
- валидация (по образцу `test_query_validation.py`): 422 на `search`/фильтры длиннее 1000 и на прежние невалидные параметры;
|
||||||
- 3 организации (одна с не уникальным `title`);
|
- сервис:
|
||||||
- FORM_1, FORM_4 и FORM_2;
|
- `_allowed_org_ids` применяется к страницам и вариантам;
|
||||||
- все три `smeta_type`, оба направления и `direction = NULL`;
|
- пустое пересечение даёт пустой ответ без SQL;
|
||||||
- `contract_summary` и `contract_detail` с кириллицей разного регистра;
|
- второй вызов за `total` при пустой странице;
|
||||||
- строки с `plan_q1 = 0`;
|
- выбор режима: без новых параметров зовётся `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` верен на каждой странице и за концом;
|
- склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом;
|
||||||
- `search` (`аРЕНДА`, подстрока контрагента, цифра) и каждый фильтр совпадают с фильтром полного ответа на Python по требованию «Серверный поиск»;
|
- поиск (`аРЕНДА`, `ё`, перекрытое поле, число `1234.5` и `1234.50`, пробелы и NBSP по краям) и фильтры (`кв `, одинаковые названия) совпадают с оракулом;
|
||||||
- `view=compact` — проекция `view=full` без `null`, нули на месте;
|
- `facets` совпадает с оракулом для сочетаний поиска и фильтров;
|
||||||
- `/facets` совпадает с вычисленным по полному;
|
- `svod2_casefold` и `svod2_js_trim` дают одинаковый результат при `LC_CTYPE` базы (тест вызывает их и сравнивает с ожидаемыми строками);
|
||||||
- исполнитель не видит чужую организацию ни в страницах, ни в вариантах;
|
- `svod2_js_number_text` совпадает с векторами Node;
|
||||||
- прежний режим — ключи как раньше и без `total`; существующие тесты `test_summary_api_smoke.py` проходят без правок.
|
- compact — это проекция full;
|
||||||
- Весь набор `pytest` (зелёный только целиком): PG16 на стенде (отдельная БД `budget_test`, см. P4 «Стенд для тестов») плюс проверка контроллером на PG15.12.
|
- права;
|
||||||
|
- прежний режим без `total`, существующие смоук-тесты проходят без правок.
|
||||||
|
|
||||||
**PR 1, байт-в-байт на данных стенда.** До и после миграции снять `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, «все» и одна организация, `offset=0&limit=50`) и Excel RF. Сравнить sha256 распакованных тел; для xlsx — `openpyxl`-значения всех ячеек.
|
**Фаза 1, байт-в-байт на стенде (`#17`).** До и после миграции: `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, все и одна организация, `offset=0&limit=50`) и Excel RF. Сравнение — sha256 распакованных тел; для xlsx — значения ячеек через `openpyxl`.
|
||||||
|
|
||||||
**PR 2, `node --test web/src/pages/Svod2Page/*.test.js`:**
|
**Фаза 2, `node --test web/src/pages/Svod2Page/*.test.js`:**
|
||||||
- окно по `scrollTop`/`rowH`/`total` (край начала, края конца, `total < PAGE_SIZE`, `total = 0`);
|
- окно и срез: края, неполная последняя страница, `total = 0`, сумма отступов;
|
||||||
- сборка среза: число заглушек на последней неполной странице, отступы `paddingStart`/`paddingEnd`, сумма = `total * rowH`;
|
- LRU;
|
||||||
- LRU не держит больше 5 страниц и не выбрасывает страницы окна;
|
- параметры запроса;
|
||||||
- параметры запроса: пустые не передаются, фильтр «ССП» заменяет `org_ids`, `view=compact` всегда;
|
- `generation`;
|
||||||
- отбрасывание ответа устаревшего `generation`;
|
- `facets` → `Map`;
|
||||||
- «показывать ли Найдено»;
|
- условие «Найдено» (только поиск);
|
||||||
- существующий `svod2Styles.test.js` зелёный.
|
- `svod2Styles.test.js` по-прежнему зелёный.
|
||||||
|
|
||||||
**PR 2, руками на стенде** (GO и RF, admin и dfip):
|
**Фаза 2, руками на стенде** (выполняет контроллер, `#17`). Сравнение со сборкой «до» на тех же данных:
|
||||||
- «Все» и выбор организаций, «Загрузить»/«Обновить»;
|
- GO и RF, admin и dfip, «Все» и выбор организаций;
|
||||||
- перенос ползунка в середину и в конец (номера строк по порядку, последняя строка у нижнего края);
|
- поиск, три фильтра и их сочетания, варианты фильтров с числами, «Найдено», сброс фильтров;
|
||||||
- поиск, три фильтра, их сочетание, «Найдено N из M»;
|
- вкладки и положение прокрутки;
|
||||||
- быстрый ввод с последующим выбором фильтра (без мигания старого результата);
|
- перенос ползунка в середину и в конец;
|
||||||
- переключение вкладок, единицы, «Перейти к задачам»;
|
- быстрый ввод с последующим фильтром;
|
||||||
- горизонтальная прокрутка (закреплённые колонки, полосы, группы);
|
- единицы, «Перейти к задачам»;
|
||||||
- ошибка страницы (остановить API во время прокрутки);
|
- горизонтальная прокрутка;
|
||||||
- скриншоты «до/после» первого экрана и трёх горизонтальных позиций.
|
- ошибка догрузки;
|
||||||
|
- скриншоты.
|
||||||
|
|
||||||
**PR 3.**
|
**Фаза 3.** Пробный прогон сценариев с `PERF_REPEATS=1` на «до» и «после». `svod2_paging_check.py` и `svod2_search_oracle.mjs` должны быть зелёными на масштабах 1 и 2.
|
||||||
- Сценарии сначала прогнать с `PERF_REPEATS=1` на сборке «до» и «после», чтобы метрики писались, а `incomplete` не появлялось.
|
|
||||||
- `svod2_paging_check.py` — зелёный на масштабах 1 и 2.
|
|
||||||
|
|
||||||
## Замер до/после
|
## Замер до/после
|
||||||
|
|
||||||
**Базы:**
|
Выполняет контроллер на стенде (`#17`). В плане это точка ожидания. PR открываются без цифр, цифры дописываются в PR по готовности. Reset стенда разрешён, но не во время чужих замеров.
|
||||||
- «до» — вершина AURORA-1842 (`399234a` или её ребейзнутая версия, если 1836 вольётся раньше);
|
|
||||||
- «после» — вершина PR 3.
|
|
||||||
|
|
||||||
Код сценариев в обоих прогонах один и тот же — из PR 3. Для «до» — временный worktree на вершине 1842 и `git checkout <ветка PR 3> -- perf/` в нём, без коммита.
|
**Базы:**
|
||||||
|
- «до» — вершина AURORA-1842 (или ребейзнутая, если 1836 вольётся раньше);
|
||||||
|
- «после» — вершина ветки PR 3.
|
||||||
|
|
||||||
|
Код сценариев один (из PR 3): для «до» — временный worktree 1842 и `git checkout <ветка PR 3> -- perf/` без коммита.
|
||||||
|
|
||||||
**Порядок на одних данных:**
|
**Порядок на одних данных:**
|
||||||
1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` (~12 мин) → `verify`. Своды мерить до `grow`: `grow` доращивает AHR, который попадает в своды.
|
1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` → `verify`. Своды мерить до `grow`.
|
||||||
2. Скопировать `results/manifest-s1.json` в оба worktree; при желании снять `pg_dump -Fc`.
|
2. Скопировать `results/manifest-s1.json` в оба worktree.
|
||||||
3. «До»: в worktree 1842 + perf из PR 3 поднять `api`, `web-build`, `web`.
|
3. «До»: поднять `api`, `web-build`, `web` в worktree 1842 и снять замеры.
|
||||||
4. Остановить `api`/`web`.
|
4. Остановить сервисы.
|
||||||
5. «После»: в worktree PR 3 `alembic upgrade head` поверх тех же данных, поднять `api`, `web-build`, `web`, прогнать `svod2_paging_check.py`, затем замеры.
|
5. «После»: `alembic upgrade head` → `api`, `web-build`, `web` → `svod2_paging_check.py` и `svod2_search_oracle.mjs` → замеры.
|
||||||
6. То же для масштаба 2: `reset` + генерация ~45–70 мин.
|
6. То же на масштабе 2.
|
||||||
|
|
||||||
**Между «до» и «после» не вливать AURORA-1917 и AURORA-1836** (или переснять «до» после rebase).
|
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836.
|
||||||
|
|
||||||
**Команды** (из `perf/e2e`, затем `cd .. && .venv/bin/python -m perfkit.aggregate results/<run>`):
|
**Команды** (из `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` (RF не помещается в 5 000 шагов, REPORT раздел 6) | s1: «до» ~35 мин, «после» ~15 мин; s2: ×3–4 |
|
| скролл, время до первых строк | `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 мин |
|
| фильтр | тот же запуск с `--grep filter` | ~5 мин |
|
||||||
| память | `PERF_RUN_ID=1843-mem-<before\|after>-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 мин «до» |
|
| память | `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 мин |
|
| байты и время 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 мин |
|
| планы SQL | `.venv/bin/python analysis/explain_manual.py --scale <N> --out results/1843-explain-s<N>` | ~3 мин |
|
||||||
|
|
||||||
**Пороги** — требования в `specs/`:
|
**Целевые значения** (`#10`, не условие слияния — решение 11):
|
||||||
|
|
||||||
| метрика | порог |
|
| метрика | цель |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `heap_used_mb` в точке `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 |
|
| память `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 |
|
||||||
| `first_rows_ms` | ≤ 0,5 × «до», GO и RF, s1 и s2 |
|
| `first_rows_ms` | ≤ 0,5 × «до» |
|
||||||
| `bytes(compact)/bytes(full)` той же страницы | ≤ 0,40 |
|
| compact / full | ≤ 0,40 |
|
||||||
| первая страница + facets | ≤ 5 % прежнего полного ответа |
|
| первая страница + facets | ≤ 5 % полного ответа |
|
||||||
| прежний полный запрос | медиана полного времени ≤ 1,10 × «до» |
|
| прежний запрос | ≤ 1,10 × «до» |
|
||||||
| `main_thread_ms_per_step` (s1) | ≤ 1,10 × «до» (результат 1842); рядом `placeholder_frame_share` |
|
| шаг прокрутки | ≤ 1,10 × «до» |
|
||||||
|
| `placeholder_frame_share` | ≤ 5 % (если больше, цена шага «после» занижена) |
|
||||||
|
|
||||||
Если `placeholder_frame_share` > 5 %, цену шага «после» считать заниженной: заглушки дешевле строк. Разобрать, успевает ли догрузка (`explain_manual`: стоимость страницы), и при необходимости увеличить упреждение (окно ±2).
|
Недобор → follow-up.
|
||||||
|
|
||||||
Результаты записать в PR 3 и в отчёт задачи: таблица «до/после» по масштабам, ссылки на `results/…`.
|
|
||||||
|
|
||||||
## Риски / компромиссы
|
## Риски / компромиссы
|
||||||
|
|
||||||
- **[Каждая страница заново выполняет полный отбор строк — 13 LEFT JOIN + `DISTINCT` по году]** → Время страницы ≈ время отбора, а не сборки `jsonb`. `explain_manual` (`svod2_*_page`) показывает его отдельно. Упреждающая загрузка соседних страниц прячет задержку при обычной прокрутке. Если страница на масштабе 2 дольше ~1 с, следующий шаг — переписать отбор на `EXISTS`-полусоединения с тем же смыслом (сверка — тот же unit-тест отбора и `svod2_paging_check.py`) отдельным коммитом PR 1.
|
- **[Каждая страница и каждый запрос вариантов заново выполняет полный отбор строк: 13 LEFT JOIN + `DISTINCT`, плюс поиск по `jsonb_each`]** → `explain_manual` показывает цену отбора. Страница 1 и соседние страницы окна запрашиваются заранее. Если страница на масштабе 2 дольше ~1 с, отбор переписывается на `EXISTS` с тем же смыслом (сверка — те же тесты), отдельным коммитом фазы 1.
|
||||||
- **[Без новых параметров поиск и фильтры добавляют работу и замедляют прежний ответ]** → Предикаты вида `(p IS NULL OR …)`. Порог +10 % в `api_split` ловит регресс.
|
- **[Поиск строит три небольших `jsonb` на каждую отобранную строку]** → Только при непустом поиске. Цель «прежний режим ≤ 1,10» ловит регресс без поиска.
|
||||||
- **[`lower()` не переводит кириллицу при `LC_CTYPE=C` на проде]** → Интеграционный тест с «аРЕНДА» падает на такой базе. Обход — `lower(x COLLATE "und-x-icu")`, если PG собран с ICU. Узнать `LC_CTYPE` прод-базы до выкатки PR 1 (вопрос к эксплуатации, ответ не меняет дизайн).
|
- **[`svod2_casefold` расходится с JS для заглавных букв вне таблицы]** → Таблица покрывает ASCII, кириллицу и латиницу-1. Остальное в данных свода не ожидается. Расширить таблицу — одна строка SQL.
|
||||||
- **[Данные меняются между запросами страниц: строки сдвигаются, на стыке страниц строка может повториться или пропасть]** → Свод — просмотр, «Обновить» даёт согласованный снимок. `total` обновляется по последнему ответу. Ключ строки (`source_form-source_id-line_id`) не даёт React смешать строки.
|
- **[`Number::toString` в SQL расходится с V8]** → Векторный тест Node ↔ SQL. `SET extra_float_digits` внутри функции.
|
||||||
- **[Сдвиг среза отступами в MRT ведёт себя иначе, чем ожидается: прыжки, пустоты]** → Проверка в первой задаче PR 2. Запасной вариант `#6` — массив длины `total`.
|
- **[Данные меняются между запросами страниц]** → «Обновить» даёт согласованный снимок, `total` берётся из последнего ответа.
|
||||||
- **[Окно опирается на фиксированную высоту строки, а 1836 меняет её с 37 px на 2,75 rem]** → Единый источник высоты. Тест суммы отступов. Чек-лист rebase выше.
|
- **[Отступы в MRT ведут себя не так, как ожидается]** → Проверка в первой задаче фазы 2, запасной вариант `#6`.
|
||||||
- **[Замер шага «после» занижен заглушками]** → Метрика `placeholder_frame_share`, порог 5 % (см. «Замер»).
|
- **[1836 меняет высоту строки]** → Единый источник высоты, тест суммы отступов, чек-лист rebase.
|
||||||
- **[Тест фильтра свода 2 «стабилизируется» до ответа сервера (первая мутация — индикатор загрузки)]** → Ожидание ответа с `search=` перед `awaitStable`.
|
- **[Цена шага «после» занижена заглушками]** → `placeholder_frame_share`.
|
||||||
- **[Варианты фильтров не учитывают поиск: можно выбрать значение, которое с поиском даёт 0 строк]** → Таблица покажет «Нет данных для выбранных параметров». Сейчас варианты учитывают поиск, это небольшое изменение UX (`#7`).
|
- **[Тест фильтра свода 2 «стабилизируется» до ответа]** → Ожидание ответа с `search=`.
|
||||||
- **[Поиск по техническим id (`source_id`, `vsp_id`, `expense_item_id`) находит «лишнее», например по «1»]** → Так ищет и сейчас, поведение сохранено. Сужение полей поиска — отдельный вопрос бизнеса.
|
- **[Пока идёт запрос по поиску или фильтру, на экране прежние строки]** → Сейчас фильтрация мгновенная. Это допустимое отличие (spec: «Поведение для пользователя не меняется»). Индикатора нет, чтобы не менять вид (`#20`).
|
||||||
- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only`/`--repeats`, описание в README.
|
- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only` и `--repeats`.
|
||||||
|
|
||||||
## Что оставлено исполнителю
|
## Что оставлено исполнителю
|
||||||
|
|
||||||
- Имена моделей ответа, методов сервиса и репозитория, хука и файла чистой логики (предложенные выше — ориентир).
|
- Имена моделей, методов, хука и файла чистой логики.
|
||||||
- Как именно вернуть `total` при пустой странице (второй вызов или `LEFT JOIN` к счётчику) — при условии одного SQL на обычную страницу.
|
- Способ получить `total` при пустой странице, если обычная страница делает один SQL-запрос.
|
||||||
- Оформление заглушки (фон, «бегущая» полоса или ровный цвет) — при прозрачном тексте и той же высоте.
|
- Оформление заглушки при прозрачном тексте и той же высоте.
|
||||||
- Где сэмплировать `placeholder_frame_share`: в пробе страницы или колбэком шага `scrollThrough`.
|
- Где сэмплировать `placeholder_frame_share`.
|
||||||
- Точные значения `PAGE_SIZE` (200–500) и окна (±1…±2), если замер покажет выигрыш. Изменение записать в решения без обсуждения.
|
- `PAGE_SIZE` (200–500) и окно (±1…±2) по замеру. Изменение записать в решения без обсуждения.
|
||||||
|
|
||||||
## Decisions made without discussion
|
## Decisions made without discussion
|
||||||
|
|
||||||
1. **2026-10-28 — новый режим API включается наличием любого из новых параметров, в том числе явного `view=full`. Поле `total` есть только в новом режиме.**
|
1. **2026-10-28 — новый режим API включается наличием любого из новых параметров (в том числе `view=full`); поле `total` есть только в нём.**
|
||||||
Обоснование: требование «без новых параметров ответ байт-в-байт прежний». `limit` — прежний параметр, привязать `total` к нему значило бы изменить прежний ответ.
|
Обоснование: требование «без новых параметров байт-в-байт». `limit` — прежний параметр.
|
||||||
Цена ошибки: если нужен `total` для прежнего полного вида, его даёт `view=full` — дёшево.
|
Цена ошибки: `total` для полного вида даёт `view=full` — дёшево.
|
||||||
2. **2026-10-28 — фильтр «ССП» передаётся сужением `org_ids` до id организации, а не параметром `org_name`. Значения `smeta_type`/`smeta_direction` в API — подписи с экрана (`АХР`, `Поддержка`…), а не коды БД.**
|
2. **2026-10-28 — ~~фильтр «ССП» через `org_ids`, значения фильтров — перечисления~~. Отменено 28.10 решением человека 9: фильтр «ССП» — по `org_name` с семантикой MRT `equals`, значения фильтров — свободные строки.**
|
||||||
Обоснование: `org_unit.title` не уникален, `org_ids` уже есть и проверяется на права. Подписи совпадают с `data.header` и вариантами — перевод не нужен.
|
3. **2026-10-28 — общий отбор строк вынесен в `v3.svod2_lines` (одна копия условий для страниц и вариантов), без `SET search_path`, чтобы функция могла встраиваться.**
|
||||||
Цена ошибки: при переименовании подписей в SQL нужно править валидацию API — одна строка.
|
|
||||||
3. **2026-10-28 — общий отбор строк выносится в `v3.svod2_lines` (одна копия условий для страниц GO/RF и вариантов), без `SET search_path`, чтобы функция могла встраиваться. `v_svod2_go_export` сохраняет свою копию.**
|
|
||||||
Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами.
|
Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами.
|
||||||
Цена ошибки: если встраивание не срабатывает и отбор заметно дорожает, вернуть копии в каждую функцию — час работы. Сравнение — `explain_manual`.
|
Цена ошибки: если отбор заметно дорожает, вернуть копии — час работы, сравнение через `explain_manual`.
|
||||||
4. **2026-10-28 — компактный вид — та же вложенная форма `data`, только нужные пути, без ключей `null` (`jsonb_strip_nulls`). Строится проекцией полного `jsonb` в SQL (`v3.svod2_compact`), а не массивом значений и не отдельной сборкой из таблиц.**
|
4. **2026-10-28 — компактный вид — та же вложенная форма `data`, нужные пути, без `null`, проекцией полного `jsonb` в SQL (`v3.svod2_compact`).**
|
||||||
Обоснование: фронт читает те же пути (`valueAt`); `null` и отсутствие ключа отображаются одинаково (`formatValue`, `textColumn`); у свода 2 нет WS-слияния, которое зависело бы от явных `null` (зависимость из 1848 — только у `RealtimeTable`). Проверка «компакт = проекция полного» тривиальна. Ожидаемо ~1–1,5 КБ на строку против ~5,5 КБ.
|
Обоснование: фронт читает те же пути; `null` и отсутствие ключа отображаются одинаково; WS-слияния у свода 2 нет; проверка «проекция полного» тривиальна.
|
||||||
Цена ошибки: если 40 % не набирается, переход на массив значений меняет только `svod2_compact` и маппинг на фронте.
|
Цена ошибки: если цель 40 % не набирается, переход на массив значений меняет `svod2_compact` и маппинг на фронте.
|
||||||
5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU не больше 5 страниц на вкладку.**
|
5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU 5 страниц.**
|
||||||
Обоснование: 300 строк — около 8 экранов и примерно 0,3–0,45 МБ компактного JSON. На шаг прокрутки 200 px граница страницы приходится примерно раз в 55 шагов, упреждения на соседнюю страницу хватает при ответе < 2–3 с. В памяти ≤ 1 500 строк данных и ≤ 900 строк в таблице.
|
Обоснование: 300 строк — около 8 экранов, примерно 0,3–0,45 МБ компактного JSON; в памяти не больше 1 500 строк.
|
||||||
Цена ошибки: подбор констант по замеру, правка двух чисел.
|
Цена ошибки: правка двух чисел по замеру.
|
||||||
6. **2026-10-28 — окно реализуется срезом данных и отступами `paddingStart`/`paddingEnd` виртуализатора строк. Запасной вариант — массив длины `total` с заглушками.**
|
6. **2026-10-28 — окно реализуется срезом данных и отступами виртуализатора; запасной вариант — массив длины `total` с заглушками.**
|
||||||
Обоснование: только срез даёт память O(окна) и для объектов строк TanStack, а не только для данных. Накопительная бесконечная прокрутка отвергнута: после прокрутки до конца в памяти снова весь ответ, что противоречит решению 28.10.
|
Обоснование: только срез даёт память O(окна) и для объектов строк TanStack. Накопительная бесконечная прокрутка отвергнута: в конце в памяти оказался бы весь ответ.
|
||||||
Цена ошибки: если срез не заработает с MRT 3.2.1, запасной вариант дороже по памяти (десятки МБ объектов строк на масштабе 2) — порог памяти может не выполниться. Тогда это вопрос к человеку.
|
Цена ошибки: запасной вариант дороже по памяти — цель по памяти может не выполниться (follow-up).
|
||||||
7. **2026-10-28 — варианты фильтров считаются по году и организациям без учёта поиска и других фильтров. `NULL`-значения в варианты не попадают.**
|
7. **2026-10-28 — ~~варианты фильтров без учёта поиска и других фильтров~~. Отменено 28.10 решением человека 9: варианты — как в TanStack, с поиском и фильтрами других колонок, с числами.**
|
||||||
Обоснование: один запрос вариантов на «Загрузить» вместо запроса на каждое изменение. M в «Найдено N из M» — тот же ответ.
|
8. **2026-10-28 — ~~поиск ищет и в перекрытых полях `contract_summary`, числа — по тексту из БД~~. Отменено 28.10 решением человека 9: семантика `matchesSearch` повторяется точно, перекрытие описано как находка.**
|
||||||
Цена ошибки: если бизнесу нужны «перекрёстные» варианты, `facets` получает те же `search`/фильтры, а фронт перезапрашивает его при изменениях — полдня.
|
9. **2026-10-28 — ~~«Найдено» считает поиск и фильтры вместе~~. Отменено 28.10 решением человека 9: только поиск (`search_total`).**
|
||||||
8. **2026-10-28 — поиск ищет и в тех полях `contract_summary` (`counterparty`, `reference`, `deadline`, `comment`), которые на клиенте сейчас фактически не ищутся: спред `{...contract_summary, ...contract_detail}` (`Svod2Page.jsx:284-288`) перекрывает их одноимёнными полями `contract_detail`. Числа сравниваются по тексту из БД (`1234.50`), а не по JS-записи (`1234.5`).**
|
10. **2026-10-28 — целевые значения:**
|
||||||
Обоснование: «по тем же полям» понимается как «по тем же группам полей»; повторять перекрытие — значит повторять ошибку.
|
- память s1 ≤ 30 %, s2 ≤ 1,25 × s1;
|
||||||
Цена ошибки: поиск находит немного больше строк, чем раньше; сузить — одна строка SQL.
|
- первые строки ≤ 50 %;
|
||||||
9. **2026-10-28 — «Найдено N из M» считает поиск и колоночные фильтры вместе (раньше — только поиск).**
|
- compact ≤ 40 %;
|
||||||
Обоснование: N приходит с сервера одним `total` по всем условиям, отдельно «только поиск» пришлось бы считать вторым запросом.
|
- первая страница + варианты ≤ 5 %;
|
||||||
Цена ошибки: подпись иначе ведёт себя при одном колоночном фильтре без поиска. Вернуть прежнее — дополнительный `total` без фильтров.
|
- прежний режим ≤ +10 %;
|
||||||
10. **2026-10-28 — пороги успеха:**
|
- шаг прокрутки ≤ +10 %;
|
||||||
- память s1 ≤ 30 % «до», s2 ≤ 1,25 × s1;
|
- заглушки ≤ 5 %.
|
||||||
- время до первых строк ≤ 50 % «до»;
|
|
||||||
- компакт ≤ 40 % полного;
|
|
||||||
- первая страница + варианты ≤ 5 % полного ответа;
|
|
||||||
- прежний режим не медленнее +10 %;
|
|
||||||
- шаг прокрутки ≤ 1,10 × результата 1842;
|
|
||||||
- заглушки в кадре ≤ 5 %.
|
|
||||||
|
|
||||||
Обоснование: оценки по REPORT (М2, М9, М10). Перечисленные пороги консервативны, ожидаемый эффект больше. 10 % — шум при n=2.
|
Решением человека 11 это цели, а не условия слияния.
|
||||||
Цена ошибки: порог окажется недостижим или слишком слаб — пересмотр по фактическим цифрам с человеком, код не меняется.
|
Обоснование: оценки по REPORT (М2, М9, М10).
|
||||||
11. **2026-10-28 — стенд: код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`. `json_bytes_sheet` сохраняет смысл «максимум одного ответа», суммарные байты — новые метрики.**
|
Цена ошибки: пересмотр по фактическим цифрам, код не меняется.
|
||||||
Обоснование: сопоставимость метрик и прежних отчётов.
|
11. **2026-10-28 — код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`; `json_bytes_sheet` сохраняет смысл, суммарные байты — новые метрики.**
|
||||||
Цена ошибки: при расхождении кода сценариев «до» нужно переснять — около часа на масштабе 1.
|
Обоснование: сопоставимость.
|
||||||
12. **2026-10-28 — рабочий номер миграции `0053`; окончательный — «голова `test` + 1» при rebase.**
|
Цена ошибки: переснять «до» — около часа на масштабе 1.
|
||||||
Обоснование: 0050 занят тремя PR, их порядок неизвестен.
|
12. **2026-10-28 — рабочий номер миграции `0053`, окончательный — «голова + 1» при rebase.**
|
||||||
Цена ошибки: переименование двух файлов и правка тестов.
|
Обоснование: 0050 занят тремя PR.
|
||||||
13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2 в откатываемой сессии, а не полагаются на `fixture.sql`.**
|
Цена ошибки: переименование двух файлов.
|
||||||
Обоснование: в фикстуре нет ни одной строки, проходящей отбор свода 2 за 2026 год. Тесты со `skip` при отсутствии данных ничего не доказывали бы.
|
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 символов нереалистичен.
|
||||||
|
Цена ошибки: пользователь с таким запросом увидит ошибку вместо пустого результата. Снять ограничение — одна строка.
|
||||||
|
|||||||
@ -24,13 +24,13 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835
|
|||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- **API `GET /api/v1/svod/2/{sheet}/{year}`.** У эндпоинта уже есть `org_ids`, `offset`, `limit`, `sort_by` и `sort_direction`. Добавляются необязательные параметры:
|
- **API `GET /api/v1/svod/2/{sheet}/{year}`.** У эндпоинта уже есть `org_ids`, `offset`, `limit`, `sort_by` и `sort_direction`. Добавляются необязательные параметры:
|
||||||
- `search` — поиск подстроки без учёта регистра по тем же полям, что ищет экран сейчас;
|
- `search` — поиск с точно той же семантикой, что у экрана сейчас (`matchesSearch`: те же поля с тем же перекрытием, числа в записи JavaScript, регистр не зависит от локали БД);
|
||||||
- `smeta_type` и `smeta_direction` — фильтры по виду сметы и направлению;
|
- `smeta_type`, `smeta_direction` и `org_name` — колоночные фильтры с семантикой фильтра таблицы экрана (`equals`: без учёта регистра и пробелов по краям);
|
||||||
- `view=full|compact` — `compact` отдаёт только поля, которые экран показывает.
|
- `view=full|compact` — `compact` отдаёт только поля, которые экран показывает.
|
||||||
|
|
||||||
Если в запросе есть хотя бы один новый параметр, в ответе появляется поле `total` — число строк по всем условиям запроса без учёта `offset`/`limit`. `count`, как и раньше, равен длине `result`. Фильтр по организации — это сужение `org_ids` (параметр уже есть), с прежней проверкой прав.
|
Если в запросе есть хотя бы один новый параметр, в ответе появляется поле `total` — число строк по всем условиям запроса без учёта `offset`/`limit`. `count`, как и раньше, равен длине `result`. `org_ids` по-прежнему задаёт выбор ССП/РФ и проверяется на права.
|
||||||
- **Без новых параметров ответ байт-в-байт прежний.** Это нужно Excel-выгрузке RF, интеграционным тестам, `perfkit.bench` и `perfkit.gen.verify`.
|
- **Без новых параметров ответ байт-в-байт прежний.** Это нужно Excel-выгрузке RF, интеграционным тестам, `perfkit.bench` и `perfkit.gen.verify`.
|
||||||
- **Новый эндпоинт `GET /api/v1/svod/2/{sheet}/{year}/facets`.** Отдаёт общее число строк и варианты трёх колоночных фильтров («Вид сметы», «Направление», «ССП») для выбранного года и организаций.
|
- **Новый эндпоинт `GET /api/v1/svod/2/{sheet}/{year}/facets`.** Отдаёт общее число строк, число строк по поиску («Найдено N из M») и варианты трёх колоночных фильтров («Вид сметы», «Направление», «ССП») с числами строк. Варианты считаются как сейчас на экране: с учётом поиска и фильтров двух других колонок.
|
||||||
- **SQL.** Новая миграция:
|
- **SQL.** Новая миграция:
|
||||||
- `DROP` прежних `v3.v_svod2_go` и `v3.v_svod2_rf` (6 аргументов) и `CREATE` с новой сигнатурой, без перегрузок;
|
- `DROP` прежних `v3.v_svod2_go` и `v3.v_svod2_rf` (6 аргументов) и `CREATE` с новой сигнатурой, без перегрузок;
|
||||||
- общий отбор строк свода 2 выносится в одну функцию, её используют и страницы, и варианты фильтров;
|
- общий отбор строк свода 2 выносится в одну функцию, её используют и страницы, и варианты фильтров;
|
||||||
@ -38,17 +38,19 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835
|
|||||||
|
|
||||||
Всё совместимо с PostgreSQL 15. Отбор строк (какие строки попадают в свод) не меняется.
|
Всё совместимо с PostgreSQL 15. Отбор строк (какие строки попадают в свод) не меняется.
|
||||||
- **Экран свода 2.**
|
- **Экран свода 2.**
|
||||||
|
- Для пользователя всё остаётся таким же (решение 28.10): строки, поиск, варианты фильтров с числами, «Найдено N из M», сброс фильтров, вкладки, прокрутка. Отличаются только ожидание ответа сервера и заглушки при быстрой прокрутке.
|
||||||
- Таблица и колонки остаются прежними.
|
- Таблица и колонки остаются прежними.
|
||||||
- После «Загрузить» приходит первая страница строк, остальные догружаются при прокрутке.
|
- После «Загрузить» приходят первые две страницы строк (вторая — заранее), остальные догружаются при прокрутке.
|
||||||
- В памяти держится окно из нескольких страниц вокруг видимой области, а не весь ответ. Полоса прокрутки соответствует полному числу строк.
|
- В памяти держится окно из нескольких страниц вокруг видимой области, а не весь ответ. Полоса прокрутки соответствует полному числу строк.
|
||||||
- Поиск (debounce 300 мс уже есть) и три колоночных фильтра выполняются на сервере, варианты фильтров приходят с сервера.
|
- Поиск (debounce 300 мс уже есть) и три колоночных фильтра выполняются на сервере, варианты фильтров приходят с сервера.
|
||||||
- «Найдено N из M» строится из `total` и считает и поиск, и колоночные фильтры.
|
- «Найдено N из M» считает, как и сейчас, только поиск.
|
||||||
- Свод 2 больше не кладёт ответы в кэш `SvodContext`. Кэш остаётся только у свода 1, его поведение не меняется.
|
- Свод 2 больше не кладёт ответы в кэш `SvodContext`. Кэш остаётся только у свода 1, его поведение не меняется.
|
||||||
- **Стенд замеров `perf/`.**
|
- **Стенд замеров `perf/`.**
|
||||||
- `scroll.spec.ts`: свод 2 учитывает строки-заглушки, добавлены метрики времени до первых строк и байтов, фильтр ждёт ответа сервера.
|
- `scroll.spec.ts`: свод 2 учитывает строки-заглушки, добавлены метрики времени до первых строк и байтов, фильтр ждёт ответа сервера.
|
||||||
- Новый сценарий `svod2-memory.spec.ts` (GO «все» → RF «все» → GO, куча после GC).
|
- Новый сценарий `svod2-memory.spec.ts` (GO «все» → RF «все» → GO, куча после GC).
|
||||||
- В `api_split.py`, `explain_manual.py` и `scale_compare.py` добавлены страницы, компактный вид и варианты фильтров.
|
- В `api_split.py`, `explain_manual.py` и `scale_compare.py` добавлены страницы, компактный вид и варианты фильтров.
|
||||||
- Скрипт сверки серверной выдачи со старым полным ответом на данных стенда.
|
- Скрипты сверки серверной выдачи со старым полным ответом на данных стенда, в том числе оракул на Node.js с дословной копией `matchesSearch` и правил фильтров и вариантов таблицы.
|
||||||
|
- Цифры замеров — цели, а не условие слияния: недобор уходит в follow-up (решение 28.10). Условие слияния — доказанная корректность.
|
||||||
- **Три PR в стеке:**
|
- **Три PR в стеке:**
|
||||||
1. сервер — SQL, API, тесты;
|
1. сервер — SQL, API, тесты;
|
||||||
2. фронт — догрузка, поиск, фильтры;
|
2. фронт — догрузка, поиск, фильтры;
|
||||||
@ -60,13 +62,14 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835
|
|||||||
- Какие строки попадают в свод 2. Отбор по наличию данных — миграция 0049. Отбор RF по типу формы — баг O-5, он решается отдельно в AURORA-1917.
|
- Какие строки попадают в свод 2. Отбор по наличию данных — миграция 0049. Отбор RF по типу формы — баг O-5, он решается отдельно в AURORA-1917.
|
||||||
- Свод 1, сводные проектов, `SvodFilters` (общий для сводов 1 и 2), кэш `SvodContext` для свода 1.
|
- Свод 1, сводные проектов, `SvodFilters` (общий для сводов 1 и 2), кэш `SvodContext` для свода 1.
|
||||||
- Сортировка свода 2: на экране она выключена (`enableSorting: false`), на сервере остаётся по умолчанию `org_name asc`.
|
- Сортировка свода 2: на экране она выключена (`enableSorting: false`), на сервере остаётся по умолчанию `org_name asc`.
|
||||||
|
- Семантика поиска, колоночных фильтров, вариантов фильтров и «Найдено N из M», включая найденную странность поиска (поля `contract_summary`, одноимённые полям `contract_detail`, не ищутся) — описана в design.md как находка, не исправляется.
|
||||||
- Набор, порядок, ширины и форматирование колонок, единицы (руб./тыс./млн), закреплённые колонки, «Перейти к задачам». Подписи «Все ССП/РФ», «Загрузить» и «Обновить» и порядок действий пользователя (выбрать организации → «Загрузить») тоже прежние.
|
- Набор, порядок, ширины и форматирование колонок, единицы (руб./тыс./млн), закреплённые колонки, «Перейти к задачам». Подписи «Все ССП/РФ», «Загрузить» и «Обновить» и порядок действий пользователя (выбрать организации → «Загрузить») тоже прежние.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
### New Capabilities
|
### New Capabilities
|
||||||
- `summary2-api`: контракт API свода 2. Постраничная выдача с `total`, серверный поиск и фильтры, компактное представление строки, варианты фильтров, права доступа, совместимость прежнего ответа, критерии размера ответа.
|
- `summary2-api`: контракт API свода 2. Постраничная выдача с `total`, серверные поиск, фильтры и варианты фильтров с семантикой экрана, компактное представление строки, права доступа, совместимость прежнего ответа, замер размера ответа.
|
||||||
- `summary2-screen`: поведение экрана свода 2. Догрузка строк страницами при прокрутке, ограниченное окно строк в памяти, поиск и фильтры через сервер, «Найдено N из M», ошибки. Критерии производительности: память, время до первых строк, шаг скролла.
|
- `summary2-screen`: поведение экрана свода 2. Неизменное для пользователя поведение, догрузка строк страницами при прокрутке, ограниченное окно строк в памяти, поиск и фильтры через сервер, «Найдено N из M», ошибки. Замеры производительности: память, время до первых строк, шаг скролла.
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
Нет: `openspec/specs/` пока пуст.
|
Нет: `openspec/specs/` пока пуст.
|
||||||
@ -81,6 +84,7 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835
|
|||||||
- новая миграция `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`. Номер выбирается при rebase, см. design.md.
|
- новая миграция `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`. Номер выбирается при rebase, см. design.md.
|
||||||
- **Тесты бэка:**
|
- **Тесты бэка:**
|
||||||
- `api/tests/integration/test_summary_api_smoke.py` и новые интеграционные тесты со своими данными свода 2;
|
- `api/tests/integration/test_summary_api_smoke.py` и новые интеграционные тесты со своими данными свода 2;
|
||||||
|
- векторы `api/tests/fixtures/js_number_text_vectors.json` (запись чисел в JavaScript, сгенерированы Node.js);
|
||||||
- `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить на новую миграцию);
|
- `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить на новую миграцию);
|
||||||
- unit-тесты сервиса и валидации параметров.
|
- unit-тесты сервиса и валидации параметров.
|
||||||
- **Фронт:**
|
- **Фронт:**
|
||||||
@ -92,7 +96,7 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835
|
|||||||
- `perf/e2e/scenarios/scroll.spec.ts`;
|
- `perf/e2e/scenarios/scroll.spec.ts`;
|
||||||
- новый `perf/e2e/scenarios/svod2-memory.spec.ts`;
|
- новый `perf/e2e/scenarios/svod2-memory.spec.ts`;
|
||||||
- `perf/analysis/api_split.py`, `perf/analysis/explain_manual.py`, `perf/analysis/scale_compare.py`;
|
- `perf/analysis/api_split.py`, `perf/analysis/explain_manual.py`, `perf/analysis/scale_compare.py`;
|
||||||
- новый `perf/analysis/svod2_paging_check.py`;
|
- новые `perf/analysis/svod2_paging_check.py` и `perf/analysis/svod2_search_oracle.mjs`;
|
||||||
- `perf/README.md`.
|
- `perf/README.md`.
|
||||||
- **Связанные задачи:**
|
- **Связанные задачи:**
|
||||||
- **AURORA-1842** (PR #227, база стека) — виртуализация колонок и CSS-стили свода 2; от неё берётся результат шага скролла, который нельзя ухудшить.
|
- **AURORA-1842** (PR #227, база стека) — виртуализация колонок и CSS-стили свода 2; от неё берётся результат шага скролла, который нельзя ухудшить.
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Контракт API свода 2 (`/api/v1/svod/2/...`): постраничная выдача строк листа GO или RF с общим числом строк, серверные поиск и фильтры, компактное представление строки для экрана, варианты колоночных фильтров и совместимость прежнего ответа для выгрузки, тестов и стенда замеров.
|
Контракт API свода 2 (`/api/v1/svod/2/...`): постраничная выдача строк листа GO или RF с общим числом строк, серверные поиск и колоночные фильтры с той же семантикой, что у экрана до изменения, варианты колоночных фильтров, компактное представление строки и совместимость прежнего ответа для выгрузки, тестов и стенда замеров.
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: Прежний ответ без новых параметров
|
### Requirement: Прежний ответ без новых параметров
|
||||||
Запрос `GET /api/v1/svod/2/{sheet}/{year}`, в котором нет ни одного из параметров `view`, `search`, `smeta_type`, `smeta_direction`, SHALL возвращать ответ, байт-в-байт совпадающий с ответом до изменения на тех же данных и с теми же `org_ids`, `offset`, `limit`, `sort_by`, `sort_direction`. Совпадать должны набор и порядок строк, `sort_order`, содержимое `data` со всеми ключами, включая `null`, и верхний уровень `success`/`message`/`result`/`count`. Поля `total` в таком ответе SHALL NOT быть. Excel-выгрузка свода 2 SHALL выдавать тот же файл, что до изменения.
|
Запрос `GET /api/v1/svod/2/{sheet}/{year}`, в котором нет ни одного из параметров `view`, `search`, `smeta_type`, `smeta_direction`, `org_name`, SHALL возвращать ответ, байт-в-байт совпадающий с ответом до изменения на тех же данных и с теми же `org_ids`, `offset`, `limit`, `sort_by`, `sort_direction`. Совпадать должны набор и порядок строк, `sort_order`, содержимое `data` со всеми ключами, включая `null`, и верхний уровень `success`/`message`/`result`/`count`. Поля `total` в таком ответе SHALL NOT быть. Excel-выгрузка свода 2 SHALL выдавать то же содержимое, что до изменения. Это условие слияния.
|
||||||
|
|
||||||
#### Scenario: Полный ответ «все организации» не изменился
|
#### Scenario: Полный ответ «все организации» не изменился
|
||||||
- **WHEN** на одних и тех же данных стенда выполняется `GET /api/v1/svod/2/GO/{year}` и `GET /api/v1/svod/2/RF/{year}` без параметров до и после изменения
|
- **WHEN** на одних и тех же данных стенда выполняются `GET /api/v1/svod/2/GO/{year}` и `GET /api/v1/svod/2/RF/{year}` без параметров до и после изменения (роли admin и dfip, все организации и одна организация, `offset=0&limit=50`)
|
||||||
- **THEN** тела ответов после распаковки совпадают побайтно (одинаковый sha256)
|
- **THEN** тела ответов после распаковки совпадают побайтно (одинаковый sha256)
|
||||||
|
|
||||||
#### Scenario: Прежняя пагинация без total
|
#### Scenario: Прежняя пагинация без total
|
||||||
@ -20,58 +20,91 @@
|
|||||||
- **THEN** ответ — 422, как до изменения
|
- **THEN** ответ — 422, как до изменения
|
||||||
|
|
||||||
### Requirement: Постраничная выдача с общим числом строк
|
### Requirement: Постраничная выдача с общим числом строк
|
||||||
Если в запросе есть хотя бы один из параметров `view`, `search`, `smeta_type`, `smeta_direction` (это «новый режим»), ответ SHALL содержать поле `total`. `total` — число строк, удовлетворяющих всем условиям запроса (год, организации, поиск, фильтры), без учёта `offset` и `limit`. `count` SHALL по-прежнему равняться длине `result`. Строки SHALL идти в том же порядке, что в полном ответе с теми же условиями: `sort_order` — номер строки в этом порядке, начиная с 1. Страницы с последовательными `offset` при `limit = L` SHALL в объединении давать ровно полный список без пропусков и повторов, если данные между запросами не менялись. Если `offset` не меньше `total`, `result` SHALL быть пустым, а `total` — верным.
|
Если в запросе есть хотя бы один из параметров `view`, `search`, `smeta_type`, `smeta_direction`, `org_name` (это «новый режим»), ответ SHALL содержать поле `total`. `total` — число строк, удовлетворяющих всем условиям запроса (год, `org_ids`, поиск, колоночные фильтры), без учёта `offset` и `limit`. `count` SHALL по-прежнему равняться длине `result`. Строки SHALL идти в том же порядке, что в полном ответе с теми же условиями: `sort_order` — номер строки в этом порядке, начиная с 1. Страницы с последовательными `offset` при `limit = L` SHALL в объединении давать ровно полный список без пропусков и повторов, если данные между запросами не менялись. Если `offset` не меньше `total`, `result` SHALL быть пустым, а `total` — верным. Это условие слияния.
|
||||||
|
|
||||||
#### Scenario: Склейка страниц равна полному списку
|
#### Scenario: Склейка страниц равна полному списку
|
||||||
- **WHEN** при неизменных данных запрашиваются страницы `offset = 0, L, 2L, …` с `limit = L` и `view=full`, пока страница не станет короче `L`
|
- **WHEN** при неизменных данных запрашиваются страницы `offset = 0, L, 2L, …` с `limit = L` и `view=full`, пока страница не станет короче `L`
|
||||||
- **THEN** последовательность `data.line_id` по всем страницам совпадает с последовательностью в ответе без `offset`/`limit` с теми же условиями, а `total` на каждой странице равен длине этого полного списка
|
- **THEN** последовательность `data.line_id` по всем страницам совпадает с последовательностью в ответе без `offset`/`limit` с теми же условиями, а `total` на каждой странице равен длине этого полного списка
|
||||||
|
|
||||||
#### Scenario: Страница за концом списка
|
#### Scenario: Страница за концом списка
|
||||||
- **WHEN** запрашивается `offset = total` в новом режиме
|
- **WHEN** в новом режиме запрашивается `offset = total`
|
||||||
- **THEN** `result` пустой, `count` = 0, `total` равен числу строк по условиям запроса
|
- **THEN** `result` пустой, `count` = 0, `total` равен числу строк по условиям запроса
|
||||||
|
|
||||||
#### Scenario: Явный view=full включает total
|
#### Scenario: Явный view=full включает total
|
||||||
- **WHEN** выполняется `GET /api/v1/svod/2/RF/{year}?view=full&offset=0&limit=50`
|
- **WHEN** выполняется `GET /api/v1/svod/2/RF/{year}?view=full&offset=0&limit=50`
|
||||||
- **THEN** строки в `data` совпадают с прежним полным представлением тех же строк, а в ответе есть `total`
|
- **THEN** строки в `data` совпадают с прежним полным представлением тех же строк, а в ответе есть `total`
|
||||||
|
|
||||||
### Requirement: Серверный поиск
|
### Requirement: Серверный поиск с семантикой экрана
|
||||||
Параметр `search` SHALL отбирать строки, в которых строка поиска (без пробелов по краям) входит как подстрока, без учёта регистра (и для кириллицы), хотя бы в одно значение полей строки:
|
Параметр `search` SHALL отбирать ровно те строки, которые до изменения оставлял поиск экрана:
|
||||||
- все поля `data.header`;
|
1. Запрос нормализуется как `search.trim().toLowerCase()` в JavaScript (обрезка пробельных символов по правилам ECMAScript `String.prototype.trim`). Если после нормализации строка пустая, выборка SHALL NOT сужаться.
|
||||||
- все поля `data.contract_summary`;
|
2. Строка проходит отбор, если нормализованный запрос входит подстрокой хотя бы в одно значение объекта, полученного слиянием `{...data.header, ...data.contract_summary, ...data.contract_detail}` (поздние группы перекрывают одноимённые ключи ранних). Поля `contract_summary`, одноимённые полям `contract_detail` (`counterparty`, `reference`, `deadline`, `comment`), поэтому SHALL NOT участвовать в поиске.
|
||||||
- все поля `data.contract_detail`.
|
3. Каждое значение приводится к тексту как `String(value ?? '').toLowerCase()` в JavaScript:
|
||||||
|
- `null` — пустая строка;
|
||||||
|
- строка — как есть;
|
||||||
|
- число — по правилам `Number.prototype.toString` для двойной точности, которую экран получил из JSON ответа (например, `1234.50` → `1234.5`, `100.00` → `100`, `1e-7` → `1e-7`).
|
||||||
|
4. Значения берутся такими, какими они приходят в ответе этого листа. Например, на листе RF `contract_detail.expenses_q1..4` — это значения ответа (`COALESCE` договора и ЦКК).
|
||||||
|
5. Приведение к нижнему регистру SHALL NOT зависеть от локали базы данных. Для латиницы ASCII и кириллицы (включая Ё и буквы U+0400–U+042F, U+0490) результат SHALL совпадать с `toLowerCase()`.
|
||||||
|
|
||||||
Числа сравниваются по их текстовой записи, отсутствующие значения не совпадают ни с чем. Пустой после обрезки `search` SHALL NOT сужать выборку. `search` длиннее 200 символов SHALL отклоняться с 422. Поиск SHALL сочетаться с остальными условиями по «И».
|
Поиск сочетается с остальными условиями по «И». `search` длиннее 1000 символов SHALL отклоняться с 422.
|
||||||
|
|
||||||
#### Scenario: Поиск без учёта регистра по кириллице
|
#### Scenario: Поиск без учёта регистра по кириллице независимо от локали
|
||||||
- **WHEN** в данных есть строка с `header.name = «Аренда помещения»` и выполняется запрос с `search=аРЕНДА`
|
- **WHEN** в данных есть строка с `header.name = «Аренда помещения»`, база работает с `LC_CTYPE=C` или `en_US.utf8`, и выполняется запрос с `search=аРЕНДА`
|
||||||
- **THEN** строка есть в выдаче, а `total` равен числу строк, у которых хотя бы одно поле из перечисленных групп содержит «аренда» без учёта регистра
|
- **THEN** в обоих случаях строка есть в выдаче, а `total` равен числу строк, отобранных поиском экрана до изменения
|
||||||
|
|
||||||
|
#### Scenario: Перекрытые поля contract_summary не ищутся
|
||||||
|
- **WHEN** у строки `contract_summary.counterparty = «ООО Ромашка»`, а `contract_detail.counterparty` пустой, и ни одно другое поле не содержит «ромаш»
|
||||||
|
- **THEN** при `search=ромаш` строки нет в выдаче, как и на экране до изменения
|
||||||
|
|
||||||
#### Scenario: Поиск по договору
|
#### Scenario: Поиск по договору
|
||||||
- **WHEN** у строки `contract_detail.counterparty = «ООО Ромашка»` и `search=ромаш`
|
- **WHEN** у строки `contract_detail.counterparty = «ООО Ромашка»` и `search=ромаш`
|
||||||
- **THEN** строка есть в выдаче
|
- **THEN** строка есть в выдаче
|
||||||
|
|
||||||
|
#### Scenario: Числа ищутся в записи JavaScript
|
||||||
|
- **WHEN** у строки `contract_detail.ceiling_amount = 1234.50` в БД
|
||||||
|
- **THEN** `search=1234.5` находит строку, а `search=1234.50` не находит
|
||||||
|
|
||||||
#### Scenario: Пустой поиск не сужает
|
#### Scenario: Пустой поиск не сужает
|
||||||
- **WHEN** передан `search=%20%20` (два пробела) в новом режиме
|
- **WHEN** в новом режиме передан `search` из одних пробельных символов
|
||||||
- **THEN** `total` равен `total` того же запроса без `search`
|
- **THEN** `total` равен `total` того же запроса без `search`
|
||||||
|
|
||||||
#### Scenario: Слишком длинный поиск
|
#### Scenario: Сверка с поиском экрана на данных стенда
|
||||||
- **WHEN** длина `search` больше 200 символов
|
- **WHEN** для GO и RF масштаба 1 по набору запросов (`аренда`, `АРЕНДА`, `1`, `,`, подстрока названия организации, подстрока контрагента, дробное число из данных) серверная выдача сравнивается с функцией `matchesSearch` экрана до изменения, применённой в Node.js к прежнему полному ответу
|
||||||
- **THEN** ответ — 422
|
- **THEN** множества и порядок `line_id` совпадают для каждого запроса
|
||||||
|
|
||||||
### Requirement: Фильтры по виду сметы, направлению и организации
|
### Requirement: Колоночные фильтры с семантикой экрана
|
||||||
`smeta_type` SHALL принимать одно из значений `АХР`, `КВ`, `Операц` и оставлять строки с точно таким `data.header.smeta_type`. `smeta_direction` SHALL принимать `Поддержка` или `Развитие` и оставлять строки с точно таким `data.header.smeta_direction`. Другие значения SHALL отклоняться с 422. Фильтр по организации задаётся параметром `org_ids` (список id) и SHALL пересекаться с организациями, разрешёнными пользователю, как и раньше. Все условия сочетаются по «И».
|
Параметры `smeta_type`, `smeta_direction` и `org_name` SHALL оставлять строки, у которых соответствующее значение (`data.header.smeta_type`, `data.header.smeta_direction`, `data.header.org_name`) не `null` и после `toLowerCase().trim()` равно значению параметра после `toLowerCase().trim()`. Это семантика фильтра `equals` таблицы экрана до изменения. Пустое значение параметра SHALL NOT сужать выборку. Параметры длиннее 1000 символов SHALL отклоняться с 422. Все условия, включая `org_ids` и `search`, сочетаются по «И». `org_ids`, как и раньше, пересекается с организациями, разрешёнными пользователю.
|
||||||
|
|
||||||
#### Scenario: Фильтр по виду сметы
|
#### Scenario: Фильтр по виду сметы
|
||||||
- **WHEN** запрос с `smeta_type=КВ`
|
- **WHEN** запрос с `smeta_type=КВ`
|
||||||
- **THEN** у всех строк выдачи `data.header.smeta_type = «КВ»`, а `total` равен числу таких строк в полном ответе с теми же организациями
|
- **THEN** у всех строк выдачи `data.header.smeta_type = «КВ»`, а `total` равен числу таких строк в полном ответе с теми же организациями
|
||||||
|
|
||||||
#### Scenario: Комбинация условий
|
#### Scenario: Организации с одинаковым названием
|
||||||
- **WHEN** запрос с `search=X`, `smeta_direction=Развитие` и `org_ids=<id>`
|
- **WHEN** у двух организаций одинаковое название и запрос передаёт `org_name` с этим названием
|
||||||
- **THEN** выдача — пересечение трёх условий, `total` равен числу строк полного ответа для `org_ids=<id>`, которые удовлетворяют поиску и направлению
|
- **THEN** в выдаче строки обеих организаций, как при выборе этого значения в фильтре «ССП» на экране до изменения
|
||||||
|
|
||||||
#### Scenario: Недопустимое значение фильтра
|
#### Scenario: Комбинация условий
|
||||||
- **WHEN** передан `smeta_type=AHR` или `smeta_direction=Support`
|
- **WHEN** запрос с `search=X`, `smeta_direction=Развитие` и `org_name=<название>`
|
||||||
- **THEN** ответ — 422
|
- **THEN** выдача — пересечение трёх условий, её состав совпадает с тем, что экран до изменения показывал при тех же поиске и фильтрах
|
||||||
|
|
||||||
|
### Requirement: Варианты колоночных фильтров с семантикой экрана
|
||||||
|
`GET /api/v1/svod/2/{sheet}/{year}/facets` принимает `org_ids`, `search`, `smeta_type`, `smeta_direction`, `org_name` с тем же смыслом, что у страниц. Ответ SHALL содержать:
|
||||||
|
- `total` — число строк по году и `org_ids` без поиска и фильтров (M в «Найдено N из M»);
|
||||||
|
- `search_total` — число строк по году, `org_ids` и поиску без колоночных фильтров (N в «Найдено N из M»);
|
||||||
|
- для каждой из колонок `smeta_type`, `smeta_direction`, `org_name` — пары «значение → число строк». Они считаются по строкам, отобранным годом, `org_ids`, поиском и фильтрами **двух других** колонок; фильтр самой колонки не применяется. Значения различаются точным совпадением строк, `null` не выдаётся.
|
||||||
|
|
||||||
|
Это семантика вариантов (`getFacetedUniqueValues`) таблицы экрана до изменения, где поиск применялся к данным до таблицы. Неизвестный `sheet` SHALL давать 404. Это условие слияния.
|
||||||
|
|
||||||
|
#### Scenario: Варианты с учётом поиска и других фильтров
|
||||||
|
- **WHEN** задан поиск `X` и фильтр `smeta_type=КВ`
|
||||||
|
- **THEN** варианты `smeta_direction` и `org_name` с числами строк посчитаны по строкам, найденным по `X` с видом сметы «КВ», а варианты `smeta_type` — по строкам, найденным по `X`, без фильтра вида сметы
|
||||||
|
|
||||||
|
#### Scenario: Варианты совпадают с вычисленными по полному ответу
|
||||||
|
- **WHEN** для набора сочетаний поиска и фильтров запрошены `/facets` и прежний полный ответ того же листа, года и `org_ids`
|
||||||
|
- **THEN** `total`, `search_total` и пары «значение → число» совпадают с вычисленными по полному ответу по правилам выше
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный лист
|
||||||
|
- **WHEN** запрошен `/api/v1/svod/2/XX/{year}/facets`
|
||||||
|
- **THEN** ответ — 404
|
||||||
|
|
||||||
### Requirement: Компактное представление строки
|
### Requirement: Компактное представление строки
|
||||||
При `view=compact` поле `data` каждой строки SHALL содержать только поля ниже, со значениями, равными значениям тех же путей в полном представлении:
|
При `view=compact` поле `data` каждой строки SHALL содержать только поля ниже, со значениями, равными значениям тех же путей в полном представлении:
|
||||||
@ -101,36 +134,24 @@
|
|||||||
- **WHEN** в полном представлении `plan.plan_q1 = 0`
|
- **WHEN** в полном представлении `plan.plan_q1 = 0`
|
||||||
- **THEN** в компактном `plan.plan_q1 = 0`
|
- **THEN** в компактном `plan.plan_q1 = 0`
|
||||||
|
|
||||||
### Requirement: Варианты колоночных фильтров
|
|
||||||
`GET /api/v1/svod/2/{sheet}/{year}/facets` с необязательным `org_ids` SHALL возвращать для строк листа за год по этим организациям (без учёта поиска и колоночных фильтров):
|
|
||||||
- `total` — число строк;
|
|
||||||
- `smeta_type` и `smeta_direction` — отсортированные списки различных непустых значений;
|
|
||||||
- `org` — список различных организаций строк как пар `{id, name}`, отсортированный по `name`.
|
|
||||||
|
|
||||||
Результат SHALL совпадать с тем, что можно вычислить по полному ответу того же листа с теми же `org_ids`. Неизвестный `sheet` SHALL давать 404.
|
|
||||||
|
|
||||||
#### Scenario: Варианты совпадают с полным ответом
|
|
||||||
- **WHEN** запрошены `/facets` и полный ответ GO для одного года и `org_ids`
|
|
||||||
- **THEN** `total` равен длине полного ответа, множества `smeta_type`, `smeta_direction` и `{source_org_unit_id, org_name}` совпадают с вычисленными по полному ответу
|
|
||||||
|
|
||||||
#### Scenario: Неизвестный лист
|
|
||||||
- **WHEN** запрошен `/api/v1/svod/2/XX/{year}/facets`
|
|
||||||
- **THEN** ответ — 404
|
|
||||||
|
|
||||||
### Requirement: Права доступа на страницы и варианты
|
### Requirement: Права доступа на страницы и варианты
|
||||||
Для не-администратора выдача страниц, `total` и варианты фильтров SHALL строиться только по организациям, привязанным к пользователю. Запрошенные `org_ids` вне этого множества SHALL отбрасываться, как в прежнем режиме. Если пересечение пусто, ответ SHALL быть пустым: `result` пустой, `total` = 0, списки вариантов пусты.
|
Для не-администратора выдача страниц, `total` и варианты фильтров SHALL строиться только по организациям, привязанным к пользователю. Запрошенные `org_ids` вне этого множества SHALL отбрасываться, как в прежнем режиме. Если пересечение пусто, ответ SHALL быть пустым: `result` пустой, `total` = 0, `search_total` = 0, списки вариантов пусты.
|
||||||
|
|
||||||
#### Scenario: Чужая организация в фильтре
|
#### Scenario: Чужая организация в выборке
|
||||||
- **WHEN** исполнитель, привязанный к организации A, запрашивает страницу и `/facets` с `org_ids=<B>`
|
- **WHEN** исполнитель, привязанный к организации A, запрашивает страницу и `/facets` с `org_ids=<B>`
|
||||||
- **THEN** `result` пустой, `total` = 0, `org` в вариантах пуст
|
- **THEN** `result` пустой, `total` = 0, варианты пусты
|
||||||
|
|
||||||
### Requirement: Размер и стоимость ответа
|
### Requirement: Замер размера и стоимости ответа
|
||||||
На данных стенда масштаба 1:
|
Для GO и RF на масштабах 1 и 2 SHALL быть сняты и приведены в PR цифры «до/после». Цифры условием слияния не являются: недобор оформляется follow-up задачей. Что снимается:
|
||||||
- распакованный размер страницы `view=compact` SHALL быть не больше 40 % размера той же страницы `view=full`;
|
- распакованный размер страницы `view=compact` и той же страницы `view=full`;
|
||||||
- распакованный размер первой страницы (`limit` = размер страницы экрана), который экран получает до показа первых строк, SHALL быть не больше 5 % прежнего полного ответа того же листа.
|
- распакованный размер первой страницы и вариантов против прежнего полного ответа;
|
||||||
|
- медиана времени прежнего полного запроса.
|
||||||
|
|
||||||
Время ответа запроса в прежнем режиме (без новых параметров) SHALL ухудшиться не больше чем на 10 % по медиане.
|
Целевые значения:
|
||||||
|
- `compact` не больше 40 % `full`;
|
||||||
|
- первая страница вместе с вариантами не больше 5 % прежнего полного ответа;
|
||||||
|
- время прежнего запроса не больше 1,10 значения «до».
|
||||||
|
|
||||||
#### Scenario: Замер размеров на стенде
|
#### Scenario: Замер размеров на стенде
|
||||||
- **WHEN** `perf/analysis/api_split.py --scale 1` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets`
|
- **WHEN** `perf/analysis/api_split.py --scale <N>` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets`
|
||||||
- **THEN** отношение `bytes(compact) / bytes(full)` той же страницы не больше 0,40, `bytes(compact первой страницы) + bytes(facets)` не больше 5 % `bytes` полного ответа, медиана полного времени прежнего запроса не больше 1,10 от замера «до»
|
- **THEN** в отчёте PR есть отношения размеров и времён к целевым значениям, а каждый недобор записан как follow-up
|
||||||
|
|||||||
@ -1,15 +1,36 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Поведение экрана свода 2 (`/svod/2`, вкладки «ССП» и «РФ»): загрузка строк страницами при прокрутке с ограниченным объёмом данных в памяти, поиск и колоночные фильтры через сервер, счётчик «Найдено N из M» и критерии производительности экрана (память, время до первых строк, шаг прокрутки).
|
Поведение экрана свода 2 (`/svod/2`, вкладки «ССП» и «РФ») при загрузке строк страницами: для пользователя всё остаётся таким же, как до изменения (поиск, колоночные фильтры, варианты фильтров, «Найдено N из M», вкладки, прокрутка), а в памяти держится ограниченное окно строк. Плюс замеры производительности экрана: память, время до первых строк, шаг прокрутки.
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Поведение для пользователя не меняется
|
||||||
|
Всё, что пользователь видит и делает на экране свода 2, SHALL остаться таким же, как до изменения, при тех же данных:
|
||||||
|
- набор и порядок строк при любых сочетаниях года, ССП/РФ, поиска и колоночных фильтров;
|
||||||
|
- варианты колоночных фильтров и числа в скобках у вариантов;
|
||||||
|
- подпись «Найдено N из M строк» и момент её показа;
|
||||||
|
- сброс колоночных фильтров;
|
||||||
|
- поведение вкладок, «Загрузить», «Обновить», «Перейти к задачам», единиц;
|
||||||
|
- колонки, форматирование и оформление.
|
||||||
|
|
||||||
|
Допустимые отличия:
|
||||||
|
- время ожидания ответа сервера после ввода поиска или выбора фильтра;
|
||||||
|
- пустые строки-заглушки на месте ещё не пришедших строк при быстрой прокрутке.
|
||||||
|
|
||||||
|
#### Scenario: Визуальный паритет
|
||||||
|
- **WHEN** на одних данных открыть GO «все» до и после изменения и сравнить первые экраны и три позиции горизонтальной прокрутки
|
||||||
|
- **THEN** значения ячеек, прочерки и оформление совпадают
|
||||||
|
|
||||||
|
#### Scenario: Сброс колоночных фильтров
|
||||||
|
- **WHEN** задан колоночный фильтр, и пользователь меняет строку поиска, год или выбор ССП/РФ
|
||||||
|
- **THEN** колоночные фильтры вкладки сбрасываются, как до изменения
|
||||||
|
|
||||||
### Requirement: Загрузка строк страницами при прокрутке
|
### Requirement: Загрузка строк страницами при прокрутке
|
||||||
После нажатия «Загрузить» или «Обновить» экран SHALL запрашивать с сервера только страницу строк с начала списка и варианты колоночных фильтров. Полная длина полосы прокрутки SHALL соответствовать общему числу строк по текущим условиям. Строки, которые ещё не пришли, SHALL показываться пустыми строками-заглушками той же высоты: без значений и без прочерков, которыми помечаются пустые данные. Когда видимая область доходит до незагруженных строк, экран SHALL их запросить и заменить заглушки данными, без сдвига прокрутки. В памяти страницы SHALL держаться строки не более чем фиксированного числа страниц вокруг видимой области, независимо от общего числа строк. Порядок действий пользователя (выбор года и ССП/РФ, пункт «Все ССП/РФ», кнопка «Загрузить») и подписи не меняются.
|
После нажатия «Загрузить» или «Обновить» экран SHALL запрашивать с сервера страницы, покрывающие видимую область и по одной соседней странице с каждой стороны. При положении прокрутки в начале это страницы с `offset=0` и `offset=<размер страницы>`; вторая запрашивается сразу, чтобы при подходе к её строкам не было заглушек. Вместе со страницами запрашиваются варианты фильтров. Полная длина полосы прокрутки SHALL соответствовать общему числу строк по текущим условиям. Строки, которые ещё не пришли, SHALL показываться пустыми строками-заглушками той же высоты: без значений и без прочерков. Когда видимая область доходит до незагруженных строк, экран SHALL их запросить и заменить заглушки данными без сдвига прокрутки. Экран SHALL NOT менять положение прокрутки программно: оно ведёт себя так же, как раньше при смене данных в той же таблице. В памяти SHALL держаться строки не более чем фиксированного числа страниц, независимо от общего числа строк.
|
||||||
|
|
||||||
#### Scenario: Первая загрузка «Все ССП/РФ»
|
#### Scenario: Первая загрузка «Все ССП/РФ»
|
||||||
- **WHEN** пользователь выбирает «Все ССП/РФ» на вкладке «ССП» и нажимает «Загрузить»
|
- **WHEN** пользователь выбирает «Все ССП/РФ» на вкладке «ССП» и нажимает «Загрузить», а прокрутка в начале
|
||||||
- **THEN** экран отправляет один запрос страницы с `offset=0` и один запрос вариантов фильтров, показывает первые строки, а высота прокрутки соответствует `total` строкам
|
- **THEN** экран запрашивает варианты фильтров и страницы `offset=0` и `offset=<размер страницы>`, показывает первые строки, а высота прокрутки соответствует `total` строкам
|
||||||
|
|
||||||
#### Scenario: Прокрутка в середину и в конец
|
#### Scenario: Прокрутка в середину и в конец
|
||||||
- **WHEN** пользователь перетаскивает полосу прокрутки в середину, а затем в самый конец списка
|
- **WHEN** пользователь перетаскивает полосу прокрутки в середину, а затем в самый конец списка
|
||||||
@ -17,74 +38,69 @@
|
|||||||
|
|
||||||
#### Scenario: Память не растёт при прокрутке до конца
|
#### Scenario: Память не растёт при прокрутке до конца
|
||||||
- **WHEN** пользователь прокручивает лист RF «все» до конца и обратно
|
- **WHEN** пользователь прокручивает лист RF «все» до конца и обратно
|
||||||
- **THEN** число строк с данными, которые экран держит в памяти, не превышает предела окна (число страниц окна × размер страницы)
|
- **THEN** число строк с данными, которые экран держит в памяти, не превышает предела (число страниц кэша × размер страницы)
|
||||||
|
|
||||||
### Requirement: Поиск и колоночные фильтры через сервер
|
### Requirement: Поиск и колоночные фильтры через сервер
|
||||||
Изменение строки поиска (после существующей задержки ввода 300 мс) и изменение колоночного фильтра «Вид сметы», «Направление» или «ССП» SHALL перезапрашивать данные с сервера с начала списка, если по текущим году и ССП/РФ данные уже загружены. Если данные не загружены, перезапроса не происходит, как и сейчас. Варианты в колоночных фильтрах SHALL браться из ответа сервера с вариантами для текущих года и ССП/РФ. Ответ на устаревший запрос (после него условия уже поменялись) SHALL NOT попадать на экран. Как и сейчас, изменение поиска, года или ССП/РФ сбрасывает колоночные фильтры текущей вкладки.
|
Изменение строки поиска (после существующей задержки ввода 300 мс) и изменение колоночного фильтра «Вид сметы», «Направление» или «ССП» SHALL перезапрашивать с сервера страницы видимой области и варианты фильтров, если по текущим году и ССП/РФ данные уже загружены. Если данные не загружены, запросов нет, как и сейчас. До прихода ответа экран SHALL показывать прежние строки. Ответ на устаревший запрос (после него условия уже поменялись) SHALL NOT попадать на экран. Варианты каждого колоночного фильтра SHALL совпадать с теми, что экран показывал до изменения: по строкам, найденным поиском и отобранным фильтрами двух других колонок, с числом строк у каждого варианта, без пустых значений, в прежнем порядке сортировки.
|
||||||
|
|
||||||
#### Scenario: Поиск
|
#### Scenario: Поиск
|
||||||
- **WHEN** данные загружены и пользователь вводит «аренда» в поле поиска
|
- **WHEN** данные загружены и пользователь вводит «аренда» в поле поиска
|
||||||
- **THEN** через 300 мс после последнего нажатия экран запрашивает первую страницу с этим поиском и показывает только найденные строки с начала списка
|
- **THEN** через 300 мс после последнего нажатия экран запрашивает строки и варианты с этим поиском и показывает тот же набор строк, что до изменения
|
||||||
|
|
||||||
|
#### Scenario: Варианты фильтра зависят от поиска и других фильтров
|
||||||
|
- **WHEN** задан поиск и выбран фильтр «Вид сметы»
|
||||||
|
- **THEN** в фильтрах «Направление» и «ССП» перечислены значения только из найденных строк этого вида сметы, с числами строк, а в фильтре «Вид сметы» — значения найденных строк без учёта его собственного выбора
|
||||||
|
|
||||||
#### Scenario: Быстрая смена условий
|
#### Scenario: Быстрая смена условий
|
||||||
- **WHEN** пользователь вводит поиск и, не дожидаясь ответа, выбирает колоночный фильтр «Направление»
|
- **WHEN** пользователь вводит поиск и, не дожидаясь ответа, выбирает колоночный фильтр «Направление»
|
||||||
- **THEN** на экране остаются строки, отвечающие обоим условиям, а ответ на первый запрос не показывается
|
- **THEN** на экране остаются строки, отвечающие обоим условиям, а ответ на первый запрос не показывается
|
||||||
|
|
||||||
#### Scenario: Варианты фильтра «ССП»
|
|
||||||
- **WHEN** загружен лист с тремя организациями
|
|
||||||
- **THEN** в фильтре «ССП» перечислены ровно эти три организации, и выбор одной из них оставляет только её строки
|
|
||||||
|
|
||||||
### Requirement: Счётчик «Найдено N из M»
|
### Requirement: Счётчик «Найдено N из M»
|
||||||
Когда задан поиск или хотя бы один колоночный фильтр и число строк по условиям N отличается от общего числа строк M по текущим году и ССП/РФ, экран SHALL показывать «Найдено N из M строк». N берётся из `total` ответа страницы, M — из ответа с вариантами фильтров. Числа форматируются с разделителями `ru-RU`. Иначе подпись не показывается.
|
Подпись «Найдено N из M строк» SHALL вести себя как до изменения:
|
||||||
|
- N — число строк, найденных поиском, без учёта колоночных фильтров;
|
||||||
|
- M — число всех строк по текущим году и ССП/РФ;
|
||||||
|
- подпись видна, когда данные загружены, загрузка по «Загрузить»/«Обновить» не идёт, и N ≠ M;
|
||||||
|
- числа форматируются `ru-RU`.
|
||||||
|
|
||||||
#### Scenario: Счётчик учитывает и поиск, и фильтры
|
#### Scenario: Колоночный фильтр не меняет счётчик
|
||||||
- **WHEN** загружено 5 563 строки, задан поиск и фильтр «Вид сметы», и по ним найдено 120 строк
|
- **WHEN** загружено 5 563 строки, поиск находит 300, и пользователь выбирает фильтр «Вид сметы», после которого видно 120 строк
|
||||||
- **THEN** показано «Найдено 120 из 5 563 строк»
|
- **THEN** подпись остаётся «Найдено 300 из 5 563 строк»
|
||||||
|
|
||||||
### Requirement: Вкладки и обновление
|
### Requirement: Вкладки и обновление
|
||||||
У каждой вкладки («ССП», «РФ») SHALL сохраняться своё состояние (условия, загруженное окно строк, общее число строк). Возврат на вкладку показывает её строки без нового запроса, если её условия не менялись. «Обновить» SHALL перезапрашивать варианты фильтров и строки текущей вкладки с сервера. Смена года или ССП/РФ, как и сейчас, требует нажать «Загрузить». Свод 2 SHALL NOT сохранять полные ответы в общем кэше сводов. Поведение свода 1 не меняется.
|
Возврат на ранее загруженную вкладку SHALL показывать её строки, как до изменения. Запрос к серверу допускается только за страницами, которых нет в ограниченном кэше вкладки. «Обновить» SHALL перезапрашивать данные текущей вкладки с сервера. Смена года или ССП/РФ, как и сейчас, требует нажать «Загрузить». Свод 2 SHALL NOT сохранять полные ответы в общем кэше сводов. Поведение свода 1 не меняется.
|
||||||
|
|
||||||
#### Scenario: Переключение вкладок
|
#### Scenario: Переключение вкладок
|
||||||
- **WHEN** загружены «ССП» и «РФ», пользователь возвращается на «ССП»
|
- **WHEN** загружены «ССП» и «РФ» при прокрутке в начале, пользователь возвращается на «ССП»
|
||||||
- **THEN** строки «ССП» видны сразу, без запроса страницы
|
- **THEN** строки «ССП» видны сразу, без запроса страниц
|
||||||
|
|
||||||
#### Scenario: Обновить
|
#### Scenario: Обновить
|
||||||
- **WHEN** пользователь нажимает «Обновить»
|
- **WHEN** пользователь нажимает «Обновить»
|
||||||
- **THEN** экран заново запрашивает варианты фильтров и первую страницу и показывает актуальные данные с начала списка
|
- **THEN** экран заново запрашивает варианты фильтров и страницы видимой области и показывает актуальные данные
|
||||||
|
|
||||||
### Requirement: Неизменное отображение строк
|
|
||||||
Колонки, их группы, порядок, ширины, закреплённые колонки, форматирование чисел и прочерков (`-` для нуля, `—` для пустого), единицы (руб./тыс./млн), бейджи вида сметы и ССП, «Перейти к задачам» SHALL выглядеть и работать так же, как до изменения. Отображение SHALL NOT зависеть от того, пришло ли значение `null` или поле отсутствует.
|
|
||||||
|
|
||||||
#### Scenario: Визуальный паритет
|
|
||||||
- **WHEN** на одних данных открыть GO «все» до и после изменения и сравнить первые экраны и три позиции горизонтальной прокрутки
|
|
||||||
- **THEN** значения ячеек, прочерки и оформление совпадают
|
|
||||||
|
|
||||||
### Requirement: Ошибки загрузки
|
### Requirement: Ошибки загрузки
|
||||||
Если не удалась первая страница или варианты фильтров, экран SHALL показывать, как и сейчас, сообщение об ошибке с кнопкой «Повторить». Если не удалась одна из следующих страниц, экран SHALL оставить на её месте заглушки, сообщить об ошибке всплывающим уведомлением и повторить запрос, когда эта область снова окажется в видимой зоне.
|
Если не удался запрос по «Загрузить», «Обновить», поиску или колоночному фильтру, экран SHALL показывать, как и при ошибке загрузки сейчас, сообщение об ошибке с кнопкой «Повторить». Если не удалась догрузка страницы при прокрутке, экран SHALL оставить на её месте заглушки, показать всплывающее уведомление и повторить запрос, когда эта область снова окажется в видимой зоне.
|
||||||
|
|
||||||
#### Scenario: Ошибка догрузки
|
#### Scenario: Ошибка догрузки
|
||||||
- **WHEN** запрос страницы при прокрутке завершился ошибкой 500
|
- **WHEN** запрос страницы при прокрутке завершился ошибкой 500
|
||||||
- **THEN** уже показанные строки остаются, на месте страницы — заглушки, показано уведомление; при повторной прокрутке к этому месту запрос повторяется
|
- **THEN** уже показанные строки остаются, на месте страницы — заглушки, показано уведомление; при повторной прокрутке к этому месту запрос повторяется
|
||||||
|
|
||||||
### Requirement: Память страницы свода 2
|
### Requirement: Замеры производительности экрана
|
||||||
На стенде замеров сценарий `perf/e2e/scenarios/svod2-memory.spec.ts` (роль dfip: GO «Все ССП/РФ» → «Загрузить» → вкладка RF «Все ССП/РФ» → «Загрузить» → вкладка GO, затем сборка мусора) SHALL показывать JS-кучу `heap_used_mb` в конечной точке:
|
Для GO и RF на масштабах 1 и 2 (роль dfip, CPU 1×, одни данные, один и тот же код сценариев для «до» — вершины AURORA-1842 — и «после») SHALL быть сняты и приведены в PR:
|
||||||
- на масштабе 1 — не больше 30 % значения «до»;
|
- **память** (`perf/e2e/scenarios/svod2-memory.spec.ts`: GO «Все ССП/РФ» → «Загрузить» → RF «Все ССП/РФ» → «Загрузить» → GO, сборка мусора) — `heap_used_mb` в конечной точке;
|
||||||
- на масштабе 2 — не больше 1,25 значения «после» на масштабе 1, то есть куча не растёт вместе с объёмом данных.
|
- **время до первых строк** — `first_rows_ms`, от «Загрузить» до 20 строк с данными, заглушки не считаются;
|
||||||
|
- **шаг прокрутки** — `main_thread_ms_per_step` (масштаб 1) и доля кадров с заглушками.
|
||||||
|
|
||||||
#### Scenario: Замер памяти до и после
|
Целевые значения:
|
||||||
- **WHEN** сценарий выполнен на одних данных для сборки «до» (вершина AURORA-1842) и «после», на масштабах 1 и 2, по 3 повтора
|
- память на масштабе 1 — не больше 30 % «до», на масштабе 2 — не больше 1,25 значения «после» на масштабе 1;
|
||||||
- **THEN** медиана `heap_used_mb` в конечной точке «после» на масштабе 1 не больше 0,30 × «до», а на масштабе 2 — не больше 1,25 × «после» на масштабе 1
|
- время до первых строк — не больше 50 % «до»;
|
||||||
|
- шаг прокрутки — не больше 1,10 значения «до».
|
||||||
|
|
||||||
### Requirement: Время до первых строк
|
Цифры условием слияния не являются: недобор оформляется follow-up задачей.
|
||||||
Время от нажатия «Загрузить» при «Все ССП/РФ» до появления в таблице не менее 20 строк с данными (заглушки не считаются) SHALL быть не больше 50 % значения «до» для GO и RF на масштабах 1 и 2.
|
|
||||||
|
|
||||||
#### Scenario: Замер времени до первых строк
|
#### Scenario: Замер памяти
|
||||||
- **WHEN** `scroll.spec.ts` для `svod/svod2_GO` и `svod/svod2_RF` (роль dfip) пишет метрику `first_rows_ms` для сборок «до» и «после» на одних данных
|
- **WHEN** `svod2-memory.spec.ts` выполнен для «до» и «после» на масштабах 1 и 2, по 3 повтора
|
||||||
- **THEN** медиана `first_rows_ms` «после» не больше 0,5 × медианы «до» для каждого листа и масштаба
|
- **THEN** в PR приведены медианы `heap_used_mb` по точкам и их отношение к целевым значениям, недобор записан как follow-up
|
||||||
|
|
||||||
### Requirement: Шаг прокрутки не хуже AURORA-1842
|
#### Scenario: Замер времени до первых строк и шага прокрутки
|
||||||
Цена шага вертикальной прокрутки свода 2 (`main_thread_ms_per_step` в `scroll.spec.ts`, роль dfip, CPU 1×) SHALL быть не больше 1,10 значения «до» (вершина AURORA-1842, снятая тем же кодом сценария на тех же данных) для GO и RF на масштабе 1. Доля кадров с видимыми заглушками SHALL записываться рядом с ценой шага.
|
|
||||||
|
|
||||||
#### Scenario: Замер шага прокрутки
|
|
||||||
- **WHEN** `scroll.spec.ts` с `PERF_TARGETS='svod/svod2_*'`, `PERF_ROLES=dfip`, `--grep-invert filter` выполнен для «до» и «после»
|
- **WHEN** `scroll.spec.ts` с `PERF_TARGETS='svod/svod2_*'`, `PERF_ROLES=dfip`, `--grep-invert filter` выполнен для «до» и «после»
|
||||||
- **THEN** медиана `main_thread_ms_per_step` «после» не больше 1,10 × «до» для каждого листа, а в записи есть метрика доли кадров с заглушками
|
- **THEN** в записях есть `first_rows_ms`, `main_thread_ms_per_step` и доля кадров с заглушками, а в PR приведено сравнение с целевыми значениями
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user