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)`.