From c7ef4032d6aab29677453a4785c12692959d09f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:06:06 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20aurora-1843=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три фазы — три PR стеком: сервер (SQL, API, тесты-оракулы первыми), фронт (окно страниц в той же таблице), стенд замеров. Решения плана дописаны в design.md (#23–#34). Co-Authored-By: Claude Opus 5.5 --- ...6-10-28-aurora-1843-svod2-server-paging.md | 996 ++++++++++++++++++ .../aurora-1843-svod2-server-paging/design.md | 46 + 2 files changed, 1042 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md diff --git a/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md b/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md new file mode 100644 index 0000000..9c66a27 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md @@ -0,0 +1,996 @@ +# 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 `. +- `.bounded` не создавать. `git stash` без тега не использовать. Не мержить, не пушить в `test`/`main`. +- Между фазами остановок для человека нет: PR создаётся, и сразу начинается следующая фаза. Merge — только по команде человека. +- Решения без обсуждения дописываются в `openspec/changes/aurora-1843-svod2-server-paging/design.md` → `## Decisions made without discussion`: дата, решение, обоснование, цена ошибки. Нумерация продолжается с 35. + +**Маркировка находок.** +- `T.` — minor-находка ревьюера задачи n. Номера задач сквозные по плану, 1–18. +- `C-n`/`I-n`/`M-n` — финальное ревью ветки. Нумерация сквозная по фиче: фаза 2 продолжает номера фазы 1, фаза 3 — номера фазы 2. +- `R-n (design #m)` — рулинг контроллера, `O-n` — наблюдение контроллера. +- Статус каждой находки: `fixed `, `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 ` пишет `/js_number_text_vectors.json` (`[{ "input": "1234.50", "js": "1234.5" }, …]`, где `js = String(JSON.parse(input))`) и `/svod2_screen_vectors.json` (`{ "rows": SYNTHETIC_ROWS, "cases": [{ "search", "filters", "line_ids", "found": { "found", "total" }, "facets": }] }`). + +`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": , "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", "", "я" * 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` @ `` на `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 `, `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, 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[]`; + - `buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] }): Record`: + - `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>` — новые объекты на каждый вызов; + - `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 @ ``: сравнение со сборкой "до" на тех же данных». Чек-лист — 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 --username --password

--year ` — код выхода 1 при расхождении; + - `svod2_paging_check.py --api --scale ` (пользователь dfip и год — из `perf/results/manifest-s.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//` и `/api/v1/svod/2///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[]` в `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/', … 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. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index 0090955..b82dd9d 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -627,3 +627,49 @@ PR создаются по готовности, merge — по команде 22. **2026-10-28 — ~~ограничение длины `search` и значений фильтров — 1000 символов (422)~~. Отменено контроллером 28.10: противоречит решению человека 9 («поведение абсолютно такое же»).** Новая формулировка: API не ограничивает длину `search` и значений фильтров и не отвечает ошибкой из-за длины; длинный запрос даёт тот же результат, что на экране (совпадения есть или нет). Защита от злоупотребления — только невидимая пользователю: общий предел длины строки запроса у веб-сервера (uvicorn/h11 и прокси платформы, порядка килобайт и выше) остаётся как есть, отдельного предела в API не вводим. Цена ошибки: очень длинный URL (десятки КБ) может упереться в предел прокси до приложения — на экране это нереалистичный ввод; если понадобится, фронт переведёт запросы на POST — полдня. +23. **2026-10-28 (план) — JS-оракул `perf/analysis/svod2_search_oracle.mjs` пишется первой задачей фазы 1 и входит в PR 1, а не в PR 3. Режим сверки на стенде (`--check`) добавляется в фазе 3.** + Обоснование: требование человека — тесты-оракулы идут первыми и красными. Дословная копия правил экрана «до» должна существовать раньше SQL. + Цена ошибки: файл `perf/` в серверном PR — нет. +24. **2026-10-28 (план) — Python-оракул интеграционных тестов (`api/tests/svod2_oracle.py`) сверяется с векторами, которые порождает JS-оракул (`api/tests/fixtures/svod2_screen_vectors.json`, `js_number_text_vectors.json`).** + Обоснование: равенство Python-порта коду экрана доказывается без Node в CI. + Цена ошибки: при смене векторов — повторная генерация одной командой. +25. **2026-10-28 (план) — эталон прежнего ответа на засеянных данных (`api/tests/fixtures/svod2_legacy_golden.json`, sha256 тела) записывается один раз на коде «до» (Task 3) и после этого не перезаписывается.** + Обоснование: «байт-в-байт» проверяется в `pytest` на каждом прогоне, а не только сверкой на стенде. + Цена ошибки: при изменении засева эталон придётся переснимать на коде «до» — около 15 минут. +26. **2026-10-28 (план) — засев свода 2:** + - год 2031, явные id от 910001; + - скрипт выполняется в сессии фикстуры `client` (`client.db_session`) через `client.portal`; + - isp1 привязан к одной засеянной организации; + - добавлена четвёртая организация `«филиал ёлки »` (вариант названия по регистру и пробелу); + - добавлена строка без строки `contract_detail`. + + Обоснование: Review Focus плана — различимость вариантов фильтра и слияние пустой секции договора. + Цена ошибки: нет. +27. **2026-10-28 (план) — компактное `data` содержит все группы и подгруппы путей всегда, возможно пустыми (`{}`): это ровно результат `jsonb_strip_nulls(jsonb_build_object(...))`. Python-проекция `compact_projection` повторяет это.** + Обоснование: спецификация молчит о пустых группах. Экран читает пути через `?.`, отображение то же. + Цена ошибки: если нужен пропуск пустых групп — одна обёртка в SQL и в проекции. +28. **2026-10-28 (план) — `v3.svod2_lines` устроена как `WITH eligible_lines AS (…) SELECT … WHERE p_search IS NULL OR EXISTS (…)`. Тело отбора обрамлено комментариями-маркерами, по ним SQLite-тест `test_summary2_related_records_sql.py` извлекает отбор для GO и RF.** + Цена ошибки: если планировщик хуже встраивает CTE — переписать на подзапрос, тесты те же. +29. **2026-10-28 (план) — на фронте вся логика состояния вкладки — чистый редьюсер `svod2PagingReducer` в `svod2Paging.js` (покрыт `node --test`). Хук `useSvod2Pages.js` только ходит в API. `SummaryApi.getSvod2Page/getSvod2Facets` получают готовые параметры. `measureElement` переопределяется в компоненте константой `ROW_HEIGHT`, объект `ROW_VIRTUALIZER_OPTIONS` не меняется.** + Обоснование: хук без тестового рендерера не проверить; поколения и устаревшие ответы — главный риск поведения. + Цена ошибки: нет. +30. **2026-10-28 (план) — чистые помощники стенда для свода 2 вынесены в `perf/e2e/lib/svod2.ts`, их тест — `node --test` (Node 24 стирает типы). Проверку типов и `playwright test --list` делает контроллер.** + Обоснование: у исполнителей нет `perf/e2e/node_modules`, и стенд они не запускают. + Цена ошибки: ошибка типов в сценарии ловится контроллером перед замером — одна итерация. +31. **2026-10-28 (план) — PR 1 и PR 2 ссылаются на issue как `Refs #236` (часть 1/3, 2/3), PR 3 — `Closes #236`.** + Обоснование: стековые PR вливаются не в `test`, и issue не должна закрыться до последней части. + Цена ошибки: правка описания PR. +32. **2026-10-28 (план) — проверки, которые делает главный контроллер, не блокируют следующую фазу:** + - PG15; + - стенд байт-в-байт и Excel; + - ручная проверка фазы 2; + - `playwright --list`. + + Исполнение ждёт контроллера только в конце фазы 3 (замеры). Красный результат проверки становится находкой `O-n`, правка идёт в ветку своей фазы с каскадным rebase. + Обоснование: «между фазами без остановки», а слияние всё равно только по команде человека. + Цена ошибки: каскадный rebase — до часа. +33. **2026-10-28 (план) — нумерация `C/I/M` финальных ревью сквозная по трём веткам, номера задач плана сквозные (1–18).** + Обоснование: маркеры не меняются при переносе между PR и follow-up. + Цена ошибки: нет. +34. **2026-10-28 (план) — найденные странности (перекрытие полей поиска, поиск по техническим id) заносятся в таблицу находок сразу как `O-1` и `O-2` со статусом `deferred`. При закрытии они попадут в follow-up issue.** + Цена ошибки: нет.