DFiP_Budget_planing/docs/superpowers/plans/2026-10-28-aurora-1848-sheet-json.md
Andrey Epifancev 1d885d1bbd docs(plan): aurora-1848 — план реализации
Две фазы: A (feat/233-aurora-1848-sheet-json-no-pydantic, PR в test) и B (feat/233-aurora-1848-sheet-json-no-pydantic-b, стеком на A). Решения планирования дописаны в design.md.

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

76 KiB
Raw Blame History

AURORA-1848: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей. План реализации

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.

Issue: Gitea #233, Jira AURORA-1848 (эпик AURORA-1834).

Ветки и фазы:

  • Фаза A (Task 1–10) — ветка feat/233-aurora-1848-sheet-json-no-pydantic (от test), worktree .claude/worktrees/aurora-1848, PR в test.
  • Фаза B (Task 11–19) — ветка feat/233-aurora-1848-sheet-json-no-pydantic-b стеком на ветке A, worktree .claude/worktrees/aurora-1848-b, PR в ветку A. Между фазами остановки для человека нет: после открытия PR A контроллер сразу начинает B. PR B не мержится раньше PR A и PR #223 (AURORA-1838) — это пишется в описании PR B.

Goal: GET листа, сводов 1 и 2 и отчётов проектов отдают тело, собранное из текста строк Postgres (json_build_object(...)::text), без pydantic-модели на строку (A); у листа и сводов в data нет null-полей, фронт не теряет правки соавторов (B).

Architecture: Рядом со старыми методами репозиториев и сервисов (их использует выгрузка Excel) появляются методы *_json, возвращающие list[str] — по готовой JSON-строке на ряд. Роутеры склеивают строки в обёртку {"success","message","result","count"} хелпером list_json_response и возвращают Response с X-DB-Time-Ms; response_model остаётся в декораторе ради OpenAPI. В B выражение data заменяется на jsonb_strip_nulls(data) для листа и сводов, фронт закрепляет вставку отсутствующих ключей (updateCells из #223) и отправку value: null вместо пропуска.

Tech Stack: Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, PostgreSQL 16 (тесты) / 15.12 (контур), pytest; React 18 (JSX), node:test, Biome, Rsbuild; httpx для perf/.

Spec: openspec/changes/aurora-1848-sheet-json/ (proposal.md, design.md, specs/sheet-read-api/spec.md, specs/realtime-sheet-updates/spec.md). Спека — единственный источник истины при рулингах; ссылки вида «D3» — разделы design.md.

Global Constraints

Замок ночного прогона (обязательно). С ~01:00 на машине идут замеры производительности; любая нагрузка на CPU их портит.

  • Перед КАЖДЫМ шагом, помеченным [замок] (pytest, alembic, node --test, npx biome check, npm run build, codegraph init, docker-команды, запуск API/фронта), ждать, пока замок существует: 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 Команду ожидания запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго — повторять проверку.
  • Контроллер передаёт это правило в бриф каждого исполнителя и ревьюера (сабагенты не видят этот раздел, если его не процитировать).
  • Чтение и написание кода, git-коммиты — можно при замке.
  • Стенд perf/ (БД perf в perf-pg, порты 8000/8080/8081/8082) — не запускать вообще: замеры делает главный контроллер (Task 19).

Окружение тестов.

  • PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin — pytest, python. Свой venv не создавать.
  • DBURL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848 — своя БД в контейнере perf-pg (PG16). БД perf не трогать никогда. Пароли OPENBAO__TEST_* по умолчанию admin123.
  • Полный набор бэка: cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore (с --init_db — только на только что созданной БД, см. Task 1). Зелёным считается весь набор.
  • Исходное состояние test: ровно 3 падения не наши — test_backend_calls_form3_add_project_and_add_line_functions и два теста в test_export_service_query_params.py. Новых падений быть не должно.
  • Тесты perf/: cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests.
  • Фронт: web/node_modules — симлинк на /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules, npm install не запускать. Команды: cd web && node --test src/components/RealtimeTable/utils/*.test.js, npx biome check <изменённые файлы>, npm run build.
  • PG 15.12 (dfip-pg15:55434) проверяет главный контроллер (Task 8, Task 16).
  • Карта кода: mcp__codegraph__codegraph_explore с projectPath = абсолютный путь текущего worktree. Пустой ответ — один раз отметить O-n в ledger и дальше читать файлы.

Инварианты кода (из спеки).

  • Старые методы SheetRepository.get, SummaryRepository.get_svod1_rows/get_svod2_rows, ProjectRepository.get_*_rows, SheetService.get, SummaryService.get_svod*_rows, ProjectService.get_*_rows не меняют сигнатуру и результат; единственная допустимая правка — вынос FROM-части в приватный метод с эквивалентным текстом SQL (D2).
  • Дифф api/src/services/export_service/, api/src/api/v1/websocket.py, api/alembic/ против test — пустой. Миграций нет.
  • Строка ответа: json_build_object('row_type', <row_type>, 'depth', <depth>, 'data', <data>, 'sort_order', sort_order)::text, порядок строк — порядок функции, без ORDER BY (D1).
  • Обёртка: {"success":true,"message":null,"result":[...],"count":N}, media_type="application/json" без charset (D3).
  • X-DB-Time-Ms ставится на возвращаемый Response (headers=), формат f"{db_ms:.2f}", окно замера прежнее, лог db_time с прежними полями. Параметр response: Response из сигнатур убрать (D3).
  • response_model=BaseListResponse[SheetResponse] — в декораторе @router.get(...), аннотация возврата -> Response (D3).
  • B: jsonb_strip_nulls только у листа и сводов 1/2; отчёты и сводные проектов отдают null как раньше; ответы путей записи и WebSocket не меняются (D4, решение человека 3).

Коммиты и PR.

  • Стиль репозитория (perf(...), fix(...), test(...), docs(...)), по-русски, git add конкретных файлов (не -A). Последняя строка сообщения: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • git stash без тега не использовать. Не мержить. Не пушить в test/main. .bounded не создавать (задача architectural, правки разрешает этот план).
  • PR: tea pulls create --base <база> --head <ветка> --title "AURORA-1848: …" --description "…". В описании: Closes #233 (только PR B; в PR A — Part of #233), ключ AURORA-1848, что сделано, тесты, чек-лист ручной проверки, строка «Замер до/после — добавит контроллер после прогона на стенде», раздел «Rulings I made» (маркеры R-n), таблица находок, последняя строка 🤖 Generated with [Claude Code](https://claude.com/claude-code).

Маркировка находок. Каждая находка и решение несёт маркер, присвоенный один раз: T<n>.<k> — minor ревьюера задачи n; C-n/I-n/M-n — финальное ревью; R-n — рулинг контроллера (рядом номер строки в design.md, напр. R-3 (design #24)); O-n — наблюдение контроллера; H-n — замечание человека из PR. Статус каждой: fixed <commit> / deferred / rejected <причина>. Таблица ## Findings (| Маркер | Суть | Статус |) — в конце ledger, обновляется при каждой правке; та же таблица — в описании PR. Каждый рулинг без обсуждения с человеком дописывается в openspec/changes/aurora-1848-sheet-json/design.md → ## Decisions made without discussion в той же ветке, где он принят.

Review Focus

  1. Текст с кавычками, обратным слэшем, переводом строки и кириллицей в data (обоснование, наименование). Postgres экранирует иначе, чем json.dumps; ожидается, что после json.loads значение то же. Тест test_sheet_text_escaping_matches_old_path (Task 3) и unit-тест хелпера (Task 2).
  2. Пустой результат (пользователь без доступа к запрошенным org_ids, свод без строк): тело {"success":true,"message":null,"result":[],"count":0} — валидный JSON, а не "result":[]-обрывок. Тесты test_list_json_response_empty (Task 2), test_svod2_foreign_org_ids_empty_body (Task 4).
  3. Ответ в обход response_model перестаёт проверяться: незаметно уходит старый путь, теряется X-DB-Time-Ms или обёртка. Ожидается: GET не вызывает старые методы, заголовок есть на всех 9 эндпоинтах. Тесты test_*_does_not_use_old_path (Task 3, 4, 5), get_ok проверяет заголовок и ключи.
  4. Очистка null затрагивает не только data (обёртку, sort_order, ответы путей записи, отчёты проектов) или удаляет {} и null в массивах. Ожидается: чистится только data листа и сводов, {} и null-элементы массивов остаются, PATCH отдаёт null явно. Тесты test_strip_response_* (Task 7), test_patch_cell_null_is_explicit и test_project_report_keeps_nulls (Task 13).
  5. Пустая ячейка без ключа на фронте: выход из редактирования без ввода отправляет сообщение без value, и сервер падает на event_data["value"]. Ожидается "value": null. Тест buildCellUpdatedMessage: undefined → null (Task 12).

Фаза A — готовый JSON без pydantic-модели на строку

Ветка feat/233-aurora-1848-sheet-json-no-pydantic, worktree /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848.

Task 1: Окружение тестов, фикстура db_session, снимок схемы OpenAPI

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: api/tests/integration/conftest.py:15-18 (хунк AURORA-1846, если его ещё нет)
  • Create: api/tests/integration/test_heavy_get_json.py

Interfaces:

  • Produces: фикстура db_session (та же сессия, что у client); в test_heavy_get_json.py — ADMIN, ISP, YEAR, HEAVY_GET_PATHS, OPENAPI_REF, old_rows(client, coro_factory) -> list[dict], expected_sheet(rows: list[dict]) -> list[dict], get_ok(client, url, headers, params=None) -> list[dict]. Все последующие интеграционные тесты фазы A и B дописываются в этот файл и пользуются этими именами.

  • Step 1: Фикстура db_session. Если grep -q "def db_session" api/tests/integration/conftest.py ничего не находит — внести ровно этот хунк (тот же, что в PR #232; одинаковая правка сольётся без конфликта):

@pytest.fixture
def db_session():
    """Сессия БД теста: её же использует client, всё откатывается в конце теста."""
    db_ref = SessionLocal()
    yield db_ref


@pytest.fixture
def client(db_session):
    db_ref = db_session
    loop_ref = None

(заменяет строки def client(): / db_ref = SessionLocal() / loop_ref = None; остальное тело client без изменений).

  • Step 2: Создать модуль тестов с помощниками и снимком OpenAPI
"""AURORA-1848: тяжёлые GET отдают готовый JSON из Postgres.

Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel)
в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md
"""
from types import SimpleNamespace

import pytest

from src.main import app

ADMIN = SimpleNamespace(id=1, role_id=1)  # fixture.sql: admin
ISP = SimpleNamespace(id=2, role_id=2)    # fixture.sql: isp1, EXECUTOR_DFIP
YEAR = 2026

HEAVY_GET_PATHS = [
    "/api/v1/form/{form_id}/sheet/{sheet}",
    "/api/v1/svod/1/{sheet}/{year}",
    "/api/v1/svod/2/{sheet}/{year}",
    "/api/v1/projects/{project_id}/report/{year}/{report_type}",
    "/api/v1/rf-rollup/{branch_id}/{year}",
    "/api/v1/projects/{project_id}/summary",
    "/api/v1/projects/plan-summary",
    "/api/v1/projects/fact-summary",
    "/api/v1/projects/detail-summary",
]
OPENAPI_REF = "#/components/schemas/BaseListResponse_SheetResponse_"


def _payload(rows) -> list[dict]:
    """Строки старого пути в том виде, в каком их отдавал BaseListResponse[SheetResponse]."""
    return [{"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]} for r in rows]


def old_rows(client, coro_factory) -> list[dict]:
    """Вызвать старый метод (coro_factory() -> awaitable list[tuple]) в цикле приложения."""
    async def _go():
        return _payload(await coro_factory())

    return client.portal.call(_go)


def expected_sheet(rows: list[dict]) -> list[dict]:
    """Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls."""
    return rows


def get_ok(client, url, headers, params=None) -> list[dict]:
    r = client.get(url, headers=headers, params=params)
    assert r.status_code == 200, r.text
    assert r.headers["content-type"] == "application/json"
    assert float(r.headers["x-db-time-ms"]) >= 0
    body = r.json()
    assert list(body) == ["success", "message", "result", "count"]
    assert body["success"] is True and body["message"] is None
    assert body["count"] == len(body["result"])
    for row in body["result"]:
        assert row.keys() == {"row_type", "depth", "sort_order", "data"}
    return body["result"]


def test_openapi_heavy_get_schema_unchanged():
    spec = app.openapi()
    for path in HEAVY_GET_PATHS:
        schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
        assert schema == {"$ref": OPENAPI_REF}, path
  • Step 3: [замок] Создать БД. docker exec perf-pg dropdb -U perf --if-exists dfip_1848 && docker exec perf-pg createdb -U perf dfip_1848

  • Step 4: [замок] Базовый полный прогон на ещё не изменённом коде (это и снимок OpenAPI). Run: cd api && DATABASE_URL=$DBURL $PY/pytest --init_db -q -p no:cacheprovider -W ignore Expected: падают ровно 3 известных теста (см. Global Constraints); test_openapi_heavy_get_schema_unchanged PASS. Если он падает только из-за имени компоненты — заменить литерал OPENAPI_REF на фактическое значение из схемы (это и есть снимок до изменения), отметить O-n в ledger. Другие падения — стоп, superpowers:systematic-debugging.

  • Step 5: Commit

git add api/tests/integration/conftest.py api/tests/integration/test_heavy_get_json.py
git commit -m "test(api): фикстура db_session и снимок схемы OpenAPI тяжёлых GET" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Хелпер ответа и SQL-выражение строки

Исполнитель: haiku (код готов в плане).

Files:

  • Create: api/src/api/v1/json_response.py
  • Create: api/src/repository/row_json.py
  • Test: api/tests/unit/test_json_list_response.py

Interfaces:

  • Produces:

    • list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> fastapi.Response (src.api.v1.json_response)
    • ROW_COLUMNS: str = "row_type, depth, sort_order, data" (src.repository.row_json)
    • row_json_select(row_type: str = "row_type", depth: str = "depth") -> str — SELECT-список без FROM
    • async fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]
  • Step 1: Write the failing tests

import json

from src.api.v1.json_response import list_json_response
from src.repository.row_json import ROW_COLUMNS, row_json_select


def test_list_json_response_empty():
    r = list_json_response([])
    assert json.loads(r.body) == {"success": True, "message": None, "result": [], "count": 0}


def test_list_json_response_rows_order_count_escaping():
    rows = [
        '{"row_type":"META","depth":-1,"data":{"a":null},"sort_order":null}',
        r'{"row_type": "INPUT", "depth": 3, "data": {"t": "Кавычка \" и \\ слэш\nперенос"}, "sort_order": 1}',
    ]
    body = json.loads(list_json_response(rows).body)
    assert list(body) == ["success", "message", "result", "count"]
    assert body["count"] == 2
    assert [row["row_type"] for row in body["result"]] == ["META", "INPUT"]
    assert body["result"][0]["data"] == {"a": None}
    assert body["result"][1]["data"]["t"] == 'Кавычка " и \\ слэш\nперенос'


def test_list_json_response_media_type_and_headers():
    r = list_json_response(["{}"], headers={"X-DB-Time-Ms": "1.50"})
    assert r.media_type == "application/json"
    assert r.headers["content-type"] == "application/json"
    assert r.headers["x-db-time-ms"] == "1.50"
    assert r.headers["content-length"] == str(len(r.body))


def test_row_json_select_key_order():
    assert ROW_COLUMNS == "row_type, depth, sort_order, data"
    assert row_json_select() == (
        "SELECT json_build_object('row_type', row_type, 'depth', depth, "
        "'data', data, 'sort_order', sort_order)::text"
    )
    assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith(
        "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, "
    )
  • Step 2: [замок] Run — FAIL cd api && DATABASE_URL=$DBURL $PY/pytest tests/unit/test_json_list_response.py -q -p no:cacheprovider → ModuleNotFoundError: src.api.v1.json_response.

  • Step 3: Implement

api/src/api/v1/json_response.py:

"""Ответ тяжёлых GET из готовых JSON-строк Postgres (AURORA-1848, design D3)."""
from fastapi import Response


def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response:
    """Обёртка как у BaseListResponse при успехе; rows — уже сериализованные строки ответа."""
    body = b"".join((
        b'{"success":true,"message":null,"result":[',
        ",".join(rows).encode(),
        b'],"count":',
        str(len(rows)).encode(),
        b"}",
    ))
    return Response(content=body, media_type="application/json", headers=headers)

api/src/repository/row_json.py:

"""Строка ответа тяжёлого GET как текст JSON из Postgres (AURORA-1848, design D1)."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

ROW_COLUMNS = "row_type, depth, sort_order, data"


def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str:
    """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg."""
    return (
        f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, "
        f"'data', data, 'sort_order', sort_order)::text"
    )


async def fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]:
    return list((await db.execute(text(sql), params)).scalars().all())
  • Step 4: [замок] Run — PASS (та же команда).

  • Step 5: Commit git add api/src/api/v1/json_response.py api/src/repository/row_json.py api/tests/unit/test_json_list_response.py → perf(api): хелпер ответа из готовых JSON-строк и SQL-выражение строки + Co-Authored-By.


Task 3: GET листа формы — готовый JSON

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: api/src/repository/sheet_repository.py:33-68 (get → вынос _view_source, новый get_json)
  • Modify: api/src/services/sheet_service.py:23-36 (новый get_json рядом с get)
  • Modify: api/src/api/v1/forms.py:150-202 (get_sheet)
  • Test: api/tests/integration/test_heavy_get_json.py

Interfaces:

  • Consumes: row_json_select, fetch_row_texts, ROW_COLUMNS (Task 2), list_json_response (Task 2), помощники old_rows/expected_sheet/get_ok/ADMIN (Task 1).

  • Produces:

    • SheetRepository._view_source(self, form_id, sheet, direction, sections) -> tuple[str, dict] — валидация (SheetValidationError) + FROM-выражение и параметры
    • SheetRepository.get_json(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[str]
    • SheetService.get_json(self, sheet: str, direction: str | None, sections: list[str] | None = None, form_id: int | None = None) -> list[str]
    • Task 13 добавит к обоим get_json параметр strip_nulls: bool = False.
  • Step 1: Write the failing tests (дописать в test_heavy_get_json.py; импорты — в шапку модуля)

import json

from sqlalchemy import text

from src.repository.sheet_repository import SheetRepository

SHEET_CASES = [
    (1, "AHR", "Support", None),        # FORM_1, лист с direction
    (4, "AHR", None, None),             # FORM_2, без direction
    (4, "AHR", None, ["plan", "q1"]),   # FORM_2, с sections
]


def _sheet_params(direction, sections):
    params = {}
    if direction:
        params["direction"] = direction
    if sections:
        params["sections"] = ",".join(sections)
    return params


@pytest.mark.parametrize("form_id,sheet,direction,sections", SHEET_CASES)
def test_sheet_matches_old_path(client, db_session, admin_tokens, auth_headers, form_id, sheet, direction, sections):
    result = get_ok(client, f"/api/v1/form/{form_id}/sheet/{sheet}", auth_headers(admin_tokens), _sheet_params(direction, sections))
    old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=form_id, sheet=sheet, direction=direction, sections=sections))
    assert result
    assert result[0]["row_type"] == "META" and result[0]["depth"] == -1
    assert result == expected_sheet(old)


TRICKY = 'Кавычка " и \\ слэш\nперенос\tтаб'


def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, auth_headers):
    async def _set():
        await db_session.execute(
            text("UPDATE v3.budget_line SET justification = :t WHERE id = (SELECT min(id) FROM v3.budget_line WHERE budget_form_id = 4)"),
            {"t": TRICKY},
        )
    client.portal.call(_set)
    result = get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))
    old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=4, sheet="AHR", direction=None))
    assert result == expected_sheet(old)
    assert any(TRICKY in json.dumps(r["data"], ensure_ascii=False) for r in result)


def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch):
    async def boom(*args, **kwargs):
        raise AssertionError("GET листа не должен вызывать SheetRepository.get")
    monkeypatch.setattr(SheetRepository, "get", boom)
    get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))

Если justification не попадает в data листа FORM_2/AHR (последний assert падает и на старом пути) — взять колонку budget_line.name; проверка присутствия TRICKY в ответе обязательна. Замену отметить в отчёте задачи.

  • Step 2: [замок] Run — FAIL cd api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_heavy_get_json.py -q -p no:cacheprovider -W ignore. Expected: test_sheet_get_does_not_use_old_path падает с AssertionError: GET листа не должен…; test_sheet_matches_old_path/escaping проходят уже на старом коде (они — страховка эквивалентности).

  • Step 3: Repository. Вынести из get валидацию и выбор func_query в _view_source; get и get_json:

    def _view_source(self, form_id, sheet, direction, sections) -> tuple[str, dict]:
        """Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа)."""
        # проверка any([...]) -> SheetValidationError и выбор func_query — перенести из get без изменений
        ...
        return func_query, {"form_id": form_id, "sheet": sheet, "direction": direction, "sections": sections}

    async def get(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[tuple]:
        func_query, params = self._view_source(form_id, sheet, direction, sections)
        return (await self.db.execute(text(f"SELECT {ROW_COLUMNS} FROM {func_query}"), params)).all()

    async def get_json(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[str]:
        func_query, params = self._view_source(form_id, sheet, direction, sections)
        return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params)
  • Step 4: Service. SheetService.get_json(...) — копия get, вызывает self.sheet_repo.get_json(form_id=..., sheet=..., direction=..., sections=...).

  • Step 5: Router get_sheet.

    • декоратор: @router.get("/{form_id}/sheet/{sheet}", response_model=BaseListResponse[SheetResponse]); аннотация -> Response; параметр response: Response удалить;
    • result = await sheet_service.get(...) → rows = await sheet_service.get_json(...) (аргументы те же);
    • строку response.headers["X-DB-Time-Ms"] = ... удалить; extra/logger.info без изменений;
    • возврат: return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"});
    • импорт from src.api.v1.json_response import list_json_response. _rows_to_sheet_response остаётся (её используют PATCH/POST/DELETE).
  • Step 6: [замок] Run — PASS модуль test_heavy_get_json.py + tests/integration/test_forms_api_smoke.py + tests/integration/test_db_functions_integration.py.

  • Step 7: Commit git add api/src/repository/sheet_repository.py api/src/services/sheet_service.py api/src/api/v1/forms.py api/tests/integration/test_heavy_get_json.py → perf(api): GET листа отдаёт готовый JSON из Postgres без SheetResponse на строку + Co-Authored-By.


Task 4: GET сводов 1 и 2 — готовый JSON

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: api/src/repository/summary_repository.py:11-76 (вынос FROM-частей, get_svod1_json, get_svod2_json)
  • Modify: api/src/services/summary_service.py:15-53 (get_svod1_json, get_svod2_json)
  • Modify: api/src/api/v1/summary.py (оба GET; удалить _rows_to_sheet_response)
  • Test: api/tests/integration/test_heavy_get_json.py

Interfaces:

  • Consumes: Task 1, Task 2.

  • Produces:

    • SummaryRepository._svod1_source() -> str, SummaryRepository._svod2_source(sheet: str) -> str (staticmethod, строка FROM ...)
    • SummaryRepository.get_svod1_json(self, year: int, sheet: str, org_ids: list[int] | None = None) -> list[str]
    • SummaryRepository.get_svod2_json(self, year: int, sheet: str, org_ids=None, offset=None, limit=None, sort_by="org_name", sort_direction="asc") -> list[str]
    • SummaryService.get_svod1_json(self, year, sheet, user, org_ids=None) -> list[str], SummaryService.get_svod2_json(self, year, sheet, user, org_ids=None, offset=None, limit=None, sort_by="org_name", sort_direction="asc") -> list[str] — те же проверки, что у *_rows (_allowed_org_ids, ранний []).
    • Task 13 добавит strip_nulls: bool = False ко всем четырём.
  • Step 1: Write the failing tests

from src.repository.summary_repository import SummaryRepository
from src.services.summary_service import SummaryService


@pytest.mark.parametrize("sheet", ["FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"])
def test_svod1_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet):
    result = get_ok(client, f"/api/v1/svod/1/{sheet}/{YEAR}", auth_headers(admin_tokens))
    old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet=sheet, org_ids=None))
    assert result == expected_sheet(old)


def test_svod1_org_ids_matches_old_path(client, db_session, admin_tokens, auth_headers):
    result = get_ok(client, f"/api/v1/svod/1/FORM_1/{YEAR}", auth_headers(admin_tokens), {"org_ids": "1,2,3"})
    old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet="FORM_1", org_ids=[1, 2, 3]))
    assert result
    assert result == expected_sheet(old)


SVOD2_CASES = [
    ("GO", {}, {}),
    ("RF", {}, {}),
    ("GO", {"org_ids": "1,2"}, {"org_ids": [1, 2]}),
    ("GO", {"offset": 1, "limit": 2}, {"offset": 1, "limit": 2}),
    ("RF", {"sort_by": "section_code", "sort_direction": "desc"}, {"sort_by": "section_code", "sort_direction": "desc"}),
]


@pytest.mark.parametrize("sheet,query,repo_kwargs", SVOD2_CASES)
def test_svod2_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet, query, repo_kwargs):
    result = get_ok(client, f"/api/v1/svod/2/{sheet}/{YEAR}", auth_headers(admin_tokens), query)
    old = old_rows(client, lambda: SummaryRepository(db_session).get_svod2_rows(year=YEAR, sheet=sheet, **repo_kwargs))
    assert result == expected_sheet(old)


def test_svod2_fixture_not_empty(client, admin_tokens, auth_headers):
    """Иначе сравнения свода 2 выше проходят на пустых списках."""
    go = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(admin_tokens))
    rf = get_ok(client, f"/api/v1/svod/2/RF/{YEAR}", auth_headers(admin_tokens))
    assert go or rf


def test_svod2_isp_matches_service_old_path(client, db_session, isp_tokens, auth_headers):
    result = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(isp_tokens))
    old = old_rows(client, lambda: SummaryService(db_session).get_svod2_rows(year=YEAR, sheet="GO", user=ISP))
    assert result == expected_sheet(old)


def test_svod2_foreign_org_ids_empty_body(client, isp_tokens, auth_headers):
    r = client.get(f"/api/v1/svod/2/GO/{YEAR}", headers=auth_headers(isp_tokens), params={"org_ids": "999999"})
    assert r.status_code == 200
    assert r.json() == {"success": True, "message": None, "result": [], "count": 0}
    assert float(r.headers["x-db-time-ms"]) >= 0


@pytest.mark.parametrize("url,method", [
    (f"/api/v1/svod/1/MAIN/{YEAR}", "get_svod1_rows"),
    (f"/api/v1/svod/2/GO/{YEAR}", "get_svod2_rows"),
])
def test_svod_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch, url, method):
    async def boom(*args, **kwargs):
        raise AssertionError(f"GET свода не должен вызывать SummaryRepository.{method}")
    monkeypatch.setattr(SummaryRepository, method, boom)
    get_ok(client, url, auth_headers(admin_tokens))

Если test_svod2_fixture_not_empty падает и на старом коде — до сравнения добавить строку через POST /api/v1/form/4/sheet/AHR/line (как test_forms_add_line в test_forms_api_smoke.py) в фикстуре модуля и отметить в отчёте задачи.

  • Step 2: [замок] Run — FAIL: падают оба test_svod_get_does_not_use_old_path; сравнения проходят на старом коде.

  • Step 3: Repository. _svod1_source() возвращает "FROM v3.v_summary_view(CAST(:year AS INT), CAST(:sheet AS TEXT), CAST(:org_ids AS INT[]))"; _svod2_source(sheet) — словарь {"GO": "v3.v_svod2_go", "RF": "v3.v_svod2_rf"}[sheet] и прежний список из шести CAST(...). Старые методы:

    • get_svod1_rows: text(f"SELECT {ROW_COLUMNS} {self._svod1_source()}"), параметры прежние;
    • get_svod2_rows: text(f"SELECT 'INPUT'::VARCHAR AS row_type, 3 AS depth, sort_order, data {self._svod2_source(sheet)}"), параметры прежние. Новые методы — те же параметры, SQL f"{row_json_select()} {self._svod1_source()}" и для свода 2 select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3") → f"{select} {self._svod2_source(sheet)}" (константы те же, что в старом SELECT), выполнение через fetch_row_texts. Словарь параметров свода 2 строится в одном приватном _svod2_params(...) -> dict для обоих методов.
  • Step 4: Service. get_svod1_json/get_svod2_json — копии get_svod1_rows/get_svod2_rows, вызывают *_json репозитория.

  • Step 5: Routers get_summary, get_summary2 — по образцу Task 3 Step 5: response_model=BaseListResponse[SheetResponse] в декоратор, -> Response, убрать параметр response, вызвать *_json, вернуть list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}). Удалить _rows_to_sheet_response из summary.py (импорты BaseListResponse, SheetResponse остаются для декоратора; Response импортирован уже).

  • Step 6: [замок] Run — PASS test_heavy_get_json.py + tests/integration/test_summary_api_smoke.py.

  • Step 7: Commit (summary_repository.py, summary_service.py, summary.py, тест) → perf(api): GET сводов 1 и 2 отдают готовый JSON из Postgres + Co-Authored-By.


Task 5: GET отчётов и сводных проектов — готовый JSON (отдельный коммит)

Исполнитель: sonnet (интеграция: шесть эндпоинтов, разные проверки доступа, характеризующие тесты ошибок).

Files:

  • Modify: api/src/repository/project_repository.py:358-472
  • Modify: api/src/services/project_service.py:97-196
  • Modify: api/src/api/v1/projects.py:117-338 (шесть GET и _get_projects_summary_response)
  • Test: api/tests/integration/test_heavy_get_json.py

Interfaces:

  • Consumes: Task 1, Task 2.

  • Produces (репозиторий, все -> list[str], параметры как у пары *_rows): get_report_json(report_id, sections=None, user_id=None), get_rf_rollup_json(branch_id, year, sections=None), get_project_summary_json(project_id), get_plan_summary_json(year, branch_ids, project_type=None), get_fact_summary_json(...), get_detail_summary_json(...); FROM-части выносятся в приватные функции, как в Task 3/4 (у _get_project_summary_rows — общий _project_summary_source(function_name) -> str).

  • Сервис: get_report_json, get_rf_rollup_json, get_project_summary_json, get_plan_summary_json, get_fact_summary_json, get_detail_summary_json — те же проверки, что у *_rows (resolve_report_id + ValidationException("Отчёт не найден"), _allowed_org_unit_ids + AccessDeniedException, get_instance + ValidationException("Проект не найден"), _allowed_summary_branch_ids + ранний []).

  • Роутер: _get_projects_summary_response заменяется синхронной _projects_summary_response(*, year, branch_id, project_type, handler_name, rows: list[str], t0: float, current_user) -> Response. Без strip_nulls и в фазе B (D4).

  • Step 1: Write the tests. В модуль добавить помощники _create_project(client, headers, year=YEAR, branch_id=1) -> int и _add_line(client, headers, project_id, year=YEAR) -> int | None — копии _create_project/_add_line из test_projects_api_smoke.py (без pytest.skip: None, если строку добавить не удалось). Тесты:

    • test_project_report_matches_old_path[sections] для sections in (None, "q1,year"): проект + строка, GET /api/v1/projects/{pid}/report/{YEAR}/LIMIT; эталон ProjectService(db_session).get_report_rows(project_id=pid, year=YEAR, report_type="LIMIT", sections=<list|None>, user=ADMIN); result == old (без expected_sheet: отчёты не чистятся и в B).
    • test_rf_rollup_matches_old_path: GET /api/v1/rf-rollup/1/{YEAR} ↔ get_rf_rollup_rows(branch_id=1, year=YEAR, sections=None, user=ADMIN).
    • test_project_summary_matches_old_path: GET /api/v1/projects/{pid}/summary ↔ get_project_summary_rows(project_id=pid, user=ADMIN).
    • test_projects_summaries_match_old_path[kind] для plan|fact|detail: GET /api/v1/projects/{kind}-summary?year={YEAR} ↔ get_{kind}_summary_rows(year=YEAR, branch_ids=None, user=ADMIN).
    • test_projects_get_does_not_use_old_path[method,url] — monkeypatch ProjectRepository.<method> на boom для каждого из шести *_rows, GET → 200.
    • Характеризующие тесты ошибок (пишутся и прогоняются до правки роутеров, должны быть зелёными и до, и после): test_project_report_not_found_unchanged — GET .../report/2099/LIMIT у созданного проекта, assert статуса и r.json() равного зафиксированному на старом коде литералу; test_rf_rollup_foreign_branch_unchanged — isp_tokens, ветка не из оргединиц isp1 (подобрать в fixture.sql), так же статус и тело литералом.
  • Step 2: [замок] Run — характеризующие тесты PASS, test_projects_get_does_not_use_old_path FAIL на старом коде.

  • Step 3: Implement репозиторий → сервис → роутеры по образцу Task 3/4 (декоратор с response_model, -> Response, без параметра response, list_json_response(rows, headers={"X-DB-Time-Ms": ...}), лог db_time с прежними полями, включая handler get_project_report у get_rf_rollup как сейчас). _rows_to_payload остаётся для правок FORM_3.

  • Step 4: [замок] Run — PASS test_heavy_get_json.py + tests/integration/test_projects_api_smoke.py.

  • Step 5: Commit отдельным коммитом (файлы проектов и тест) → perf(api): отчёты и сводные проектов отдают готовый JSON из Postgres + Co-Authored-By.


Task 6: api_split.py — размер на проводе

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: perf/analysis/api_split.py (вынос цикла замера в measure, поле wire_bytes, колонка в .md, docstring)
  • Test: perf/tests/test_api_split.py

Interfaces:

  • Produces: measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict с ключами rows, total_ms, db_ms, bytes, wire_bytes, content_encoding (медианы, как сейчас; первый запрос — прогрев).

  • Step 1: Write the failing test

import gzip
import json
import sys
from pathlib import Path

import httpx

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))

from api_split import measure  # noqa: E402


def test_measure_reports_wire_and_decoded_bytes():
    raw = json.dumps({"result": [{"a": 1}] * 50}).encode()
    gz = gzip.compress(raw)

    def handler(request):
        assert request.headers["authorization"] == "Bearer tok"
        return httpx.Response(200, headers={"content-encoding": "gzip", "x-db-time-ms": "12.50"}, content=gz)

    with httpx.Client(transport=httpx.MockTransport(handler), base_url="http://t") as c:
        m = measure(c, "/x", "tok", repeats=3)
    assert m["rows"] == 50
    assert m["bytes"] == len(raw)
    assert m["wire_bytes"] == len(gz)
    assert m["db_ms"] == 12.5
    assert m["content_encoding"] == "gzip"
  • Step 2: [замок] Run — FAIL cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_api_split.py → ImportError: cannot import name 'measure'.

  • Step 3: Implement. Тело цикла for i in range(a.repeats + 1) из main переносится в measure без изменения логики; добавляется wire = r.num_bytes_downloaded на тех же итерациях, что size, и "wire_bytes": wire в результат. main собирает row из measure(...) плюс role, request, path и прежние производные поля. В .md — колонка «на проводе, МБ» (wire_bytes / 1e6, :.2f) после «размер, МБ». Docstring: ссылки forms.py:184, summary.py:74,126, projects.py:128 заменить на места установки X-DB-Time-Ms после Task 3–5 и упомянуть, что остаток после A — склейка строк, gzip, передача и распаковка (без pydantic).

  • Step 4: [замок] Run — PASS + весь perf/tests -m "not stand".

  • Step 5: Commit perf/analysis/api_split.py perf/tests/test_api_split.py → perf(analysis): api_split — размер ответа на проводе (wire_bytes) + Co-Authored-By.


Task 7: compare_responses.py — сравнение ответов до и после

Исполнитель: sonnet (интеграция: обход манифеста, роли, CLI).

Files:

  • Create: perf/analysis/compare_responses.py
  • Test: perf/tests/test_compare_responses.py

Interfaces:

  • Produces:
    • build_requests(man: dict) -> list[tuple[str, str, str]] — (role, name, path_with_query)
    • strip_nulls(value) — рекурсивно удаляет поля объектов со значением None; элементы списков None остаются, {} остаётся (семантика jsonb_strip_nulls PG 15)
    • strip_response(body: dict) -> dict — копия тела, где strip_nulls применён только к row["data"] каждой строки result; success, message, count, row_type, depth, sort_order не трогаются
    • is_stripped(name: str) -> bool — True для листов и сводов (name.startswith(("sheet-", "svod1-", "svod2-")))
    • first_diff(a, b, path: str = "$") -> str | None — путь до первого отличия ($.result[3].data.q1.m1) с обоими значениями, None при равенстве
    • compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]] — число совпавших и список расхождений
    • CLI: dump --scale N --out DIR [--api URL], compare BEFORE AFTER --mode exact|strip-nulls; код возврата 1 при расхождении.

Правила build_requests (манифест perf/results/manifest-s{scale}.json, ключи как в perfkit/manifest.py):

  • листы: каждый sheets[i], роль rf при owner == "rf", иначе dfip; путь /api/v1/form/{form_id}/sheet/{sheet} + ?direction=<d>, если direction не None; имя sheet-{form_type}-{sheet}-{direction or 'none'}-{form_id}; плюс один вариант FORM_2/AHR (owner == "rf") с sections=plan,q1, имя с суффиксом -sections;
  • год сводов — year листа FORM_2/AHR роли rf (как в api_split.py); svod1-{FORM_1..FORM_4,MAIN} и svod2-{GO,RF} под dfip;
  • отчёты: каждый project_reports[i] под rf (report-{project_id}-{year}-{report_type}); по каждому уникальному project_id — /api/v1/projects/{id}/summary под rf (project-summary-{id}); plan|fact|detail-summary?year= под dfip (projects-{kind}-summary);
  • rf-rollup не снимается: в манифесте нет branch_id (он покрыт интеграционным тестом Task 5).

dump: логин каждой роли (man["users"][role]), GET каждого запроса, тело (r.content, распакованное) — в <out>/<name>.json.gz, статусы — в <out>/index.json ({name: status}). compare: статусы равны; для mode == "strip-nulls" и is_stripped(name) сравнивается json.loads(after) == strip_response(json.loads(before)), иначе точное равенство; отсутствующий в after файл — расхождение. Печать: совпало N из M, затем по строке на расхождение.

  • Step 1: Write the failing tests (sys.path.insert(...)/"analysis" как в test_analysis.py):

    • test_strip_nulls_pg_semantics: strip_nulls({"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}}) == {"b": {}, "d": [None, {"f": 1}], "g": {}}.
    • test_strip_response_keeps_wrapper_and_sort_order: тело {"success": True, "message": None, "result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}], "count": 1} → message и sort_order остаются None, data == {"y": 1}.
    • test_first_diff_path: first_diff({"result": [{"data": {"q1": {"m1": 1}}}]}, {"result": [{"data": {"q1": {"m1": 2}}}]}) начинается с "$.result[0].data.q1.m1"; для равных — None; разная длина списка и разный набор ключей тоже дают путь.
    • test_compare_exact_and_strip_modes(tmp_path): в before/ и after/ по sheet-FORM_2-AHR-none-1.json.gz (after без null-поля) и report-5-2026-LIMIT.json.gz (after тоже без null-поля), index.json с 200: exact → 0 совпало, 2 расхождения; strip-nulls → 1 совпало (лист), 1 расхождение (отчёт сравнивается точно).
    • test_compare_status_mismatch(tmp_path): одинаковые тела, статусы 200/500 → расхождение.
    • test_build_requests_minimal_manifest: манифест из двух листов (rf FORM_2/AHR без direction, ssp FORM_1/AHR Support) и одного отчёта → есть sheet-FORM_2-AHR-none-<id> под rf, его -sections вариант, sheet-FORM_1-AHR-Support-<id> под dfip с ?direction=Support, пять svod1-*, два svod2-*, report-*, project-summary-*, три projects-*-summary; нет rf-rollup.
  • Step 2: [замок] Run — FAIL cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_compare_responses.py → ModuleNotFoundError.

  • Step 3: Implement по Interfaces; docstring модуля — порядок использования из D7.1 (dump на базовом коммите → dump на A → compare --mode exact → dump на B → compare --mode strip-nulls с дампом «до»). Стенд не запускать.

  • Step 4: [замок] Run — PASS + весь perf/tests -m "not stand".

  • Step 5: Commit → perf(analysis): compare_responses — снимок и сравнение ответов тяжёлых GET + Co-Authored-By.


Task 8: Полный прогон фазы A, проверка инвариантов, передача на PG15

Исполнитель: контроллер (команды; правки — новой задачей-исправлением у haiku/sonnet).

  • Step 1: [замок] Полный бэк cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore → только 3 известных падения.
  • Step 2: [замок] perf cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests → PASS.
  • Step 3: Инварианты git diff origin/test -- api/src/services/export_service/ api/src/api/v1/websocket.py api/alembic/ → пусто. git diff origin/test -- api/src/repository/ — у старых методов меняется только вынос FROM-части, текст SQL эквивалентен. Расхождение — находка O-n, исправление отдельной задачей.
  • Step 4: Передать главному контроллеру на проверку PG15. В ledger и в отчёте SDD-контроллера: SHA ветки A, новый SQL (json_build_object(...)::text для v_form_view, v_summary_view, v_svod2_go/rf, шести функций проектов), просьба проверить на dfip-pg15:55434 (PG 15.12). Не блокирует открытие PR A; блокирует merge — пункт чек-листа PR A «PG15 — проверяет контроллер».

Task 9: Финальное ревью ветки A

Исполнитель: opus (superpowers:requesting-code-review, ревью всей ветки git diff origin/test...HEAD).

  • Step 1: Ревьюеру передать: спеку (openspec/changes/aurora-1848-sheet-json/), этот план, раздел Global Constraints (включая замок), Review Focus; диапазон origin/test...feat/233-aurora-1848-sheet-json-no-pydantic.
  • Step 2: Находки C-n/I-n/M-n → ledger. Critical и Important — исправить (задача-исправление: haiku, если правка однозначна, иначе sonnet), [замок] перепрогон затронутых тестов, коммит fix(...). Minor — fixed, deferred или rejected <причина>.
  • Step 3: Обновить таблицу ## Findings в ledger; рулинги — в design.md → ## Decisions made without discussion (коммит docs(openspec): ...).

Task 10: PR A

Исполнитель: контроллер.

  • Step 1: git push -u origin feat/233-aurora-1848-sheet-json-no-pydantic.
  • Step 2: tea pulls create --base test --head feat/233-aurora-1848-sheet-json-no-pydantic --title "AURORA-1848: тяжёлые GET — готовый JSON из Postgres без pydantic-модели на строку (A)" --description "<описание>". Описание:
    • Part of #233 (issue закрывает PR B), Jira AURORA-1848;
    • что сделано (лист, своды 1 и 2, отдельный коммит — шесть GET проектов; api_split.py wire_bytes; compare_responses.py); ответ по смыслу тот же, байты могут отличаться (пробелы, запись чисел);
    • тесты: unit, интеграционные эквивалентности со старым путём на 9 эндпоинтах, OpenAPI, полный pytest (3 известных падения test);
    • чек-лист до merge: сравнение ответов compare --mode exact на стенде; PG15 (контроллер); A ≤ 1,05 × до по api_split.py — справочно, не условие мержа;
    • строка «Замер до/после — добавит контроллер после прогона на стенде»;
    • «Rulings I made» (R-n), таблица находок (все маркеры со статусами);
    • связь: AURORA-1846 (#232) — общий хунк db_session в conftest; PR B стеком на этой ветке;
    • последняя строка 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • Step 3: Номер PR A — в ledger. Сразу переходить к фазе B, без остановки.

Фаза B — без null-полей в data листа и сводов + фронт

Ветка feat/233-aurora-1848-sheet-json-no-pydantic-b стеком на ветке A, worktree /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848-b.

Task 11: Ветка B, ветка #223 внутри B, тесты updateCells на отсутствующие ключи

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: web/src/components/RealtimeTable/utils/cellUtils.test.js (файл приходит из #223)
  • Modify (только если тест падает на версии #223): web/src/components/RealtimeTable/utils/cellUtils.js

Interfaces:

  • Consumes: updateCells(data, newRows) и deepFreeze из cellUtils.test.js ветки #223 (origin/feat/212-aurora-1838-ws-immutable-update).

  • Step 1: Worktree B (из основного checkout /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing): git fetch origin && git worktree add .claude/worktrees/aurora-1848-b -b feat/233-aurora-1848-sheet-json-no-pydantic-b feat/233-aurora-1848-sheet-json-no-pydantic; ln -s /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules .claude/worktrees/aurora-1848-b/web/node_modules; [замок] cd .claude/worktrees/aurora-1848-b && codegraph init.

  • Step 2: Влить #223 git merge --no-ff origin/feat/212-aurora-1838-ws-immutable-update -m "Merge AURORA-1838 (#223) в ветку B: updateCells вставляет отсутствующие ключи" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>". Конфликтов быть не должно (A не трогает web/). После merge #223 и A в test ветка B перебазируется на test, и коммиты #223 уйдут из диффа (пишется в PR B).

  • Step 3: Тесты — дописать в конец cellUtils.test.js:

const row = (data) => ({ row_type: 'INPUT', depth: 3, sort_order: 1, data });

test('AURORA-1848: нет блока q1 — блок с полем вставляется, остальное сохраняется', () => {
	const data = deepFreeze([row({ line_id: 7, header: { name: 'X' } })]);
	const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]);
	assert.equal(out[0].data.q1.m1, 5);
	assert.deepEqual(out[0].data.header, { name: 'X' });
	assert.equal(out[0].data.line_id, 7);
});

test('AURORA-1848: блок q1 без поля m1 — поле вставляется, остальные поля q1 сохраняются', () => {
	const data = deepFreeze([row({ line_id: 7, q1: { m2: 3 } })]);
	const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]);
	assert.deepEqual(out[0].data.q1, { m2: 3, m1: 5 });
});

test('AURORA-1848: очистка — null записывается явно', () => {
	const data = deepFreeze([row({ line_id: 7, q1: { m1: 5 } })]);
	const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: null } }]]);
	assert.ok('m1' in out[0].data.q1);
	assert.equal(out[0].data.q1.m1, null);
});

test('AURORA-1848: отсутствующий скалярный ключ верхнего уровня data вставляется', () => {
	const data = deepFreeze([row({ header: { name: 'X' } })]);
	const out = updateCells(data, [['INPUT', 3, 1, { line_id: 9 }]]);
	assert.equal(out[0].data.line_id, 9);
});
  • Step 4: [замок] Красный шаг на версии test: git show origin/test:web/src/components/RealtimeTable/utils/cellUtils.js > web/src/components/RealtimeTable/utils/cellUtils.js && (cd web && node --test src/components/RealtimeTable/utils/cellUtils.test.js) → новые тесты 1, 2 и 4 FAIL (на test правка по отсутствующему ключу теряется). Вернуть файл: git checkout HEAD -- web/src/components/RealtimeTable/utils/cellUtils.js.
  • Step 5: [замок] Зелёный шаг: cd web && node --test src/components/RealtimeTable/utils/*.test.js → PASS. Если какой-то из четырёх тестов падает на версии #223 — минимальная правка cellUtils.js в B (D5.1), рулинг R-n в design.md.
  • Step 6: Commit git add web/src/components/RealtimeTable/utils/cellUtils.test.js (+ cellUtils.js, если правился) → test(realtime): правка соавтора по ключу, которого нет в строке (AURORA-1848) + Co-Authored-By.

Task 12: Сообщение cell_updated всегда содержит value

Исполнитель: haiku (код готов в плане).

Files:

  • Create: web/src/components/RealtimeTable/utils/messageUtils.js
  • Test: web/src/components/RealtimeTable/utils/messageUtils.test.js
  • Modify: web/src/components/RealtimeTable/contexts/RealtimeContext.js:304-330 (updateCell)

Interfaces:

  • Produces: buildCellUpdatedMessage({ lineId, lineIdCode, column, value }) -> { event: 'cell_updated', data: { line_id, line_id_code, column, value } }, где value === undefined → null.

  • Step 1: Write the failing test

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCellUpdatedMessage } from './messageUtils.js';

const msg = (value) => buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value });

test('buildCellUpdatedMessage: undefined → null, ключ value есть после JSON.stringify', () => {
	const m = msg(undefined);
	assert.deepEqual(m, { event: 'cell_updated', data: { line_id: 1, line_id_code: '0.1', column: 'q1.m1', value: null } });
	assert.match(JSON.stringify(m), /"value":null/);
});

test('buildCellUpdatedMessage: прочие значения не меняются', () => {
	for (const v of [null, 0, '', '1.5', 42, 'текст']) {
		assert.equal(msg(v).data.value, v);
	}
});
  • Step 2: [замок] Run — FAIL cd web && node --test src/components/RealtimeTable/utils/messageUtils.test.js → Cannot find module './messageUtils.js'.
  • Step 3: Implement messageUtils.js (одна экспортируемая функция по Interfaces, комментарий: «value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2»). В RealtimeContext.updateCell литерал message заменить на buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value }); импорт import { buildCellUpdatedMessage } from '../utils/messageUtils';.
  • Step 4: [замок] Run — PASS все utils/*.test.js; npx biome check src/components/RealtimeTable/utils/messageUtils.js src/components/RealtimeTable/utils/messageUtils.test.js src/components/RealtimeTable/contexts/RealtimeContext.js.
  • Step 5: Commit → fix(realtime): пустая правка уходит как value: null, а не без ключа (AURORA-1848) + Co-Authored-By.

Task 13: Бэк — jsonb_strip_nulls для листа и сводов

Исполнитель: sonnet (интеграция: смена контракта, правка существующего smoke-теста).

Files:

  • Modify: api/src/repository/row_json.py (strip_nulls)
  • Modify: api/src/repository/sheet_repository.py, summary_repository.py (*_json + strip_nulls)
  • Modify: api/src/services/sheet_service.py, summary_service.py (проброс strip_nulls)
  • Modify: api/src/api/v1/forms.py, summary.py (вызов со strip_nulls=True)
  • Modify: api/tests/unit/test_json_list_response.py, api/tests/integration/test_heavy_get_json.py
  • Modify (если падает): api/tests/integration/test_summary_api_smoke.py::test_summary2_sheets_smoke

Interfaces:

  • Consumes: Task 2–5.

  • Produces: row_json_select(row_type="row_type", depth="depth", strip_nulls: bool = False) — при True выражение data → jsonb_strip_nulls(data); strip_nulls: bool = False последним параметром у SheetRepository.get_json, SummaryRepository.get_svod1_json/get_svod2_json, SheetService.get_json, SummaryService.get_svod1_json/get_svod2_json. Методы проектов не меняются.

  • Step 1: Write the failing tests

    • unit: test_row_json_select_strip_nulls — row_json_select(strip_nulls=True) содержит "'data', jsonb_strip_nulls(data), " и не содержит "'data', data,"; вызов без параметра — прежний текст.
    • интеграционные: в test_heavy_get_json.py добавить _strip_nulls(value) (та же семантика, что strip_nulls в Task 7: поля объектов с None удаляются рекурсивно, None в списках и {} остаются) и _has_null_field(value) -> bool; тело expected_sheet заменить на [{**r, "data": _strip_nulls(r["data"])} for r in rows]. Новые тесты:
      • test_sheet_and_svod_have_no_null_fields (лист 4/AHR, свод 1 MAIN, свод 2 GO): ни у одной строки _has_null_field(row["data"]); ключи строки — все четыре всегда;
      • test_project_report_keeps_nulls: GET /api/v1/projects/{pid}/report/{YEAR}/LIMIT нового проекта — хотя бы у одной строки _has_null_field(row["data"]);
      • test_patch_cell_null_is_explicit: PATCH /api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary с {"line_id": 1, "column": "plan.q1", "value": None} → 200, в result есть строка с data["plan"] содержащим ключ "q1" со значением None.
  • Step 2: [замок] Run — FAIL: unit-тест и сравнения листа/сводов (expected_sheet теперь чистит null, а ответ ещё с null), test_sheet_and_svod_have_no_null_fields. test_project_report_keeps_nulls и test_patch_cell_null_is_explicit — PASS уже сейчас (страховка).

  • Step 3: Implement по Interfaces; роутеры get_sheet, get_summary, get_summary2 вызывают *_json(..., strip_nulls=True).

  • Step 4: [замок] Run test_heavy_get_json.py, test_json_list_response.py, test_summary_api_smoke.py, test_forms_api_smoke.py, test_projects_api_smoke.py. Если test_summary2_sheets_smoke падает на отсутствии ключей с null — ослабить по D6: проверять ключи header и блоки data только там, где у строки эталона старого пути значение не null (или сравнивать набор ключей с strip_nulls-эталоном); это ожидаемое изменение контракта, отметить для описания PR B.

  • Step 5: Commit → perf(api): лист и своды без null-полей в data (jsonb_strip_nulls) + Co-Authored-By.


Task 14: Потребители GET в perf/perfkit — без падения на отсутствующих ключах

Исполнитель: haiku (код готов в плане).

Files:

  • Modify: perf/perfkit/gen/grow.py:63-67
  • Test: perf/tests/test_gen_grow.py

Interfaces:

  • Produces: form4_ids(rows: list[dict]) -> tuple[list[int], list[int]] — (vsp_ids, project_ids) в perfkit.gen.grow, используется в grow_form4_sheet.

  • Step 1: Write the failing test

from perfkit.gen.grow import form4_ids


def test_form4_ids_tolerates_absent_keys():
    rows = [
        {"row_type": "INPUT", "data": {"header": {"vsp_id": 3}}},
        {"row_type": "INPUT", "data": {"header": {}}},
        {"row_type": "INPUT", "data": {}},
        {"row_type": "ITEM", "data": {"header": {"project_id": 11}}},
        {"row_type": "ITEM", "data": {"header": {}}},
        {"row_type": "ITEM", "data": {}},
    ]
    assert form4_ids(rows) == ([3], [11])
  • Step 2: [замок] Run — FAIL cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_gen_grow.py → ImportError.
  • Step 3: Implement form4_ids через (r.get("data") or {}).get("header") or {} и .get(...), отбрасывая None; grow_form4_sheet вызывает её вместо двух выражений в строках 66–67. Обращения в run.py:248, 467, 505 уже защищены .get в условии — не трогать.
  • Step 4: [замок] Run — PASS + весь perf/tests -m "not stand".
  • Step 5: Commit → perf(gen): grow не падает на ответах листа без null-полей + Co-Authored-By.

Task 15: Аудит null / undefined на фронте

Исполнитель: sonnet (поиск и суждение; правка — только если найдено место, где отсутствующий ключ ведёт себя иначе, чем null).

Files:

  • Read: web/src (страницы листа, отчёта проекта, свода 1 и 2)

  • Modify (только при находке): соответствующий файл + тест в *.test.js рядом (чистую функцию вынести в .js, как в Task 12)

  • Step 1: Поиск по web/src на ветке B: === null, !== null, == null, typeof … === 'object', in по data, hasOwnProperty, Object.keys/entries/values по data или блокам data. Повторно проверить места из D5.3 (validation.js:94-209, Cell.jsx:149-156, tableColumns.jsx:88, SummaryTable.jsx:25, 71-72, Svod2Page.jsx:38-39, 354-365, SvodPage.jsx:53-54, RealtimeTable.jsx:48-57, rowUtils.js, useRealtimeData.js:49-50).

  • Step 2: Для каждого найденного места — вывод «обрабатывает undefined как null: да/нет» со строкой кода. Перечень — в отчёт задачи и в ledger (уходит в описание PR B).

  • Step 3: При «нет»: красный тест на чистую функцию с отсутствующим ключом → минимальная правка → [замок] node --test и npx biome check <файлы> → коммит fix(...). При отсутствии находок — коммита нет.


Task 16: Полный прогон фазы B, сборка, передача на PG15

Исполнитель: контроллер.

  • Step 1: [замок] Полный бэк (как Task 8 Step 1) → только 3 известных падения.
  • Step 2: [замок] perf -m "not stand" → PASS.
  • Step 3: [замок] Фронт: cd web && node --test src/components/RealtimeTable/utils/*.test.js; npx biome check по всем изменённым в B файлам web/src (git diff --name-only feat/233-aurora-1848-sheet-json-no-pydantic... -- web/src); npm run build → успех.
  • Step 4: Инварианты: git diff feat/233-aurora-1848-sheet-json-no-pydantic... -- api/src/services/export_service/ api/src/api/v1/websocket.py api/alembic/ → пусто; у методов проектов нет strip_nulls.
  • Step 5: Передать главному контроллеру на проверку PG15: SHA ветки B, новый SQL — jsonb_strip_nulls(data) внутри json_build_object(...)::text для листа и сводов; семантика на PG 15.12 должна совпадать с _strip_nulls теста (null в массивах и {} остаются). Не блокирует PR B; блокирует merge.

Task 17: Финальное ревью ветки B

Исполнитель: opus.

  • Step 1: Диапазон ревью — коммиты B без коммитов #223: git log --no-merges -p feat/233-aurora-1848-sheet-json-no-pydantic..HEAD --not origin/feat/212-aurora-1838-ws-immutable-update. Ревьюеру передать спеку (оба specs/), этот план, Global Constraints (с замком), Review Focus, результат аудита Task 15.
  • Step 2–3: Как Task 9 Step 2–3 (находки C-n/I-n/M-n продолжают сквозную нумерацию фичи).

Task 18: PR B

Исполнитель: контроллер.

  • Step 1: git push -u origin feat/233-aurora-1848-sheet-json-no-pydantic-b.
  • Step 2: tea pulls create --base feat/233-aurora-1848-sheet-json-no-pydantic --head feat/233-aurora-1848-sheet-json-no-pydantic-b --title "AURORA-1848: лист и своды без null-полей + фронт (B)" --description "<описание>". Описание:
    • первой строкой: «Не мержить до merge PR A (#<номер A>) и PR #223 (AURORA-1838). Ветка содержит merge-коммит #223; после их merge ветка перебазируется на test, коммиты #223 уйдут из диффа.»;
    • Closes #233, Jira AURORA-1848;
    • BREAKING для потребителя, который отличает «ключ есть и равен null» от «ключа нет»: в data листа и сводов 1/2 нет null-полей (на любой глубине, {} и null в массивах остаются); отчёты проектов, ответы путей записи и WebSocket отдают null явно; выгрузка Excel не меняется;
    • фронт: тесты updateCells на отсутствующие ключи (версия #223 достаточна / правки), value: null вместо пропуска в cell_updated, результат аудита Task 15;
    • изменение test_summary2_sheets_smoke (если было) — как ожидаемое изменение контракта;
    • TanStack Table в dev-сборке пишет console.warn для undefined во вложенном accessorKey — не исправляем, в prod нет;
    • чек-лист ручной проверки (D6): на FORM_2/AHR второй пользователь заполняет пустую ячейку — первый видит значение без перезагрузки; то же с очисткой; вход в пустую ячейку и выход без ввода — без ошибки; свод 2: пустые ячейки «—», поиск работает; отчёт проекта не изменился; сценарий соавтора на стенде PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1, page_ws_updates > 0 (выполняет контроллер);
    • до merge: compare --mode strip-nulls на стенде, PG15 (контроллер);
    • строка «Замер до/после — добавит контроллер после прогона на стенде»; пороги (свод 2 GO −30 %, FORM_2/AHR −10 % и меньший распакованный размер) не условие мержа, недобор — follow-up;
    • «Rulings I made», таблица находок; openspec archive aurora-1848-sheet-json — только после merge B;
    • последняя строка 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • Step 3: Номер PR B — в ledger и комментарием в PR A («стек: PR B #<номер>»).

Task 19: Точка ожидания — замеры и сравнение ответов (главный контроллер)

Исполнитель: главный контроллер после основных ночных замеров. SDD-контроллер здесь останавливается: отчёт с SHA A и B, номерами PR, таблицей находок и пометкой «ожидает замеров». PR A и B уже открыты без цифр.

  • Step 1: Данные: масштаб 1, одна БД perf для «до» и «после» (восстановить из дампа s1 перед серией); база «до» — test на момент замера; если до замера «после» в test влилась миграция или правка SQL листа (AURORA-1847, 1846, 1844) — «до» переснимается на новой базе. Не параллельно с другими замерами и тестами на perf-pg.
  • Step 2: Корректность (обязательна до merge): compare_responses.py dump на базовом коммите → на A → compare --mode exact → dump на B → compare --mode strip-nulls (с дампом «до»). Код возврата 0.
  • Step 3: Время: cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/<tag>-1848-<before|A|B>/analysis на трёх коммитах, API перезапускается на каждом. Пороги: свод 2 GO B ≤ 0,7 × до; FORM_2/AHR B ≤ 0,9 × до и bytes B < до; все запросы A ≤ 1,05 × до; справочно python_share, wire_bytes.
  • Step 4: Цифры и результат сравнения — в описания PR A и B. Недобор порога — находка deferred (уходит в follow-up issue при закрытии); корректность не прошла — PR не мержится, исправление в ветке.
  • Step 5: Ручной сценарий соавтора на стенде (Task 18) — результат в PR B.