diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3edd31..f4bf145 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. + ## Модули | Модуль | Ответственность | Зависит от | diff --git a/api/alembic/versions/0059_cell_formula.py b/api/alembic/versions/0059_cell_formula.py new file mode 100644 index 0000000..5bb8695 --- /dev/null +++ b/api/alembic/versions/0059_cell_formula.py @@ -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 diff --git a/api/alembic/versions/sql/0059_cell_formula.sql b/api/alembic/versions/sql/0059_cell_formula.sql new file mode 100644 index 0000000..3f0c30d --- /dev/null +++ b/api/alembic/versions/sql/0059_cell_formula.sql @@ -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(); diff --git a/api/tests/integration/test_cell_formula_sql.py b/api/tests/integration/test_cell_formula_sql.py new file mode 100644 index 0000000..7b6a2c6 --- /dev/null +++ b/api/tests/integration/test_cell_formula_sql.py @@ -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)