From e500c85e81176ff5dec0a5f4277c7340e07424e8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 21:28:34 +0400 Subject: [PATCH 01/13] =?UTF-8?q?AURORA-1984:=20OpenSpec=20change=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B=20=D0=9F?= =?UTF-8?q?=D0=94=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=BC=D1=8B=20(+1979,=201980,=201989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #305 Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 2 + .../design.md | 127 ++++++++++++++++++ .../proposal.md | 42 ++++++ .../specs/form4-programs-projects/spec.md | 92 +++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 openspec/changes/aurora-1984-pd-standalone-projects/.openspec.yaml create mode 100644 openspec/changes/aurora-1984-pd-standalone-projects/design.md create mode 100644 openspec/changes/aurora-1984-pd-standalone-projects/proposal.md create mode 100644 openspec/changes/aurora-1984-pd-standalone-projects/specs/form4-programs-projects/spec.md diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/.openspec.yaml b/openspec/changes/aurora-1984-pd-standalone-projects/.openspec.yaml new file mode 100644 index 0000000..c0ad5ed --- /dev/null +++ b/openspec/changes/aurora-1984-pd-standalone-projects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-08 diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md new file mode 100644 index 0000000..25e1128 --- /dev/null +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -0,0 +1,127 @@ +# Design + +## Context + +Форма 4 (FORM_4) в БД — «Смета РФ поддержка», на фронте — «Проектная деятельность». Листы AHR/CAP/OPER строятся `v3.v_form4_sheet_jsonb` (последняя версия — `sql/0053_form2_ahr_source_sheet.sql:840`). Иерархия листа: ROOT (раздел, depth 0) → GROUP (программа, depth 1) → ITEM (проект, depth 2) → INPUT (расход, `budget_line`, depth 3). `sort_order` — `ROW_NUMBER() OVER (ORDER BY sort_path)`, где `sort_path` у INPUT — `ARRAY[sec_id, COALESCE(prog_id, 999999), …, line_id]`. ROOT выводится только если в разделе есть хотя бы один расход (`root_names` из `inp`); пустые программы и проекты выводятся из `all_programs` / `all_projects`. + +Таблица `v3.form4_project` (`sql/0010_form4.sql:25`): `level IN ('program','project')`, `parent_id` (self-FK), `section_code`, `form_id`. Ограничения: `chk_form4_project_parent` — у программы `parent_id IS NULL`; `chk_form4_project_section_code` — `(level = 'program') = (section_code IS NOT NULL)`. `budget_line.project_id` → `form4_project.id` (FK); расход может ссылаться и на программу напрямую (ветка «B» в `inp`, исторические данные). + +Операции (`sql/0024_upd_form4_projects.sql`, `sql/0026_budget_line_stage_access.sql:486`): `add_form4_program(name, form_id, section)`, `add_form4_project(name, program_id)`, `upd_/del_form4_program`, `upd_/del_form4_project`. Все возвращают `(project_id, row_type, depth, sort_order, data)` — затронутые строки листа. WebSocket (`api/src/api/v1/websocket.py:470-505`): `program_added`, `project_added` (`{name, program_id}`), `program_updated`, `program_deleted`, `project_updated`, `project_deleted`; рассылка всем подключённым к листу. + +Ответ на правку ячейки для FORM_4 — `v3._form_affected_rows` (`sql/0052_…:3293`): полный лист, отбор INPUT + ROOT + GROUP по `program_id` + ITEM по `project_id` изменённых строк. + +Фронт: `web/src/components/RealtimeTable/` — `RealtimeTable.jsx` (кнопки «Добавить программу/проект», `getDeleteTarget` из AURORA-1978), `hooks/useRealtimeData.js` (обработчики событий, `insertProjectRow` по `program_id`), `utils/cellUtils.js` (`alignNodeRows`, `deleteProgramRow`/`deleteProjectRow` из AURORA-1978), `utils/rowUtils.js` (`buildRowHierarchy` по `row_type`, переживает пропуск уровня). Выгрузка Excel (`api/src/services/export_service/`) берёт те же строки листа, иерархию строит по `row_type` (`export_writers._hierarchical_rows`). + +Кто ещё читает программы/проекты — проверено 08.11: своды (`v_summary_view`), SMETA (`v_form4_smeta`) и STRUCTURE (`v_form4_view`, ветка STRUCTURE) — нет; `hard_delete_budget_form` удаляет по `form_id`; `add_budget_line` проверяет только существование `project_id`. + +Ветка `feat/305-aurora-1984-pd-standalone-projects` начата от `feat/303-aurora-1978-pd-delete-program-project` (PR #304, не влит на 08.11). + +## Goals / Non-Goals + +**Goals** +- Самостоятельный проект: создание, расходы, переименование, удаление пустого, место в листе в порядке создания, суммы в ROOT ровно один раз, выгрузка. +- AURORA-1979, 1980, 1989 (см. proposal). + +**Non-Goals** +- Перенос существующего проекта в программу и из неё. +- Миграция существующих данных: проекты в программах не трогаются. +- Расходы, привязанные прямо к программе (ветка «B»), — поведение не меняется. +- Изменение сводов, листов SMETA/STRUCTURE. + +**Anti-goals** (сломать — хуже, чем не сделать) +- Фиктивная GROUP с пустым `program_id` в листе или выгрузке; двойной учёт сумм самостоятельного проекта в ROOT. +- Любое изменение вида листа и сумм для форм без самостоятельных проектов. + +## 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`. + +### 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 раздела (если он есть в листе). + +### D3. Лист: ITEM под ROOT, порядок по id верхнего узла +В `v3.v_form4_sheet_jsonb`: +- `inp`: для расхода самостоятельного проекта `prj_id` = проект (соединение `prj` по `level='project'` его находит), `prog_via_prj` и `prog_direct` пусты → `prog_id` = NULL. +- `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]` — точную форму массивов подбирает исполнитель так, чтобы проект шёл сразу за своими расходами и ничего не пересекалось с программами. Сортировка программ и проектов в программах не меняется. +- ITEM самостоятельного проекта: `depth = 2` (как у проекта в программе), `header`: `section_code`, `name`, `program_id: null`, `project_id`, `expense_item_id` раздела. +Тело функции — от версии 0053 (последней), меняются только перечисленные блоки. + +### D4. Правка и удаление без родителя +`upd_form4_project` / `del_form4_project`: раздел — `COALESCE(программа.section_code, проект.section_code)`; в возвращаемых строках GROUP только при наличии родителя. `del_form4_project` — прежний запрет при расходах. `upd/del_form4_program` не меняются. + +### D4a. Контракт ответа, когда ROOT раздела нет +ROOT выводится только при наличии расходов в разделе (как сейчас, не меняется). Ответ `add_/upd_/del_form4_project` для самостоятельного проекта: затронутый ITEM (кроме удаления) и ROOT раздела, **если ROOT есть в листе после операции**; `sort_order` — глобальный, из полной сборки листа (как у всех функций, строящих ответ из `v_form4_sheet_jsonb`). В пустом разделе ответ на создание — один ITEM. Первый расход в разделе возвращает ROOT существующим путём `add_budget_line` → фронт добавляет ROOT (`addRootRow` в `useRealtimeData`), это поведение уже есть. Фронт вставляет ITEM под ROOT, если ROOT есть локально, иначе на верхний уровень. + +### D5. `_form_affected_rows` не меняется +Для расхода самостоятельного проекта `program_id` в header INPUT пустой → GROUP не отбирается, ITEM — по `project_id`, ROOT — по пути статей. Покрывается интеграционным тестом (спека: «Правка расхода»). Если тест покажет иначе — правка в той же миграции. + +### D6. Сервер (Python) +- `websocket.py` `__add_project`: `program_id` необязателен; без него — `form_id` и `sheet` соединения передаются в сервис. +- `SheetService.add_project`: без программы — проверка доступа по форме, как в `add_program` (не админ → форма среди оргединиц пользователя), затем `sheet_repo.add_project(name, program_id=None, form_id, section_code=sheet)`. +- `SheetRepository.add_project` — вызов новой сигнатуры с именованными параметрами. +- Ответ `project_added`: `[project_id, rows]` как сейчас. + +### D7. Фронт: создание и вставка +- «Добавить проект» доступна без выделения. Цель по выделенной строке (функция рядом с `getDeleteTarget` в `rowUtils.js`, с unit-тестом): GROUP → `program_id` этой программы; ITEM/INPUT → `program_id` из header (может быть null → в разделе); ROOT или ничего → в разделе. Отправка `project_added {name}` или `{name, program_id}`. +- Обработка ответа: новый ITEM — в `subRows` программы (как сейчас) или под ROOT раздела, либо на верхний уровень, если ROOT в локальном дереве нет. Позиция — по `sort_order` среди соседей; агрегаты из ответа вливаются через `alignNodeRows` (сопоставление по id, а не по `sort_order`, см. AURORA-1978). Если в ответе пришёл ROOT, которого локально нет, — добавить его (аналог `addRootRow`). +- Удаление — без изменений (AURORA-1978 работает по `project_id`). +- Добавление расхода к самостоятельному проекту — существующий путь без правок: `SelectExpenseItemModal` берёт статьи по листу (`ExpenseItemApi.getAll({ r_start, sheet })`), раздел самостоятельного проекта = лист; `add_budget_line` раздел по проекту не проверяет, только существование `project_id`; `handleAddExpenseItemRow` берёт `project_id` из header ITEM. +- Одновременное создание проектов двумя пользователями: сервер упорядочивает по 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. + +### D8. AURORA-1980: переименование +- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — `CreateProgramModal` с начальным значением и заголовком «Переименование программы/проекта» (или отдельный компонент — на усмотрение исполнителя). +- Отправка `program_updated {program_id, name}` / `project_updated {project_id, name}` с обрезанным названием; пустое после обрезки — кнопка сохранения неактивна. +- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по событию и id узла (WS без идентификатора запроса). +- Обработчики ответов `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` в данных не меняется. + +### 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/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 побайтно равен результату до миграции (эталон снимается до миграции в том же тесте или фикстурой). +- Существующие тесты FORM_4 (`test_form4_sheet_sums.py`, `test_form4_transfer_econ.py`) — зелёные без правок. +- Фронт (`node --test`): цель «Добавить проект» по выделению; вставка ITEM под ROOT / на верхний уровень; подпись третьего столбца; подпись/сортировка/фильтр статей расходов. +- Выгрузка: unit или интеграционный тест — самостоятельный проект в Excel без лишней строки, подпись «проект». +- Живая проверка на стенде (perf-pg, форма 4): все сценарии спеки, две вкладки для рассылки. + +## Risks / Trade-offs + +- Перенумерация `sort_order` сервером при появлении/исчезновении ROOT — фронт сопоставляет агрегаты по id (`alignNodeRows`), но позиция вставки нового ITEM опирается на `sort_order`; при расхождении строка может встать не на своё место до перезагрузки. Митигация — позиция по `sort_order` соседей из ответа, тест на пустой раздел. +- Ветка поверх #304: если #304 изменится по ревью, нужен rebase. +- Excel: самостоятельный ITEM на уровне группировки 1 (вместо 2) — проверить вид, не ломать раскладку колонок по `depth`. + +## Оставлено исполнителю + +- Точная форма `sort_path` (D3) при соблюдении порядка и непересечения. +- Отдельный компонент окна переименования или переиспользование `CreateProgramModal`. +- Размещение функции подписи третьего столбца в конфиге колонок FORM_4. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-11-08 | Ветка от `feat/303-aurora-1978-…` (PR #304), а не от `test` | удаление программ/проектов и `alignNodeRows` уже там; 1984 их использует | rebase при изменениях #304 по ревью | +| 2026-11-08 | «Добавить проект»: программа → в ней; раздел/ничего → в разделе; проект/расход → рядом (та же программа или раздел) | естественное продолжение текущей кнопки, без новой кнопки | при другом ожидании — правка выбора цели (одна функция) | +| 2026-11-08 | Одна функция `add_form4_project` с DEFAULT-параметрами вместо новой | старые вызовы работают, одна точка прав и аудита | DROP/CREATE функции в миграции | +| 2026-11-08 | Самостоятельный ITEM с `depth = 2`, без новой глубины | фронт и выгрузка строят дерево по `row_type`, вид как у проекта | отступ в Excel/на экране может потребовать правки | +| 2026-11-08 | Подпись «программа»/«проект» (1979) и в выгрузке Excel | выгрузка повторяет экран (как 1902/1903) | лишняя правка export_service, если заказчику нужен код | +| 2026-11-08 | Сортировка и подпись статей (1989) на клиенте | список короткий, сервер не трогаем | при большом списке — перенос на сервер | +| 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 | Гонку одновременного создания не тестируем, позиция до перезагрузки может отличаться | сервер упорядочивает по id, редкий случай | редкое визуальное расхождение до F5 | diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md new file mode 100644 index 0000000..00d39bb --- /dev/null +++ b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md @@ -0,0 +1,42 @@ +# Proposal + +## Why + +На форме 4 (FORM_4, на фронте «Проектная деятельность») иерархия листа жёсткая: раздел (лист AHR/CAP/OPER) → программа → проект → расходы. Проект без программы создать нельзя: интерфейс требует выделить программу, `v3.add_form4_project` отвергает вызов без `p_program_id`, у проекта нет своего раздела (ограничение `chk_form4_project_section_code`), а лист находит проекты только через программу. Пользователю приходится заводить программу-обёртку ради одного проекта. + +Замечание тестировщика AURORA-1984 (Gitea #305): проект можно создать прямо в разделе, без программы. Человек (08.11.2026): «всё то же самое, как и сейчас, только нет уровня программы»; в разделе программы и самостоятельные проекты стоят вперемешку в порядке создания. + +В той же ветке закрываются три смежных замечания по ПД: AURORA-1979 (подпись «программа»/«проект» в третьем столбце), AURORA-1980 (переименование программы и проекта), AURORA-1989 (окно выбора расхода: номер статьи, сортировка, поиск). + +## What Changes + +- **Самостоятельный проект.** Запись `v3.form4_project` с `level='project'`, `parent_id IS NULL`, собственными `section_code` и `form_id`. Ограничение `chk_form4_project_section_code` ослабляется: раздел обязателен у программы и у проекта без родителя, запрещён у проекта в программе. +- **Создание.** `v3.add_form4_project` принимает либо программу (как сейчас), либо форму и раздел. WS-событие `project_added` принимает `program_id` необязательно; без него проект создаётся в разделе текущего листа соединения. +- **Лист.** `v3.v_form4_sheet_jsonb` выводит самостоятельный проект строкой ITEM прямо под ROOT раздела (без GROUP), с расходами под ним. Порядок на втором уровне — по id верхнего узла (программы или самостоятельного проекта), то есть в порядке создания. Суммы проекта входят в ROOT один раз; фиктивной GROUP с `program_id = NULL` нет. +- **Правка и удаление.** `v3.upd_form4_project` и `v3.del_form4_project` работают и без родителя: раздел берётся у проекта, в ответе нет GROUP. Запрет удалять проект с расходами — прежний. +- **Фронт.** «Добавить проект»: выделена программа → внутри неё; выделена строка раздела или ничего → в разделе; выделен проект или расход → рядом, в той же программе или в разделе. Вставка нового ITEM под ROOT. Удаление (AURORA-1978) уже работает по `project_id`. +- **AURORA-1979.** В третьем столбце листов ПД у программы «программа», у проекта (в программе и самостоятельного) «проект» вместо кода раздела; так же в выгрузке Excel. Коды статей у строк расходов и ROOT не меняются. +- **AURORA-1980.** Действие «Переименовать» для выделенной программы или проекта: окно с текущим названием, события `program_updated` / `project_updated` (сервер уже поддерживает). Пустое название и пробелы не сохраняются; при ошибке окно остаётся открытым с введённым текстом. +- **AURORA-1989.** Окно выбора расхода: «номер статьи — название», сортировка по номеру статьи с учётом числовых частей (2 раньше 10), поиск по номеру и названию. +- Новая миграция `0059_form4_standalone_project` (номер — следующий после 0058 на момент ветки). + +**Не меняется:** +- существующие данные: все проекты остаются в своих программах, перенос проекта в программу и из неё не делается; +- своды, листы SMETA и STRUCTURE (не читают программы и проекты); +- права: создание, правка и удаление самостоятельного проекта проверяются так же, как у программы (`v3.can_edit` по форме и разделу); +- контракт строк листа для программ и проектов в программах. + +## Capabilities + +### New Capabilities +- `form4-programs-projects`: программы и проекты листов AHR/CAP/OPER формы 4 — создание (в программе и в разделе), переименование, удаление, место в листе и в итогах, подпись типа в третьем столбце, выбор расхода для проекта. + +### Modified Capabilities +Нет. + +## 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. +- Тесты: интеграционные (лист, итоги, создание/правка/удаление самостоятельного проекта, ответ `_form_affected_rows`), unit фронта (`node --test`), живая проверка на стенде. diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/specs/form4-programs-projects/spec.md b/openspec/changes/aurora-1984-pd-standalone-projects/specs/form4-programs-projects/spec.md new file mode 100644 index 0000000..34e8b74 --- /dev/null +++ b/openspec/changes/aurora-1984-pd-standalone-projects/specs/form4-programs-projects/spec.md @@ -0,0 +1,92 @@ +# Spec Delta + +## Purpose + +Программы и проекты на листах AHR/CAP/OPER формы 4 (FORM_4, «Проектная деятельность»): как они создаются, переименовываются и удаляются, где стоят в листе, как участвуют в итогах и выгрузке, как подписаны и как к проекту добавляется расход. + +## ADDED Requirements + +### Requirement: Проект создаётся в программе или прямо в разделе +Пользователь с правом добавлять строки в раздел листа SHALL иметь возможность создать проект как внутри программы этого раздела, так и прямо в разделе, без программы. Название SHALL быть непустым после обрезки пробелов. Права SHALL проверяться так же, как при создании программы в этом разделе. + +#### Scenario: Проект в разделе +- **WHEN** на листе CAP выделена строка раздела (или ничего не выделено) и пользователь создаёт проект «П1» +- **THEN** «П1» появляется в разделе CAP без программы у всех подключённых к листу пользователей без перезагрузки +- **AND** после перезагрузки страницы «П1» стоит на том же месте + +#### Scenario: Проект в программе — как раньше +- **WHEN** выделена программа «Пр» и пользователь создаёт проект +- **THEN** проект создаётся внутри «Пр» + +#### Scenario: Выделен проект или расход +- **WHEN** выделен проект или строка расхода и пользователь создаёт проект +- **THEN** новый проект создаётся там же, где выделенный проект: в той же программе или прямо в разделе + +#### Scenario: Пустое название +- **WHEN** пользователь вводит название из одних пробелов +- **THEN** проект не создаётся + +### Requirement: Место самостоятельного проекта в листе +Самостоятельный проект SHALL выводиться строкой ITEM непосредственно под ROOT своего раздела, без строки GROUP над ним; его расходы — строками INPUT под ним. На уровне под ROOT программы и самостоятельные проекты SHALL идти в порядке создания (по id записи `form4_project`). Проекты внутри программы и их расходы SHALL выводиться как раньше. + +#### Scenario: Вперемешку в порядке создания +- **WHEN** в разделе последовательно созданы программа «А», самостоятельный проект «Б», программа «В» +- **THEN** под ROOT раздела строки идут в порядке «А» (GROUP), «Б» (ITEM), «В» (GROUP) + +#### Scenario: Пустой раздел +- **WHEN** в разделе нет ни одного расхода, но есть самостоятельный проект +- **THEN** проект виден в листе так же, как видна пустая программа + +### Requirement: Суммы самостоятельного проекта +Суммы расходов самостоятельного проекта SHALL входить в строку ITEM проекта и в ROOT раздела ровно один раз. Лист SHALL NOT содержать строку GROUP без программы (`program_id` пустой). Ответ на правку ячейки расхода самостоятельного проекта SHALL содержать строку расхода, ITEM проекта и ROOT раздела. + +#### Scenario: Итог раздела +- **WHEN** в разделе есть программа с расходом 100 и самостоятельный проект с расходом 50 в `plan.q1` +- **THEN** ROOT раздела показывает 150 в `plan.q1`, ITEM самостоятельного проекта — 50 + +#### Scenario: Правка расхода +- **WHEN** пользователь меняет `plan.q1` у расхода самостоятельного проекта +- **THEN** ответ содержит этот расход, ITEM проекта и ROOT раздела с пересчитанными суммами и не содержит GROUP + +### Requirement: Расходы самостоятельного проекта +К самостоятельному проекту SHALL можно добавлять и удалять расходы так же, как к проекту в программе. + +#### Scenario: Добавление расхода +- **WHEN** выделен самостоятельный проект и пользователь добавляет расход по статье +- **THEN** расход появляется под этим проектом, суммы проекта и раздела пересчитываются + +### Requirement: Переименование программы и проекта +Пользователь с правом изменения SHALL иметь возможность переименовать выделенную программу или проект (в программе и самостоятельный). Окно переименования SHALL открываться с текущим названием. Пустое название и название из одних пробелов SHALL NOT сохраняться. При ошибке сервера окно SHALL оставаться открытым с введённым текстом и показывать понятное сообщение. Отмена SHALL оставлять прежнее название. Переименование SHALL NOT менять состав проектов, расходов и суммы. + +#### Scenario: Успешное переименование +- **WHEN** пользователь переименовывает программу «А» в «А2» +- **THEN** у всех подключённых пользователей строка показывает «А2», после перезагрузки тоже + +#### Scenario: Ошибка +- **WHEN** сервер отказывает в переименовании (например, нет прав) +- **THEN** окно остаётся открытым с введённым названием и показывает причину + +### Requirement: Удаление самостоятельного проекта +Пустой самостоятельный проект SHALL удаляться с подтверждением, как проект в программе (AURORA-1978). Проект с расходами SHALL NOT удаляться, пользователь видит причину. + +#### Scenario: Удаление пустого +- **WHEN** пользователь удаляет пустой самостоятельный проект и подтверждает +- **THEN** проект исчезает из листа у всех подключённых, ROOT раздела пересчитывается + +### Requirement: Подпись типа в третьем столбце +В третьем столбце листов AHR/CAP/OPER формы 4 строка программы SHALL быть подписана «программа», строка проекта (в программе и самостоятельного) — «проект». Строки ROOT и расходов SHALL показывать код, как раньше. Подпись SHALL совпадать на экране и в выгрузке Excel. + +#### Scenario: Подписи +- **WHEN** открыт лист CAP с программой, проектом в ней и самостоятельным проектом +- **THEN** напротив программы «программа», напротив обоих проектов «проект», у расходов — код статьи + +### Requirement: Выбор расхода для проекта +Окно выбора расхода SHALL показывать варианты в виде «<номер статьи> — <название>», упорядоченные по номеру статьи с учётом числовых частей (например, 2 раньше 10). Поиск SHALL находить вариант по номеру и по названию. Выбор SHALL добавлять расход именно по выбранной статье. + +#### Scenario: Сортировка +- **WHEN** среди статей есть номера «3.2.» и «3.10.» +- **THEN** «3.2.» стоит выше «3.10.» + +#### Scenario: Поиск по номеру +- **WHEN** пользователь вводит «3.01» +- **THEN** в списке остаются статьи, номер которых содержит «3.01» -- 2.47.2 From 551066bb0563429863da7dabc01a7fd9c12d9344 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:14:29 +0400 Subject: [PATCH 02/13] =?UTF-8?q?AURORA-1984:=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=D0=B0=20=D0=B8=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...1-08-aurora-1984-pd-standalone-projects.md | 713 ++++++++++++++++++ .../design.md | 30 +- .../proposal.md | 6 +- 3 files changed, 734 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md diff --git a/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md b/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md new file mode 100644 index 0000000..3605986 --- /dev/null +++ b/docs/superpowers/plans/2026-11-08-aurora-1984-pd-standalone-projects.md @@ -0,0 +1,713 @@ +# AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** На форме 4 проект создаётся прямо в разделе, без программы, и живёт в листе, итогах и выгрузке наравне с проектом в программе; плюс подпись типа в третьем столбце, переименование программ и проектов, удобный выбор статьи расхода. + +**Architecture:** Самостоятельный проект — запись `v3.form4_project` с `level='project'`, `parent_id IS NULL` и своими `section_code`/`form_id`. Миграция 0059 ослабляет ограничение, расширяет `add_/upd_/del_form4_project` и `v_form4_sheet_jsonb` (ITEM прямо под ROOT, порядок по id верхнего узла). Сервер принимает `project_added` без программы, фронт вставляет ITEM под ROOT и получает окно переименования; подпись типа считается при отображении, данные не меняются. + +**Tech Stack:** PostgreSQL (PL/pgSQL, Alembic), FastAPI + SQLAlchemy async, React 18 (JSX) + material-react-table, `pytest`, `node --test`. + +**Spec:** `openspec/changes/aurora-1984-pd-standalone-projects/` — `proposal.md`, `design.md` (D1–D11, поправлен при перепроверке 08.11), `specs/form4-programs-projects/spec.md`. + +## Global Constraints + +- Worktree `.claude/worktrees/aurora-1984`, ветка `feat/305-aurora-1984-pd-standalone-projects` поверх PR #304; PR — `--base test`. +- Перед изменением — `codegraph_explore` с `projectPath` = абсолютный путь worktree. +- Миграция: `revision = "0059"`, `down_revision = "0058"`, `downgrade()` — `pass`, формат — `api/alembic/versions/0058_form2_ahr_delta_depth4.py`. +- Тело `v3.v_form4_sheet_jsonb` копируется из `sql/0053_form2_ahr_source_sheet.sql:840-1437`, меняются только блоки из задачи 1. +- `v3._form_affected_rows`, `v3.upd_form4_program`, `v3.del_form4_program`, `add_budget_line`, `del_budget_line` не меняются. +- `sort_path` и `sort_order` существующих строк (программы, проекты в программах, расходы на программе, исторические расходы без проекта с `999999`) не меняются. +- `header.section_code` в данных не меняется: подпись «программа»/«проект» — только при отображении и в выгрузке. +- В лист и выгрузку не попадает GROUP с пустым `program_id`. +- В конфигах колонок (`constants/FORM_4/*.js`) нет функций: `tableColumns.jsx:131` делает `structuredClone`. +- `header.program_id` сравнивать только через `!= null`: GET срезает null (AURORA-1848), по WS приходит `null`. +- Подписи ровно: «программа», «проект», «Переименовать», «Переименование программы», «Переименование проекта», «Сохранить», «Отмена». +- Backend-тесты: `cd api && pytest` (зелёным считается весь набор), фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`. Известное падение на базе: `tests/unit/test_export_service_query_params.py` (O-2 из #304), новых быть не должно. +- Интеграционным тестам нужен Postgres из `DATABASE_URL`: стенд `perf-pg` (`docker compose` в `perf/`), отдельная БД, первый прогон с `--init_db`. +- Коммиты: сообщение начинается с `AURORA-1984:` и заканчивается строкой `Co-Authored-By: Claude Opus 5.5 `. +- Решения без обсуждения дописываются в `design.md`, раздел `## Decisions made without discussion`, с маркером `R-n` (занято R-1…R-6, O-1). + +## Review Focus + +1. Первый расход в разделе, где уже стоит самостоятельный проект, а ROOT ещё нет — ROOT появляется над проектом, расход встаёт под проект (задача 4, `insertCell после addRootRow`). +2. В разделе есть исторический расход без проекта (`999999`) — новый проект встаёт перед ним и на сервере, и локально (задачи 1 и 4). +3. Самостоятельный проект раздела CAP не появляется на листе AHR той же формы (задача 1). +4. Другой пользователь переименовал тот же узел, пока у меня открыто окно — моё окно не закрывается (задача 5, `isRenameResponse`). +5. Удаление последнего пустого самостоятельного проекта в разделе без ROOT — ответ пустой, сервис не падает и отдаёт `(id, [])` (задача 2). + +--- + +### Task 1: Ограничения и лист + +**Модель:** sonnet + +**Files:** +- Create: `api/alembic/versions/0059_form4_standalone_project.py` +- Create: `api/alembic/versions/sql/0059_form4_standalone_project.sql` +- Modify: `api/src/db/models/form4_project.py:26-29` +- Test: `api/tests/integration/test_form4_standalone_project.py` + +**Interfaces:** +- Consumes: `F4`, `SHEET`, `_loads` из `tests/integration/test_form4_sheet_sums.py` (импорт `from test_form4_sheet_sums import ...`). +- Produces: строка ITEM самостоятельного проекта — `row_type='ITEM'`, `depth=2`, `header = {section_code, name, program_id: null, project_id, expense_item_id: }`; `sort_path` ITEM `[sec, prj_id, 0, 0]`, INPUT `[sec, prj_id, 0, line_id]`. + +- [ ] **Step 1: Написать падающие тесты** + +Фикстура `f4` — копия из `test_form4_sheet_sums.py:157`. Хелпер и тесты: + +```python +def standalone(f4, name="solo", section="CAP"): + return f4.sql( + "INSERT INTO v3.form4_project (name, level, form_id, section_code) " + "VALUES (:n, 'project', :f, :s) RETURNING id", + n=name, f=f4.form_id, s=section, + )[0][0] + +def outline(rows): + return [(r["row_type"], r["data"]["header"]["name"]) for r in rows if r["row_type"] != "META"] + +def test_mixed_order_by_creation(f4): + f4.program("А"); standalone(f4, "Б"); f4.program("В") + assert outline(f4.sheet()) == [("GROUP", "А"), ("ITEM", "Б"), ("GROUP", "В")] + +def test_sums_counted_once_and_no_null_group(f4): + prog = f4.program(); prj = f4.project(prog); solo = standalone(f4) + f4.cells(f4.line(prj), {"plan.q1": 100}) + f4.cells(f4.line(solo), {"plan.q1": 50}) + rows = f4.sheet() + root = next(r for r in rows if r["row_type"] == "ROOT") + item = next(r for r in rows if r["row_type"] == "ITEM" and r["data"]["header"]["project_id"] == solo) + assert Decimal(str(root["data"]["plan"]["q1"])) == 150 + assert Decimal(str(item["data"]["plan"]["q1"])) == 50 + assert item["data"]["header"].get("program_id") is None + assert all(r["data"]["header"].get("program_id") is not None for r in rows if r["row_type"] == "GROUP") + +def test_expense_goes_right_after_its_project(f4): + a = f4.program("А"); solo = standalone(f4, "Б"); f4.program("В") + f4.line(f4.project(a, "А1")); line = f4.line(solo) + rows = [r for r in f4.sheet() if r["row_type"] != "META"] + idx = next(i for i, r in enumerate(rows) if r["data"].get("line_id") == line) + assert rows[idx - 1]["data"]["header"]["project_id"] == solo + assert rows[idx + 1]["data"]["header"]["name"] == "В" + +def test_historical_line_stays_after_standalone(f4): + solo = standalone(f4) + f4.line(solo); hist = f4.line(None) + rows = [r for r in f4.sheet() if r["row_type"] != "META"] + assert rows[-1]["data"].get("line_id") == hist + +def test_other_section_does_not_show_standalone(f4): + solo = standalone(f4, section="CAP") + count = f4.sql( + "SELECT count(*) FROM v3.v_form4_sheet_jsonb(:f, 'AHR', NULL) " + "WHERE data->'header'->>'project_id' = :p", f=f4.form_id, p=str(solo), + )[0][0] + assert count == 0 + +def test_cell_patch_response(f4, client): + solo = standalone(f4); line = f4.line(solo) + resp = client.patch( + f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell", + json={"line_id": line, "column": "plan.q1", "value": 20}, headers=f4.h, + ) + assert resp.status_code == 200, resp.text + by_type = {r["row_type"]: r["data"] for r in _loads(resp.text)["result"]} + assert {"INPUT", "ITEM", "ROOT"} <= set(by_type) and "GROUP" not in by_type + fresh = next(r for r in f4.sheet() if r["row_type"] == "ITEM") + assert by_type["ITEM"]["plan"] == fresh["data"]["plan"] + +def test_delete_expense_recalculates(f4): + solo = standalone(f4) + keep, gone = f4.line(solo), f4.line(solo) + f4.cells(keep, {"plan.q1": 10}) + f4.cells(gone, {"plan.q1": 5}) + rows = f4.sql("SELECT row_type, data::text FROM v3.del_budget_line(:l, NULL, 'CAP')", l=gone) + by_type = {r[0]: _loads(r[1]) for r in rows} + assert "GROUP" not in by_type + assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 10 + assert Decimal(str(by_type["ROOT"]["plan"]["q1"])) == 10 + +def test_sheet_without_standalone_equals_0053(f4): + source = Path("alembic/versions/sql/0053_form2_ahr_source_sheet.sql").read_text(encoding="utf-8") + body = re.search(r"CREATE OR REPLACE FUNCTION v3\.v_form4_sheet_jsonb.*?\$function\$\s*;", source, re.S).group(0) + ddl = body.replace("v3.v_form4_sheet_jsonb", "pg_temp.v_form4_sheet_old", 1) + async def _create(): + conn = await f4.db.connection() + await conn.exec_driver_sql(ddl) + f4.client.portal.call(_create) + prog = f4.program(); prj = f4.project(prog); f4.program(); f4.project(prog) + f4.cells(f4.line(prj), {"plan.q1": 7}) + f4.line(prog) # расход прямо на программе + f4.line(None) # исторический расход без проекта + q = "SELECT row_type, depth, sort_order, data::text FROM {}(:f, 'CAP', NULL) ORDER BY sort_order" + old = [tuple(r) for r in f4.sql(q.format("pg_temp.v_form4_sheet_old"), f=f4.form_id)] + new = [tuple(r) for r in f4.sql(q.format("v3.v_form4_sheet_jsonb"), f=f4.form_id)] + assert new == old +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v` +Expected: FAIL — `INSERT` нарушает `chk_form4_project_section_code`; `test_sheet_without_standalone_equals_0053` проходит. + +- [ ] **Step 3: Написать миграцию** + +В `.sql` по порядку: + +1. `DO`-блок: `RAISE EXCEPTION`, если есть строка с `parent_id IS NULL AND form_id IS NULL`. +2. `DROP CONSTRAINT chk_form4_project_section_code` и `ADD CONSTRAINT` с `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. +3. `ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL)`. +4. `CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb` — копия из 0053 с правками (номера строк — по 0053): + - `all_projects` (1149-1158): `UNION ALL` самостоятельных проектов: `prj.level='project' AND prj.parent_id IS NULL AND prj.section_code = p_sheet AND prj.form_id = p_form_id AND (p_sections IS NULL OR prj.section_code = ANY(p_sections))`, `NULL::INT AS prog_id`, `sec_id/sec_code/sec_name` — `LATERAL` по `expense_item` с `depth = 0`, как в `all_programs`. + - `prj_agg` (1289): `d.prog_id IS NOT DISTINCT FROM n.prog_id`. + - `sort_path` обеих веток ITEM (1370, 1385): `ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[]`. + - `sort_path` INPUT (1407): `ARRAY[sec_id, COALESCE(prog_id, prj_id, 999999), CASE WHEN prj_id IS NULL AND prog_id IS NULL THEN 999999 WHEN prj_id IS NULL OR prog_id IS NULL THEN 0 ELSE prj_id END, line_id]::INT[]`. + +В ORM заменить условие `chk_form4_project_section_code` на то же выражение и добавить `CheckConstraint` `chk_form4_project_standalone_form`. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py tests/integration/test_form4_transfer_econ.py --init_db -v` +Expected: PASS, файлы `test_form4_sheet_sums.py` и `test_form4_transfer_econ.py` не правились. + +- [ ] **Step 5: Коммит** + +```bash +git add api/alembic/versions/0059_form4_standalone_project.py api/alembic/versions/sql/0059_form4_standalone_project.sql api/src/db/models/form4_project.py api/tests/integration/test_form4_standalone_project.py +git commit -m "AURORA-1984: самостоятельный проект в модели и листе формы 4" +``` + +### Task 2: Создание, переименование и удаление в SQL + +**Модель:** sonnet + +**Files:** +- Modify: `api/alembic/versions/sql/0059_form4_standalone_project.sql` +- Test: `api/tests/integration/test_form4_standalone_project.py` + +**Interfaces:** +- Consumes: ограничения задачи 1. +- Produces: `v3.add_form4_project(p_name varchar, p_program_id int DEFAULT NULL, p_form_id int DEFAULT NULL, p_section_code varchar DEFAULT NULL)` — те же колонки результата `(project_id, row_type, depth, sort_order, data)`. + +- [ ] **Step 1: Написать падающие тесты** + +Хелпер `standalone` из задачи 1 заменить вызовом функции, остальные тесты задачи 1 остаются: + +```python +def add(f4, name="solo", section="CAP"): + return f4.sql( + "SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)", + n=name, f=f4.form_id, s=section, + ) + +def standalone(f4, name="solo", section="CAP"): + return add(f4, name, section)[0][0] + +def test_add_in_empty_section_returns_item_only(f4): + assert [r[1] for r in add(f4)] == ["ITEM"] + +def test_add_with_root_returns_item_and_root(f4): + f4.line(f4.project(f4.program())) + assert sorted(r[1] for r in add(f4)) == ["ITEM", "ROOT"] + +def test_add_rejects_blank_name(f4): + with pytest.raises(Exception, match="p_name обязателен"): + add(f4, name=" ") + +def test_add_rejects_no_target(f4): + with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"): + f4.sql("SELECT * FROM v3.add_form4_project('x')") + +def test_add_rejects_both_targets(f4): + prog = f4.program() + with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"): + f4.sql("SELECT * FROM v3.add_form4_project('x', :p, :f, 'CAP')", p=prog, f=f4.form_id) + +def test_add_rejects_foreign_section(f4): + with pytest.raises(Exception, match="Раздел SMETA недоступен для проекта"): + add(f4, section="SMETA") + +def test_two_argument_call_still_creates_in_program(f4): + prog = f4.program(); prj = f4.project(prog) + item = next(r for r in f4.sheet() if r["row_type"] == "ITEM") + assert (item["data"]["header"]["program_id"], item["data"]["header"]["project_id"]) == (prog, prj) + +def test_rename_standalone(f4): + solo = standalone(f4) + rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, ' Новое ')", p=solo) + assert [r[0] for r in rows] == ["ITEM"] + assert _loads(rows[0][1])["header"]["name"] == "Новое" + +def test_delete_empty_standalone_in_empty_section_returns_nothing(f4): + solo = standalone(f4) + assert f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo) == [] + assert outline(f4.sheet()) == [] + +def test_delete_standalone_with_line_rejected(f4): + solo = standalone(f4); f4.line(solo) + with pytest.raises(Exception, match="Нельзя удалить проект, содержащий строки бюджета"): + f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo) +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v` +Expected: FAIL — `function v3.add_form4_project(... ) does not exist` / нет ожидаемых сообщений. + +- [ ] **Step 3: Реализовать функции в 0059, перед `v_form4_sheet_jsonb`** + +- `DROP FUNCTION v3.add_form4_project(varchar, int4);` и новая функция с сигнатурой из Interfaces, тело — от `sql/0026_budget_line_stage_access.sql:486`. +- Проверка: если `(p_program_id IS NOT NULL) = (p_form_id IS NOT NULL OR p_section_code IS NOT NULL)` либо задан только один из `p_form_id`/`p_section_code` — `RAISE EXCEPTION 'Укажите либо программу, либо форму и раздел'`. +- Без программы: `'Только FORM_4'` — как в `add_form4_program`; при `p_section_code NOT IN ('AHR','CAP','OPER')` — `RAISE EXCEPTION 'Раздел % недоступен для проекта', p_section_code`; `v_form_id := p_form_id`, `v_section_code := p_section_code`, блок `can_edit` — общий, те же коды ошибок. `INSERT` с `parent_id NULL` и `section_code`; в `log_event` добавить `'section_code', v_section_code`. +- С программой — поведение прежнее. `RETURN QUERY` общий: при пустом `p_program_id` условие GROUP ложно само. +- `upd_form4_project`, `del_form4_project` — тела из `sql/0024_upd_form4_projects.sql:79-155`, раздел везде `COALESCE(v_program.section_code, v_project.section_code)` в локальной переменной `v_section_code`. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py --init_db -v` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add api/alembic/versions/sql/0059_form4_standalone_project.sql api/tests/integration/test_form4_standalone_project.py +git commit -m "AURORA-1984: создание, переименование и удаление проекта без программы в SQL" +``` + +### Task 3: Сервер: `project_added` без программы + +**Модель:** haiku + +**Files:** +- Modify: `api/src/api/v1/websocket.py:482-486`, `api/src/api/v1/websocket.py:610-625` +- Modify: `api/src/services/sheet_service.py:158-188` +- Modify: `api/src/repository/sheet_repository.py:208-231` +- Test: `api/tests/unit/test_sheet_service_form4_standalone.py`, `api/tests/integration/test_form4_standalone_project.py` + +**Interfaces:** +- Consumes: SQL-функция задачи 2. +- Produces: + - `SheetRepository.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, section_code: str | None = None) -> list[tuple]` + - `SheetService.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, sheet: str | None = None, user: AppUser | None = None) -> tuple[int, list[tuple]] | None` + - WS `project_added`: `data = {name: str, program_id?: int}`, ответ `[project_id, rows]`. + +- [ ] **Step 1: Написать падающие тесты** + +Unit (через `asyncio.run`, `SheetService(AsyncMock())`, репозитории заменены на `AsyncMock`, импорт `SheetValidationError` — как в `sheet_service.py`): + +```python +def test_admin_creates_in_section(): + service = make_service() + service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})] + result = asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=ADMIN)) + assert result == (9, [("ITEM", 2, 1, {})]) + service.sheet_repo.add_project.assert_awaited_once_with(name="П", program_id=None, form_id=7, section_code="CAP") + +def test_foreign_executor_cannot_create_in_section(): + service = make_service() + service.bf_repo.get.return_value = None + with pytest.raises(SheetValidationError): + asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR)) + service.sheet_repo.add_project.assert_not_awaited() + +@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"]) +def test_foreign_executor_cannot_change_standalone(method): + service = make_service() + service.f4_project_repo.get.return_value = SimpleNamespace(form_id=7, parent_id=None) + service.bf_repo.get.return_value = None + kwargs = {"project_id": 3, "user": EXECUTOR} + if method == "update_form4_project": + kwargs["name"] = "Н" + with pytest.raises(SheetValidationError): + asyncio.run(getattr(service, method)(**kwargs)) +``` + +Здесь `ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN)`, `EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF)`, `make_service()` дополнительно задаёт `user_repo.get.return_value = SimpleNamespace(org_units=[])`. + +Integration (NULL-параметры доходят до функции): + +```python +def test_repository_creates_in_section(f4): + repo = SheetRepository(f4.db) + rows = f4.client.portal.call(lambda: repo.add_project(name="Р", form_id=f4.form_id, section_code="CAP")) + assert [r[1] for r in rows] == ["ITEM"] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py -v` +Expected: FAIL — `add_project() got an unexpected keyword argument 'form_id'`. + +- [ ] **Step 3: Реализовать** + +- Repository — вызов `v3.add_form4_project(:p_name, :p_program_id, :p_form_id, :p_section_code)`. +- Service — при `program_id is None` для не-админа та же проверка, что в `add_program` (`sheet_service.py:137-147`, ошибка «Форма не найдена»), вызов `sheet_repo.add_project(name=name, program_id=None, form_id=form_id, section_code=sheet)`. Ветка с программой прежняя, вызов репозитория `add_project(name=name, program_id=program_id, form_id=None, section_code=None)`. +- WebSocket — в `process` передать `form_id=form_id, sheet=sheet`; в `__add_project` `program_id=event_data.get("program_id")`. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py tests/integration/test_form4_standalone_project.py --init_db -v` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add api/src/api/v1/websocket.py api/src/services/sheet_service.py api/src/repository/sheet_repository.py api/tests/unit/test_sheet_service_form4_standalone.py api/tests/integration/test_form4_standalone_project.py +git commit -m "AURORA-1984: событие project_added без программы" +``` + +### Task 4: Фронт: создание и вставка проекта + +**Модель:** sonnet + +**Files:** +- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`, `web/src/components/RealtimeTable/utils/cellUtils.js` +- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js:135-158` +- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx:936-943`, `web/src/components/RealtimeTable/RealtimeTable.jsx:1013-1029` +- Modify: `web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx:35` +- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js` + +**Interfaces:** +- Consumes: WS из задачи 3; `alignNodeRows`, `addRootRow`, `updateCells`, `insertProjectRow` из `cellUtils.js`. +- Produces: + - `getProjectAddTarget(rowData): { program_id?: number }` (`rowUtils.js`) + - `insertStandaloneProjectRow(data, newRow): rows` (`cellUtils.js`), `newRow = [row_type, depth, sort_order, data]` + +- [ ] **Step 1: Написать падающие тесты** + +```js +// rowUtils.test.js +test('AURORA-1984: getProjectAddTarget — цель по выделенной строке', () => { + assert.deepEqual(getProjectAddTarget(undefined), {}); + assert.deepEqual(getProjectAddTarget(form4Row('ROOT', { section_code: 'CAP' })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('GROUP', { program_id: 11 })), { program_id: 11 }); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: 11, project_id: 21 })), { program_id: 11 }); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: null, project_id: 21 })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 }); +}); + +// cellUtils.test.js +const node = (rowType, sortOrder, header = {}, subRows = []) => ({ + row_type: rowType, depth: { ROOT: 0, GROUP: 1, ITEM: 2, INPUT: 3 }[rowType], sort_order: sortOrder, subRows, data: { header }, +}); +const newItem = (sortOrder) => ['ITEM', 2, sortOrder, { header: { project_id: 77, name: 'Н' } }]; + +test('AURORA-1984: самостоятельный проект — под ROOT, после соседей', () => { + const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2)])], newItem(3)); + assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3]]); +}); + +test('AURORA-1984: самостоятельный проект — на верхний уровень без ROOT', () => { + const result = insertStandaloneProjectRow([node('GROUP', 1)], newItem(2)); + assert.deepEqual(result.map((r) => r.row_type), ['GROUP', 'ITEM']); +}); + +test('AURORA-1984: самостоятельный проект — перед историческим расходом, номера сдвигаются', () => { + const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2), node('INPUT', 3)])], newItem(3)); + assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3], ['INPUT', 4]]); +}); + +test('AURORA-1984: insertCell после addRootRow — первый расход самостоятельного проекта', () => { + const withRoot = addRootRow([node('ITEM', 1, { project_id: 21 })], ['ROOT', 0, 1, { header: { expense_item_id: 900 } }]); + const result = insertCell(withRoot, ['INPUT', 3, 3, { line_id: 5, header: { project_id: 21 } }], 21); + assert.equal(result[0].subRows[0].subRows[0].data.line_id, 5); +}); +``` + +Импорт в `cellUtils.test.js` дополнить `addRootRow, insertCell, insertStandaloneProjectRow`. + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js` +Expected: FAIL — `getProjectAddTarget` / `insertStandaloneProjectRow` нет в модуле. + +- [ ] **Step 3: Реализовать** + +- `getProjectAddTarget`: `header.program_id != null` у GROUP, ITEM, INPUT → `{ program_id }`, иначе `{}`. +- `insertStandaloneProjectRow`: копия данных (`structuredClone`), у всех строк дерева `sort_order >= нового` → `+1`; целевой список — `subRows` первого ROOT или верхний уровень; вставка перед первой строкой списка с `sort_order` больше нового, иначе в конец. +- `subscribeToProjectAdds`: `program_id = data.data.program_id`. В `setData`: если `prevData[0]?.row_type !== 'ROOT'` и в ответе есть ROOT — `addRootRow`; затем `updateCells(x, alignNodeRows(x, updatedCells))`; затем `program_id != null ? insertProjectRow(...) : insertStandaloneProjectRow(...)`. +- `RealtimeTable.jsx`: `addProject` без проверок выделения и типа строки (проверка `canModifyRows` остаётся); `handleAddProjectRow` — `contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) })`. +- `CreateProgramProjectModal.jsx`: `isFormValid = name.trim() !== ''`. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx +git commit -m "AURORA-1984: добавление проекта в разделе на фронте" +``` + +### Task 5: AURORA-1980 — переименование + +**Модель:** sonnet + +**Files:** +- Create: `web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx` +- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js` +- Modify: `web/src/components/RealtimeTable/contexts/RealtimeContext.js:114-180` +- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js` +- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx` +- Modify: `web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx:225-232` +- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js` + +**Interfaces:** +- Consumes: `alignNodeRows`, `updateCells`; `upd_form4_*` возвращают `[id, rows]`. +- Produces: + - `getRenameTarget(rowData): { kind: 'program' | 'project', id: number, name: string } | null` + - `isRenameResponse(msg, target): boolean` + - из `useRealtimeActions()` (тот же объект, что `deleteProgram`): `renameNode(kind, id, name): boolean`, `subscribeToNodeUpdates(cb)`, `subscribeToNodeRenameResult(cb)` — по образцу `subscribeToNodeDeletes`. + - `RenameNodeModal({ target, isSaving, onClose, onSubmit })`, `onSubmit(trimmedName)`. + +- [ ] **Step 1: Написать падающие тесты** + +```js +// rowUtils.test.js +test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => { + assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' }); + assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' }); + assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null); + assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null); + assert.equal(getRenameTarget(undefined), null); +}); + +test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => { + const target = { kind: 'project', id: 21, name: 'Б' }; + const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } }; + assert.equal(isRenameResponse(msg, target), true); + assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true); + assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false); + assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false); + assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false); + assert.equal(isRenameResponse(msg, null), false); +}); + +// cellUtils.test.js +test('AURORA-1980: ответ переименования обновляет название в строке', () => { + const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])]; + const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]]; + const result = updateCells(local, alignNodeRows(local, rows)); + assert.equal(result[0].subRows[0].data.header.name, 'Новое'); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js` +Expected: FAIL — `getRenameTarget` нет в модуле (тест `cellUtils` проходит: он фиксирует поведение существующих функций). + +- [ ] **Step 3: Реализовать функции и канал событий** + +- `RealtimeContext.js`, `handleMessage`: в ветке `data.error` после тоста и `onErrorRef` — `onNodeRenameResultRef.current?.(data)`, если `data.event` — `program_updated` или `project_updated`. В `switch` добавить оба события: при `data.result` вызвать `onNodeUpdateRef.current?.(data)` и `onNodeRenameResultRef.current?.(data)`. +- `renameNode(kind, id, name)` шлёт `{ event: `${kind}_updated`, data: { [`${kind}_id`]: id, name } }` через `addCommonField` и `sendMessage`, как `deleteProject`. +- `useRealtimeData`: `subscribeToNodeUpdates((msg) => setData((prev) => updateCells(prev, alignNodeRows(prev, msg.result[1] ?? []))))`, отписка — как у `subscribeToNodeDeletes`. + +- [ ] **Step 4: Реализовать окно и кнопку** + +- `RenameNodeModal` — разметка как у `CreateProgramModal`; поле управляемое (`value`), начальное значение из `target.name` при смене `target`; заголовок по `target.kind`; кнопка «Сохранить» неактивна при `name.trim() === ''` или `isSaving`; «Отмена» и клик по фону закрывают без отправки. +- `RealtimeTable.jsx`: состояния `pendingRename`, `isRenameSaving` рядом с `pendingDelete`; подписка `subscribeToNodeRenameResult` в `useEffect`: при `isRenameResponse(msg, pendingRename)` — при `msg.error` снять `isRenameSaving` (окно и текст остаются), иначе закрыть окно. `onSubmit`: `renameNode(...)`; при `false` — окно остаётся, иначе `isRenameSaving = true`. Рендер рядом с `ConfirmDeleteRowModal` (`:1159`). +- `SettingPanel.jsx`: в блоке формы 4 после «Добавить проект» — `Tooltip` «Переименовать», `IconButton` с `onClick={onRenameNode}`, `disabled={!canRenameNode}`, иконка `EditSvg` из `common/icons/icons`. Вокруг отключаемой кнопки — ``: MUI `Tooltip` не работает на `disabled`. `canRenameNode` считается в `RealtimeTable` при рендере: `Boolean(getRenameTarget(getSelectedRow()?.original))`; `onRenameNode` — `setPendingRename(getRenameTarget(getSelectedRow()?.original))`; оба пропса передаются в панель рядом с `onAddProject` (`:1099`). + +- [ ] **Step 5: Убедиться, что тесты и линт проходят** + +Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable/Modals/RenameNodeModal.jsx` +Expected: PASS, без ошибок линта. + +- [ ] **Step 6: Коммит** + +```bash +git add web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/contexts/RealtimeContext.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +git commit -m "AURORA-1980: переименование программы и проекта" +``` + +### Task 6: AURORA-1979 — подпись типа на экране и в выгрузке + +**Модель:** sonnet + +**Files:** +- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js` +- Modify: `web/src/components/RealtimeTable/tableColumns.jsx:255` +- Modify: `web/src/components/RealtimeTable/constants/FORM_4/AHR.js`, `CAP.js`, `OPER.js` (колонка `data.header.section_code`) +- Modify: `api/src/services/export_service/export_normalizers.py`, `api/src/services/export_service/export_service.py:125,975,981,987` +- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/form4TypeLabel.test.js` (новый), `api/tests/unit/test_export_normalizers.py`, `api/tests/unit/test_export_writers.py` + +**Interfaces:** +- Consumes: строки листа задачи 1. +- Produces: + - `getForm4TypeLabel(row): 'программа' | 'проект' | null`, `row = { row_type, data }` + - признак колонки `valueLabel: 'form4NodeType'` + - `Form4DepthRowNormalizer(Form1DepthRowNormalizer)` в `export_normalizers.py` + +- [ ] **Step 1: Написать падающие тесты** + +```js +// rowUtils.test.js +test('AURORA-1979: getForm4TypeLabel', () => { + assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа'); + assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект'); + assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект'); + assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null); + assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null); + assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null); +}); + +// form4TypeLabel.test.js — конфиги читаются как текст, см. form2SmetaConfig.test.js +for (const sheet of ['AHR', 'CAP', 'OPER']) { + test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => { + const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8'); + assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/); + assert.doesNotMatch(source, /accessorFn/); + }); +} +``` + +```python +# test_export_normalizers.py +@pytest.mark.parametrize("data, expected", [ + ({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"), + ({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"), + ({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"), + ({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"), + ({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."), +]) +def test_form4_depth_normalizer_type_label(data, expected): + normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data) + assert normalized["header.section_code"] == expected + assert data["header"]["section_code"] != "программа" + +# test_export_writers.py +def test_standalone_project_outline_without_group(): + rows = [("ROOT", 0, 1, {}), ("ITEM", 2, 2, {}), ("INPUT", 3, 3, {}), ("GROUP", 1, 4, {})] + assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [ + ("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1), + ] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/form4TypeLabel.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py -v` +Expected: FAIL — нет `getForm4TypeLabel`, `valueLabel`, `Form4DepthRowNormalizer`; `test_standalone_project_outline_without_group` проходит (закрепляет D7a). + +- [ ] **Step 3: Реализовать** + +- `getForm4TypeLabel`: GROUP с `program_id != null` → «программа»; ITEM с `project_id != null` → «проект»; иначе `null`. +- В трёх конфигах в дереве `columns`, в листовой колонке под заголовком «Код статьи / подстатьи / позиции» (строка с `columnLetter: "C"` рядом; не в карте цветов в начале файла), строкой сразу после `accessorKey: "data.header.section_code",` добавить `valueLabel: "form4NodeType",`. +- `processColumns`, до условия на `:255`: при `isLeafColumn && col.valueLabel === 'form4NodeType'` — `col.id = col.id ?? col.accessorKey; col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey);`. +- `Form4DepthRowNormalizer.normalize`: `super().normalize(data)`; при `data.get("line_id") is None` и `header.get("project_id") is not None` → `normalized["header.section_code"] = "проект"`, при `header.get("program_id") is not None` → «программа». Проект проверяется первым. +- `export_service.py`: `self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer)` рядом с `:125`; в `writer_configs` формы 4 — только `OPER`, `AHR`, `CAP` (`:975,981,987`), `STRUCTURE` не трогать. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py tests/unit/test_export_writers.py tests/unit/test_export_form1_form4_mapping.py -v` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/form4TypeLabel.test.js web/src/components/RealtimeTable/tableColumns.jsx web/src/components/RealtimeTable/constants/FORM_4/AHR.js web/src/components/RealtimeTable/constants/FORM_4/CAP.js web/src/components/RealtimeTable/constants/FORM_4/OPER.js api/src/services/export_service/export_normalizers.py api/src/services/export_service/export_service.py api/tests/unit/test_export_normalizers.py api/tests/unit/test_export_writers.py +git commit -m "AURORA-1979: подпись программы и проекта в третьем столбце и выгрузке" +``` + +### Task 7: AURORA-1989 — окно выбора расхода + +**Модель:** haiku + +**Files:** +- Create: `web/src/components/RealtimeTable/utils/expenseItemOptions.js` +- Modify: `web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx:14-22,75` +- Test: `web/src/components/RealtimeTable/utils/expenseItemOptions.test.js` + +**Interfaces:** +- Produces: `getExpenseItemLabel(option): string`, `compareExpenseItems(a, b): number`. + +- [ ] **Step 1: Написать падающие тесты** + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { compareExpenseItems, getExpenseItemLabel } from './expenseItemOptions.js'; + +const sorted = (ids) => ids.map((item_id) => ({ item_id, name: 'x' })).sort(compareExpenseItems).map((o) => o.item_id); + +test('AURORA-1989: подпись варианта', () => { + assert.equal(getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' }), '3.01. — Связь'); + assert.equal(getExpenseItemLabel({ item_id: null, name: 'Связь' }), 'Связь'); + assert.equal(getExpenseItemLabel({ name: '' }), ''); + assert.equal(getExpenseItemLabel(''), ''); +}); + +test('AURORA-1989: сортировка с учётом числовых частей, без кода — в конце', () => { + assert.deepEqual(sorted(['3.10.', '3.2.', '10', '2']), ['2', '3.2.', '3.10.', '10']); + assert.deepEqual(sorted([null, '1.', '']), ['1.', null, '']); +}); + +test('AURORA-1989: поиск по подписи находит номер и название', () => { + const label = getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' }); + assert.ok(label.includes('3.01')); + assert.ok(label.includes('Связь')); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализовать** + +- `compareExpenseItems`: сравнение `item_id` через `localeCompare(b, 'ru', { numeric: true })`; пустой или отсутствующий код — после любых с кодом; порядок пустых между собой сохраняется (возврат 0). +- `SelectExpenseItemModal.jsx`: `setExpenseItemOption([...data.result].sort(compareExpenseItems))`; `getOptionLabel={getExpenseItemLabel}`. Встроенный фильтр Autocomplete ищет по подписи, серверный запрос не меняется. + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add web/src/components/RealtimeTable/utils/expenseItemOptions.js web/src/components/RealtimeTable/utils/expenseItemOptions.test.js web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx +git commit -m "AURORA-1989: номер статьи, сортировка и поиск в окне выбора расхода" +``` + +### Task 8: Полная проверка и стенд + +**Модель:** sonnet + +**Files:** код не меняется; находки исправляются отдельными коммитами в файлах своей задачи. + +**Interfaces:** +- Consumes: всё, что сделано в задачах 1–7. + +- [ ] **Step 1: Прогнать весь backend** + +Run: `cd api && pytest --init_db` +Expected: PASS, кроме известного `tests/unit/test_export_service_query_params.py`. + +- [ ] **Step 2: Прогнать фронт и линт** + +Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable` +Expected: PASS; линт — без новых ошибок относительно ветки `test`. + +- [ ] **Step 3: Живая проверка на стенде (форма 4, две вкладки)** + +Поднять API и фронт по способу из заметки о стенде (`perf-pg`, логин admin/admin123). Каждый пункт отмечается результатом: + +- Проект в разделе: ничего не выделено и выделен ROOT — виден в обеих вкладках, после F5 на том же месте. +- Выделены программа, проект в программе, расход, самостоятельный проект — новый проект создаётся там, где требует спека. +- Порядок «программа, проект, программа» по созданию. +- Пустой раздел: проект без ROOT; первый расход — появляется ROOT, расход под проектом. +- Правка `plan.q1` у расхода самостоятельного проекта пересчитывает ITEM и ROOT. +- Переименование программы и обоих видов проекта: видно в двух вкладках; пробелы не сохраняются; ошибка (остановить API или `role_not_allowed`) оставляет окно с текстом. +- Удаление пустого самостоятельного проекта; с расходом — отказ с причиной. +- Подписи «программа»/«проект» и цвета строк на экране; те же подписи и outline в Excel, строки GROUP с пустой программой нет. +- Окно выбора расхода: «номер — название», порядок, поиск «3.01». + +- [ ] **Step 4: Записать найденное** + +Новые решения — в `design.md` с маркером `R-n`, наблюдения — `O-n`. Исправления кода — коммитами с тестом. diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index 25e1128..bfe02c3 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -35,7 +35,7 @@ ## Decisions ### D1. Модель: проект без родителя хранит раздел сам -`form4_project(level='project', parent_id NULL, section_code NOT NULL, form_id NOT NULL)`. Ограничение `chk_form4_project_section_code` пересоздаётся как `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. Новое ограничение `chk_form4_project_standalone_form`: `parent_id IS NOT NULL OR form_id IS NOT NULL` (у программы и самостоятельного проекта форма обязательна; существующие программы её имеют — проверить запросом в миграции перед `ADD CONSTRAINT`). Существующие строки обоим ограничениям удовлетворяют (у всех проектов есть родитель и нет раздела). Отдельный тип/флаг не вводится: «самостоятельный» = `level='project' AND parent_id IS NULL`. +`form4_project(level='project', parent_id NULL, section_code NOT NULL, form_id NOT NULL)`. Ограничение `chk_form4_project_section_code` пересоздаётся как `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. Новое ограничение `chk_form4_project_standalone_form`: `parent_id IS NOT NULL OR form_id IS NOT NULL` (у программы и самостоятельного проекта форма обязательна; существующие программы её имеют — проверить запросом в миграции перед `ADD CONSTRAINT`). Существующие строки обоим ограничениям удовлетворяют (у всех проектов есть родитель и нет раздела). Отдельный тип/флаг не вводится: «самостоятельный» = `level='project' AND parent_id IS NULL`. ORM-модель `api/src/db/models/form4_project.py` повторяет ограничения — правится вместе с миграцией. ### D2. `add_form4_project` — одна функция, программа или форма+раздел Новая сигнатура: `v3.add_form4_project(p_name varchar, p_program_id int DEFAULT NULL, p_form_id int DEFAULT NULL, p_section_code varchar DEFAULT NULL)`; старая `(varchar, int)` удаляется (`DROP FUNCTION`), вызовы с двумя аргументами продолжают работать за счёт DEFAULT. Ровно один из вариантов: `p_program_id` или пара `p_form_id` + `p_section_code`; иначе `RAISE`. Для самостоятельного: проверка, что форма — FORM_4 и раздел — один из AHR/CAP/OPER; права — `v3.can_edit(p_form_id, p_section_code, NULL, NULL, user)` с теми же кодами ошибок, что сейчас; аудит `PROJECT_CREATE` с `parent_id: null`, `section_code`. Возвращает строки так же, как вариант с программой: новый ITEM + ROOT раздела (если он есть в листе). @@ -46,7 +46,7 @@ - `all_projects`: добавить самостоятельные проекты листа (`parent_id IS NULL AND section_code = p_sheet AND form_id = p_form_id`, с `p_sections`); сейчас там `JOIN all_programs`, самостоятельные туда не попадают. - Агрегаты (блок «4», GROUPING SETS по раздел / программа / проект). Уровень программы (`lvl = 1`) уже отсекает `prog_id IS NULL` (`0053:1248`) — фиктивной GROUP не будет. **Уровень проекта соединяется по `d.prog_id = n.prog_id AND d.prj_id = n.prj_id` (`0053:1289`) — при `prog_id IS NULL` равенство ложно, суммы ITEM самостоятельного проекта потеряются.** Заменить сравнение `prog_id` на `IS NOT DISTINCT FROM` во всех соединениях уровня проекта (`prj_names` ↔ агрегаты, пустые проекты из `all_projects`); для строк с непустой программой результат идентичен. ROOT суммирует все расходы раздела один раз (как сейчас). - Исторические расходы FORM_4 без проекта и программы (`project_id IS NULL`) остаются как есть: `sort_path` с `999999`, только в ROOT. Самостоятельные проекты используют реальные id (< 999999), пересечения нет. Тест: лист с таким расходом не меняется. -- `sort_path` на уровне под ROOT — id верхнего узла: для программы `prog_id`, для самостоятельного проекта `prj_id`. Id из одной последовательности → порядок создания. Пример: GROUP `[sec, prog_id]`, ITEM в программе `[sec, prog_id, prj_id]`, самостоятельный ITEM `[sec, prj_id, 0]`, его INPUT `[sec, prj_id, 0, line_id]` — точную форму массивов подбирает исполнитель так, чтобы проект шёл сразу за своими расходами и ничего не пересекалось с программами. Сортировка программ и проектов в программах не меняется. +- `sort_path` на уровне под ROOT — id верхнего узла: для программы `prog_id`, для самостоятельного проекта `prj_id`. Id из одной последовательности → порядок создания. Все `sort_path` остаются четырёхэлементными: GROUP `[sec, prog_id, 0, 0]`, ITEM в программе `[sec, prog_id, prj_id, 0]`, самостоятельный ITEM `[sec, prj_id, 0, 0]`, его INPUT `[sec, prj_id, 0, line_id]`. Сортировка программ и проектов в программах не меняется, `sort_path` существующих строк не меняется. - ITEM самостоятельного проекта: `depth = 2` (как у проекта в программе), `header`: `section_code`, `name`, `program_id: null`, `project_id`, `expense_item_id` раздела. Тело функции — от версии 0053 (последней), меняются только перечисленные блоки. @@ -73,26 +73,26 @@ ROOT выводится только при наличии расходов в - Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем. ### D7a. Вид в выгрузке и на экране -Самостоятельный ITEM: отступ названия и раскраска — как у проекта (`row_type` ITEM, `depth` 2); уровень группировки (outline) в Excel — как у программы (1), это получается само из `export_writers._hierarchical_rows` (стек по `row_type`), правка писателя не нужна. На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. +Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это получается само из `export_writers._hierarchical_rows` (стек по `row_type`), правка писателя не нужна. Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. ### D8. AURORA-1980: переименование -- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — `CreateProgramModal` с начальным значением и заголовком «Переименование программы/проекта» (или отдельный компонент — на усмотрение исполнителя). +- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла). - Отправка `program_updated {program_id, name}` / `project_updated {project_id, name}` с обрезанным названием; пустое после обрезки — кнопка сохранения неактивна. -- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по событию и id узла (WS без идентификатора запроса). +- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по `is_self`, событию и id узла (WS без идентификатора запроса; чужое переименование того же узла окно не закрывает). Ошибка приходит только отправителю с исходными `event` и `data`, но `handleMessage` сейчас передаёт в `onError` лишь `data.error` — для `program_updated` / `project_updated` добавляется отдельный канал с полным сообщением. - Обработчики ответов `program_updated` / `project_updated` (их сейчас нет на фронте): имя в строке и агрегаты через `alignNodeRows` + `updateCells`; рассылка приходит всем подключённым. ### D9. AURORA-1979: подпись в третьем столбце -Колонка «Код статьи / подстатьи / позиции» листов AHR/CAP/OPER формы 4: для GROUP с `program_id` — «программа», для ITEM с `project_id` — «проект»; иначе — как сейчас. Чистая функция подписи (unit-тест) в конфиге колонок FORM_4. В выгрузке Excel — та же подпись в той же колонке (нормализатор строк FORM_4 в export_service). `header.section_code` в данных не меняется. +Колонка «Код статьи / подстатьи / позиции» листов AHR/CAP/OPER формы 4: для GROUP с `program_id` — «программа», для ITEM с `project_id` — «проект»; иначе — как сейчас. Чистая функция подписи (unit-тест) — в `rowUtils.js`; в конфиге колонок FORM_4 только строковый признак `valueLabel: 'form4NodeType'`, а `accessorFn` назначает `processColumns` после клонирования: `tableColumns.jsx:131` делает `structuredClone` конфига, функция в нём уронит таблицу. В выгрузке Excel — та же подпись в той же колонке: новый `Form4DepthRowNormalizer` (наследник `Form1DepthRowNormalizer`, который общий для всех форм) только для листов AHR/CAP/OPER формы 4; нормализатор не получает `row_type`, тип строки определяется по `line_id` и id в header. `header.section_code` в данных не меняется, раскраска (`getColorCell`, `export_service.py:840`) читает его из исходной строки. ### D10. AURORA-1989: окно выбора расхода `SelectExpenseItemModal`: подпись варианта `«${item_id} — ${name}»` (если `item_id` пуст — только название); сортировка на клиенте естественная по `item_id` (числовые части сравниваются как числа: «3.2.» < «3.10.»), без кода — в конце; поиск MUI Autocomplete по подписи (номер и название). Чистые функции подписи/сравнения — в utils с unit-тестами. Серверный запрос не меняется. ### D11. Миграция -`api/alembic/versions/0059_form4_standalone_project.py` + `sql/0059_form4_standalone_project.sql`. Порядок: ограничение → `DROP FUNCTION add_form4_project(varchar, int4)` → новая `add_form4_project` → `upd_/del_form4_project` → `v_form4_sheet_jsonb`. Downgrade — возврат версий из 0024/0026/0053 и прежнего ограничения (падает, если есть самостоятельные проекты — это ожидаемо, текст ошибки понятный). Номер 0059 может сдвинуться при rebase на свежий `test`. +`api/alembic/versions/0059_form4_standalone_project.py` + `sql/0059_form4_standalone_project.sql`. Порядок: ограничение → `DROP FUNCTION add_form4_project(varchar, int4)` → новая `add_form4_project` → `upd_/del_form4_project` → `v_form4_sheet_jsonb`. Downgrade — `pass`, как у SQL-миграций репозитория (26 из 29 последних; 0058 — образец). Номер 0059 может сдвинуться при rebase на свежий `test`. ## Тестирование -- Интеграционные (`api/tests/integration/test_form4_standalone_project.py`, по образцу `test_form4_sheet_sums.py`): создание самостоятельного проекта (WS или SQL + GET листа) — ITEM под ROOT, нет GROUP с пустым `program_id`; порядок «программа, проект, программа» по созданию; суммы ROOT = программа + самостоятельный проект; правка ячейки расхода — состав ответа (INPUT, ITEM, ROOT, без GROUP) и равенство `data` с GET; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — GET побайтно равен результату до миграции (эталон снимается до миграции в том же тесте или фикстурой). +- Интеграционные (`api/tests/integration/test_form4_standalone_project.py`, по образцу `test_form4_sheet_sums.py`): создание самостоятельного проекта (WS или SQL + GET листа) — ITEM под ROOT, нет GROUP с пустым `program_id`; порядок «программа, проект, программа» по созданию; суммы ROOT = программа + самостоятельный проект; правка ячейки расхода — состав ответа (INPUT, ITEM, ROOT, без GROUP) и равенство `data` с GET; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — результат `v_form4_sheet_jsonb` совпадает побайтно с версией 0053: тесты идут на БД после миграций, поэтому эталон — тело функции из `0053_*.sql`, созданное в тесте как `pg_temp`-функция. - Существующие тесты FORM_4 (`test_form4_sheet_sums.py`, `test_form4_transfer_econ.py`) — зелёные без правок. - Фронт (`node --test`): цель «Добавить проект» по выделению; вставка ITEM под ROOT / на верхний уровень; подпись третьего столбца; подпись/сортировка/фильтр статей расходов. - Выгрузка: unit или интеграционный тест — самостоятельный проект в Excel без лишней строки, подпись «проект». @@ -103,12 +103,12 @@ ROOT выводится только при наличии расходов в - Перенумерация `sort_order` сервером при появлении/исчезновении ROOT — фронт сопоставляет агрегаты по id (`alignNodeRows`), но позиция вставки нового ITEM опирается на `sort_order`; при расхождении строка может встать не на своё место до перезагрузки. Митигация — позиция по `sort_order` соседей из ответа, тест на пустой раздел. - Ветка поверх #304: если #304 изменится по ревью, нужен rebase. - Excel: самостоятельный ITEM на уровне группировки 1 (вместо 2) — проверить вид, не ломать раскладку колонок по `depth`. +- `rowUtils.getParentId` для ROOT и GROUP возвращает `expense_item_id`, и `insertCell` сравнивает его с `project_id`: при совпадении чисел новый расход встанет под ROOT до перезагрузки. Поведение существовало и для проектов в программах; в этой ветке не меняется (O-1). +- GET срезает null (AURORA-1848), поэтому `header.program_id` приходит то `null`, то `undefined`: сравнивать только через `!= null`. ## Оставлено исполнителю -- Точная форма `sort_path` (D3) при соблюдении порядка и непересечения. -- Отдельный компонент окна переименования или переиспользование `CreateProgramModal`. -- Размещение функции подписи третьего столбца в конфиге колонок FORM_4. +Пунктов нет: форма `sort_path`, окно переименования и размещение подписи зафиксированы при перепроверке 08.11. ## Decisions made without discussion @@ -123,5 +123,11 @@ ROOT выводится только при наличии расходов в | 2026-11-08 | Окно переименования закрывается по ответу сервера, сопоставление по событию и id | требование «при ошибке сохранять введённое значение» | при двух быстрых переименованиях одного узла — ответ может прийти «не тому» окну (маловероятно) | | 2026-11-08 | Соединения уровня проекта в `v_form4_sheet_jsonb` — `IS NOT DISTINCT FROM` по `prog_id` (находка рецензента дизайна) | иначе суммы самостоятельного проекта теряются | при ошибке — изменение сумм существующих форм; закрывается тестом эквивалентности GET | | 2026-11-08 | Ограничение `form_id IS NOT NULL` для узлов без родителя | самостоятельный проект без формы не должен существовать | миграция упадёт, если в данных есть программа без формы — проверка перед `ADD CONSTRAINT` | -| 2026-11-08 | Outline самостоятельного проекта в Excel — уровень программы (1), отступ — как у проекта | получается без правки писателя, «нет уровня программы» | если заказчик ждёт отступ как у программы — правка нормализатора | +| 2026-11-08 | Outline и отступ самостоятельного проекта — уровень программы (1), на экране и в Excel | получается без правки писателя и `Cell.jsx`, «нет уровня программы» | если заказчик ждёт отступ как у проекта — правка отображения | | 2026-11-08 | Гонку одновременного создания не тестируем, позиция до перезагрузки может отличаться | сервер упорядочивает по id, редкий случай | редкое визуальное расхождение до F5 | +| 2026-11-08 | R-1 (design #13): подпись третьего столбца — через строковый признак в конфиге и `accessorFn` в `processColumns` | `structuredClone` конфига падает на функциях | правка одной ветки в `processColumns` | +| 2026-11-08 | R-2 (design #14): отдельный `Form4DepthRowNormalizer` для AHR/CAP/OPER формы 4 | `Form1DepthRowNormalizer` общий для всех форм, его правка задела бы их выгрузки | лишний класс из нескольких строк | +| 2026-11-08 | R-3 (design #15): отдельное окно `RenameNodeModal`, закрытие по `is_self` + событие + id, отдельный канал ошибок | `CreateProgramModal` закрывается сразу, ошибка теряет событие и id | лишний компонент; чужой ответ окно не закроет | +| 2026-11-08 | R-4 (design #16): downgrade миграции 0059 — `pass` вместо возврата трёх функций | соглашение репозитория; ~800 строк копий тел, которые никто не запускает | откат только вручную | +| 2026-11-08 | R-5 (design #17): эталон «как до миграции» — `pg_temp`-копия функции из `0053_*.sql` | тесты идут на БД после миграций, снять снимок до неё нельзя | тест привязан к файлу 0053 | +| 2026-11-08 | R-6 (design #18): права на переименование и удаление оставлены как есть — членство в форме (`_validate_form4_node_access`), без `v3.can_edit` | D4 не меняет `upd/del_form4_program`; единообразно для программ и проектов; кнопки в UI скрыты при `canModifyRows = false` | прямой запрос по WS может переименовать или удалить узел при закрытом окне ввода | diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md index 00d39bb..5b25b66 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/proposal.md @@ -23,7 +23,7 @@ **Не меняется:** - существующие данные: все проекты остаются в своих программах, перенос проекта в программу и из неё не делается; - своды, листы SMETA и STRUCTURE (не читают программы и проекты); -- права: создание, правка и удаление самостоятельного проекта проверяются так же, как у программы (`v3.can_edit` по форме и разделу); +- права: создание самостоятельного проекта проверяется так же, как у программы (`v3.can_edit` по форме и разделу), переименование и удаление — тоже как у программы (членство в форме, `_validate_form4_node_access`); - контракт строк листа для программ и проектов в программах. ## Capabilities @@ -37,6 +37,6 @@ ## Impact - SQL: миграция `0059_*.py` + `sql/0059_*.sql`: ограничение `form4_project`, `v3.add_form4_project` (новая сигнатура — DROP/CREATE), `v3.upd_form4_project`, `v3.del_form4_project`, `v3.v_form4_sheet_jsonb` (тело — от версии 0053). -- Python: `websocket.py` (`__add_project`), `SheetService.add_project` (проверка доступа по форме без программы), `SheetRepository.add_project`; при необходимости — export_service (подпись «программа»/«проект» в третьем столбце). -- Фронт: `RealtimeTable.jsx`, `RealtimeContext.js`, `useRealtimeData.js`, `utils/cellUtils.js`, модалки создания/переименования, `SelectExpenseItemModal.jsx`, конфиг колонок FORM_4. +- Python: `websocket.py` (`__add_project`), `SheetService.add_project` (проверка доступа по форме без программы), `SheetRepository.add_project`, ORM `form4_project.py`; export_service — новый `Form4DepthRowNormalizer` (подпись «программа»/«проект» в третьем столбце). +- Фронт: `RealtimeTable.jsx`, `RealtimeContext.js`, `useRealtimeData.js`, `utils/cellUtils.js`, `utils/rowUtils.js`, `tableColumns.jsx`, `SettingPanel.jsx`, модалки создания/переименования, `SelectExpenseItemModal.jsx`, конфиг колонок FORM_4. - Тесты: интеграционные (лист, итоги, создание/правка/удаление самостоятельного проекта, ответ `_form_affected_rows`), unit фронта (`node --test`), живая проверка на стенде. -- 2.47.2 From 836e812bad8a404e1b47c41f0e7ae4bd643581c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:18:22 +0400 Subject: [PATCH 03/13] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20=D0=B2=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B8=20=D0=BB=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/0059_form4_standalone_project.py | 15 + .../sql/0059_form4_standalone_project.sql | 644 ++++++++++++++++++ api/src/db/models/form4_project.py | 6 +- .../test_form4_standalone_project.py | 112 +++ 4 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/0059_form4_standalone_project.py create mode 100644 api/alembic/versions/sql/0059_form4_standalone_project.sql create mode 100644 api/tests/integration/test_form4_standalone_project.py diff --git a/api/alembic/versions/0059_form4_standalone_project.py b/api/alembic/versions/0059_form4_standalone_project.py new file mode 100644 index 0000000..ef181f6 --- /dev/null +++ b/api/alembic/versions/0059_form4_standalone_project.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0059" +down_revision = "0058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0059_form4_standalone_project.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0059_form4_standalone_project.sql b/api/alembic/versions/sql/0059_form4_standalone_project.sql new file mode 100644 index 0000000..e5ec0ff --- /dev/null +++ b/api/alembic/versions/sql/0059_form4_standalone_project.sql @@ -0,0 +1,644 @@ +-- AURORA-1984: проект без программы (самостоятельный проект формы 4). +-- Часть 1: ограничения модели и лист v3.v_form4_sheet_jsonb. +-- Самостоятельный проект: level='project', parent_id IS NULL, section_code и form_id заданы. + +-- ═══ 1. Ограничения ══════════════════════════════════════════════════════════ +DO $do$ +BEGIN + IF EXISTS (SELECT 1 FROM v3.form4_project WHERE parent_id IS NULL AND form_id IS NULL) THEN + RAISE EXCEPTION 'form4_project: есть узлы без родителя и без form_id (AURORA-1984)'; + END IF; +END +$do$; + +ALTER TABLE v3.form4_project DROP CONSTRAINT chk_form4_project_section_code; +ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_section_code + CHECK (((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)); +ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_standalone_form + CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL); + +-- ═══ 2. Функции add/upd/del_form4_project (Task 2) ═══════════════════════════ +-- >>> здесь будут add_form4_project / upd_form4_project / del_form4_project <<< + +-- ═══ 3. Лист ═════════════════════════════════════════════════════════════════ +-- Тело — копия sql/0053_form2_ahr_source_sheet.sql, изменены: all_projects (+самостоятельные), +-- соединение prj_agg (IS NOT DISTINCT FROM по prog_id), sort_path ITEM и INPUT. +CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE sql + STABLE + SET jit TO 'off' -- оценка плана выше порога JIT, компиляция дороже выполнения (AURORA-1846) +AS $function$ + -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ + WITH inp_raw AS ( + SELECT j.* + FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j + WHERE j.row_type = 'INPUT' + ), + inp AS ( + SELECT + i.*, + bl.project_id AS line_project_id, + + -- --- разрешение проекта --- + -- A. bl.project_id → project (level='project') + -- B. bl.project_id → program (level='program') — тогда проекта нет + CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id, + CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name, + + -- --- разрешение программы --- + -- A. через проект: prog_via_prj + -- B. напрямую: prog_direct + COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id, + COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name, + + -- --- section (depth=0) --- + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + + FROM inp_raw i + JOIN v3.budget_line bl ON bl.id = i.line_id + + -- проект (level='project') + LEFT JOIN v3.form4_project prj + ON prj.id = bl.project_id AND prj.level = 'project' + + -- программа через проект + LEFT JOIN v3.form4_project prog_via_prj + ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program' + + -- программа напрямую (bl.project_id → program) + LEFT JOIN v3.form4_project prog_direct + ON prog_direct.id = bl.project_id AND prog_direct.level = 'program' + + -- раздел depth=0 (через expense_item) + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN LATERAL ( + WITH RECURSIVE up AS ( + SELECT id, parent_id, name, section_code, depth + FROM v3.expense_item WHERE id = ei.id + UNION ALL + SELECT e.id, e.parent_id, e.name, e.section_code, e.depth + FROM v3.expense_item e JOIN up u ON u.parent_id = e.id + ) + SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1 + ) sec ON TRUE + ), + tp_agg AS ( + SELECT 1 AS tp_id, + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, 'comment', null + ) AS tp_plan, + + + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, + 'justification', null + ) AS tp_seq_dfip, + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0 + ) AS tp_approved, + + jsonb_build_object( + 'total', null, + 'reference', null, + 'counterparty', null, + 'deadline', null, + 'comment', null, + 'future_y1', null, + 'future_y2', null, + 'other_ssp', null, + 'centralized_flag', null + ) AS tp_contract_summary, + + jsonb_build_object( + 'contract_ref', null, + 'allocation_purpose', null + ) AS tp_allocation, + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, + 'justification', null + ) AS tp_reserve, + + jsonb_build_object( + 'approved', null, + 'protocol', null, + 'note', null + ) AS tp_collegial, + + jsonb_build_object( + 'ceiling', null, + 'q1', null, + 'q2', null, + 'q3', null, + 'q4', null, + 'rf_schedule', null, + 'delivery_deadline', null, + 'proc_plan', null, + 'proc_method', null, + 'comment', null + ) AS tp_ckk, + + jsonb_build_object( + 'act', null, + 'date', null, + 'scheme', null, + 'addenda', null, + 'ceiling', null, + 'comment', null, + 'subject', null, + 'currency', null, + 'deadline', null, + 'vat_rate', null, + 'reference', null, + 'q1', null, + 'q2', null, + 'q3', null, + 'q4', null, + 'rf_schedule', null, + 'counterparty', null, + 'exchange_rate', null, + 'amount_foreign', null + ) AS tp_contract, + + jsonb_build_object( + 'y2026', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null), + 'y2027', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null) + ) AS tp_booking, + + jsonb_build_object( + 'total', 0, + 'adj_rf', null, + 'pay_ho', null, + 'pay_rf', null, + 'adj_ssp', null, + 'booking', null, + 'pay_act', null, + 'pay_date', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'pay_amount', null, + 'adj_comment', null, + 'adj_current', null, + 'adj_reserve', null, + 'pay_comment', null, + 'transfer_q2', null, + 'transfer_q3', null, + 'transfer_q4', null, + 'transfer_econ', null, + 'actual_quarter', 0, + 'corrected_plan', 0, + 'residual_after_actual', 0, + 'residual_after_booking', 0 + ) AS tp_q1, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_q3', null, + 'transfer_q4', null, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q2, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_q4', null, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q3, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_spod', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q4, + jsonb_build_object( + 'fact_year', 0 + ) AS tp_totals + + + + + + ), + -- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════ + all_programs AS ( + SELECT + fp.id AS prog_id, + fp.name AS prog_name, + fp.section_code, + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + FROM v3.form4_project fp + LEFT JOIN LATERAL ( + SELECT id, section_code, name + FROM v3.expense_item + WHERE section_code = fp.section_code AND depth = 0 + LIMIT 1 + ) sec ON TRUE + WHERE fp.level = 'program' + AND (p_sections IS NULL OR fp.section_code = ANY(p_sections)) + AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id + ), + + -- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════ + all_projects AS ( + SELECT + prj.id AS prj_id, + prj.name AS prj_name, + prj.parent_id AS prog_id, + ap.sec_id, ap.sec_code, ap.sec_name + FROM v3.form4_project prj + JOIN all_programs ap ON ap.prog_id = prj.parent_id + WHERE prj.level = 'project' AND (ap.section_code = p_sheet) + + UNION ALL + + -- самостоятельные проекты (AURORA-1984): без программы, раздел хранит сам проект + SELECT + prj.id AS prj_id, + prj.name AS prj_name, + NULL::INT AS prog_id, + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + FROM v3.form4_project prj + LEFT JOIN LATERAL ( + SELECT id, section_code, name + FROM v3.expense_item + WHERE section_code = prj.section_code AND depth = 0 + LIMIT 1 + ) sec ON TRUE + WHERE 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)) + ), + + -- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════ + -- Замена рекурсивного агрегата v3.jsonb_sum (AURORA-1846): листья jsonb + -- разворачиваются в строки, суммируются GROUPING SETS по трём уровням + -- (раздел / программа / проект) и собираются обратно в jsonb. + -- Правила те же, что у v3.jsonb_numeric_sum: числа складываются, нечисловые + -- игнорируются, если по пути есть число; текст/null сохраняются, если путь + -- встретился в группе один раз; иначе JSON null; NULL-секция даёт NULL. + + -- Имена групп (без jsonb) + prj_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + prj_id, MAX(prj_name) AS prj_name + FROM inp WHERE prj_id IS NOT NULL + GROUP BY sec_id, prog_id, prj_id + ), + prog_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name + FROM inp WHERE prog_id IS NOT NULL + GROUP BY sec_id, prog_id + ), + root_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name + FROM inp + GROUP BY sec_id + ), + + -- Листья jsonb: (колонка, ключ 1-го уровня, ключ 2-го уровня или NULL, значение) + leaves AS ( + SELECT + i.sec_id, i.prog_id, i.prj_id, + c.col, e1.key AS k1, e2.key AS k2, + COALESCE(e2.value, e1.value) AS val + FROM inp i + CROSS JOIN LATERAL (VALUES + ('plan', i.plan_data), + ('seq_dfip', i.seq_dfip_data), + ('approved', i.approved_data), + ('contract_summary', i.contract_summary_data), + ('allocation', i.allocation_data), + ('reserve', i.reserve_data), + ('collegial', i.collegial_data), + ('ckk', i.ckk_data), + ('contract', i.contract_data), + ('booking', i.booking_data), + ('q1', i.q1_data), + ('q2', i.q2_data), + ('q3', i.q3_data), + ('q4', i.q4_data), + ('totals', i.totals_data) + ) AS c(col, v) + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(c.v) = 'object' THEN c.v END + ) AS e1 + LEFT JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(e1.value) = 'object' THEN e1.value END + ) AS e2 ON TRUE + ), + + -- Суммы листьев сразу по трём уровням (lvl: 3 раздел, 1 программа, 0 проект) + leaf_agg AS ( + SELECT + GROUPING(prog_id, prj_id) AS lvl, + sec_id, prog_id, prj_id, col, k1, k2, + COUNT(*) AS n, + COUNT(*) FILTER (WHERE jsonb_typeof(val) = 'number') AS n_num, + SUM((val #>> '{}')::numeric) FILTER (WHERE jsonb_typeof(val) = 'number') AS s, + (array_agg(val))[1] AS one + FROM leaves + GROUP BY GROUPING SETS ( + (sec_id, col, k1, k2), + (sec_id, prog_id, col, k1, k2), + (sec_id, prog_id, prj_id, col, k1, k2) + ) + ), + leaf_val AS ( + SELECT + lvl, sec_id, prog_id, prj_id, col, k1, k2, + CASE + WHEN n_num > 0 THEN to_jsonb(s) + WHEN n = 1 THEN one + ELSE 'null'::jsonb + END AS v + FROM leaf_agg + WHERE NOT (lvl = 1 AND prog_id IS NULL) + AND NOT (lvl = 0 AND prj_id IS NULL) + ), + -- Сборка второго уровня вложенности (booking.y2026.q1 → объект по ключу k1) + k1_val AS ( + SELECT + lvl, sec_id, prog_id, prj_id, col, k1, + CASE + WHEN bool_or(k2 IS NOT NULL) THEN jsonb_object_agg(k2, v) FILTER (WHERE k2 IS NOT NULL) + ELSE (array_agg(v))[1] + END AS v + FROM leaf_val + GROUP BY lvl, sec_id, prog_id, prj_id, col, k1 + ), + -- Сборка по колонкам: одна строка на группу + lvl_data AS ( + SELECT + lvl, sec_id, prog_id, prj_id, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'plan') AS plan_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'seq_dfip') AS seq_dfip_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'approved') AS approved_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract_summary') AS contract_summary_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'allocation') AS allocation_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'reserve') AS reserve_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'collegial') AS collegial_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'ckk') AS ckk_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract') AS contract_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'booking') AS booking_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q1') AS q1_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q2') AS q2_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q3') AS q3_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q4') AS q4_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'totals') AS totals_data + FROM k1_val + GROUP BY lvl, sec_id, prog_id, prj_id + ), + + prj_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM prj_names n + LEFT JOIN lvl_data d + ON d.lvl = 0 AND d.sec_id = n.sec_id AND d.prog_id IS NOT DISTINCT FROM n.prog_id AND d.prj_id = n.prj_id + ), + prog_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM prog_names n + LEFT JOIN lvl_data d + ON d.lvl = 1 AND d.sec_id = n.sec_id AND d.prog_id = n.prog_id + ), + root_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM root_names n + LEFT JOIN lvl_data d + ON d.lvl = 3 AND d.sec_id = n.sec_id + ), + + -- Нулевой JSONB-шаблон (для пустых программ/проектов) + zero AS ( + SELECT '{}'::jsonb AS z + ), + + -- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════ + unioned AS ( + -- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк) + SELECT + 'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id, + jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header, + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path + FROM root_agg + + UNION ALL + + -- GROUP — из агрегатов (программы с бюджетными строками) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM prog_agg + + UNION ALL + + -- GROUP — пустые программы (нет ни одной budget_line) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved + tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve + tp_collegial, tp_ckk, -- collegial, ckk + tp_contract, tp_booking, -- contract, booking + tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM all_programs ap + JOIN tp_agg ON tp_agg.tp_id = 1 + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND ( + bl.project_id = ap.prog_id -- прямая привязка к программе + OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект + WHERE pr.parent_id = ap.prog_id AND pr.level = 'project') + ) + ) + + UNION ALL + + -- ITEM — из агрегатов (проекты с бюджетными строками) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[] + FROM prj_agg + + UNION ALL + + -- ITEM — пустые проекты (нет budget_line) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved + tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve + tp_collegial, tp_ckk, -- collegial, ckk + tp_contract, tp_booking, -- contract, booking + tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals + ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[] + FROM all_projects apr + JOIN tp_agg ON tp_agg.tp_id = 1 + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND bl.project_id = apr.prj_id + ) + + UNION ALL + + -- INPUT — все строки бюджета + SELECT + 'INPUT', 3, line_id, + header || jsonb_build_object( + 'project_id', prj_id, 'project_name', prj_name, + 'program_id', prog_id, 'program_name', prog_name + ), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + 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[] + FROM inp + ) + + SELECT + u.row_type, u.depth, + ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order, + jsonb_build_object( + 'sort_path', u.sort_path, + 'line_id', u.line_id, + 'header', u.header, + 'plan', u.plan_data, + 'seq_dfip', u.seq_dfip_data, + 'approved', u.approved_data, + 'contract_summary', u.contract_summary_data, + 'allocation', u.allocation_data, + 'reserve', u.reserve_data, + 'collegial', u.collegial_data, + 'ckk', u.ckk_data, + 'contract', u.contract_data, + 'booking', u.booking_data, + 'q1', u.q1_data, + 'q2', u.q2_data, + 'q3', u.q3_data, + 'q4', u.q4_data, + 'totals', u.totals_data + ) AS data + FROM unioned u + ORDER BY u.sort_path; +$function$ +; diff --git a/api/src/db/models/form4_project.py b/api/src/db/models/form4_project.py index fe74414..a05171c 100644 --- a/api/src/db/models/form4_project.py +++ b/api/src/db/models/form4_project.py @@ -24,9 +24,13 @@ class Form4Project(Base): name="chk_form4_project_parent", ), CheckConstraint( - "(level = 'program') = (section_code IS NOT NULL)", + "((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)", name="chk_form4_project_section_code", ), + CheckConstraint( + "parent_id IS NOT NULL OR form_id IS NOT NULL", + name="chk_form4_project_standalone_form", + ), Index( "ix_form4_project_section_code", "section_code", diff --git a/api/tests/integration/test_form4_standalone_project.py b/api/tests/integration/test_form4_standalone_project.py new file mode 100644 index 0000000..845bf61 --- /dev/null +++ b/api/tests/integration/test_form4_standalone_project.py @@ -0,0 +1,112 @@ +"""AURORA-1984: самостоятельный проект формы 4 (без программы) на листе CAP.""" +import re +from decimal import Decimal +from pathlib import Path + +import pytest + +from sheet_contract import strip_nulls +from test_form4_sheet_sums import F4, SHEET, _loads + + +@pytest.fixture +def f4(client, db_session, admin_tokens, auth_headers): + return F4(client, db_session, auth_headers(admin_tokens)) + + +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 strip_nulls(by_type["ITEM"]["plan"]) == fresh["data"]["plan"] # GET без null (AURORA-1848) + + +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 -- 2.47.2 From 209d36e832134f2d58070447f3250370a13098f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:23:28 +0400 Subject: [PATCH 04/13] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=B2?= =?UTF-8?q?=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../sql/0059_form4_standalone_project.sql | 192 +++++++++++++++++- .../test_form4_standalone_project.py | 67 +++++- 2 files changed, 253 insertions(+), 6 deletions(-) diff --git a/api/alembic/versions/sql/0059_form4_standalone_project.sql b/api/alembic/versions/sql/0059_form4_standalone_project.sql index e5ec0ff..722e85f 100644 --- a/api/alembic/versions/sql/0059_form4_standalone_project.sql +++ b/api/alembic/versions/sql/0059_form4_standalone_project.sql @@ -17,8 +17,196 @@ ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_section_code ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL); --- ═══ 2. Функции add/upd/del_form4_project (Task 2) ═══════════════════════════ --- >>> здесь будут add_form4_project / upd_form4_project / del_form4_project <<< +-- ═══ 2. Функции add/upd/del_form4_project ════════════════════════════════════ +-- Права: создание — v3.can_edit; переименование и удаление — как раньше (R-6). +DROP FUNCTION v3.add_form4_project(varchar, int4); + +CREATE FUNCTION 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 +) + RETURNS TABLE(project_id integer, row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_section_code VARCHAR; + v_form_id INT; + v_user_id INT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF NOT ((p_program_id IS NOT NULL AND p_form_id IS NULL AND p_section_code IS NULL) + OR (p_program_id IS NULL AND p_form_id IS NOT NULL AND p_section_code IS NOT NULL)) THEN + RAISE EXCEPTION 'Укажите либо программу, либо форму и раздел'; + END IF; + + IF p_program_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program') THEN + RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id; + END IF; + SELECT fp.form_id, fp.section_code INTO v_form_id, v_section_code + FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + ELSE + IF NOT EXISTS (SELECT 1 FROM v3.budget_form bf + WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id) THEN + RAISE EXCEPTION 'Только FORM_4'; + END IF; + IF p_section_code NOT IN ('AHR', 'CAP', 'OPER') THEN + RAISE EXCEPTION 'Раздел % недоступен для проекта', p_section_code; + END IF; + v_form_id := p_form_id; + v_section_code := p_section_code; + END IF; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(v_form_id, v_section_code, NULL, NULL, v_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: Невозможно вставить строку'; + + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: Невозможно вставить строку'; + + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: opens %s', v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: closed %s', v_decision->>'closes_at'); + ELSE + v_msg := 'window_closed: no phase'; + END IF; + RAISE EXCEPTION '%', v_msg; + + ELSE + RAISE EXCEPTION '%', v_decision->>'code'; + END IF; + END IF; + + IF p_program_id IS NOT NULL THEN + INSERT INTO v3.form4_project (name, level, parent_id, form_id) + VALUES (p_name, 'project', p_program_id, v_form_id) + RETURNING id INTO v_pid; + ELSE + INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) + VALUES (p_name, 'project', NULL, v_form_id, v_section_code) + RETURNING id INTO v_pid; + END IF; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'project', + 'parent_id', p_program_id, + 'form_id', v_form_id, + 'section_code', v_section_code, + 'form_type', 'FORM_4' + )), + v_form_id, NULL, NULL + ); + + IF v_section_code IS NOT NULL THEN + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; + END IF; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION v3.upd_form4_project(p_project_id INT, p_name VARCHAR) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; + v_section_code VARCHAR; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + v_section_code := COALESCE(v_program.section_code, v_project.section_code); + UPDATE v3.form4_project SET name = trim(p_name) WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_UPDATE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', trim(p_name), + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = p_project_id) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +CREATE OR REPLACE FUNCTION v3.del_form4_project(p_project_id INT) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; + v_section_code VARCHAR; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF EXISTS (SELECT 1 FROM v3.budget_line bl WHERE bl.project_id = p_project_id) THEN + RAISE EXCEPTION 'Нельзя удалить проект, содержащий строки бюджета'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + v_section_code := COALESCE(v_program.section_code, v_project.section_code); + DELETE FROM v3.form4_project WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_DELETE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', v_project.name, + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; -- ═══ 3. Лист ═════════════════════════════════════════════════════════════════ -- Тело — копия sql/0053_form2_ahr_source_sheet.sql, изменены: all_projects (+самостоятельные), diff --git a/api/tests/integration/test_form4_standalone_project.py b/api/tests/integration/test_form4_standalone_project.py index 845bf61..f247c25 100644 --- a/api/tests/integration/test_form4_standalone_project.py +++ b/api/tests/integration/test_form4_standalone_project.py @@ -14,12 +14,15 @@ def f4(client, db_session, admin_tokens, auth_headers): return F4(client, db_session, auth_headers(admin_tokens)) -def standalone(f4, name="solo", section="CAP"): +def add(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", + "SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)", n=name, f=f4.form_id, s=section, - )[0][0] + ) + + +def standalone(f4, name="solo", section="CAP"): + return add(f4, name, section)[0][0] def outline(rows): @@ -78,6 +81,7 @@ def test_cell_patch_response(f4, client): 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 + assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 20 # значение дошло до ITEM (T1.1) fresh = next(r for r in f4.sheet() if r["row_type"] == "ITEM") assert strip_nulls(by_type["ITEM"]["plan"]) == fresh["data"]["plan"] # GET без null (AURORA-1848) @@ -110,3 +114,58 @@ def test_sheet_without_standalone_equals_0053(f4): 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 + + +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) -- 2.47.2 From 69c4a8f9c996db5a4ada8cb62b60ced5bf71486b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:27:16 +0400 Subject: [PATCH 05/13] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20project=5Fadded=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/websocket.py | 10 ++- api/src/repository/sheet_repository.py | 12 +++- api/src/services/sheet_service.py | 65 +++++++++++++------ .../test_form4_standalone_project.py | 21 ++++++ .../test_sheet_service_form4_standalone.py | 54 +++++++++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 api/tests/unit/test_sheet_service_form4_standalone.py diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index c4902c6..c0e3122 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -482,7 +482,9 @@ class FormEventProcess: case "project_added": return await self.__add_project( event_data=event_data["data"], + form_id=form_id, user=curr_user, + sheet=sheet, ) case "program_updated": return await self.__update_form4_program( @@ -610,17 +612,21 @@ class FormEventProcess: async def __add_project( self, event_data: dict, + form_id: int, + sheet: str, user: AppUser, ) -> list[tuple]: """ data = { name: str, - program_id: int + program_id?: int } """ return await self.sheet_service.add_project( name=event_data["name"], - program_id=event_data["program_id"], + program_id=event_data.get("program_id"), + form_id=form_id, + sheet=sheet, user=user, ) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 22795d6..c76ccc1 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -208,7 +208,9 @@ class SheetRepository: async def add_project( self, name: str, - program_id: int, + program_id: int | None = None, + form_id: int | None = None, + section_code: str | None = None, ) -> list[tuple]: return [ tuple(r) for r in ( @@ -218,13 +220,17 @@ class SheetRepository: SELECT project_id, row_type, depth, sort_order, data FROM v3.add_form4_project( :p_name, - :p_program_id + :p_program_id, + :p_form_id, + :p_section_code ) """ ), { - "p_program_id": program_id, "p_name": name, + "p_program_id": program_id, + "p_form_id": form_id, + "p_section_code": section_code, } ) ).all() diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index f1b24b4..decd1d6 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -158,31 +158,54 @@ class SheetService: async def add_project( self, name: str, - program_id: int, + program_id: int | None = None, + form_id: int | None = None, + sheet: str | None = None, user: AppUser | None = None, - ) -> tuple[int, list[tuple]]: - if user.role_id != UserRoleEnum.ADMIN: - program = await self.f4_project_repo.get( - project_id=program_id, - level=Form4ProjectLevelEnum.PROGRAM, + ) -> tuple[int, list[tuple]] | None: + if program_id is None: + if user.role_id != UserRoleEnum.ADMIN: + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Форма не найдена") + result = await self.sheet_repo.add_project( + name=name, + program_id=None, + form_id=form_id, + section_code=sheet, ) - if not program: - raise SheetValidationError("Программа не найдена") + else: + if user.role_id != UserRoleEnum.ADMIN: + program = await self.f4_project_repo.get( + project_id=program_id, + level=Form4ProjectLevelEnum.PROGRAM, + ) + if not program: + raise SheetValidationError("Программа не найдена") - user = await self.user_repo.get( - user_id=user.id, - load_orgs=True, + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=program.form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Программа не найдена") + result = await self.sheet_repo.add_project( + name=name, + program_id=program_id, + form_id=None, + section_code=None, ) - form = await self.bf_repo.get( - budget_form_id=program.form_id, - org_unit=[ou.id for ou in user.org_units], - ) - if not form: - raise SheetValidationError("Программа не найдена") - result = await self.sheet_repo.add_project( - program_id=program_id, - name=name, - ) if result: return (result[0][0], [tuple(r[1:]) for r in result]) return None diff --git a/api/tests/integration/test_form4_standalone_project.py b/api/tests/integration/test_form4_standalone_project.py index f247c25..44e87b9 100644 --- a/api/tests/integration/test_form4_standalone_project.py +++ b/api/tests/integration/test_form4_standalone_project.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest from sheet_contract import strip_nulls +from src.repository.sheet_repository import SheetRepository from test_form4_sheet_sums import F4, SHEET, _loads @@ -169,3 +170,23 @@ 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) + + +def test_rename_standalone_with_root_returns_item_and_root(f4): + solo = standalone(f4); f4.line(solo) + rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, 'Новое')", p=solo) + assert sorted(r[0] for r in rows) == ["ITEM", "ROOT"] + + +def test_delete_empty_standalone_with_root_returns_root_only(f4): + first = standalone(f4, "А"); f4.line(first) + second = standalone(f4, "Б") + rows = f4.sql("SELECT row_type FROM v3.del_form4_project(:p)", p=second) + assert [r[0] for r in rows] == ["ROOT"] + + +def test_repository_creates_in_section(f4): + async def _run(): + return await SheetRepository(f4.db).add_project(name="Р", form_id=f4.form_id, section_code="CAP") + rows = f4.client.portal.call(_run) + assert [r[1] for r in rows] == ["ITEM"] diff --git a/api/tests/unit/test_sheet_service_form4_standalone.py b/api/tests/unit/test_sheet_service_form4_standalone.py new file mode 100644 index 0000000..11fa314 --- /dev/null +++ b/api/tests/unit/test_sheet_service_form4_standalone.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.db.models.role import UserRoleEnum +from src.repository.sheet_repository import SheetValidationError +from src.services.sheet_service import SheetService + +ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN) +EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF) + + +def make_service() -> SheetService: + service = SheetService(AsyncMock()) + service.sheet_repo = AsyncMock() + service.bf_repo = AsyncMock() + service.user_repo = AsyncMock() + service.f4_project_repo = AsyncMock() + service.user_repo.get.return_value = SimpleNamespace(org_units=[]) + return service + + +@pytest.mark.asyncio +async def test_admin_creates_in_section(): + service = make_service() + service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})] + result = await 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" + ) + + +@pytest.mark.asyncio +async def test_foreign_executor_cannot_create_in_section(): + service = make_service() + service.bf_repo.get.return_value = None + with pytest.raises(SheetValidationError): + await service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR) + service.sheet_repo.add_project.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"]) +async 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): + await getattr(service, method)(**kwargs) -- 2.47.2 From 374ac4bd9e45b4de498278020cde400aa5311f94 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:29:42 +0400 Subject: [PATCH 06/13] =?UTF-8?q?AURORA-1984:=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=D0=B2=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Modals/CreateProgramProjectModal.jsx | 2 +- .../RealtimeTable/RealtimeTable.jsx | 17 +----- .../RealtimeTable/hooks/useRealtimeData.js | 12 +++- .../RealtimeTable/utils/cellUtils.js | 24 ++++++++ .../RealtimeTable/utils/cellUtils.test.js | 57 ++++++++++++++++++- .../RealtimeTable/utils/rowUtils.js | 9 +++ .../RealtimeTable/utils/rowUtils.test.js | 12 +++- 7 files changed, 113 insertions(+), 20 deletions(-) diff --git a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx index fee94e7..f5b6d77 100644 --- a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx +++ b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx @@ -30,7 +30,7 @@ const CreateProgramModalComponent = ({ isOpen, onClose, onCreate, title = '', is onClose(); }; - const isFormValid = name !== ''; + const isFormValid = name.trim() !== ''; if (!isOpen) return null; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index d9ae991..deb3a23 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -28,7 +28,7 @@ import { useValidationRules } from './hooks/useValidationRules'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; import { isCellEditable } from './utils/cellEditability'; import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; -import { canAddRowForHeader, getDeleteTarget } from './utils/rowUtils'; +import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget } from './utils/rowUtils'; import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; @@ -931,9 +931,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const handleAddProjectRow = useCallback( (name) => { - const row = getSelectedRow(); - if (!row) return; - contextAddProject({ name, program_id: row.original.data.header.program_id }); + contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) }); }, [contextAddProject, getSelectedRow], ); @@ -1015,17 +1013,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); return; } - const row = getSelectedRow(); - if (!row) { - toast.error('Выделите строку для вставки'); - return; - } - if (row.original.row_type !== 'GROUP') { - toast.error('Выделите строку программы'); - return; - } modalsRef.current?.open('project'); - }, [canModifyRows, getSelectedRow]); + }, [canModifyRows]); useEffect(() => { return () => { diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index 5d3f383..6d69fda 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -11,6 +11,7 @@ import { deleteRow, insertCell, insertProjectRow, + insertStandaloneProjectRow, updateCells, } from '../utils/cellUtils'; import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils'; @@ -150,9 +151,14 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { return; } setData((prevData) => { - const updateData = updateCells(prevData, updatedCells); - const newData = insertProjectRow(updateData, newRow, program_id); - return newData; + let base = structuredClone(prevData); + const rootRow = updates.find((row) => row[0] === 'ROOT'); + if (base[0]?.row_type !== 'ROOT' && rootRow) { + base = addRootRow(base, rootRow); + } + const updateData = updateCells(base, alignNodeRows(base, updatedCells)); + if (program_id != null) return insertProjectRow(updateData, newRow, program_id); + return insertStandaloneProjectRow(updateData, newRow); }); }); diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js index 9e6d247..601072d 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.js @@ -284,6 +284,30 @@ export function insertProjectRow(data, newRow, program_id) { return updatedRows; } +// AURORA-1984: самостоятельный проект (без программы) — под первый ROOT или на +// верхний уровень, перед первой строкой списка с большим sort_order (исторические +// INPUT идут после проектов). Номера строк с sort_order >= нового сдвигаются. +export function insertStandaloneProjectRow(data, newRow) { + const updatedRows = structuredClone(data); + const [row_type, depth, sort_order, dataRow] = newRow; + const newRowObject = { row_type, depth, sort_order, subRows: [], data: dataRow }; + + const shift = (rows) => { + for (const row of rows) { + if (row.sort_order >= sort_order) row.sort_order += 1; + if (Array.isArray(row.subRows)) shift(row.subRows); + } + }; + shift(updatedRows); + + const root = updatedRows.find((row) => row.row_type === 'ROOT'); + if (root && !Array.isArray(root.subRows)) root.subRows = []; + const target = root ? root.subRows : updatedRows; + const index = target.findIndex((row) => row.sort_order > sort_order); + target.splice(index === -1 ? target.length : index, 0, newRowObject); + return updatedRows; +} + export function deleteRow(data, lineId) { if (lineId == null) return data; return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId); diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js index 7e31d11..1652177 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.test.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js @@ -1,6 +1,15 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { alignNodeRows, deleteProgramRow, deleteProjectRow, deleteRow, updateCells } from './cellUtils.js'; +import { + addRootRow, + alignNodeRows, + deleteProgramRow, + deleteProjectRow, + deleteRow, + insertCell, + insertStandaloneProjectRow, + updateCells, +} from './cellUtils.js'; // Эталон: прежняя реализация updateCells (до AURORA-1838). function oldUpdateCells(data, newRows) { @@ -385,3 +394,49 @@ test('AURORA-1978: alignNodeRows — ROOT по section_code, строки без ], ); }); + +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); +}); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index 48d556f..4dad616 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -69,6 +69,15 @@ export function getDeleteTarget(rowData, { isForm4 = false } = {}) { return id == null ? null : { kind: 'row', id }; } +// AURORA-1984: цель добавления проекта по выделенной строке. Программа задана +// только если у GROUP/ITEM/INPUT header.program_id != null; иначе проект +// самостоятельный. rowData — row.original. +export function getProjectAddTarget(rowData) { + const programId = rowData?.data?.header?.program_id; + const typed = ['GROUP', 'ITEM', 'INPUT'].includes(rowData?.row_type); + return typed && programId != null ? { program_id: programId } : {}; +} + export function isVspRow(row) { if (row?.original?.data?.id) { return true; diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index a7b623f..281ddc8 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget } from './rowUtils.js'; +import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget, getProjectAddTarget } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -87,3 +87,13 @@ test('AURORA-1978: getDeleteTarget — строка бюджета и прочи assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null); assert.equal(getDeleteTarget(null, { isForm4: true }), null); }); + +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 }); +}); -- 2.47.2 From 9218d2f7f5620a0b451e82f4e794887dc8a99c47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:31:17 +0400 Subject: [PATCH 07/13] =?UTF-8?q?AURORA-1984:=20=D1=80=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20R-7=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1984-pd-standalone-projects/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index bfe02c3..ff0fb4e 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -131,3 +131,4 @@ ROOT выводится только при наличии расходов в | 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 может переименовать или удалить узел при закрытом окне ввода | +| 2026-11-08 | R-7 (design #19): путь «проект в программу» в `subscribeToProjectAdds` теперь тоже идёт через `alignNodeRows` + `updateCells` (как требует шаг 3 плана, задача 4), а не через `updateCells` с серверными номерами. Находка T4.1 ревью задачи 4 | сопоставление по id устойчивее номеров, ROOT добавляется общим кодом; для программы, уже присутствующей локально, результат тот же или точнее | строки ответа с ключом, которого нет локально (например GROUP чужой только что созданной программы), отбрасываются до события `program_added`/перезагрузки | -- 2.47.2 From 78227b46c07c4cf444692fb5e6195255cbe16c98 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:32:42 +0400 Subject: [PATCH 08/13] =?UTF-8?q?AURORA-1980:=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Modals/RenameNodeModal.jsx | 61 +++++++++++++++++++ .../RealtimeTable/RealtimeTable.jsx | 42 ++++++++++++- .../SettingPanel/SettingPanel.jsx | 11 ++++ .../RealtimeTable/contexts/RealtimeContext.js | 55 +++++++++++++++++ .../RealtimeTable/hooks/useRealtimeData.js | 8 +++ .../RealtimeTable/utils/cellUtils.test.js | 7 +++ .../RealtimeTable/utils/rowUtils.js | 19 ++++++ .../RealtimeTable/utils/rowUtils.test.js | 29 ++++++++- 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx diff --git a/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx b/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx new file mode 100644 index 0000000..9eab314 --- /dev/null +++ b/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx @@ -0,0 +1,61 @@ +import { Box, Divider, IconButton, Stack } from '@mui/material'; +import { memo, useEffect, useState } from 'react'; +import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; +import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; +import { StyledTextField } from '../../common/StyledTextField'; +import { Cancel } from '../../common/icons/icons'; + +const TITLES = { program: 'Переименование программы', project: 'Переименование проекта' }; + +// AURORA-1980: target — результат getRenameTarget; onSubmit получает обрезанное название. +// Окно само не закрывается после отправки: закрытие/остаток при ошибке решает родитель. +const RenameNodeModalComponent = ({ target, isSaving = false, onClose, onSubmit }) => { + const [name, setName] = useState(''); + + useEffect(() => { + setName(target?.name ?? ''); + }, [target]); + + if (!target) return null; + + const isFormValid = name.trim() !== ''; + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget) onClose(); + }; + + const handleSave = () => { + if (!isFormValid || isSaving) return; + onSubmit?.(name.trim()); + }; + + return ( + + + + + {TITLES[target.kind]} + + + + + + + + + setName(e.target.value)} /> + + + Отмена + + + Сохранить + + + + + + ); +}; + +export const RenameNodeModal = memo(RenameNodeModalComponent); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index deb3a23..76b1814 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -13,6 +13,7 @@ import { CircularProgress } from '@mui/material'; import { debounce } from '@mui/material'; import { toast } from 'react-toastify'; import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal'; +import { RenameNodeModal } from './Modals/RenameNodeModal'; import { SheetModals } from './Modals/SheetModals'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig'; @@ -28,7 +29,7 @@ import { useValidationRules } from './hooks/useValidationRules'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; import { isCellEditable } from './utils/cellEditability'; import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; -import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget } from './utils/rowUtils'; +import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils'; import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; @@ -139,6 +140,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { // AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget. const [pendingDelete, setPendingDelete] = useState(null); + // AURORA-1980: { kind: 'program' | 'project', id, name } — см. getRenameTarget. + const [pendingRename, setPendingRename] = useState(null); + const [isRenameSaving, setIsRenameSaving] = useState(false); const modalsRef = useRef(null); const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]); @@ -174,6 +178,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { deleteRow: contextDeleteRow, deleteProgram: contextDeleteProgram, deleteProject: contextDeleteProject, + renameNode: contextRenameNode, + subscribeToNodeRenameResult, startEditing: contextStartEditing, addProgram: contextAddProgram, addProject: contextAddProject, @@ -986,6 +992,37 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { setRowSelection({}); }, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]); + // AURORA-1980: ответ на своё переименование — успех закрывает окно, ошибка + // оставляет его с введённым текстом (тост показывает общий обработчик). + useEffect( + () => + subscribeToNodeRenameResult((msg) => { + if (!isRenameResponse(msg, pendingRename)) return; + setIsRenameSaving(false); + if (!msg.error) setPendingRename(null); + }), + [pendingRename, subscribeToNodeRenameResult], + ); + + const canRenameNode = Boolean(getRenameTarget(getSelectedRow()?.original)); + + const handleRenameNode = useCallback(() => { + setPendingRename(getRenameTarget(getSelectedRow()?.original)); + }, [getSelectedRow]); + + const closeRenameNode = useCallback(() => { + setPendingRename(null); + setIsRenameSaving(false); + }, []); + + const submitRenameNode = useCallback( + (name) => { + if (!pendingRename) return; + setIsRenameSaving(contextRenameNode(pendingRename.kind, pendingRename.id, name)); + }, + [contextRenameNode, pendingRename], + ); + const addRow = useCallback(() => { if (!canModifyRows) { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); @@ -1086,6 +1123,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onAddRow={addRow} onAddProgram={addProgram} onAddProject={addProject} + onRenameNode={handleRenameNode} + canRenameNode={canRenameNode} onDeleteRow={handleDeleteRow} formId={formId} sheetName={sheetName} @@ -1146,6 +1185,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { + + + + + + + + + )} diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index 328915e..b45d02a 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -117,6 +117,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, console.log(data.error); toast.error(getWsErrorMessage(data.error)); onErrorRef.current?.(data.error); + // AURORA-1980: окну переименования нужно полное сообщение (event + data), + // а не только текст ошибки. + if (data.event === 'program_updated' || data.event === 'project_updated') { + onNodeRenameResultRef.current?.(data); + } return; } @@ -176,6 +181,15 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, } break; + // AURORA-1980: переименование программы/проекта формы 4. + case 'program_updated': + case 'project_updated': + if (data.result) { + onNodeUpdateRef.current?.(data); + onNodeRenameResultRef.current?.(data); + } + break; + default: console.log('Unknown event:', data.event); } @@ -189,6 +203,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, const onProgramAddRef = useRef(null); const onProjectAddRef = useRef(null); const onNodeDeleteRef = useRef(null); + const onNodeUpdateRef = useRef(null); + const onNodeRenameResultRef = useRef(null); const onErrorRef = useRef(null); const onAuthSuccessRef = useRef(null); const onCellEditStartRef = useRef(null); @@ -450,6 +466,25 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, [addCommonField, isConnectedRef, sendMessage], ); + // AURORA-1980: переименование программы/проекта формы 4. + const renameNode = useCallback( + (kind, id, name) => { + if (!isConnectedRef.current) { + onErrorRef.current?.('Нет подключения или авторизации'); + return false; + } + + const message = { + event: `${kind}_updated`, + data: { [`${kind}_id`]: id, name }, + }; + addCommonField(message); + + return sendMessage(message); + }, + [addCommonField, isConnectedRef, sendMessage], + ); + const subscribeToCellUpdates = useCallback((callback) => { onCellUpdateRef.current = callback; return () => { @@ -492,6 +527,20 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, }; }, []); + const subscribeToNodeUpdates = useCallback((callback) => { + onNodeUpdateRef.current = callback; + return () => { + onNodeUpdateRef.current = null; + }; + }, []); + + const subscribeToNodeRenameResult = useCallback((callback) => { + onNodeRenameResultRef.current = callback; + return () => { + onNodeRenameResultRef.current = null; + }; + }, []); + const subscribeToErrors = useCallback((callback) => { onErrorRef.current = callback; return () => { @@ -538,11 +587,14 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, deleteRow, deleteProgram, deleteProject, + renameNode, subscribeToRowAdds, subscribeToRowDeletes, subscribeToProgramAdds, subscribeToProjectAdds, subscribeToNodeDeletes, + subscribeToNodeUpdates, + subscribeToNodeRenameResult, subscribeToErrors, subscribeToAuthSuccess, subscribeToCellEditStart, @@ -560,6 +612,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, deleteProject, deleteRow, endEditing, + renameNode, lockedCellsStore, retryLogin, startEditing, @@ -569,6 +622,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, subscribeToCellUpdates, subscribeToErrors, subscribeToNodeDeletes, + subscribeToNodeRenameResult, + subscribeToNodeUpdates, subscribeToProgramAdds, subscribeToProjectAdds, subscribeToRowAdds, diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index 6d69fda..e3a4ee4 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -33,6 +33,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { subscribeToProjectAdds, subscribeToRowDeletes, subscribeToNodeDeletes, + subscribeToNodeUpdates, subscribeToErrors, subscribeToCellEditStart, subscribeToCellEditEnd, @@ -213,6 +214,11 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { }); }); + // AURORA-1980: переименование — имя в строке и пересчёт приходят всем. + const unsubscribeNodeUpdates = subscribeToNodeUpdates((msg) => { + setData((prevData) => updateCells(prevData, alignNodeRows(prevData, msg.result[1] ?? []))); + }); + // Обработчик начала редактирования ячейки const unsubscribeCellEditStart = subscribeToCellEditStart((data) => { const dataCell = data.data; @@ -253,6 +259,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { unsubscribeProjectAdds(); unsubscribeRowDeletes(); unsubscribeNodeDeletes(); + unsubscribeNodeUpdates(); unsubscribeErrors(); unsubscribeCellEditStart(); unsubscribeCellEditEnd(); @@ -263,6 +270,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { subscribeToRowAdds, subscribeToRowDeletes, subscribeToNodeDeletes, + subscribeToNodeUpdates, subscribeToErrors, subscribeToCellEditStart, subscribeToCellEditEnd, diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js index 1652177..929ee9e 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.test.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js @@ -440,3 +440,10 @@ test('AURORA-1984: insertCell после addRootRow — первый расхо 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); }); + +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, 'Новое'); +}); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index 4dad616..d9ebe4c 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -103,3 +103,22 @@ export function getParentId(data) { export function canAddRowForHeader(header) { return header?.can_add_row !== false; } + +// AURORA-1980: что переименовывает кнопка панели — программу (GROUP) или проект (ITEM) формы 4. +export function getRenameTarget(rowData) { + const header = rowData?.data?.header; + if (rowData?.row_type === 'GROUP' && header?.program_id != null) { + return { kind: 'program', id: header.program_id, name: header.name ?? '' }; + } + if (rowData?.row_type === 'ITEM' && header?.project_id != null) { + return { kind: 'project', id: header.project_id, name: header.name ?? '' }; + } + return null; +} + +// AURORA-1980: WS без идентификатора запроса — свой ответ на своё переименование +// узнаём по is_self, событию и id узла (чужое переименование окно не закрывает). +export function isRenameResponse(msg, target) { + if (!msg || !target || msg.is_self !== true) return false; + return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id; +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 281ddc8..bbf6381 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget, getProjectAddTarget } from './rowUtils.js'; +import { + buildRowHierarchy, + canAddRowForHeader, + getDataRowId, + getDeleteTarget, + getProjectAddTarget, + getRenameTarget, + isRenameResponse, +} from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -97,3 +105,22 @@ test('AURORA-1984: getProjectAddTarget — цель по выделенной с assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {}); assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 }); }); + +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); +}); -- 2.47.2 From 321943db5657c7fe53121aec5beda070ce5c088e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:35:02 +0400 Subject: [PATCH 09/13] =?UTF-8?q?AURORA-1979:=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D1=82=D1=80=D0=B5=D1=82=D1=8C=D0=B5=D0=BC=20?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D1=86=D0=B5=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../export_service/export_normalizers.py | 18 ++++++++++++++++++ .../services/export_service/export_service.py | 8 +++++--- api/tests/unit/test_export_normalizers.py | 16 ++++++++++++++++ api/tests/unit/test_export_writers.py | 8 ++++++++ .../RealtimeTable/constants/FORM_4/AHR.js | 1 + .../RealtimeTable/constants/FORM_4/CAP.js | 1 + .../RealtimeTable/constants/FORM_4/OPER.js | 1 + .../components/RealtimeTable/tableColumns.jsx | 9 +++++++-- .../RealtimeTable/utils/form4TypeLabel.test.js | 13 +++++++++++++ .../components/RealtimeTable/utils/rowUtils.js | 8 ++++++++ .../RealtimeTable/utils/rowUtils.test.js | 10 ++++++++++ 11 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/form4TypeLabel.test.js diff --git a/api/src/services/export_service/export_normalizers.py b/api/src/services/export_service/export_normalizers.py index 1f67d87..5cf83f6 100644 --- a/api/src/services/export_service/export_normalizers.py +++ b/api/src/services/export_service/export_normalizers.py @@ -232,6 +232,24 @@ class Form1DepthRowNormalizer: normalized[f"{prefix}{key}"] = value +class Form4DepthRowNormalizer(Form1DepthRowNormalizer): + """AURORA-1979: подпись «программа»/«проект» вместо кода в колонке header.section_code. + + Нормализатор не получает row_type, поэтому узел определяется по line_id и id в header. + Исходные данные не меняются: раскраска читает header.section_code из исходной строки. + """ + + def normalize(self, data: dict[str, Any]) -> dict[str, Any]: + normalized = super().normalize(data) + header = data.get("header") + if data.get("line_id") is None and isinstance(header, dict): + if header.get("project_id") is not None: + normalized["header.section_code"] = "проект" + elif header.get("program_id") is not None: + normalized["header.section_code"] = "программа" + return normalized + + class SmetaRowNormalizer: def normalize(self, data: dict[str, Any]) -> dict[str, Any]: return { diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index 8fcadd7..12af8c3 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -28,6 +28,7 @@ from src.services.export_service.export_normalizers import ( ExcelValueSerializer, Form1DepthRowNormalizer, Form2DepthRowNormalizer, + Form4DepthRowNormalizer, Form3DepthRowNormalizer, SmetaRowNormalizer, ) @@ -125,6 +126,7 @@ class ExportService: self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer) self.form2_row_normalizer = Form2DepthRowNormalizer(self.value_serializer) self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer) + self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer) self.smeta_row_normalizer = SmetaRowNormalizer() self.sheet_writers: dict[str, TabularSheetWriter] = { "__default__": DepthSheetWriter( @@ -972,19 +974,19 @@ class ExportService: FORM4_OPER_DEPTH_COLUMNS, FORM4_OPER_COLORS, _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "AHR": ( FORM4_DEPTH_COLUMNS, FORM4_COLORS["AHR"], _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "CAP": ( FORM4_DEPTH_COLUMNS, FORM4_COLORS["CAP"], _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "STRUCTURE": ( FORM4_STRUCTURE_DEPTH_COLUMNS, diff --git a/api/tests/unit/test_export_normalizers.py b/api/tests/unit/test_export_normalizers.py index 9d650fb..819a053 100644 --- a/api/tests/unit/test_export_normalizers.py +++ b/api/tests/unit/test_export_normalizers.py @@ -1,5 +1,8 @@ +import pytest + from src.services.export_service.export_normalizers import ( ExcelValueSerializer, + Form4DepthRowNormalizer, Form1RowNormalizer, Form3DepthRowNormalizer, SmetaRowNormalizer, @@ -78,3 +81,16 @@ def test_form3_depth_normalizer_flattens_nested_data(): assert normalized["totals.total_actual"] == 40 assert normalized["totals.economy"] == 2.1 assert "line_id" not in normalized + + +@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"] != "программа" diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index 7535a9b..8d23f69 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -16,6 +16,7 @@ from src.services.export_service.export_writers import ( TabularSheetWriter, XlsxDepthSheetWriter, XlsxTabularSheetWriter, + _hierarchical_rows, ) # Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903). @@ -231,3 +232,10 @@ def test_export_service_uses_sheet_writer_strategy(): assert default_writer.calls == 1 assert smeta_writer.calls == 1 + + +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), + ] diff --git a/web/src/components/RealtimeTable/constants/FORM_4/AHR.js b/web/src/components/RealtimeTable/constants/FORM_4/AHR.js index 1422b89..e7f068b 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/AHR.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/AHR.js @@ -790,6 +790,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/constants/FORM_4/CAP.js b/web/src/components/RealtimeTable/constants/FORM_4/CAP.js index 60394de..ed4e4f9 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/CAP.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/CAP.js @@ -790,6 +790,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/constants/FORM_4/OPER.js b/web/src/components/RealtimeTable/constants/FORM_4/OPER.js index 64a2898..bd64570 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/OPER.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/OPER.js @@ -791,6 +791,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index f928581..5cbece6 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -11,7 +11,7 @@ import { isCellEditable } from './utils/cellEditability'; import { getColumnValue } from './utils/columnUtils'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; -import { getRowId } from './utils/rowUtils'; +import { getForm4TypeLabel, getRowId } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); @@ -252,7 +252,12 @@ export const getTableColumns = ({ // Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit // по строкам сами — enableEditing им не нужен, в отличие от листовых. const isLeafColumn = !col.columns?.length; - if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) { + if (isLeafColumn && !col.accessorFn && col.valueLabel === 'form4NodeType') { + // AURORA-1979: подпись «программа»/«проект»; функция назначается здесь, + // а не в конфиге — structuredClone конфига не переносит функции. + col.id = col.id ?? col.accessorKey; + col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey); + } else if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) { // MRT предупреждает об undefined для вложенных путей, отсутствующих // у отдельных типов строк. Сохраняем исходный id для настроек и прав. col.id = col.id ?? col.accessorKey; diff --git a/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js b/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js new file mode 100644 index 0000000..03afe71 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// Конфиги читаются как текст (импорт без расширений не грузится в node). +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/); + }); +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index d9ebe4c..3ccfdf4 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -122,3 +122,11 @@ export function isRenameResponse(msg, target) { if (!msg || !target || msg.is_self !== true) return false; return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id; } + +// AURORA-1979: подпись типа узла в колонке «Код статьи / подстатьи / позиции» формы 4. +export function getForm4TypeLabel(row) { + const header = row?.data?.header; + if (row?.row_type === 'GROUP' && header?.program_id != null) return 'программа'; + if (row?.row_type === 'ITEM' && header?.project_id != null) return 'проект'; + return null; +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index bbf6381..0cec593 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -5,6 +5,7 @@ import { canAddRowForHeader, getDataRowId, getDeleteTarget, + getForm4TypeLabel, getProjectAddTarget, getRenameTarget, isRenameResponse, @@ -124,3 +125,12 @@ test('AURORA-1980: isRenameResponse — только свой ответ на с assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false); assert.equal(isRenameResponse(msg, null), false); }); + +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); +}); -- 2.47.2 From b87b12f9800dfcca715840682407ca8d78d161be Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:38:04 +0400 Subject: [PATCH 10/13] =?UTF-8?q?AURORA-1989:=20=D0=BD=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D0=B8,=20=D1=81=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B2=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Modals/SelectExpenseItemModal.jsx | 5 ++-- .../RealtimeTable/utils/expenseItemOptions.js | 22 +++++++++++++++ .../utils/expenseItemOptions.test.js | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/expenseItemOptions.js create mode 100644 web/src/components/RealtimeTable/utils/expenseItemOptions.test.js diff --git a/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx b/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx index de0f2a3..bbd94e5 100644 --- a/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx +++ b/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx @@ -5,6 +5,7 @@ import { ExpenseItemApi } from '../../../api/expense-item'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { Cancel } from '../../common/icons/icons'; +import { compareExpenseItems, getExpenseItemLabel } from '../utils/expenseItemOptions'; const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, title = '', isSaving = false, sheet }) => { const [selectedValue, setSelectedValue] = useState(''); @@ -14,7 +15,7 @@ const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, ti if (!isOpen || formId == null) return; ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => { if (data.success) { - setExpenseItemOption(data.result); + setExpenseItemOption([...data.result].sort(compareExpenseItems)); return; } toast.error('Ошибка получения статья расходов'); @@ -69,7 +70,7 @@ const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, ti size='small' options={expenseItemOptions} noOptionsText='Не найдено' - getOptionLabel={(option) => option.name || ''} + getOptionLabel={getExpenseItemLabel} getOptionKey={(option) => option.id} value={selectedValue} onChange={(_event, newValue) => { diff --git a/web/src/components/RealtimeTable/utils/expenseItemOptions.js b/web/src/components/RealtimeTable/utils/expenseItemOptions.js new file mode 100644 index 0000000..70dbe92 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/expenseItemOptions.js @@ -0,0 +1,22 @@ +const getItemCode = (option) => { + const code = option?.item_id; + if (code == null) return ''; + return String(code).trim(); +}; + +export const getExpenseItemLabel = (option) => { + if (!option || typeof option !== 'object') return ''; + const code = getItemCode(option); + const name = option.name || ''; + if (code && name) return `${code} — ${name}`; + return code || name; +}; + +export const compareExpenseItems = (a, b) => { + const codeA = getItemCode(a); + const codeB = getItemCode(b); + if (!codeA && !codeB) return 0; + if (!codeA) return 1; + if (!codeB) return -1; + return codeA.localeCompare(codeB, 'ru', { numeric: true }); +}; diff --git a/web/src/components/RealtimeTable/utils/expenseItemOptions.test.js b/web/src/components/RealtimeTable/utils/expenseItemOptions.test.js new file mode 100644 index 0000000..08e94be --- /dev/null +++ b/web/src/components/RealtimeTable/utils/expenseItemOptions.test.js @@ -0,0 +1,27 @@ +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('Связь')); +}); -- 2.47.2 From f5ce14fd112bf1f4a3e7d49864f5bb26c4116604 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 23:05:59 +0400 Subject: [PATCH 11/13] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20=E2=80=94=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=B2=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=B5=20=D0=B8=20=D0=B2=20=D0=B2=D1=8B=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../services/export_service/export_writers.py | 15 +++++++- api/tests/unit/test_export_writers.py | 20 ++++++++++ .../design.md | 2 +- .../RealtimeTable/utils/rowUtils.js | 22 +++++++++-- .../RealtimeTable/utils/rowUtils.test.js | 37 +++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/api/src/services/export_service/export_writers.py b/api/src/services/export_service/export_writers.py index fbd528a..d39a7c0 100644 --- a/api/src/services/export_service/export_writers.py +++ b/api/src/services/export_service/export_writers.py @@ -22,6 +22,19 @@ RowFormatResolver = Callable[[tuple, str, int], dict[str, Any] | None] ColumnFormatResolver = Callable[[str, int], dict[str, Any] | None] +def _is_standalone_project(row: tuple) -> bool: + """AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 — + ITEM с header.project_id и без header.program_id. Он ровня программе (GROUP).""" + if not row or row[0] != "ITEM" or len(row) <= 3 or not isinstance(row[3], dict): + return False + header = row[3].get("header") + return ( + isinstance(header, dict) + and header.get("project_id") is not None + and header.get("program_id") is None + ) + + def _hierarchical_rows(rows: list[tuple]): """Use the same row-type hierarchy as the realtime table, skipping META.""" levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4} @@ -30,7 +43,7 @@ def _hierarchical_rows(rows: list[tuple]): item_type = row[0] if row else None if item_type == "META": continue - level = levels.get(item_type, 999) + level = levels["GROUP"] if _is_standalone_project(row) else levels.get(item_type, 999) while stack and stack[-1] >= level: stack.pop() yield row, len(stack) diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index 8d23f69..f5739d5 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -239,3 +239,23 @@ def test_standalone_project_outline_without_group(): assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [ ("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1), ] + + +def test_standalone_project_after_program_is_sibling_of_program(): + """AURORA-1984 I-1: самостоятельный проект после программы — уровень программы.""" + + def node(kind, **header): + return (kind, 0, 0, {"header": header, "line_id": None}) + + rows = [ + node("ROOT"), + node("GROUP", program_id=1), + node("ITEM", project_id=10, program_id=1), + ("INPUT", 4, 0, {"header": {"project_id": 10, "program_id": 1}, "line_id": 5}), + node("ITEM", project_id=11, program_id=None), + ("INPUT", 4, 0, {"header": {"project_id": 11}, "line_id": 6}), + node("GROUP", program_id=2), + node("ITEM", project_id=12), + ("INPUT", 4, 0, {"header": {"project_id": 12}, "line_id": 7}), + ] + assert [level for _, level in _hierarchical_rows(rows)] == [0, 1, 2, 3, 1, 2, 1, 1, 2] diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index ff0fb4e..9a6628c 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -73,7 +73,7 @@ ROOT выводится только при наличии расходов в - Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем. ### D7a. Вид в выгрузке и на экране -Самостоятельный 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. +Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это НЕ получается само: оба построителя вкладывают строки по `row_type` (ITEM глубже GROUP), и самостоятельный проект после программы оказался бы её потомком (свёртка программы прячет его, в Excel уровни 2/3). Поэтому построители распознают самостоятельный проект явно (ITEM, `header.project_id != null`, `header.program_id == null`) и ставят его на уровень GROUP: `isStandaloneProject` в `rowUtils.js` (`buildRowHierarchy`) и `_is_standalone_project` в `export_writers.py` (`_hierarchical_rows`) (находка I-1 / B1 живой проверки). Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. ### D8. AURORA-1980: переименование - Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла). diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index 3ccfdf4..3f6c47e 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -6,24 +6,38 @@ const ROW_TYPE_LEVELS = { INPUT: 4, }; +// AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 — ITEM с +// header.project_id и без header.program_id. По row_type он глубже GROUP, но по +// смыслу — ровня программе (прямой потомок ROOT), где бы он ни стоял в порядке листа. +// Строки других форм/листов project_id в header не имеют, их это не затрагивает. +export function isStandaloneProject(item) { + const header = item?.data?.header; + return item?.row_type === 'ITEM' && header?.project_id != null && header?.program_id == null; +} + +function getHierarchyLevel(item) { + if (isStandaloneProject(item)) return ROW_TYPE_LEVELS.GROUP; + return ROW_TYPE_LEVELS[item.row_type] ?? 999; +} + export function buildRowHierarchy(items, mapItem = (item) => item) { const result = []; const stack = []; for (const [index, item] of items.entries()) { - const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999; + const currentLevel = getHierarchyLevel(item); - while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) { + while (stack.length > 0 && stack.at(-1).level >= currentLevel) { stack.pop(); } - const parent = stack.at(-1); + const parent = stack.at(-1)?.node; const newNode = { ...mapItem(item, index), subRows: [] }; if (parent) parent.subRows.push(newNode); else result.push(newNode); - if (item.row_type !== 'INPUT') stack.push(newNode); + if (item.row_type !== 'INPUT') stack.push({ node: newNode, level: currentLevel }); } return result; diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 0cec593..b817690 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -9,6 +9,7 @@ import { getProjectAddTarget, getRenameTarget, isRenameResponse, + isStandaloneProject, } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { @@ -134,3 +135,39 @@ test('AURORA-1979: getForm4TypeLabel', () => { assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null); assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null); }); + +const node = (row_type, header = {}, extra = {}) => ({ row_type, data: { header, ...extra } }); +const names = (rows) => rows.map((r) => [r.data.header.name, names(r.subRows)]); +const rows = [ + node('ROOT', { name: 'root' }), + node('GROUP', { name: 'prog1', program_id: 1 }), + node('ITEM', { name: 'in-prog', project_id: 10, program_id: 1 }), + node('INPUT', { name: 'e1', project_id: 10, program_id: 1 }, { line_id: 1 }), + node('ITEM', { name: 'solo', project_id: 11 }), + node('INPUT', { name: 'e2', project_id: 11 }, { line_id: 2 }), + node('GROUP', { name: 'prog2', program_id: 2 }), + node('ITEM', { name: 'solo2', project_id: 12, program_id: null }), + node('INPUT', { name: 'e3', project_id: 12 }, { line_id: 3 }), +]; + +test('I-1: standalone ITEM после программы — потомок ROOT, расходы — его дети', () => { + const tree = buildRowHierarchy(rows); + assert.deepEqual(names(tree), [ + [ + 'root', + [ + ['prog1', [['in-prog', [['e1', []]]]]], + ['solo', [['e2', []]]], + ['prog2', []], + ['solo2', [['e3', []]]], + ], + ], + ]); +}); + +test('I-1: isStandaloneProject различает узлы', () => { + assert.equal(isStandaloneProject(rows[4]), true); + assert.equal(isStandaloneProject(rows[2]), false); + assert.equal(isStandaloneProject(rows[1]), false); + assert.equal(isStandaloneProject(rows[5]), false); +}); -- 2.47.2 From 77db9cb02f9f02e73ee5dde629a45cb24eccc9af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 23:06:28 +0400 Subject: [PATCH 12/13] =?UTF-8?q?AURORA-1984:=20=D1=80=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B8=20R-8=20=D0=B8=20R-9=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1984-pd-standalone-projects/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index 9a6628c..fc3f607 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -132,3 +132,5 @@ ROOT выводится только при наличии расходов в | 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 может переименовать или удалить узел при закрытом окне ввода | | 2026-11-08 | R-7 (design #19): путь «проект в программу» в `subscribeToProjectAdds` теперь тоже идёт через `alignNodeRows` + `updateCells` (как требует шаг 3 плана, задача 4), а не через `updateCells` с серверными номерами. Находка T4.1 ревью задачи 4 | сопоставление по id устойчивее номеров, ROOT добавляется общим кодом; для программы, уже присутствующей локально, результат тот же или точнее | строки ответа с ключом, которого нет локально (например GROUP чужой только что созданной программы), отбрасываются до события `program_added`/перезагрузки | +| 2026-11-08 | R-8 (design #20): самостоятельный проект узнаётся явно (`ITEM` с `project_id != null` и без `program_id`) и в дереве на экране, и в выгрузке получает уровень программы (брат GROUP под ROOT); данные (`depth = 2`) не меняются. Находка I-1 живой проверки (D7a ошибочно считал, что это получается само) | проект, созданный после программы, иначе вкладывался в предыдущую программу: сворачивание программы скрывало его, в Excel уровни 2/3 вместо 1/2 | если у ITEM другой формы появится `project_id` без `program_id`, он станет братом верхнего уровня; для SMETA и форм 1/2 `project_id` в header нет | +| 2026-11-08 | R-9 (design #21): находка B2 живой проверки (после добавления расхода у строк ниже вставки временно показывается имя предыдущей строки до F5) не чинится в этой ветке: воспроизводится на программах без самостоятельных проектов, код `useRealtimeData.js:118-119` / `findRowPath` на базе не менялся | не относится к 1984; правка затрагивает общий путь `row_added` всех форм | пользователь видит неверные названия до перезагрузки; вынесено в follow-up (deferred) | -- 2.47.2 From e5660add7aa6e5b4ecd09882bf165ea36a42d9eb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 23:16:20 +0400 Subject: [PATCH 13/13] =?UTF-8?q?AURORA-1984:=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(M-?= =?UTF-8?q?5,=20M-6,=20M-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0059_form4_standalone_project.sql | 3 ++- api/tests/unit/test_export_normalizers.py | 4 +++- api/tests/unit/test_export_writers.py | 13 ++++--------- .../aurora-1984-pd-standalone-projects/design.md | 7 ++++--- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/api/alembic/versions/sql/0059_form4_standalone_project.sql b/api/alembic/versions/sql/0059_form4_standalone_project.sql index 722e85f..57cc37e 100644 --- a/api/alembic/versions/sql/0059_form4_standalone_project.sql +++ b/api/alembic/versions/sql/0059_form4_standalone_project.sql @@ -1,5 +1,6 @@ -- AURORA-1984: проект без программы (самостоятельный проект формы 4). --- Часть 1: ограничения модели и лист v3.v_form4_sheet_jsonb. +-- Весь файл: ограничения модели, v3.add_form4_project / upd_form4_project / del_form4_project +-- и лист v3.v_form4_sheet_jsonb. -- Самостоятельный проект: level='project', parent_id IS NULL, section_code и form_id заданы. -- ═══ 1. Ограничения ══════════════════════════════════════════════════════════ diff --git a/api/tests/unit/test_export_normalizers.py b/api/tests/unit/test_export_normalizers.py index 819a053..d16bf49 100644 --- a/api/tests/unit/test_export_normalizers.py +++ b/api/tests/unit/test_export_normalizers.py @@ -1,3 +1,4 @@ +import copy import pytest from src.services.export_service.export_normalizers import ( @@ -91,6 +92,7 @@ def test_form3_depth_normalizer_flattens_nested_data(): ({"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): + original = copy.deepcopy(data) normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data) assert normalized["header.section_code"] == expected - assert data["header"]["section_code"] != "программа" + assert data == original diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index f5739d5..9590d6e 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -234,21 +234,16 @@ def test_export_service_uses_sheet_writer_strategy(): assert smeta_writer.calls == 1 -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), - ] - - def test_standalone_project_after_program_is_sibling_of_program(): - """AURORA-1984 I-1: самостоятельный проект после программы — уровень программы.""" + """AURORA-1984 I-1: самостоятельный проект (ITEM без program_id) — уровень программы, без GROUP и после программы.""" def node(kind, **header): return (kind, 0, 0, {"header": header, "line_id": None}) rows = [ node("ROOT"), + node("ITEM", project_id=9), + ("INPUT", 4, 0, {"header": {"project_id": 9}, "line_id": 4}), node("GROUP", program_id=1), node("ITEM", project_id=10, program_id=1), ("INPUT", 4, 0, {"header": {"project_id": 10, "program_id": 1}, "line_id": 5}), @@ -258,4 +253,4 @@ def test_standalone_project_after_program_is_sibling_of_program(): node("ITEM", project_id=12), ("INPUT", 4, 0, {"header": {"project_id": 12}, "line_id": 7}), ] - assert [level for _, level in _hierarchical_rows(rows)] == [0, 1, 2, 3, 1, 2, 1, 1, 2] + assert [level for _, level in _hierarchical_rows(rows)] == [0, 1, 2, 1, 2, 3, 1, 2, 1, 1, 2] diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index fc3f607..b6d204b 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -38,7 +38,7 @@ `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 раздела (если он есть в листе). +Новая сигнатура: `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` с `section_code` (ключ `parent_id` в событии отсутствует: `log_event` применяет `jsonb_strip_nulls`). Возвращает строки так же, как вариант с программой: новый ITEM + ROOT раздела (если он есть в листе). ### D3. Лист: ITEM под ROOT, порядок по id верхнего узла В `v3.v_form4_sheet_jsonb`: @@ -104,6 +104,7 @@ ROOT выводится только при наличии расходов в - Ветка поверх #304: если #304 изменится по ревью, нужен rebase. - Excel: самостоятельный ITEM на уровне группировки 1 (вместо 2) — проверить вид, не ломать раскладку колонок по `depth`. - `rowUtils.getParentId` для ROOT и GROUP возвращает `expense_item_id`, и `insertCell` сравнивает его с `project_id`: при совпадении чисел новый расход встанет под ROOT до перезагрузки. Поведение существовало и для проектов в программах; в этой ветке не меняется (O-1). +- Исторический расход без проекта (`project_id` NULL, sort 999999), оказавшийся сразу после самостоятельного проекта, построители дерева (фронт `buildRowHierarchy` и выгрузка) прикрепят к нему — тот же класс дефекта, что уже есть у последнего проекта программы. Follow-up, если в реальных данных есть такие строки (M-1 итогового ревью). - GET срезает null (AURORA-1848), поэтому `header.program_id` приходит то `null`, то `undefined`: сравнивать только через `!= null`. ## Оставлено исполнителю @@ -117,13 +118,13 @@ ROOT выводится только при наличии расходов в | 2026-11-08 | Ветка от `feat/303-aurora-1978-…` (PR #304), а не от `test` | удаление программ/проектов и `alignNodeRows` уже там; 1984 их использует | rebase при изменениях #304 по ревью | | 2026-11-08 | «Добавить проект»: программа → в ней; раздел/ничего → в разделе; проект/расход → рядом (та же программа или раздел) | естественное продолжение текущей кнопки, без новой кнопки | при другом ожидании — правка выбора цели (одна функция) | | 2026-11-08 | Одна функция `add_form4_project` с DEFAULT-параметрами вместо новой | старые вызовы работают, одна точка прав и аудита | DROP/CREATE функции в миграции | -| 2026-11-08 | Самостоятельный ITEM с `depth = 2`, без новой глубины | фронт и выгрузка строят дерево по `row_type`, вид как у проекта | отступ в Excel/на экране может потребовать правки | +| 2026-11-08 | Самостоятельный ITEM с `depth = 2`, без новой глубины | раскраска и вид — по `row_type`, как у проекта; уровень в дереве задаёт явный признак самостоятельного проекта (`isStandaloneProject` / `_is_standalone_project`, R-8, design #20), `depth = 2` остаётся в данных для `findRowPath` | отступ в Excel/на экране может потребовать правки | | 2026-11-08 | Подпись «программа»/«проект» (1979) и в выгрузке Excel | выгрузка повторяет экран (как 1902/1903) | лишняя правка export_service, если заказчику нужен код | | 2026-11-08 | Сортировка и подпись статей (1989) на клиенте | список короткий, сервер не трогаем | при большом списке — перенос на сервер | | 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 и отступ самостоятельного проекта — уровень программы (1), на экране и в Excel | получается без правки писателя и `Cell.jsx`, «нет уровня программы» | если заказчик ждёт отступ как у проекта — правка отображения | +| 2026-11-08 | Outline и отступ самостоятельного проекта — уровень программы (1), на экране и в Excel | уровень 1 задаёт явный признак самостоятельного проекта в построителях дерева (фронт `buildRowHierarchy`) и выгрузки (`_hierarchical_rows`), см. R-8 (design #20); писатель и `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` общий для всех форм, его правка задела бы их выгрузки | лишний класс из нескольких строк | -- 2.47.2