# 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 `. - Решения без обсуждения дописываются в `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: }`; `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`. Вокруг отключаемой кнопки — ``: 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`. Исправления кода — коммитами с тестом.