48 KiB
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
- Первый расход в разделе, где уже стоит самостоятельный проект, а ROOT ещё нет — ROOT появляется над проектом, расход встаёт под проект (задача 4,
insertCell после addRootRow). - В разделе есть исторический расход без проекта (
999999) — новый проект встаёт перед ним и на сервере, и локально (задачи 1 и 4). - Самостоятельный проект раздела CAP не появляется на листе AHR той же формы (задача 1).
- Другой пользователь переименовал тот же узел, пока у меня открыто окно — моё окно не закрывается (задача 5,
isRenameResponse). - Удаление последнего пустого самостоятельного проекта в разделе без 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_pathITEM[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 по порядку:
DO-блок:RAISE EXCEPTION, если есть строка сparent_id IS NULL AND form_id IS NULL.DROP CONSTRAINT chk_form4_project_section_codeиADD CONSTRAINTс((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL).ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL).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_pathINPUT (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_projectprogram_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 } | nullisRenameResponse(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>: MUITooltipне работает на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. Исправления кода — коммитами с тестом.