feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896) #323

Merged
aep merged 25 commits from feat/320-aurora-1896-cell-formulas into test 2026-11-12 17:03:43 +03:00
4 changed files with 411 additions and 0 deletions
Showing only changes of commit a29207f814 - Show all commits

View File

@ -4,6 +4,8 @@
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём.
Триггеры. В схеме `v3` есть первые триггеры (миграция 0059, AURORA-1896): `AFTER DELETE FOR EACH ROW` на `budget_line`, `rent_detail`, `utility_detail`, `security_detail`, `fixed_asset_report`, `rf_project_report_line` (функции `v3.cell_formula_cleanup_<таблица>()`) удаляют формулы ячеек `v3.cell_formula` удалённой строки. Это поведение не видно из тел функций удаления (`del_budget_line`, `del_form3_line`, `sync_form2_ahr_satellite_line` и др.); формулы формы и отчёта проекта при удалении самой формы или отчёта уходят каскадом по FK.
## Модули
| Модуль | Ответственность | Зависит от |

View File

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

View File

@ -0,0 +1,255 @@
-- AURORA-1896: формулы ячеек на сервере.
--
-- Таблица v3.cell_formula хранит последнюю формулу ячейки формы или отчёта проекта.
-- Владелец - ровно один из budget_form_id / rf_project_report_id (FK с ON DELETE CASCADE:
-- формулы уходят вместе с формой при hard_delete_budget_form и с отчётом при del_project).
--
-- ТРИГГЕРЫ (первые в схеме v3). AFTER DELETE FOR EACH ROW на таблицах строк удаляют формулы
-- удалённой строки, чтобы не править каждую функцию удаления (del_budget_line,
-- sync_form2_ahr_satellite_line, del_form3_line, hard_delete_budget_form, del_project):
-- budget_line -> формулы листов AHR/CAP/OPER/SMETA этой формы по line_id = id строки;
-- rent_detail -> AHR_RENT, utility_detail -> AHR_UTILITY, security_detail -> AHR_SECURITY
-- (line_id = id детали);
-- fixed_asset_report -> OTCH9F этой формы;
-- rf_project_report_line -> формулы строки отчёта проекта.
-- AHR_LIMIT (line_id = limit_template.id, общий для всех форм) построчно не удаляется:
-- формулы уходят каскадом вместе с формой.
CREATE TABLE v3.cell_formula (
id bigserial PRIMARY KEY,
budget_form_id integer REFERENCES v3.budget_form(id) ON DELETE CASCADE,
rf_project_report_id integer REFERENCES v3.rf_project_report(id) ON DELETE CASCADE,
sheet varchar NOT NULL,
line_id integer NOT NULL,
column_key text NOT NULL,
expression text NOT NULL,
value numeric NOT NULL,
updated_by integer NOT NULL REFERENCES v3.app_user(id),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((budget_form_id IS NULL) <> (rf_project_report_id IS NULL))
);
CREATE UNIQUE INDEX cell_formula_form_uidx
ON v3.cell_formula (budget_form_id, sheet, line_id, column_key)
WHERE budget_form_id IS NOT NULL;
CREATE UNIQUE INDEX cell_formula_report_uidx
ON v3.cell_formula (rf_project_report_id, sheet, line_id, column_key)
WHERE rf_project_report_id IS NOT NULL;
CREATE INDEX cell_formula_line_idx ON v3.cell_formula (sheet, line_id);
CREATE OR REPLACE FUNCTION v3.set_cell_formulas(
p_budget_form_id integer,
p_report_id integer,
p_sheet varchar,
p_changes jsonb,
p_user_id integer
)
RETURNS TABLE(
line_id integer,
column_key text,
expression text,
value numeric,
updated_by integer,
updated_by_name text,
updated_at timestamptz
)
LANGUAGE plpgsql
AS $function$
#variable_conflict use_column
DECLARE
v_change jsonb;
v_line integer;
v_column text;
v_expr text;
v_value numeric;
BEGIN
IF (p_budget_form_id IS NULL) = (p_report_id IS NULL) THEN
RAISE EXCEPTION 'invalid_formula: owner';
END IF;
FOR v_change IN SELECT e FROM jsonb_array_elements(coalesce(p_changes, '[]'::jsonb)) AS t(e)
LOOP
BEGIN
v_line := (v_change->>'line_id')::integer;
EXCEPTION WHEN others THEN
RAISE EXCEPTION 'invalid_formula: line_id';
END;
v_column := v_change->>'column';
IF v_line IS NULL OR v_column IS NULL THEN
RAISE EXCEPTION 'invalid_formula: line_id';
END IF;
v_expr := btrim(coalesce(v_change->>'formula', ''));
IF left(v_expr, 1) = '=' THEN
v_expr := btrim(substr(v_expr, 2));
END IF;
IF v_expr = '' THEN
DELETE FROM v3.cell_formula cf
WHERE cf.sheet = p_sheet
AND cf.line_id = v_line
AND cf.column_key = v_column
AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id
AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id;
CONTINUE;
END IF;
IF char_length(v_expr) > 500 THEN
RAISE EXCEPTION 'invalid_formula: length';
END IF;
IF v_expr !~ '^[0-9+\-*/%(),. ]+$' THEN
RAISE EXCEPTION 'invalid_formula: chars';
END IF;
BEGIN
v_value := (v_change->>'value')::numeric;
EXCEPTION WHEN others THEN
RAISE EXCEPTION 'invalid_formula: value';
END;
IF v_value IS NULL THEN
RAISE EXCEPTION 'invalid_formula: value';
END IF;
IF p_budget_form_id IS NOT NULL THEN
INSERT INTO v3.cell_formula AS cf
(budget_form_id, sheet, line_id, column_key, expression, value, updated_by, updated_at)
VALUES (p_budget_form_id, p_sheet, v_line, v_column, v_expr, v_value, p_user_id, now())
ON CONFLICT (budget_form_id, sheet, line_id, column_key) WHERE budget_form_id IS NOT NULL
DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at;
ELSE
INSERT INTO v3.cell_formula AS cf
(rf_project_report_id, sheet, line_id, column_key, expression, value, updated_by, updated_at)
VALUES (p_report_id, p_sheet, v_line, v_column, v_expr, v_value, p_user_id, now())
ON CONFLICT (rf_project_report_id, sheet, line_id, column_key) WHERE rf_project_report_id IS NOT NULL
DO UPDATE SET expression = EXCLUDED.expression, value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at;
END IF;
RETURN QUERY
SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by,
u.full_name::text, cf.updated_at
FROM v3.cell_formula cf
LEFT JOIN v3.app_user u ON u.id = cf.updated_by
WHERE cf.sheet = p_sheet
AND cf.line_id = v_line
AND cf.column_key = v_column
AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id
AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id;
END LOOP;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.v_cell_formulas(
p_budget_form_id integer,
p_report_id integer,
p_sheet varchar
)
RETURNS TABLE(
line_id integer,
column_key text,
expression text,
value numeric,
updated_by integer,
updated_by_name text,
updated_at timestamptz
)
LANGUAGE plpgsql
STABLE
AS $function$
#variable_conflict use_column
BEGIN
IF (p_budget_form_id IS NULL) = (p_report_id IS NULL) THEN
RAISE EXCEPTION 'invalid_formula: owner';
END IF;
RETURN QUERY
SELECT cf.line_id, cf.column_key, cf.expression, cf.value, cf.updated_by,
u.full_name::text, cf.updated_at
FROM v3.cell_formula cf
LEFT JOIN v3.app_user u ON u.id = cf.updated_by
WHERE cf.sheet = p_sheet
AND cf.budget_form_id IS NOT DISTINCT FROM p_budget_form_id
AND cf.rf_project_report_id IS NOT DISTINCT FROM p_report_id
ORDER BY cf.line_id, cf.column_key;
END;
$function$;
-- Триггерные функции очистки (см. комментарий в начале файла)
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_budget_line()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula
WHERE budget_form_id = OLD.budget_form_id
AND sheet IN ('AHR', 'CAP', 'OPER', 'SMETA')
AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_rent_detail()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula WHERE sheet = 'AHR_RENT' AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_utility_detail()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula WHERE sheet = 'AHR_UTILITY' AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_security_detail()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula WHERE sheet = 'AHR_SECURITY' AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_fixed_asset_report()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula
WHERE budget_form_id = OLD.budget_form_id AND sheet = 'OTCH9F' AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE OR REPLACE FUNCTION v3.cell_formula_cleanup_rf_project_report_line()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
DELETE FROM v3.cell_formula
WHERE rf_project_report_id = OLD.rf_project_report_id AND line_id = OLD.id;
RETURN NULL;
END;
$function$;
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.budget_line
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_budget_line();
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.rent_detail
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_rent_detail();
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.utility_detail
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_utility_detail();
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.security_detail
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_security_detail();
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.fixed_asset_report
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_fixed_asset_report();
CREATE TRIGGER cell_formula_cleanup AFTER DELETE ON v3.rf_project_report_line
FOR EACH ROW EXECUTE FUNCTION v3.cell_formula_cleanup_rf_project_report_line();

View File

@ -0,0 +1,139 @@
"""SQL-слой формул ячеек (миграция 0059, AURORA-1896): set_cell_formulas, v_cell_formulas, триггеры, каскады."""
import json
import pytest
@pytest.fixture
def svod2_sql(client, svod2_sql):
"""Как svod2_sql, но с откатом в конце: без запросов к API клиент сам транзакцию не откатывает."""
yield svod2_sql
client.portal.call(client.db_session.rollback)
def _admin(sql):
rows = sql("SELECT id FROM v3.app_user WHERE username = 'admin'")
assert rows, "нет пользователя admin"
return rows[0][0]
def _line(sql, form_id=1):
rows = sql("SELECT id FROM v3.budget_line WHERE budget_form_id = :f LIMIT 1", {"f": form_id})
if not rows:
pytest.skip("в форме нет budget_line")
return rows[0][0]
def _set(sql, form_id, sheet, changes, user, report_id=None):
return sql(
"SELECT * FROM v3.set_cell_formulas(:f, :r, :s, CAST(:c AS jsonb), :u)",
{"f": form_id, "r": report_id, "s": sheet, "c": json.dumps(changes), "u": user},
)
def _list(sql, form_id, sheet, report_id=None):
return sql(
"SELECT * FROM v3.v_cell_formulas(:f, :r, :s)",
{"f": form_id, "r": report_id, "s": sheet},
)
def _item(line, formula=None, value=150, column="plan.q1", **extra):
d = {"line_id": line, "column": column, "value": value}
if formula is not None:
d["formula"] = formula
d.update(extra)
return d
def test_set_formula_upsert_and_list(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
rows = _set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin)
assert len(rows) == 1
r = rows[0]
assert r.expression == "100+50"
assert float(r.value) == 150
assert r.updated_by == admin
assert r.updated_by_name is not None
listed = _list(svod2_sql, 1, "AHR")
assert any(x.line_id == line and x.expression == "100+50" for x in listed)
_set(svod2_sql, 1, "AHR", [_item(line, "200-50")], admin)
mine = [x for x in _list(svod2_sql, 1, "AHR") if x.line_id == line and x.column_key == "plan.q1"]
assert len(mine) == 1
assert mine[0].expression == "200-50"
def test_set_without_formula_deletes(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
_set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin)
rows = _set(svod2_sql, 1, "AHR", [_item(line)], admin)
assert rows == []
assert [x for x in _list(svod2_sql, 1, "AHR") if x.line_id == line and x.column_key == "plan.q1"] == []
def test_normalization(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
rows = _set(svod2_sql, 1, "AHR", [_item(line, " =100+50 ")], admin)
assert rows[0].expression == "100+50"
rows = _set(svod2_sql, 1, "AHR", [_item(line, "100,5+1", value=101.5)], admin)
assert rows[0].expression == "100,5+1"
def test_empty_after_normalization_is_no_formula(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
assert _set(svod2_sql, 1, "AHR", [_item(line, " = ")], admin) == []
@pytest.mark.parametrize(
"formula,value",
[("drop table", 1), ("1" * 501, 1), ("1+1", None), ("1+1", "abc")],
)
def test_invalid_formula_raises(svod2_sql, formula, value):
admin, line = _admin(svod2_sql), _line(svod2_sql)
with pytest.raises(Exception, match="invalid_formula:"):
_set(svod2_sql, 1, "AHR", [_item(line, formula, value=value)], admin)
def test_ahr_limit_formula_scoped_to_form(svod2_sql):
admin = _admin(svod2_sql)
forms = svod2_sql("SELECT id FROM v3.budget_form ORDER BY id")
if len(forms) < 2:
pytest.skip("нужны две формы")
tpl = svod2_sql("SELECT id FROM v3.limit_template WHERE row_type = 'LEAF' LIMIT 1")
if not tpl:
pytest.skip("нет limit_template LEAF")
a, b = forms[0][0], forms[1][0]
_set(svod2_sql, a, "AHR_LIMIT", [_item(tpl[0][0], "100+50")], admin)
assert len(_list(svod2_sql, a, "AHR_LIMIT")) == 1
assert _list(svod2_sql, b, "AHR_LIMIT") == []
def test_trigger_budget_line_delete(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
new_id = 987654321
svod2_sql(
"INSERT INTO v3.budget_line SELECT * FROM jsonb_populate_record("
"NULL::v3.budget_line, (SELECT to_jsonb(b) || jsonb_build_object('id', CAST(:n AS integer)) "
"FROM v3.budget_line b WHERE b.id = :l))",
{"n": new_id, "l": line},
)
_set(svod2_sql, 1, "AHR", [_item(new_id, "1+1", value=2)], admin)
_set(svod2_sql, 1, "AHR", [_item(line, "3+3", value=6)], admin)
svod2_sql("DELETE FROM v3.budget_line WHERE id = :n", {"n": new_id})
ids = {x.line_id for x in _list(svod2_sql, 1, "AHR")}
assert new_id not in ids
assert line in ids
def test_cascade_hard_delete_form(svod2_sql):
admin, line = _admin(svod2_sql), _line(svod2_sql)
_set(svod2_sql, 1, "AHR", [_item(line, "100+50")], admin)
svod2_sql("SELECT v3.hard_delete_budget_form(1)")
assert svod2_sql("SELECT count(*) FROM v3.cell_formula WHERE budget_form_id = 1")[0][0] == 0
def test_owner_check(svod2_sql):
admin = _admin(svod2_sql)
with pytest.raises(Exception, match="invalid_formula: owner"):
_set(svod2_sql, None, "AHR", [], admin)