From 8e294e28992dd36d4f9f6b0890b3f95e0cf154a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 18:22:26 +0400 Subject: [PATCH 01/25] =?UTF-8?q?docs(openspec):=20change=20aurora-1896-ce?= =?UTF-8?q?ll-formulas=20=E2=80=94=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB?= =?UTF-8?q?=D1=8B=20=D1=8F=D1=87=D0=B5=D0=B5=D0=BA=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B5=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, дельта спеки cell-formula, design.md со сверкой дизайна Jira с кодом test 5a91f35 и разделом решений без обсуждения. Co-Authored-By: Claude Opus 5.5 --- .../aurora-1896-cell-formulas/.openspec.yaml | 2 + .../aurora-1896-cell-formulas/design.md | 177 ++++++++++++++++++ .../aurora-1896-cell-formulas/proposal.md | 39 ++++ .../specs/cell-formula/spec.md | 80 ++++++++ 4 files changed, 298 insertions(+) create mode 100644 openspec/changes/aurora-1896-cell-formulas/.openspec.yaml create mode 100644 openspec/changes/aurora-1896-cell-formulas/design.md create mode 100644 openspec/changes/aurora-1896-cell-formulas/proposal.md create mode 100644 openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md diff --git a/openspec/changes/aurora-1896-cell-formulas/.openspec.yaml b/openspec/changes/aurora-1896-cell-formulas/.openspec.yaml new file mode 100644 index 0000000..2a7773d --- /dev/null +++ b/openspec/changes/aurora-1896-cell-formulas/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-11 diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md new file mode 100644 index 0000000..152c897 --- /dev/null +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -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 не слит | Низкая | diff --git a/openspec/changes/aurora-1896-cell-formulas/proposal.md b/openspec/changes/aurora-1896-cell-formulas/proposal.md new file mode 100644 index 0000000..4e9a8b4 --- /dev/null +++ b/openspec/changes/aurora-1896-cell-formulas/proposal.md @@ -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` на утилиты формул. diff --git a/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md b/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md new file mode 100644 index 0000000..274f86c --- /dev/null +++ b/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md @@ -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** чтение формул листа не возвращает формул этой строки From 1187ac2f8feb643df129d3dc8db4fcb6672cee5b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 18:25:11 +0400 Subject: [PATCH 02/25] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?AURORA-1896=20=E2=80=94=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB?= =?UTF-8?q?=D1=8B=20=D1=8F=D1=87=D0=B5=D0=B5=D0=BA=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-11-11-aurora-1896-cell-formulas.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md diff --git a/docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md b/docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md new file mode 100644 index 0000000..3239d4a --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md @@ -0,0 +1,203 @@ +# AURORA-1896: формулы ячеек на сервере — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Формула ячейки (`=100+50`) хранится на сервере вместе со значением и видна всем пользователям листа — маркер `fx` с выражением, автором и временем, сразу по WebSocket и после перезагрузки; для форм и отчётов проектов. + +**Architecture:** Новая таблица `v3.cell_formula` (владелец — FK на форму или отчёт проекта с каскадом), SQL-функции `v3.set_cell_formulas` (проверка + upsert/delete) и `v3.v_cell_formulas` (чтение), триггеры очистки на таблицах строк. Репозитории вызывают `set_cell_formulas` сразу после записи значения в той же транзакции; WS-рассылка `cell_updated` несёт сохранённую формулу. Фронт держит карту формул листа в `RealtimeContext`, показывает маркер в `Cell` и подставляет формулу в `EditCell`; localStorage для формул убирается. + +**Tech Stack:** PostgreSQL (PL/pgSQL, схема `v3`), Alembic с SQL-файлами, FastAPI 0.111, SQLAlchemy 2 async; React 18 (JSX), MUI, `node --test`. + +**Spec:** `openspec/changes/aurora-1896-cell-formulas/` — `proposal.md`, `specs/cell-formula/spec.md`, `design.md` (решения D1–D9). Исполнители читают design.md своей задачи целиком. + +## Global Constraints + +- Базовая ветка `test`; работа только в worktree `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1896`, ветка `feat/320-aurora-1896-cell-formulas`. В `api/src/`, `api/alembic/`, `web/src/` писать можно только при наличии этого плана (хук require-plan). +- Миграция: `api/alembic/versions/0059_cell_formula.py` (`revision = "0059"`, `down_revision = "0058"`, `run_sql_file_migration("0059_cell_formula.sql")`) + `api/alembic/versions/sql/0059_cell_formula.sql`. **Только именованные dollar-теги** (`$function$`, `$do$`): `alembic/sql_migration._split_statements` не распознаёт `$$`. Проверять миграцию только через `pytest --init_db`, не через psql. +- Python-окружение: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest` (в worktree venv нет; ничего не ставить из сети, не обходить Nexus). +- Postgres: контейнер `perf-pg`, `perf:perf@localhost:55432`. Своя БД на прогон: `docker exec perf-pg psql -q -U perf -d perf -c "DROP DATABASE IF EXISTS verify_1896" -c "CREATE DATABASE verify_1896"`; затем `cd api && DATABASE_URL="postgresql+asyncpg://perf:perf@localhost:55432/verify_1896" /bin/pytest --init_db …`. Не трогать базы `perf`, `perf_1835`, `verify_1936`, `verify_1879`. +- Эталон весь набора на test 5a91f35: 10 известных падений — `test_db_functions_integration::test_backend_calls_form3_add_project_and_add_line_functions`, 3× `test_form2_ahr_satellite_spod_integration::test_satellite_spod_written_and_included_in_q4_total[*]`, `test_upd_form_cells_subtree::test_edit_path_does_not_call_v_form_view[delta]`, `::test_ahr_delta_matches_get_after_rent_edit`, `::test_jit_off_on_builders_and_edit_path`, `test_upd_form_cells_subtree_0037_guard::test_v_form2_view_keeps_0037_limit_formula`, 2× `test_export_service_query_params`. Новых падений быть не должно. +- Фронт: `node_modules` нет в worktree — симлинк `web/node_modules` → `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules` (не коммитить). Тесты: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`. Сборка `npm run build`. Не менять `npm_config_registry`. +- Префикс ошибки — `invalid_formula:`. Выражение: длина 1..500, regex `^[0-9+\-*/%(),. ]+$` после нормализации (`btrim`, снять один ведущий `=`, `btrim`). Сравнение формулы со значением — `|a − b| ≤ 1e-9`. +- Подсказка маркера — ровно `= · <ФИО> · дд.мм.гггг чч:мм`; если ФИО нет — `Пользователь #`. +- Текст подсказки при вводе выражения без «=» — «Формула начинается с =». +- Коммиты на русском в стиле истории (`feat(formulas): … (AURORA-1896)`), последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 `. Не пушить. + +## Review Focus + +- Формула в нечисловую ячейку (`header.justification`, `name`) или с `value: null` → 422 `invalid_formula`, значение не записано (Task 3). +- Фоновое `cell_updated` от синхронизации сателлитов (`data:{source_sheet}`, без `line_id`/`column`) не должно стирать формулы листа AHR у клиента (Task 6). +- AHR_LIMIT: `line_id = limit_template.id` одинаков во всех формах — формула формы A не видна в форме B (Task 1). +- Десятичная запятая `100,5+1` принимается сервером как есть (фронт сам вычисляет) (Task 1). +- Каскады: окончательное удаление формы с формулами и удаление проекта с формулами не падают на FK (Task 1, Task 4). + +--- + +### Task 1: Миграция — таблица, функции, триггеры + +**Files:** +- Create: `api/alembic/versions/0059_cell_formula.py`, `api/alembic/versions/sql/0059_cell_formula.sql` +- Create: `api/tests/integration/test_cell_formula_sql.py` +- Modify: `ARCHITECTURE.md` (абзац «Триггеры» — первые триггеры в схеме, что делают) + +**Interfaces:** +- Produces: + - таблица `v3.cell_formula` — ровно как в design.md D1 (колонки, CHECK, индексы `cell_formula_form_uidx`, `cell_formula_report_uidx`, `cell_formula_line_idx`); + - `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)` — по design.md D2/D3; возвращает по строке на каждый элемент `p_changes`, у которого после вызова есть формула (элементы без формулы не возвращаются); + - `v3.v_cell_formulas(p_budget_form_id integer, p_report_id integer, p_sheet varchar)` — тот же RETURNS TABLE, все формулы владельца и листа, `ORDER BY line_id, column_key`; + - триггерные функции и триггеры по таблице design.md D4 (`v3.cell_formula_cleanup_()`, `AFTER DELETE FOR EACH ROW`). + - Ровно один из `p_budget_form_id`/`p_report_id` не NULL, иначе `RAISE EXCEPTION 'invalid_formula: owner'`. + +- [ ] **Step 1: Write the failing tests** в `test_cell_formula_sql.py` через фикстуру `svod2_sql` (`tests/integration/conftest.py:136`; работает на сессии клиента с откатом). Владелец — форма 1 из фикстуры; строка — любая `budget_line` формы 1 на AHR (`SELECT id FROM v3.budget_line WHERE budget_form_id = 1 LIMIT 1`, skip если нет); пользователь — id админа (`SELECT id FROM v3.app_user WHERE username = 'admin'`). + - `test_set_formula_upsert_and_list`: `set_cell_formulas(1, NULL, 'AHR', '[{"line_id":L,"column":"plan.q1","value":150,"formula":"100+50"}]', admin)` возвращает одну строку `expression='100+50'`, `value=150`, `updated_by=admin`, `updated_by_name` не NULL; `v_cell_formulas(1, NULL, 'AHR')` содержит её; повтор с `"formula":"200-50"` → в `v_cell_formulas` одна запись `200-50`. + - `test_set_without_formula_deletes`: после установки — вызов с тем же элементом без ключа `formula` → `v_cell_formulas` пуст для ячейки, функция ничего не вернула. + - `test_normalization`: `"formula":" =100+50 "` → `expression='100+50'`; `"formula":"100,5+1"` → `expression='100,5+1'`. + - `test_invalid_formula_raises` (параметризовать): `"drop table"`, `""`-после-нормализации не ошибка (= нет формулы), `"1"*501`, `value: null` с формулой, `value: "abc"` с формулой → `pytest.raises(Exception, match="invalid_formula:")` (для пустой строки — не raise, формулы нет). + - `test_ahr_limit_formula_scoped_to_form`: формула на `AHR_LIMIT`, `line_id` = любой `limit_template.id` (`row_type='LEAF'`), для формы 1 → `v_cell_formulas(<другая форма из фикстуры>, NULL, 'AHR_LIMIT')` не содержит её (skip, если второй формы нет). + - `test_trigger_budget_line_delete`: формула на строке L → `DELETE FROM v3.budget_line WHERE id = L` (предварительно удалить зависимые: проще создать свою строку через `v3.add_budget_line` или найти строку без зависимостей; если громоздко — использовать `v3.del_budget_line(L, direction, 'AHR')` под `SET LOCAL app.user_id` админа) → формул строки нет. + - `test_cascade_hard_delete_form`: формула на форме → `SELECT v3.hard_delete_budget_form(id)` проходит (подготовить форму по условиям 0039: `to_delete`, другой пометивший — смотри `budget_form_service.hard_delete` и существующие тесты удаления форм; если подготовка недоступна в фикстуре — skip с причиной) и формул формы нет. + - `test_owner_check`: оба владельца NULL → `invalid_formula:`. +- [ ] **Step 2: Run to verify failure** — `pytest --init_db tests/integration/test_cell_formula_sql.py` на своей БД → FAIL (`function v3.set_cell_formulas … does not exist`). +- [ ] **Step 3: Implement** миграцию по Interfaces и design.md D1–D4. Тело проверки и upsert — `FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)`; upsert — `INSERT … ON CONFLICT (budget_form_id, sheet, line_id, column_key) WHERE budget_form_id IS NOT NULL DO UPDATE` (и аналог для отчёта). `updated_by_name` — `app_user.full_name`. Комментарий в начале SQL-файла: зачем триггеры и что они удаляют. Абзац в ARCHITECTURE.md. +- [ ] **Step 4: Run to verify pass** — пересоздать БД `verify_1896`, `pytest --init_db tests/integration/test_cell_formula_sql.py` → PASS. +- [ ] **Step 5: Commit** — `feat(formulas): таблица cell_formula, set/v_cell_formulas, триггеры очистки, миграция 0059 (AURORA-1896)`. + +### Task 2: Репозиторий формул и префикс ошибки + +**Files:** +- Create: `api/src/repository/cell_formula_repository.py` +- Modify: `api/src/core/CONSTANTS.py:1-11` (добавить `"invalid_formula:"` в `VALIDATION_PREFIXES`) +- Modify: `api/src/domain/schemas.py` (новые `UserShort`, `CellFormulaResponse`) +- Test: `api/tests/unit/test_cell_formula_repository.py`, дополнить `api/tests/unit/test_exception_handlers.py` + +**Interfaces:** +- Consumes: SQL из Task 1. +- Produces: + - `class CellFormulaRepository(db: AsyncSession)`: + - `async def set_formulas(self, *, budget_form_id: int | None, report_id: int | None, sheet: str, changes: list[dict], user_id: int) -> list[dict]` — вызывает `SELECT * FROM v3.set_cell_formulas(:budget_form_id, :report_id, :sheet, CAST(:changes AS JSONB), :user_id)`, `changes` сериализуются `json.dumps`; возвращает `[{"line_id", "column", "expression", "value": float, "updated_by": {"id", "full_name"}, "updated_at": datetime}]` (`column` ← `column_key`, `value` → float); + - `async def list_formulas(self, *, budget_form_id: int | None, report_id: int | None, sheet: str) -> list[dict]` — то же из `v3.v_cell_formulas`. + - `schemas.UserShort(BaseModel): id: int; full_name: str | None = None` + - `schemas.CellFormulaResponse(BaseModel): line_id: int; column: str; expression: str; value: float; updated_by: UserShort; updated_at: datetime` + - `CellPatch` получает `formula: str | None = None` (используется в Task 3/4). + +- [ ] **Step 1: Write failing unit tests** (мок `AsyncSession` по образцу существующих unit-тестов репозиториев): `test_set_formulas_calls_sql_with_json_changes` — `execute` вызван с текстом, содержащим `v3.set_cell_formulas`, и параметрами `budget_form_id=1, report_id=None, sheet='AHR', user_id=7`, `changes` — JSON-строка, в которой у элемента есть `formula`; `test_set_formulas_maps_rows` — строка результата `(10, 'plan.q1', '100+50', Decimal('150'), 7, 'Иванов И.И.', dt)` → `{"line_id": 10, "column": "plan.q1", "expression": "100+50", "value": 150.0, "updated_by": {"id": 7, "full_name": "Иванов И.И."}, "updated_at": dt}`; `test_list_formulas_calls_view`. В `test_exception_handlers.py`: ошибка с текстом `invalid_formula: expression` → статус 422, `field == "expression"` (по образцу существующих кейсов префиксов). +- [ ] **Step 2: Run** `pytest tests/unit/test_cell_formula_repository.py tests/unit/test_exception_handlers.py` → FAIL. +- [ ] **Step 3: Implement** репозиторий, префикс, схемы, поле `CellPatch.formula`. +- [ ] **Step 4: Run** → PASS; весь `pytest tests/unit` — только 2 известных падения export. +- [ ] **Step 5: Commit** — `feat(formulas): CellFormulaRepository, invalid_formula → 422, схемы (AURORA-1896)`. + +### Task 3: Формы — запись формулы и GET formulas (REST) + +**Files:** +- Modify: `api/src/services/sheet_service.py:55-93` (`update_cells`, `update_cell`), новый метод чтения +- Modify: `api/src/api/v1/forms.py:202-320` (PATCH `cell`/`cells` передают `formula`), новый GET +- Test: `api/tests/integration/test_cell_formula_integration.py` (новый) + +**Interfaces:** +- Consumes: `CellFormulaRepository` (Task 2), `CellPatch.formula`. +- Produces: + - `SheetService.update_cell_with_formula(self, form_id, sheet, direction, sections, line_id, column, value=None, formula: str | None = None, user: AppUser | None = None) -> tuple[list[tuple], dict | None]` — пишет значение прежним `sheet_repo.update_cell`, затем (если `user`) `CellFormulaRepository.set_formulas(budget_form_id=form_id, report_id=None, sheet=sheet, changes=[{"line_id", "column", "value", "formula"}], user_id=user.id)`; возвращает `(rows, formula_or_None)`. Ключ `formula` в change передаётся только если не None. + - `SheetService.update_cell(... , formula: str | None = None, ...) -> list[tuple]` — возвращает `update_cell_with_formula(...)[0]` (сигнатура прежняя + `formula`). + - `SheetService.update_cells(..., changes: list[dict], ...)` — после записи вызывает `set_formulas` с теми же `changes` (каждый элемент может нести `formula`). + - `SheetService.list_formulas(self, form_id: int, sheet: str) -> list[dict]`. + - `GET /api/v1/form/{form_id}/sheet/{sheet}/formulas` → `BaseListResponse[CellFormulaResponse]`; доступ — `BudgetFormService.get` (нет → 404 «Форма не найдена»), лист — проверка по `form_type.sheet_list`, как в PATCH ячейки; без `direction`. + +- [ ] **Step 1: Write failing integration tests** (`client`, `admin_tokens`, `isp_tokens`, `auth_headers`; форма 1, лист AHR, `?direction=Support`, строка и колонка — как в `test_forms_api_smoke.py:72-125`): + - `test_patch_cell_with_formula_then_get` → GET formulas содержит `{line_id, column: 'plan.q1', expression: '100+50', value: 150, updated_by.id == }`; + - `test_patch_without_formula_removes`; + - `test_patch_invalid_formula_422_value_unchanged` — `formula: 'drop table'` → 422, `error.code`/поле как у других validation-ошибок; последующий GET листа — значение прежнее (сравнить с GET до запроса); + - `test_patch_formula_into_text_column_422` — `column: 'header.justification'`, `value: 'текст'`, `formula: '1+1'` → 422 (Review Focus); + - `test_patch_formula_null_value_422` — `value: None`, `formula: '1+1'` → 422; + - `test_patch_formula_forbidden_403_no_formula` — `isp_tokens` (исполнитель не в оргединице формы 1) → 403, GET formulas под админом — формулы нет; + - `test_patch_cells_mixed` — два элемента в `/cells`, один с формулой → одна формула; + - `test_delete_line_removes_formulas` — создать строку (`POST …/line`, как в существующих тестах добавления строк), формула на ней, `DELETE …/line/{id}` → формул строки нет; + - `test_get_formulas_unknown_form_404`. + Помнить: после ошибочного запроса тестовая сессия откатывается целиком (`tests/integration/conftest.py:34-36`) — проверки «значение не изменилось/формулы нет» делать отдельным GET в том же тесте только если откат это допускает; иначе проверять, что в ответе ошибки, и что GET после отката не содержит формулы. +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** сервис и эндпоинты по Interfaces. В REST PATCH `cell` передать `formula=body.formula`; в `cells` — каждый элемент `c.model_dump(exclude={"formula"})`, плюс ключ `formula`, только если `c.formula is not None`. +- [ ] **Step 4: Run** новые тесты → PASS; `pytest tests/integration/test_forms_api_smoke.py` → без новых падений. +- [ ] **Step 5: Commit** — `feat(formulas): запись формулы при правке ячейки формы и GET formulas листа (AURORA-1896)`. + +### Task 4: Отчёты проектов — запись формулы и GET formulas (REST) + +**Files:** +- Modify: `api/src/services/project_service.py:285-320` (`upd_form3_cell`, `upd_form3_cells`), новый метод чтения +- Modify: `api/src/api/v1/projects.py:332-370`, новый GET +- Test: `api/tests/integration/test_cell_formula_integration.py` (дополнить) + +**Interfaces:** +- Consumes: Task 2; `resolve_report_id` (нормализованный `report_type` — `report_type.upper()`, допускаются `LIMIT`, `CURRENT_EXPENSES`). +- Produces: + - `ProjectService.upd_form3_cell_with_formula(self, project_id, year, report_type, line_id, column, value, formula: str | None, user: AppUser) -> tuple[list[tuple], dict | None]`; `upd_form3_cell(..., formula: str | None = None, ...)` возвращает `[0]`; `upd_form3_cells` — формулы по `changes`. Владелец — `report_id=`, `sheet=<нормализованный report_type>`. + - `ProjectService.list_formulas(self, project_id, year, report_type, user) -> list[dict]`. + - `GET /api/v1/projects/{project_id}/report/{year}/{report_type}/formulas` → `BaseListResponse[CellFormulaResponse]`; отчёт не найден → то же, что у PATCH (ValidationException «Отчёт не найден»). + +- [ ] **Step 1: Write failing tests** (по образцу `test_projects_api_smoke.py:107-160`): `test_project_report_formula_roundtrip` — PATCH `…/cell` с `formula` → GET `…/formulas` содержит запись; `report_type` в пути в нижнем регистре (`limit`) работает так же; `test_project_report_formula_removed_without_formula`; `test_project_delete_cascades_formulas` — если в фикстуре можно удалить проект (`v3.del_project` / API удаления проекта; условия `can_delete_project`) — после удаления проекта запрос `SELECT count(*) FROM v3.cell_formula WHERE rf_project_report_id = :id` = 0, иначе skip с причиной. +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** по Interfaces. +- [ ] **Step 4: Run** → PASS; `pytest tests/integration/test_projects_api_smoke.py` без новых падений. +- [ ] **Step 5: Commit** — `feat(formulas): формулы в отчётах проектов — запись и GET formulas (AURORA-1896)`. + +### Task 5: WebSocket — formula во входящем cell_updated и в рассылке + +**Files:** +- Modify: `api/src/api/v1/websocket.py` — `FormEventProcess.__update_cell` (~:510-534), `ProjectEventProcess.__update_cell` (~:739-762) +- Test: `api/tests/integration/test_cell_formula_ws_integration.py` (новый, по образцу `test_websocket_connection_cache_integration.py`) + +**Interfaces:** +- Consumes: `SheetService.update_cell_with_formula`, `ProjectService.upd_form3_cell_with_formula`. +- Produces: после записи `__update_cell` мутирует `event_data` (он и есть `data` рассылки): `event_data["formula"] = formula["expression"] if formula else None`; `event_data["formula_meta"] = {"updated_by": {"id", "full_name"}, "updated_at": } if formula else None`. Входящее `event_data.get("formula")` передаётся в сервис (отсутствует → None). Возвращает строки, как раньше. + +- [ ] **Step 1: Write failing tests**: WS к `/api/v1/ws/form/1/sheet/AHR?direction=Support`, логин и последовательность `cell_edit_start` → `cell_updated` (с `data.formula = "100+50"`, `value = 150`) → `cell_edit_end`, как в образце; в полученном `cell_updated` `data.formula == "100+50"`, `data.formula_meta.updated_by.id == `, `updated_at` — строка; второй `cell_updated` той же ячейки без `formula` → `data.formula is None`, `data.formula_meta is None`. WS коммитит: в `finally` вернуть прежнее значение и удалить формулу (`DELETE FROM v3.cell_formula WHERE budget_form_id = 1 AND line_id = … AND column_key = 'plan.q1'` отдельной сессией с commit), как образец откатывает значение. Кейс с клиентской подменой: входящее `data.formula = " =100+50 "` → в рассылке `"100+50"`. +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** по Interfaces в обоих процессорах. +- [ ] **Step 4: Run** → PASS; `pytest tests/integration/test_websocket_connection_cache_integration.py tests/unit/test_websocket_connection_cache.py` без новых падений. +- [ ] **Step 5: Commit** — `feat(formulas): formula и formula_meta в cell_updated по WebSocket (AURORA-1896)`. + +### Task 6: Фронт — чистые утилиты формул и API-клиент + +**Files:** +- Create: `web/src/components/RealtimeTable/utils/cellFormulaUtils.js`, `web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js` +- Modify: `web/src/api/` — клиент форм (где `getSheet`/`TasksApi`) и проектов: функции чтения формул + +**Interfaces:** +- Produces (`cellFormulaUtils.js`, без импортов React/MUI/constants.jsx): + - `formulaKey(lineId, column) -> string` — `${lineId}|${column}`; + - `buildFormulaMap(list) -> Map` — из ответа GET formulas (`{line_id, column, expression, value, updated_by, updated_at}`), ключ `formulaKey`; + - `applyFormulaEvent(map, data) -> Map` — новый `Map` (не мутирует вход); `data.line_id == null || data.column == null` → вернуть `map` как есть; `typeof data.formula === 'string' && data.formula` → записать `{line_id, column, expression: data.formula, value: Number(data.value), updated_by: data.formula_meta?.updated_by, updated_at: data.formula_meta?.updated_at}`; иначе удалить ключ; + - `isFormulaActual(formula, value) -> boolean` — `formula` есть, оба приводятся к конечным числам, `Math.abs(a - b) <= 1e-9`; + - `formatFormulaHint(formula) -> string` — `= · · дд.мм.гггг чч:мм` (локальное время; без `updated_at` — без последней части); + - `clearLegacyFormulaStorage(storage) -> number` — удаляет из переданного storage все ключи, начинающиеся с `formula_`, возвращает число удалённых; ошибки доступа глотает и возвращает 0. +- Produces (API): `getSheetFormulas(formId, sheet)` → `GET /form/${formId}/sheet/${encodeURIComponent(sheet)}/formulas` → `result`; `getProjectReportFormulas(projectId, year, reportType)` → `GET /projects/${projectId}/report/${year}/${reportType}/formulas` → `result`. Разместить в существующих модулях API рядом с запросами листа/отчёта. + +- [ ] **Step 1: Write failing tests** `cellFormulaUtils.test.js`: `applyFormulaEvent` — строка → запись с `expression`, `value` числом, `updated_by` из `formula_meta`; `formula: null` → удаление; без поля `formula` → удаление; `{source_sheet: 'AHR_RENT'}` (без `line_id`/`column`) → та же карта (Review Focus); вход не мутируется; `isFormulaActual` — `150` и `'150'` → true, `150` и `150.0000000001` → true, `150` и `151` → false, `null` формула → false, нечисловое значение → false; `formatFormulaHint` — с ФИО и датой `2026-10-27T14:03:00` → `=100+50 · Иванов И.И. · 27.10.2026 14:03`, без ФИО → `Пользователь #7`; `clearLegacyFormulaStorage` на фейковом storage (`{length, key(i), removeItem}`) с ключами `formula_v2_a`, `formula_x`, `other` → удаляет 2, `other` остаётся; storage, бросающий при доступе → 0; `buildFormulaMap`/`formulaKey`. +- [ ] **Step 2: Run** `node --test src/components/RealtimeTable/utils/cellFormulaUtils.test.js` → FAIL. +- [ ] **Step 3: Implement** утилиты и функции API. +- [ ] **Step 4: Run** все `node --test src/components/RealtimeTable/utils/*.test.js` → PASS. +- [ ] **Step 5: Commit** — `feat(formulas): утилиты карты формул и API чтения формул (AURORA-1896)`. + +### Task 7: Фронт — карта формул в RealtimeContext, маркер в Cell, EditCell без localStorage + +**Files:** +- Modify: `web/src/components/RealtimeTable/contexts/RealtimeContext.js` (загрузка карты при открытии листа и переподключении; `handleMessage` :118-181 — на `cell_updated` `applyFormulaEvent`; отправка `data.formula` в `updateCell` :313-332; экспорт карты через контекст) +- Modify: `web/src/components/RealtimeTable/Cell/Cell/Cell.jsx` (маркер `fx` + Tooltip) +- Modify: `web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx` (начальный текст из карты, удалить всё про localStorage, при сохранении формулы передавать выражение) +- Modify: `web/src/components/RealtimeTable/utils/formulaUtils.js` (если `resolveEditorState` больше не нужен — удалить вместе с тестами; подсказка для ввода без «=») +- Modify: `web/src/components/RealtimeTable/tableColumns.jsx` (проброс того, что нужно Cell/EditCell, если не через контекст) + +**Interfaces:** +- Consumes: Task 6 (`buildFormulaMap`, `applyFormulaEvent`, `isFormulaActual`, `formatFormulaHint`, `formulaKey`, `clearLegacyFormulaStorage`, `getSheetFormulas`, `getProjectReportFormulas`); строка — `getRowId(row)` (`utils/rowUtils.js:34`), колонка — ключ без `data.` (как `getColumnRequestKey`, `RealtimeContext.js:13-19`). +- Produces: хук `useCellFormula(lineId, column) -> formula | undefined` из `RealtimeContext` (или эквивалентный селектор), `updateCell(row, column, value, { formula })` — `formula` (строка без «=») кладётся в `data.formula` исходящего `cell_updated`, при обычном вводе поля нет. + +- [ ] **Step 1: Behaviour by design.md D7** — реализовать: загрузка карты (`isProject` → `getProjectReportFormulas(formId, year, sheetName)`, иначе `getSheetFormulas(formId, sheetName)`), ошибка загрузки — тост не нужен, карта пустая, `console.warn`; `clearLegacyFormulaStorage(window.localStorage)` один раз при загрузке модуля `EditCell`/контекста в `try/catch`; маркер `fx` — только при `isFormulaActual(formula, cell.getValue())`, в углу ячейки, не перекрывая индикацию редактируемости AURORA-1890; Tooltip `formatFormulaHint(formula)`, при `displayFactor !== 1` дописать ` (в тыс. руб.)` или ` (в руб.)` по `getMoneyUnit`; EditCell открывается с `=` + `expression` при актуальной формуле, при открытии набором символа (AURORA-1891) — как сейчас; ввод без «=», похожий на выражение (`/[+\-*/%()]/` после первой цифры) — не отправлять, `toast.info('Формула начинается с =')`, редактор не закрывать. +- [ ] **Step 2: Verify** — `node --test src/components/RealtimeTable/utils/*.test.js` → PASS; `npm run build` → успешно; `npx biome check` по изменённым файлам — не больше ошибок, чем до правки; `grep -rn "localStorage" web/src/components/RealtimeTable/Cell` — нет обращений для формул. +- [ ] **Step 3: Commit** — `feat(formulas): маркер fx у ячейки, формула с сервера в редакторе, без localStorage (AURORA-1896)`. + +### Task 8: Финальная проверка ветки + +**Files:** — + +- [ ] **Step 1:** Весь `pytest --init_db` на новой БД `verify_1896` → те же 10 падений, что в Global Constraints, остальное PASS; сохранить сравнение `diff` списков FAILED. +- [ ] **Step 2:** `git fetch origin && git log origin/test --oneline -5`: если в test уже есть `0059_*` (PR #315), переименовать миграцию в `0060_cell_formula` (`revision = "0060"`, `down_revision = "0059"`, имя SQL-файла), rebase на `origin/test`, повторить Step 1. +- [ ] **Step 3:** `openspec validate aurora-1896-cell-formulas` → valid. +- [ ] **Step 4: Commit** (если были правки) — `chore(formulas): номер миграции после rebase (AURORA-1896)`. From a29207f8149349bebb969b180e7a194eca1cc4ed Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:07:01 +0400 Subject: [PATCH 03/25] =?UTF-8?q?feat(formulas):=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20cell=5Fformula,=20set/v=5Fcell=5Ffor?= =?UTF-8?q?mulas,=20=D1=82=D1=80=D0=B8=D0=B3=D0=B3=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B8,=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=200059=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 + api/alembic/versions/0059_cell_formula.py | 15 ++ .../versions/sql/0059_cell_formula.sql | 255 ++++++++++++++++++ .../integration/test_cell_formula_sql.py | 139 ++++++++++ 4 files changed, 411 insertions(+) create mode 100644 api/alembic/versions/0059_cell_formula.py create mode 100644 api/alembic/versions/sql/0059_cell_formula.sql create mode 100644 api/tests/integration/test_cell_formula_sql.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3edd31..f4bf145 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,6 +4,8 @@ Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём. +Триггеры. В схеме `v3` есть первые триггеры (миграция 0059, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK. + ## Модули | Модуль | Ответственность | Зависит от | diff --git a/api/alembic/versions/0059_cell_formula.py b/api/alembic/versions/0059_cell_formula.py new file mode 100644 index 0000000..5bb8695 --- /dev/null +++ b/api/alembic/versions/0059_cell_formula.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0059" +down_revision = "0058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0059_cell_formula.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0059_cell_formula.sql b/api/alembic/versions/sql/0059_cell_formula.sql new file mode 100644 index 0000000..3f0c30d --- /dev/null +++ b/api/alembic/versions/sql/0059_cell_formula.sql @@ -0,0 +1,255 @@ +-- AURORA-1896: формулы ячеек на сервере. +-- +-- Таблица v3.cell_formula хранит последнюю формулу ячейки формы или отчёта проекта. +-- Владелец - ровно один из budget_form_id / rf_project_report_id (FK с ON DELETE CASCADE: +-- формулы уходят вместе с формой при hard_delete_budget_form и с отчётом при del_project). +-- +-- ТРИГГЕРЫ (первые в схеме v3). AFTER DELETE FOR EACH ROW на таблицах строк удаляют формулы +-- удалённой строки, чтобы не править каждую функцию удаления (del_budget_line, +-- sync_form2_ahr_satellite_line, del_form3_line, hard_delete_budget_form, del_project): +-- budget_line -> формулы листов AHR/CAP/OPER/SMETA этой формы по line_id = id строки; +-- rent_detail -> AHR_RENT, utility_detail -> AHR_UTILITY, security_detail -> AHR_SECURITY +-- (line_id = id детали); +-- fixed_asset_report -> OTCH9F этой формы; +-- rf_project_report_line -> формулы строки отчёта проекта. +-- AHR_LIMIT (line_id = limit_template.id, общий для всех форм) построчно не удаляется: +-- формулы уходят каскадом вместе с формой. + +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, + line_id integer NOT NULL, + column_key text NOT NULL, + 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); + +CREATE OR REPLACE FUNCTION 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 +) +LANGUAGE plpgsql +AS $function$ +#variable_conflict use_column +DECLARE + v_change jsonb; + v_line integer; + v_column text; + v_expr text; + v_value numeric; +BEGIN + IF (p_budget_form_id IS NULL) = (p_report_id IS NULL) THEN + RAISE EXCEPTION 'invalid_formula: owner'; + END IF; + + FOR v_change IN SELECT e FROM jsonb_array_elements(coalesce(p_changes, '[]'::jsonb)) AS t(e) + LOOP + BEGIN + v_line := (v_change->>'line_id')::integer; + EXCEPTION WHEN others THEN + RAISE EXCEPTION 'invalid_formula: line_id'; + END; + v_column := v_change->>'column'; + IF v_line IS NULL OR v_column IS NULL THEN + RAISE EXCEPTION 'invalid_formula: line_id'; + END IF; + + v_expr := btrim(coalesce(v_change->>'formula', '')); + IF left(v_expr, 1) = '=' THEN + v_expr := btrim(substr(v_expr, 2)); + END IF; + + IF v_expr = '' THEN + DELETE FROM v3.cell_formula cf + WHERE cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column + AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id + AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id; + CONTINUE; + END IF; + + IF char_length(v_expr) > 500 THEN + RAISE EXCEPTION 'invalid_formula: length'; + END IF; + IF v_expr !~ '^[0-9+\-*/%(),. ]+$' THEN + RAISE EXCEPTION 'invalid_formula: chars'; + END IF; + + BEGIN + v_value := (v_change->>'value')::numeric; + EXCEPTION WHEN others THEN + RAISE EXCEPTION 'invalid_formula: value'; + END; + IF v_value IS NULL THEN + RAISE EXCEPTION 'invalid_formula: value'; + END IF; + + IF p_budget_form_id IS NOT NULL THEN + INSERT INTO v3.cell_formula AS cf + (budget_form_id, sheet, line_id, column_key, expression, value, updated_by, updated_at) + VALUES (p_budget_form_id, p_sheet, v_line, v_column, v_expr, v_value, p_user_id, now()) + ON CONFLICT (budget_form_id, sheet, line_id, column_key) WHERE budget_form_id IS NOT NULL + DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value, + updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; + ELSE + INSERT INTO v3.cell_formula AS cf + (rf_project_report_id, sheet, line_id, column_key, expression, value, updated_by, updated_at) + VALUES (p_report_id, p_sheet, v_line, v_column, v_expr, v_value, p_user_id, now()) + ON CONFLICT (rf_project_report_id, sheet, line_id, column_key) WHERE rf_project_report_id IS NOT NULL + DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value, + updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; + END IF; + + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column + AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id + AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id; + END LOOP; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.v_cell_formulas( + p_budget_form_id integer, + p_report_id integer, + p_sheet varchar +) +RETURNS TABLE( + line_id integer, + column_key text, + expression text, + value numeric, + updated_by integer, + updated_by_name text, + updated_at timestamptz +) +LANGUAGE plpgsql +STABLE +AS $function$ +#variable_conflict use_column +BEGIN + IF (p_budget_form_id IS NULL) = (p_report_id IS NULL) THEN + RAISE EXCEPTION 'invalid_formula: owner'; + END IF; + + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.sheet = p_sheet + AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id + AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id + ORDER BY cf.line_id, cf.column_key; +END; +$function$; + +-- Триггерные функции очистки (см. комментарий в начале файла) +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_budget_line() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula + WHERE budget_form_id = OLD.budget_form_id + AND sheet IN ('AHR', 'CAP', 'OPER', 'SMETA') + AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_rent_detail() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula WHERE sheet = 'AHR_RENT' AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_utility_detail() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula WHERE sheet = 'AHR_UTILITY' AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_security_detail() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula WHERE sheet = 'AHR_SECURITY' AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_fixed_asset_report() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula + WHERE budget_form_id = OLD.budget_form_id AND sheet = 'OTCH9F' AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_rf_project_report_line() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + DELETE FROM v3.cell_formula + WHERE rf_project_report_id = OLD.rf_project_report_id AND line_id = OLD.id; + RETURN NULL; +END; +$function$; + +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.budget_line + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_budget_line(); +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.rent_detail + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_rent_detail(); +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.utility_detail + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_utility_detail(); +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.security_detail + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_security_detail(); +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.fixed_asset_report + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_fixed_asset_report(); +CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.rf_project_report_line + FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_rf_project_report_line(); diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py new file mode 100644 index 0000000..7b6a2c6 --- /dev/null +++ b/api/tests/integration/test_cell_formula_sql.py @@ -0,0 +1,139 @@ +"""SQL-слой формул ячеек (миграция 0059, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады.""" +import json + +import pytest + + +@pytest.fixture +def svod2_sql(client, svod2_sql): + """Как svod2_sql, но с откатом в конце: без запросов к API клиент сам транзакцию не откатывает.""" + yield svod2_sql + client.portal.call(client.db_session.rollback) + + +def _admin(sql): + rows = sql("SELECT id FROM v3.app_user WHERE username = 'admin'") + assert rows, "нет пользователя admin" + return rows[0][0] + + +def _line(sql, form_id=1): + rows = sql("SELECT id FROM v3.budget_line WHERE budget_form_id = :f LIMIT 1", {"f": form_id}) + if not rows: + pytest.skip("в форме нет budget_line") + return rows[0][0] + + +def _set(sql, form_id, sheet, changes, user, report_id=None): + return sql( + "SELECT * FROM v3.set_cell_formulas(:f, :r, :s, CAST(:c AS jsonb), :u)", + {"f": form_id, "r": report_id, "s": sheet, "c": json.dumps(changes), "u": user}, + ) + + +def _list(sql, form_id, sheet, report_id=None): + return sql( + "SELECT * FROM v3.v_cell_formulas(:f, :r, :s)", + {"f": form_id, "r": report_id, "s": sheet}, + ) + + +def _item(line, formula=None, value=150, column="plan.q1", **extra): + d = {"line_id": line, "column": column, "value": value} + if formula is not None: + d["formula"] = formula + d.update(extra) + return d + + +def test_set_formula_upsert_and_list(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + rows = _set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin) + assert len(rows) == 1 + r = rows[0] + assert r.expression == "100+50" + assert float(r.value) == 150 + assert r.updated_by == admin + assert r.updated_by_name is not None + listed = _list(svod2_sql, 1, "AHR") + assert any(x.line_id == line and x.expression == "100+50" for x in listed) + + _set(svod2_sql, 1, "AHR", [_item(line, "200-50")], admin) + mine = [x for x in _list(svod2_sql, 1, "AHR") if x.line_id == line and x.column_key == "plan.q1"] + assert len(mine) == 1 + assert mine[0].expression == "200-50" + + +def test_set_without_formula_deletes(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + _set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin) + rows = _set(svod2_sql, 1, "AHR", [_item(line)], admin) + assert rows == [] + assert [x for x in _list(svod2_sql, 1, "AHR") if x.line_id == line and x.column_key == "plan.q1"] == [] + + +def test_normalization(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + rows = _set(svod2_sql, 1, "AHR", [_item(line, " =100+50 ")], admin) + assert rows[0].expression == "100+50" + rows = _set(svod2_sql, 1, "AHR", [_item(line, "100,5+1", value=101.5)], admin) + assert rows[0].expression == "100,5+1" + + +def test_empty_after_normalization_is_no_formula(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + assert _set(svod2_sql, 1, "AHR", [_item(line, " = ")], admin) == [] + + +@pytest.mark.parametrize( + "formula,value", + [("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc")], +) +def test_invalid_formula_raises(svod2_sql, formula, value): + admin, line = _admin(svod2_sql), _line(svod2_sql) + with pytest.raises(Exception, match="invalid_formula:"): + _set(svod2_sql, 1, "AHR", [_item(line, formula, value=value)], admin) + + +def test_ahr_limit_formula_scoped_to_form(svod2_sql): + admin = _admin(svod2_sql) + forms = svod2_sql("SELECT id FROM v3.budget_form ORDER BY id") + if len(forms) < 2: + pytest.skip("нужны две формы") + tpl = svod2_sql("SELECT id FROM v3.limit_template WHERE row_type = 'LEAF' LIMIT 1") + if not tpl: + pytest.skip("нет limit_template LEAF") + a, b = forms[0][0], forms[1][0] + _set(svod2_sql, a, "AHR_LIMIT", [_item(tpl[0][0], "100+50")], admin) + assert len(_list(svod2_sql, a, "AHR_LIMIT")) == 1 + assert _list(svod2_sql, b, "AHR_LIMIT") == [] + + +def test_trigger_budget_line_delete(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + new_id = 987654321 + svod2_sql( + "INSERT INTO v3.budget_line SELECT * FROM jsonb_populate_record(" + "NULL::v3.budget_line, (SELECT to_jsonb(b) || jsonb_build_object('id', CAST(:n AS integer)) " + "FROM v3.budget_line b WHERE b.id = :l))", + {"n": new_id, "l": line}, + ) + _set(svod2_sql, 1, "AHR", [_item(new_id, "1+1", value=2)], admin) + _set(svod2_sql, 1, "AHR", [_item(line, "3+3", value=6)], admin) + svod2_sql("DELETE FROM v3.budget_line WHERE id = :n", {"n": new_id}) + ids = {x.line_id for x in _list(svod2_sql, 1, "AHR")} + assert new_id not in ids + assert line in ids + + +def test_cascade_hard_delete_form(svod2_sql): + admin, line = _admin(svod2_sql), _line(svod2_sql) + _set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin) + svod2_sql("SELECT v3.hard_delete_budget_form(1)") + assert svod2_sql("SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1")[0][0] == 0 + + +def test_owner_check(svod2_sql): + admin = _admin(svod2_sql) + with pytest.raises(Exception, match="invalid_formula: owner"): + _set(svod2_sql, None, "AHR", [], admin) From 725b994afacff09545aba133b100b5aba9c3821b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:10:23 +0400 Subject: [PATCH 04/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#10=E2=80=93#12=20(AURORA-1896?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 152c897..7b1599f 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -175,3 +175,6 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 | Низкая | From 1d6d95db9f852ac8e98eebd1580693ac5a0c4ffc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:11:08 +0400 Subject: [PATCH 05/25] =?UTF-8?q?feat(formulas):=20CellFormulaRepository,?= =?UTF-8?q?=20invalid=5Fformula=20=E2=86=92=20422,=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/core/CONSTANTS.py | 1 + api/src/domain/schemas.py | 15 ++++ api/src/repository/cell_formula_repository.py | 65 ++++++++++++++ .../unit/test_cell_formula_repository.py | 88 +++++++++++++++++++ api/tests/unit/test_exception_handlers.py | 12 +++ 5 files changed, 181 insertions(+) create mode 100644 api/src/repository/cell_formula_repository.py create mode 100644 api/tests/unit/test_cell_formula_repository.py diff --git a/api/src/core/CONSTANTS.py b/api/src/core/CONSTANTS.py index 3cbaac0..c1baf44 100644 --- a/api/src/core/CONSTANTS.py +++ b/api/src/core/CONSTANTS.py @@ -8,6 +8,7 @@ VALIDATION_PREFIXES = ( "bad_booking_year:", "bad_booking_quarter:", "unsupported_scope:", + "invalid_formula:", ) PG_404_ERRORS = ( diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 2685b36..6364d78 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -394,10 +394,25 @@ class Svod2Facets(BaseModel): org_name: list[Svod2FacetValue] +class UserShort(BaseModel): + id: int + full_name: str | None = None + + +class CellFormulaResponse(BaseModel): + line_id: int + column: str + expression: str + value: float + updated_by: UserShort + updated_at: datetime + + class CellPatch(BaseModel): line_id: int column: str value: Any = None + formula: str | None = None class CellsPatch(BaseModel): diff --git a/api/src/repository/cell_formula_repository.py b/api/src/repository/cell_formula_repository.py new file mode 100644 index 0000000..e169dc9 --- /dev/null +++ b/api/src/repository/cell_formula_repository.py @@ -0,0 +1,65 @@ +import json + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +def _map_row(row) -> dict: + line_id, column_key, expression, value, user_id, user_name, updated_at = tuple(row) + return { + "line_id": line_id, + "column": column_key, + "expression": expression, + "value": float(value), + "updated_by": {"id": user_id, "full_name": user_name}, + "updated_at": updated_at, + } + + +class CellFormulaRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def set_formulas( + self, + *, + budget_form_id: int | None, + report_id: int | None, + sheet: str, + changes: list[dict], + user_id: int, + ) -> list[dict]: + result = await self.db.execute( + text( + """ + SELECT line_id, column_key, expression, value, updated_by, updated_by_name, updated_at + FROM v3.set_cell_formulas(:budget_form_id, :report_id, :sheet, CAST(:changes AS JSONB), :user_id) + """ + ), + { + "budget_form_id": budget_form_id, + "report_id": report_id, + "sheet": sheet, + "changes": json.dumps(changes), + "user_id": user_id, + }, + ) + return [_map_row(r) for r in result.all()] + + async def list_formulas( + self, + *, + budget_form_id: int | None, + report_id: int | None, + sheet: str, + ) -> list[dict]: + result = await self.db.execute( + text( + """ + SELECT line_id, column_key, expression, value, updated_by, updated_by_name, updated_at + FROM v3.v_cell_formulas(:budget_form_id, :report_id, :sheet) + """ + ), + {"budget_form_id": budget_form_id, "report_id": report_id, "sheet": sheet}, + ) + return [_map_row(r) for r in result.all()] diff --git a/api/tests/unit/test_cell_formula_repository.py b/api/tests/unit/test_cell_formula_repository.py new file mode 100644 index 0000000..f2057ae --- /dev/null +++ b/api/tests/unit/test_cell_formula_repository.py @@ -0,0 +1,88 @@ +import json +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.repository.cell_formula_repository import CellFormulaRepository + +DT = datetime(2026, 11, 11, 10, 30, tzinfo=timezone.utc) +ROW = (10, "plan.q1", "100+50", Decimal("150"), 7, "Иванов И.И.", DT) +EXPECTED = { + "line_id": 10, + "column": "plan.q1", + "expression": "100+50", + "value": 150.0, + "updated_by": {"id": 7, "full_name": "Иванов И.И."}, + "updated_at": DT, +} + + +def _result(rows): + result = MagicMock() + result.all.return_value = rows + return result + + +@pytest.fixture +def mock_db(): + return AsyncMock(spec=AsyncSession) + + +@pytest.fixture +def repository(mock_db): + return CellFormulaRepository(mock_db) + + +@pytest.mark.asyncio +async def test_set_formulas_calls_sql_with_json_changes(repository, mock_db): + mock_db.execute.return_value = _result([]) + changes = [{"line_id": 10, "column": "plan.q1", "value": 150, "formula": "=100+50"}] + + await repository.set_formulas( + budget_form_id=1, report_id=None, sheet="AHR", changes=changes, user_id=7 + ) + + mock_db.execute.assert_awaited_once() + stmt, params = mock_db.execute.await_args.args + assert "v3.set_cell_formulas" in str(stmt) + assert params["budget_form_id"] == 1 + assert params["report_id"] is None + assert params["sheet"] == "AHR" + assert params["user_id"] == 7 + assert isinstance(params["changes"], str) + assert json.loads(params["changes"])[0]["formula"] == "=100+50" + + +@pytest.mark.asyncio +async def test_set_formulas_maps_rows(repository, mock_db): + mock_db.execute.return_value = _result([ROW]) + + result = await repository.set_formulas( + budget_form_id=1, report_id=None, sheet="AHR", changes=[], user_id=7 + ) + + assert result == [EXPECTED] + + +@pytest.mark.asyncio +async def test_list_formulas_calls_view(repository, mock_db): + mock_db.execute.return_value = _result([ROW]) + + result = await repository.list_formulas(budget_form_id=None, report_id=5, sheet="AHR") + + stmt, params = mock_db.execute.await_args.args + assert "v3.v_cell_formulas" in str(stmt) + assert params == {"budget_form_id": None, "report_id": 5, "sheet": "AHR"} + assert result == [EXPECTED] + + +@pytest.mark.asyncio +async def test_mapping_handles_missing_user_name(repository, mock_db): + mock_db.execute.return_value = _result([(1, "q", "1+1", Decimal("2"), 3, None, DT)]) + + result = await repository.list_formulas(budget_form_id=1, report_id=None, sheet="CAP") + + assert result[0]["updated_by"] == {"id": 3, "full_name": None} diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index 679a2c2..82c3c4a 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -157,3 +157,15 @@ def test_map_http_exception_not_found_maps_code_from_status(): assert code == status.HTTP_404_NOT_FOUND assert message == "not found" assert field == "" + + +def test_map_sqlalchemy_error_invalid_formula_prefix(): + exc = SQLAlchemyError() + exc.orig = Exception("invalid_formula: expression") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert message == "invalid_formula: expression" + assert field == "expression" From 2bf1aba536e858e716b6bc55ba48c89d460fa6cd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:14:25 +0400 Subject: [PATCH 06/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#13=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 7b1599f..80e3bd3 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -178,3 +178,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 игнорирует лишние ключи | From 5bc6a755bcafed87fcd04085cfd555cb121658fc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:18:44 +0400 Subject: [PATCH 07/25] =?UTF-8?q?feat(formulas):=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D1=8B?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=B8=20GET=20formulas=20=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SheetService.update_cell_with_formula/update_cells пишут формулу после значения в одной транзакции, GET /form/{id}/sheet/{sheet}/formulas. Ошибки БД с обёрткой asyncpg ": invalid_formula: ..." теперь корректно мапятся в 422. Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/forms.py | 28 ++- api/src/core/exception_handlers.py | 17 +- api/src/services/sheet_service.py | 83 ++++++-- .../test_cell_formula_integration.py | 177 ++++++++++++++++++ 4 files changed, 286 insertions(+), 19 deletions(-) create mode 100644 api/tests/integration/test_cell_formula_integration.py diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index cad99dd..9d1323d 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -11,7 +11,7 @@ from src.db.models.form_type import FormTypeEnum from src.db.models.role import UserRoleEnum from src.services.sheet_service import SheetService from src.api.v1.json_response import list_json_response -from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse +from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellFormulaResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse from src.db.models.app_user import AppUser from src.services.budget_form_service import BudgetFormService from src.api.v1.deps import get_current_active_user_with_set_db, require_admin @@ -236,6 +236,7 @@ async def update_cell( line_id=cell_body.line_id, column=cell_body.column, value=cell_body.value, + formula=cell_body.formula, user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 @@ -264,6 +265,26 @@ async def update_cell( +@router.get("/{form_id}/sheet/{sheet}/formulas") +async def get_sheet_formulas( + form_id: int, + sheet: str, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[CellFormulaResponse]: + form = await BudgetFormService(db).get( + user=current_user, + budget_form_id=form_id, + load_form_type=True, + ) + if not form: + raise HTTPException(404, "Форма не найдена") + if sheet not in form.form_type.sheet_list: + raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден") + result = await SheetService(db).list_formulas(form_id=form_id, sheet=sheet) + return BaseListResponse(count=len(result), result=result) + + @router.patch("/{form_id}/sheet/{sheet}/cells") async def update_cells( form_id: int, @@ -298,7 +319,10 @@ async def update_cells( sheet=sheet, direction=direction.value if direction else None, sections=sections, - changes=[c.model_dump() for c in cells_body.changes], + changes=[ + {**c.model_dump(exclude={"formula"}), **({"formula": c.formula} if c.formula is not None else {})} + for c in cells_body.changes + ], user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 6c2cfa2..466712d 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,3 +1,5 @@ +import re + from fastapi import FastAPI, HTTPException, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -28,6 +30,14 @@ def _error_payload(code: int, message: str, field: str = "") -> dict: } +_ASYNCPG_CLASS_PREFIX = re.compile(r"^:\s*") + + +def _strip_driver_class_prefix(message: str) -> str: + """asyncpg-диалект даёт str(orig) вида ": msg"; убираем обёртку.""" + return _ASYNCPG_CLASS_PREFIX.sub("", message) + + def _parse_prefixed_validation_error(message: str) -> str: for prefix in VALIDATION_PREFIXES: if message.lower().startswith(prefix): @@ -51,10 +61,11 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, int, str, str]: message = _first_db_message_line(raw) or "Database error" low = message.lower() - if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): + bare_message = _strip_driver_class_prefix(message) + if any(bare_message.lower().startswith(prefix) for prefix in VALIDATION_PREFIXES): status_code = status.HTTP_422_UNPROCESSABLE_ENTITY - field = _parse_prefixed_validation_error(message) - return status_code, status_code, message, field + field = _parse_prefixed_validation_error(bare_message) + return status_code, status_code, bare_message, field if any(error in low for error in PG_404_ERRORS): status_code = status.HTTP_404_NOT_FOUND return status_code, status_code, message, "" diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index decd1d6..8fb6138 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -8,6 +8,7 @@ from src.repository.form4_project_repository import Form4ProjectRepository from src.repository.user_repository import UserRepository from src.db.models.app_user import AppUser from src.db.models.budget_form import BudgetForm +from src.repository.cell_formula_repository import CellFormulaRepository from src.repository.sheet_repository import SheetRepository, SheetValidationError @@ -16,6 +17,7 @@ class SheetService: def __init__(self, db: AsyncSession): self.db = db self.sheet_repo = SheetRepository(db) + self.formula_repo = CellFormulaRepository(db) self.bf_repo = BudgetFormRepository(db) self.user_repo = UserRepository(db) self.f4_project_repo = Form4ProjectRepository(db) @@ -61,7 +63,7 @@ class SheetService: changes: list[dict], user: AppUser | None = None, ) -> list[tuple]: - return await self.sheet_repo.update_cells( + rows = await self.sheet_repo.update_cells( form_id=form_id, sheet=sheet, direction=direction, @@ -69,28 +71,81 @@ class SheetService: changes=changes, user_id=user.id if user else None, ) - - async def update_cell( - self, - form_id: int, + if user: + await self.formula_repo.set_formulas( + budget_form_id=form_id, + report_id=None, + sheet=sheet, + changes=changes, + user_id=user.id, + ) + return rows + + async def update_cell_with_formula( + self, + form_id: int, sheet: str, direction: str | None, sections: list[str] | None, - line_id: int, - column: str, - value: str | int | float | None = None, + line_id: int, + column: str, + value: str | int | float | None = None, + formula: str | None = None, user: AppUser | None = None, - ) -> list[tuple]: - return await self.sheet_repo.update_cell( - form_id=form_id, + ) -> tuple[list[tuple], dict | None]: + rows = await self.sheet_repo.update_cell( + form_id=form_id, sheet=sheet, direction=direction, sections=sections, - line_id=line_id, - column=column, - value=value, + line_id=line_id, + column=column, + value=value, user_id=user.id if user else None, ) + if not user: + return rows, None + change = {"line_id": line_id, "column": column, "value": value} + if formula is not None: + change["formula"] = formula + saved = await self.formula_repo.set_formulas( + budget_form_id=form_id, + report_id=None, + sheet=sheet, + changes=[change], + user_id=user.id, + ) + return rows, (saved[0] if saved else None) + + async def update_cell( + self, + form_id: int, + sheet: str, + direction: str | None, + sections: list[str] | None, + line_id: int, + column: str, + value: str | int | float | None = None, + formula: str | None = None, + user: AppUser | None = None, + ) -> list[tuple]: + rows, _ = await self.update_cell_with_formula( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + line_id=line_id, + column=column, + value=value, + formula=formula, + user=user, + ) + return rows + + async def list_formulas(self, form_id: int, sheet: str) -> list[dict]: + return await self.formula_repo.list_formulas( + budget_form_id=form_id, report_id=None, sheet=sheet + ) async def add_line( diff --git a/api/tests/integration/test_cell_formula_integration.py b/api/tests/integration/test_cell_formula_integration.py new file mode 100644 index 0000000..50229c9 --- /dev/null +++ b/api/tests/integration/test_cell_formula_integration.py @@ -0,0 +1,177 @@ +"""AURORA-1896: формулы ячеек форм (REST): запись при PATCH и GET formulas.""" +import pytest + +FORM = "/api/v1/form/1" +QS = "?direction=Support§ions=plan,contract_summary" + + +@pytest.fixture +def headers(admin_tokens, auth_headers): + return auth_headers(admin_tokens) + + +def _add_line(client, headers) -> int: + r = client.post( + f"{FORM}/sheet/AHR/line{QS}", + json={"expense_item_id": 120, "direction": "Support"}, + headers=headers, + ) + assert r.status_code == 200, r.text + inputs = [x for x in r.json()["result"] if x.get("row_type") == "INPUT"] + assert len(inputs) == 1 + return inputs[0]["data"]["line_id"] + + +def _patch(client, headers, body, path="cell"): + return client.patch(f"{FORM}/sheet/AHR/{path}{QS}", json=body, headers=headers) + + +def _formulas(client, headers) -> list[dict]: + r = client.get(f"{FORM}/sheet/AHR/formulas", headers=headers) + assert r.status_code == 200, r.text + return r.json()["result"] + + +def _cell_value(client, headers, line_id, column="plan.q1"): + r = client.get(f"{FORM}/sheet/AHR{QS}", headers=headers) + assert r.status_code == 200, r.text + for row in r.json()["result"]: + if row.get("row_type") == "INPUT" and row["data"].get("line_id") == line_id: + node = row["data"] + for part in column.split("."): + node = node.get(part) if isinstance(node, dict) else None + return node + return "ROW_NOT_FOUND" + + +def _line_formulas(client, headers, line_id): + return [f for f in _formulas(client, headers) if f["line_id"] == line_id] + + +def test_patch_cell_with_formula_then_get(client, headers, admin_tokens): + line_id = _add_line(client, headers) + r = _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": " =100+50 "}) + assert r.status_code == 200, r.text + found = _line_formulas(client, headers, line_id) + assert len(found) == 1 + f = found[0] + assert f["column"] == "plan.q1" + assert f["expression"] == "100+50" + assert f["value"] == 150 + me = client.get("/api/v1/auth/me", headers=headers) + if me.status_code == 200: + assert f["updated_by"]["id"] == me.json().get("id", f["updated_by"]["id"]) + assert f["updated_by"]["id"] > 0 + assert f["updated_at"] + + +def test_patch_without_formula_removes(client, headers): + line_id = _add_line(client, headers) + assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": "100+50"}).status_code == 200 + assert len(_line_formulas(client, headers, line_id)) == 1 + assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 200}).status_code == 200 + assert _line_formulas(client, headers, line_id) == [] + + +def test_patch_invalid_formula_422_value_unchanged(client, headers): + before = _cell_value(client, headers, 1) + r = _patch(client, headers, {"line_id": 1, "column": "plan.q1", "value": 777, "formula": "drop table"}) + assert r.status_code == 422, r.text + assert "invalid_formula" in r.text + assert r.json()["field"] == "chars" + # сессия теста откатывается целиком (conftest) — GET ниже видит состояние после отката + assert _cell_value(client, headers, 1) == before + assert _line_formulas(client, headers, 1) == [] + + +@pytest.mark.parametrize( + "line_col,value,formula", + [("plan.q1", 777, "drop table"), ("plan.q1", None, "1+1"), ("header.justification", "текст", "1+1")], + ids=["bad-chars", "null-value", "text-column"], +) +def test_invalid_formula_does_not_write_value(client, headers, line_col, value, formula): + """Атомарность без опоры на откат фикстуры: ошибку ловим внутри SAVEPOINT.""" + from sqlalchemy.exc import DBAPIError + + from src.db.models.app_user import AppUser + from src.services.sheet_service import SheetService + + line_id = _add_line(client, headers) + before = _cell_value(client, headers, line_id, line_col) + + async def _run(): + db = client.db_session + user = await db.get(AppUser, 1) + try: + async with db.begin_nested(): + await SheetService(db).update_cell( + form_id=1, sheet="AHR", direction="Support", sections=["plan", "contract_summary"], + line_id=line_id, column=line_col, value=value, formula=formula, user=user, + ) + except DBAPIError as exc: + return str(exc.orig) + return None + + err = client.portal.call(_run) + assert err is not None and "invalid_formula" in err + assert _cell_value(client, headers, line_id, line_col) == before + assert _line_formulas(client, headers, line_id) == [] + + +def test_patch_formula_into_text_column_422(client, headers): + line_id = _add_line(client, headers) + r = _patch(client, headers, {"line_id": line_id, "column": "header.justification", "value": "текст", "formula": "1+1"}) + assert r.status_code == 422, r.text + assert "invalid_formula" in r.text + assert _line_formulas(client, headers, line_id) == [] + + +def test_patch_formula_null_value_422(client, headers): + line_id = _add_line(client, headers) + r = _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": None, "formula": "1+1"}) + assert r.status_code == 422, r.text + assert "invalid_formula" in r.text + assert _line_formulas(client, headers, line_id) == [] + + +def test_patch_formula_forbidden_403_no_formula(client, headers, isp2_tokens, auth_headers): + # isp2 в оргединице формы 1, но колонку plan.q2 ему править нельзя (role_not_allowed). + # isp1 форму 1 не видит вовсе (404), поэтому для 403 берём isp2. + r = client.patch( + f"{FORM}/sheet/AHR/cell{QS}", + json={"line_id": 1, "column": "plan.q2", "value": 5, "formula": "2+3"}, + headers=auth_headers(isp2_tokens), + ) + assert r.status_code == 403, r.text + assert [f for f in _formulas(client, headers) if f["line_id"] == 1] == [] + + +def test_patch_cells_mixed(client, headers): + l1 = _add_line(client, headers) + l2 = _add_line(client, headers) + r = _patch( + client, + headers, + {"changes": [ + {"line_id": l1, "column": "plan.q1", "value": 3, "formula": "1+2"}, + {"line_id": l2, "column": "plan.q1", "value": 4}, + ]}, + path="cells", + ) + assert r.status_code == 200, r.text + assert [f["expression"] for f in _line_formulas(client, headers, l1)] == ["1+2"] + assert _line_formulas(client, headers, l2) == [] + + +def test_delete_line_removes_formulas(client, headers): + line_id = _add_line(client, headers) + assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 3, "formula": "1+2"}).status_code == 200 + assert len(_line_formulas(client, headers, line_id)) == 1 + r = client.delete(f"{FORM}/sheet/AHR/line/{line_id}?direction=Support", headers=headers) + assert r.status_code == 200, r.text + assert _line_formulas(client, headers, line_id) == [] + + +def test_get_formulas_unknown_form_404(client, headers): + r = client.get("/api/v1/form/999/sheet/AHR/formulas", headers=headers) + assert r.status_code == 404 From f707130ff068ba94556f861408d96831470a05bd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:22:15 +0400 Subject: [PATCH 08/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#14=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 80e3bd3..6c13a04 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -179,3 +179,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 `: ` перед проверкой `VALIDATION_PREFIXES` (R-5); побочно все validation-префиксы на реальной БД дают 422 вместо 400 и заполняют `field` | Без этого `invalid_formula:` не распознавался (спека требует 422); локальная альтернатива дублировала бы маппинг | Клиенты, ожидавшие 400 для `computed_field:`/`unknown_column:` и др.; тесты и фронт 400 не проверяют | From 187988bd76fd53e27b82e9b480f175c452c0b6e4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:28:55 +0400 Subject: [PATCH 09/25] =?UTF-8?q?feat(formulas):=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=83=D0=BB=D1=8B=20=D0=B2=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=B0=D1=85=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20?= =?UTF-8?q?=D0=B8=20GET=20formulas=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH cell/cells отчёта проекта пишут формулу в той же транзакции, GET .../report/{year}/{report_type}/formulas возвращает формулы отчёта. Тесты: roundtrip, снятие формулы, каскад удаления проекта, триггер rf_project_report_line (del_form3_line и прямой DELETE). Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/projects.py | 24 +- api/src/services/project_service.py | 69 +++++- .../test_cell_formula_integration.py | 211 ++++++++++++++++++ 3 files changed, 298 insertions(+), 6 deletions(-) diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 288a56f..92270fa 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -17,6 +17,7 @@ from src.domain.schemas import ( AddProjectBody, BaseListResponse, BaseSingleResponse, + CellFormulaResponse, CellPatch, CellsPatch, ResponseBase, @@ -346,11 +347,26 @@ async def upd_form3_cell( line_id=body.line_id, column=body.column, value=body.value, + formula=body.formula, user=current_user, ) return _rows_to_payload(rows) +@router.get("/projects/{project_id}/report/{year}/{report_type}/formulas") +async def get_form3_formulas( + project_id: int, + year: int, + report_type: str, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[CellFormulaResponse]: + result = await ProjectService(db).list_formulas( + project_id=project_id, year=year, report_type=report_type, user=current_user + ) + return BaseListResponse(count=len(result), result=result) + + @router.patch("/projects/{project_id}/report/{year}/{report_type}/cells") async def upd_form3_cells( project_id: int, @@ -361,11 +377,17 @@ async def upd_form3_cells( current_user: AppUser = Depends(get_current_active_user_with_set_db), ) -> list[SheetResponse]: project_service = ProjectService(db) + changes = [] + for change in body.changes: + item = change.model_dump(exclude={"formula"}) + if change.formula is not None: + item["formula"] = change.formula + changes.append(item) rows = await project_service.upd_form3_cells( project_id=project_id, year=year, report_type=report_type, - changes=[change.model_dump() for change in body.changes], + changes=changes, user=current_user, ) return _rows_to_payload(rows) diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index 8099e8f..8359772 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -5,6 +5,7 @@ from src.db.models.app_user import AppUser from src.db.models.role import UserRoleEnum from src.core.errors import AccessDeniedException, ValidationException from src.domain.schemas import placement_type_to_db +from src.repository.cell_formula_repository import CellFormulaRepository from src.repository.project_repository import ProjectRepository from src.repository.user_repository import UserRepository @@ -14,6 +15,7 @@ class ProjectService: self.db = db self.project_repo = ProjectRepository(db) self.user_repo = UserRepository(db) + self.formula_repo = CellFormulaRepository(db) async def get_list( self, @@ -282,6 +284,33 @@ class ProjectService: project_type=project_type, ) + async def upd_form3_cell_with_formula( + self, + project_id: int, + year: int, + report_type: str, + line_id: int, + column: str, + value, + formula: str | None, + user: AppUser, + ) -> tuple[list[tuple], dict | None]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + rows = await self.project_repo.upd_form3_cell(report_id, line_id, column, value) + change = {"line_id": line_id, "column": column, "value": value} + if formula is not None: + change["formula"] = formula + saved = await self.formula_repo.set_formulas( + budget_form_id=None, + report_id=report_id, + sheet=report_type.upper(), + changes=[change], + user_id=user.id, + ) + return rows, (saved[0] if saved else None) + async def upd_form3_cell( self, project_id: int, @@ -291,11 +320,19 @@ class ProjectService: column: str, value, user: AppUser, + formula: str | None = None, ) -> list[tuple]: - report_id = await self.resolve_report_id(project_id, year, report_type, user) - if not report_id: - raise ValidationException("Отчёт не найден") - return await self.project_repo.upd_form3_cell(report_id, line_id, column, value) + rows, _ = await self.upd_form3_cell_with_formula( + project_id=project_id, + year=year, + report_type=report_type, + line_id=line_id, + column=column, + value=value, + formula=formula, + user=user, + ) + return rows async def upd_form3_cells( self, @@ -308,7 +345,29 @@ class ProjectService: report_id = await self.resolve_report_id(project_id, year, report_type, user) if not report_id: raise ValidationException("Отчёт не найден") - return await self.project_repo.upd_form3_cells(report_id, changes) + rows = await self.project_repo.upd_form3_cells(report_id, changes) + await self.formula_repo.set_formulas( + budget_form_id=None, + report_id=report_id, + sheet=report_type.upper(), + changes=changes, + user_id=user.id, + ) + return rows + + async def list_formulas( + self, + project_id: int, + year: int, + report_type: str, + user: AppUser, + ) -> list[dict]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.formula_repo.list_formulas( + budget_form_id=None, report_id=report_id, sheet=report_type.upper() + ) async def add_form3_line( self, diff --git a/api/tests/integration/test_cell_formula_integration.py b/api/tests/integration/test_cell_formula_integration.py index 50229c9..baee100 100644 --- a/api/tests/integration/test_cell_formula_integration.py +++ b/api/tests/integration/test_cell_formula_integration.py @@ -175,3 +175,214 @@ def test_delete_line_removes_formulas(client, headers): def test_get_formulas_unknown_form_404(client, headers): r = client.get("/api/v1/form/999/sheet/AHR/formulas", headers=headers) assert r.status_code == 404 + + +# ---- Отчёты проектов (FORM_3) ---- + +import uuid +from datetime import datetime + +from sqlalchemy import text + + +def _create_project(client, headers, year) -> int: + r = client.post( + "/api/v1/projects", + json={ + "name": f"PF_{uuid.uuid4().hex[:10]}", + "year": year, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, + headers=headers, + ) + assert r.status_code == 200, r.text + return r.json()["project_id"] + + +def _add_project_line(client, headers, project_id, year, report_type="LIMIT", nth=0) -> int: + """Строки отчёта создаются вместе с проектом: берём существующую nth-ю строку отчёта.""" + async def _run(): + res = await client.db_session.execute( + text( + "SELECT l.id FROM v3.rf_project_report_line l " + "JOIN v3.rf_project_report r ON r.id = l.rf_project_report_id " + "JOIN v3.rf_project_year py ON py.id = r.rf_project_year_id " + "WHERE r.project_id = :pid AND r.report_type = :rt AND py.year = :y " + "ORDER BY l.id LIMIT 1 OFFSET :n" + ), + {"pid": project_id, "rt": report_type, "y": year, "n": nth}, + ) + return res.scalar() + + line_id = client.portal.call(_run) + assert line_id, "у нового проекта нет строк отчёта" + return int(line_id) + + +def _report_url(project_id, year, report_type="LIMIT"): + return f"/api/v1/projects/{project_id}/report/{year}/{report_type}" + + +def _project_formulas(client, headers, project_id, year, report_type="LIMIT") -> list[dict]: + r = client.get(f"{_report_url(project_id, year, report_type)}/formulas", headers=headers) + assert r.status_code == 200, r.text + return r.json()["result"] + + +def _scalar(client, sql, params): + async def _run(): + return (await client.db_session.execute(text(sql), params)).scalar() + + return client.portal.call(_run) + + +def test_project_report_formula_roundtrip(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + r = client.patch( + f"{_report_url(pid, year)}/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 150, "formula": " =100+50 "}, + headers=headers, + ) + assert r.status_code == 200, r.text + found = _project_formulas(client, headers, pid, year) + assert len(found) == 1 + assert found[0]["line_id"] == line_id + assert found[0]["column"] == "q1.m1" + assert found[0]["expression"] == "100+50" + assert found[0]["value"] == 150 + assert found[0]["updated_by"]["id"] > 0 + # нижний регистр report_type в пути + assert _project_formulas(client, headers, pid, year, "limit") == found + # другой тип отчёта формулу не видит + assert _project_formulas(client, headers, pid, year, "CURRENT_EXPENSES") == [] + + +def test_project_report_formula_removed_without_formula(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + url = f"{_report_url(pid, year)}/cell" + assert client.patch(url, json={"line_id": line_id, "column": "q1.m1", "value": 150, "formula": "100+50"}, headers=headers).status_code == 200 + assert len(_project_formulas(client, headers, pid, year)) == 1 + assert client.patch(url, json={"line_id": line_id, "column": "q1.m1", "value": 200}, headers=headers).status_code == 200 + assert _project_formulas(client, headers, pid, year) == [] + + +def test_project_report_cells_formulas(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + r = client.patch( + f"{_report_url(pid, year)}/cells", + json={"changes": [ + {"line_id": line_id, "column": "q1.m1", "value": 3, "formula": "1+2"}, + {"line_id": line_id, "column": "q1.m2", "value": 5}, + ]}, + headers=headers, + ) + assert r.status_code == 200, r.text + found = _project_formulas(client, headers, pid, year) + assert [(f["column"], f["expression"]) for f in found] == [("q1.m1", "1+2")] + + +def test_project_report_invalid_formula_422(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + r = client.patch( + f"{_report_url(pid, year)}/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 5, "formula": "drop table"}, + headers=headers, + ) + assert r.status_code == 422, r.text + assert "invalid_formula" in r.text + + +def test_project_formulas_report_not_found(client, headers): + pid = _create_project(client, headers, datetime.now().year) + r = client.get(f"{_report_url(pid, 2099)}/formulas", headers=headers) + assert r.status_code == 400 + assert r.json().get("message") == "Отчёт не найден" + + +def test_project_delete_cascades_formulas(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + assert client.patch( + f"{_report_url(pid, year)}/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 3, "formula": "1+2"}, + headers=headers, + ).status_code == 200 + count_sql = ( + "SELECT count(*) FROM v3.cell_formula WHERE rf_project_report_id IN " + "(SELECT id FROM v3.rf_project_report WHERE project_id = :pid)" + ) + assert _scalar(client, count_sql, {"pid": pid}) == 1 + report_ids = client.portal.call( + lambda: _report_ids(client, pid) + ) + r = client.delete(f"/api/v1/projects/{pid}", headers=headers) + assert r.status_code == 200, r.text + assert report_ids + cnt = _scalar( + client, + "SELECT count(*) FROM v3.cell_formula WHERE rf_project_report_id = ANY(:ids)", + {"ids": report_ids}, + ) + assert cnt == 0 + + +async def _report_ids(client, pid): + res = await client.db_session.execute( + text("SELECT id FROM v3.rf_project_report WHERE project_id = :pid"), {"pid": pid} + ) + return [row[0] for row in res.all()] + + +def test_del_form3_line_removes_formula(client, headers): + year = datetime.now().year + pid = _create_project(client, headers, year) + line_id = _add_project_line(client, headers, pid, year) + assert client.patch( + f"{_report_url(pid, year)}/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 3, "formula": "1+2"}, + headers=headers, + ).status_code == 200 + assert len(_project_formulas(client, headers, pid, year)) == 1 + r = client.delete(f"{_report_url(pid, year)}/line/{line_id}", headers=headers) + assert r.status_code == 200, r.text + assert _project_formulas(client, headers, pid, year) == [] + + +def test_rf_project_report_line_trigger_direct_delete(client, headers): + """Триггер на rf_project_report_line: прямой DELETE строки чистит формулу + только этой строки (формула соседней строки того же отчёта остаётся).""" + year = datetime.now().year + pid = _create_project(client, headers, year) + l1 = _add_project_line(client, headers, pid, year) + l2 = _add_project_line(client, headers, pid, year, nth=1) + other = [l2] + for line in [l1] + other[:1]: + assert client.patch( + f"{_report_url(pid, year)}/cell", + json={"line_id": line, "column": "q1.m1", "value": 3, "formula": "1+2"}, + headers=headers, + ).status_code == 200 + + async def _delete_direct(): + db = client.db_session + await db.execute(text("DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = :l"), {"l": l1}) + await db.execute(text("DELETE FROM v3.rf_project_report_line WHERE id = :l"), {"l": l1}) + + client.portal.call(_delete_direct) + left = {f["line_id"] for f in _project_formulas(client, headers, pid, year)} + assert l1 not in left + if other: + assert other[0] in left From 17e7d46859a24d2e2c2c780d506d811fc7157a4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:33:44 +0400 Subject: [PATCH 10/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#15=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 6c13a04..c76d299 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -180,3 +180,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 `: ` перед проверкой `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 | Фикстуры/поведение существующих эндпоинтов | Низкая: только тесты/коды ошибок | From 84dbaaf361f565f642cfeaca54e5795e25d738ee Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:36:09 +0400 Subject: [PATCH 11/25] =?UTF-8?q?test(formulas):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20minor-=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20T1.1=E2=80=93T4.2=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1.1 каскад hard_delete проверяется по AHR_LIMIT; T1.2 тесты триггеров rent/utility/security/fixed_asset_report; T1.3 NaN в set_cell_formulas отклоняется (0059 правится на месте); T3.1 unit-кейсы asyncpg-обёртки; T3.2 предусловие SAVEPOINT-теста и комментарий к 403; T4.1 проверка незаписи значения при 422 отчёта проекта; T4.2 убрана мёртвая логика. Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0059_cell_formula.sql | 2 +- .../test_cell_formula_integration.py | 97 ++++++++++++++++--- .../integration/test_cell_formula_sql.py | 56 ++++++++++- api/tests/unit/test_exception_handlers.py | 22 +++++ 4 files changed, 163 insertions(+), 14 deletions(-) diff --git a/api/alembic/versions/sql/0059_cell_formula.sql b/api/alembic/versions/sql/0059_cell_formula.sql index 3f0c30d..6cac185 100644 --- a/api/alembic/versions/sql/0059_cell_formula.sql +++ b/api/alembic/versions/sql/0059_cell_formula.sql @@ -106,7 +106,7 @@ BEGIN EXCEPTION WHEN others THEN RAISE EXCEPTION 'invalid_formula: value'; END; - IF v_value IS NULL THEN + IF v_value IS NULL OR v_value = 'NaN'::numeric THEN RAISE EXCEPTION 'invalid_formula: value'; END IF; diff --git a/api/tests/integration/test_cell_formula_integration.py b/api/tests/integration/test_cell_formula_integration.py index baee100..4dfe13e 100644 --- a/api/tests/integration/test_cell_formula_integration.py +++ b/api/tests/integration/test_cell_formula_integration.py @@ -97,24 +97,51 @@ def test_invalid_formula_does_not_write_value(client, headers, line_col, value, from src.services.sheet_service import SheetService line_id = _add_line(client, headers) - before = _cell_value(client, headers, line_id, line_col) + # исходное значение отличается от пробного, иначе «значение не изменилось» ничего не доказывает + def _read(): + if line_col == "header.justification": # текстовой колонки нет в JSON листа: читаем из БД + return _scalar(client, "SELECT justification FROM v3.budget_line WHERE id = :l", {"l": line_id}) + return _cell_value(client, headers, line_id, line_col) - async def _run(): + seed = "исходный" if line_col.startswith("header.") else 5 + seed_r = _patch(client, headers, {"line_id": line_id, "column": line_col, "value": seed}) + assert seed_r.status_code == 200, seed_r.text + before = _read() + assert before == seed and before != value + + from src.db.models.app_user import AppUser + from src.services.sheet_service import SheetService + + async def _update(formula): db = client.db_session user = await db.get(AppUser, 1) + await SheetService(db).update_cell( + form_id=1, sheet="AHR", direction="Support", sections=["plan", "contract_summary"], + line_id=line_id, column=line_col, value=value, formula=formula, user=user, + ) + + async def _precondition(): + # тот же вызов без формулы пишет значение: после отката SAVEPOINT его снова нет + nested = await client.db_session.begin_nested() + await _update(None) + return nested + + nested = client.portal.call(_precondition) + assert _read() != before, "без формулы значение должно записаться" + client.portal.call(nested.rollback) + assert _read() == before + + async def _run(): try: - async with db.begin_nested(): - await SheetService(db).update_cell( - form_id=1, sheet="AHR", direction="Support", sections=["plan", "contract_summary"], - line_id=line_id, column=line_col, value=value, formula=formula, user=user, - ) + async with client.db_session.begin_nested(): + await _update(formula) except DBAPIError as exc: return str(exc.orig) return None err = client.portal.call(_run) assert err is not None and "invalid_formula" in err - assert _cell_value(client, headers, line_id, line_col) == before + assert _read() == before assert _line_formulas(client, headers, line_id) == [] @@ -137,6 +164,8 @@ def test_patch_formula_null_value_422(client, headers): def test_patch_formula_forbidden_403_no_formula(client, headers, isp2_tokens, auth_headers): # isp2 в оргединице формы 1, но колонку plan.q2 ему править нельзя (role_not_allowed). # isp1 форму 1 не видит вовсе (404), поэтому для 403 берём isp2. + # Опирается на порядок записи (проверка прав до записи формулы) и откат сессии фикстурой: + # если бы формула успела записаться, GET ниже увидел бы её в той же сессии. r = client.patch( f"{FORM}/sheet/AHR/cell{QS}", json={"line_id": 1, "column": "plan.q2", "value": 5, "formula": "2+3"}, @@ -295,6 +324,52 @@ def test_project_report_invalid_formula_422(client, headers): year = datetime.now().year pid = _create_project(client, headers, year) line_id = _add_project_line(client, headers, pid, year) + qsql = "SELECT actual_m1 FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = :l AND quarter = 1" + before = _scalar(client, qsql, {"l": line_id}) + # HTTP-ошибка откатывает всю сессию теста вместе с созданным проектом, поэтому «значение не записано» + # проверяем до неё: тот же вызов сервиса внутри SAVEPOINT (с предусловием: без формулы значение пишется). + from sqlalchemy.exc import DBAPIError + + from src.db.models.app_user import AppUser + from src.services.project_service import ProjectService + + async def _call(formula): + db = client.db_session + user = await db.get(AppUser, 1) + await ProjectService(db).upd_form3_cell( + project_id=pid, year=year, report_type="LIMIT", line_id=line_id, + column="q1.m1", value=5, user=user, formula=formula, + ) + + async def _precondition(): + nested = await client.db_session.begin_nested() + await _call(None) + return nested + + nested = client.portal.call(_precondition) + assert _scalar(client, qsql, {"l": line_id}) == 5, "без формулы значение должно записаться" + client.portal.call(nested.rollback) + assert _scalar(client, qsql, {"l": line_id}) == before + + async def _run(): + try: + async with client.db_session.begin_nested(): + await _call("drop table") + except DBAPIError as exc: + return str(exc.orig) + return None + + err = client.portal.call(_run) + assert err is not None and "invalid_formula" in err + assert _scalar(client, qsql, {"l": line_id}) == before + assert _scalar( + client, + "SELECT count(*) FROM v3.cell_formula WHERE rf_project_report_id IN " + "(SELECT id FROM v3.rf_project_report WHERE project_id = :pid)", + {"pid": pid}, + ) == 0 + + # а контракт REST: 422 (после него сессия откатывается, проверять БД дальше нельзя) r = client.patch( f"{_report_url(pid, year)}/cell", json={"line_id": line_id, "column": "q1.m1", "value": 5, "formula": "drop table"}, @@ -368,8 +443,7 @@ def test_rf_project_report_line_trigger_direct_delete(client, headers): pid = _create_project(client, headers, year) l1 = _add_project_line(client, headers, pid, year) l2 = _add_project_line(client, headers, pid, year, nth=1) - other = [l2] - for line in [l1] + other[:1]: + for line in (l1, l2): assert client.patch( f"{_report_url(pid, year)}/cell", json={"line_id": line, "column": "q1.m1", "value": 3, "formula": "1+2"}, @@ -384,5 +458,4 @@ def test_rf_project_report_line_trigger_direct_delete(client, headers): client.portal.call(_delete_direct) left = {f["line_id"] for f in _project_formulas(client, headers, pid, year)} assert l1 not in left - if other: - assert other[0] in left + assert l2 in left diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py index 7b6a2c6..fd24c7e 100644 --- a/api/tests/integration/test_cell_formula_sql.py +++ b/api/tests/integration/test_cell_formula_sql.py @@ -87,7 +87,7 @@ def test_empty_after_normalization_is_no_formula(svod2_sql): @pytest.mark.parametrize( "formula,value", - [("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc")], + [("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc"), ("1+1", "NaN")], ) def test_invalid_formula_raises(svod2_sql, formula, value): admin, line = _admin(svod2_sql), _line(svod2_sql) @@ -127,12 +127,66 @@ def test_trigger_budget_line_delete(svod2_sql): def test_cascade_hard_delete_form(svod2_sql): + """Формула AHR_LIMIT (триггеры её не трогают) уходит только каскадом FK при hard_delete_budget_form; + формула AHR на budget_line может быть снята и триггером, поэтому каскад проверяем по AHR_LIMIT.""" admin, line = _admin(svod2_sql), _line(svod2_sql) + tpl = svod2_sql("SELECT id FROM v3.limit_template WHERE row_type = 'LEAF' LIMIT 1") + if not tpl: + pytest.skip("нет limit_template LEAF") _set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin) + _set(svod2_sql, 1, "AHR_LIMIT", [_item(tpl[0][0], "100+50")], admin) + assert len(_list(svod2_sql, 1, "AHR_LIMIT")) == 1 svod2_sql("SELECT v3.hard_delete_budget_form(1)") + assert svod2_sql("SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1 AND sheet = 'AHR_LIMIT'")[0][0] == 0 assert svod2_sql("SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1")[0][0] == 0 +def _vsp(sql): + rows = sql("SELECT id FROM v3.vsp ORDER BY id LIMIT 1") + if not rows: + pytest.skip("нет vsp") + return rows[0][0] + + +@pytest.mark.parametrize( + "table,sheet", + [("rent_detail", "AHR_RENT"), ("utility_detail", "AHR_UTILITY"), ("security_detail", "AHR_SECURITY")], +) +def test_trigger_detail_delete(svod2_sql, table, sheet): + """Глобальные id (D4): DELETE строки detail-таблицы чистит формулу только этой строки.""" + admin, line, vsp = _admin(svod2_sql), _line(svod2_sql), _vsp(svod2_sql) + ids = [ + svod2_sql( + f"INSERT INTO v3.{table} (line_id, vsp_id) VALUES (:l, :v) RETURNING id", + {"l": line, "v": vsp}, + )[0][0] + for _ in range(2) + ] + for i in ids: + _set(svod2_sql, 1, sheet, [_item(i, "1+1", value=2)], admin) + svod2_sql(f"DELETE FROM v3.{table} WHERE id = :i", {"i": ids[0]}) + left = {x.line_id for x in _list(svod2_sql, 1, sheet)} + assert ids[0] not in left + assert ids[1] in left + + +def test_trigger_fixed_asset_report_delete(svod2_sql): + admin = _admin(svod2_sql) + ids = [ + svod2_sql( + "INSERT INTO v3.fixed_asset_report (budget_form_id, month) VALUES (1, :m) RETURNING id", + {"m": m}, + )[0][0] + for m in (1, 2) + ] + for i in ids: + _set(svod2_sql, 1, "OTCH9F", [_item(i, "1+1", value=2)], admin) + svod2_sql("DELETE FROM v3.fixed_asset_report WHERE id = :i", {"i": ids[0]}) + left = {x.line_id for x in _list(svod2_sql, 1, "OTCH9F")} + assert ids[0] not in left + assert ids[1] in left + + def test_owner_check(svod2_sql): admin = _admin(svod2_sql) with pytest.raises(Exception, match="invalid_formula: owner"): diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index 82c3c4a..703e717 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -50,6 +50,28 @@ def test_map_sqlalchemy_error_validation_prefix(): assert field == "q5.plan" +def test_map_sqlalchemy_error_validation_prefix_asyncpg_wrapped(): + exc = SQLAlchemyError() + exc.orig = Exception(": invalid_formula: chars") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert message == "invalid_formula: chars" + assert "asyncpg" not in message + assert field == "chars" + + +def test_map_sqlalchemy_error_asyncpg_wrapped_non_validation_keeps_message(): + exc = SQLAlchemyError() + exc.orig = Exception(": something else") + + status_code, _, message, _ = map_sqlalchemy_error(exc) + + assert status_code != status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_map_sqlalchemy_error_not_found(): exc = SQLAlchemyError() exc.orig = Exception("budget_form #99 не существует") From c1e21e7cd6338653f5fb4252f63ad4f229b84ac6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:38:24 +0400 Subject: [PATCH 12/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#16=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index c76d299..fc32b2c 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -181,3 +181,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 `: ` перед проверкой `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, тесты зелёные | Регрессия обнаружится на шаг позже | From af687a7e229ece10729ca56f74850396f58a551d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:40:40 +0400 Subject: [PATCH 13/25] =?UTF-8?q?feat(formulas):=20formula=20=D0=B8=20form?= =?UTF-8?q?ula=5Fmeta=20=D0=B2=20cell=5Fupdated=20=D0=BF=D0=BE=20WebSocket?= =?UTF-8?q?=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/websocket.py | 24 +++- .../test_cell_formula_ws_integration.py | 118 ++++++++++++++++++ .../unit/test_websocket_connection_cache.py | 6 +- 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 api/tests/integration/test_cell_formula_ws_integration.py diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index c0e3122..360f9d5 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -411,6 +411,20 @@ class ConnectionCache: self._forms[form_id] = self._clock() +def _apply_saved_formula(event_data: dict, formula: dict | None) -> None: + """Перезаписывает formula/formula_meta в данных рассылки тем, что сохранил сервер.""" + if formula: + updated_at = formula["updated_at"] + event_data["formula"] = formula["expression"] + event_data["formula_meta"] = { + "updated_by": formula["updated_by"], + "updated_at": updated_at.isoformat() if hasattr(updated_at, "isoformat") else updated_at, + } + else: + event_data["formula"] = None + event_data["formula_meta"] = None + + class FormEventProcess: def __init__(self, db: AsyncSession): self.sheet_service: SheetService = SheetService(db) @@ -527,7 +541,7 @@ class FormEventProcess: if not await self._check_form_access(form_id=form_id, user=user): return None - return await self.sheet_service.update_cell( + rows, formula = await self.sheet_service.update_cell_with_formula( form_id=form_id, sheet=sheet, direction=direction, @@ -535,8 +549,11 @@ class FormEventProcess: line_id=event_data["line_id"], column=event_data["column"], value=event_data["value"], + formula=event_data.get("formula"), user=user, ) + _apply_saved_formula(event_data, formula) + return rows async def __add_row( self, @@ -761,15 +778,18 @@ class ProjectEventProcess: if not project: return None - return await self.project_service.upd_form3_cell( + rows, formula = await self.project_service.upd_form3_cell_with_formula( project_id=project_id, year=year, report_type=report_type, line_id=event_data["line_id"], column=event_data["column"], value=event_data["value"], + formula=event_data.get("formula"), user=user, ) + _apply_saved_formula(event_data, formula) + return rows async def __add_row( self, diff --git a/api/tests/integration/test_cell_formula_ws_integration.py b/api/tests/integration/test_cell_formula_ws_integration.py new file mode 100644 index 0000000..e4cfec0 --- /dev/null +++ b/api/tests/integration/test_cell_formula_ws_integration.py @@ -0,0 +1,118 @@ +"""AURORA-1896: formula и formula_meta в cell_updated по WebSocket (лист формы).""" +import asyncio + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import NullPool + +from src.core.config import settings + +WS_URL = "/api/v1/ws/form/1/sheet/AHR?direction=Support" +CELL = {"line_id": 1, "column": "plan.q1"} + + +def _roundtrip(ws, event: str, data: dict) -> dict: + ws.send_json({"event": event, "data": data}) + return ws.receive_json() + + +def _db_scalar(sql: str, commit: bool = False): + """Отдельная сессия на собственном движке (WS коммитит в БД).""" + async def run(): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + try: + async with engine.connect() as conn: + res = await conn.execute(text(sql)) + row = res.first() if res.returns_rows else None + if commit: + await conn.commit() + return row[0] if row else None + finally: + await engine.dispose() + + return asyncio.run(run()) + + +def _cleanup_formula(): + _db_scalar( + "DELETE FROM v3.cell_formula WHERE budget_form_id = 1 " + "AND line_id = 1 AND column_key = 'plan.q1'", + commit=True, + ) + + +def _edit(ws, data: dict) -> dict: + assert not _roundtrip(ws, "cell_edit_start", CELL).get("error") + updated = _roundtrip(ws, "cell_updated", {**CELL, **data}) + _roundtrip(ws, "cell_edit_end", CELL) + return updated + + +def test_ws_cell_updated_carries_saved_formula(client, admin_tokens): + with client.websocket_connect(WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + try: + updated = _edit(ws, {"value": 150, "formula": "100+50"}) + assert not updated.get("error"), updated + assert updated["data"]["formula"] == "100+50" + meta = updated["data"]["formula_meta"] + assert isinstance(meta["updated_by"]["id"], int) + assert "full_name" in meta["updated_by"] + assert isinstance(meta["updated_at"], str) + + plain = _edit(ws, {"value": 7}) + assert not plain.get("error"), plain + assert plain["data"]["formula"] is None + assert plain["data"]["formula_meta"] is None + finally: + _roundtrip(ws, "cell_edit_start", CELL) + _roundtrip(ws, "cell_updated", {**CELL, "value": None}) + _roundtrip(ws, "cell_edit_end", CELL) + _cleanup_formula() + + +def test_ws_broadcasts_normalized_formula_not_client_string(client, admin_tokens): + with client.websocket_connect(WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + try: + updated = _edit(ws, {"value": 150, "formula": " =100+50 "}) + assert not updated.get("error"), updated + assert updated["data"]["formula"] == "100+50" + stored = _db_scalar( + "SELECT expression FROM v3.cell_formula WHERE budget_form_id = 1 " + "AND line_id = 1 AND column_key = 'plan.q1'" + ) + assert stored == "100+50" + finally: + _roundtrip(ws, "cell_edit_start", CELL) + _roundtrip(ws, "cell_updated", {**CELL, "value": None}) + _roundtrip(ws, "cell_edit_end", CELL) + _cleanup_formula() + + +def test_ws_invalid_formula_returns_error_and_does_not_commit(client, admin_tokens): + with client.websocket_connect(WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + try: + before = _db_scalar( + "SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1 " + "AND line_id = 1 AND column_key = 'plan.q1'" + ) + updated = _edit(ws, {"value": 999, "formula": "abc+1"}) + assert updated.get("error"), updated + assert updated["error"]["code"] == 422 + assert updated["error"]["message"].startswith("invalid_formula") + assert "result" not in updated + after = _db_scalar( + "SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1 " + "AND line_id = 1 AND column_key = 'plan.q1'" + ) + assert after == before + # значение не закоммичено: следующий успешный ответ не видит 999 + ok = _edit(ws, {"value": 5}) + assert not ok.get("error"), ok + finally: + _roundtrip(ws, "cell_edit_start", CELL) + _roundtrip(ws, "cell_updated", {**CELL, "value": None}) + _roundtrip(ws, "cell_edit_end", CELL) + _cleanup_formula() diff --git a/api/tests/unit/test_websocket_connection_cache.py b/api/tests/unit/test_websocket_connection_cache.py index d220c80..a4bfd58 100644 --- a/api/tests/unit/test_websocket_connection_cache.py +++ b/api/tests/unit/test_websocket_connection_cache.py @@ -11,7 +11,7 @@ def _processor(user=None, form=object()): proc = FormEventProcess(MagicMock()) proc.user_service = MagicMock(get=AsyncMock(return_value=user or SimpleNamespace(id=7, role_id=2))) proc.bf_service = MagicMock(get=AsyncMock(return_value=form)) - proc.sheet_service = MagicMock(update_cell=AsyncMock(return_value=[])) + proc.sheet_service = MagicMock(update_cell_with_formula=AsyncMock(return_value=([], None))) return proc @@ -30,7 +30,7 @@ async def test_cache_reads_user_and_form_once_per_connection(): await _send(proc, cache, 5) assert proc.user_service.get.await_count == 1 assert proc.bf_service.get.await_count == 1 - assert proc.sheet_service.update_cell.await_count == 5 + assert proc.sheet_service.update_cell_with_formula.await_count == 5 @pytest.mark.asyncio @@ -61,7 +61,7 @@ async def test_missing_form_is_not_cached(): res = await proc.process(event_data=EVENT, form_id=5, user_id=7, sheet="S", cache=cache) assert res is None assert proc.bf_service.get.await_count == 3 - proc.sheet_service.update_cell.assert_not_awaited() + proc.sheet_service.update_cell_with_formula.assert_not_awaited() @pytest.mark.asyncio From 6ba677cd399f3f1f4e9a47f412110cf836d86704 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:47:52 +0400 Subject: [PATCH 14/25] =?UTF-8?q?feat(formulas):=20=D1=83=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8B=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=20=D0=B8=20API=20=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D1=83?= =?UTF-8?q?=D0=BB=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/form_sheet.js | 3 + web/src/api/projects.js | 3 + .../RealtimeTable/utils/cellFormulaUtils.js | 75 ++++++++++++ .../utils/cellFormulaUtils.test.js | 110 ++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 web/src/components/RealtimeTable/utils/cellFormulaUtils.js create mode 100644 web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js diff --git a/web/src/api/form_sheet.js b/web/src/api/form_sheet.js index 7ac4ecd..c048a29 100644 --- a/web/src/api/form_sheet.js +++ b/web/src/api/form_sheet.js @@ -4,6 +4,9 @@ export const FormsSheetApi = { get(formId, sheetName, params) { return api.get(`/form/${formId}/sheet/${sheetName}`, params).then((r) => r.data); }, + getFormulas(formId, sheetName) { + return api.get(`/form/${formId}/sheet/${encodeURIComponent(sheetName)}/formulas`).then((r) => r.data.result); + }, export(formId, sheetName, params = {}) { return api.get(`export/form/${formId}/sheet/${sheetName}`, { params, diff --git a/web/src/api/projects.js b/web/src/api/projects.js index e7c8ca8..cbbdc2f 100644 --- a/web/src/api/projects.js +++ b/web/src/api/projects.js @@ -35,6 +35,9 @@ export const ProjectsApi = { getTableData: (id, sheetName, year) => { return api.get(`/projects/${id}/report/${year}/${sheetName}`).then((r) => r.data); }, + getReportFormulas: (id, year, reportType) => { + return api.get(`/projects/${id}/report/${year}/${reportType}/formulas`).then((r) => r.data.result); + }, getSummary: (id) => { return api.get(`/projects/${id}/summary`).then((r) => r.data); }, diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js new file mode 100644 index 0000000..0964c8d --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js @@ -0,0 +1,75 @@ +// AURORA-1896: чистые утилиты карты формул ячеек (без React/MUI/constants.jsx). + +export const formulaKey = (lineId, column) => `${lineId}|${column}`; + +export const buildFormulaMap = (list) => { + const map = new Map(); + for (const item of list ?? []) { + map.set(formulaKey(item.line_id, item.column), item); + } + return map; +}; + +// Возвращает новый Map; вход не мутируется. Фоновые события без ячейки +// (синхронизация сателлитов: data:{source_sheet}) карту не меняют. +export const applyFormulaEvent = (map, data) => { + if (!data || data.line_id == null || data.column == null) return map; + const next = new Map(map); + const key = formulaKey(data.line_id, data.column); + if (typeof data.formula === 'string' && data.formula) { + next.set(key, { + line_id: data.line_id, + column: data.column, + expression: data.formula, + value: Number(data.value), + updated_by: data.formula_meta?.updated_by, + updated_at: data.formula_meta?.updated_at, + }); + } else { + next.delete(key); + } + return next; +}; + +const toFiniteNumber = (v) => { + if (v == null || (typeof v === 'string' && v.trim() === '')) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +export const isFormulaActual = (formula, value) => { + if (!formula) return false; + const a = toFiniteNumber(formula.value); + const b = toFiniteNumber(value); + if (a === null || b === null) return false; + return Math.abs(a - b) <= 1e-9; +}; + +const pad = (n) => String(n).padStart(2, '0'); + +export const formatFormulaHint = (formula) => { + const author = formula.updated_by; + const name = author?.full_name || `Пользователь #${author?.id}`; + const parts = [`=${formula.expression}`, name]; + if (formula.updated_at) { + const d = new Date(formula.updated_at); + if (!Number.isNaN(d.getTime())) { + parts.push(`${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`); + } + } + return parts.join(' · '); +}; + +export const clearLegacyFormulaStorage = (storage) => { + try { + const toRemove = []; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (typeof k === 'string' && k.startsWith('formula_')) toRemove.push(k); + } + for (const k of toRemove) storage.removeItem(k); + return toRemove.length; + } catch { + return 0; + } +}; diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js new file mode 100644 index 0000000..0320013 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyFormulaEvent, + buildFormulaMap, + clearLegacyFormulaStorage, + formatFormulaHint, + formulaKey, + isFormulaActual, +} from './cellFormulaUtils.js'; + +const user = { id: 7, full_name: 'Иванов И.И.' }; + +test('formulaKey склеивает line_id и column', () => { + assert.equal(formulaKey(5, 'q1'), '5|q1'); +}); + +test('buildFormulaMap строит карту по ключу', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1+2', value: 3, updated_by: user, updated_at: 'x' }]); + assert.equal(map.size, 1); + assert.equal(map.get('1|a').expression, '1+2'); + assert.equal(buildFormulaMap(null).size, 0); +}); + +test('applyFormulaEvent: строка записывает формулу', () => { + const map = new Map(); + const next = applyFormulaEvent(map, { + line_id: 1, + column: 'a', + value: '150', + formula: '100+50', + formula_meta: { updated_by: user, updated_at: '2026-10-27T14:03:00' }, + }); + assert.notEqual(next, map); + assert.equal(map.size, 0); + assert.deepEqual(next.get('1|a'), { + line_id: 1, + column: 'a', + expression: '100+50', + value: 150, + updated_by: user, + updated_at: '2026-10-27T14:03:00', + }); +}); + +test('applyFormulaEvent: formula null удаляет, вход не мутируется', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + const next = applyFormulaEvent(map, { line_id: 1, column: 'a', value: 2, formula: null }); + assert.equal(next.size, 0); + assert.equal(map.size, 1); +}); + +test('applyFormulaEvent: без поля formula удаляет', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + const next = applyFormulaEvent(map, { line_id: 1, column: 'a', value: 2 }); + assert.equal(next.size, 0); +}); + +test('applyFormulaEvent: фоновое событие без line_id/column возвращает ту же карту', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + assert.equal(applyFormulaEvent(map, { source_sheet: 'AHR_RENT' }), map); + assert.equal(applyFormulaEvent(map, { line_id: 1, source_sheet: 'X' }), map); + assert.equal(map.size, 1); +}); + +test('isFormulaActual', () => { + const f = { expression: '100+50', value: 150 }; + assert.equal(isFormulaActual(f, 150), true); + assert.equal(isFormulaActual(f, '150'), true); + assert.equal(isFormulaActual({ ...f, value: '150' }, 150.0000000001), true); + assert.equal(isFormulaActual({ ...f, value: 150 }, 151), false); + assert.equal(isFormulaActual(null, 150), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, 'abc'), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, null), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, ''), false); +}); + +test('formatFormulaHint', () => { + assert.equal( + formatFormulaHint({ expression: '100+50', updated_by: user, updated_at: '2026-10-27T14:03:00' }), + '=100+50 · Иванов И.И. · 27.10.2026 14:03', + ); + assert.equal( + formatFormulaHint({ expression: '1+1', updated_by: { id: 7 }, updated_at: '2026-10-27T14:03:00' }), + '=1+1 · Пользователь #7 · 27.10.2026 14:03', + ); + assert.equal(formatFormulaHint({ expression: '1+1', updated_by: user }), '=1+1 · Иванов И.И.'); +}); + +test('clearLegacyFormulaStorage', () => { + const keys = ['formula_v2_a', 'formula_x', 'other']; + const storage = { + get length() { + return keys.length; + }, + key: (i) => keys[i] ?? null, + removeItem: (k) => { + keys.splice(keys.indexOf(k), 1); + }, + }; + assert.equal(clearLegacyFormulaStorage(storage), 2); + assert.deepEqual(keys, ['other']); + const broken = { + get length() { + throw new Error('denied'); + }, + }; + assert.equal(clearLegacyFormulaStorage(broken), 0); + assert.equal(clearLegacyFormulaStorage(null), 0); +}); From b93e22080e99c71d306b3fd1e348305a661354ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:53:06 +0400 Subject: [PATCH 15/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#17=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index fc32b2c..0de4c40 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -182,3 +182,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 14 | 2026-11-11 | В `core/exception_handlers.py` снимается обёртка asyncpg `: ` перед проверкой `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` | Нет | From 3bee5e795f21ae3c3ec3cc94a59838d339d606ac Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:58:10 +0400 Subject: [PATCH 16/25] =?UTF-8?q?feat(formulas):=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=20fx=20=D1=83=20=D1=8F=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8,=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B0=20?= =?UTF-8?q?=D1=81=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5,=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20localStorage=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RealtimeContext: стор карты формул (formulaStore), загрузка при открытии листа и переподключении (формы и отчёты проектов), обновление на каждом cell_updated, защита от устаревших ответов при смене листа; useCellFormula; updateCell(..., { formula }) - Cell: маркер fx только при актуальной формуле, подсказка с единицей при displayFactor != 1 - EditCell: начальный текст «=выражение», выражение уходит в data.formula, без localStorage; ввод «100+50» без «=» не отправляется (тост «Формула начинается с =») - удалены resolveEditorState и clearLegacyFormulaKeys; T6.1/T6.2 в cellFormulaUtils Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 34 ++++++++- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 69 ++++++++----------- .../RealtimeTable/RealtimeTable.jsx | 11 +-- .../RealtimeTable/contexts/RealtimeContext.js | 69 +++++++++++++++++-- .../components/RealtimeTable/tableColumns.jsx | 18 ++--- .../RealtimeTable/utils/cellFormulaUtils.js | 39 ++++++++++- .../utils/cellFormulaUtils.test.js | 51 ++++++++++++++ .../RealtimeTable/utils/formulaStore.js | 66 ++++++++++++++++++ .../RealtimeTable/utils/formulaStore.test.js | 66 ++++++++++++++++++ .../RealtimeTable/utils/formulaUtils.js | 33 +-------- .../RealtimeTable/utils/formulaUtils.test.js | 34 +-------- .../RealtimeTable/utils/localStorageUtils.js | 17 ----- .../RealtimeTable/utils/messageUtils.js | 4 +- .../RealtimeTable/utils/messageUtils.test.js | 10 +++ 14 files changed, 368 insertions(+), 153 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/formulaStore.js create mode 100644 web/src/components/RealtimeTable/utils/formulaStore.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index bd07a07..b86cc73 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,9 +1,11 @@ import { Tooltip } from '@mui/material'; import React, { useMemo, useCallback, useState } from 'react'; -import { useCellLock } from '../../contexts/RealtimeContext'; +import { getColumnRequestKey, useCellFormula, useCellLock } from '../../contexts/RealtimeContext'; +import { formatFormulaHintWithUnit, isFormulaActual } from '../../utils/cellFormulaUtils'; import { resolveCellTextColor } from '../../utils/cellTextColor'; -import { formatNumber } from '../../utils/numberFormat'; import { isTextColumn } from '../../utils/moneyUtils'; +import { formatNumber } from '../../utils/numberFormat'; +import { getRowId } from '../../utils/rowUtils'; const BASE_CELL_STYLES = { width: '100%', @@ -75,6 +77,23 @@ const LOCK_BADGE_STYLES = { border: '1px solid rgba(255, 255, 255, 0.1)', }; +// AURORA-1896: маркер «fx» у ячейки со значением, посчитанным по сохранённой формуле. +// Угол слева-снизу: значок блокировки (AURORA-1890) занят справа-сверху, рамка редактируемости — +// граница ячейки, поэтому маркер ничего из этого не перекрывает. +const FORMULA_MARK_STYLES = { + position: 'absolute', + left: '3px', + bottom: '1px', + fontSize: '9px', + lineHeight: 1, + fontStyle: 'italic', + fontWeight: 600, + color: '#4a90d9', + cursor: 'help', + userSelect: 'none', + zIndex: 5, +}; + const CONTAINER_STYLES = { display: '-webkit-box', WebkitBoxOrient: 'vertical', @@ -144,6 +163,7 @@ const CellComponent = ({ invalidMessage, vspOptions, displayFactor = 1, + moneyUnit = null, }) => { const cellKey = `${row.id}_${column.id}`; const isLocked = useCellLock(cellKey); @@ -183,6 +203,10 @@ const CellComponent = ({ return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative }; }, [cell, column.id, isVspDropdown, vspOptions, displayFactor]); + // AURORA-1896: формула с сервера; маркер только если она даёт текущее значение ячейки. + const formula = useCellFormula(getRowId(row), getColumnRequestKey(column)); + const showFormulaMark = isFormulaActual(formula, rawValue); + const highlightedContent = useMemo(() => { const searchQueries = [globalFilter, columnFilter]; return createHighlightedContent(rawValue, displayValue, searchQueries); @@ -230,6 +254,11 @@ const CellComponent = ({ {highlightedContent} {isOverLimit && сверх лимита} {lockBadge} + {showFormulaMark && ( + + fx + + )} ); @@ -261,6 +290,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { prevProps.isEditable === nextProps.isEditable && prevProps.vspOptions === nextProps.vspOptions && prevProps.displayFactor === nextProps.displayFactor && + prevProps.moneyUnit === nextProps.moneyUnit && prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit ); }); diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index b43ce31..2dd9d03 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,7 +1,8 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { nonNegativeColumnKeys } from '../../constants/validation'; -import { useRealtimeActions } from '../../contexts/RealtimeContext'; +import { getColumnRequestKey, useCellFormula, useRealtimeActions } from '../../contexts/RealtimeContext'; +import { FORMULA_NEEDS_EQUALS_MESSAGE, looksLikeExpressionWithoutEquals, resolveInitialEditorText } from '../../utils/cellFormulaUtils'; import { calculateFormula, extractFormula, @@ -9,9 +10,9 @@ import { isFormula, isSameCellValue, normalizeNumericInput, - resolveEditorState, } from '../../utils/formulaUtils'; -import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils'; +import { isTextColumn } from '../../utils/moneyUtils'; +import { getRowId } from '../../utils/rowUtils'; // Вычисляет отображаемое значение формулы, не бросая исключение наружу. const computeFormulaDisplay = (formulaValue) => { @@ -48,9 +49,10 @@ const TEXTAREA_STYLES = { fontSize: 'inherit', }; -const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => { +const EditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); - const storageKey = `formula_v2_${tableId}_${cellId}`; + // AURORA-1896: формулу хранит сервер; карта приходит из контекста (localStorage не используется). + const formula = useCellFormula(getRowId(cell.row), getColumnRequestKey(cell.column)); // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не // редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и @@ -60,26 +62,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta // применяется для getSelectedColumnId/getColumnTitle в этом файле. // Инициализатор useState вызывается один раз при монтировании — ровно // тогда, когда этот редактор открылся именно так. - // hasInitialInputRef: редактор открыт набором символа на выделенной ячейке — - // пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя. - const hasInitialInputRef = useRef(false); - const [state, setState] = useState(() => { + // Начальный текст: набор символа на выделенной ячейке заменяет значение с нуля + // (формулу не подставляем); иначе актуальная формула открывается как «=выражение», + // неактуальная (значение изменилось без ввода) — просто значение (AURORA-1896). + // Инициализатор useState вызывается один раз при монтировании, поэтому набранный + // текст не затирается, если формула или значение изменятся, пока ячейка открыта. + const [initial] = useState(() => { const initialInput = table?.options?.meta?.getEditorInitialInput?.(); if (initialInput !== null && initialInput !== undefined) { - hasInitialInputRef.current = true; return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false }; } - return { currentValue: value, displayValue: value, isFormulaMode: false }; + const { text, isFormulaMode } = resolveInitialEditorText(formula, value); + return { currentValue: text, displayValue: isFormulaMode ? computeFormulaDisplay(text) : text, isFormulaMode }; }); + const [state, setState] = useState(initial); // Исходное значение редактора на момент открытия — чтобы не отправлять // на сервер открытие и закрытие ячейки без правки (AURORA-1895). - const initialEditorValueRef = useRef(value); - // Актуальное значение ячейки для эффекта разрешения формулы: эффект - // срабатывает один раз при открытии и не должен затирать набранный текст, - // если другой пользователь изменит ячейку, пока она открыта. - const valueRef = useRef(value); - valueRef.current = value; + const initialEditorValueRef = useRef(initial.isFormulaMode ? initial.currentValue : value); // Позиция ячейки const [position, setPosition] = useState(); @@ -91,27 +91,6 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const refFinished = useRef(false); - // Загрузка сохраненной формулы (AURORA-1895): подставляем её только если - // результат совпадает с текущим значением ячейки, иначе формула устарела - // (stale: значение изменил другой пользователь) и удаляется из хранилища. - useEffect(() => { - if (hasInitialInputRef.current) return; - - const currentCellValue = valueRef.current; - const savedFormula = loadFromStorage(storageKey); - const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, currentCellValue); - - if (stale) { - removeFromStorage(storageKey); - } - - initialEditorValueRef.current = currentValue; - - if (isFormulaMode) { - setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true }); - } - }, [storageKey]); - // Вычисление позиции useLayoutEffect(() => { if (!refCell?.current) return; @@ -211,18 +190,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta } const formattedResult = formatNumber(result); - saveToStorage(storageKey, currentValue); setState((prev) => ({ ...prev, displayValue: formattedResult, isFormulaMode: true, })); warnIfNegativeNotAllowed(formattedResult); - onChange?.(formattedResult); + // AURORA-1896: уходит значение и выражение без «=» — формулу сохраняет сервер. + onChange?.(formattedResult, { formula: extractFormula(currentValue) }); return true; } - removeFromStorage(storageKey); + // AURORA-1896: «100+50» без «=» — не отправляем (иначе на сервер уйдёт текст), подсказываем + // и оставляем редактор открытым. Текстовые колонки не проверяем: там «5-1» — обычный текст. + if (!isTextColumn(cell?.column) && looksLikeExpressionWithoutEquals(currentValue)) { + toast.info(FORMULA_NEEDS_EQUALS_MESSAGE); + return false; + } + setState((prev) => ({ ...prev, displayValue: currentValue, @@ -236,7 +221,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta if (isSameCellValue(normalizedValue, value)) return true; onChange?.(normalizedValue); return true; - }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]); + }, [state, cell, onChange, warnIfNegativeNotAllowed, value]); // Завершает редактирование текущей ячейки (cell_edit_end) — читает // table.getState().editingCell, а не cell.row/cell.column: к этому моменту diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 93e456d..50417fb 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -32,7 +32,6 @@ import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/c import { isCellEditable } from './utils/cellEditability'; import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils'; -import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; import { DictVspApi } from '../../api/dict-vsp'; @@ -269,12 +268,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnVirtualizerRef.current?.measure?.(); }, [sizeMult]); - // Разовая очистка устаревших ключей формул, привязанных к позиции строки - // (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*. - useEffect(() => { - clearLegacyFormulaKeys(); - }, []); - useEffect(() => { if (!formType || !sheetName) return; let isCancelled = false; @@ -320,8 +313,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [formId, columnsConfig, setVspOptions]); const handleUpdateCell = useCallback( - (row, column, value) => { - return contextUpdateCell(row, column, value); + (row, column, value, options) => { + return contextUpdateCell(row, column, value, options); }, [contextUpdateCell], ); diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index b45d02a..7f7dd19 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -1,6 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { toast } from 'react-toastify'; +import { FormsSheetApi } from '../../../api/form_sheet'; +import { ProjectsApi } from '../../../api/projects'; import { useWebSocket } from '../hooks/useWebSocket'; +import { clearLegacyFormulaStorage, formulaKey } from '../utils/cellFormulaUtils'; +import { createFormulaStore } from '../utils/formulaStore'; import { buildCellUpdatedMessage } from '../utils/messageUtils'; import { getRowId } from '../utils/rowUtils'; import { getWsErrorMessage } from '../utils/wsErrorUtils'; @@ -9,8 +13,17 @@ const RealtimeActionsContext = createContext(null); const RealtimeConnectionContext = createContext(null); const RealtimeVspOptionsContext = createContext(null); const LockedCellsStoreContext = createContext(null); +const FormulaStoreContext = createContext(null); -const getColumnRequestKey = (column) => { +// AURORA-1896: формулы теперь хранятся на сервере — прежние ключи formula_* в localStorage +// удаляем один раз при загрузке модуля (идемпотентно, ошибки хранилища не критичны). +try { + clearLegacyFormulaStorage(window.localStorage); +} catch { + // localStorage недоступен (приватный режим и т.п.) — чистить нечего +} + +export const getColumnRequestKey = (column) => { const columnKey = column?.columnDef?.id ?? column?.id; if (columnKey === null || columnKey === undefined) return ''; @@ -86,6 +99,19 @@ export const useCellLock = (cellKey) => { // именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок. export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore'); +export const useFormulaStore = () => useRequiredContext(FormulaStoreContext, 'useFormulaStore'); + +// AURORA-1896: формула ячейки (line_id + ключ колонки без `data.`) или undefined. +// Подписка по ключу — при событии перерисовывается только затронутая ячейка. +export const useCellFormula = (lineId, columnKey) => { + const store = useFormulaStore(); + const key = formulaKey(lineId, columnKey); + const subscribe = useCallback((listener) => store.subscribe(key, listener), [key, store]); + const getSnapshot = useCallback(() => store.get(key), [key, store]); + + return useSyncExternalStore(subscribe, getSnapshot, () => undefined); +}; + export const useRealtime = () => { const actions = useRealtimeActions(); const connection = useRealtimeConnection(); @@ -110,6 +136,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, lockedCellsStoreRef.current = createLockedCellsStore(); } const lockedCellsStore = lockedCellsStoreRef.current; + const formulaStoreRef = useRef(null); + if (formulaStoreRef.current === null) { + formulaStoreRef.current = createFormulaStore(); + } + const formulaStore = formulaStoreRef.current; const handleMessage = useCallback((data) => { // Обработка ошибок @@ -141,6 +172,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, break; case 'cell_updated': + // AURORA-1896: ответы с ошибкой отсекаются выше (data.error), сюда доходят только + // успешные события — и свои, и чужие. Фоновое событие без line_id/column карту не меняет. + formulaStoreRef.current.applyEvent(data.data); if (data.result && onCellUpdateRef.current) { const updatedCells = Array.isArray(data.result) ? data.result : [data.result]; onCellUpdateRef.current(updatedCells); @@ -197,6 +231,31 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage); + // AURORA-1896: смена листа/формы/года — карта прежнего листа не должна протекать. + // Ответы загрузок, начатых до сброса, стор отбрасывает по токену (reset/beginLoad). + // biome-ignore lint/correctness/useExhaustiveDependencies: идентификаторы листа — триггеры сброса карты + useEffect(() => { + formulaStore.reset(); + return () => formulaStore.reset(); + }, [formulaStore, formId, sheetName, year, isProject]); + + // Карта формул грузится при открытии листа и при каждом (пере)подключении WS: + // за время обрыва другие пользователи могли изменить формулы. + // biome-ignore lint/correctness/useExhaustiveDependencies: isConnected — триггер перезагрузки при переподключении + useEffect(() => { + if (formId === undefined || formId === null || !sheetName) return; + const token = formulaStore.beginLoad(); + const request = isProject ? ProjectsApi.getReportFormulas(formId, year, sheetName) : FormsSheetApi.getFormulas(formId, sheetName); + Promise.resolve(request) + .then((list) => { + formulaStore.finishLoad(token, Array.isArray(list) ? list : []); + }) + .catch((error) => { + console.warn('Не удалось загрузить формулы листа:', error); + formulaStore.failLoad(token); + }); + }, [formulaStore, formId, sheetName, year, isProject, isConnected]); + const onCellUpdateRef = useRef(null); const onRowAddRef = useRef(null); const onRowDeleteRef = useRef(null); @@ -336,7 +395,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, ); const updateCell = useCallback( - (row, column, value) => { + (row, column, value, { formula } = {}) => { if (!isConnectedRef.current) { onErrorRef.current?.('WebSocket не подключен'); return false; @@ -344,7 +403,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, const lineId = getRowId(row); const columnKey = getColumnRequestKey(column); - const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value }); + const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value, formula }); const sent = sendMessage(message); if (!sent) { @@ -638,7 +697,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, - {children} + + {children} + diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index 5cbece6..2205d60 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { useParams } from 'react-router'; import { useVspOptions } from './contexts/RealtimeContext'; import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils'; @@ -11,11 +10,9 @@ import { isCellEditable } from './utils/cellEditability'; import { getColumnValue } from './utils/columnUtils'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; -import { getForm4TypeLabel, getRowId } from './utils/rowUtils'; +import { getForm4TypeLabel } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { - const { formId, formType, sheetName, direction } = useParams(); - const tableKey = `${formId}_${formType}_${sheetName}_${direction}`; const ref = useRef(null); const [refTbody, setRefTbody] = useState(null); useEffect(() => { @@ -28,11 +25,11 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }, []); const handleChange = useCallback( - (val) => { + (val, options) => { table.setEditingCell(null); try { if (table.options.meta?.updateCell) { - const success = table.options.meta.updateCell(cell.row, cell.column, val); + const success = table.options.meta.updateCell(cell.row, cell.column, val, options); if (!success) { console.error('Failed to update cell via WebSocket'); onError?.('Не удалось сохранить изменение'); @@ -53,13 +50,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable value: cell.getValue(), disabled: !isEditable, onChange: handleChange, - tableId: tableKey, table, - // Привязан к line_id из данных строки, а не к её позиции в дереве - // (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк). - cellId: `${getRowId(cell.row)}_${cell.column.id}`, }), - [cell, isEditable, handleChange, tableKey, table], + [cell, isEditable, handleChange, table], ); const editType = cell.column.columnDef?.editType; @@ -182,6 +175,7 @@ export const getTableColumns = ({ const isEditableCell = isEditable(row, column); const backgroundColor = getColorCell(columnColors, column, row); const displayFactor = getCellDisplayFactor(column); + const moneyUnit = getMoneyUnit(column, sheetName); if (!cellPropsCache.has(row)) { cellPropsCache.set(row, {}); } @@ -197,6 +191,7 @@ export const getTableColumns = ({ cached.columnFilter === currentColumnFilter && cached.value === value && cached.displayFactor === displayFactor && + cached.moneyUnit === moneyUnit && cached.isInvalid === isInvalid && cached.invalidMessage === invalidMessage && cached.backgroundColor === backgroundColor @@ -217,6 +212,7 @@ export const getTableColumns = ({ invalidMessage, value, displayFactor, + moneyUnit, _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`, }; diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js index 0964c8d..a7b7174 100644 --- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js @@ -49,8 +49,10 @@ const pad = (n) => String(n).padStart(2, '0'); export const formatFormulaHint = (formula) => { const author = formula.updated_by; - const name = author?.full_name || `Пользователь #${author?.id}`; - const parts = [`=${formula.expression}`, name]; + const parts = [`=${formula.expression}`]; + if (author && (author.full_name || author.id != null)) { + parts.push(author.full_name || `Пользователь #${author.id}`); + } if (formula.updated_at) { const d = new Date(formula.updated_at); if (!Number.isNaN(d.getTime())) { @@ -60,6 +62,39 @@ export const formatFormulaHint = (formula) => { return parts.join(' · '); }; +const MONEY_UNIT_LABELS = { thousand: 'тыс. руб.', rub: 'руб.' }; + +// Подсказка маркера. Выражение хранится в базовых единицах колонки; если ячейка +// показана в другой разрядности (displayFactor !== 1), добавляем единицу выражения. +export const formatFormulaHintWithUnit = (formula, displayFactor = 1, moneyUnit = null) => { + const hint = formatFormulaHint(formula); + const label = MONEY_UNIT_LABELS[moneyUnit]; + return displayFactor !== 1 && label ? `${hint} (в ${label})` : hint; +}; + +// Начальный текст редактора: «=» + выражение, если формула актуальна для текущего +// значения, иначе значение ячейки. +export const resolveInitialEditorText = (formula, value) => { + if (isFormulaActual(formula, value)) { + return { text: `=${formula.expression}`, isFormulaMode: true }; + } + return { text: value, isFormulaMode: false }; +}; + +export const FORMULA_NEEDS_EQUALS_MESSAGE = 'Формула начинается с ='; + +// Ввод вида «100+50» без ведущего «=»: символы только из набора формулы и после первой +// цифры встречается оператор или скобка. Одиночное число (в т.ч. с знаком и запятой) — не выражение. +export const looksLikeExpressionWithoutEquals = (text) => { + if (typeof text !== 'string') return false; + const trimmed = text.trim(); + if (trimmed === '' || trimmed.startsWith('=')) return false; + if (!/^[0-9+\-*/%(),. \u00a0]+$/.test(trimmed)) return false; + const firstDigit = trimmed.search(/\d/); + if (firstDigit === -1) return false; + return /[+\-*/%()]/.test(trimmed.slice(firstDigit + 1)); +}; + export const clearLegacyFormulaStorage = (storage) => { try { const toRemove = []; diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js index 0320013..f871eb3 100644 --- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js @@ -1,12 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + FORMULA_NEEDS_EQUALS_MESSAGE, applyFormulaEvent, buildFormulaMap, clearLegacyFormulaStorage, formatFormulaHint, + formatFormulaHintWithUnit, formulaKey, isFormulaActual, + looksLikeExpressionWithoutEquals, + resolveInitialEditorText, } from './cellFormulaUtils.js'; const user = { id: 7, full_name: 'Иванов И.И.' }; @@ -108,3 +112,50 @@ test('clearLegacyFormulaStorage', () => { assert.equal(clearLegacyFormulaStorage(broken), 0); assert.equal(clearLegacyFormulaStorage(null), 0); }); + +test('formatFormulaHint: без автора часть автора опускается (T6.1)', () => { + assert.equal(formatFormulaHint({ expression: '1+2', updated_at: '2026-11-11T10:05:00' }), '=1+2 · 11.11.2026 10:05'); + assert.equal(formatFormulaHint({ expression: '1+2', updated_by: {}, updated_at: null }), '=1+2'); + assert.ok(!formatFormulaHint({ expression: '1+2', updated_by: {} }).includes('undefined')); + assert.equal(formatFormulaHint({ expression: '1+2', updated_by: { id: 0 } }), '=1+2 · Пользователь #0'); +}); + +test('formatFormulaHint: некорректная updated_at — без даты и без NaN (T6.2)', () => { + const hint = formatFormulaHint({ expression: '1+2', updated_by: user, updated_at: 'не дата' }); + assert.equal(hint, '=1+2 · Иванов И.И.'); + assert.ok(!hint.includes('NaN')); +}); + +test('applyFormulaEvent: line_id 0 и column "" не считаются отсутствующими (T6.2)', () => { + const next = applyFormulaEvent(new Map(), { line_id: 0, column: 'a', value: 5, formula: '2+3' }); + assert.equal(next.get('0|a').expression, '2+3'); + const next2 = applyFormulaEvent(new Map(), { line_id: 3, column: '', value: 5, formula: '2+3' }); + assert.equal(next2.get('3|').expression, '2+3'); + assert.equal(applyFormulaEvent(next, { line_id: 0, column: 'a', value: 5, formula: null }).size, 0); +}); + +test('formatFormulaHintWithUnit добавляет единицу при displayFactor !== 1', () => { + const f = { expression: '1+2', updated_by: user }; + assert.equal(formatFormulaHintWithUnit(f, 1, 'thousand'), '=1+2 · Иванов И.И.'); + assert.equal(formatFormulaHintWithUnit(f, 0.001, 'thousand'), '=1+2 · Иванов И.И. (в тыс. руб.)'); + assert.equal(formatFormulaHintWithUnit(f, 1000, 'rub'), '=1+2 · Иванов И.И. (в руб.)'); + assert.equal(formatFormulaHintWithUnit(f, 1000, null), '=1+2 · Иванов И.И.'); +}); + +test('resolveInitialEditorText: актуальная формула -> "=" + выражение', () => { + const f = { expression: '100+50', value: 150 }; + assert.deepEqual(resolveInitialEditorText(f, 150), { text: '=100+50', isFormulaMode: true }); + assert.deepEqual(resolveInitialEditorText(f, '150'), { text: '=100+50', isFormulaMode: true }); + assert.deepEqual(resolveInitialEditorText(f, 200), { text: 200, isFormulaMode: false }); + assert.deepEqual(resolveInitialEditorText(undefined, 150), { text: 150, isFormulaMode: false }); +}); + +test('looksLikeExpressionWithoutEquals', () => { + for (const s of ['100+50', ' 10*2 ', '(1+2)*3', '100/4', '50%', '1,5+2', '10 - 3']) { + assert.equal(looksLikeExpressionWithoutEquals(s), true, s); + } + for (const s of ['150', '-5', '+5', '1 500,5', '', '=1+2', 'abc', 'Договор 5-1', '01.11-05.11 ab', null, 12, '-', '1.5', ' 12,5 ']) { + assert.equal(looksLikeExpressionWithoutEquals(s), false, String(s)); + } + assert.equal(FORMULA_NEEDS_EQUALS_MESSAGE, 'Формула начинается с ='); +}); diff --git a/web/src/components/RealtimeTable/utils/formulaStore.js b/web/src/components/RealtimeTable/utils/formulaStore.js new file mode 100644 index 0000000..a749943 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/formulaStore.js @@ -0,0 +1,66 @@ +// AURORA-1896: стор карты формул листа (без React). Подписка по ключу ячейки, +// защита от устаревших ответов загрузки и повтор WS-событий, пришедших во время загрузки. +import { applyFormulaEvent, buildFormulaMap } from './cellFormulaUtils.js'; + +export const createFormulaStore = () => { + let map = new Map(); + let loadSeq = 0; + let pending = null; + const listenersByKey = new Map(); + + const notify = (prev, next) => { + const keys = new Set([...prev.keys(), ...next.keys()]); + for (const key of keys) { + if (prev.get(key) !== next.get(key)) { + listenersByKey.get(key)?.forEach((listener) => listener()); + } + } + }; + + const replace = (next) => { + const prev = map; + map = next; + notify(prev, next); + }; + + return { + get: (key) => map.get(key), + size: () => map.size, + subscribe: (key, listener) => { + const listeners = listenersByKey.get(key) || new Set(); + listeners.add(listener); + listenersByKey.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) listenersByKey.delete(key); + }; + }, + // Смена листа/формы: карта очищается, ответы прежних загрузок игнорируются. + reset: () => { + loadSeq += 1; + pending = null; + replace(new Map()); + }, + beginLoad: () => { + loadSeq += 1; + pending = []; + return loadSeq; + }, + finishLoad: (token, list) => { + if (token !== loadSeq) return false; + let next = buildFormulaMap(list); + for (const data of pending ?? []) next = applyFormulaEvent(next, data); + pending = null; + replace(next); + return true; + }, + failLoad: (token) => { + if (token === loadSeq) pending = null; + }, + applyEvent: (data) => { + if (pending) pending.push(data); + const next = applyFormulaEvent(map, data); + if (next !== map) replace(next); + }, + }; +}; diff --git a/web/src/components/RealtimeTable/utils/formulaStore.test.js b/web/src/components/RealtimeTable/utils/formulaStore.test.js new file mode 100644 index 0000000..1eb6bfe --- /dev/null +++ b/web/src/components/RealtimeTable/utils/formulaStore.test.js @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFormulaStore } from './formulaStore.js'; + +const item = (line_id, column, expression, value) => ({ line_id, column, expression, value }); + +test('finishLoad заполняет карту и уведомляет подписчика ключа', () => { + const store = createFormulaStore(); + let calls = 0; + store.subscribe('1|a', () => calls++); + const token = store.beginLoad(); + assert.equal(store.finishLoad(token, [item(1, 'a', '1+2', 3), item(2, 'b', '5*2', 10)]), true); + assert.equal(store.get('1|a').expression, '1+2'); + assert.equal(calls, 1); +}); + +test('устаревший ответ после reset или новой загрузки игнорируется', () => { + const store = createFormulaStore(); + const old = store.beginLoad(); + store.reset(); + assert.equal(store.finishLoad(old, [item(1, 'a', '1+2', 3)]), false); + assert.equal(store.size(), 0); + + const first = store.beginLoad(); + const second = store.beginLoad(); + assert.equal(store.finishLoad(first, [item(1, 'a', 'old', 1)]), false); + assert.equal(store.finishLoad(second, [item(1, 'a', 'new', 1)]), true); + assert.equal(store.get('1|a').expression, 'new'); +}); + +test('reset очищает карту и уведомляет подписчиков', () => { + const store = createFormulaStore(); + store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]); + let calls = 0; + store.subscribe('1|a', () => calls++); + store.reset(); + assert.equal(store.get('1|a'), undefined); + assert.equal(calls, 1); +}); + +test('WS-событие во время загрузки повторяется поверх ответа', () => { + const store = createFormulaStore(); + const token = store.beginLoad(); + store.applyEvent({ line_id: 1, column: 'a', value: 7, formula: '3+4', formula_meta: null }); + store.applyEvent({ line_id: 2, column: 'b', value: 1, formula: null, formula_meta: null }); + store.finishLoad(token, [item(1, 'a', 'stale', 1), item(2, 'b', '9', 9)]); + assert.equal(store.get('1|a').expression, '3+4'); + assert.equal(store.get('2|b'), undefined); +}); + +test('applyEvent: фоновое событие без line_id карту не меняет и не уведомляет', () => { + const store = createFormulaStore(); + store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]); + let calls = 0; + store.subscribe('1|a', () => calls++); + store.applyEvent({ source_sheet: 'X' }); + assert.equal(store.size(), 1); + assert.equal(calls, 0); +}); + +test('failLoad не стирает карту', () => { + const store = createFormulaStore(); + store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]); + store.failLoad(store.beginLoad()); + assert.equal(store.get('1|a').expression, '1+2'); +}); diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js index 25fd36b..74fa0e9 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.js @@ -86,7 +86,7 @@ export const evaluateCellValue = (value) => { // запись в БД и в журнале аудита при открытии/закрытии ячейки без правки). // Формула — особый случай: даже текстуально совпадающая формула всегда // считается «изменённой», чтобы handleSave по-прежнему всегда её -// пересчитывал и писал в localStorage (так было до этой задачи). +// пересчитывал и отправлял выражение на сервер (AURORA-1896). const normalizeComparableValue = (value) => { if (value === null || value === undefined) return ''; if (typeof value === 'number') return String(value); @@ -123,37 +123,6 @@ export const isSameCellValue = (a, b) => { return normalizeComparableValue(a) === normalizeComparableValue(b); }; -/** - * Начальное состояние редактора ячейки. - * Решает, показывать ли сохранённую формулу или текущее значение ячейки, - * и помечает формулу как устаревшую (stale), если её результат расходится - * с текущим значением — значит, значение изменил кто-то другой, либо - * формула больше не вычисляется. - * @param {string|null} savedFormula - строка вида '=...' из localStorage, либо null - * @param {string|number|null} value - текущее значение ячейки (с сервера) - * @returns {{ currentValue: string|number|null, isFormulaMode: boolean, stale: boolean }} - */ -export const resolveEditorState = (savedFormula, value) => { - if (!savedFormula || !isFormula(savedFormula)) { - return { currentValue: value, isFormulaMode: false, stale: false }; - } - - try { - const expression = extractFormula(savedFormula); - const result = calculateFormula(expression); - const numericValue = Number(value); - const stale = Number.isNaN(numericValue) || Math.abs(result - numericValue) > 1e-9; - - if (stale) { - return { currentValue: value, isFormulaMode: false, stale: true }; - } - - return { currentValue: savedFormula, isFormulaMode: true, stale: false }; - } catch { - return { currentValue: value, isFormulaMode: false, stale: true }; - } -}; - /** * Форматирует число для отображения * @param {number} value - Число для форматирования diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.test.js b/web/src/components/RealtimeTable/utils/formulaUtils.test.js index 824887b..aae8af1 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.test.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isSameCellValue, resolveEditorState } from './formulaUtils.js'; +import { isSameCellValue } from './formulaUtils.js'; // AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если // значение фактически не изменилось — иначе лишняя запись в БД/аудите при @@ -37,38 +37,6 @@ test('non-numeric text compares as-is after trimming surrounding whitespace', () assert.equal(isSameCellValue(' текст ', 'текст'), true); }); -// AURORA-1895: формула из localStorage не перезаписывает чужое значение. - -test('resolveEditorState keeps formula mode when the formula result matches the current value', () => { - const result = resolveEditorState('=100+50', 150); - assert.equal(result.isFormulaMode, true); - assert.equal(result.stale, false); -}); - -test('resolveEditorState marks the formula stale when the current value diverged (another user changed it)', () => { - const result = resolveEditorState('=100+50', 200); - assert.equal(result.stale, true); - assert.equal(result.currentValue, 200); -}); - -test('resolveEditorState keeps formula mode when the current value is a numeric string that matches', () => { - const result = resolveEditorState('=100+50', '150'); - assert.equal(result.stale, false); - assert.equal(result.isFormulaMode, true); -}); - -test('resolveEditorState marks the formula stale when it cannot be calculated', () => { - const result = resolveEditorState('=abc', 0); - assert.equal(result.stale, true); -}); - -test('resolveEditorState falls back to plain value mode when there is no saved formula', () => { - const result = resolveEditorState(null, 5); - assert.equal(result.isFormulaMode, false); - assert.equal(result.stale, false); - assert.equal(result.currentValue, 5); -}); - test('isSameCellValue treats a numeric string and a number as equal', () => { assert.equal(isSameCellValue('150', 150), true); }); diff --git a/web/src/components/RealtimeTable/utils/localStorageUtils.js b/web/src/components/RealtimeTable/utils/localStorageUtils.js index dfd40a6..38fd5a4 100644 --- a/web/src/components/RealtimeTable/utils/localStorageUtils.js +++ b/web/src/components/RealtimeTable/utils/localStorageUtils.js @@ -81,23 +81,6 @@ export const removeFromStorage = (key) => { } }; -/** - * Удаляет устаревшие ключи формул вида `formula__`, - * привязанные к позиции строки в дереве таблицы (AURORA-1895). Текущие ключи - * формул имеют префикс `formula_v2_` и привязаны к line_id — их не трогаем. - * @returns {number} - количество удалённых ключей - */ -export const clearLegacyFormulaKeys = () => { - try { - const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith('formula_') && !key.startsWith('formula_v2_')); - keysToRemove.forEach((key) => localStorage.removeItem(key)); - return keysToRemove.length; - } catch (error) { - console.error('Error clearing legacy formula keys from localStorage:', error); - return 0; - } -}; - /** * Очистка всех данных приложения из localStorage * @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить diff --git a/web/src/components/RealtimeTable/utils/messageUtils.js b/web/src/components/RealtimeTable/utils/messageUtils.js index 5e305ed..06f10c8 100644 --- a/web/src/components/RealtimeTable/utils/messageUtils.js +++ b/web/src/components/RealtimeTable/utils/messageUtils.js @@ -1,10 +1,12 @@ // value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2 -export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value }) => ({ +// formula (AURORA-1896) — выражение без «=»; поле data.formula добавляется только когда оно передано. +export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value, formula }) => ({ event: 'cell_updated', data: { line_id: lineId, line_id_code: lineIdCode, column, value: value === undefined ? null : value, + ...(typeof formula === 'string' && formula !== '' ? { formula } : {}), }, }); diff --git a/web/src/components/RealtimeTable/utils/messageUtils.test.js b/web/src/components/RealtimeTable/utils/messageUtils.test.js index 2e7bb6c..3bcd4d6 100644 --- a/web/src/components/RealtimeTable/utils/messageUtils.test.js +++ b/web/src/components/RealtimeTable/utils/messageUtils.test.js @@ -15,3 +15,13 @@ test('buildCellUpdatedMessage: прочие значения не меняютс assert.equal(msg(v).data.value, v); } }); + +test('buildCellUpdatedMessage: formula кладётся в data.formula только когда передана (AURORA-1896)', () => { + const withFormula = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 150, formula: '100+50' }); + assert.equal(withFormula.data.formula, '100+50'); + assert.equal(Object.hasOwn(msg(150).data, 'formula'), false); + for (const f of [undefined, null, '']) { + const m = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 1, formula: f }); + assert.equal(Object.hasOwn(m.data, 'formula'), false); + } +}); From aebaaa4ab7be2a19f57c64272d467579ffdb25bf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 20:04:22 +0400 Subject: [PATCH 17/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20#18=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 0de4c40..58b5bd8 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -183,3 +183,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 | Низкая: визуальные детали и один лишний запрос; в браузере не проверено | From 18573d6dc4979f4908d0727cc4e6ed9414da7e24 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 20:06:39 +0400 Subject: [PATCH 18/25] =?UTF-8?q?test(formulas):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20WS-=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?T5.1=E2=80=93T5.3=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../test_cell_formula_ws_integration.py | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/api/tests/integration/test_cell_formula_ws_integration.py b/api/tests/integration/test_cell_formula_ws_integration.py index e4cfec0..06a3120 100644 --- a/api/tests/integration/test_cell_formula_ws_integration.py +++ b/api/tests/integration/test_cell_formula_ws_integration.py @@ -16,13 +16,13 @@ def _roundtrip(ws, event: str, data: dict) -> dict: return ws.receive_json() -def _db_scalar(sql: str, commit: bool = False): +def _db_scalar(sql: str, commit: bool = False, **params): """Отдельная сессия на собственном движке (WS коммитит в БД).""" async def run(): engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) try: async with engine.connect() as conn: - res = await conn.execute(text(sql)) + res = await conn.execute(text(sql), params) row = res.first() if res.returns_rows else None if commit: await conn.commit() @@ -33,6 +33,14 @@ def _db_scalar(sql: str, commit: bool = False): return asyncio.run(run()) +PLAN_Q1_SQL = "SELECT plan_q1 FROM v3.plan WHERE line_id = 1" + + +def _restore_cell(prior): + """Вернуть plan.q1 строки 1 к значению до теста (NULL -> None) прямо в БД.""" + _db_scalar("UPDATE v3.plan SET plan_q1 = :v WHERE line_id = 1", commit=True, v=prior) + + def _cleanup_formula(): _db_scalar( "DELETE FROM v3.cell_formula WHERE budget_form_id = 1 " @@ -51,6 +59,7 @@ def _edit(ws, data: dict) -> dict: def test_ws_cell_updated_carries_saved_formula(client, admin_tokens): with client.websocket_connect(WS_URL) as ws: ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + prior = _db_scalar(PLAN_Q1_SQL) try: updated = _edit(ws, {"value": 150, "formula": "100+50"}) assert not updated.get("error"), updated @@ -65,15 +74,14 @@ def test_ws_cell_updated_carries_saved_formula(client, admin_tokens): assert plain["data"]["formula"] is None assert plain["data"]["formula_meta"] is None finally: - _roundtrip(ws, "cell_edit_start", CELL) - _roundtrip(ws, "cell_updated", {**CELL, "value": None}) - _roundtrip(ws, "cell_edit_end", CELL) _cleanup_formula() + _restore_cell(prior) def test_ws_broadcasts_normalized_formula_not_client_string(client, admin_tokens): with client.websocket_connect(WS_URL) as ws: ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + prior = _db_scalar(PLAN_Q1_SQL) try: updated = _edit(ws, {"value": 150, "formula": " =100+50 "}) assert not updated.get("error"), updated @@ -84,16 +92,16 @@ def test_ws_broadcasts_normalized_formula_not_client_string(client, admin_tokens ) assert stored == "100+50" finally: - _roundtrip(ws, "cell_edit_start", CELL) - _roundtrip(ws, "cell_updated", {**CELL, "value": None}) - _roundtrip(ws, "cell_edit_end", CELL) _cleanup_formula() + _restore_cell(prior) def test_ws_invalid_formula_returns_error_and_does_not_commit(client, admin_tokens): with client.websocket_connect(WS_URL) as ws: ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + prior = _db_scalar(PLAN_Q1_SQL) try: + value_before = _db_scalar(PLAN_Q1_SQL) before = _db_scalar( "SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1 " "AND line_id = 1 AND column_key = 'plan.q1'" @@ -108,11 +116,75 @@ def test_ws_invalid_formula_returns_error_and_does_not_commit(client, admin_toke "AND line_id = 1 AND column_key = 'plan.q1'" ) assert after == before - # значение не закоммичено: следующий успешный ответ не видит 999 + # значение не закоммичено: ячейка в БД не изменилась + assert _db_scalar(PLAN_Q1_SQL) == value_before ok = _edit(ws, {"value": 5}) assert not ok.get("error"), ok finally: - _roundtrip(ws, "cell_edit_start", CELL) - _roundtrip(ws, "cell_updated", {**CELL, "value": None}) - _roundtrip(ws, "cell_edit_end", CELL) _cleanup_formula() + _restore_cell(prior) + + +# ---- Отчёт проекта (ProjectEventProcess) ---- + +PROJECT_ID = 2 +YEAR = 2026 +PROJECT_WS_URL = f"/api/v1/ws/projects/{PROJECT_ID}/report/{YEAR}/LIMIT" +LIMIT_REPORT_SQL = ( + "SELECT r.id FROM v3.rf_project_report r " + "JOIN v3.rf_project_year py ON py.id = r.rf_project_year_id " + "WHERE r.project_id = :pid AND r.report_type = 'LIMIT' AND py.year = :y" +) + + +def test_ws_project_report_cell_updated_carries_saved_formula(client, admin_tokens): + """ProjectEventProcess.__update_cell: formula и formula_meta в рассылке.""" + report_id = _db_scalar(LIMIT_REPORT_SQL, pid=PROJECT_ID, y=YEAR) + assert report_id, "в фикстурной БД нет LIMIT-отчёта проекта 2" + line_id = None + try: + # строк отчёта в фикстуре нет: создаём свою (удаляется в finally) + line_id = _db_scalar( + "INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id) " + "SELECT :rid, id FROM v3.expense_item ORDER BY id LIMIT 1 RETURNING id", + commit=True, + rid=report_id, + ) + cell = {"line_id": line_id, "column": "q1.m1"} + qsql = ( + "SELECT actual_m1 FROM v3.rf_project_report_quarter " + f"WHERE rf_project_report_line_id = {line_id} AND quarter = 1" + ) + + def edit(ws, data): + assert not _roundtrip(ws, "cell_edit_start", cell).get("error") + res = _roundtrip(ws, "cell_updated", {**cell, **data}) + _roundtrip(ws, "cell_edit_end", cell) + return res + + with client.websocket_connect(PROJECT_WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + updated = edit(ws, {"value": 150, "formula": "100+50"}) + assert not updated.get("error"), updated + assert updated["data"]["formula"] == "100+50" + meta = updated["data"]["formula_meta"] + assert meta["updated_by"]["id"] == _db_scalar( + "SELECT id FROM v3.app_user WHERE username = 'admin'" + ) + assert isinstance(meta["updated_at"], str) + assert _db_scalar(qsql) == 150 + + plain = edit(ws, {"value": 7}) + assert not plain.get("error"), plain + assert plain["data"]["formula"] is None + assert plain["data"]["formula_meta"] is None + finally: + if line_id is not None: + # prior-значения нет: строка создана тестом; удаляем её вместе с формулами + _db_scalar( + "DELETE FROM v3.cell_formula WHERE rf_project_report_id = :rid " + "AND line_id = :l", commit=True, rid=report_id, l=line_id) + _db_scalar( + "DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = :l", + commit=True, l=line_id) + _db_scalar("DELETE FROM v3.rf_project_report_line WHERE id = :l", commit=True, l=line_id) From 683e12c52ffaff128b97d1098002f94363e130f6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 20:10:55 +0400 Subject: [PATCH 19/25] =?UTF-8?q?chore(formulas):=20=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=200060=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20rebase=20=D0=BD?= =?UTF-8?q?=D0=B0=20test=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- .../versions/{0059_cell_formula.py => 0060_cell_formula.py} | 6 +++--- .../sql/{0059_cell_formula.sql => 0060_cell_formula.sql} | 0 api/tests/integration/test_cell_formula_sql.py | 2 +- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) rename api/alembic/versions/{0059_cell_formula.py => 0060_cell_formula.py} (61%) rename api/alembic/versions/sql/{0059_cell_formula.sql => 0060_cell_formula.sql} (100%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f4bf145..8385739 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём. -Триггеры. В схеме `v3` есть первые триггеры (миграция 0059, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK. +Триггеры. В схеме `v3` есть первые триггеры (миграция 0060, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK. ## Модули diff --git a/api/alembic/versions/0059_cell_formula.py b/api/alembic/versions/0060_cell_formula.py similarity index 61% rename from api/alembic/versions/0059_cell_formula.py rename to api/alembic/versions/0060_cell_formula.py index 5bb8695..e6bacab 100644 --- a/api/alembic/versions/0059_cell_formula.py +++ b/api/alembic/versions/0060_cell_formula.py @@ -1,14 +1,14 @@ from sql_migration import run_sql_file_migration -revision = "0059" -down_revision = "0058" +revision = "0060" +down_revision = "0059" branch_labels = None depends_on = None def upgrade() -> None: - run_sql_file_migration("0059_cell_formula.sql") + run_sql_file_migration("0060_cell_formula.sql") def downgrade() -> None: diff --git a/api/alembic/versions/sql/0059_cell_formula.sql b/api/alembic/versions/sql/0060_cell_formula.sql similarity index 100% rename from api/alembic/versions/sql/0059_cell_formula.sql rename to api/alembic/versions/sql/0060_cell_formula.sql diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py index fd24c7e..08d01da 100644 --- a/api/tests/integration/test_cell_formula_sql.py +++ b/api/tests/integration/test_cell_formula_sql.py @@ -1,4 +1,4 @@ -"""SQL-слой формул ячеек (миграция 0059, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады.""" +"""SQL-слой формул ячеек (миграция 0060, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады.""" import json import pytest diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index 58b5bd8..bba3599 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -184,3 +184,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 | Низкая: визуальные детали и один лишний запрос; в браузере не проверено | +| 19 | 2026-11-11 | Миграция переименована в `0060_cell_formula` (`down_revision = "0059"`), ветка перебазирована на `origin/test` f047031, где уже есть `0059_form4_standalone_project` (R-9; D9 исполнен) | Номер 0059 занят слитым PR | Нет | From ecef6eb247e8f40697846427694a84b41787a597 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 21:06:53 +0400 Subject: [PATCH 20/25] =?UTF-8?q?fix(formulas):=20=D1=80=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B0=20WS=20=D0=BD=D0=B5=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=91=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D1=83?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B5=20=D0=B2=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B5=20(AURORA-1896,=20I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/websocket.py | 10 ++++++-- .../test_cell_formula_ws_integration.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index 360f9d5..cc81078 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -538,6 +538,9 @@ class FormEventProcess: "value": any, } """ + client_formula = event_data.get("formula") + # Формула клиента не должна уйти в рассылку, даже если запись не состоится. + _apply_saved_formula(event_data, None) if not await self._check_form_access(form_id=form_id, user=user): return None @@ -549,7 +552,7 @@ class FormEventProcess: line_id=event_data["line_id"], column=event_data["column"], value=event_data["value"], - formula=event_data.get("formula"), + formula=client_formula, user=user, ) _apply_saved_formula(event_data, formula) @@ -774,6 +777,9 @@ class ProjectEventProcess: "value": any, } """ + client_formula = event_data.get("formula") + # Формула клиента не должна уйти в рассылку, даже если запись не состоится. + _apply_saved_formula(event_data, None) project = await self.project_service.get(project_id=project_id, user=user) if not project: return None @@ -785,7 +791,7 @@ class ProjectEventProcess: line_id=event_data["line_id"], column=event_data["column"], value=event_data["value"], - formula=event_data.get("formula"), + formula=client_formula, user=user, ) _apply_saved_formula(event_data, formula) diff --git a/api/tests/integration/test_cell_formula_ws_integration.py b/api/tests/integration/test_cell_formula_ws_integration.py index 06a3120..54a08ea 100644 --- a/api/tests/integration/test_cell_formula_ws_integration.py +++ b/api/tests/integration/test_cell_formula_ws_integration.py @@ -188,3 +188,28 @@ def test_ws_project_report_cell_updated_carries_saved_formula(client, admin_toke "DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = :l", commit=True, l=line_id) _db_scalar("DELETE FROM v3.rf_project_report_line WHERE id = :l", commit=True, l=line_id) + + +def test_ws_no_access_user_forged_formula_is_not_echoed(client, isp_tokens): + """I-1: пользователь без доступа к форме 1 (isp1) шлёт cell_updated с подделанной формулой: + в ответе/рассылке formula и formula_meta обнулены, строки cell_formula не появляется.""" + count_sql = ( + "SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1 " + "AND line_id = 1 AND column_key = 'plan.q1'" + ) + before = _db_scalar(count_sql) + prior = _db_scalar(PLAN_Q1_SQL) + try: + with client.websocket_connect(WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": isp_tokens["access_token"]}}) + forged_meta = {"updated_by": {"id": 1, "full_name": "Forged"}, "updated_at": "2020-01-01T00:00:00"} + updated = _edit(ws, {"value": 777, "formula": "7*111", "formula_meta": forged_meta}) + assert not updated.get("error"), updated + data = updated.get("data", {}) + assert data.get("formula") is None, updated + assert data.get("formula_meta") is None, updated + assert _db_scalar(count_sql) == before + assert _db_scalar(PLAN_Q1_SQL) == prior + finally: + _cleanup_formula() + _restore_cell(prior) From e569d18624c61e720b9dd923ace8fab33f38cf2c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 21:06:53 +0400 Subject: [PATCH 21/25] =?UTF-8?q?fix(formulas):=20=D0=BD=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=B5=D0=BB=D0=BE=D0=B2=20=D0=B2=D1=8B=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B8=20invalid=5Fformula=20(AURORA-1896,=20I-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 9 +++++++-- .../components/RealtimeTable/utils/cellFormulaUtils.js | 4 ++++ .../RealtimeTable/utils/cellFormulaUtils.test.js | 10 ++++++++++ web/src/components/RealtimeTable/utils/wsErrorUtils.js | 4 ++++ .../RealtimeTable/utils/wsErrorUtils.test.js | 9 +++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index 2dd9d03..07788cf 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -2,7 +2,12 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { toast } from 'react-toastify'; import { nonNegativeColumnKeys } from '../../constants/validation'; import { getColumnRequestKey, useCellFormula, useRealtimeActions } from '../../contexts/RealtimeContext'; -import { FORMULA_NEEDS_EQUALS_MESSAGE, looksLikeExpressionWithoutEquals, resolveInitialEditorText } from '../../utils/cellFormulaUtils'; +import { + FORMULA_NEEDS_EQUALS_MESSAGE, + looksLikeExpressionWithoutEquals, + normalizeFormulaExpression, + resolveInitialEditorText, +} from '../../utils/cellFormulaUtils'; import { calculateFormula, extractFormula, @@ -197,7 +202,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => { })); warnIfNegativeNotAllowed(formattedResult); // AURORA-1896: уходит значение и выражение без «=» — формулу сохраняет сервер. - onChange?.(formattedResult, { formula: extractFormula(currentValue) }); + onChange?.(formattedResult, { formula: normalizeFormulaExpression(extractFormula(currentValue)) }); return true; } diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js index a7b7174..26c70f3 100644 --- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js @@ -108,3 +108,7 @@ export const clearLegacyFormulaStorage = (storage) => { return 0; } }; + +// Сервер принимает в выражении только ASCII-пробел, а клиент при вычислении снимает любые \s +// (NBSP, узкий NBSP, табуляция, перевод строки): перед отправкой убираем все пробельные символы. +export const normalizeFormulaExpression = (expr) => String(expr ?? '').replace(/\s+/g, ''); diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js index f871eb3..cec6c6a 100644 --- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js @@ -10,6 +10,7 @@ import { formulaKey, isFormulaActual, looksLikeExpressionWithoutEquals, + normalizeFormulaExpression, resolveInitialEditorText, } from './cellFormulaUtils.js'; @@ -159,3 +160,12 @@ test('looksLikeExpressionWithoutEquals', () => { } assert.equal(FORMULA_NEEDS_EQUALS_MESSAGE, 'Формула начинается с ='); }); + +test('AURORA-1896: normalizeFormulaExpression снимает любые пробельные символы', () => { + assert.equal(normalizeFormulaExpression('1 000+500'), '1000+500'); + assert.equal(normalizeFormulaExpression('1\u00a0000+500'), '1000+500'); + assert.equal(normalizeFormulaExpression('1\u202f000+5\t0\n0'), '1000+500'); + assert.equal(normalizeFormulaExpression('100 + 50'), '100+50'); + assert.equal(normalizeFormulaExpression(''), ''); + assert.equal(normalizeFormulaExpression(null), ''); +}); diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.js index e8def58..aef7283 100644 --- a/web/src/components/RealtimeTable/utils/wsErrorUtils.js +++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.js @@ -4,6 +4,10 @@ const KNOWN_PREFIX_MESSAGES = [ { prefix: 'computed_field:', message: 'Это расчётная ячейка, её нельзя изменить' }, { prefix: 'role_not_allowed:', message: 'Нет прав на изменение этой колонки' }, { prefix: 'window_closed:', message: 'Окно ввода для этой колонки закрыто' }, + { + prefix: 'invalid_formula:', + message: 'Формула содержит недопустимые символы, пуста или слишком длинная (до 500 символов)', + }, { prefix: 'org_not_assigned', message: 'Вы не назначены на эту форму' }, ]; diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js index 1dbbda0..2cc4735 100644 --- a/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js +++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js @@ -71,3 +71,12 @@ test('AURORA-1978: префикс класса исключения asyncpg ср ); assert.equal(getWsErrorMessage(": "), 'Неизвестная ошибка сервера'); }); + +test('AURORA-1896: invalid_formula: заменяется человеческим текстом', () => { + const expected = 'Формула содержит недопустимые символы, пуста или слишком длинная (до 500 символов)'; + assert.equal(getWsErrorMessage('invalid_formula: chars'), expected); + assert.equal( + getWsErrorMessage({ code: 422, field: '', message: ": invalid_formula: length" }), + expected, + ); +}); From e107fa418de25a077433dc531fa88fec47961353 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 21:06:53 +0400 Subject: [PATCH 22/25] =?UTF-8?q?perf(formulas):=20=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D1=81=D1=82=D0=B2=D0=BE=20=D0=BF=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D1=83=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20IS=20NOT=20DISTINCT=20FROM,=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BD=D0=B0=20Infinity=20(AURORA-18?= =?UTF-8?q?96,=20M-1,=20M-2,=20M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0060_cell_formula.sql | 80 ++++++++++++------- .../integration/test_cell_formula_sql.py | 2 +- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/api/alembic/versions/sql/0060_cell_formula.sql b/api/alembic/versions/sql/0060_cell_formula.sql index 6cac185..c10f4af 100644 --- a/api/alembic/versions/sql/0060_cell_formula.sql +++ b/api/alembic/versions/sql/0060_cell_formula.sql @@ -85,12 +85,20 @@ BEGIN END IF; IF v_expr = '' THEN - DELETE FROM v3.cell_formula cf - WHERE cf.sheet = p_sheet - AND cf.line_id = v_line - AND cf.column_key = v_column - AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id - AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id; + -- равенство по владельцу в отдельных ветках: так планировщик берёт частичные уникальные индексы + IF p_budget_form_id IS NOT NULL THEN + DELETE FROM v3.cell_formula cf + WHERE cf.budget_form_id = p_budget_form_id + AND cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column; + ELSE + DELETE FROM v3.cell_formula cf + WHERE cf.rf_project_report_id = p_report_id + AND cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column; + END IF; CONTINUE; END IF; @@ -106,7 +114,7 @@ BEGIN EXCEPTION WHEN others THEN RAISE EXCEPTION 'invalid_formula: value'; END; - IF v_value IS NULL OR v_value = 'NaN'::numeric THEN + IF v_value IS NULL OR v_value::text IN ('NaN', 'Infinity', '-Infinity') THEN RAISE EXCEPTION 'invalid_formula: value'; END IF; @@ -117,6 +125,16 @@ BEGIN ON CONFLICT (budget_form_id, sheet, line_id, column_key) WHERE budget_form_id IS NOT NULL DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; + + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.budget_form_id = p_budget_form_id + AND cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column; ELSE INSERT INTO v3.cell_formula AS cf (rf_project_report_id, sheet, line_id, column_key, expression, value, updated_by, updated_at) @@ -124,18 +142,16 @@ BEGIN ON CONFLICT (rf_project_report_id, sheet, line_id, column_key) WHERE rf_project_report_id IS NOT NULL DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.rf_project_report_id = p_report_id + AND cf.sheet = p_sheet + AND cf.line_id = v_line + AND cf.column_key = v_column; END IF; - - RETURN QUERY - SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, - u.full_name::text, cf.updated_at - FROM v3.cell_formula cf - LEFT JOIN v3.app_user u ON u.id = cf.updated_by - WHERE cf.sheet = p_sheet - AND cf.line_id = v_line - AND cf.column_key = v_column - AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id - AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id; END LOOP; END; $function$; @@ -163,15 +179,25 @@ BEGIN RAISE EXCEPTION 'invalid_formula: owner'; END IF; - RETURN QUERY - SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, - u.full_name::text, cf.updated_at - FROM v3.cell_formula cf - LEFT JOIN v3.app_user u ON u.id = cf.updated_by - WHERE cf.sheet = p_sheet - AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id - AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id - ORDER BY cf.line_id, cf.column_key; + IF p_budget_form_id IS NOT NULL THEN + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.budget_form_id = p_budget_form_id + AND cf.sheet = p_sheet + ORDER BY cf.line_id, cf.column_key; + ELSE + RETURN QUERY + SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by, + u.full_name::text, cf.updated_at + FROM v3.cell_formula cf + LEFT JOIN v3.app_user u ON u.id = cf.updated_by + WHERE cf.rf_project_report_id = p_report_id + AND cf.sheet = p_sheet + ORDER BY cf.line_id, cf.column_key; + END IF; END; $function$; diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py index 08d01da..d333497 100644 --- a/api/tests/integration/test_cell_formula_sql.py +++ b/api/tests/integration/test_cell_formula_sql.py @@ -87,7 +87,7 @@ def test_empty_after_normalization_is_no_formula(svod2_sql): @pytest.mark.parametrize( "formula,value", - [("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc"), ("1+1", "NaN")], + [("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc"), ("1+1", "NaN"), ("1+1", "Infinity"), ("1+1", "-Infinity")], ) def test_invalid_formula_raises(svod2_sql, formula, value): admin, line = _admin(svod2_sql), _line(svod2_sql) From 54e60dcf8e7acae130ec84e1bf9b48f5cd333231 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 21:06:53 +0400 Subject: [PATCH 23/25] =?UTF-8?q?test(formulas):=20GET=20formulas=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0,=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B0=20=D0=BA=20=D0=BA=D0=BE=D0=B4=D1=83=20?= =?UTF-8?q?(AURORA-1896,=20M-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../integration/test_cell_formula_integration.py | 15 +++++++++++++++ .../specs/cell-formula/spec.md | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/api/tests/integration/test_cell_formula_integration.py b/api/tests/integration/test_cell_formula_integration.py index 4dfe13e..881d26d 100644 --- a/api/tests/integration/test_cell_formula_integration.py +++ b/api/tests/integration/test_cell_formula_integration.py @@ -206,6 +206,12 @@ def test_get_formulas_unknown_form_404(client, headers): assert r.status_code == 404 +def test_get_formulas_no_form_access_404(client, isp_tokens, auth_headers): + # isp1 в оргединице формы 1 не состоит: форма для него «не найдена» (спека: как у GET листа). + r = client.get(f"{FORM}/sheet/AHR/formulas", headers=auth_headers(isp_tokens)) + assert r.status_code == 404, r.text + + # ---- Отчёты проектов (FORM_3) ---- import uuid @@ -459,3 +465,12 @@ def test_rf_project_report_line_trigger_direct_delete(client, headers): left = {f["line_id"] for f in _project_formulas(client, headers, pid, year)} assert l1 not in left assert l2 in left + + +def test_project_formulas_report_no_access_400(client, headers, isp_tokens, auth_headers): + # Проект админа в филиале 1; isp1 доступа к нему не имеет: та же ошибка, что у PATCH отчёта. + year = datetime.now().year + pid = _create_project(client, headers, year) + r = client.get(f"{_report_url(pid, year)}/formulas", headers=auth_headers(isp_tokens)) + assert r.status_code == 400, r.text + assert r.json().get("message") == "Отчёт не найден" diff --git a/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md b/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md index 274f86c..dce076c 100644 --- a/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md +++ b/openspec/changes/aurora-1896-cell-formulas/specs/cell-formula/spec.md @@ -40,7 +40,7 @@ - **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. +Сервер 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 «Форма не найдена»; отчёт проекта не найден или нет доступа — та же ошибка, что у PATCH этого отчёта (400 «Отчёт не найден»). #### Scenario: Формулы видны другому пользователю - **WHEN** пользователь А сохранил формулу, а пользователь Б с доступом к форме запрашивает формулы листа From baa5f4e8cdad044abbf31e364b925c0d35db4241 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 21:14:18 +0400 Subject: [PATCH 24/25] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20#19=E2=80=93#21=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B3=D0=B0=D0=BC=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1896-cell-formulas/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index bba3599..d17a69c 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -185,3 +185,5 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 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 | Низкая: визуальные детали и один лишний запрос; в браузере не проверено | | 19 | 2026-11-11 | Миграция переименована в `0060_cell_formula` (`down_revision = "0059"`), ветка перебазирована на `origin/test` f047031, где уже есть `0059_form4_standalone_project` (R-9; D9 исполнен) | Номер 0059 занят слитым PR | Нет | +| 20 | 2026-11-11 | По итогам финального ревью (opus): WS обнуляет присланные клиентом `formula`/`formula_meta` до проверки доступа (I-1); клиент убирает все пробельные символы перед отправкой, добавлен текст ошибки `invalid_formula:` (I-2); в SQL равенство по владельцу вместо `IS NOT DISTINCT FROM` и отказ на `Infinity` (M-1/M-2/M-3); спека для GET формул отчёта проекта приведена к коду — 400 «Отчёт не найден» как у PATCH (M-5, R-10) | Подделка `fx` через WS без доступа; несовпадение наборов символов; индексы | Низкая | +| 21 | 2026-11-11 | Миграция остаётся 0060 (R-9); PR #315 (AURORA-1879) тоже занимает 0060 — при слиянии #315 первым переименовать в 0061 (I-3 deferred человеку) | Порядок мержа решает человек | Два head Alembic, если слить оба без переименования | From 38036b31af8c96a1277301c0054ffbb2b69dbb6f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 01:08:21 +0400 Subject: [PATCH 25/25] =?UTF-8?q?chore(formulas):=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=200060=20=E2=86=92=200061=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=200060=5Fform=5Fphase=5Fdi?= =?UTF-8?q?rection=5Ffix=20(#315)=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порядок мержа фиксирован: #315 → #317 → #319 → #323. design.md #22. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- .../versions/{0060_cell_formula.py => 0061_cell_formula.py} | 6 +++--- .../sql/{0060_cell_formula.sql => 0061_cell_formula.sql} | 0 api/tests/integration/test_cell_formula_sql.py | 2 +- openspec/changes/aurora-1896-cell-formulas/design.md | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) rename api/alembic/versions/{0060_cell_formula.py => 0061_cell_formula.py} (61%) rename api/alembic/versions/sql/{0060_cell_formula.sql => 0061_cell_formula.sql} (100%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8385739..7b86cf7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём. -Триггеры. В схеме `v3` есть первые триггеры (миграция 0060, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK. +Триггеры. В схеме `v3` есть первые триггеры (миграция 0061, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK. ## Модули diff --git a/api/alembic/versions/0060_cell_formula.py b/api/alembic/versions/0061_cell_formula.py similarity index 61% rename from api/alembic/versions/0060_cell_formula.py rename to api/alembic/versions/0061_cell_formula.py index e6bacab..adaa6c1 100644 --- a/api/alembic/versions/0060_cell_formula.py +++ b/api/alembic/versions/0061_cell_formula.py @@ -1,14 +1,14 @@ from sql_migration import run_sql_file_migration -revision = "0060" -down_revision = "0059" +revision = "0061" +down_revision = "0060" branch_labels = None depends_on = None def upgrade() -> None: - run_sql_file_migration("0060_cell_formula.sql") + run_sql_file_migration("0061_cell_formula.sql") def downgrade() -> None: diff --git a/api/alembic/versions/sql/0060_cell_formula.sql b/api/alembic/versions/sql/0061_cell_formula.sql similarity index 100% rename from api/alembic/versions/sql/0060_cell_formula.sql rename to api/alembic/versions/sql/0061_cell_formula.sql diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py index d333497..2df5b5a 100644 --- a/api/tests/integration/test_cell_formula_sql.py +++ b/api/tests/integration/test_cell_formula_sql.py @@ -1,4 +1,4 @@ -"""SQL-слой формул ячеек (миграция 0060, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады.""" +"""SQL-слой формул ячеек (миграция 0061, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады.""" import json import pytest diff --git a/openspec/changes/aurora-1896-cell-formulas/design.md b/openspec/changes/aurora-1896-cell-formulas/design.md index d17a69c..63291ca 100644 --- a/openspec/changes/aurora-1896-cell-formulas/design.md +++ b/openspec/changes/aurora-1896-cell-formulas/design.md @@ -187,3 +187,4 @@ AHR_LIMIT (`limit_template.id`) построчно не удаляется — | 19 | 2026-11-11 | Миграция переименована в `0060_cell_formula` (`down_revision = "0059"`), ветка перебазирована на `origin/test` f047031, где уже есть `0059_form4_standalone_project` (R-9; D9 исполнен) | Номер 0059 занят слитым PR | Нет | | 20 | 2026-11-11 | По итогам финального ревью (opus): WS обнуляет присланные клиентом `formula`/`formula_meta` до проверки доступа (I-1); клиент убирает все пробельные символы перед отправкой, добавлен текст ошибки `invalid_formula:` (I-2); в SQL равенство по владельцу вместо `IS NOT DISTINCT FROM` и отказ на `Infinity` (M-1/M-2/M-3); спека для GET формул отчёта проекта приведена к коду — 400 «Отчёт не найден» как у PATCH (M-5, R-10) | Подделка `fx` через WS без доступа; несовпадение наборов символов; индексы | Низкая | | 21 | 2026-11-11 | Миграция остаётся 0060 (R-9); PR #315 (AURORA-1879) тоже занимает 0060 — при слиянии #315 первым переименовать в 0061 (I-3 deferred человеку) | Порядок мержа решает человек | Два head Alembic, если слить оба без переименования | +| 22 | 2026-11-12 | Миграция переименована в `0061_cell_formula` (`down_revision = "0060"` — `0060_form_phase_direction_fix` из PR #315). Порядок мержа фиксирует человек: #315 → #317 → #319 → #323 (I-3 закрыт) | Человек подтвердил, что мержит сам и соблюдает порядок стека | PR #323 нельзя вливать раньше #315: alembic не найдёт ревизию 0060 |