30 KiB
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. 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 раздела (если он есть в листе).
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 из одной последовательности → порядок создания. Все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 (последней), меняются только перечисленные блоки.
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); отступ названия и уровень группировки (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), активна при выделенной программе или проекте. Окно — отдельный компонент
RenameNodeModalс начальным значением и заголовком «Переименование программы/проекта»:CreateProgramModalзакрывается сразу после отправки и не принимает начальное значение. Состояние окна — вRealtimeTableрядом сpendingDelete(редьюсерSheetModalsне хранит данных узла). - Отправка
program_updated {program_id, name}/project_updated {project_id, name}с обрезанным названием; пустое после обрезки — кнопка сохранения неактивна. - Окно закрывается по успешному ответу на своё событие; при
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-тест) — в 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 — 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; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — результат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 без лишней строки, подпись «проект».
- Живая проверка на стенде (perf-pg, форма 4): все сценарии спеки, две вкладки для рассылки.
Risks / Trade-offs
- Перенумерация
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, окно переименования и размещение подписи зафиксированы при перепроверке 08.11.
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 и отступ самостоятельного проекта — уровень программы (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 может переименовать или удалить узел при закрытом окне ввода |