Andrey Epifancev e500c85e81 AURORA-1984: OpenSpec change — проекты ПД без программы (+1979, 1980, 1989)
Refs #305

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:28:34 +04:00

26 KiB
Raw Blame History

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