feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896) #323
@ -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.
|
||||
|
||||
## Модули
|
||||
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|
||||
15
api/alembic/versions/0059_cell_formula.py
Normal file
15
api/alembic/versions/0059_cell_formula.py
Normal 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
|
||||
255
api/alembic/versions/sql/0059_cell_formula.sql
Normal file
255
api/alembic/versions/sql/0059_cell_formula.sql
Normal 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();
|
||||
139
api/tests/integration/test_cell_formula_sql.py
Normal file
139
api/tests/integration/test_cell_formula_sql.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user