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

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

913 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AURORA-1848: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей. План реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Issue:** Gitea #233, Jira AURORA-1848 (эпик AURORA-1834).
**Ветки и фазы:**
- **Фаза A** (Task 1–10) — ветка `feat/233-aurora-1848-sheet-json-no-pydantic` (от `test`), worktree `.claude/worktrees/aurora-1848`, PR в `test`.
- **Фаза B** (Task 11–19) — ветка `feat/233-aurora-1848-sheet-json-no-pydantic-b` стеком на ветке A, worktree `.claude/worktrees/aurora-1848-b`, PR в ветку A. Между фазами остановки для человека нет: после открытия PR A контроллер сразу начинает B. **PR B не мержится раньше PR A и PR #223 (AURORA-1838)** — это пишется в описании PR B.
**Goal:** GET листа, сводов 1 и 2 и отчётов проектов отдают тело, собранное из текста строк Postgres (`json_build_object(...)::text`), без pydantic-модели на строку (A); у листа и сводов в `data` нет null-полей, фронт не теряет правки соавторов (B).
**Architecture:** Рядом со старыми методами репозиториев и сервисов (их использует выгрузка Excel) появляются методы `*_json`, возвращающие `list[str]` — по готовой JSON-строке на ряд. Роутеры склеивают строки в обёртку `{"success","message","result","count"}` хелпером `list_json_response` и возвращают `Response` с `X-DB-Time-Ms`; `response_model` остаётся в декораторе ради OpenAPI. В B выражение `data` заменяется на `jsonb_strip_nulls(data)` для листа и сводов, фронт закрепляет вставку отсутствующих ключей (`updateCells` из #223) и отправку `value: null` вместо пропуска.
**Tech Stack:** Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, PostgreSQL 16 (тесты) / 15.12 (контур), pytest; React 18 (JSX), `node:test`, Biome, Rsbuild; httpx для `perf/`.
**Spec:** `openspec/changes/aurora-1848-sheet-json/` (`proposal.md`, `design.md`, `specs/sheet-read-api/spec.md`, `specs/realtime-sheet-updates/spec.md`). Спека — единственный источник истины при рулингах; ссылки вида «D3» — разделы `design.md`.
## Global Constraints
**Замок ночного прогона (обязательно).** С ~01:00 на машине идут замеры производительности; любая нагрузка на CPU их портит.
- Перед КАЖДЫМ шагом, помеченным **[замок]** (pytest, alembic, `node --test`, `npx biome check`, `npm run build`, `codegraph init`, docker-команды, запуск API/фронта), ждать, пока замок существует:
`L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done`
Команду ожидания запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго — повторять проверку.
- Контроллер передаёт это правило в бриф каждого исполнителя и ревьюера (сабагенты не видят этот раздел, если его не процитировать).
- Чтение и написание кода, git-коммиты — можно при замке.
- Стенд `perf/` (БД `perf` в `perf-pg`, порты 8000/8080/8081/8082) — **не запускать вообще**: замеры делает главный контроллер (Task 19).
**Окружение тестов.**
- `PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin` — pytest, python. Свой venv не создавать.
- `DBURL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848` — своя БД в контейнере `perf-pg` (PG16). БД `perf` не трогать никогда. Пароли `OPENBAO__TEST_*` по умолчанию `admin123`.
- Полный набор бэка: `cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore` (с `--init_db` — только на только что созданной БД, см. Task 1). Зелёным считается весь набор.
- Исходное состояние `test`: ровно 3 падения не наши — `test_backend_calls_form3_add_project_and_add_line_functions` и два теста в `test_export_service_query_params.py`. Новых падений быть не должно.
- Тесты `perf/`: `cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests`.
- Фронт: `web/node_modules` — симлинк на `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules`, `npm install` не запускать. Команды: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`.
- PG 15.12 (`dfip-pg15:55434`) проверяет главный контроллер (Task 8, Task 16).
- Карта кода: `mcp__codegraph__codegraph_explore` с `projectPath` = абсолютный путь текущего worktree. Пустой ответ — один раз отметить `O-n` в ledger и дальше читать файлы.
**Инварианты кода (из спеки).**
- Старые методы `SheetRepository.get`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, `SheetService.get`, `SummaryService.get_svod*_rows`, `ProjectService.get_*_rows` не меняют сигнатуру и результат; единственная допустимая правка — вынос FROM-части в приватный метод с эквивалентным текстом SQL (D2).
- Дифф `api/src/services/export_service/`, `api/src/api/v1/websocket.py`, `api/alembic/` против `test` — пустой. Миграций нет.
- Строка ответа: `json_build_object('row_type', <row_type>, 'depth', <depth>, 'data', <data>, 'sort_order', sort_order)::text`, порядок строк — порядок функции, без `ORDER BY` (D1).
- Обёртка: `{"success":true,"message":null,"result":[...],"count":N}`, `media_type="application/json"` без `charset` (D3).
- `X-DB-Time-Ms` ставится на возвращаемый `Response` (`headers=`), формат `f"{db_ms:.2f}"`, окно замера прежнее, лог `db_time` с прежними полями. Параметр `response: Response` из сигнатур убрать (D3).
- `response_model=BaseListResponse[SheetResponse]` — в декораторе `@router.get(...)`, аннотация возврата `-> Response` (D3).
- B: `jsonb_strip_nulls` только у листа и сводов 1/2; отчёты и сводные проектов отдают null как раньше; ответы путей записи и WebSocket не меняются (D4, решение человека 3).
**Коммиты и PR.**
- Стиль репозитория (`perf(...)`, `fix(...)`, `test(...)`, `docs(...)`), по-русски, `git add` конкретных файлов (не `-A`). Последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- `git stash` без тега не использовать. Не мержить. Не пушить в `test`/`main`. `.bounded` не создавать (задача architectural, правки разрешает этот план).
- PR: `tea pulls create --base <база> --head <ветка> --title "AURORA-1848: …" --description "…"`. В описании: `Closes #233` (только PR B; в PR A — `Part of #233`), ключ AURORA-1848, что сделано, тесты, чек-лист ручной проверки, строка «Замер до/после — добавит контроллер после прогона на стенде», раздел «Rulings I made» (маркеры `R-n`), таблица находок, последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
**Маркировка находок.** Каждая находка и решение несёт маркер, присвоенный один раз: `T<n>.<k>` — minor ревьюера задачи n; `C-n`/`I-n`/`M-n` — финальное ревью; `R-n` — рулинг контроллера (рядом номер строки в `design.md`, напр. `R-3 (design #24)`); `O-n` — наблюдение контроллера; `H-n` — замечание человека из PR. Статус каждой: `fixed <commit>` / `deferred` / `rejected <причина>`. Таблица `## Findings` (`| Маркер | Суть | Статус |`) — в конце ledger, обновляется при каждой правке; та же таблица — в описании PR. Каждый рулинг без обсуждения с человеком дописывается в `openspec/changes/aurora-1848-sheet-json/design.md` → `## Decisions made without discussion` в той же ветке, где он принят.
## Review Focus
1. **Текст с кавычками, обратным слэшем, переводом строки и кириллицей в `data`** (обоснование, наименование). Postgres экранирует иначе, чем `json.dumps`; ожидается, что после `json.loads` значение то же. Тест `test_sheet_text_escaping_matches_old_path` (Task 3) и unit-тест хелпера (Task 2).
2. **Пустой результат** (пользователь без доступа к запрошенным `org_ids`, свод без строк): тело `{"success":true,"message":null,"result":[],"count":0}` — валидный JSON, а не `"result":[]`-обрывок. Тесты `test_list_json_response_empty` (Task 2), `test_svod2_foreign_org_ids_empty_body` (Task 4).
3. **Ответ в обход `response_model` перестаёт проверяться**: незаметно уходит старый путь, теряется `X-DB-Time-Ms` или обёртка. Ожидается: GET не вызывает старые методы, заголовок есть на всех 9 эндпоинтах. Тесты `test_*_does_not_use_old_path` (Task 3, 4, 5), `get_ok` проверяет заголовок и ключи.
4. **Очистка `null` затрагивает не только `data`** (обёртку, `sort_order`, ответы путей записи, отчёты проектов) или удаляет `{}` и `null` в массивах. Ожидается: чистится только `data` листа и сводов, `{}` и `null`-элементы массивов остаются, PATCH отдаёт `null` явно. Тесты `test_strip_response_*` (Task 7), `test_patch_cell_null_is_explicit` и `test_project_report_keeps_nulls` (Task 13).
5. **Пустая ячейка без ключа на фронте**: выход из редактирования без ввода отправляет сообщение без `value`, и сервер падает на `event_data["value"]`. Ожидается `"value": null`. Тест `buildCellUpdatedMessage: undefined → null` (Task 12).
---
# Фаза A — готовый JSON без pydantic-модели на строку
Ветка `feat/233-aurora-1848-sheet-json-no-pydantic`, worktree `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848`.
### Task 1: Окружение тестов, фикстура `db_session`, снимок схемы OpenAPI
**Исполнитель:** haiku (код готов в плане).
**Files:**
- Modify: `api/tests/integration/conftest.py:15-18` (хунк AURORA-1846, если его ещё нет)
- Create: `api/tests/integration/test_heavy_get_json.py`
**Interfaces:**
- Produces: фикстура `db_session` (та же сессия, что у `client`); в `test_heavy_get_json.py` — `ADMIN`, `ISP`, `YEAR`, `HEAVY_GET_PATHS`, `OPENAPI_REF`, `old_rows(client, coro_factory) -> list[dict]`, `expected_sheet(rows: list[dict]) -> list[dict]`, `get_ok(client, url, headers, params=None) -> list[dict]`. Все последующие интеграционные тесты фазы A и B дописываются в этот файл и пользуются этими именами.
- [ ] **Step 1: Фикстура `db_session`.** Если `grep -q "def db_session" api/tests/integration/conftest.py` ничего не находит — внести ровно этот хунк (тот же, что в PR #232; одинаковая правка сольётся без конфликта):
```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 <noreply@anthropic.com>"
```
---
### Task 2: Хелпер ответа и SQL-выражение строки
**Исполнитель:** haiku (код готов в плане).
**Files:**
- Create: `api/src/api/v1/json_response.py`
- Create: `api/src/repository/row_json.py`
- Test: `api/tests/unit/test_json_list_response.py`
**Interfaces:**
- Produces:
- `list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> fastapi.Response` (`src.api.v1.json_response`)
- `ROW_COLUMNS: str = "row_type, depth, sort_order, data"` (`src.repository.row_json`)
- `row_json_select(row_type: str = "row_type", depth: str = "depth") -> str` — SELECT-список без `FROM`
- `async fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]`
- [ ] **Step 1: Write the failing tests**
```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=<list|None>, user=ADMIN)`; `result == old` (без `expected_sheet`: отчёты не чистятся и в B).
- `test_rf_rollup_matches_old_path`: `GET /api/v1/rf-rollup/1/{YEAR}` ↔ `get_rf_rollup_rows(branch_id=1, year=YEAR, sections=None, user=ADMIN)`.
- `test_project_summary_matches_old_path`: `GET /api/v1/projects/{pid}/summary` ↔ `get_project_summary_rows(project_id=pid, user=ADMIN)`.
- `test_projects_summaries_match_old_path[kind]` для `plan|fact|detail`: `GET /api/v1/projects/{kind}-summary?year={YEAR}` ↔ `get_{kind}_summary_rows(year=YEAR, branch_ids=None, user=ADMIN)`.
- `test_projects_get_does_not_use_old_path[method,url]` — monkeypatch `ProjectRepository.<method>` на `boom` для каждого из шести `*_rows`, GET → 200.
- Характеризующие тесты ошибок (пишутся и прогоняются **до** правки роутеров, должны быть зелёными и до, и после): `test_project_report_not_found_unchanged` — `GET .../report/2099/LIMIT` у созданного проекта, assert статуса и `r.json()` равного зафиксированному на старом коде литералу; `test_rf_rollup_foreign_branch_unchanged` — `isp_tokens`, ветка не из оргединиц `isp1` (подобрать в `fixture.sql`), так же статус и тело литералом.
- [ ] **Step 2: [замок] Run — характеризующие тесты PASS, `test_projects_get_does_not_use_old_path` FAIL** на старом коде.
- [ ] **Step 3: Implement** репозиторий → сервис → роутеры по образцу Task 3/4 (декоратор с `response_model`, `-> Response`, без параметра `response`, `list_json_response(rows, headers={"X-DB-Time-Ms": ...})`, лог `db_time` с прежними полями, включая `handler` `get_project_report` у `get_rf_rollup` как сейчас). `_rows_to_payload` остаётся для правок FORM_3.
- [ ] **Step 4: [замок] Run — PASS** `test_heavy_get_json.py` + `tests/integration/test_projects_api_smoke.py`.
- [ ] **Step 5: Commit отдельным коммитом** (файлы проектов и тест) → `perf(api): отчёты и сводные проектов отдают готовый JSON из Postgres` + Co-Authored-By.
---
### Task 6: `api_split.py` — размер на проводе
**Исполнитель:** haiku (код готов в плане).
**Files:**
- Modify: `perf/analysis/api_split.py` (вынос цикла замера в `measure`, поле `wire_bytes`, колонка в `.md`, docstring)
- Test: `perf/tests/test_api_split.py`
**Interfaces:**
- Produces: `measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict` с ключами `rows, total_ms, db_ms, bytes, wire_bytes, content_encoding` (медианы, как сейчас; первый запрос — прогрев).
- [ ] **Step 1: Write the failing test**
```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=<d>`, если `direction` не `None`; имя `sheet-{form_type}-{sheet}-{direction or 'none'}-{form_id}`; плюс один вариант FORM_2/AHR (`owner == "rf"`) с `sections=plan,q1`, имя с суффиксом `-sections`;
- год сводов — `year` листа FORM_2/AHR роли `rf` (как в `api_split.py`); `svod1-{FORM_1..FORM_4,MAIN}` и `svod2-{GO,RF}` под `dfip`;
- отчёты: каждый `project_reports[i]` под `rf` (`report-{project_id}-{year}-{report_type}`); по каждому уникальному `project_id` — `/api/v1/projects/{id}/summary` под `rf` (`project-summary-{id}`); `plan|fact|detail-summary?year=` под `dfip` (`projects-{kind}-summary`);
- `rf-rollup` не снимается: в манифесте нет `branch_id` (он покрыт интеграционным тестом Task 5).
`dump`: логин каждой роли (`man["users"][role]`), GET каждого запроса, тело (`r.content`, распакованное) — в `<out>/<name>.json.gz`, статусы — в `<out>/index.json` (`{name: status}`). `compare`: статусы равны; для `mode == "strip-nulls"` и `is_stripped(name)` сравнивается `json.loads(after) == strip_response(json.loads(before))`, иначе точное равенство; отсутствующий в `after` файл — расхождение. Печать: `совпало N из M`, затем по строке на расхождение.
- [ ] **Step 1: Write the failing tests** (`sys.path.insert(...)/"analysis"` как в `test_analysis.py`):
- `test_strip_nulls_pg_semantics`: `strip_nulls({"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}})` == `{"b": {}, "d": [None, {"f": 1}], "g": {}}`.
- `test_strip_response_keeps_wrapper_and_sort_order`: тело `{"success": True, "message": None, "result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}], "count": 1}` → `message` и `sort_order` остаются `None`, `data == {"y": 1}`.
- `test_first_diff_path`: `first_diff({"result": [{"data": {"q1": {"m1": 1}}}]}, {"result": [{"data": {"q1": {"m1": 2}}}]})` начинается с `"$.result[0].data.q1.m1"`; для равных — `None`; разная длина списка и разный набор ключей тоже дают путь.
- `test_compare_exact_and_strip_modes(tmp_path)`: в `before/` и `after/` по `sheet-FORM_2-AHR-none-1.json.gz` (after без null-поля) и `report-5-2026-LIMIT.json.gz` (after тоже без null-поля), `index.json` с 200: `exact` → 0 совпало, 2 расхождения; `strip-nulls` → 1 совпало (лист), 1 расхождение (отчёт сравнивается точно).
- `test_compare_status_mismatch(tmp_path)`: одинаковые тела, статусы 200/500 → расхождение.
- `test_build_requests_minimal_manifest`: манифест из двух листов (`rf` FORM_2/AHR без direction, `ssp` FORM_1/AHR `Support`) и одного отчёта → есть `sheet-FORM_2-AHR-none-<id>` под `rf`, его `-sections` вариант, `sheet-FORM_1-AHR-Support-<id>` под `dfip` с `?direction=Support`, пять `svod1-*`, два `svod2-*`, `report-*`, `project-summary-*`, три `projects-*-summary`; нет `rf-rollup`.
- [ ] **Step 2: [замок] Run — FAIL** `cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_compare_responses.py` → `ModuleNotFoundError`.
- [ ] **Step 3: Implement** по Interfaces; docstring модуля — порядок использования из D7.1 (`dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` с дампом «до»). Стенд не запускать.
- [ ] **Step 4: [замок] Run — PASS** + весь `perf/tests -m "not stand"`.
- [ ] **Step 5: Commit** → `perf(analysis): compare_responses — снимок и сравнение ответов тяжёлых GET` + Co-Authored-By.
---
### Task 8: Полный прогон фазы A, проверка инвариантов, передача на PG15
**Исполнитель:** контроллер (команды; правки — новой задачей-исправлением у haiku/sonnet).
- [ ] **Step 1: [замок] Полный бэк** `cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore` → только 3 известных падения.
- [ ] **Step 2: [замок] perf** `cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests` → PASS.
- [ ] **Step 3: Инварианты** `git diff origin/test -- api/src/services/export_service/ api/src/api/v1/websocket.py api/alembic/` → пусто. `git diff origin/test -- api/src/repository/` — у старых методов меняется только вынос FROM-части, текст SQL эквивалентен. Расхождение — находка `O-n`, исправление отдельной задачей.
- [ ] **Step 4: Передать главному контроллеру на проверку PG15.** В ledger и в отчёте SDD-контроллера: SHA ветки A, новый SQL (`json_build_object(...)::text` для `v_form_view`, `v_summary_view`, `v_svod2_go/rf`, шести функций проектов), просьба проверить на `dfip-pg15:55434` (PG 15.12). Не блокирует открытие PR A; блокирует merge — пункт чек-листа PR A «PG15 — проверяет контроллер».
---
### Task 9: Финальное ревью ветки A
**Исполнитель:** opus (`superpowers:requesting-code-review`, ревью всей ветки `git diff origin/test...HEAD`).
- [ ] **Step 1:** Ревьюеру передать: спеку (`openspec/changes/aurora-1848-sheet-json/`), этот план, раздел Global Constraints (включая замок), Review Focus; диапазон `origin/test...feat/233-aurora-1848-sheet-json-no-pydantic`.
- [ ] **Step 2:** Находки `C-n`/`I-n`/`M-n` → ledger. Critical и Important — исправить (задача-исправление: haiku, если правка однозначна, иначе sonnet), [замок] перепрогон затронутых тестов, коммит `fix(...)`. Minor — `fixed`, `deferred` или `rejected <причина>`.
- [ ] **Step 3:** Обновить таблицу `## Findings` в ledger; рулинги — в `design.md` → `## Decisions made without discussion` (коммит `docs(openspec): ...`).
---
### Task 10: PR A
**Исполнитель:** контроллер.
- [ ] **Step 1:** `git push -u origin feat/233-aurora-1848-sheet-json-no-pydantic`.
- [ ] **Step 2:** `tea pulls create --base test --head feat/233-aurora-1848-sheet-json-no-pydantic --title "AURORA-1848: тяжёлые GET — готовый JSON из Postgres без pydantic-модели на строку (A)" --description "<описание>"`. Описание:
- `Part of #233` (issue закрывает PR B), Jira AURORA-1848;
- что сделано (лист, своды 1 и 2, отдельный коммит — шесть GET проектов; `api_split.py` `wire_bytes`; `compare_responses.py`); ответ по смыслу тот же, байты могут отличаться (пробелы, запись чисел);
- тесты: unit, интеграционные эквивалентности со старым путём на 9 эндпоинтах, OpenAPI, полный pytest (3 известных падения `test`);
- чек-лист до merge: сравнение ответов `compare --mode exact` на стенде; PG15 (контроллер); `A ≤ 1,05 × до` по `api_split.py` — справочно, не условие мержа;
- строка «Замер до/после — добавит контроллер после прогона на стенде»;
- «Rulings I made» (`R-n`), таблица находок (все маркеры со статусами);
- связь: AURORA-1846 (#232) — общий хунк `db_session` в conftest; PR B стеком на этой ветке;
- последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- [ ] **Step 3:** Номер PR A — в ledger. Сразу переходить к фазе B, без остановки.
---
# Фаза B — без null-полей в `data` листа и сводов + фронт
Ветка `feat/233-aurora-1848-sheet-json-no-pydantic-b` стеком на ветке A, worktree `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848-b`.
### Task 11: Ветка B, ветка #223 внутри B, тесты `updateCells` на отсутствующие ключи
**Исполнитель:** haiku (код готов в плане).
**Files:**
- Modify: `web/src/components/RealtimeTable/utils/cellUtils.test.js` (файл приходит из #223)
- Modify (только если тест падает на версии #223): `web/src/components/RealtimeTable/utils/cellUtils.js`
**Interfaces:**
- Consumes: `updateCells(data, newRows)` и `deepFreeze` из `cellUtils.test.js` ветки #223 (`origin/feat/212-aurora-1838-ws-immutable-update`).
- [ ] **Step 1: Worktree B** (из основного checkout `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing`):
`git fetch origin && git worktree add .claude/worktrees/aurora-1848-b -b feat/233-aurora-1848-sheet-json-no-pydantic-b feat/233-aurora-1848-sheet-json-no-pydantic`;
`ln -s /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules .claude/worktrees/aurora-1848-b/web/node_modules`;
**[замок]** `cd .claude/worktrees/aurora-1848-b && codegraph init`.
- [ ] **Step 2: Влить #223** `git merge --no-ff origin/feat/212-aurora-1838-ws-immutable-update -m "Merge AURORA-1838 (#223) в ветку B: updateCells вставляет отсутствующие ключи" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`. Конфликтов быть не должно (A не трогает `web/`). После merge #223 и A в `test` ветка B перебазируется на `test`, и коммиты #223 уйдут из диффа (пишется в PR B).
- [ ] **Step 3: Тесты** — дописать в конец `cellUtils.test.js`:
```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&sections=plan,contract_summary` с `{"line_id": 1, "column": "plan.q1", "value": None}` → 200, в `result` есть строка с `data["plan"]` содержащим ключ `"q1"` со значением `None`.
- [ ] **Step 2: [замок] Run — FAIL**: unit-тест и сравнения листа/сводов (`expected_sheet` теперь чистит null, а ответ ещё с null), `test_sheet_and_svod_have_no_null_fields`. `test_project_report_keeps_nulls` и `test_patch_cell_null_is_explicit` — PASS уже сейчас (страховка).
- [ ] **Step 3: Implement** по Interfaces; роутеры `get_sheet`, `get_summary`, `get_summary2` вызывают `*_json(..., strip_nulls=True)`.
- [ ] **Step 4: [замок] Run** `test_heavy_get_json.py`, `test_json_list_response.py`, `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_projects_api_smoke.py`. Если `test_summary2_sheets_smoke` падает на отсутствии ключей с `null` — ослабить по D6: проверять ключи `header` и блоки `data` только там, где у строки эталона старого пути значение не `null` (или сравнивать набор ключей с `strip_nulls`-эталоном); это ожидаемое изменение контракта, отметить для описания PR B.
- [ ] **Step 5: Commit** → `perf(api): лист и своды без null-полей в data (jsonb_strip_nulls)` + Co-Authored-By.
---
### Task 14: Потребители GET в `perf/perfkit` — без падения на отсутствующих ключах
**Исполнитель:** haiku (код готов в плане).
**Files:**
- Modify: `perf/perfkit/gen/grow.py:63-67`
- Test: `perf/tests/test_gen_grow.py`
**Interfaces:**
- Produces: `form4_ids(rows: list[dict]) -> tuple[list[int], list[int]]` — `(vsp_ids, project_ids)` в `perfkit.gen.grow`, используется в `grow_form4_sheet`.
- [ ] **Step 1: Write the failing test**
```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/<tag>-1848-<before|A|B>/analysis` на трёх коммитах, API перезапускается на каждом. Пороги: свод 2 GO `B ≤ 0,7 × до`; FORM_2/AHR `B ≤ 0,9 × до` и `bytes B < до`; все запросы `A ≤ 1,05 × до`; справочно `python_share`, `wire_bytes`.
- [ ] **Step 4:** Цифры и результат сравнения — в описания PR A и B. Недобор порога — находка `deferred` (уходит в follow-up issue при закрытии); корректность не прошла — PR не мержится, исправление в ветке.
- [ ] **Step 5:** Ручной сценарий соавтора на стенде (Task 18) — результат в PR B.