# 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 |