From 551066bb0563429863da7dabc01a7fd9c12d9344 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:14:29 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1984:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B4=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=D0=B0=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...1-08-aurora-1984-pd-standalone-projects.md | 713 ++++++++++++++++++ .../design.md | 30 +- .../proposal.md | 6 +- 3 files changed, 734 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md diff --git a/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md b/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md new file mode 100644 index 0000000..3605986 --- /dev/null +++ b/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md @@ -0,0 +1,713 @@ +# 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`. Исправления кода — коммитами с тестом. diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index 25e1128..bfe02c3 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -35,7 +35,7 @@ ## Decisions ### D1. Модель: проект без родителя хранит раздел сам -`form4_project(level='project', parent_id NULL, section_code NOT NULL, form_id NOT NULL)`. Ограничение `chk_form4_project_section_code` пересоздаётся как `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. Новое ограничение `chk_form4_project_standalone_form`: `parent_id IS NOT NULL OR form_id IS NOT NULL` (у программы и самостоятельного проекта форма обязательна; существующие программы её имеют — проверить запросом в миграции перед `ADD CONSTRAINT`). Существующие строки обоим ограничениям удовлетворяют (у всех проектов есть родитель и нет раздела). Отдельный тип/флаг не вводится: «самостоятельный» = `level='project' AND parent_id IS NULL`. +`form4_project(level='project', parent_id NULL, section_code NOT NULL, form_id NOT NULL)`. Ограничение `chk_form4_project_section_code` пересоздаётся как `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. Новое ограничение `chk_form4_project_standalone_form`: `parent_id IS NOT NULL OR form_id IS NOT NULL` (у программы и самостоятельного проекта форма обязательна; существующие программы её имеют — проверить запросом в миграции перед `ADD CONSTRAINT`). Существующие строки обоим ограничениям удовлетворяют (у всех проектов есть родитель и нет раздела). Отдельный тип/флаг не вводится: «самостоятельный» = `level='project' AND parent_id IS NULL`. ORM-модель `api/src/db/models/form4_project.py` повторяет ограничения — правится вместе с миграцией. ### D2. `add_form4_project` — одна функция, программа или форма+раздел Новая сигнатура: `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)`; старая `(varchar, int)` удаляется (`DROP FUNCTION`), вызовы с двумя аргументами продолжают работать за счёт DEFAULT. Ровно один из вариантов: `p_program_id` или пара `p_form_id` + `p_section_code`; иначе `RAISE`. Для самостоятельного: проверка, что форма — FORM_4 и раздел — один из AHR/CAP/OPER; права — `v3.can_edit(p_form_id, p_section_code, NULL, NULL, user)` с теми же кодами ошибок, что сейчас; аудит `PROJECT_CREATE` с `parent_id: null`, `section_code`. Возвращает строки так же, как вариант с программой: новый ITEM + ROOT раздела (если он есть в листе). @@ -46,7 +46,7 @@ - `all_projects`: добавить самостоятельные проекты листа (`parent_id IS NULL AND section_code = p_sheet AND form_id = p_form_id`, с `p_sections`); сейчас там `JOIN all_programs`, самостоятельные туда не попадают. - Агрегаты (блок «4», GROUPING SETS по раздел / программа / проект). Уровень программы (`lvl = 1`) уже отсекает `prog_id IS NULL` (`0053:1248`) — фиктивной GROUP не будет. **Уровень проекта соединяется по `d.prog_id = n.prog_id AND d.prj_id = n.prj_id` (`0053:1289`) — при `prog_id IS NULL` равенство ложно, суммы ITEM самостоятельного проекта потеряются.** Заменить сравнение `prog_id` на `IS NOT DISTINCT FROM` во всех соединениях уровня проекта (`prj_names` ↔ агрегаты, пустые проекты из `all_projects`); для строк с непустой программой результат идентичен. ROOT суммирует все расходы раздела один раз (как сейчас). - Исторические расходы FORM_4 без проекта и программы (`project_id IS NULL`) остаются как есть: `sort_path` с `999999`, только в ROOT. Самостоятельные проекты используют реальные id (< 999999), пересечения нет. Тест: лист с таким расходом не меняется. -- `sort_path` на уровне под ROOT — id верхнего узла: для программы `prog_id`, для самостоятельного проекта `prj_id`. Id из одной последовательности → порядок создания. Пример: GROUP `[sec, prog_id]`, ITEM в программе `[sec, prog_id, prj_id]`, самостоятельный ITEM `[sec, prj_id, 0]`, его INPUT `[sec, prj_id, 0, line_id]` — точную форму массивов подбирает исполнитель так, чтобы проект шёл сразу за своими расходами и ничего не пересекалось с программами. Сортировка программ и проектов в программах не меняется. +- `sort_path` на уровне под ROOT — id верхнего узла: для программы `prog_id`, для самостоятельного проекта `prj_id`. Id из одной последовательности → порядок создания. Все `sort_path` остаются четырёхэлементными: GROUP `[sec, prog_id, 0, 0]`, ITEM в программе `[sec, prog_id, prj_id, 0]`, самостоятельный ITEM `[sec, prj_id, 0, 0]`, его INPUT `[sec, prj_id, 0, line_id]`. Сортировка программ и проектов в программах не меняется, `sort_path` существующих строк не меняется. - ITEM самостоятельного проекта: `depth = 2` (как у проекта в программе), `header`: `section_code`, `name`, `program_id: null`, `project_id`, `expense_item_id` раздела. Тело функции — от версии 0053 (последней), меняются только перечисленные блоки. @@ -73,26 +73,26 @@ ROOT выводится только при наличии расходов в - Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем. ### D7a. Вид в выгрузке и на экране -Самостоятельный ITEM: отступ названия и раскраска — как у проекта (`row_type` ITEM, `depth` 2); уровень группировки (outline) в Excel — как у программы (1), это получается само из `export_writers._hierarchical_rows` (стек по `row_type`), правка писателя не нужна. На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. +Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это получается само из `export_writers._hierarchical_rows` (стек по `row_type`), правка писателя не нужна. Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. ### D8. AURORA-1980: переименование -- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — `CreateProgramModal` с начальным значением и заголовком «Переименование программы/проекта» (или отдельный компонент — на усмотрение исполнителя). +- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла). - Отправка `program_updated {program_id, name}` / `project_updated {project_id, name}` с обрезанным названием; пустое после обрезки — кнопка сохранения неактивна. -- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по событию и id узла (WS без идентификатора запроса). +- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по `is_self`, событию и id узла (WS без идентификатора запроса; чужое переименование того же узла окно не закрывает). Ошибка приходит только отправителю с исходными `event` и `data`, но `handleMessage` сейчас передаёт в `onError` лишь `data.error` — для `program_updated` / `project_updated` добавляется отдельный канал с полным сообщением. - Обработчики ответов `program_updated` / `project_updated` (их сейчас нет на фронте): имя в строке и агрегаты через `alignNodeRows` + `updateCells`; рассылка приходит всем подключённым. ### D9. AURORA-1979: подпись в третьем столбце -Колонка «Код статьи / подстатьи / позиции» листов AHR/CAP/OPER формы 4: для GROUP с `program_id` — «программа», для ITEM с `project_id` — «проект»; иначе — как сейчас. Чистая функция подписи (unit-тест) в конфиге колонок FORM_4. В выгрузке Excel — та же подпись в той же колонке (нормализатор строк FORM_4 в export_service). `header.section_code` в данных не меняется. +Колонка «Код статьи / подстатьи / позиции» листов AHR/CAP/OPER формы 4: для GROUP с `program_id` — «программа», для ITEM с `project_id` — «проект»; иначе — как сейчас. Чистая функция подписи (unit-тест) — в `rowUtils.js`; в конфиге колонок FORM_4 только строковый признак `valueLabel: 'form4NodeType'`, а `accessorFn` назначает `processColumns` после клонирования: `tableColumns.jsx:131` делает `structuredClone` конфига, функция в нём уронит таблицу. В выгрузке Excel — та же подпись в той же колонке: новый `Form4DepthRowNormalizer` (наследник `Form1DepthRowNormalizer`, который общий для всех форм) только для листов AHR/CAP/OPER формы 4; нормализатор не получает `row_type`, тип строки определяется по `line_id` и id в header. `header.section_code` в данных не меняется, раскраска (`getColorCell`, `export_service.py:840`) читает его из исходной строки. ### D10. AURORA-1989: окно выбора расхода `SelectExpenseItemModal`: подпись варианта `«${item_id} — ${name}»` (если `item_id` пуст — только название); сортировка на клиенте естественная по `item_id` (числовые части сравниваются как числа: «3.2.» < «3.10.»), без кода — в конце; поиск MUI Autocomplete по подписи (номер и название). Чистые функции подписи/сравнения — в utils с unit-тестами. Серверный запрос не меняется. ### D11. Миграция -`api/alembic/versions/0059_form4_standalone_project.py` + `sql/0059_form4_standalone_project.sql`. Порядок: ограничение → `DROP FUNCTION add_form4_project(varchar, int4)` → новая `add_form4_project` → `upd_/del_form4_project` → `v_form4_sheet_jsonb`. Downgrade — возврат версий из 0024/0026/0053 и прежнего ограничения (падает, если есть самостоятельные проекты — это ожидаемо, текст ошибки понятный). Номер 0059 может сдвинуться при rebase на свежий `test`. +`api/alembic/versions/0059_form4_standalone_project.py` + `sql/0059_form4_standalone_project.sql`. Порядок: ограничение → `DROP FUNCTION add_form4_project(varchar, int4)` → новая `add_form4_project` → `upd_/del_form4_project` → `v_form4_sheet_jsonb`. Downgrade — `pass`, как у SQL-миграций репозитория (26 из 29 последних; 0058 — образец). Номер 0059 может сдвинуться при rebase на свежий `test`. ## Тестирование -- Интеграционные (`api/tests/integration/test_form4_standalone_project.py`, по образцу `test_form4_sheet_sums.py`): создание самостоятельного проекта (WS или SQL + GET листа) — ITEM под ROOT, нет GROUP с пустым `program_id`; порядок «программа, проект, программа» по созданию; суммы ROOT = программа + самостоятельный проект; правка ячейки расхода — состав ответа (INPUT, ITEM, ROOT, без GROUP) и равенство `data` с GET; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — GET побайтно равен результату до миграции (эталон снимается до миграции в том же тесте или фикстурой). +- Интеграционные (`api/tests/integration/test_form4_standalone_project.py`, по образцу `test_form4_sheet_sums.py`): создание самостоятельного проекта (WS или SQL + GET листа) — ITEM под ROOT, нет GROUP с пустым `program_id`; порядок «программа, проект, программа» по созданию; суммы ROOT = программа + самостоятельный проект; правка ячейки расхода — состав ответа (INPUT, ITEM, ROOT, без GROUP) и равенство `data` с GET; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — результат `v_form4_sheet_jsonb` совпадает побайтно с версией 0053: тесты идут на БД после миграций, поэтому эталон — тело функции из `0053_*.sql`, созданное в тесте как `pg_temp`-функция. - Существующие тесты FORM_4 (`test_form4_sheet_sums.py`, `test_form4_transfer_econ.py`) — зелёные без правок. - Фронт (`node --test`): цель «Добавить проект» по выделению; вставка ITEM под ROOT / на верхний уровень; подпись третьего столбца; подпись/сортировка/фильтр статей расходов. - Выгрузка: unit или интеграционный тест — самостоятельный проект в Excel без лишней строки, подпись «проект». @@ -103,12 +103,12 @@ ROOT выводится только при наличии расходов в - Перенумерация `sort_order` сервером при появлении/исчезновении ROOT — фронт сопоставляет агрегаты по id (`alignNodeRows`), но позиция вставки нового ITEM опирается на `sort_order`; при расхождении строка может встать не на своё место до перезагрузки. Митигация — позиция по `sort_order` соседей из ответа, тест на пустой раздел. - Ветка поверх #304: если #304 изменится по ревью, нужен rebase. - Excel: самостоятельный ITEM на уровне группировки 1 (вместо 2) — проверить вид, не ломать раскладку колонок по `depth`. +- `rowUtils.getParentId` для ROOT и GROUP возвращает `expense_item_id`, и `insertCell` сравнивает его с `project_id`: при совпадении чисел новый расход встанет под ROOT до перезагрузки. Поведение существовало и для проектов в программах; в этой ветке не меняется (O-1). +- GET срезает null (AURORA-1848), поэтому `header.program_id` приходит то `null`, то `undefined`: сравнивать только через `!= null`. ## Оставлено исполнителю -- Точная форма `sort_path` (D3) при соблюдении порядка и непересечения. -- Отдельный компонент окна переименования или переиспользование `CreateProgramModal`. -- Размещение функции подписи третьего столбца в конфиге колонок FORM_4. +Пунктов нет: форма `sort_path`, окно переименования и размещение подписи зафиксированы при перепроверке 08.11. ## Decisions made without discussion @@ -123,5 +123,11 @@ ROOT выводится только при наличии расходов в | 2026-11-08 | Окно переименования закрывается по ответу сервера, сопоставление по событию и id | требование «при ошибке сохранять введённое значение» | при двух быстрых переименованиях одного узла — ответ может прийти «не тому» окну (маловероятно) | | 2026-11-08 | Соединения уровня проекта в `v_form4_sheet_jsonb` — `IS NOT DISTINCT FROM` по `prog_id` (находка рецензента дизайна) | иначе суммы самостоятельного проекта теряются | при ошибке — изменение сумм существующих форм; закрывается тестом эквивалентности GET | | 2026-11-08 | Ограничение `form_id IS NOT NULL` для узлов без родителя | самостоятельный проект без формы не должен существовать | миграция упадёт, если в данных есть программа без формы — проверка перед `ADD CONSTRAINT` | -| 2026-11-08 | Outline самостоятельного проекта в Excel — уровень программы (1), отступ — как у проекта | получается без правки писателя, «нет уровня программы» | если заказчик ждёт отступ как у программы — правка нормализатора | +| 2026-11-08 | Outline и отступ самостоятельного проекта — уровень программы (1), на экране и в Excel | получается без правки писателя и `Cell.jsx`, «нет уровня программы» | если заказчик ждёт отступ как у проекта — правка отображения | | 2026-11-08 | Гонку одновременного создания не тестируем, позиция до перезагрузки может отличаться | сервер упорядочивает по id, редкий случай | редкое визуальное расхождение до F5 | +| 2026-11-08 | R-1 (design #13): подпись третьего столбца — через строковый признак в конфиге и `accessorFn` в `processColumns` | `structuredClone` конфига падает на функциях | правка одной ветки в `processColumns` | +| 2026-11-08 | R-2 (design #14): отдельный `Form4DepthRowNormalizer` для AHR/CAP/OPER формы 4 | `Form1DepthRowNormalizer` общий для всех форм, его правка задела бы их выгрузки | лишний класс из нескольких строк | +| 2026-11-08 | R-3 (design #15): отдельное окно `RenameNodeModal`, закрытие по `is_self` + событие + id, отдельный канал ошибок | `CreateProgramModal` закрывается сразу, ошибка теряет событие и id | лишний компонент; чужой ответ окно не закроет | +| 2026-11-08 | R-4 (design #16): downgrade миграции 0059 — `pass` вместо возврата трёх функций | соглашение репозитория; ~800 строк копий тел, которые никто не запускает | откат только вручную | +| 2026-11-08 | R-5 (design #17): эталон «как до миграции» — `pg_temp`-копия функции из `0053_*.sql` | тесты идут на БД после миграций, снять снимок до неё нельзя | тест привязан к файлу 0053 | +| 2026-11-08 | R-6 (design #18): права на переименование и удаление оставлены как есть — членство в форме (`_validate_form4_node_access`), без `v3.can_edit` | D4 не меняет `upd/del_form4_program`; единообразно для программ и проектов; кнопки в UI скрыты при `canModifyRows = false` | прямой запрос по WS может переименовать или удалить узел при закрытом окне ввода | diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md index 00d39bb..5b25b66 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md @@ -23,7 +23,7 @@ **Не меняется:** - существующие данные: все проекты остаются в своих программах, перенос проекта в программу и из неё не делается; - своды, листы SMETA и STRUCTURE (не читают программы и проекты); -- права: создание, правка и удаление самостоятельного проекта проверяются так же, как у программы (`v3.can_edit` по форме и разделу); +- права: создание самостоятельного проекта проверяется так же, как у программы (`v3.can_edit` по форме и разделу), переименование и удаление — тоже как у программы (членство в форме, `_validate_form4_node_access`); - контракт строк листа для программ и проектов в программах. ## Capabilities @@ -37,6 +37,6 @@ ## Impact - SQL: миграция `0059_*.py` + `sql/0059_*.sql`: ограничение `form4_project`, `v3.add_form4_project` (новая сигнатура — DROP/CREATE), `v3.upd_form4_project`, `v3.del_form4_project`, `v3.v_form4_sheet_jsonb` (тело — от версии 0053). -- Python: `websocket.py` (`__add_project`), `SheetService.add_project` (проверка доступа по форме без программы), `SheetRepository.add_project`; при необходимости — export_service (подпись «программа»/«проект» в третьем столбце). -- Фронт: `RealtimeTable.jsx`, `RealtimeContext.js`, `useRealtimeData.js`, `utils/cellUtils.js`, модалки создания/переименования, `SelectExpenseItemModal.jsx`, конфиг колонок FORM_4. +- Python: `websocket.py` (`__add_project`), `SheetService.add_project` (проверка доступа по форме без программы), `SheetRepository.add_project`, ORM `form4_project.py`; export_service — новый `Form4DepthRowNormalizer` (подпись «программа»/«проект» в третьем столбце). +- Фронт: `RealtimeTable.jsx`, `RealtimeContext.js`, `useRealtimeData.js`, `utils/cellUtils.js`, `utils/rowUtils.js`, `tableColumns.jsx`, `SettingPanel.jsx`, модалки создания/переименования, `SelectExpenseItemModal.jsx`, конфиг колонок FORM_4. - Тесты: интеграционные (лист, итоги, создание/правка/удаление самостоятельного проекта, ответ `_form_affected_rows`), unit фронта (`node --test`), живая проверка на стенде.