32 KiB
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.pyFormEventProcess.__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.pyPATCH …/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
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'→ 422invalid_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) с формулами не падает.
- PATCH ячейки с
- 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 | Низкая |
| 13 | 2026-11-11 | В Task 4 upd_form3_cells — то же правило, что в Task 3 для cells: exclude={"formula"} и ключ formula только если не None (R-4) |
Пробел в формулировке плана | Нет: SQL игнорирует лишние ключи |
| 14 | 2026-11-11 | В core/exception_handlers.py снимается обёртка asyncpg <class '…'>: перед проверкой VALIDATION_PREFIXES (R-5); побочно все validation-префиксы на реальной БД дают 422 вместо 400 и заполняют field |
Без этого invalid_formula: не распознавался (спека требует 422); локальная альтернатива дублировала бы маппинг |
Клиенты, ожидавшие 400 для computed_field:/unknown_column: и др.; тесты и фронт 400 не проверяют |
| 15 | 2026-11-11 | Task 3/4: тест 403 с isp2/plan.q2 вместо isp_tokens; SAVEPOINT-тест атомарности; тесты проектов берут строки из rf_project_report_line; GET формул несуществующего отчёта → 400 как у PATCH |
Фикстуры/поведение существующих эндпоинтов | Низкая: только тесты/коды ошибок |
| 16 | 2026-11-11 | Пачка minor-правок Task 1–4 (коммит 0e2aafe) без отдельного scoped re-review — её проверит финальное ревью ветки (R-6); миграция 0059 правилась на месте (проверка NaN) | Диф — тесты и одно условие SQL, тесты зелёные | Регрессия обнаружится на шаг позже |
| 17 | 2026-11-11 | Клиент API формул — методы FormsSheetApi.getFormulas и ProjectsApi.getReportFormulas, а не отдельные функции getSheetFormulas/getProjectReportFormulas (R-7); правки minor T6.1/T6.2 переданы исполнителю Task 7 (R-8) |
Соответствие шаблону модулей web/src/api |
Нет |
| 18 | 2026-11-11 | Task 7: стор формул вынесен в чистый utils/formulaStore.js (подписка по ключу ячейки, токены против устаревших ответов, повтор WS-событий поверх GET); загрузка карты при каждой смене isConnected (один лишний GET при открытии); fx слева-снизу; проверка «выражение без =» не действует на текстовые колонки; удалены clearLegacyFormulaKeys, resolveEditorState, пропсы tableId/cellId; options проброшен и через RealtimeTable.jsx |
Тестируемость и отсутствие перерисовки всей таблицы; согласуется с D7 | Низкая: визуальные детали и один лишний запрос; в браузере не проверено |