714 lines
48 KiB
Markdown
714 lines
48 KiB
Markdown
# 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`. Исправления кода — коммитами с тестом.
|