Andrey Epifancev f0470313c1 openspec: архив aurora-1839-cell-memo и aurora-1984-pd-standalone-projects
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:33:14 +04:00

34 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. 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 с section_code (ключ parent_id в событии отсутствует: log_event применяет jsonb_strip_nulls). Возвращает строки так же, как вариант с программой: новый 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) они считаются по фактической вложенности, это НЕ получается само: оба построителя вкладывают строки по 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 не хранит данных узла).
  • Отправка 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).
  • Исторический расход без проекта (project_id NULL, sort 999999), оказавшийся сразу после самостоятельного проекта, построители дерева (фронт buildRowHierarchy и выгрузка) прикрепят к нему — тот же класс дефекта, что уже есть у последнего проекта программы. Follow-up, если в реальных данных есть такие строки (M-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, как у проекта; уровень в дереве задаёт явный признак самостоятельного проекта (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 уровень 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 общий для всех форм, его правка задела бы их выгрузки лишний класс из нескольких строк
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 может переименовать или удалить узел при закрытом окне ввода
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)