feat(formulas): таблица cell_formula, set/v_cell_formulas, триггеры очистки, миграция 0059 (AURORA-1896)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1187ac2f8f
commit
a29207f814
@ -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`) идёт тем же путём.
|
Поток редактирования ячейки: `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