docs(openspec): change aurora-1896-cell-formulas — формулы ячеек на сервере (AURORA-1896)

proposal, дельта спеки cell-formula, design.md со сверкой дизайна Jira
с кодом test 5a91f35 и разделом решений без обсуждения.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-11 18:22:26 +04:00
parent f0470313c1
commit 8e294e2899
4 changed files with 298 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11

View File

@ -0,0 +1,177 @@
# 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 не слит | Низкая |

View File

@ -0,0 +1,39 @@
# Proposal
## Why
Замечание заказчика №8 «Формулы и факт» (свод замечаний Фомичева от 27.10.2026, Jira AURORA-1896, Критичный): формулы, введённые одним пользователем (например, `=100+50`), не видны другим пользователям в исходном виде. Это мешает проверять фактические проводки, введённые разными сотрудниками.
Сейчас формула живёт только в localStorage браузера автора (`web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx`, ключ `formula_v2_…`, после AURORA-1895 — с привязкой к `line_id` и проверкой совпадения результата). На сервер уходит только число. Другие пользователи — и сам автор на другом компьютере — видят только число; признака формулы в обычной ячейке нет, индикатор `fx` есть только в открытом редакторе автора.
## What Changes
- Новая таблица `v3.cell_formula`: последняя формула ячейки — выражение, значение на момент сохранения, автор, время. Ключ — (форма или отчёт проекта, лист, строка, колонка).
- Запись ячейки (WS `cell_updated`, REST `PATCH …/cell` и `…/cells` — для форм и для отчётов проектов) принимает необязательное `formula`. Формула пишется в той же транзакции, что и значение, и только после успешной записи значения (те же права). Ввод значения без формулы удаляет формулу этой ячейки.
- Сервер проверяет выражение (допустимые символы, длина) и отвечает 422 `invalid_formula:` на некорректное; значение при этом тоже не пишется.
- Новые эндпоинты чтения формул листа формы и отчёта проекта.
- WS-рассылка `cell_updated` несёт нормализованную формулу (или `null`) и автора/время — остальные клиенты обновляют маркер без перезагрузки.
- Фронт: карта формул листа в `RealtimeContext`; маркер `fx` в углу ячейки с подсказкой «=выражение · ФИО · дата время», если значение формулы совпадает с текущим значением ячейки; редактор открывается с `=выражением` с сервера; localStorage для формул больше не используется, старые ключи `formula_*` удаляются.
- Формулы удаляемых строк, форм и отчётов удаляются автоматически (FK с каскадом и триггеры на таблицах строк).
**Не меняется:**
- вычисление выражения — по-прежнему на фронте (`formulaUtils.calculateFormula`); сервер выражение не вычисляет;
- ответ на правку (`result` — затронутое поддерево, AURORA-1850), содержимое GET листа (AURORA-1848);
- права на запись ячеек; отсутствие серверной проверки окон для FORM_3 (существующее поведение, см. design.md);
- Excel-выгрузка (формулы не выгружаются);
- история значений ячейки не ведётся — хранится только последняя формула.
## Capabilities
### New Capabilities
- `cell-formula`: хранение, запись, чтение и рассылка формул ячеек листов бюджетных форм и отчётов проектов; отображение маркера формулы.
### Modified Capabilities
Нет.
## Impact
- SQL: новая миграция (следующий свободный номер на момент мержа — см. design.md, D9): таблица `v3.cell_formula`, функции `v3.set_cell_formulas`, `v3.v_cell_formulas`, триггеры очистки на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line`.
- Python: `sheet_repository.update_cell/update_cells`, `project_repository.upd_form3_cell(s)`, сервисы, `websocket.py` (`FormEventProcess`/`ProjectEventProcess.__update_cell`), `forms.py`/`projects.py` (новые GET, `formula` в `CellPatch`), `schemas.py`, `CONSTANTS.VALIDATION_PREFIXES`.
- Web: `RealtimeContext`, `useRealtimeData`, `Cell`, `EditCell`, `formulaUtils`, новые чистые утилиты карты формул, API-клиент.
- Тесты: `api/tests/integration/test_cell_formula_integration.py`, unit на репозитории/сервис, `node --test` на утилиты формул.

View File

@ -0,0 +1,80 @@
# Spec Delta
## Purpose
Формулы ячеек листов бюджетных форм (FORM_1, FORM_2, FORM_4) и отчётов проектов (FORM_3): как формула сохраняется вместе со значением, кто и как её видит, когда она перестаёт показываться и когда удаляется.
Термины: **ячейка** — (владелец, лист, `line_id`, `column`), где владелец — бюджетная форма (`budget_form.id`) или отчёт проекта (`rf_project_report.id`); `line_id` и `column` — те же, что в `cell_updated` и `PATCH …/cell`. **Формула** — выражение без ведущего «=», например `100+50`.
## ADDED Requirements
### Requirement: Формула сохраняется вместе со значением ячейки
Запись ячейки с полем `formula` (WS `cell_updated` — `data.formula`; REST `PATCH /form/{id}/sheet/{sheet}/cell`, `…/cells`, `PATCH /projects/{id}/report/{year}/{type}/cell`, `…/cells` — поле `formula` элемента) SHALL сохранять формулу этой ячейки в той же транзакции, что и значение: выражение, значение (`value` записи), автора (текущий пользователь) и время записи. Предыдущая формула ячейки SHALL заменяться. Формула SHALL сохраняться только если запись значения прошла (те же проверки прав и принадлежности строки, что у значения); при отказе в записи значения формула SHALL NOT сохраняться.
#### Scenario: Сохранение формулы
- **WHEN** администратор пишет в ячейку FORM_2/AHR `plan.q1` значение 150 с формулой `100+50`
- **THEN** чтение формул листа возвращает для этой ячейки выражение `100+50`, значение 150, автора — этого администратора и время записи
#### Scenario: Отказ в праве на ячейку
- **WHEN** исполнитель без права на колонку (окно закрыто или чужая оргединица) пишет значение с формулой
- **THEN** запрос отклоняется так же, как без формулы (403)
- **AND** формула ячейки не появляется и прежняя формула не меняется
### Requirement: Ввод без формулы удаляет формулу ячейки
Запись значения ячейки без поля `formula` (или с `formula: null`) через любой из путей записи выше SHALL удалять формулу этой ячейки, если она была. Это касается и массовой записи `…/cells`: для каждого элемента без формулы формула его ячейки удаляется.
#### Scenario: Перезапись числом
- **WHEN** у ячейки есть формула `100+50`, и пользователь записывает в неё значение 200 без формулы
- **THEN** чтение формул листа не возвращает формулы этой ячейки
### Requirement: Проверка выражения
Сервер SHALL принимать выражение длиной от 1 до 500 символов, состоящее только из цифр, пробелов и символов `+ - * / % ( ) , .`; ведущий «=» и пробелы по краям SHALL отбрасываться при нормализации. Сервер SHALL NOT вычислять выражение. Формула с непустым выражением SHALL требовать числовое `value`. При нарушении сервер SHALL отвечать 422 с кодом ошибки по префиксу `invalid_formula:`, и значение ячейки в этом запросе SHALL NOT записываться.
#### Scenario: Недопустимое выражение
- **WHEN** пользователь пишет значение 150 с формулой `drop table`
- **THEN** ответ — 422 `invalid_formula`
- **AND** значение ячейки осталось прежним, формула не сохранилась
#### Scenario: Нормализация
- **WHEN** пользователь пишет формулу ` =100+50 `
- **THEN** сохраняется выражение `100+50`
### Requirement: Чтение формул листа
Сервер SHALL отдавать все формулы листа: `GET /api/v1/form/{form_id}/sheet/{sheet}/formulas` и `GET /api/v1/projects/{project_id}/report/{year}/{report_type}/formulas`. Каждая запись SHALL содержать `line_id`, `column`, `expression`, `value`, `updated_by` (`id`, `full_name`) и `updated_at`. Доступ SHALL проверяться так же, как у GET листа (форма) или отчёта (проект): нет доступа к форме или проекту — 404.
#### Scenario: Формулы видны другому пользователю
- **WHEN** пользователь А сохранил формулу, а пользователь Б с доступом к форме запрашивает формулы листа
- **THEN** Б получает формулу А с её автором и временем
### Requirement: Рассылка формулы по WebSocket
Рассылка `cell_updated` всем подключённым к листу SHALL содержать в `data` поле `formula` — нормализованное сохранённое выражение или `null`, если формулы у ячейки после записи нет, — и при наличии формулы поле `formula_meta` с `updated_by` (`id`, `full_name`) и `updated_at`. Значение `data.formula` в рассылке SHALL задавать сервер, а не пересылать входящее значение клиента.
#### Scenario: Второй пользователь видит формулу без перезагрузки
- **WHEN** пользователь А вводит в ячейку `=100+50`
- **THEN** пользователь Б, открывший тот же лист, получает `cell_updated` с `data.formula = "100+50"` и автором А
- **AND** в ячейке у Б отображается 150 с маркером формулы
#### Scenario: Перезапись снимает маркер у всех
- **WHEN** пользователь Б записывает в ту же ячейку число 200
- **THEN** все подключённые клиенты получают `cell_updated` с `data.formula = null`, и маркер формулы пропадает
### Requirement: Отображение формулы
Фронт SHALL показывать у ячейки маркер формулы `fx` и подсказку с выражением, ФИО автора и датой-временем, только если для ячейки есть формула и её `value` совпадает с текущим значением ячейки с точностью 1e-9. Редактор ячейки SHALL открываться с текстом `=` + выражение при том же условии совпадения. Если значения не совпадают (значение изменилось не через ввод, например синхронизацией), маркер SHALL NOT показываться, и редактор открывается со значением.
#### Scenario: Формула устарела после пересчёта
- **WHEN** у ячейки есть формула со значением 150, а текущее значение ячейки стало 170
- **THEN** маркер формулы не показывается, редактор открывается со значением 170
### Requirement: Формулы не хранятся в браузере
Фронт SHALL NOT читать и писать формулы в localStorage. При первом запуске новой версии ключи `formula_*` SHALL удаляться из localStorage.
#### Scenario: Формула с другого компьютера
- **WHEN** автор сохранил формулу и открыл лист на другом компьютере
- **THEN** он видит маркер формулы и открывает редактор с `=выражением`
### Requirement: Формулы удаляются вместе с владельцем и строкой
Формулы SHALL удаляться при удалении строки, к которой относится ячейка (строка бюджета, запись сателлита аренды/коммунальных/охраны, запись отчёта ОС OTCH9F, строка отчёта проекта), при окончательном удалении бюджетной формы и при удалении отчёта проекта — независимо от того, какая функция удаляет строку.
#### Scenario: Удаление строки
- **WHEN** у строки листа есть формулы, и строку удаляют
- **THEN** чтение формул листа не возвращает формул этой строки