DFiP_Budget_planing/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md
Andrey Epifancev 551066bb05 AURORA-1984: перепроверка дизайна и план реализации
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:14:29 +04:00

714 lines
48 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-1984: проекты ПД без программы (+1979, 1980, 1989) Implementation Plan
> **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.
**Goal:** На форме 4 проект создаётся прямо в разделе, без программы, и живёт в листе, итогах и выгрузке наравне с проектом в программе; плюс подпись типа в третьем столбце, переименование программ и проектов, удобный выбор статьи расхода.
**Architecture:** Самостоятельный проект — запись `v3.form4_project` с `level='project'`, `parent_id IS NULL` и своими `section_code`/`form_id`. Миграция 0059 ослабляет ограничение, расширяет `add_/upd_/del_form4_project` и `v_form4_sheet_jsonb` (ITEM прямо под ROOT, порядок по id верхнего узла). Сервер принимает `project_added` без программы, фронт вставляет ITEM под ROOT и получает окно переименования; подпись типа считается при отображении, данные не меняются.
**Tech Stack:** PostgreSQL (PL/pgSQL, Alembic), FastAPI + SQLAlchemy async, React 18 (JSX) + material-react-table, `pytest`, `node --test`.
**Spec:** `openspec/changes/aurora-1984-pd-standalone-projects/` — `proposal.md`, `design.md` (D1–D11, поправлен при перепроверке 08.11), `specs/form4-programs-projects/spec.md`.
## Global Constraints
- Worktree `.claude/worktrees/aurora-1984`, ветка `feat/305-aurora-1984-pd-standalone-projects` поверх PR #304; PR — `--base test`.
- Перед изменением — `codegraph_explore` с `projectPath` = абсолютный путь worktree.
- Миграция: `revision = "0059"`, `down_revision = "0058"`, `downgrade()` — `pass`, формат — `api/alembic/versions/0058_form2_ahr_delta_depth4.py`.
- Тело `v3.v_form4_sheet_jsonb` копируется из `sql/0053_form2_ahr_source_sheet.sql:840-1437`, меняются только блоки из задачи 1.
- `v3._form_affected_rows`, `v3.upd_form4_program`, `v3.del_form4_program`, `add_budget_line`, `del_budget_line` не меняются.
- `sort_path` и `sort_order` существующих строк (программы, проекты в программах, расходы на программе, исторические расходы без проекта с `999999`) не меняются.
- `header.section_code` в данных не меняется: подпись «программа»/«проект» — только при отображении и в выгрузке.
- В лист и выгрузку не попадает GROUP с пустым `program_id`.
- В конфигах колонок (`constants/FORM_4/*.js`) нет функций: `tableColumns.jsx:131` делает `structuredClone`.
- `header.program_id` сравнивать только через `!= null`: GET срезает null (AURORA-1848), по WS приходит `null`.
- Подписи ровно: «программа», «проект», «Переименовать», «Переименование программы», «Переименование проекта», «Сохранить», «Отмена».
- Backend-тесты: `cd api && pytest` (зелёным считается весь набор), фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`. Известное падение на базе: `tests/unit/test_export_service_query_params.py` (O-2 из #304), новых быть не должно.
- Интеграционным тестам нужен Postgres из `DATABASE_URL`: стенд `perf-pg` (`docker compose` в `perf/`), отдельная БД, первый прогон с `--init_db`.
- Коммиты: сообщение начинается с `AURORA-1984:` и заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Решения без обсуждения дописываются в `design.md`, раздел `## Decisions made without discussion`, с маркером `R-n` (занято R-1…R-6, O-1).
## Review Focus
1. Первый расход в разделе, где уже стоит самостоятельный проект, а ROOT ещё нет — ROOT появляется над проектом, расход встаёт под проект (задача 4, `insertCell после addRootRow`).
2. В разделе есть исторический расход без проекта (`999999`) — новый проект встаёт перед ним и на сервере, и локально (задачи 1 и 4).
3. Самостоятельный проект раздела CAP не появляется на листе AHR той же формы (задача 1).
4. Другой пользователь переименовал тот же узел, пока у меня открыто окно — моё окно не закрывается (задача 5, `isRenameResponse`).
5. Удаление последнего пустого самостоятельного проекта в разделе без ROOT — ответ пустой, сервис не падает и отдаёт `(id, [])` (задача 2).
---
### Task 1: Ограничения и лист
**Модель:** sonnet
**Files:**
- Create: `api/alembic/versions/0059_form4_standalone_project.py`
- Create: `api/alembic/versions/sql/0059_form4_standalone_project.sql`
- Modify: `api/src/db/models/form4_project.py:26-29`
- Test: `api/tests/integration/test_form4_standalone_project.py`
**Interfaces:**
- Consumes: `F4`, `SHEET`, `_loads` из `tests/integration/test_form4_sheet_sums.py` (импорт `from test_form4_sheet_sums import ...`).
- Produces: строка ITEM самостоятельного проекта — `row_type='ITEM'`, `depth=2`, `header = {section_code, name, program_id: null, project_id, expense_item_id: <id раздела>}`; `sort_path` ITEM `[sec, prj_id, 0, 0]`, INPUT `[sec, prj_id, 0, line_id]`.
- [ ] **Step 1: Написать падающие тесты**
Фикстура `f4` — копия из `test_form4_sheet_sums.py:157`. Хелпер и тесты:
```python
def standalone(f4, name="solo", section="CAP"):
return f4.sql(
"INSERT INTO v3.form4_project (name, level, form_id, section_code) "
"VALUES (:n, 'project', :f, :s) RETURNING id",
n=name, f=f4.form_id, s=section,
)[0][0]
def outline(rows):
return [(r["row_type"], r["data"]["header"]["name"]) for r in rows if r["row_type"] != "META"]
def test_mixed_order_by_creation(f4):
f4.program("А"); standalone(f4, "Б"); f4.program("В")
assert outline(f4.sheet()) == [("GROUP", "А"), ("ITEM", "Б"), ("GROUP", "В")]
def test_sums_counted_once_and_no_null_group(f4):
prog = f4.program(); prj = f4.project(prog); solo = standalone(f4)
f4.cells(f4.line(prj), {"plan.q1": 100})
f4.cells(f4.line(solo), {"plan.q1": 50})
rows = f4.sheet()
root = next(r for r in rows if r["row_type"] == "ROOT")
item = next(r for r in rows if r["row_type"] == "ITEM" and r["data"]["header"]["project_id"] == solo)
assert Decimal(str(root["data"]["plan"]["q1"])) == 150
assert Decimal(str(item["data"]["plan"]["q1"])) == 50
assert item["data"]["header"].get("program_id") is None
assert all(r["data"]["header"].get("program_id") is not None for r in rows if r["row_type"] == "GROUP")
def test_expense_goes_right_after_its_project(f4):
a = f4.program("А"); solo = standalone(f4, "Б"); f4.program("В")
f4.line(f4.project(a, "А1")); line = f4.line(solo)
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
idx = next(i for i, r in enumerate(rows) if r["data"].get("line_id") == line)
assert rows[idx - 1]["data"]["header"]["project_id"] == solo
assert rows[idx + 1]["data"]["header"]["name"] == "В"
def test_historical_line_stays_after_standalone(f4):
solo = standalone(f4)
f4.line(solo); hist = f4.line(None)
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
assert rows[-1]["data"].get("line_id") == hist
def test_other_section_does_not_show_standalone(f4):
solo = standalone(f4, section="CAP")
count = f4.sql(
"SELECT count(*) FROM v3.v_form4_sheet_jsonb(:f, 'AHR', NULL) "
"WHERE data->'header'->>'project_id' = :p", f=f4.form_id, p=str(solo),
)[0][0]
assert count == 0
def test_cell_patch_response(f4, client):
solo = standalone(f4); line = f4.line(solo)
resp = client.patch(
f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell",
json={"line_id": line, "column": "plan.q1", "value": 20}, headers=f4.h,
)
assert resp.status_code == 200, resp.text
by_type = {r["row_type"]: r["data"] for r in _loads(resp.text)["result"]}
assert {"INPUT", "ITEM", "ROOT"} <= set(by_type) and "GROUP" not in by_type
fresh = next(r for r in f4.sheet() if r["row_type"] == "ITEM")
assert by_type["ITEM"]["plan"] == fresh["data"]["plan"]
def test_delete_expense_recalculates(f4):
solo = standalone(f4)
keep, gone = f4.line(solo), f4.line(solo)
f4.cells(keep, {"plan.q1": 10})
f4.cells(gone, {"plan.q1": 5})
rows = f4.sql("SELECT row_type, data::text FROM v3.del_budget_line(:l, NULL, 'CAP')", l=gone)
by_type = {r[0]: _loads(r[1]) for r in rows}
assert "GROUP" not in by_type
assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 10
assert Decimal(str(by_type["ROOT"]["plan"]["q1"])) == 10
def test_sheet_without_standalone_equals_0053(f4):
source = Path("alembic/versions/sql/0053_form2_ahr_source_sheet.sql").read_text(encoding="utf-8")
body = re.search(r"CREATE OR REPLACE FUNCTION v3\.v_form4_sheet_jsonb.*?\$function\$\s*;", source, re.S).group(0)
ddl = body.replace("v3.v_form4_sheet_jsonb", "pg_temp.v_form4_sheet_old", 1)
async def _create():
conn = await f4.db.connection()
await conn.exec_driver_sql(ddl)
f4.client.portal.call(_create)
prog = f4.program(); prj = f4.project(prog); f4.program(); f4.project(prog)
f4.cells(f4.line(prj), {"plan.q1": 7})
f4.line(prog) # расход прямо на программе
f4.line(None) # исторический расход без проекта
q = "SELECT row_type, depth, sort_order, data::text FROM {}(:f, 'CAP', NULL) ORDER BY sort_order"
old = [tuple(r) for r in f4.sql(q.format("pg_temp.v_form4_sheet_old"), f=f4.form_id)]
new = [tuple(r) for r in f4.sql(q.format("v3.v_form4_sheet_jsonb"), f=f4.form_id)]
assert new == old
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v`
Expected: FAIL — `INSERT` нарушает `chk_form4_project_section_code`; `test_sheet_without_standalone_equals_0053` проходит.
- [ ] **Step 3: Написать миграцию**
В `.sql` по порядку:
1. `DO`-блок: `RAISE EXCEPTION`, если есть строка с `parent_id IS NULL AND form_id IS NULL`.
2. `DROP CONSTRAINT chk_form4_project_section_code` и `ADD CONSTRAINT` с `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`.
3. `ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL)`.
4. `CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb` — копия из 0053 с правками (номера строк — по 0053):
- `all_projects` (1149-1158): `UNION ALL` самостоятельных проектов: `prj.level='project' AND prj.parent_id IS NULL AND prj.section_code = p_sheet AND prj.form_id = p_form_id AND (p_sections IS NULL OR prj.section_code = ANY(p_sections))`, `NULL::INT AS prog_id`, `sec_id/sec_code/sec_name` — `LATERAL` по `expense_item` с `depth = 0`, как в `all_programs`.
- `prj_agg` (1289): `d.prog_id IS NOT DISTINCT FROM n.prog_id`.
- `sort_path` обеих веток ITEM (1370, 1385): `ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[]`.
- `sort_path` INPUT (1407): `ARRAY[sec_id, COALESCE(prog_id, prj_id, 999999), CASE WHEN prj_id IS NULL AND prog_id IS NULL THEN 999999 WHEN prj_id IS NULL OR prog_id IS NULL THEN 0 ELSE prj_id END, line_id]::INT[]`.
В ORM заменить условие `chk_form4_project_section_code` на то же выражение и добавить `CheckConstraint` `chk_form4_project_standalone_form`.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py tests/integration/test_form4_transfer_econ.py --init_db -v`
Expected: PASS, файлы `test_form4_sheet_sums.py` и `test_form4_transfer_econ.py` не правились.
- [ ] **Step 5: Коммит**
```bash
git add api/alembic/versions/0059_form4_standalone_project.py api/alembic/versions/sql/0059_form4_standalone_project.sql api/src/db/models/form4_project.py api/tests/integration/test_form4_standalone_project.py
git commit -m "AURORA-1984: самостоятельный проект в модели и листе формы 4"
```
### Task 2: Создание, переименование и удаление в SQL
**Модель:** sonnet
**Files:**
- Modify: `api/alembic/versions/sql/0059_form4_standalone_project.sql`
- Test: `api/tests/integration/test_form4_standalone_project.py`
**Interfaces:**
- Consumes: ограничения задачи 1.
- Produces: `v3.add_form4_project(p_name varchar, p_program_id int DEFAULT NULL, p_form_id int DEFAULT NULL, p_section_code varchar DEFAULT NULL)` — те же колонки результата `(project_id, row_type, depth, sort_order, data)`.
- [ ] **Step 1: Написать падающие тесты**
Хелпер `standalone` из задачи 1 заменить вызовом функции, остальные тесты задачи 1 остаются:
```python
def add(f4, name="solo", section="CAP"):
return f4.sql(
"SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)",
n=name, f=f4.form_id, s=section,
)
def standalone(f4, name="solo", section="CAP"):
return add(f4, name, section)[0][0]
def test_add_in_empty_section_returns_item_only(f4):
assert [r[1] for r in add(f4)] == ["ITEM"]
def test_add_with_root_returns_item_and_root(f4):
f4.line(f4.project(f4.program()))
assert sorted(r[1] for r in add(f4)) == ["ITEM", "ROOT"]
def test_add_rejects_blank_name(f4):
with pytest.raises(Exception, match="p_name обязателен"):
add(f4, name=" ")
def test_add_rejects_no_target(f4):
with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"):
f4.sql("SELECT * FROM v3.add_form4_project('x')")
def test_add_rejects_both_targets(f4):
prog = f4.program()
with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"):
f4.sql("SELECT * FROM v3.add_form4_project('x', :p, :f, 'CAP')", p=prog, f=f4.form_id)
def test_add_rejects_foreign_section(f4):
with pytest.raises(Exception, match="Раздел SMETA недоступен для проекта"):
add(f4, section="SMETA")
def test_two_argument_call_still_creates_in_program(f4):
prog = f4.program(); prj = f4.project(prog)
item = next(r for r in f4.sheet() if r["row_type"] == "ITEM")
assert (item["data"]["header"]["program_id"], item["data"]["header"]["project_id"]) == (prog, prj)
def test_rename_standalone(f4):
solo = standalone(f4)
rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, ' Новое ')", p=solo)
assert [r[0] for r in rows] == ["ITEM"]
assert _loads(rows[0][1])["header"]["name"] == "Новое"
def test_delete_empty_standalone_in_empty_section_returns_nothing(f4):
solo = standalone(f4)
assert f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo) == []
assert outline(f4.sheet()) == []
def test_delete_standalone_with_line_rejected(f4):
solo = standalone(f4); f4.line(solo)
with pytest.raises(Exception, match="Нельзя удалить проект, содержащий строки бюджета"):
f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo)
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v`
Expected: FAIL — `function v3.add_form4_project(... ) does not exist` / нет ожидаемых сообщений.
- [ ] **Step 3: Реализовать функции в 0059, перед `v_form4_sheet_jsonb`**
- `DROP FUNCTION v3.add_form4_project(varchar, int4);` и новая функция с сигнатурой из Interfaces, тело — от `sql/0026_budget_line_stage_access.sql:486`.
- Проверка: если `(p_program_id IS NOT NULL) = (p_form_id IS NOT NULL OR p_section_code IS NOT NULL)` либо задан только один из `p_form_id`/`p_section_code` — `RAISE EXCEPTION 'Укажите либо программу, либо форму и раздел'`.
- Без программы: `'Только FORM_4'` — как в `add_form4_program`; при `p_section_code NOT IN ('AHR','CAP','OPER')` — `RAISE EXCEPTION 'Раздел % недоступен для проекта', p_section_code`; `v_form_id := p_form_id`, `v_section_code := p_section_code`, блок `can_edit` — общий, те же коды ошибок. `INSERT` с `parent_id NULL` и `section_code`; в `log_event` добавить `'section_code', v_section_code`.
- С программой — поведение прежнее. `RETURN QUERY` общий: при пустом `p_program_id` условие GROUP ложно само.
- `upd_form4_project`, `del_form4_project` — тела из `sql/0024_upd_form4_projects.sql:79-155`, раздел везде `COALESCE(v_program.section_code, v_project.section_code)` в локальной переменной `v_section_code`.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py --init_db -v`
Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git add api/alembic/versions/sql/0059_form4_standalone_project.sql api/tests/integration/test_form4_standalone_project.py
git commit -m "AURORA-1984: создание, переименование и удаление проекта без программы в SQL"
```
### Task 3: Сервер: `project_added` без программы
**Модель:** haiku
**Files:**
- Modify: `api/src/api/v1/websocket.py:482-486`, `api/src/api/v1/websocket.py:610-625`
- Modify: `api/src/services/sheet_service.py:158-188`
- Modify: `api/src/repository/sheet_repository.py:208-231`
- Test: `api/tests/unit/test_sheet_service_form4_standalone.py`, `api/tests/integration/test_form4_standalone_project.py`
**Interfaces:**
- Consumes: SQL-функция задачи 2.
- Produces:
- `SheetRepository.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, section_code: str | None = None) -> list[tuple]`
- `SheetService.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, sheet: str | None = None, user: AppUser | None = None) -> tuple[int, list[tuple]] | None`
- WS `project_added`: `data = {name: str, program_id?: int}`, ответ `[project_id, rows]`.
- [ ] **Step 1: Написать падающие тесты**
Unit (через `asyncio.run`, `SheetService(AsyncMock())`, репозитории заменены на `AsyncMock`, импорт `SheetValidationError` — как в `sheet_service.py`):
```python
def test_admin_creates_in_section():
service = make_service()
service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})]
result = asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=ADMIN))
assert result == (9, [("ITEM", 2, 1, {})])
service.sheet_repo.add_project.assert_awaited_once_with(name="П", program_id=None, form_id=7, section_code="CAP")
def test_foreign_executor_cannot_create_in_section():
service = make_service()
service.bf_repo.get.return_value = None
with pytest.raises(SheetValidationError):
asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR))
service.sheet_repo.add_project.assert_not_awaited()
@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"])
def test_foreign_executor_cannot_change_standalone(method):
service = make_service()
service.f4_project_repo.get.return_value = SimpleNamespace(form_id=7, parent_id=None)
service.bf_repo.get.return_value = None
kwargs = {"project_id": 3, "user": EXECUTOR}
if method == "update_form4_project":
kwargs["name"] = "Н"
with pytest.raises(SheetValidationError):
asyncio.run(getattr(service, method)(**kwargs))
```
Здесь `ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN)`, `EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF)`, `make_service()` дополнительно задаёт `user_repo.get.return_value = SimpleNamespace(org_units=[])`.
Integration (NULL-параметры доходят до функции):
```python
def test_repository_creates_in_section(f4):
repo = SheetRepository(f4.db)
rows = f4.client.portal.call(lambda: repo.add_project(name="Р", form_id=f4.form_id, section_code="CAP"))
assert [r[1] for r in rows] == ["ITEM"]
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py -v`
Expected: FAIL — `add_project() got an unexpected keyword argument 'form_id'`.
- [ ] **Step 3: Реализовать**
- Repository — вызов `v3.add_form4_project(:p_name, :p_program_id, :p_form_id, :p_section_code)`.
- Service — при `program_id is None` для не-админа та же проверка, что в `add_program` (`sheet_service.py:137-147`, ошибка «Форма не найдена»), вызов `sheet_repo.add_project(name=name, program_id=None, form_id=form_id, section_code=sheet)`. Ветка с программой прежняя, вызов репозитория `add_project(name=name, program_id=program_id, form_id=None, section_code=None)`.
- WebSocket — в `process` передать `form_id=form_id, sheet=sheet`; в `__add_project` `program_id=event_data.get("program_id")`.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py tests/integration/test_form4_standalone_project.py --init_db -v`
Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git add api/src/api/v1/websocket.py api/src/services/sheet_service.py api/src/repository/sheet_repository.py api/tests/unit/test_sheet_service_form4_standalone.py api/tests/integration/test_form4_standalone_project.py
git commit -m "AURORA-1984: событие project_added без программы"
```
### Task 4: Фронт: создание и вставка проекта
**Модель:** sonnet
**Files:**
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`, `web/src/components/RealtimeTable/utils/cellUtils.js`
- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js:135-158`
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx:936-943`, `web/src/components/RealtimeTable/RealtimeTable.jsx:1013-1029`
- Modify: `web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx:35`
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js`
**Interfaces:**
- Consumes: WS из задачи 3; `alignNodeRows`, `addRootRow`, `updateCells`, `insertProjectRow` из `cellUtils.js`.
- Produces:
- `getProjectAddTarget(rowData): { program_id?: number }` (`rowUtils.js`)
- `insertStandaloneProjectRow(data, newRow): rows` (`cellUtils.js`), `newRow = [row_type, depth, sort_order, data]`
- [ ] **Step 1: Написать падающие тесты**
```js
// rowUtils.test.js
test('AURORA-1984: getProjectAddTarget — цель по выделенной строке', () => {
assert.deepEqual(getProjectAddTarget(undefined), {});
assert.deepEqual(getProjectAddTarget(form4Row('ROOT', { section_code: 'CAP' })), {});
assert.deepEqual(getProjectAddTarget(form4Row('GROUP', { program_id: 11 })), { program_id: 11 });
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: 11, project_id: 21 })), { program_id: 11 });
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: null, project_id: 21 })), {});
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {});
assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 });
});
// cellUtils.test.js
const node = (rowType, sortOrder, header = {}, subRows = []) => ({
row_type: rowType, depth: { ROOT: 0, GROUP: 1, ITEM: 2, INPUT: 3 }[rowType], sort_order: sortOrder, subRows, data: { header },
});
const newItem = (sortOrder) => ['ITEM', 2, sortOrder, { header: { project_id: 77, name: 'Н' } }];
test('AURORA-1984: самостоятельный проект — под ROOT, после соседей', () => {
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2)])], newItem(3));
assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3]]);
});
test('AURORA-1984: самостоятельный проект — на верхний уровень без ROOT', () => {
const result = insertStandaloneProjectRow([node('GROUP', 1)], newItem(2));
assert.deepEqual(result.map((r) => r.row_type), ['GROUP', 'ITEM']);
});
test('AURORA-1984: самостоятельный проект — перед историческим расходом, номера сдвигаются', () => {
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2), node('INPUT', 3)])], newItem(3));
assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3], ['INPUT', 4]]);
});
test('AURORA-1984: insertCell после addRootRow — первый расход самостоятельного проекта', () => {
const withRoot = addRootRow([node('ITEM', 1, { project_id: 21 })], ['ROOT', 0, 1, { header: { expense_item_id: 900 } }]);
const result = insertCell(withRoot, ['INPUT', 3, 3, { line_id: 5, header: { project_id: 21 } }], 21);
assert.equal(result[0].subRows[0].subRows[0].data.line_id, 5);
});
```
Импорт в `cellUtils.test.js` дополнить `addRootRow, insertCell, insertStandaloneProjectRow`.
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js`
Expected: FAIL — `getProjectAddTarget` / `insertStandaloneProjectRow` нет в модуле.
- [ ] **Step 3: Реализовать**
- `getProjectAddTarget`: `header.program_id != null` у GROUP, ITEM, INPUT → `{ program_id }`, иначе `{}`.
- `insertStandaloneProjectRow`: копия данных (`structuredClone`), у всех строк дерева `sort_order >= нового` → `+1`; целевой список — `subRows` первого ROOT или верхний уровень; вставка перед первой строкой списка с `sort_order` больше нового, иначе в конец.
- `subscribeToProjectAdds`: `program_id = data.data.program_id`. В `setData`: если `prevData[0]?.row_type !== 'ROOT'` и в ответе есть ROOT — `addRootRow`; затем `updateCells(x, alignNodeRows(x, updatedCells))`; затем `program_id != null ? insertProjectRow(...) : insertStandaloneProjectRow(...)`.
- `RealtimeTable.jsx`: `addProject` без проверок выделения и типа строки (проверка `canModifyRows` остаётся); `handleAddProjectRow` — `contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) })`.
- `CreateProgramProjectModal.jsx`: `isFormValid = name.trim() !== ''`.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx
git commit -m "AURORA-1984: добавление проекта в разделе на фронте"
```
### Task 5: AURORA-1980 — переименование
**Модель:** sonnet
**Files:**
- Create: `web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx`
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`
- Modify: `web/src/components/RealtimeTable/contexts/RealtimeContext.js:114-180`
- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js`
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`
- Modify: `web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx:225-232`
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js`
**Interfaces:**
- Consumes: `alignNodeRows`, `updateCells`; `upd_form4_*` возвращают `[id, rows]`.
- Produces:
- `getRenameTarget(rowData): { kind: 'program' | 'project', id: number, name: string } | null`
- `isRenameResponse(msg, target): boolean`
- из `useRealtimeActions()` (тот же объект, что `deleteProgram`): `renameNode(kind, id, name): boolean`, `subscribeToNodeUpdates(cb)`, `subscribeToNodeRenameResult(cb)` — по образцу `subscribeToNodeDeletes`.
- `RenameNodeModal({ target, isSaving, onClose, onSubmit })`, `onSubmit(trimmedName)`.
- [ ] **Step 1: Написать падающие тесты**
```js
// rowUtils.test.js
test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => {
assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' });
assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' });
assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null);
assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null);
assert.equal(getRenameTarget(undefined), null);
});
test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => {
const target = { kind: 'project', id: 21, name: 'Б' };
const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } };
assert.equal(isRenameResponse(msg, target), true);
assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true);
assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false);
assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false);
assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
assert.equal(isRenameResponse(msg, null), false);
});
// cellUtils.test.js
test('AURORA-1980: ответ переименования обновляет название в строке', () => {
const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])];
const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]];
const result = updateCells(local, alignNodeRows(local, rows));
assert.equal(result[0].subRows[0].data.header.name, 'Новое');
});
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js`
Expected: FAIL — `getRenameTarget` нет в модуле (тест `cellUtils` проходит: он фиксирует поведение существующих функций).
- [ ] **Step 3: Реализовать функции и канал событий**
- `RealtimeContext.js`, `handleMessage`: в ветке `data.error` после тоста и `onErrorRef` — `onNodeRenameResultRef.current?.(data)`, если `data.event` — `program_updated` или `project_updated`. В `switch` добавить оба события: при `data.result` вызвать `onNodeUpdateRef.current?.(data)` и `onNodeRenameResultRef.current?.(data)`.
- `renameNode(kind, id, name)` шлёт `{ event: `${kind}_updated`, data: { [`${kind}_id`]: id, name } }` через `addCommonField` и `sendMessage`, как `deleteProject`.
- `useRealtimeData`: `subscribeToNodeUpdates((msg) => setData((prev) => updateCells(prev, alignNodeRows(prev, msg.result[1] ?? []))))`, отписка — как у `subscribeToNodeDeletes`.
- [ ] **Step 4: Реализовать окно и кнопку**
- `RenameNodeModal` — разметка как у `CreateProgramModal`; поле управляемое (`value`), начальное значение из `target.name` при смене `target`; заголовок по `target.kind`; кнопка «Сохранить» неактивна при `name.trim() === ''` или `isSaving`; «Отмена» и клик по фону закрывают без отправки.
- `RealtimeTable.jsx`: состояния `pendingRename`, `isRenameSaving` рядом с `pendingDelete`; подписка `subscribeToNodeRenameResult` в `useEffect`: при `isRenameResponse(msg, pendingRename)` — при `msg.error` снять `isRenameSaving` (окно и текст остаются), иначе закрыть окно. `onSubmit`: `renameNode(...)`; при `false` — окно остаётся, иначе `isRenameSaving = true`. Рендер рядом с `ConfirmDeleteRowModal` (`:1159`).
- `SettingPanel.jsx`: в блоке формы 4 после «Добавить проект» — `Tooltip` «Переименовать», `IconButton` с `onClick={onRenameNode}`, `disabled={!canRenameNode}`, иконка `EditSvg` из `common/icons/icons`. Вокруг отключаемой кнопки — `<span>`: MUI `Tooltip` не работает на `disabled`. `canRenameNode` считается в `RealtimeTable` при рендере: `Boolean(getRenameTarget(getSelectedRow()?.original))`; `onRenameNode` — `setPendingRename(getRenameTarget(getSelectedRow()?.original))`; оба пропса передаются в панель рядом с `onAddProject` (`:1099`).
- [ ] **Step 5: Убедиться, что тесты и линт проходят**
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable/Modals/RenameNodeModal.jsx`
Expected: PASS, без ошибок линта.
- [ ] **Step 6: Коммит**
```bash
git add web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/contexts/RealtimeContext.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
git commit -m "AURORA-1980: переименование программы и проекта"
```
### Task 6: AURORA-1979 — подпись типа на экране и в выгрузке
**Модель:** sonnet
**Files:**
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`
- Modify: `web/src/components/RealtimeTable/tableColumns.jsx:255`
- Modify: `web/src/components/RealtimeTable/constants/FORM_4/AHR.js`, `CAP.js`, `OPER.js` (колонка `data.header.section_code`)
- Modify: `api/src/services/export_service/export_normalizers.py`, `api/src/services/export_service/export_service.py:125,975,981,987`
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/form4TypeLabel.test.js` (новый), `api/tests/unit/test_export_normalizers.py`, `api/tests/unit/test_export_writers.py`
**Interfaces:**
- Consumes: строки листа задачи 1.
- Produces:
- `getForm4TypeLabel(row): 'программа' | 'проект' | null`, `row = { row_type, data }`
- признак колонки `valueLabel: 'form4NodeType'`
- `Form4DepthRowNormalizer(Form1DepthRowNormalizer)` в `export_normalizers.py`
- [ ] **Step 1: Написать падающие тесты**
```js
// rowUtils.test.js
test('AURORA-1979: getForm4TypeLabel', () => {
assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа');
assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект');
assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект');
assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null);
assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null);
assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null);
});
// form4TypeLabel.test.js — конфиги читаются как текст, см. form2SmetaConfig.test.js
for (const sheet of ['AHR', 'CAP', 'OPER']) {
test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => {
const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8');
assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/);
assert.doesNotMatch(source, /accessorFn/);
});
}
```
```python
# test_export_normalizers.py
@pytest.mark.parametrize("data, expected", [
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"),
({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"),
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"),
({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"),
({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."),
])
def test_form4_depth_normalizer_type_label(data, expected):
normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data)
assert normalized["header.section_code"] == expected
assert data["header"]["section_code"] != "программа"
# test_export_writers.py
def test_standalone_project_outline_without_group():
rows = [("ROOT", 0, 1, {}), ("ITEM", 2, 2, {}), ("INPUT", 3, 3, {}), ("GROUP", 1, 4, {})]
assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [
("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1),
]
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/form4TypeLabel.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py -v`
Expected: FAIL — нет `getForm4TypeLabel`, `valueLabel`, `Form4DepthRowNormalizer`; `test_standalone_project_outline_without_group` проходит (закрепляет D7a).
- [ ] **Step 3: Реализовать**
- `getForm4TypeLabel`: GROUP с `program_id != null` → «программа»; ITEM с `project_id != null` → «проект»; иначе `null`.
- В трёх конфигах в дереве `columns`, в листовой колонке под заголовком «Код статьи / подстатьи / позиции» (строка с `columnLetter: "C"` рядом; не в карте цветов в начале файла), строкой сразу после `accessorKey: "data.header.section_code",` добавить `valueLabel: "form4NodeType",`.
- `processColumns`, до условия на `:255`: при `isLeafColumn && col.valueLabel === 'form4NodeType'` — `col.id = col.id ?? col.accessorKey; col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey);`.
- `Form4DepthRowNormalizer.normalize`: `super().normalize(data)`; при `data.get("line_id") is None` и `header.get("project_id") is not None` → `normalized["header.section_code"] = "проект"`, при `header.get("program_id") is not None` → «программа». Проект проверяется первым.
- `export_service.py`: `self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer)` рядом с `:125`; в `writer_configs` формы 4 — только `OPER`, `AHR`, `CAP` (`:975,981,987`), `STRUCTURE` не трогать.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py tests/unit/test_export_writers.py tests/unit/test_export_form1_form4_mapping.py -v`
Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/form4TypeLabel.test.js web/src/components/RealtimeTable/tableColumns.jsx web/src/components/RealtimeTable/constants/FORM_4/AHR.js web/src/components/RealtimeTable/constants/FORM_4/CAP.js web/src/components/RealtimeTable/constants/FORM_4/OPER.js api/src/services/export_service/export_normalizers.py api/src/services/export_service/export_service.py api/tests/unit/test_export_normalizers.py api/tests/unit/test_export_writers.py
git commit -m "AURORA-1979: подпись программы и проекта в третьем столбце и выгрузке"
```
### Task 7: AURORA-1989 — окно выбора расхода
**Модель:** haiku
**Files:**
- Create: `web/src/components/RealtimeTable/utils/expenseItemOptions.js`
- Modify: `web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx:14-22,75`
- Test: `web/src/components/RealtimeTable/utils/expenseItemOptions.test.js`
**Interfaces:**
- Produces: `getExpenseItemLabel(option): string`, `compareExpenseItems(a, b): number`.
- [ ] **Step 1: Написать падающие тесты**
```js
import assert from 'node:assert/strict';
import test from 'node:test';
import { compareExpenseItems, getExpenseItemLabel } from './expenseItemOptions.js';
const sorted = (ids) => ids.map((item_id) => ({ item_id, name: 'x' })).sort(compareExpenseItems).map((o) => o.item_id);
test('AURORA-1989: подпись варианта', () => {
assert.equal(getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' }), '3.01. — Связь');
assert.equal(getExpenseItemLabel({ item_id: null, name: 'Связь' }), 'Связь');
assert.equal(getExpenseItemLabel({ name: '' }), '');
assert.equal(getExpenseItemLabel(''), '');
});
test('AURORA-1989: сортировка с учётом числовых частей, без кода — в конце', () => {
assert.deepEqual(sorted(['3.10.', '3.2.', '10', '2']), ['2', '3.2.', '3.10.', '10']);
assert.deepEqual(sorted([null, '1.', '']), ['1.', null, '']);
});
test('AURORA-1989: поиск по подписи находит номер и название', () => {
const label = getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' });
assert.ok(label.includes('3.01'));
assert.ok(label.includes('Связь'));
});
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js`
Expected: FAIL — модуль не найден.
- [ ] **Step 3: Реализовать**
- `compareExpenseItems`: сравнение `item_id` через `localeCompare(b, 'ru', { numeric: true })`; пустой или отсутствующий код — после любых с кодом; порядок пустых между собой сохраняется (возврат 0).
- `SelectExpenseItemModal.jsx`: `setExpenseItemOption([...data.result].sort(compareExpenseItems))`; `getOptionLabel={getExpenseItemLabel}`. Встроенный фильтр Autocomplete ищет по подписи, серверный запрос не меняется.
- [ ] **Step 4: Убедиться, что тесты проходят**
Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js`
Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/expenseItemOptions.js web/src/components/RealtimeTable/utils/expenseItemOptions.test.js web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx
git commit -m "AURORA-1989: номер статьи, сортировка и поиск в окне выбора расхода"
```
### Task 8: Полная проверка и стенд
**Модель:** sonnet
**Files:** код не меняется; находки исправляются отдельными коммитами в файлах своей задачи.
**Interfaces:**
- Consumes: всё, что сделано в задачах 1–7.
- [ ] **Step 1: Прогнать весь backend**
Run: `cd api && pytest --init_db`
Expected: PASS, кроме известного `tests/unit/test_export_service_query_params.py`.
- [ ] **Step 2: Прогнать фронт и линт**
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable`
Expected: PASS; линт — без новых ошибок относительно ветки `test`.
- [ ] **Step 3: Живая проверка на стенде (форма 4, две вкладки)**
Поднять API и фронт по способу из заметки о стенде (`perf-pg`, логин admin/admin123). Каждый пункт отмечается результатом:
- Проект в разделе: ничего не выделено и выделен ROOT — виден в обеих вкладках, после F5 на том же месте.
- Выделены программа, проект в программе, расход, самостоятельный проект — новый проект создаётся там, где требует спека.
- Порядок «программа, проект, программа» по созданию.
- Пустой раздел: проект без ROOT; первый расход — появляется ROOT, расход под проектом.
- Правка `plan.q1` у расхода самостоятельного проекта пересчитывает ITEM и ROOT.
- Переименование программы и обоих видов проекта: видно в двух вкладках; пробелы не сохраняются; ошибка (остановить API или `role_not_allowed`) оставляет окно с текстом.
- Удаление пустого самостоятельного проекта; с расходом — отказ с причиной.
- Подписи «программа»/«проект» и цвета строк на экране; те же подписи и outline в Excel, строки GROUP с пустой программой нет.
- Окно выбора расхода: «номер — название», порядок, поиск «3.01».
- [ ] **Step 4: Записать найденное**
Новые решения — в `design.md` с маркером `R-n`, наблюдения — `O-n`. Исправления кода — коммитами с тестом.