Merge pull request 'feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896)' (#323) from feat/320-aurora-1896-cell-formulas into test
This commit is contained in:
commit
2b780f5267
@ -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.
|
||||
|
||||
## Модули
|
||||
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|
||||
15
api/alembic/versions/0061_cell_formula.py
Normal file
15
api/alembic/versions/0061_cell_formula.py
Normal 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
|
||||
281
api/alembic/versions/sql/0061_cell_formula.sql
Normal file
281
api/alembic/versions/sql/0061_cell_formula.sql
Normal 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();
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -8,6 +8,7 @@ VALIDATION_PREFIXES = (
|
||||
"bad_booking_year:",
|
||||
"bad_booking_quarter:",
|
||||
"unsupported_scope:",
|
||||
"invalid_formula:",
|
||||
)
|
||||
|
||||
PG_404_ERRORS = (
|
||||
|
||||
@ -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, ""
|
||||
|
||||
@ -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):
|
||||
|
||||
65
api/src/repository/cell_formula_repository.py
Normal file
65
api/src/repository/cell_formula_repository.py
Normal 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()]
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
476
api/tests/integration/test_cell_formula_integration.py
Normal file
476
api/tests/integration/test_cell_formula_integration.py
Normal file
@ -0,0 +1,476 @@
|
||||
"""AURORA-1896: формулы ячеек форм (REST): запись при PATCH и GET formulas."""
|
||||
import pytest
|
||||
|
||||
FORM = "/api/v1/form/1"
|
||||
QS = "?direction=Support§ions=plan,contract_summary"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(admin_tokens, auth_headers):
|
||||
return auth_headers(admin_tokens)
|
||||
|
||||
|
||||
def _add_line(client, headers) -> int:
|
||||
r = client.post(
|
||||
f"{FORM}/sheet/AHR/line{QS}",
|
||||
json={"expense_item_id": 120, "direction": "Support"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
inputs = [x for x in r.json()["result"] if x.get("row_type") == "INPUT"]
|
||||
assert len(inputs) == 1
|
||||
return inputs[0]["data"]["line_id"]
|
||||
|
||||
|
||||
def _patch(client, headers, body, path="cell"):
|
||||
return client.patch(f"{FORM}/sheet/AHR/{path}{QS}", json=body, headers=headers)
|
||||
|
||||
|
||||
def _formulas(client, headers) -> list[dict]:
|
||||
r = client.get(f"{FORM}/sheet/AHR/formulas", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["result"]
|
||||
|
||||
|
||||
def _cell_value(client, headers, line_id, column="plan.q1"):
|
||||
r = client.get(f"{FORM}/sheet/AHR{QS}", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
for row in r.json()["result"]:
|
||||
if row.get("row_type") == "INPUT" and row["data"].get("line_id") == line_id:
|
||||
node = row["data"]
|
||||
for part in column.split("."):
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
return node
|
||||
return "ROW_NOT_FOUND"
|
||||
|
||||
|
||||
def _line_formulas(client, headers, line_id):
|
||||
return [f for f in _formulas(client, headers) if f["line_id"] == line_id]
|
||||
|
||||
|
||||
def test_patch_cell_with_formula_then_get(client, headers, admin_tokens):
|
||||
line_id = _add_line(client, headers)
|
||||
r = _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": " =100+50 "})
|
||||
assert r.status_code == 200, r.text
|
||||
found = _line_formulas(client, headers, line_id)
|
||||
assert len(found) == 1
|
||||
f = found[0]
|
||||
assert f["column"] == "plan.q1"
|
||||
assert f["expression"] == "100+50"
|
||||
assert f["value"] == 150
|
||||
me = client.get("/api/v1/auth/me", headers=headers)
|
||||
if me.status_code == 200:
|
||||
assert f["updated_by"]["id"] == me.json().get("id", f["updated_by"]["id"])
|
||||
assert f["updated_by"]["id"] > 0
|
||||
assert f["updated_at"]
|
||||
|
||||
|
||||
def test_patch_without_formula_removes(client, headers):
|
||||
line_id = _add_line(client, headers)
|
||||
assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": "100+50"}).status_code == 200
|
||||
assert len(_line_formulas(client, headers, line_id)) == 1
|
||||
assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 200}).status_code == 200
|
||||
assert _line_formulas(client, headers, line_id) == []
|
||||
|
||||
|
||||
def test_patch_invalid_formula_422_value_unchanged(client, headers):
|
||||
before = _cell_value(client, headers, 1)
|
||||
r = _patch(client, headers, {"line_id": 1, "column": "plan.q1", "value": 777, "formula": "drop table"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "invalid_formula" in r.text
|
||||
assert r.json()["field"] == "chars"
|
||||
# сессия теста откатывается целиком (conftest) — GET ниже видит состояние после отката
|
||||
assert _cell_value(client, headers, 1) == before
|
||||
assert _line_formulas(client, headers, 1) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line_col,value,formula",
|
||||
[("plan.q1", 777, "drop table"), ("plan.q1", None, "1+1"), ("header.justification", "текст", "1+1")],
|
||||
ids=["bad-chars", "null-value", "text-column"],
|
||||
)
|
||||
def test_invalid_formula_does_not_write_value(client, headers, line_col, value, formula):
|
||||
"""Атомарность без опоры на откат фикстуры: ошибку ловим внутри SAVEPOINT."""
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.services.sheet_service import SheetService
|
||||
|
||||
line_id = _add_line(client, headers)
|
||||
# исходное значение отличается от пробного, иначе «значение не изменилось» ничего не доказывает
|
||||
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") == "Отчёт не найден"
|
||||
193
api/tests/integration/test_cell_formula_sql.py
Normal file
193
api/tests/integration/test_cell_formula_sql.py
Normal 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)
|
||||
215
api/tests/integration/test_cell_formula_ws_integration.py
Normal file
215
api/tests/integration/test_cell_formula_ws_integration.py
Normal 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)
|
||||
88
api/tests/unit/test_cell_formula_repository.py
Normal file
88
api/tests/unit/test_cell_formula_repository.py
Normal 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}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
203
docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md
Normal file
203
docs/superpowers/plans/2026-11-11-aurora-1896-cell-formulas.md
Normal 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)`.
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-11
|
||||
190
openspec/changes/aurora-1896-cell-formulas/design.md
Normal file
190
openspec/changes/aurora-1896-cell-formulas/design.md
Normal 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 |
|
||||
39
openspec/changes/aurora-1896-cell-formulas/proposal.md
Normal file
39
openspec/changes/aurora-1896-cell-formulas/proposal.md
Normal 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` на утилиты формул.
|
||||
@ -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** чтение формул листа не возвращает формул этой строки
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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: к этому моменту
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
|
||||
|
||||
114
web/src/components/RealtimeTable/utils/cellFormulaUtils.js
Normal file
114
web/src/components/RealtimeTable/utils/cellFormulaUtils.js
Normal 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, '');
|
||||
171
web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
Normal file
171
web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
Normal 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), '');
|
||||
});
|
||||
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal 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');
|
||||
});
|
||||
@ -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 - Число для форматирования
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 - Массив ключей, которые нужно сохранить
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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: 'Вы не назначены на эту форму' },
|
||||
];
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user