DFiP_Budget_planing/docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md
Andrey Epifancev 1187ac2f8f docs(plan): план реализации AURORA-1896 — формулы ячеек на сервере
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:07:04 +04:00

32 KiB
Raw Blame History

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" <venv>/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.
  • Подсказка маркера — ровно =<expression> · <ФИО> · дд.мм.гггг чч:мм; если ФИО нет — Пользователь #<id>.
  • Текст подсказки при вводе выражения без «=» — «Формула начинается с =».
  • Коммиты на русском в стиле истории (feat(formulas): … (AURORA-1896)), последняя строка сообщения: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Не пушить.

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_<table>(), 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 == <admin 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=<resolve_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": <ISO-строка>} 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 == <admin 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 — =<expression> · <full_name | "Пользователь #id"> · дд.мм.гггг чч:мм (локальное время; без 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).