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>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:02:48 +04:00
parent 3c5487c458
commit 1d885d1bbd
2 changed files with 921 additions and 0 deletions

View File

@ -0,0 +1,912 @@
# 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.

View File

@ -228,3 +228,12 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
| 2026-10-28 | База сравнения для порогов — `test` на момент замера «до» A; если AURORA-1847 (или другая правка SQL/миграция) вольётся раньше замера «после», «до» переснимается на новой базе (решение контроллера) | Иначе в разницу попадёт чужое ускорение или замедление | Лишняя серия замеров (~10 мин) |
| 2026-10-28 | Замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания; PR открываются без цифр, цифры дописываются (решение контроллера) | Стенд занят ночным прогоном, замеры нельзя вести параллельно (`pg_stat_statements`) | Цифры появляются в PR позже ревью кода |
| 2026-10-28 | Полный pytest — на PG16 (`perf-pg:55432`, БД `dfip_1848`, пароли `OPENBAO__TEST_*` = `admin123`); совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер (решение контроллера) | `test` пока не накатывается на PG15 (AURORA-1922); новый SQL change совместим с PG 9.5+ | Низкая: несовместимость с PG15 ловит контроллер до мержа |
| 2026-10-28 | (план) Ветка B вливает ветку PR #223 (`feat/212-aurora-1838-ws-immutable-update`) merge-коммитом в начале фазы B, а не ждёт merge #223 в `test`; после merge #223 и A ветка B перебазируется на `test` | Человек задал «между фазами без остановки», а тесты фронта B без `updateCells` из #223 красные; merge-коммит не дублирует код #223 и уходит из диффа при rebase | Средняя: PR B до rebase показывает дифф #223; если #223 изменится на ревью — повторный merge или rebase B |
| 2026-10-28 | (план) Фаза B — в отдельном worktree `.claude/worktrees/aurora-1848-b`, а не переключением ветки в worktree A | Правки по ревью PR A (H-n) идут в A параллельно с работой над B | Низкая: лишний worktree, удаляется при закрытии |
| 2026-10-28 | (план) Красный шаг TDD для GET — тест «GET не вызывает старый метод» (monkeypatch старого метода репозитория на исключение); тесты эквивалентности со старым путём зелёные и до правки, они — страховка | Эквивалентность по определению не отличает старый код от нового; без такого теста шаг «падает» невозможен | Низкая |
| 2026-10-28 | (план) Общий модуль `api/src/repository/row_json.py` (`ROW_COLUMNS`, `row_json_select`, `fetch_row_texts`); `strip_nulls` добавляется в `row_json_select` в фазе B | Одно место для выражения строки (D1) и для включения B (D4) во всех репозиториях | Низкая: перенос функции |
| 2026-10-28 | (план) Нормализация `undefined → null` вынесена в `web/src/components/RealtimeTable/utils/messageUtils.js` (`buildCellUpdatedMessage`) | `RealtimeContext.js` импортирует React и не тестируется `node --test`; D5.2 требует чистую функцию в `.js` | Низкая |
| 2026-10-28 | (план) `compare_responses.py` не снимает `rf-rollup`: в манифесте стенда нет `branch_id`; эндпоинт покрыт интеграционным тестом эквивалентности | Требование «все листы, своды, отчёты проектов по манифесту» выполнимо только для того, что есть в манифесте | Низкая: регрессия rf-rollup на данных стенда не видна сравнению |
| 2026-10-28 | (план) В режиме `strip-nulls` `compare_responses.py` чистит только `data` строк (обёртка, `sort_order`, `message` не трогаются); `dump` пишет статусы в `index.json`, `compare` сравнивает и статусы | Спека: «из `data` которого рекурсивно удалены поля со значением null»; ошибки тоже должны совпадать | Низкая |
| 2026-10-28 | (план) Финальное ревью opus — по одному на каждую ветку фазы: A до PR A, B (без коммитов #223) до PR B | Два PR ревьюются и мержатся раздельно | Низкая: два прогона opus вместо одного |
| 2026-10-28 | (план) Проверка PG15 главным контроллером не блокирует открытие PR, блокирует merge; в PR A пишется `Part of #233`, `Closes #233` — в PR B | Замеры и PG15 у главного контроллера, PR открываются без них; issue закрывается только всем объёмом (A+B) | Низкая |