# 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', , 'depth', , '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 `. - `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.` — minor ревьюера задачи n; `C-n`/`I-n`/`M-n` — финальное ревью; `R-n` — рулинг контроллера (рядом номер строки в `design.md`, напр. `R-3 (design #24)`); `O-n` — наблюдение контроллера; `H-n` — замечание человека из PR. Статус каждой: `fixed ` / `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; одинаковая правка сольётся без конфликта): ```python @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** ```python """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** ```bash 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 " ``` --- ### 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** ```python 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`: ```python """Ответ тяжёлых 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`: ```python """Строка ответа тяжёлого 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`; импорты — в шапку модуля) ```python 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`: ```python 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** ```python 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=, 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.` на `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** ```python 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=`, если `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`, распакованное) — в `/.json.gz`, статусы — в `/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-` под `rf`, его `-sections` вариант, `sheet-FORM_1-AHR-Support-` под `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 "`. Конфликтов быть не должно (A не трогает `web/`). После merge #223 и A в `test` ветка B перебазируется на `test`, и коммиты #223 уйдут из диффа (пишется в PR B). - [ ] **Step 3: Тесты** — дописать в конец `cellUtils.test.js`: ```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** ```js 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`. - [ ] **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** ```python 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/-1848-/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.