Andrey Epifancev 8e294e2899 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>
2026-11-11 20:07:04 +04:00

28 KiB
Raw Blame History

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

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 не слит Низкая