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

97 KiB
Raw Blame History

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 или в фоне с уведомлением; если инструмент не даёт ждать долго, повторять проверку.
    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. Дословно:

    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):
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: Проверить дословность копий.
    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.
    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:

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, иначе сравнение:

@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] — прежний ответ без параметров; это оракульная база. Тесты:
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 (красные тесты в промежуточном коммите допустимы):

    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:

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:

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):

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-тест:

@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, затем:

    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):

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: Написать падающие тесты:

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: Написать падающие тесты:

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:

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.

    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):

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.

    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.