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

48 KiB
Raw Blame History

AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: На форме 4 проект создаётся прямо в разделе, без программы, и живёт в листе, итогах и выгрузке наравне с проектом в программе; плюс подпись типа в третьем столбце, переименование программ и проектов, удобный выбор статьи расхода.

Architecture: Самостоятельный проект — запись v3.form4_project с level='project', parent_id IS NULL и своими section_code/form_id. Миграция 0059 ослабляет ограничение, расширяет add_/upd_/del_form4_project и v_form4_sheet_jsonb (ITEM прямо под ROOT, порядок по id верхнего узла). Сервер принимает project_added без программы, фронт вставляет ITEM под ROOT и получает окно переименования; подпись типа считается при отображении, данные не меняются.

Tech Stack: PostgreSQL (PL/pgSQL, Alembic), FastAPI + SQLAlchemy async, React 18 (JSX) + material-react-table, pytest, node --test.

Spec: openspec/changes/aurora-1984-pd-standalone-projects/ — proposal.md, design.md (D1–D11, поправлен при перепроверке 08.11), specs/form4-programs-projects/spec.md.

Global Constraints

  • Worktree .claude/worktrees/aurora-1984, ветка feat/305-aurora-1984-pd-standalone-projects поверх PR #304; PR — --base test.
  • Перед изменением — codegraph_explore с projectPath = абсолютный путь worktree.
  • Миграция: revision = "0059", down_revision = "0058", downgrade() — pass, формат — api/alembic/versions/0058_form2_ahr_delta_depth4.py.
  • Тело v3.v_form4_sheet_jsonb копируется из sql/0053_form2_ahr_source_sheet.sql:840-1437, меняются только блоки из задачи 1.
  • v3._form_affected_rows, v3.upd_form4_program, v3.del_form4_program, add_budget_line, del_budget_line не меняются.
  • sort_path и sort_order существующих строк (программы, проекты в программах, расходы на программе, исторические расходы без проекта с 999999) не меняются.
  • header.section_code в данных не меняется: подпись «программа»/«проект» — только при отображении и в выгрузке.
  • В лист и выгрузку не попадает GROUP с пустым program_id.
  • В конфигах колонок (constants/FORM_4/*.js) нет функций: tableColumns.jsx:131 делает structuredClone.
  • header.program_id сравнивать только через != null: GET срезает null (AURORA-1848), по WS приходит null.
  • Подписи ровно: «программа», «проект», «Переименовать», «Переименование программы», «Переименование проекта», «Сохранить», «Отмена».
  • Backend-тесты: cd api && pytest (зелёным считается весь набор), фронт: cd web && node --test src/components/RealtimeTable/utils/*.test.js. Известное падение на базе: tests/unit/test_export_service_query_params.py (O-2 из #304), новых быть не должно.
  • Интеграционным тестам нужен Postgres из DATABASE_URL: стенд perf-pg (docker compose в perf/), отдельная БД, первый прогон с --init_db.
  • Коммиты: сообщение начинается с AURORA-1984: и заканчивается строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Решения без обсуждения дописываются в design.md, раздел ## Decisions made without discussion, с маркером R-n (занято R-1…R-6, O-1).

Review Focus

  1. Первый расход в разделе, где уже стоит самостоятельный проект, а ROOT ещё нет — ROOT появляется над проектом, расход встаёт под проект (задача 4, insertCell после addRootRow).
  2. В разделе есть исторический расход без проекта (999999) — новый проект встаёт перед ним и на сервере, и локально (задачи 1 и 4).
  3. Самостоятельный проект раздела CAP не появляется на листе AHR той же формы (задача 1).
  4. Другой пользователь переименовал тот же узел, пока у меня открыто окно — моё окно не закрывается (задача 5, isRenameResponse).
  5. Удаление последнего пустого самостоятельного проекта в разделе без ROOT — ответ пустой, сервис не падает и отдаёт (id, []) (задача 2).

Task 1: Ограничения и лист

Модель: sonnet

Files:

  • Create: api/alembic/versions/0059_form4_standalone_project.py
  • Create: api/alembic/versions/sql/0059_form4_standalone_project.sql
  • Modify: api/src/db/models/form4_project.py:26-29
  • Test: api/tests/integration/test_form4_standalone_project.py

Interfaces:

  • Consumes: F4, SHEET, _loads из tests/integration/test_form4_sheet_sums.py (импорт from test_form4_sheet_sums import ...).

  • Produces: строка ITEM самостоятельного проекта — row_type='ITEM', depth=2, header = {section_code, name, program_id: null, project_id, expense_item_id: <id раздела>}; sort_path ITEM [sec, prj_id, 0, 0], INPUT [sec, prj_id, 0, line_id].

  • Step 1: Написать падающие тесты

Фикстура f4 — копия из test_form4_sheet_sums.py:157. Хелпер и тесты:

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: Коммит
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 остаются:

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: Коммит
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):

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-параметры доходят до функции):

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: Коммит
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: Написать падающие тесты

// 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: Коммит
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: Написать падающие тесты

// rowUtils.test.js
test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => {
	assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' });
	assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' });
	assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null);
	assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null);
	assert.equal(getRenameTarget(undefined), null);
});

test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => {
	const target = { kind: 'project', id: 21, name: 'Б' };
	const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } };
	assert.equal(isRenameResponse(msg, target), true);
	assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true);
	assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false);
	assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false);
	assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
	assert.equal(isRenameResponse(msg, null), false);
});

// cellUtils.test.js
test('AURORA-1980: ответ переименования обновляет название в строке', () => {
	const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])];
	const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]];
	const result = updateCells(local, alignNodeRows(local, rows));
	assert.equal(result[0].subRows[0].data.header.name, 'Новое');
});
  • Step 2: Убедиться, что тесты падают

Run: cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js Expected: FAIL — getRenameTarget нет в модуле (тест cellUtils проходит: он фиксирует поведение существующих функций).

  • Step 3: Реализовать функции и канал событий

  • RealtimeContext.js, handleMessage: в ветке data.error после тоста и onErrorRef — onNodeRenameResultRef.current?.(data), если data.event — program_updated или project_updated. В switch добавить оба события: при data.result вызвать onNodeUpdateRef.current?.(data) и onNodeRenameResultRef.current?.(data).

  • renameNode(kind, id, name) шлёт { event: ${kind}_updated, data: { [${kind}_id]: id, name } } через addCommonField и sendMessage, как deleteProject.

  • useRealtimeData: subscribeToNodeUpdates((msg) => setData((prev) => updateCells(prev, alignNodeRows(prev, msg.result[1] ?? [])))), отписка — как у subscribeToNodeDeletes.

  • Step 4: Реализовать окно и кнопку

  • RenameNodeModal — разметка как у CreateProgramModal; поле управляемое (value), начальное значение из target.name при смене target; заголовок по target.kind; кнопка «Сохранить» неактивна при name.trim() === '' или isSaving; «Отмена» и клик по фону закрывают без отправки.

  • RealtimeTable.jsx: состояния pendingRename, isRenameSaving рядом с pendingDelete; подписка subscribeToNodeRenameResult в useEffect: при isRenameResponse(msg, pendingRename) — при msg.error снять isRenameSaving (окно и текст остаются), иначе закрыть окно. onSubmit: renameNode(...); при false — окно остаётся, иначе isRenameSaving = true. Рендер рядом с ConfirmDeleteRowModal (:1159).

  • SettingPanel.jsx: в блоке формы 4 после «Добавить проект» — Tooltip «Переименовать», IconButton с onClick={onRenameNode}, disabled={!canRenameNode}, иконка EditSvg из common/icons/icons. Вокруг отключаемой кнопки — <span>: MUI Tooltip не работает на disabled. canRenameNode считается в RealtimeTable при рендере: Boolean(getRenameTarget(getSelectedRow()?.original)); onRenameNode — setPendingRename(getRenameTarget(getSelectedRow()?.original)); оба пропса передаются в панель рядом с onAddProject (:1099).

  • Step 5: Убедиться, что тесты и линт проходят

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable/Modals/RenameNodeModal.jsx Expected: PASS, без ошибок линта.

  • Step 6: Коммит
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: Написать падающие тесты

// 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/);
	});
}
# 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: Коммит
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: Написать падающие тесты

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: Коммит
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. Исправления кода — коммитами с тестом.