128 lines
26 KiB
Markdown
128 lines
26 KiB
Markdown
# 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 |
|