# Design — AURORA-1896: формулы ячеек на сервере ## Context Исходный дизайн — описание Jira AURORA-1896 (28.10.2026). Ниже он сверен с кодом `test` 5a91f35 (11.11.2026; миграции до 0058, слиты AURORA-1844/1848/1850/1895/1899). Отличия от Jira перечислены явно (D2, D4, D5, D7) и продублированы в разделе «Decisions made without discussion». Факты текущего кода, на которые опирается дизайн: - **Путь записи ячейки формы.** WS `cell_updated` → `websocket.py` `FormEventProcess.__update_cell` (~:510) → `sheet_service.update_cell` (:73) → `sheet_repository.update_cell` (:87-133) → `v3.upd_form_cell(form, sheet, line_id, column, value jsonb, user_id, direction, sections)` (0002:2478, обёртка над `upd_form_cells`). Массовая запись — `sheet_repository.update_cells` (:135-186) → `v3.upd_form_cells(form, sheet, changes, user_id, direction, sections)` (0031:904-987, проверка прав `can_edit`, затем внутренняя версия 0051:342-415, читает из элемента только `line_id`, `column`, `value`). Возврат — затронутое поддерево (AURORA-1850). REST — `forms.py` `PATCH …/cell` (:202), `…/cells` (:267), схемы `CellPatch{line_id, column, value}`, `CellsPatch` (`schemas.py:397-404`). - **Отчёты проектов (FORM_3).** WS `ProjectEventProcess.__update_cell` (~:739) → `project_service.upd_form3_cell` (:285) → `project_repository.upd_form3_cell` (:559) → `v3.upd_form3_cell(report_id, line_id, column, value)` — **без пользователя**; массовая — `v3.upd_form3_cells(report_id, changes)`. Версия с `can_edit3` (0033:3059) из Python не вызывается: серверной проверки окон для FORM_3 нет, только org-фильтр в `resolve_report_id`. REST — `projects.py:332-370`, те же `CellPatch`/`CellsPatch`. - **`line_id` по листам.** AHR/CAP/OPER/SMETA (FORM_1/2/4) — `budget_line.id`; AHR_RENT/UTILITY/SECURITY — `rent_detail.id`/`utility_detail.id`/`security_detail.id`; AHR_LIMIT — `limit_template.id` (общий для всех форм, значения в `form_limit`); OTCH9F — `fixed_asset_report.id`; FORM_3 — `rf_project_report_line.id`. Между листами id пересекаются; внутри (владелец, лист) уникальны. `direction` для идентификации строки не нужен. - **Рассылка.** `broadcast_to_all` пересылает входящий `data` целиком (с любыми лишними ключами) плюс `result` и `is_self`. Фоновое `notify_ahr_about_satellite_change` шлёт на AHR отдельный `cell_updated` с `data:{source_sheet}` — без `line_id`/`column`. - **Удаление строк** идёт во многих местах: `del_budget_line` (0056:220-345, основная ветка и ветка сателлитов), неявное `DELETE budget_line` в `sync_form2_ahr_satellite_line` (0052), `hard_delete_budget_form` (0039, ручной список таблиц, затем `budget_form`), `del_form3_line` (0006:1483), `del_project` (0016:657). Триггеров в схеме `v3` нет. - **Единицы.** Денежные значения хранятся и передаются в базовой единице колонки (тыс. руб., на AHR_LIMIT — руб.). Разрядность применяется только при отображении; при разрядности, отличной от базовой, денежная ячейка не редактируется (`tableColumns.jsx:167-170`). Значит, выражение всегда вводится в той же единице, что и сохраняемое `value`. - **Фронт.** Формула — текст, начинающийся с «=» (`formulaUtils.isFormula`), вычисление — `calculateFormula` (`new Function` после проверки символов `^[\d+\-*/%().]+$`, запятая → точка, `%` → `/100`, округление до 10 знаков). `EditCell.handleSave` (:192-239) сохраняет формулу в localStorage (`formula_v2_${tableId}_${cellId}`) и отправляет только число. После AURORA-1895 (`resolveEditorState`) формула подставляется в редактор, только если её результат совпадает с текущим значением (1e-9). Входящие `cell_updated` применяет `RealtimeContext.handleMessage` (:118-181) → `useRealtimeData` → `cellUtils.updateCells`. EditCell общий для форм и отчётов проектов (`NewFormTablePage`, `isProject`). `web/src/api/ws.js` и `web/src/pages/TablePage.jsx` — мёртвый код. - **Ошибки.** Префиксы `VALIDATION_PREFIXES` (`CONSTANTS.py:1-11`) → 422 с `field`; WS-ошибка уходит только отправителю. ## Goals / Non-Goals **Goals** - Формула, введённая одним пользователем, видна всем с доступом к листу — сразу (WS) и после перезагрузки (GET), с автором и временем; то же для отчётов проектов. - Маркер `fx` в обычной ячейке; редактор открывается с серверной формулой. - Формула всегда согласована с реально записанным значением: пишется в той же транзакции, после тех же проверок; ручной ввод её снимает; устаревшая (значение изменилось иначе) не показывается. - localStorage для формул не используется. **Non-Goals** - История значений и формул ячейки (только последняя формула) — ответ по умолчанию на вопрос к заказчику. - Формулы в Excel-выгрузке — ответ по умолчанию на вопрос к заказчику. - Серверное вычисление выражения; ссылки на другие ячейки. - Введение серверной проверки окон (`can_edit3`) для FORM_3 — существующее поведение, вне объёма (см. Risks). - Изменение ответа на правку (`result`) и GET листа. **Anti-goals** - Замедлить правку ячейки заметно сверх критерия AURORA-1850 (≤ 50 мс на сервере): запись формулы — один небольшой запрос по индексу. - Раздуть ответ GET листа формулами (AURORA-1848 боролась с размером ответа) — формулы грузятся отдельным запросом. - Рассылать клиентам непроверенный текст из чужого сообщения. ## Decisions ### D1. Таблица `v3.cell_formula` ```sql CREATE TABLE v3.cell_formula ( id bigserial PRIMARY KEY, budget_form_id integer REFERENCES v3.budget_form(id) ON DELETE CASCADE, rf_project_report_id integer REFERENCES v3.rf_project_report(id) ON DELETE CASCADE, sheet varchar NOT NULL, -- лист формы; для отчёта проекта — report_type (LIMIT / CURRENT_EXPENSES) line_id integer NOT NULL, -- как в cell_updated; таблица зависит от листа (см. Context) column_key text NOT NULL, -- как в cell_updated: 'plan.q1' expression text NOT NULL, -- нормализованное, без '=' value numeric NOT NULL, -- значение, записанное вместе с формулой updated_by integer NOT NULL REFERENCES v3.app_user(id), updated_at timestamptz NOT NULL DEFAULT now(), CHECK ((budget_form_id IS NULL) <> (rf_project_report_id IS NULL)) ); CREATE UNIQUE INDEX cell_formula_form_uidx ON v3.cell_formula (budget_form_id, sheet, line_id, column_key) WHERE budget_form_id IS NOT NULL; CREATE UNIQUE INDEX cell_formula_report_uidx ON v3.cell_formula (rf_project_report_id, sheet, line_id, column_key) WHERE rf_project_report_id IS NOT NULL; CREATE INDEX cell_formula_line_idx ON v3.cell_formula (sheet, line_id); ``` **Отличие от Jira:** вместо `scope` + `owner_id` без FK — два nullable FK с `ON DELETE CASCADE` и CHECK «ровно один». Так формулы удаляются при `hard_delete_budget_form` (удаляет `budget_form` последним) и `del_project` (удаляет `rf_project_report`) без правки этих функций. Значение `report_type` в `sheet` хранится в нормализованном виде (как после `resolve_report_id`). ### D2. Запись формулы — отдельная SQL-функция, вызываемая репозиторием в той же транзакции `v3.set_cell_formulas(p_budget_form_id integer, p_report_id integer, p_sheet varchar, p_changes jsonb, p_user_id integer) RETURNS TABLE(line_id integer, column_key text, expression text, value numeric, updated_by integer, updated_by_name text, updated_at timestamptz)`: - для каждого элемента `p_changes` (`line_id`, `column`, `value`, необязательный `formula`): - нормализует `formula` (`btrim`, снять один ведущий `=`, `btrim`); пустая строка = нет формулы; - формула есть: проверяет длину 1..500 и `~ '^[0-9+\-*/%(),. ]+$'`, `value` приводится к numeric (не приводится или NULL → `RAISE EXCEPTION 'invalid_formula: …'`); UPSERT по уникальному индексу владельца (`updated_by = p_user_id`, `updated_at = now()`); - формулы нет: `DELETE` формулы этой ячейки; - возвращает итоговые формулы затронутых ячеек (с `full_name` автора) — для рассылки. Репозитории вызывают её **сразу после** записи значения в том же сеансе (`sheet_repository.update_cell/update_cells` — с `budget_form_id`; `project_repository.upd_form3_cell/upd_form3_cells` — с `report_id`). Если запись значения бросила исключение, до формулы дело не доходит; если формула невалидна, исключение откатывает всю транзакцию, включая значение (WS: `get_db_session` + `commit` после `process`; REST: сессия запроса). `p_user_id` — `user.id` из сервиса. **Отличие от Jira:** Jira предлагала пересоздать обёртку `v3.upd_form_cells` (0031, ~80 строк) и `upd_form3_cells` и писать формулу внутри них. Отказ, потому что: (1) для FORM_3 Python вызывает версии без пользователя — пришлось бы либо переключать путь на `can_edit3` (изменение прав, вне объёма), либо дублировать запись формулы в низкоуровневую функцию; (2) пересоздание больших обёрток конфликтует с параллельными ветками, правящими эти функции; (3) атомарность обеспечивает общая транзакция. Требование Jira «формула пишется только после прохождения прав» выполняется порядком вызовов. `sheet_repository.update_cell` продолжает вызывать `v3.upd_form_cell` (сигнатура и ответ без изменений) и затем `set_cell_formulas` с одним элементом. ### D3. Проверка выражения — в SQL, префикс `invalid_formula:` Префикс добавляется в `VALIDATION_PREFIXES` → 422, `field` — текст после префикса. Набор символов совпадает с фронтовым `calculateFormula` плюс пробел и запятая (фронт заменяет запятую на точку). Сервер выражение не вычисляет и не сверяет с `value` (как в Jira): `value` формулы — это значение, реально записанное в ячейку в этом же запросе. ### D4. Очистка — FK с каскадом (D1) и триггеры на таблицах строк `AFTER DELETE … FOR EACH ROW` триггеры, удаляющие формулы удалённой строки: | Таблица | Условие удаления формул | |---|---| | `v3.budget_line` | `budget_form_id = OLD.budget_form_id AND sheet IN ('AHR','CAP','OPER','SMETA') AND line_id = OLD.id` | | `v3.rent_detail` | `sheet = 'AHR_RENT' AND line_id = OLD.id` | | `v3.utility_detail` | `sheet = 'AHR_UTILITY' AND line_id = OLD.id` | | `v3.security_detail` | `sheet = 'AHR_SECURITY' AND line_id = OLD.id` | | `v3.fixed_asset_report` | `budget_form_id = OLD.budget_form_id AND sheet = 'OTCH9F' AND line_id = OLD.id` | | `v3.rf_project_report_line` | `rf_project_report_id = OLD.rf_project_report_id AND line_id = OLD.id` | AHR_LIMIT (`limit_template.id`) построчно не удаляется — его формулы уходят каскадом вместе с формой. **Отличие от Jira:** Jira предлагала явные `DELETE` в `del_budget_line` и при удалении строки отчёта. Триггеры покрывают все пути удаления (включая неявное удаление в `sync_form2_ahr_satellite_line`, каскады внутри `del_budget_line`, `hard_delete_budget_form`, `del_project`, `del_form3_line`) и не требуют пересоздавать эти функции. Цена: первые триггеры в схеме — поведение, не видное из тел функций; описывается комментарием в миграции и в ARCHITECTURE.md. Нагрузка — один `DELETE` по индексу `cell_formula_line_idx` на удаляемую строку. ### D5. Чтение — `v3.v_cell_formulas(p_budget_form_id, p_report_id, p_sheet)` и два GET - `GET /api/v1/form/{form_id}/sheet/{sheet}/formulas` — доступ через `BudgetFormService.get` (404, если формы не видно), лист проверяется по `form_type.sheet_list`, как в PATCH ячейки. **Без `direction`:** `line_id` уникален внутри листа, возвращаются все формулы листа, клиент сопоставляет по `line_id` (лишние записи другого направления на клиенте просто не находят ячейку). Отличие от Jira (`?direction=` в пути) — упрощение; для FORM_1 AHR/CAP это до двух направлений на лист. - `GET /api/v1/projects/{project_id}/report/{year}/{report_type}/formulas` — `report_id` через `resolve_report_id` (org-фильтр, как у записи). - Ответ: `BaseListResponse` из записей `{line_id, column, expression, value, updated_by: {id, full_name}, updated_at}`. Новая схема `UserShort {id, full_name}` (готовой краткой схемы нет). ### D6. WebSocket - Входящее `cell_updated`: необязательное `data.formula` (строка без «=»). Отсутствует или `null` — формулы нет. - `__update_cell` (форма и проект) передаёт `formula` в сервис; сервис возвращает строки ответа и итог `set_cell_formulas`. - Перед рассылкой `__update_cell` **перезаписывает** `event_data["formula"]` нормализованным выражением из БД или `None` и ставит `event_data["formula_meta"] = {updated_by: {id, full_name}, updated_at}` (или `None`). Так клиенты получают только то, что сохранил сервер. - REST `PATCH …/cell(s)`: в `CellPatch` добавляется `formula: str | None = None`. Ответ REST не меняется (формулы — через GET); REST не рассылает по WS, как и сейчас. ### D7. Фронт - **Чистые утилиты** (`web/src/components/RealtimeTable/utils/cellFormulaUtils.js`, тестируемые `node --test`): `formulaKey(lineId, column)`, `isFormulaActual(formula, value)` (оба числа, `|a-b| ≤ 1e-9`), `applyFormulaEvent(map, data)` — `data.line_id` и `data.column` есть: `data.formula` строка → записать `{expression, value: data.value, updated_by, updated_at}`, иначе → удалить; без `line_id`/`column` (например, фоновое `cell_updated` с `source_sheet`) — карта без изменений; `formatFormulaHint(formula)` — «=100+50 · ФИО · дд.мм.гггг чч:мм». - **API-клиент**: `getSheetFormulas(formId, sheet)` и `getProjectReportFormulas(projectId, year, reportType)`. - **RealtimeContext**: карта формул (`Map`) загружается вместе с листом (при открытии и переподключении) и обновляется в `handleMessage` на `cell_updated` (и свои, и чужие) через `applyFormulaEvent`. Отдаётся через контекст. - **Cell**: если формула актуальна (`isFormulaActual` с текущим значением ячейки) — маркер `fx` в углу (не перекрывает индикацию редактируемости AURORA-1890) и MUI Tooltip с `formatFormulaHint`. Если отображение в иной разрядности, чем базовая (`displayFactor ≠ 1`), к подсказке добавляется «(в тыс. руб.)»/«(в руб.)» — выражение в базовых единицах. - **EditCell**: начальный текст — `=` + выражение из карты при актуальной формуле (иначе значение); при открытии набором символа (AURORA-1891) формула не подставляется, как сейчас. При сохранении формулы в `cell_updated` уходит `data.formula` (выражение без «=»), при обычном вводе — без поля. Все обращения к localStorage для формул удаляются; ключи `formula_*` (включая `formula_v2_*`) удаляются один раз при загрузке модуля (флаг в sessionStorage не нужен — удаление идемпотентно и дёшево; оборачивается в try/catch). - **Ввод выражения без «=»** (`100+50`): не отправлять, показать подсказку «Формула начинается с =» (текущее поведение `normalizeNumericInput` проверить и при необходимости дополнить). ### D8. Отчёты проектов Тот же механизм: `report_type` нормализуется `resolve_report_id`, в `cell_formula.sheet` пишется нормализованный тип, владелец — `rf_project_report_id`. ### D9. Номер миграции Миграция берёт следующий свободный номер на момент мержа. На момент дизайна в test последняя — 0058, но открытый PR #315 (AURORA-1879) добавляет `0059_form_phase_direction_fix`. Ветка 1896 отводится от test и создаёт `0059_cell_formula`; если #315 будет слит раньше, перед PR миграция переименовывается в `0060_cell_formula` с `down_revision = "0059"` (шаг плана). ## Risks / Trade-offs - **Нет `can_edit3` для FORM_3.** Формулу в отчёт проекта может записать тот же, кто может записать значение (org-фильтр). Не ухудшение — формула наследует права значения. Отдельная находка для follow-up. - **Доверие к клиенту.** Сервер не сверяет выражение с `value` — злонамеренный клиент может сохранить `1+1` со значением 5; подсказка покажет несоответствие. Принято (как в Jira): права и так у того, кто пишет значение. - **Устаревание.** Значение может измениться без ввода (синхронизация сателлитов, будущие пересчёты); формула остаётся в БД, но не показывается (D7). Мусор не накапливается сверх одной записи на ячейку. - **Триггеры** — новая практика в проекте (D4). - **Массовая вставка `…/cells`** без формул снимает формулы всех затронутых ячеек — ожидаемо (значение ввели не формулой). - **Конфликт номера миграции** с #315 (D9). ## Тестирование - **Интеграционные** (`api/tests/integration/test_cell_formula_integration.py`, `admin_tokens`, форма FORM_2 через `POST /api/v1/form/` или существующая из фикстуры — см. исполнителю): - PATCH ячейки с `formula: '100+50'`, `value: 150` → GET formulas: запись с `expression '100+50'`, `value 150`, `updated_by.id` админа; - повторный PATCH без формулы (`value 200`) → формулы нет; - PATCH с `formula: 'drop table'` → 422 `invalid_formula`, значение не изменилось; - нормализация ` =100+50 ` → `100+50`; - исполнитель без права на колонку → 403, формулы нет; - DELETE строки → формул строки нет (триггер); удаление сателлитной записи → формул нет; - `PATCH …/cells` с двумя элементами, один с формулой, второй без → одна формула; - отчёт проекта: PATCH с формулой → GET formulas проекта; - WS: `cell_updated` с `data.formula` → рассылка содержит нормализованную `formula` и `formula_meta`; без формулы → `formula: null` (по образцу `test_websocket_connection_cache_integration.py`); - каскад: окончательное удаление формы (`hard_delete_budget_form`) с формулами не падает. - **Unit**: репозиторий вызывает `set_cell_formulas` с нужными аргументами после записи значения (мок сессии); `invalid_formula:` → 422 в `map_sqlalchemy_error`. - **Фронт** (`node --test`): `isFormulaActual`, `applyFormulaEvent` (формула → записать, `null` → удалить, без поля → удалить, без `line_id`/`column` → без изменений), `formatFormulaHint`, `formulaKey`. - Весь `pytest --init_db` — те же падения, что на чистой test (10 известных на 5a91f35), новых нет. ## Оставлено исполнителю - Точное место маркера `fx` и стиль (не конфликтовать с индикацией AURORA-1890). - Как именно `RealtimeContext` перезагружает формулы при переподключении WS. - Форма FORM_2 для интеграционных тестов: создать через POST или взять из фикстуры. - Имя и расположение утилит фронта, если в проекте есть более подходящее место. ## Decisions made without discussion Человек на этапе дизайна сказал «делай» (пропустить вопросы); все решения ниже приняты агентом. | # | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-11-11 | История — только последняя формула; формулы не выгружаются в Excel | Ответы по умолчанию из «Вопросов к заказчику» в Jira | Если нужна история — новая таблица журнала и UI; текущая схема не мешает | | 2 | 2026-11-11 | Владелец формулы — два FK с каскадом вместо `scope` + `owner_id` (D1) | Автоматическая очистка при удалении формы и отчёта | Низкая: миграция таблицы | | 3 | 2026-11-11 | Формула пишется отдельной функцией `set_cell_formulas` из репозитория в той же транзакции, а не внутри пересозданных `upd_form_cells`/`upd_form3_cells` (D2) | FORM_3 пишет без пользователя; меньше конфликтов с параллельными ветками; атомарность сохраняется | Средняя: если появится SQL-вызов записи ячейки в обход репозиториев, он не будет снимать формулы — потребуется перенос логики в SQL | | 4 | 2026-11-11 | Очистка формул — триггерами на таблицах строк (D4), а не явными `DELETE` в функциях удаления | Покрывает все пути удаления, включая неявные | Средняя: первые триггеры в схеме; скрытое поведение | | 5 | 2026-11-11 | GET формул листа формы без `direction` (D5) | `line_id` уникален внутри листа; проще API | Низкая: лишние записи другого направления в ответе | | 6 | 2026-11-11 | REST `PATCH …/cell(s)` принимает `formula`, но ответ не меняется; рассылка формулы — только по WS (D6) | REST и сейчас не рассылает; формулы читаются GET | Низкая | | 7 | 2026-11-11 | В подсказке маркера при разрядности ≠ базовой добавляется единица выражения (D7) | Выражение хранится в базовых единицах, а ячейка показана в другой разрядности | Низкая: текст подсказки | | 8 | 2026-11-11 | Серверная проверка окон для FORM_3 (`can_edit3`) не вводится | Изменение прав вне объёма задачи; формула наследует права значения | Средняя: существующая дыра остаётся — в follow-up | | 9 | 2026-11-11 | Номер миграции — 0059 с переименованием в 0060 при слиянии #315 раньше (D9) | Ветка от test, #315 не слит | Низкая | | 10 | 2026-11-11 | Трейлер коммитов — `Co-Authored-By: Claude Opus 5.5` по Global Constraints плана, а не по умолчанию обвязки (R-1) | План утверждён человеком | Косметика | | 11 | 2026-11-11 | Принят выбор исполнителя Task 1: суффиксы `invalid_formula: owner\|line_id\|length\|chars\|value`; пустой line_id/column → `invalid_formula: line_id`; проверка владельца и в `v_cell_formulas`; `#variable_conflict use_column`; своя фикстура `svod2_sql` с откатом (R-2) | Согласуется с D3 (поле берётся из текста после префикса) | Низкая: тексты ошибок | | 12 | 2026-11-11 | Minor-находки Task 1 (T1.1–T1.3) правятся одной пачкой после Task 4 (R-3) | Не плодить диспатчи; проектный каскад появится в Task 4 | Низкая |