Merge pull request 'feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896)' (#323) from feat/320-aurora-1896-cell-formulas into test

This commit is contained in:
aep 2026-11-12 17:03:41 +03:00
commit 2b780f5267
41 changed files with 2715 additions and 181 deletions

View File

@ -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` есть первые триггеры (миграция 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.
## Модули
| Модуль | Ответственность | Зависит от |

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0061"
down_revision = "0060"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0061_cell_formula.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,281 @@
-- 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
-- равенство по владельцу в отдельных ветках: так планировщик берёт частичные уникальные индексы
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;
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 OR v_value::text IN ('NaN', 'Infinity', '-Infinity') 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;
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)
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;
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;
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;
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$;
-- Триггерные функции очистки (см. комментарий в начале файла)
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();

View File

@ -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

View File

@ -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)

View File

@ -410,6 +410,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)
@ -523,10 +537,13 @@ 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
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,
@ -534,8 +551,11 @@ class FormEventProcess:
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
formula=client_formula,
user=user,
)
_apply_saved_formula(event_data, formula)
return rows
async def __add_row(
self,
@ -756,19 +776,25 @@ 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
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=client_formula,
user=user,
)
_apply_saved_formula(event_data, formula)
return rows
async def __add_row(
self,

View File

@ -8,6 +8,7 @@ VALIDATION_PREFIXES = (
"bad_booking_year:",
"bad_booking_quarter:",
"unsupported_scope:",
"invalid_formula:",
)
PG_404_ERRORS = (

View File

@ -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"^<class '[^']+'>:\s*")
def _strip_driver_class_prefix(message: str) -> str:
"""asyncpg-диалект даёт str(orig) вида "<class '...RaiseError'>: 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, ""

View File

@ -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):

View File

@ -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()]

View File

@ -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,

View File

@ -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(

View File

@ -0,0 +1,476 @@
"""AURORA-1896: формулы ячеек форм (REST): запись при PATCH и GET formulas."""
import pytest
FORM = "/api/v1/form/1"
QS = "?direction=Support&sections=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)
# исходное значение отличается от пробного, иначе «значение не изменилось» ничего не доказывает
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)
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 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 _read() == 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.
# Опирается на порядок записи (проверка прав до записи формулы) и откат сессии фикстурой:
# если бы формула успела записаться, GET ниже увидел бы её в той же сессии.
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
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
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)
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"},
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)
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"},
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
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") == "Отчёт не найден"

View File

@ -0,0 +1,193 @@
"""SQL-слой формул ячеек (миграция 0061, 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"), ("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)
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):
"""Формула 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"):
_set(svod2_sql, None, "AHR", [], admin)

View File

@ -0,0 +1,215 @@
"""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, **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), params)
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())
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 "
"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"]}})
prior = _db_scalar(PLAN_Q1_SQL)
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:
_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
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:
_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'"
)
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
# значение не закоммичено: ячейка в БД не изменилась
assert _db_scalar(PLAN_Q1_SQL) == value_before
ok = _edit(ws, {"value": 5})
assert not ok.get("error"), ok
finally:
_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)
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)

View File

@ -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}

View File

@ -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("<class 'asyncpg.exceptions.RaiseError'>: 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("<class 'asyncpg.exceptions.RaiseError'>: 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 не существует")
@ -157,3 +179,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"

View File

@ -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

View File

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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11

View File

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

View File

@ -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` на утилиты формул.

View File

@ -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 «Форма не найдена»; отчёт проекта не найден или нет доступа — та же ошибка, что у PATCH этого отчёта (400 «Отчёт не найден»).
#### 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** чтение формул листа не возвращает формул этой строки

View File

@ -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,

View File

@ -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);
},

View File

@ -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 = ({
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
{lockBadge}
{showFormulaMark && (
<Tooltip title={formatFormulaHintWithUnit(formula, displayFactor, moneyUnit)} arrow placement='top'>
<span style={FORMULA_MARK_STYLES}>fx</span>
</Tooltip>
)}
</div>
);
@ -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
);
});

View File

@ -1,7 +1,13 @@
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,
normalizeFormulaExpression,
resolveInitialEditorText,
} from '../../utils/cellFormulaUtils';
import {
calculateFormula,
extractFormula,
@ -9,9 +15,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 +54,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 +67,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 +96,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 +195,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: normalizeFormulaExpression(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 +226,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: к этому моменту

View File

@ -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],
);

View File

@ -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,
<RealtimeActionsContext.Provider value={actions}>
<RealtimeConnectionContext.Provider value={connection}>
<RealtimeVspOptionsContext.Provider value={vspOptions}>
<LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
<LockedCellsStoreContext.Provider value={lockedCellsStore}>
<FormulaStoreContext.Provider value={formulaStore}>{children}</FormulaStoreContext.Provider>
</LockedCellsStoreContext.Provider>
</RealtimeVspOptionsContext.Provider>
</RealtimeConnectionContext.Provider>
</RealtimeActionsContext.Provider>

View File

@ -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}`,
};

View File

@ -0,0 +1,114 @@
// 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 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())) {
parts.push(`${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`);
}
}
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 = [];
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;
}
};
// Сервер принимает в выражении только ASCII-пробел, а клиент при вычислении снимает любые \s
// (NBSP, узкий NBSP, табуляция, перевод строки): перед отправкой убираем все пробельные символы.
export const normalizeFormulaExpression = (expr) => String(expr ?? '').replace(/\s+/g, '');

View File

@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
FORMULA_NEEDS_EQUALS_MESSAGE,
applyFormulaEvent,
buildFormulaMap,
clearLegacyFormulaStorage,
formatFormulaHint,
formatFormulaHintWithUnit,
formulaKey,
isFormulaActual,
looksLikeExpressionWithoutEquals,
normalizeFormulaExpression,
resolveInitialEditorText,
} 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);
});
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, 'Формула начинается с =');
});
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), '');
});

View File

@ -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);
},
};
};

View File

@ -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');
});

View File

@ -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 - Число для форматирования

View File

@ -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);
});

View File

@ -81,23 +81,6 @@ export const removeFromStorage = (key) => {
}
};
/**
* Удаляет устаревшие ключи формул вида `formula_<tableId>_<cellId>`,
* привязанные к позиции строки в дереве таблицы (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 - Массив ключей, которые нужно сохранить

View File

@ -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 } : {}),
},
});

View File

@ -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);
}
});

View File

@ -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: 'Вы не назначены на эту форму' },
];

View File

@ -71,3 +71,12 @@ test('AURORA-1978: префикс класса исключения asyncpg ср
);
assert.equal(getWsErrorMessage("<class 'asyncpg.exceptions.RaiseError'>: "), 'Неизвестная ошибка сервера');
});
test('AURORA-1896: invalid_formula: заменяется человеческим текстом', () => {
const expected = 'Формула содержит недопустимые символы, пуста или слишком длинная (до 500 символов)';
assert.equal(getWsErrorMessage('invalid_formula: chars'), expected);
assert.equal(
getWsErrorMessage({ code: 422, field: '', message: "<class 'asyncpg.exceptions.RaiseError'>: invalid_formula: length" }),
expected,
);
});