Две фазы: 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>
76 KiB
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
- Текст с кавычками, обратным слэшем, переводом строки и кириллицей в
data(обоснование, наименование). Postgres экранирует иначе, чемjson.dumps; ожидается, что послеjson.loadsзначение то же. Тестtest_sheet_text_escaping_matches_old_path(Task 3) и unit-тест хелпера (Task 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). - Ответ в обход
response_modelперестаёт проверяться: незаметно уходит старый путь, теряетсяX-DB-Time-Msили обёртка. Ожидается: GET не вызывает старые методы, заголовок есть на всех 9 эндпоинтах. Тестыtest_*_does_not_use_old_path(Task 3, 4, 5),get_okпроверяет заголовок и ключи. - Очистка
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). - Пустая ячейка без ключа на фронте: выход из редактирования без ввода отправляет сообщение без
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 ignoreExpected: падают ровно 3 известных теста (см. Global Constraints);test_openapi_heavy_get_schema_unchangedPASS. Если он падает только из-за имени компоненты — заменить литерал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-список безFROMasync 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)}"), параметры прежние. Новые методы — те же параметры, SQLf"{row_json_select()} {self._svod1_source()}"и для свода 2select = 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]— monkeypatchProjectRepository.<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_pathFAIL на старом коде. -
Step 3: Implement репозиторий → сервис → роутеры по образцу Task 3/4 (декоратор с
response_model,-> Response, без параметраresponse,list_json_response(rows, headers={"X-DB-Time-Ms": ...}), логdb_timeс прежними полями, включаяhandlerget_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_nullsPG 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: манифест из двух листов (rfFORM_2/AHR без direction,sspFORM_1/AHRSupport) и одного отчёта → есть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.pywire_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§ions=plan,contract_summaryс{"line_id": 1, "column": "plan.q1", "value": None}→ 200, вresultесть строка сdata["plan"]содержащим ключ"q1"со значениемNone.
- unit:
-
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).
- первой строкой: «Не мержить до merge PR A (#<номер A>) и PR #223 (AURORA-1838). Ветка содержит merge-коммит #223; после их merge ветка перебазируется на
- 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 GOB ≤ 0,7 × до; FORM_2/AHRB ≤ 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.