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