DFiP_Budget_planing/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md
Andrey Epifancev c7ef4032d6 docs(plan): aurora-1843 — план реализации
Три фазы — три PR стеком: сервер (SQL, API, тесты-оракулы первыми),
фронт (окно страниц в той же таблице), стенд замеров. Решения плана
дописаны в design.md (#23–#34).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:06:06 +04:00

997 lines
97 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AURORA-1843 — свод 2: серверные страницы, поиск, фильтры и компактный ответ. План реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Свод 2 (`/svod/2`) получает строки страницами с сервера, поиск, три колоночных фильтра и их варианты считает сервер, а в памяти вкладки держится окно строк. Для пользователя поведение экрана не меняется (решение человека 9 от 28.10).
**Architecture:**
- **Сервер.** Одна миграция `0053`:
- локаленезависимые нормализаторы `svod2_casefold`, `svod2_js_trim` и `svod2_js_number_text`, плюс проекция `svod2_compact`;
- общий отбор `svod2_lines`;
- новые `v_svod2_go`/`v_svod2_rf` с поиском, фильтрами и `total_count`;
- `v_svod2_facets`.
Обработчик переходит в новый режим, только если в запросе есть хотя бы один новый параметр. Без них ответ байт-в-байт прежний.
- **Фронт.** Та же таблица MRT получает срез окна и отступы виртуализатора. Чистая логика лежит в `svod2Paging.js` (функции окна и редьюсер), хук `useSvod2Pages.js` ходит в API. Варианты фильтров приходят с сервера через переопределённый `getFacetedUniqueValues`.
- **Корректность доказывают оракулы.** Дословная JS-копия правил экрана «до» (`perf/analysis/svod2_search_oracle.mjs`) порождает векторы. Python-порт проверяется на этих векторах и служит оракулом интеграционных тестов API на засеянных данных. На стенде тот же JS-оракул сверяет сервер с прежним полным ответом.
**Tech Stack:** Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, Alembic, PL/pgSQL / SQL (PostgreSQL 15/16), pytest; React 18, MRT 3.2.1, TanStack Table 8.20.5 / virtual-core 3.11.2, axios, `node --test` (Node 24), Biome; Playwright (стенд `perf/`).
**Spec:** `openspec/changes/aurora-1843-svod2-server-paging/` (`proposal.md`, `design.md`, `specs/summary2-api/spec.md`, `specs/summary2-screen/spec.md`)
**Issue:** Gitea #236, Jira AURORA-1843 (эпик AURORA-1834).
**Ветки (три фазы = три PR, стеком):**
| фаза | ветка | база PR | от чего ответвляется |
|---|---|---|---|
| 1 — сервер | `feat/236-aurora-1843-svod2-server-paging` | `feat/220-aurora-1842-svod2-column-virtual` (PR #227) | вершина 1842 `399234a` + коммиты OpenSpec |
| 2 — фронт | `feat/236-aurora-1843-svod2-server-paging-web` | `feat/236-aurora-1843-svod2-server-paging` | вершина ветки фазы 1 после Task 8 |
| 3 — стенд | `feat/236-aurora-1843-svod2-server-paging-perf` | `feat/236-aurora-1843-svod2-server-paging-web` | вершина ветки фазы 2 после Task 13 |
**База стека:** `feat/220-aurora-1842-svod2-column-virtual` @ `399234a` (AURORA-1842, PR #227), она стоит на AURORA-1835 (PR #217) → `test`.
**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1843` (дальше `$WT`). Все три ветки ведутся в нём последовательно (`git switch -c`).
## Global Constraints
**Замок ночного прогона (правило волны 2).**
- Перед каждым тяжёлым шагом исполнитель выполняет `WAIT_LOCK` и ждёт снятия замка:
- тяжёлые шаги — pytest, alembic, `npm run build`, `node --test`, biome по многим файлам, запуск API или фронта, docker;
- команду ожидания запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго, повторять проверку.
```bash
L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done
```
- Читать и писать код и коммитить можно и при замке.
- Контроллер SDD передаёт это правило дословно в бриф каждого исполнителя и ревьюера.
**Стенд `perf/` исполнителям не запускать вообще:** БД `perf`, порты 8000/8080/8081/8082, `perf/stand.sh`, `perfkit.*`, Playwright-сценарии. Замеры, reset стенда, ручную проверку и сверку на стенде делает главный контроллер.
**Окружение тестов.**
- Python — только `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/` (pytest, alembic). Свой venv не создавать.
- `API_TEST <пути>` — интеграционный прогон на PG16 (`perf-pg`, порт 55432, своя БД `dfip_1843`). БД пересоздаётся перед каждым прогоном с `--init_db`. Дословно:
```bash
docker exec perf-pg dropdb -U perf --if-exists dfip_1843 && docker exec perf-pg createdb -U perf dfip_1843 && cd $WT/api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути> --init_db
```
- `UNIT_TEST <пути>` — `cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути>`, без БД.
- БД `perf` не трогать никогда.
- Исходное состояние полного набора — 3 падения, которые не наши:
- `test_backend_calls_form3_add_project_and_add_line_functions`;
- два теста в `test_export_service_query_params`.
Новых падений быть не должно.
- PG15.12 (`dfip-pg15`, порт 55434) проверяет главный контроллер (шаги «передать контроллеру» в Task 8).
- Фронт: `web/node_modules` — симлинк, `npm install` не запускать. Доступны `cd $WT/web && node --test <файлы>`, `npx biome check <файлы>`, `npm run build`.
- `perf/e2e/node_modules` у исполнителя нет. TypeScript-логику стенда выносить в чистые модули без импортов и только со стираемым синтаксисом (без `enum`, `namespace`, parameter properties). Тесты к ним — `node --test perf/e2e/tests/<файл>.test.ts`: Node 24 сам стирает типы. Playwright-прогоны и проверку типов делает контроллер.
- Python-тесты стенда: `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/<файл>`.
- Карта кода: `mcp__codegraph__codegraph_explore` с `projectPath=$WT`. Implementer вызывает его по своей области перед изменением.
**Правила кода и данных.**
- Совместимость с PostgreSQL 15: без `any_value`, конструкторов SQL/JSON, `IS JSON` и `JSON_TABLE`; у подзапросов в `FROM` нужны алиасы.
- `v3.v_svod2_go_export` и `SummaryRepository.get_svod2_rows` не менять.
- `ROW_VIRTUALIZER_OPTIONS` в `Svod2Page.jsx:26-31` не править: это hunk AURORA-1836. Опции собираются в компоненте.
- `SvodContext`, `SvodFilters` и свод 1 не менять.
- Эталон `api/tests/fixtures/svod2_legacy_golden.json` пишется один раз в Task 3, на коде «до». Потом его не перезаписывать: если тест эталона упал, ищется причина в коде, а не меняется эталон.
- Семантику экрана не «улучшать»: перекрытие полей `contract_summary`, поиск по техническим id и запись чисел в JS сохраняются. Находки по ним — `deferred`.
- Миграция: рабочий номер `0053`, `down_revision = "0049"`. Окончательный номер «голова + 1» выбирается при rebase (design, «Совместимость»).
**Коммиты и PR.**
- Сообщения коммитов по-русски, в стиле репозитория: `perf(api): …`, `perf(web): …`, `test(api): …`, `perf: …` для `perf/`, `docs(…)`.
- `git add` только конкретных файлов. Последняя строка сообщения — `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- `.bounded` не создавать. `git stash` без тега не использовать. Не мержить, не пушить в `test`/`main`.
- Между фазами остановок для человека нет: PR создаётся, и сразу начинается следующая фаза. Merge — только по команде человека.
- Решения без обсуждения дописываются в `openspec/changes/aurora-1843-svod2-server-paging/design.md` → `## Decisions made without discussion`: дата, решение, обоснование, цена ошибки. Нумерация продолжается с 35.
**Маркировка находок.**
- `T<n>.<k>` — minor-находка ревьюера задачи n. Номера задач сквозные по плану, 1–18.
- `C-n`/`I-n`/`M-n` — финальное ревью ветки. Нумерация сквозная по фиче: фаза 2 продолжает номера фазы 1, фаза 3 — номера фазы 2.
- `R-n (design #m)` — рулинг контроллера, `O-n` — наблюдение контроллера.
- Статус каждой находки: `fixed <commit>`, `deferred` или `rejected <причина>`.
- Таблица `## Findings` (`| Маркер | Суть | Статус |`) ведётся в конце ledger и копируется в каждый PR.
- Две находки design.md («Найденные странности»: перекрытие полей поиска, поиск по техническим id) заносятся в таблицу сразу как `O-1` и `O-2` со статусом `deferred`.
## Review Focus
1. **Строка без строки договора** (`contract_detail` нет в БД, `data.contract_detail` — пустая секция с `null`). Человек ждёт, что поиск по `contract_summary.counterparty` такой строки ведёт себя как на экране «до»: поля перекрыты `null`, строка не находится. Если `cd_j` в SQL окажется `NULL`, оператор `||` даст `NULL`, и строка пропадёт из поиска по любому полю. Тест: Task 3 (засев) + `test_search_matches_screen_oracle[*-лютик]`.
2. **Названия организаций, отличающиеся регистром или пробелом по краям** (`«Филиал Ёлки»` и `«филиал ёлки »`). Человек ждёт два варианта в фильтре «ССП» (у каждого своё число), а выбор любого из них отбирает строки обеих организаций (MRT `equals`). Тест: Task 1 (векторы) и Task 3 (`test_filters_match_screen_oracle[org-case-variant]`, `test_facets_match_screen_oracle[*]`).
3. **Поиск или фильтр при прокрутке глубоко вниз:** `total` уменьшился ниже текущего `scrollTop`. Человек ждёт, что браузер сам ограничит прокрутку и покажет последние строки, а не пустоту и не запрос `offset ≥ total`. Тест: Task 10 (`getVisiblePage` прижимает к последней странице, `getWindowPages` при `total = 0` пуст).
4. **Быстрая смена условий:** ответ на старый запрос приходит после ответа на новый. Человек ждёт на экране только строки, варианты и «Найдено» нового запроса. Тест: Task 11 (устаревшие `page`/`facets`/`fail` игнорируются).
5. **Данные изменились между запросами страниц** (страница пришла короче или длиннее ожидаемого, `total` другой). Человек ждёт, что строки не съезжают: высота прокрутки по последнему `total`, недостающие места — заглушки. Ошибка догрузки оставляет заглушки и повторяет запрос при возврате к этому месту. Тест: Task 10 (`buildWindowRows` дополняет и обрезает) и Task 11 (`fail` в режиме `scroll` не трогает состояние, страница снова в `getMissingPages`).
---
# Фаза 1 — сервер (ветка `feat/236-aurora-1843-svod2-server-paging`, PR в `feat/220-aurora-1842-svod2-column-virtual`)
Тесты-оракулы идут первыми (Task 1–3). К концу Task 3 интеграционные тесты нового режима красные. SQL и API (Task 4–7) делают их зелёными.
### Task 1: JS-оракул экрана «до» и векторы
**Модель исполнителя:** sonnet.
**Files:**
- Create: `perf/analysis/svod2_search_oracle.mjs`
- Create: `perf/analysis/svod2_search_oracle.test.mjs`
- Create (порождается скриптом): `api/tests/fixtures/js_number_text_vectors.json`, `api/tests/fixtures/svod2_screen_vectors.json`
**Interfaces:**
- Consumes: код «до» — `git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx` (`matchesSearch`, `:283-294`; поиск `:387-391`), `web/node_modules/material-react-table/dist/index.esm.js` (`equals`, `:663-667`; `useDropdownOptions`, `:203-218`), `web/node_modules/@tanstack/table-core/build/lib/utils/getFacetedRowModel.js`, `getFacetedUniqueValues.js`.
- Produces (ESM-экспорты):
- `matchesSearch(row, query): boolean` — **дословная** копия строк `:283-294` (`const matchesSearch = …`), экспорт отдельной строкой `export { matchesSearch, equals }`;
- `equals(row, id, filterValue): boolean` — **дословная** копия MRT `equals` (`:663-667`, с `var _a;`). Вызывается на объекте `{ getValue: (id) => row.data?.header?.[id] }`: это `accessorFn` колонок `valueAt(row, ['header', id])`;
- `FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']`;
- `screenRows(rows, { search = '', filters = {} } = {}): object[]`. Поиск — как `filteredRows` «до»: `search.trim().toLowerCase()`, пустой не фильтрует. Затем MRT-фильтр `equals` по каждому `filters[id]`, у которого значение истинно (`equals.autoRemove = (val) => !val`). Порядок строк сохраняется;
- `screenFound(rows, search): { found: number, total: number }` — N и M «Найдено» «до»;
- `screenFacets(rows, { search = '', filters = {} } = {}): { total, search_total, smeta_type: [string, number][], smeta_direction: [...], org_name: [...] }`. Для колонки `c` берутся строки, найденные поиском, и к ним применяются активные фильтры всех колонок, кроме `c` (правило `filterableIds` в `getFacetedRowModel`). Затем подсчёт по точному значению (`Map`, как `getFacetedUniqueValues`). `null`/`undefined` отбрасываются, сортировка `(a, b) => a.localeCompare(b)` — дословный фрагмент `useDropdownOptions`;
- `NUMBER_VECTOR_INPUTS: string[]` (JSON-литералы), `SYNTHETIC_ROWS: object[]`, `SCREEN_CASES: { search: string, filters: object }[]`;
- CLI: `node perf/analysis/svod2_search_oracle.mjs --emit-vectors <dir>` пишет `<dir>/js_number_text_vectors.json` (`[{ "input": "1234.50", "js": "1234.5" }, …]`, где `js = String(JSON.parse(input))`) и `<dir>/svod2_screen_vectors.json` (`{ "rows": SYNTHETIC_ROWS, "cases": [{ "search", "filters", "line_ids", "found": { "found", "total" }, "facets": <screenFacets> }] }`).
`NUMBER_VECTOR_INPUTS` содержит как минимум: `0`, `-0`, `1`, `-1`, `100`, `100.00`, `1234.50`, `12.34`, `0.5`, `0.30000000000000004`, `0.000001`, `1e-7`, `-1.5e-10`, `123e-20`, `1e15`, `1e20`, `1e21`, `123456789012345680000`, `9007199254740993`, `5e-324`, `1.7976931348623157e308`.
`SYNTHETIC_ROWS` — 10–15 строк в форме `{ data: { line_id, header: {…}, contract_summary: {…}, contract_detail: {…} } }`. В них есть:
- кириллица разного регистра, `Ё`/`ё`;
- NBSP и `U+FEFF` внутри значений;
- перекрытое поле (`contract_summary.counterparty = 'ООО Ромашка'` при `contract_detail.counterparty = null`) и поле договора (`contract_detail.counterparty = 'ООО Ромашка Плюс'`);
- числа `1234.5`, `100`, `0`, `1e-7`; `true`; `null`;
- `smeta_direction = null`;
- две строки с одинаковым `org_name` и одна с вариантом `'филиал ёлки '`.
`SCREEN_CASES` — не меньше 12 сочетаний: пустой поиск; `'аРЕНДА'`; `'ё'`; `'ромаш'`; `'1234.5'`; `'1234.50'`; `' аренда '`; `' '`; `'1'`; фильтр `'кв '`; фильтр по названию организации; два фильтра вместе; поиск вместе с фильтром.
- [ ] **Step 1: Написать падающие тесты** в `perf/analysis/svod2_search_oracle.test.mjs` (`node:test`, `node:assert/strict`):
```js
test('перекрытое поле contract_summary не ищется', () => {
const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } };
assert.equal(matchesSearch(row, 'ромаш'), false);
});
test('число ищется в записи JS', () => {
const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } };
assert.equal(matchesSearch(row, '1234.5'), true);
assert.equal(matchesSearch(row, '1234.50'), false);
});
test('screenRows: trim + lower, NBSP по краям', () => {
const rows = [{ data: { line_id: 1, header: { name: 'Аренда помещения' } } }];
assert.deepEqual(screenRows(rows, { search: ' АРЕНДА ' }).map((r) => r.data.line_id), [1]);
});
test('equals: регистр и пробелы по краям, null не проходит', () => {
const r = (v) => ({ getValue: () => v });
assert.equal(equals(r('КВ'), 'smeta_type', 'кв '), true);
assert.equal(equals(r(null), 'smeta_type', 'кв'), false);
});
test('screenFacets: свой фильтр не применяется, чужие — да', () => { /* строки: (КВ, Развитие), (АХР, Поддержка) */
const f = screenFacets(rows2, { filters: { smeta_type: 'КВ' } });
assert.deepEqual(f.smeta_type, [['АХР', 1], ['КВ', 1]]);
assert.deepEqual(f.smeta_direction, [['Развитие', 1]]);
});
test('screenFacets: одинаковые названия — один вариант, null не выдаётся', () => { /* два 'Филиал Ёлки', один null */
assert.deepEqual(screenFacets(rows3).org_name, [['Филиал Ёлки', 2]]);
});
test('screenFound считает только поиск', () => {
assert.deepEqual(screenFound(rows2, ''), { found: 2, total: 2 });
});
test('векторы чисел', () => {
const v = Object.fromEntries(emitNumberVectors().map(({ input, js }) => [input, js]));
assert.equal(v['1e21'], '1e+21'); assert.equal(v['-0'], '0'); assert.equal(v['1e-7'], '1e-7');
assert.equal(v['0.000001'], '0.000001'); assert.equal(v['100.00'], '100'); assert.equal(v['9007199254740993'], '9007199254740992');
});
```
(`emitNumberVectors(): { input, js }[]` и `emitScreenVectors(): { rows, cases }` — тоже экспорты модуля, CLI их сериализует.)
- [ ] **Step 2: Запустить и убедиться, что тесты падают.** `WAIT_LOCK`, затем `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`. Ожидается: FAIL, `Cannot find module`.
- [ ] **Step 3: Реализовать модуль** `perf/analysis/svod2_search_oracle.mjs` по Interfaces. `matchesSearch` и `equals` скопировать дословно, не переформатировать.
- [ ] **Step 4: Проверить дословность копий.**
```bash
cd $WT && git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx | sed -n 283,294p | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo MATCHES_OK
sed -n 663,667p web/node_modules/material-react-table/dist/index.esm.js | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo EQUALS_OK
```
Ожидается: `MATCHES_OK`, `EQUALS_OK`.
- [ ] **Step 5: Тесты зелёные.** `WAIT_LOCK`; `node --test perf/analysis/svod2_search_oracle.test.mjs` — PASS.
- [ ] **Step 6: Породить векторы.** `cd $WT && node perf/analysis/svod2_search_oracle.mjs --emit-vectors api/tests/fixtures`. Ожидается: два файла, `svod2_screen_vectors.json` содержит `cases` длиной не меньше 12.
- [ ] **Step 7: Commit.**
```bash
git add perf/analysis/svod2_search_oracle.mjs perf/analysis/svod2_search_oracle.test.mjs api/tests/fixtures/js_number_text_vectors.json api/tests/fixtures/svod2_screen_vectors.json
git commit -m "test: свод 2 — JS-оракул поиска, фильтров и вариантов экрана «до» и векторы"
```
### Task 2: Python-порт оракула, сверенный с векторами JS
**Модель исполнителя:** sonnet.
**Files:**
- Create: `api/tests/svod2_oracle.py` (импорт `from tests.svod2_oracle import …`; `pythonpath = .` в `api/pytest.ini`)
- Create: `api/tests/unit/test_svod2_oracle.py`
**Interfaces:**
- Consumes: `api/tests/fixtures/js_number_text_vectors.json`, `api/tests/fixtures/svod2_screen_vectors.json` (Task 1).
- Produces:
- `FILTER_COLUMNS = ("smeta_type", "smeta_direction", "org_name")`;
- `js_number_text(x: int | float) -> str` — `Number::toString` (ECMAScript): цифры из `repr(float(x))` (кратчайшая запись), затем правила `k ≤ n ≤ 21` / `0 < n ≤ 21` / `−6 < n ≤ 0` / экспонента `e±`, `-0` → `"0"`. Тот же алгоритм, что SQL-функция в design § 3;
- `js_string(value: object) -> str` — `String(value ?? '')`: `None` → `""`, `bool` → `"true"`/`"false"`, число → `js_number_text`, `str` как есть;
- `js_trim(s: str) -> str` — обрезка по набору ECMAScript `WhiteSpace` + `LineTerminator` из design § 3;
- `matches_search(row: dict, query: str) -> bool` — слияние `{**header, **contract_summary, **contract_detail}` (отсутствующая или `None` группа — пустой словарь), `query in js_string(v).lower()`;
- `screen_rows(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> list[dict]`;
- `screen_found(rows: list[dict], search: str) -> tuple[int, int]`;
- `screen_facets(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> dict` → `{"total": int, "search_total": int, "smeta_type": dict[str, int], "smeta_direction": dict[str, int], "org_name": dict[str, int]}`;
- `COMPACT_PATHS: tuple[str, ...]` — пути через точку из требования «Компактное представление строки» (`line_id`, `header.source_form`, …, `q4.actual_spod`, `q1.transfer_to_q2` …);
- `compact_projection(data: dict) -> dict` — ровно то, что даёт `jsonb_strip_nulls(jsonb_build_object(...))` по `COMPACT_PATHS`. Каждая группа и подгруппа (`header`, `plan`, `sequestration.DFIP`, `q1` …) присутствует всегда, возможно пустой. Листья со значением `None` или отсутствующие опускаются, `0` сохраняется.
- [ ] **Step 1: Написать падающие тесты** `api/tests/unit/test_svod2_oracle.py`:
```python
FIX = Path(__file__).resolve().parents[1] / "fixtures"
NUM = json.loads((FIX / "js_number_text_vectors.json").read_text())
SCREEN = json.loads((FIX / "svod2_screen_vectors.json").read_text())
def test_js_number_text_matches_node_vectors():
for v in NUM:
assert js_number_text(json.loads(v["input"])) == v["js"], v
@pytest.mark.parametrize("case", SCREEN["cases"], ids=lambda c: f"{c['search']!r}-{c['filters']}")
def test_screen_port_matches_node(case):
rows = SCREEN["rows"]
assert [r["data"]["line_id"] for r in screen_rows(rows, case["search"], case["filters"])] == case["line_ids"]
assert screen_found(rows, case["search"]) == (case["found"]["found"], case["found"]["total"])
f = screen_facets(rows, case["search"], case["filters"])
assert (f["total"], f["search_total"]) == (case["facets"]["total"], case["facets"]["search_total"])
for col in FILTER_COLUMNS:
assert f[col] == dict(case["facets"][col])
def test_compact_projection_keeps_zero_drops_null_and_unknown():
out = compact_projection({"line_id": 1, "header": {"source_form": "FORM_1", "name": None, "vsp_id": 5},
"plan": {"plan_q1": 0, "plan_q2": None}})
assert out["line_id"] == 1
assert out["header"] == {"source_form": "FORM_1"}
assert out["plan"] == {"plan_q1": 0}
assert out["sequestration"] == {"DFIP": {}}
assert "transfer_to_q1" not in out["q1"] and set(out) >= {"q1", "q2", "q3", "q4", "contract_detail"}
```
- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_svod2_oracle.py`. Ожидается: FAIL, `ModuleNotFoundError: tests.svod2_oracle`.
- [ ] **Step 3: Реализовать** `api/tests/svod2_oracle.py` по Interfaces.
- [ ] **Step 4:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_svod2_oracle.py` — PASS (все случаи векторов).
- [ ] **Step 5: Commit.** `git add api/tests/svod2_oracle.py api/tests/unit/test_svod2_oracle.py`; сообщение `test(api): свод 2 — Python-оракул экрана, сверенный с векторами JS`.
### Task 3: Засев свода 2, эталон прежнего ответа и тесты-оракулы API (красные)
**Модель исполнителя:** sonnet.
**Files:**
- Create: `api/tests/sqls/svod2_seed.sql`
- Modify: `api/tests/integration/conftest.py`: фикстура `client` получает строку `test_client.db_session = db_ref` перед `yield`; новые фикстуры `svod2_data`, `svod2_sql`
- Create: `api/tests/integration/test_svod2_legacy_golden.py`
- Create (порождается тестом): `api/tests/fixtures/svod2_legacy_golden.json`
- Create: `api/tests/integration/test_svod2_paging_api.py`
**Interfaces:**
- Consumes: `tests.svod2_oracle` (Task 2), фикстуры `client`, `admin_tokens`, `isp_tokens`, `auth_headers`.
- Produces:
- фикстура `svod2_data(client) -> dict` → `{"year": 2031, "org_a": 910001, "org_b": 910002, "org_b_twin": 910003, "org_b_case": 910004, "cd_id": <id строки contract_detail>, "ckk_only_value": "777.25"}`. Скрипт `svod2_seed.sql` выполняется на `client.db_session` через `client.portal.call(...)` (тот же цикл событий, что у запросов). Способ выполнения многооператорного скрипта — как в `run_seed_script_async` (`api/tests/conftest.py`). Откат — общий `rollback` фикстуры `client`;
- фикстура `svod2_sql(client) -> Callable[[str, dict | None], list[Row]]` выполняет `text(sql)` на той же сессии через портал (нужна Task 4–5);
- константы теста: `SVOD2_YEAR = 2031`.
**Требования к `svod2_seed.sql`.** Явные id от `910001`, год `2031` (в `fixture.sql` его нет). Каждая строка проходит отбор 0049: есть хотя бы одна связанная запись с данными. Что засеять:
- организации: A `«Тестовый филиал Альфа»` (910001), B `«Филиал Ёлки»` (910002), двойник B с тем же названием (910003) и `«филиал ёлки »` (910004);
- формы FORM_1 и FORM_4 (лист GO), FORM_2 (лист RF) за 2031 год;
- строки на все значения `smeta_type`, достижимые по `expense_item.sheet`, и на `direction` `Support`, `Development` и `NULL`;
- `header.name = 'Аренда помещения'`, строка с `ЁЛКА` в названии;
- `contract_summary.counterparty = 'ООО Ромашка'` при строке `contract_detail` с `counterparty = NULL` (перекрытие);
- `contract_summary.counterparty = 'ООО Лютик'` **без строки** `contract_detail` (Review Focus 1);
- `contract_detail.counterparty = 'ООО Ромашка Плюс'`;
- `contract_detail.ceiling_amount = 1234.50`, `plan_q1 = 100.00`, `plan_q2 = 0`;
- на листе RF строка, у которой `contract_detail.expenses_q1 IS NULL`, а `ckk.expenses_q1 = 777.25`;
- `v3.user_org(user_id = 2 /* isp1 */, org_unit_id = 910001)`.
- [ ] **Step 1: Засев и фикстуры.** Написать `svod2_seed.sql` и фикстуры в `conftest.py`.
- [ ] **Step 2: Тест эталона** `test_svod2_legacy_golden.py`. Случаи — `GO` и `RF` × `admin_all` (без параметров), `admin_one` (`org_ids=910001`), `isp_all` (isp1, без параметров), `admin_page50` (`offset=0&limit=50`), `admin_two` (`offset=0&limit=2`). Запись режимом `SVOD2_WRITE_GOLDEN=1`, иначе сравнение:
```python
@pytest.mark.parametrize("sheet", ["GO", "RF"])
@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.name)
def test_legacy_response_is_byte_identical(client, svod2_data, admin_tokens, isp_tokens, auth_headers, sheet, case):
r = client.get(case.url(sheet, svod2_data), headers=case.headers(auth_headers, admin_tokens, isp_tokens))
assert r.status_code == 200
if case.name == "admin_all":
assert r.json()["result"], "засев не дал строк свода 2"
key = f"{sheet}-{case.name}"
if os.getenv("SVOD2_WRITE_GOLDEN") == "1":
_record(key, r.content) # {"sha256": ..., "body": r.json()}
assert hashlib.sha256(r.content).hexdigest() == _golden()[key]["sha256"]
```
- [ ] **Step 3: Записать эталон на коде «до».** `WAIT_LOCK`; `SVOD2_WRITE_GOLDEN=1 API_TEST tests/integration/test_svod2_legacy_golden.py`. Затем без переменной: `API_TEST tests/integration/test_svod2_legacy_golden.py` — PASS, два раза подряд (данные детерминированы). Если sha различаются между прогонами, в ответе есть недетерминированное поле: найти его и стабилизировать засев, эталон переписать до коммита.
- [ ] **Step 4: Красные тесты-оракулы** `test_svod2_paging_api.py`. Вспомогательная `legacy_full(client, sheet, headers, org_ids=None) -> list[dict]` — прежний ответ без параметров; это оракульная база. Тесты:
```python
def test_legacy_mode_has_no_total(...): # ?offset=0&limit=2 → count ≤ 2, "total" not in body
@pytest.mark.parametrize("q", ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad", "view=bad"])
def test_invalid_params_422(...): # 422
@pytest.mark.parametrize("sheet", ["GO", "RF"])
def test_pages_stitch_to_full_list(...): # view=full, limit=2, offset 0,2,4… пока страница < 2:
# [line_id по страницам] == [line_id legacy_full]; total == len(legacy_full) на каждой странице
def test_page_past_end_has_total(...): # offset=total → result == [], count == 0, total == len(full)
def test_view_full_rows_equal_legacy_rows(...): # RF view=full&offset=0&limit=50: result == legacy_full[:50] (сравнение row dict), "total" in body
SEARCHES = ["аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда  ",
" ", "1", ",", "777.25", "<cd_id>", "я" * 5000]
@pytest.mark.parametrize("sheet", ["GO", "RF"]) @pytest.mark.parametrize("search", SEARCHES)
def test_search_matches_screen_oracle(...): # все страницы view=compact limit=3 с search → line_id == screen_rows(full, search); total == len(...)
FILTERS = {"type-case-space": {"smeta_type": "<значение из засева в нижнем регистре + ' '>"},
"org-twin": {"org_name": "Филиал Ёлки"}, "org-case-variant": {"org_name": "филиал ёлки "},
"unknown": {"smeta_type": "нет такого"}, "dir+org": {"smeta_direction": "Развитие", "org_name": "Филиал Ёлки"},
"search+type": {"search": "аренда", "smeta_type": "<значение>"}, "long": {"org_name": "я" * 5000}}
def test_filters_match_screen_oracle(...): # line_id и total == screen_rows(full, search, filters)
def test_facets_match_screen_oracle(...): # для всех FILTERS и SEARCHES[:6]: /facets → total, search_total, {value: count} == screen_facets(...)
def test_found_matches_screen_oracle(...): # (search_total, total) из /facets == screen_found(full, search)
def test_compact_is_projection_of_full(...): # одна страница view=full и view=compact: row_type/depth/sort_order равны, data == compact_projection(full.data)
def test_rf_ckk_only_value_is_searchable(...): # RF search=777.25 → строка есть (решение #16)
def test_isp_foreign_org_is_empty(...): # isp1, org_ids=910002: страница result == [], total == 0; /facets все нули и пустые списки
def test_isp_sees_only_own_org(...): # isp1 без org_ids: total == число строк org_a в legacy_full(admin)
def test_facets_unknown_sheet_404(...): # /svod/2/XX/2031/facets → 404
```
- [ ] **Step 5: Убедиться, что новые тесты красные, а прежние зелёные.** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py`. Ожидается:
- `test_legacy_mode_has_no_total`, `test_invalid_params_422[offset/limit/sort_*]`, эталон и смоук — PASS;
- `test_invalid_params_422[view=bad]` — FAIL (параметр пока игнорируется, 200);
- остальные новые — FAIL: `KeyError: 'total'`, 404 на `/facets` или несовпадение с оракулом (поиск пока игнорируется).
Список упавших записать в отчёт задачи. `xfail` не ставить.
- [ ] **Step 6: Commit** (красные тесты в промежуточном коммите допустимы):
```bash
git add api/tests/sqls/svod2_seed.sql api/tests/integration/conftest.py api/tests/integration/test_svod2_legacy_golden.py api/tests/fixtures/svod2_legacy_golden.json api/tests/integration/test_svod2_paging_api.py
git commit -m "test(api): свод 2 — засев, эталон прежнего ответа и тесты-оракулы нового режима (красные)"
```
### Task 4: Миграция 0053 — нормализаторы и компактная проекция
**Модель исполнителя:** sonnet.
**Files:**
- Create: `api/alembic/versions/0053_svod2_server_paging.py` (`revision = "0053"`, `down_revision = "0049"`, `run_sql_file_migration("0053_svod2_server_paging.sql")`, `downgrade` — `pass`, как в 0049)
- Create: `api/alembic/versions/sql/0053_svod2_server_paging.sql`
- Create: `api/tests/integration/test_svod2_sql.py`
**Interfaces:**
- Consumes: `svod2_sql` (Task 3), `js_number_text_vectors.json` (Task 1), `compact_projection` (Task 2).
- Produces SQL:
- `v3.svod2_casefold(text) RETURNS text LANGUAGE sql IMMUTABLE` — `translate` по таблице design § 3: ASCII `A-Z`, U+0410–U+042F, U+0400–U+040F, U+0490, U+00C0–U+00DE кроме U+00D7;
- `v3.svod2_js_trim(text) RETURNS text LANGUAGE sql IMMUTABLE` — `btrim` по набору design § 3;
- `v3.svod2_js_number_text(double precision) RETURNS text LANGUAGE plpgsql IMMUTABLE SET extra_float_digits = 1` — алгоритм design § 3;
- `v3.svod2_compact(jsonb) RETURNS jsonb LANGUAGE sql IMMUTABLE` — `jsonb_strip_nulls(jsonb_build_object(...))` по путям `COMPACT_PATHS`.
- [ ] **Step 1: Написать падающие тесты** в `test_svod2_sql.py`:
```python
def test_js_number_text_matches_node_vectors(svod2_sql):
for v in NUM:
got = svod2_sql("SELECT v3.svod2_js_number_text((CAST(:x AS jsonb) #>> '{}')::float8) AS t", {"x": v["input"]})[0].t
assert got == v["js"], v
def test_js_number_text_ignores_session_extra_float_digits(svod2_sql):
svod2_sql("SET LOCAL extra_float_digits = 0", None)
assert svod2_sql("SELECT v3.svod2_js_number_text(0.1::float8 + 0.2::float8) AS t", None)[0].t == "0.30000000000000004"
@pytest.mark.parametrize("src", ["АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", "ЄІЇЎЂЏҐ", "ABCxyz", "ÀÉÖÞ", "×", "аРЕНДА 1"])
def test_casefold_matches_js_lower(svod2_sql, src):
assert svod2_sql("SELECT v3.svod2_casefold(:s) AS t", {"s": src})[0].t == src.lower()
def test_casefold_independent_of_collation(svod2_sql):
assert svod2_sql("""SELECT v3.svod2_casefold('ЁЛКА' COLLATE "C") AS t""", None)[0].t == "ёлка"
def test_js_trim_ecmascript_whitespace(svod2_sql):
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "   x 
\t"})[0].t == "x"
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "​ x"})[0].t == "​ x"
def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_tokens, auth_headers):
full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"]
got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0].c
assert got == compact_projection(full)
```
- [ ] **Step 2:** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_sql.py`. Ожидается: FAIL, `function v3.svod2_js_number_text(double precision) does not exist` и т. п.
- [ ] **Step 3: Реализовать** четыре функции в `0053_svod2_server_paging.sql` и файл ревизии.
- [ ] **Step 4:** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py` — PASS. `cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/alembic heads` → одна голова `0053`.
- [ ] **Step 5: Commit.** `git add` трёх файлов; `perf(api): свод 2 — миграция 0053: нормализаторы поиска как в JS и компактная проекция`.
### Task 5: Миграция 0053 — общий отбор, страницы с `total_count`, варианты фильтров
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `api/alembic/versions/sql/0053_svod2_server_paging.sql`
- Modify: `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить)
- Modify: `api/tests/integration/test_svod2_sql.py`
**Interfaces:**
- Consumes: функции Task 4; тела `0049_summary2_related_records.sql` (`v_svod2_go` `:6-303`, `v_svod2_rf` `:305-595`).
- Produces SQL:
- `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`;
- `v3.svod2_lines(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text) RETURNS TABLE(line_id integer, form_type_code varchar, form_id integer, org_unit_id integer, org_name varchar, section_code varchar, item_id varchar, smeta_type varchar, smeta_direction varchar)`, `LANGUAGE sql STABLE`, без `SET search_path`, все имена с `v3.`. Устройство:
- `WITH eligible_lines AS (…) SELECT … FROM eligible_lines el WHERE p_search IS NULL OR EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e WHERE strpos(v3.svod2_casefold(<текст e.value>), p_search) > 0)`;
- тело `eligible_lines` — отбор 0049 **без изменения условий**, формы по `p_sheet` (`GO` → FORM_1/FORM_4, `RF` → FORM_2), плюс столбец `smeta_direction` (тот же `CASE bl.direction`, что в `header`). Типы столбцов взять из 0049;
- тело отбора обрамить строками-маркерами `-- svod2_lines eligible: begin` и `-- svod2_lines eligible: end` (их читает SQLite-тест);
- `header_j`, `cs_j`, `cd_j` строятся **теми же выражениями**, что `data.header`, `data.contract_summary`, `data.contract_detail` в 0049 для своего листа, включая `COALESCE(…, e.<пустая секция>)` и для RF `expenses_qN = COALESCE(cd, ckk)` (решения #16 и #21);
- текст значения: `string` → `#>> '{}'`, `number` → `v3.svod2_js_number_text((v #>> '{}')::float8)`, `boolean` → `true`/`false`, `null` → `''`;
- `v3.v_svod2_go` и `v3.v_svod2_rf`:
- аргументы `(p_year integer, p_org_unit_ids integer[] DEFAULT NULL, p_offset integer DEFAULT NULL, p_limit integer DEFAULT NULL, p_sort_by varchar DEFAULT 'org_name', p_sort_direction varchar DEFAULT 'asc', 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 столбцов 0049 + `total_count bigint`;
- нормализация: поиск — `NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '')`, фильтры — `NULLIF(v3.svod2_js_trim(v3.svod2_casefold(x)), '')`;
- `numbered_lines` строится по `svod2_lines` с тремя фильтрами (`x IS NOT NULL AND v3.svod2_js_trim(v3.svod2_casefold(x)) = <параметр>`), с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`;
- `page`, `booking_data` и сборка `data` копируются из 0049 без изменений, `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный>) ELSE <полный> END`;
- `SET search_path` как в 0049;
- `v3.v_svod2_facets(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text) RETURNS TABLE(kind varchar, value varchar, n bigint)`:
- `kind` ∈ `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name` (у `total`/`search_total` `value = NULL`);
- одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска;
- для каждой колонки — `GROUP BY` по точному значению при условии «поиск + фильтры двух других колонок», `NULL`-значения не выдаются.
- [ ] **Step 1: Перенацелить SQLite-тест.**
- `SQL_PATH` → `0053_svod2_server_paging.sql`.
- `self.queries` = [тело между маркерами при `p_sheet = 'GO'`, то же при `'RF'`, тело `eligible_lines` `v_svod2_go_export` из 0049]. Подставлять `p_sheet` литералом; столбец `smeta_direction` SQLite выполнит как есть.
- Пять существующих тестов не меняются по смыслу. Индексы 0/1/2 по-прежнему значат GO/RF/экспорт GO.
- [ ] **Step 2: Добавить падающие тесты** в `test_svod2_sql.py`:
```python
def test_single_signature_without_overloads(svod2_sql):
rows = svod2_sql("SELECT proname, pronargs FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE nspname = 'v3' AND proname IN ('v_svod2_go', 'v_svod2_rf', 'v_svod2_go_export')", None)
assert sorted((r.proname, r.pronargs) for r in rows) == [("v_svod2_go", 11), ("v_svod2_go_export", 4), ("v_svod2_rf", 11)]
@pytest.mark.parametrize("fn", ["v_svod2_go", "v_svod2_rf"])
def test_page_total_count_and_order(svod2_sql, svod2_data, fn):
full = svod2_sql(f"SELECT source_line_id FROM v3.{fn}(2031)", None)
page = svod2_sql(f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, 0, 2, 'org_name', 'asc', NULL, NULL, NULL, NULL, false)", None)
assert [r.source_line_id for r in page] == [r.source_line_id for r in full][:2]
assert {r.total_count for r in page} == {len(full)}
def test_facets_totals(svod2_sql, svod2_data):
rows = {(r.kind, r.value): r.n for r in svod2_sql("SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, NULL, NULL, NULL, NULL)", None)}
assert rows[("total", None)] == rows[("search_total", None)] == len(svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None))
```
- [ ] **Step 3:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_related_records_sql.py` и `API_TEST tests/integration/test_svod2_sql.py`. Ожидается FAIL: маркеров нет; 6 аргументов вместо 11; `v_svod2_facets does not exist`.
- [ ] **Step 4: Реализовать** SQL по Interfaces.
- [ ] **Step 5:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_related_records_sql.py` — PASS. `API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py` — PASS: эталон байт-в-байт и прежние 6-аргументные вызовы не изменились.
- [ ] **Step 6: Commit.** `git add` трёх файлов; `perf(api): свод 2 — общий отбор svod2_lines, страницы с total_count, варианты фильтров в SQL`.
### Task 6: Репозиторий, сервис и схемы ответа
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `api/src/domain/schemas.py` (после `SheetResponse`, `:357`)
- Modify: `api/src/repository/summary_repository.py`
- Modify: `api/src/services/summary_service.py`
- Create: `api/tests/unit/test_summary2_service.py`
**Interfaces:**
- Consumes: SQL Task 5.
- Produces:
- `class Svod2PageResponse(BaseListResponse[SheetResponse]): total: int`;
- `class Svod2FacetValue(BaseModel): value: str; count: int`;
- `class Svod2Facets(BaseModel): total: int; search_total: int; smeta_type: list[Svod2FacetValue]; smeta_direction: list[Svod2FacetValue]; org_name: list[Svod2FacetValue]`;
- `SummaryRepository.get_svod2_page(self, year: int, sheet: str, org_ids: list[int] | None, offset: int | None, limit: int | None, sort_by: str, sort_direction: str, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None, compact: bool) -> tuple[list[tuple], int]`:
- строки `('INPUT', 3, sort_order, data)`, как у `get_svod2_rows`;
- `total` = `total_count` первой строки или `0`;
- все параметры через `CAST(:x AS …)`;
- `SummaryRepository.get_svod2_facets(self, year: int, sheet: str, org_ids: list[int] | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> dict` → форма `Svod2Facets` (`{"total", "search_total", "smeta_type": [{"value", "count"}], …}`), отсутствующие виды — `0`/`[]`;
- `SummaryService.get_svod2_page(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, offset: int | None = None, limit: int | None = None, sort_by: str = "org_name", sort_direction: str = "asc", search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None, view: str = "full") -> tuple[list[tuple], int]`;
- `SummaryService.get_svod2_facets(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None) -> dict`;
- `EMPTY_SVOD2_FACETS` — `{"total": 0, "search_total": 0, "smeta_type": [], "smeta_direction": [], "org_name": []}` (функция или копия на каждый вызов).
- [ ] **Step 1: Написать падающие тесты** (`AsyncMock` на `summary_repo`, `_allowed_org_ids` подменяется `monkeypatch`):
```python
async def test_page_passes_params_and_compact_flag(): ... # view="compact" → repo.get_svod2_page(..., compact=True); результат ([row], 7)
async def test_page_empty_intersection_skips_sql(): ... # _allowed_org_ids → [] → ([], 0); repo.get_svod2_page.assert_not_awaited()
async def test_page_past_end_second_call_for_total(): ... # 1-й вызов offset=600 → ([], 0); 2-й offset=0, limit=1 → ([row], 42); итог ([], 42)
async def test_page_empty_first_page_no_second_call(): ... # offset=0 → ([], 0); await_count == 1
async def test_facets_empty_intersection_zeros_without_sql(): ... # == EMPTY_SVOD2_FACETS; repo не вызван
async def test_facets_passes_allowed_org_ids_and_filters(): ... # repo.get_svod2_facets(org_ids=[1], search="x", smeta_type="КВ", ...)
```
- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_service.py` — FAIL (`AttributeError: get_svod2_page`).
- [ ] **Step 3: Реализовать** схемы, методы репозитория и сервиса.
- [ ] **Step 4:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_service.py` — PASS.
- [ ] **Step 5: Commit.** `perf(api): свод 2 — страницы и варианты фильтров в репозитории и сервисе`.
### Task 7: API — новый режим `GET /svod/2/{sheet}/{year}` и эндпоинт `/facets`
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `api/src/api/v1/summary.py` (`get_summary2`, `:93-146`; новый обработчик)
- Create: `api/tests/unit/test_summary2_mode.py`
**Interfaces:**
- Consumes: Task 6.
- Produces:
- `is_svod2_page_mode(view: str | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> bool` — хотя бы один не `None`; пустая строка — новый режим;
- `get_summary2` с новыми параметрами `view: Optional[Literal["full", "compact"]] = None`, `search: Optional[str] = None`, `smeta_type: Optional[str] = None`, `smeta_direction: Optional[str] = None`, `org_name: Optional[str] = None` — без ограничений длины. Декоратор с явным `response_model=Svod2PageResponse | BaseListResponse[SheetResponse]`. Прежний режим возвращает тот же `BaseListResponse(count=…, result=…)` через `get_svod2_rows`, новый — `Svod2PageResponse(count=len(rows), result=…, total=total)`. `X-DB-Time-Ms` и лог `db_time` (с новыми полями) — в обоих режимах;
- `@router.get("/2/{sheet}/{year}/facets") async def get_summary2_facets(sheet: str, year: int, response: Response, org_ids: Optional[str] = None, search: Optional[str] = None, smeta_type: Optional[str] = None, smeta_direction: Optional[str] = None, org_name: Optional[str] = None, db=…, current_user=…) -> BaseSingleResponse[Svod2Facets]` — `sheet.upper()`; неизвестный лист — 404 с тем же текстом, что у `get_summary2`; `X-DB-Time-Ms`.
- [ ] **Step 1: Написать падающий unit-тест:**
```python
@pytest.mark.parametrize("args,expected", [
((None, None, None, None, None), False), (("full", None, None, None, None), True),
((None, "", None, None, None), True), ((None, None, None, None, "x"), True)])
def test_is_svod2_page_mode(args, expected):
assert is_svod2_page_mode(*args) is expected
```
- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_mode.py` — FAIL (`ImportError`).
- [ ] **Step 3: Реализовать** обработчики.
- [ ] **Step 4: Оракулы зеленеют.** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_mode.py tests/unit/test_summary2_service.py`; `API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_svod2_sql.py tests/integration/test_summary_api_smoke.py`. Ожидается: всё PASS, в том числе каждый случай `SEARCHES`/`FILTERS` и эталон.
- Если эталон расходится только сериализацией (`response_model`), найти причину в обработчике. Эталон не трогать.
- Если оракул расходится с SQL, починить SQL (Task 5): оракул — дословная семантика экрана.
- [ ] **Step 5: Commit.** `git add api/src/api/v1/summary.py api/tests/unit/test_summary2_mode.py`; `perf(api): свод 2 — новый режим страниц с total и эндпоинт вариантов фильтров`.
### Task 8: Завершение фазы 1 — полный прогон, передача контроллеру, финальное ревью, PR 1
**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки по ревью — **sonnet**.
- [ ] **Step 1: Полный набор на PG16.** `WAIT_LOCK`; `API_TEST tests`. Ожидается ровно 3 известных падения (Global Constraints), остальное PASS.
- [ ] **Step 2: Передать главному контроллеру** (не блокирует фазу 2). Записать в ledger раздел `## Передано контроллеру`:
- **«PG15: прогнать полный `pytest` ветки `feat/236-aurora-1843-svod2-server-paging` @ `<sha>` на `dfip-pg15:55434`»** — сюда входят `test_svod2_sql.py` (нормализаторы при локали контура) и миграция `0053`;
- **«Стенд, байт-в-байт (#17)»:** `GET /api/v1/svod/2/{GO,RF}/{year}` без параметров (admin и dfip, все и одна организация, `offset=0&limit=50`) до и после миграции, sha256 распакованных тел; Excel RF — значения ячеек через `openpyxl`.
Ответ контроллера приходит в ledger или комментарием к PR. Красный PG15 или стенд даёт находку `O-n`, её правка идёт в ветку фазы 1 с каскадным rebase фаз 2–3.
- [ ] **Step 3: Финальное ревью ветки — opus.**
- Ревьюер получает diff `feat/220-aurora-1842-svod2-column-virtual...feat/236-aurora-1843-svod2-server-paging`, пути спеки и правило замка.
- Фокус: требования `summary2-api`, Review Focus 1–2, совместимость с PG15, дословность оракула, отсутствие перегрузок.
- Находки — `C-n`/`I-n`/`M-n`.
- [ ] **Step 4: Правки по ревью** (sonnet). Каждая находка закрывается статусом `fixed <commit>`, `deferred` или `rejected <причина>`. После правок — `WAIT_LOCK` и `API_TEST tests`, ожидаются те же 3 падения.
- [ ] **Step 5: Документы.** Дописать в `design.md` решения, принятые при исполнении (с № 35), и обновить таблицу `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 1`.
- [ ] **Step 6: Push и PR 1.** `git push -u origin feat/236-aurora-1843-svod2-server-paging`, затем:
```bash
tea pulls create --base feat/220-aurora-1842-svod2-column-virtual --head feat/236-aurora-1843-svod2-server-paging \
--title "AURORA-1843: свод 2 — серверные страницы, поиск, фильтры и варианты (1/3, сервер)" --description "$(cat <файл описания>)"
```
Описание (по-русски):
- `Refs #236` (часть 1/3; `Closes #236` — в PR 3), Jira AURORA-1843;
- что сделано;
- тесты: оракулы, эталон байт-в-байт, полный `pytest` на PG16 и 3 известных падения;
- «PG15 — проверит контроллер»;
- чек-лист ручной проверки: старый фронт работает, прежний ответ байт-в-байт, Excel RF;
- строка «Замер до/после — добавит контроллер после прогона на стенде»;
- «Rulings I made» (`R-n`);
- таблица находок с маркерами и статусами;
- последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
Цифр замеров в PR нет. Не мержить.
- [ ] **Step 7: Без остановки — фаза 2.**
---
# Фаза 2 — фронт (ветка `feat/236-aurora-1843-svod2-server-paging-web`, PR в `feat/236-aurora-1843-svod2-server-paging`)
Начало фазы: `cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-web` от вершины фазы 1, затем `git push -u origin feat/236-aurora-1843-svod2-server-paging-web`.
### Task 9: Проверка окна отступами виртуализатора (первая задача фазы, design #6)
**Модель исполнителя:** sonnet.
**Files:**
- Create: `web/src/pages/Svod2Page/svod2Virtualizer.test.js`
**Interfaces:**
- Consumes: `@tanstack/virtual-core` 3.11.2 (`web/node_modules/@tanstack/virtual-core`), `material-react-table` 3.2.1 (`dist/index.esm.js`).
- Produces: вывод в отчёте задачи и ledger: «отступы работают» или «запасной вариант #6».
- [ ] **Step 1: Написать тест** (`node:test`):
```js
import { Virtualizer } from '@tanstack/virtual-core';
test('paddingStart/paddingEnd ставят глобальную строку i на i*rowH, общий размер = total*rowH', () => {
const rowH = 37, total = 5563, windowStart = 300, windowEnd = 1200;
const v = new Virtualizer({ count: windowEnd - windowStart, getScrollElement: () => null, estimateSize: () => rowH,
paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH,
scrollToFn: () => {}, observeElementRect: () => {}, observeElementOffset: () => {} });
const m = v.getMeasurements();
assert.equal(m[0].start, windowStart * rowH);
assert.equal(m[m.length - 1].end, windowEnd * rowH);
assert.equal(v.getTotalSize(), total * rowH);
});
```
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Virtualizer.test.js`. Ожидается PASS. Если FAIL — тест показал, что предпосылка неверна: это результат проверки, а не баг теста.
- [ ] **Step 3: Проверить MRT по коду** и записать номера строк в отчёт:
- высота `tbody` — `rowVirtualizer.getTotalSize()` (около `:2481`);
- строки сдвигаются на `translateY(virtualRow.start)` (около `:2409`);
- в `useMRT_RowVirtualizer` пользовательские `rowVirtualizerOptions` разворачиваются **после** значений по умолчанию, в том числе `measureElement`.
- [ ] **Step 4: Решение.**
- Всё подтвердилось → продолжать по Task 10–12 как написано.
- Иначе остановиться и вернуть отчёт контроллеру. Контроллер выбирает запасной вариант #6 (массив длины `total` с общей заглушкой), записывает его в `design.md` и правит Interfaces Task 10–12 до их запуска.
- [ ] **Step 5: Commit.** `test(web): свод 2 — окно строк через отступы виртуализатора`.
### Task 10: `svod2Paging.js` — окно, срез с заглушками, LRU, параметры, варианты
**Модель исполнителя:** sonnet.
**Files:**
- Create: `web/src/pages/Svod2Page/svod2Paging.js` (чистый модуль: без React, JSX и axios)
- Create: `web/src/pages/Svod2Page/svod2Paging.test.js`
**Interfaces:**
- Produces:
- `PAGE_SIZE = 300`, `WINDOW_RADIUS = 1`, `PAGE_CACHE_LIMIT = 5`, `FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']`, `EMPTY_FACET_MAP` (замороженный пустой `Map`);
- `getPageCount(total: number): number`;
- `getVisiblePage({ scrollTop, rowHeight, total }): number` — `floor(floor(scrollTop / rowHeight) / PAGE_SIZE)`, прижатый к `[0, max(pageCount − 1, 0)]`;
- `getWindowPages(visiblePage: number, total: number): number[]` — `visiblePage ± WINDOW_RADIUS` внутри `[0, pageCount)`, по возрастанию; при `total = 0` — `[]`;
- `getWindowRange(pages: number[], total: number): { windowStart: number, windowEnd: number }` (`[]` → `{0, 0}`);
- `buildWindowRows(pages: number[], pageCache: Map<number, object[]>, total: number): object[]`. Для каждой страницы окна ровно `min(total, (p+1)·PAGE_SIZE) − p·PAGE_SIZE` элементов: строки из кэша, недостающие места — `{ __placeholder: true, index }` (глобальный индекс), лишние строки отбрасываются;
- `getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }): { paddingStart: number, paddingEnd: number }`;
- `touchPage(cache: Map, pageIndex: number, rows: object[], keepPages: number[] = []): Map` — новый `Map`, страница становится самой свежей; вытесняются самые старые не из `keepPages`, пока `size > PAGE_CACHE_LIMIT`;
- `getMissingPages(pages: number[], cache: Map, inflight: Set<number>): number[]`;
- `buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] }): Record<string, string>`:
- `org_ids` — через запятую, если массив не пуст;
- `search` как введён, если `!== ''`;
- для `{ id, value }` из `columnFilters` с `id` из `FILTER_COLUMNS` и значением не `''`/`null`/`undefined` — `[id]: String(value)`;
- `buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE })` — `{ view: 'compact', offset, limit, ...buildSvod2QueryParams(...) }`;
- `facetsToMaps(result): Map<string, Map<string, number>>` — новые объекты на каждый вызов;
- `shouldShowFound({ hasCurrentData, loading, facets }): boolean` — `hasCurrentData && !loading && !!facets && facets.search_total !== facets.total`;
- `isPlaceholderRow(row): boolean`;
- `getSvod2RowId(row): string` — `ph-${index}` у заглушки, иначе прежний id `${source_form}-${source_id}-${line_id}` (`Svod2Page.jsx:397`).
- [ ] **Step 1: Написать падающие тесты:**
```js
test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1]));
test('окно в середине и в конце', () => { assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]); assert.deepEqual(getWindowPages(18, 5563), [17, 18]); });
test('total = 0 — пустое окно и пустой срез', () => { assert.deepEqual(getWindowPages(0, 0), []); assert.deepEqual(buildWindowRows([], new Map(), 0), []); });
test('видимая страница прижимается к последней при уменьшении total', () =>
assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2));
test('срез: неполная последняя страница и заглушки', () => {
const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563);
assert.equal(rows.length, 300 + 163);
assert.deepEqual(rows[0], { __placeholder: true, index: 5100 });
assert.equal(rows[300].id, 0);
});
test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => { /* total изменился между запросами */ });
test('сумма отступов и строк окна = total * rowH', () => {
const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563);
const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 });
assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37);
});
test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => { /* 7 вставок, keepPages = [0, 1] */ });
test('getMissingPages исключает загруженные и летящие', () => assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4]));
test('параметры страницы: compact, пустые не передаются, search как введён', () => {
assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), { view: 'compact', offset: 300, limit: 300 });
assert.deepEqual(buildSvod2QueryParams({ orgIds: [3, 5], search: ' аренда ', columnFilters: [{ id: 'org_name', value: 'Филиал Ёлки' }, { id: 'name', value: 'x' }] }),
{ org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' });
});
test('facetsToMaps', () => assert.equal(facetsToMaps({ smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] }).get('smeta_type').get('КВ'), 3));
test('«Найдено» — только поиск', () => {
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true);
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false);
assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false);
});
test('getSvod2RowId', () => assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7'));
```
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Paging.test.js` — FAIL (модуля нет).
- [ ] **Step 3: Реализовать** модуль по Interfaces.
- [ ] **Step 4:** `WAIT_LOCK`; тесты PASS; `npx biome check src/pages/Svod2Page/svod2Paging.js src/pages/Svod2Page/svod2Paging.test.js` — без ошибок.
- [ ] **Step 5: Commit.** `perf(web): свод 2 — чистая логика окна страниц, параметров и вариантов`.
### Task 11: Редьюсер состояния вкладки (generation, устаревшие ответы, ошибки)
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `web/src/pages/Svod2Page/svod2Paging.js`
- Modify: `web/src/pages/Svod2Page/svod2Paging.test.js`
**Interfaces:**
- Consumes: `touchPage` (Task 10).
- Produces:
- `createSheetPagingState(): { generation: 0, dataGeneration: 0, pendingQueryKey: '', loadedQueryKey: '', total: 0, facets: null, pages: Map, loading: false, error: '' }`;
- `svod2PagingReducer(state, action)`. Действия с устаревшим `generation` (≠ `state.generation`) возвращают **тот же** объект `state`:
- `{ type: 'begin', generation, mode: 'load' | 'refine', queryKey }` — `generation` и `pendingQueryKey` обновляются, `error = ''`. При `load` — `loading = true`. При `refine` строки, `total` и `facets` остаются (решение #20);
- `{ type: 'page', generation, pageIndex, rows, total, keepPages }` — первый ответ поколения (`dataGeneration !== generation`) заменяет `pages` на `new Map([[pageIndex, rows]])` и ставит `dataGeneration = generation`, `loadedQueryKey = pendingQueryKey`. Следующие ответы идут через `touchPage`. `total = action.total`;
- `{ type: 'facets', generation, facets }` — `facets = action.facets`;
- `{ type: 'fail', generation, mode: 'load' | 'refine' | 'scroll', message }`:
- `load`/`refine` → `error = message`, `pages = new Map()`, `total = 0` (как `rows: []` при ошибке «до»);
- `scroll` → тот же объект `state`; уведомление показывает хук;
- `{ type: 'settled', generation }` → `loading = false`.
- [ ] **Step 1: Написать падающие тесты:**
```js
test('устаревшие page/facets/fail игнорируются', () => {
let s = svod2PagingReducer(createSheetPagingState(), { type: 'begin', generation: 1, mode: 'load', queryKey: 'k' });
s = svod2PagingReducer(s, { type: 'begin', generation: 2, mode: 'refine', queryKey: 'k' });
for (const a of [{ type: 'page', generation: 1, pageIndex: 0, rows: [], total: 9 }, { type: 'facets', generation: 1, facets: {} },
{ type: 'fail', generation: 1, mode: 'load', message: 'x' }]) assert.equal(svod2PagingReducer(s, a), s);
});
test('refine оставляет прежние строки до первого ответа нового поколения', () => { /* pages поколения 1 видны после begin(2, refine); page(2) заменяет их */ });
test('первый ответ поколения ставит loadedQueryKey', () => { /* begin(1, load, 'k') → page(1) → loadedQueryKey === 'k' */ });
test('ошибка догрузки (scroll) не меняет состояние, страница снова среди недостающих', () => {
const s = /* состояние с pages {0}, окно [0, 1] */;
assert.equal(svod2PagingReducer(s, { type: 'fail', generation: s.generation, mode: 'scroll', message: 'x' }), s);
assert.deepEqual(getMissingPages([0, 1], s.pages, new Set()), [1]);
});
test('ошибка load очищает строки и ставит error', () => { /* error === 'x', pages.size === 0 */ });
test('последний total поколения побеждает', () => { /* page(0, total 5563) → page(1, total 5560) → total === 5560 */ });
```
- [ ] **Step 2:** `WAIT_LOCK`; `node --test src/pages/Svod2Page/svod2Paging.test.js` — новые тесты FAIL.
- [ ] **Step 3: Реализовать** `createSheetPagingState` и `svod2PagingReducer`.
- [ ] **Step 4:** `WAIT_LOCK`; все тесты файла PASS; biome по двум файлам чист.
- [ ] **Step 5: Commit.** `perf(web): свод 2 — состояние вкладки с поколениями запросов`.
### Task 12: Интеграция в Svod2Page — API, хук, таблица, заглушки
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `web/src/api/summary.js` (`get` не трогать)
- Create: `web/src/pages/Svod2Page/useSvod2Pages.js`
- Modify: `web/src/pages/Svod2Page/Svod2Page.jsx`
- Modify: `web/src/pages/Svod2Page/svod2Styles.js` (правило заглушки в `SVOD2_TABLE_CONTAINER_SX`)
- Modify: `web/src/pages/Svod2Page/svod2Styles.test.js`
**Interfaces:**
- Consumes: Task 10–11; API фазы 1 (`GET /svod/2/{sheet}/{year}?view=compact&offset&limit&…`, `GET /svod/2/{sheet}/{year}/facets`).
- Produces:
- `SummaryApi.getSvod2Page(sheet, year, params, { signal } = {})` → `api.get(\`/svod/2/${sheet}/${year}\`, { params, signal }).then((r) => r.data)`;
- `SummaryApi.getSvod2Facets(sheet, year, params, { signal } = {})` — то же для `/facets`;
- `useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop })` → `{ state, windowRows, padding: { paddingStart, paddingEnd }, facetsMap, load(mode: 'load' | 'refresh'), onScroll() }`:
- состояние по вкладкам: `{ GO, RF }` → `svod2PagingReducer`;
- на вкладку — одно поколение и `AbortController` на запросы поколения. Новое поколение отменяет запросы прежнего; отмена axios (`axios.isCancel`) не считается ошибкой;
- `load` → `begin(load)`, затем `facets` и `getMissingPages(окно от getScrollTop())`, `settled` по завершении всех;
- смена `[search, columnFilters]` при `hasCurrentData` → `begin(refine)` с теми же запросами;
- `onScroll` — через `requestAnimationFrame`: пересчёт видимой страницы и запрос недостающих страниц текущего поколения;
- ошибка страницы в режиме `scroll` → `toast.error('Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.')` из `react-toastify`;
- `inflight` очищается при любом исходе.
**Изменения `Svod2Page.jsx`:**
- Убрать `getSummary`, `invalidateSummary`, `matchesSearch`, `filteredRows`, `tableStateBySheet`, `latestSummaryRequestRef`. `useSvod()` остаётся только ради `getSheetFilters`/`updateSheetFilters`.
- `hasCurrentData = state.loadedQueryKey === currentQueryKey`. Подсказка «Нажмите «Загрузить»…», `Alert` с «Повторить» (вызывает `load('refresh')`) и кнопки «Загрузить»/«Обновить» остаются прежними, с теми же подписями и условиями `disabled`.
- Таблица:
- `data: hasCurrentData ? windowRows : []`, `getRowId: getSvod2RowId`;
- `manualFiltering: true`, `enableFacetedValues: true`;
- `getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP`; `filterSelectOptions` **не задавать**;
- `rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }`; сам `ROW_VIRTUALIZER_OPTIONS` не менять;
- `muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }`, `getScrollTop` — из `table.refs.tableContainerRef.current?.scrollTop ?? 0`;
- `muiTableBodyRowProps: ({ row }) => isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}`.
- «Найдено» — `shouldShowFound(...)`, текст прежний: `Найдено ${facets.search_total.toLocaleString('ru-RU')} из ${facets.total.toLocaleString('ru-RU')} строк`.
- `updateCurrentSheetFilters` (сброс колоночных фильтров при смене поиска, года, организаций) не менять. Положение прокрутки программно не менять.
- [ ] **Step 1: Написать падающий тест** в `svod2Styles.test.js`:
```js
test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => {
const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td'];
assert.ok(rule, 'нет правила заглушки');
assert.equal(rule.color, 'transparent');
});
```
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Styles.test.js` — FAIL.
- [ ] **Step 3: Реализовать** `svod2Styles.js` (правило заглушки: `color: 'transparent'`, дочерним бейджам — прозрачные фон и рамку), `summary.js`, `useSvod2Pages.js`, `Svod2Page.jsx`.
- [ ] **Step 4: Проверки.** `WAIT_LOCK`, затем:
- `node --test src/pages/Svod2Page/*.test.js` — PASS;
- `npx biome check src/pages/Svod2Page src/api/summary.js` — без ошибок;
- `npm run build` — успешно;
- `git grep -n "getSummary\|invalidateSummary\|matchesSearch" -- web/src/pages/Svod2Page` — пусто.
- [ ] **Step 5: Commit.** Пять файлов; `perf(web): свод 2 — строки страницами с сервера, поиск, фильтры и варианты через API`.
### Task 13: Завершение фазы 2 — ручная проверка контроллера, финальное ревью, PR 2
**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки — **sonnet**.
- [ ] **Step 1: Полные проверки фронта.** `WAIT_LOCK`, затем `cd $WT/web && node --test src/components/RealtimeTable/utils/*.test.js src/pages/SvodPage/*.test.js src/pages/Svod2Page/*.test.js`, `npx biome check src/pages/Svod2Page src/api/summary.js`, `npm run build` — всё зелёное.
- [ ] **Step 2: Передать главному контроллеру ручную проверку на стенде** (#17, не блокирует фазу 3). В ledger, раздел `## Передано контроллеру`: «Фаза 2 @ `<sha>`: сравнение со сборкой "до" на тех же данных». Чек-лист — design, «Тестирование», фаза 2, руками:
- GO и RF, admin и dfip, «Все» и выбор организаций;
- поиск, три фильтра и их сочетания, варианты с числами, «Найдено», сброс фильтров;
- вкладки и положение прокрутки;
- ползунок в середину и в конец;
- быстрый ввод, затем фильтр;
- единицы, «Перейти к задачам»;
- горизонтальная прокрутка;
- ошибка догрузки;
- скриншоты.
Расхождения возвращаются находками `O-n`.
- [ ] **Step 3: Финальное ревью ветки — opus.**
- Diff `feat/236-aurora-1843-svod2-server-paging...feat/236-aurora-1843-svod2-server-paging-web`.
- Фокус: требования `summary2-screen`, Review Focus 3–5, неизменность поведения (подписи, условия показа, сброс фильтров), утечки `AbortController`/`requestAnimationFrame`, отсутствие правок `ROW_VIRTUALIZER_OPTIONS`, `SvodContext` и `SvodFilters`.
- Нумерация `C/I/M` продолжает фазу 1.
- [ ] **Step 4: Правки по ревью** (sonnet), повтор Step 1.
- [ ] **Step 5: Документы.** Решения — в `design.md`, таблица `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 2`.
- [ ] **Step 6: Push и PR 2.**
```bash
git push origin feat/236-aurora-1843-svod2-server-paging-web
tea pulls create --base feat/236-aurora-1843-svod2-server-paging --head feat/236-aurora-1843-svod2-server-paging-web \
--title "AURORA-1843: свод 2 — догрузка строк страницами, поиск и фильтры через сервер (2/3, фронт)" --description "$(cat <файл>)"
```
Описание — по шаблону Task 8 Step 6: `Refs #236` (часть 2/3), чек-лист ручной проверки из Step 2, «Замер до/после — добавит контроллер после прогона на стенде», «Rulings I made», таблица находок. Цифр нет. Не мержить.
- [ ] **Step 7: Без остановки — фаза 3.**
---
# Фаза 3 — стенд замеров (ветка `feat/236-aurora-1843-svod2-server-paging-perf`, PR в `feat/236-aurora-1843-svod2-server-paging-web`)
Начало фазы: `cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-perf` от вершины фазы 2, затем `git push -u origin feat/236-aurora-1843-svod2-server-paging-perf`. Исполнители стенд не запускают (Global Constraints).
### Task 14: Сверка на данных стенда — режим `--check` JS-оракула и `svod2_paging_check.py`
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `perf/analysis/svod2_search_oracle.mjs`
- Modify: `perf/analysis/svod2_search_oracle.test.mjs`
- Create: `perf/analysis/svod2_paging_check.py`
- Create: `perf/tests/test_svod2_paging_check.py`
**Interfaces:**
- Consumes: экспорты Task 1; `compact_projection` из `api/tests/svod2_oracle.py` — подключается через `importlib.util.spec_from_file_location` по пути от корня репозитория; формат манифеста стенда, как в `perf/analysis/api_split.py`.
- Produces:
- `deriveCases(fullRows): { search: string, filters: object }[]`. Запросы из спеки (`аренда`, `АРЕНДА`, `1`, `,`) плюс выведенные из данных:
- подстрока названия организации;
- подстрока контрагента `contract_detail`;
- первое дробное число из `contract_detail`/`header` в записи JS;
- первое `smeta_type` в нижнем регистре с пробелом в конце;
- первое `org_name`;
- их пары;
- `compareCase(expected: { line_ids, total, found, facets }, actual: { line_ids, total, found, facets }): string[]` — пустой массив, если совпало;
- `runCheck({ api, username, password, year, sheets = ['GO', 'RF'], pageSize = 300 }): Promise<{ ok: boolean, report: string[] }>`. Логин, прежний полный ответ, затем по каждому случаю все страницы `view=compact` и `/facets`, сравнение с `screenRows`/`screenFound`/`screenFacets`;
- CLI `node perf/analysis/svod2_search_oracle.mjs --check --api <url> --username <u> --password <p> --year <y>` — код выхода 1 при расхождении;
- `svod2_paging_check.py --api <url> --scale <N>` (пользователь dfip и год — из `perf/results/manifest-s<N>.json`). Чистые функции:
- `stitch_pages(fetch_page: Callable[[int, int], dict], limit: int) -> tuple[list[dict], set[int]]` — строки и множество `total`;
- `compare_stitched(full_rows: list[dict], stitched: list[dict]) -> list[str]`;
- `check_compact(full_rows: list[dict], compact_rows: list[dict]) -> list[str]`.
Проверки: склейка `view=full limit=300` равна прежнему ответу (`data` целиком), `total` один на всех страницах, страница за концом пуста, compact — проекция full. Код выхода 1 при расхождении.
- [ ] **Step 1: Написать падающие тесты.**
- Node, в `svod2_search_oracle.test.mjs`:
- `deriveCases` на `SYNTHETIC_ROWS` даёт не меньше 8 случаев, и в них есть дробное число;
- `compareCase` находит различие в порядке `line_ids` и в числе варианта;
- `runCheck` на локальном `http.createServer` (заглушка API: `/auth/login`, прежний ответ из `SYNTHETIC_ROWS`, страницы и `/facets`, посчитанные теми же `screen*`) возвращает `ok: true`. Если заглушка портит одну страницу, возвращается `ok: false`.
- pytest, в `test_svod2_paging_check.py`:
- `stitch_pages` на фейковом `fetch_page` из 7 строк при `limit=3` → 7 строк, `{7}`;
- `compare_stitched` ловит пропуск и повтор;
- `check_compact` ловит лишний ключ и потерянный `0`.
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`; `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py` — новые тесты FAIL.
- [ ] **Step 3: Реализовать.**
- [ ] **Step 4:** те же команды — PASS. Дословность из Task 1 Step 4 по-прежнему `MATCHES_OK`/`EQUALS_OK`.
- [ ] **Step 5: Commit.** `perf: свод 2 — сверка страниц, поиска и вариантов с экраном «до» на данных стенда`.
### Task 15: `scroll.spec.ts` для свода 2 — заглушки, время до первых строк, байты
**Модель исполнителя:** sonnet.
**Files:**
- Create: `perf/e2e/lib/svod2.ts` (чистый, без импортов, стираемый TS)
- Create: `perf/e2e/tests/svod2-metrics.test.ts` (`node --test`; Playwright его не подхватывает, `testMatch` — `*.spec.ts`)
- Modify: `perf/e2e/scenarios/scroll.spec.ts` (только ветки `t.kind === 'svod2'`)
**Interfaces:**
- Produces (`lib/svod2.ts`):
- `SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])'`, `SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]'`;
- `svod2RespRe(sheet: string): RegExp` — ответы `/api/v1/svod/2/<sheet>/` и `/api/v1/svod/2/<sheet>/<year>/facets`;
- `isSvod2SearchUrl(url: string): boolean` — запрос свода 2 с параметром `search=`;
- `class ByteCounter` — методы `add(requestId: string, bytes: number): void`, `markFirstRows(): void`, геттеры `sheet` (максимум одного ответа — прежний `json_bytes_sheet`), `total` (сумма), `first` (сумма до `markFirstRows`). Без parameter properties;
- `placeholderShare(samples: boolean[]): number` — доля кадров, где видна хотя бы одна заглушка;
- `countPagesRequested(urls: string[]): number` — число запросов страниц (`offset=` в query).
**Изменения `scroll.spec.ts`** (design § 7):
- для свода 2 `waitRows` считает `SVOD2_DATA_ROW_SELECTOR`;
- `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными;
- метрики `json_bytes_sheet` (прежний смысл), `json_bytes_total`, `json_bytes_first`, `placeholder_frame_share` (сэмпл в `requestAnimationFrame` во время прокрутки), `pages_requested`;
- тест фильтра: после `fill('1')` ждать ответ с `isSvod2SearchUrl` до 5 с (нет запроса — сборка «до», идти дальше), затем `awaitStable`, метрика `filter_result_ms`;
- клики `:194-200`, подписи и прочие `kind` не менять. Код работает и со сборкой «до»: селектор без заглушек совпадает с `tbody tr`.
- [ ] **Step 1: Написать падающие тесты** `svod2-metrics.test.ts` (`import { … } from '../lib/svod2.ts'`):
- `ByteCounter`: три ответа 10/30/20, `markFirstRows` после второго → `sheet = 30`, `total = 60`, `first = 40`;
- `placeholderShare([true, false, false, false]) === 0.25`;
- `isSvod2SearchUrl('…/svod/2/GO/2026?view=compact&search=1') === true`, без `search` — `false`;
- `svod2RespRe('GO')` матчит `/facets` и страницу, но не `/svod/2/RF/`;
- `countPagesRequested`.
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts` — FAIL.
- [ ] **Step 3: Реализовать** `lib/svod2.ts` и правки `scroll.spec.ts` (импорт `'../lib/svod2'`).
- [ ] **Step 4:** `WAIT_LOCK`; тест PASS. Проверку типов и `npx playwright test --list` выполнит контроллер (записать в ledger `## Передано контроллеру`).
- [ ] **Step 5: Commit.** `perf: scroll.spec — свод 2 со страницами: заглушки, время до первых строк, суммарные байты`.
### Task 16: Сценарий памяти `svod2-memory.spec.ts`
**Модель исполнителя:** sonnet.
**Files:**
- Create: `perf/e2e/scenarios/svod2-memory.spec.ts`
- Modify: `perf/e2e/lib/svod2.ts`, `perf/e2e/tests/svod2-metrics.test.ts`
**Interfaces:**
- Consumes: `lib/cdp.ts` (`gc`, `heapUsed` `:45`, `domCounters`), `lib/auth.ts`, `lib/manifest.ts`, `lib/results.ts` — по образцу `scenarios/memory.spec.ts`; клики выбора «Все ССП/РФ» и «Загрузить» как в `scroll.spec.ts:194-200`.
- Produces:
- `memoryPoints(env: Record<string, string | undefined>): string[]` в `lib/svod2.ts` — `['go', 'rf', 'final']`, при `PERF_SVOD2_MEM_SCROLL=1` — `['go', 'rf', 'rf_scrolled', 'final']`;
- сценарий: роль dfip, свежий контекст на каждый повтор (`PERF_REPEATS`, по умолчанию 3). В каждой точке `gc` → `heapUsed` и `domCounters`, запись `{ scenario: 'svod2-memory', target: 'svod2/<point>', … metrics: { heap_used_mb, nodes, documents, js_event_listeners } }`.
- [ ] **Step 1: Падающий тест** `memoryPoints` в `svod2-metrics.test.ts` (оба варианта env).
- [ ] **Step 2:** `WAIT_LOCK`; `node --test tests/svod2-metrics.test.ts` — FAIL.
- [ ] **Step 3: Реализовать** `memoryPoints` и сценарий.
- [ ] **Step 4:** `WAIT_LOCK`; тест PASS. `npx playwright test --list scenarios/svod2-memory.spec.ts` — передать контроллеру.
- [ ] **Step 5: Commit.** `perf: сценарий памяти свода 2 (GO «все» → RF «все» → GO)`.
### Task 17: `api_split.py`, `explain_manual.py`, `scale_compare.py`, README
**Модель исполнителя:** haiku (записи и команды заданы design § 7 и ниже).
**Files:**
- Modify: `perf/analysis/api_split.py` (список `reqs`, `:45-54`)
- Modify: `perf/analysis/explain_manual.py` (`queries`, `:59-81`, и её вызов)
- Modify: `perf/analysis/scale_compare.py` (`:35-50`)
- Modify: `perf/README.md`
- Create: `perf/tests/test_svod2_analysis.py`
**Interfaces:**
- Produces:
- в `api_split.py` — прежние строки плюс новые (роль `dfip`, `year` из манифеста):
- GO и RF `?view=full&offset=0&limit=300`;
- GO и RF `?view=compact&offset=0&limit=300`;
- GO и RF `/facets`;
- RF `?view=compact&offset=0&limit=300&search=1`;
- `explain_manual.queries(f2: int, f4: int, year: int) -> list[dict]` с новыми записями (у всех `"user": True`, варианты `[("", {}), (".jit_off", {"jit": "off"})]`):
- `svod2_go_page` — `SELECT * FROM v3.v_svod2_go({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`;
- `svod2_rf_page` — то же для `v_svod2_rf`;
- `svod2_rf_search` — `v_svod2_rf` с `'1'` в `p_search`;
- `svod2_go_facets` — `SELECT * FROM v3.v_svod2_facets('GO', {year}, NULL, NULL, NULL, NULL, NULL)`;
- `scale_compare.svod2_key(path: str) -> str` (путь с query):
- `/api/v1/svod/2/GO/2026` → `"GET свод 2 GO"`;
- с `limit=` → `"GET свод 2 GO страница"`;
- `…/facets` → `"GET свод 2 GO facets"`.
`elif` для свода 2 в цикле ключей использует `svod2_key`;
- README: раздел «Свод 2 после AURORA-1843»:
- команды из таблицы design «Замер до/после»;
- порядок «до/после» на одних данных;
- `--only`/`--repeats` для `perfkit.bench`;
- запуск `svod2_paging_check.py` и `svod2_search_oracle.mjs --check` перед замером «после».
- [ ] **Step 1: Падающие тесты** `test_svod2_analysis.py` (импорт через `sys.path.insert(0, …/analysis)`, как в `test_analysis.py`):
```python
def test_svod2_key():
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026") == "GET свод 2 GO"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets"
def test_explain_has_svod2_queries():
names = {q["name"] for q in explain_manual.queries(1, 2, 2026)}
assert {"svod2_go_page", "svod2_rf_page", "svod2_rf_search", "svod2_go_facets"} <= names
```
- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_analysis.py tests/test_analysis.py` — новые тесты FAIL.
- [ ] **Step 3: Реализовать.**
- [ ] **Step 4:** те же команды — PASS.
- [ ] **Step 5: Commit.** `perf: api_split, explain_manual и scale_compare для страниц и вариантов свода 2; README`.
### Task 18: Завершение фазы 3 — финальное ревью, PR 3, точка ожидания замеров
**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки — **sonnet**.
- [ ] **Step 1: Проверки без стенда.** `WAIT_LOCK`, затем:
- `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`;
- `cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts`;
- `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py tests/test_svod2_analysis.py tests/test_analysis.py`.
Всё должно быть PASS.
- [ ] **Step 2: Финальное ревью ветки — opus.**
- Diff `feat/236-aurora-1843-svod2-server-paging-web...feat/236-aurora-1843-svod2-server-paging-perf`.
- Фокус: сопоставимость «до/после» (код сценариев работает на сборке «до»), смысл `json_bytes_sheet` сохранён, ожидание ответа поиска, правило «стенд не трогать».
- Нумерация `C/I/M` продолжает фазу 2.
- [ ] **Step 3: Правки по ревью** (sonnet), повтор Step 1.
- [ ] **Step 4: Документы.** Решения — в `design.md`, таблица `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 3`.
- [ ] **Step 5: Push и PR 3.**
```bash
git push origin feat/236-aurora-1843-svod2-server-paging-perf
tea pulls create --base feat/236-aurora-1843-svod2-server-paging-web --head feat/236-aurora-1843-svod2-server-paging-perf \
--title "AURORA-1843: свод 2 — сценарии и скрипты замера страниц (3/3, стенд)" --description "$(cat <файл>)"
```
Описание — по шаблону Task 8 Step 6, но с `Closes #236`. Добавить список команд замера из README и строку «Замер до/после — добавит контроллер после прогона на стенде». Цифр нет. Не мержить.
- [ ] **Step 6: Точка ожидания замеров контроллера.**
- Исполнение плана на этом заканчивается. Контроллер SDD отдаёт финальный отчёт:
- ссылки на три PR;
- раздел `## Передано контроллеру`: PG15, байт-в-байт и Excel на стенде, ручная проверка фазы 2, `playwright --list`/типы, замеры;
- таблица находок;
- «Rulings I made».
- Главный контроллер:
- сам делает reset стенда, но не во время чужих замеров;
- снимает «до» во временном worktree 1842 с подменой `perf/` из ветки фазы 3 и «после» на вершине фазы 3, на масштабах 1 и 2, по порядку design «Замер до/после»;
- перед «после» запускает `svod2_paging_check.py` и `svod2_search_oracle.mjs --check`;
- дописывает цифры в PR.
- Недобор целей оформляется follow-up и в таблице находок получает статус `deferred`. Красная сверка — блокер слияния, правка идёт в ветку своей фазы с каскадным rebase.