Merge pull request 'AURORA-1844: upd_form_cells возвращает строки целиком' (#231) from feat/215-aurora-1844-upd-form-cells-full-rows into test

This commit is contained in:
aep 2026-11-01 09:46:45 +03:00
commit f2c503c292
5 changed files with 493 additions and 1 deletions

View File

@ -2,7 +2,7 @@
Монорепозиторий: `api/` (FastAPI) отдаёт REST, WebSocket и собранный фронт; `web/` — SPA на React. Основная бизнес-логика — PL/pgSQL-функции схемы `v3` в Postgres. Python-слой проверяет роли, валидирует запрос, вызывает функцию и маппит её ошибки.
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым.
Поток редактирования ячейки: `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).
## Модули

View File

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

View File

@ -0,0 +1,194 @@
-- AURORA-1844: v3.upd_form_cells возвращает затронутые строки целиком.
-- Убран снимок «до» и _jsonb_diff: после применения правок выполняется один
-- вызов v_form_view (внутри v3._form_affected_rows), ответ — полные строки
-- изменённых INPUT и всех их предков-агрегатов (включая неизменившиеся).
-- Обёртка с p_user_id (0031) и upd_form_cell не меняются.
CREATE OR REPLACE FUNCTION v3._form_affected_rows(p_form_id integer, p_sheet character varying, p_ids integer[], p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_strip TEXT[] := ARRAY[]::TEXT[];
v_lc_path INT[];
v_eid_set INT[];
v_ei_path INT[];
BEGIN
SELECT bf.form_type_code INTO v_form_type
FROM v3.budget_form bf
WHERE bf.id = p_form_id;
-- Ключи верхнего уровня исключённых разделов (при p_sections они приходят как null
-- и затёрли бы данные клиента). Вложенные null не трогаем: это очистка ячейки.
IF p_sections IS NOT NULL THEN
SELECT COALESCE(array_agg(k), ARRAY[]::TEXT[])
INTO v_strip
FROM unnest(ARRAY['plan','seq_dfip','seq_ssp','approved','contract','booking',
'contract_summary','allocation','collegial','ckk',
'sequestration','reserve','contract_detail',
'q1','q2','q3','q4','totals',
'fact_q1','fact_q2','fact_q3','fact_q4']) AS k
WHERE NOT (k = ANY(p_sections));
END IF;
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = ANY(p_ids)
ORDER BY v.sort_order;
ELSIF p_sheet = 'AHR_LIMIT' THEN
v_lc_path := v3._limit_template_path_set(p_ids);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE (v.data->>'id')::INT = ANY(v_lc_path)
ORDER BY v.sort_order;
ELSIF p_sheet = 'OTCH9F' THEN
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE EXISTS (
SELECT 1 FROM v3.fixed_asset_report far
WHERE far.id = ANY(p_ids)
AND far.expense_item_id = (v.data->>'expense_item_id')::INT
AND far.equipment_name IS NOT DISTINCT FROM (v.data->>'equipment_name'))
ORDER BY v.sort_order;
ELSE
SELECT array_agg(DISTINCT bl.expense_item_id)
INTO v_eid_set
FROM v3.budget_line bl
WHERE bl.id = ANY(p_ids);
v_ei_path := v3._expense_item_path_set(v_eid_set);
IF v_form_type = 'FORM_4' THEN
-- ROOT раздела, GROUP по program_id и ITEM по project_id изменённых строк
-- (у GROUP/ITEM FORM_4 expense_item_id = id раздела, поэтому фильтр
-- по expense_item_id захватил бы все программы и проекты раздела).
RETURN QUERY
WITH vv AS MATERIALIZED (
SELECT f.row_type, f.depth, f.sort_order, f.data
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) f
WHERE f.row_type <> 'META'
),
chg AS (
SELECT DISTINCT (x.data->'header'->>'program_id')::INT AS program_id,
(x.data->'header'->>'project_id')::INT AS project_id
FROM vv x
WHERE x.row_type = 'INPUT' AND (x.data->>'line_id')::INT = ANY(p_ids)
)
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM vv v
WHERE (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = ANY(p_ids))
OR (v.row_type = 'ROOT'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'GROUP'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
AND (v.data->'header'->>'program_id')::INT IN (SELECT c.program_id FROM chg c))
OR (v.row_type = 'ITEM'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
AND (v.data->'header'->>'project_id')::INT IN (SELECT c.project_id FROM chg c))
ORDER BY v.sort_order;
ELSE
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM','SUB_SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'INPUT'
AND (v.data->>'line_id')::INT = ANY(p_ids))
ORDER BY v.sort_order;
END IF;
END IF;
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.upd_form_cells(p_form_id integer, p_sheet character varying, p_changes jsonb, p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_change JSONB;
v_line_id INT;
v_column TEXT;
v_changed_lines INT[] := ARRAY[]::INT[];
BEGIN
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_line_id := (v_change->>'line_id')::INT;
v_column := v_change->>'column';
IF v_line_id IS NULL OR v_column IS NULL THEN
RAISE EXCEPTION 'change must have line_id and column: %', v_change;
END IF;
IF p_sheet = 'AHR_LIMIT' THEN
IF NOT EXISTS (SELECT 1 FROM v3.limit_template lt
WHERE lt.id = v_line_id AND lt.row_type = 'LEAF') THEN
RAISE EXCEPTION 'limit_template.id=% не существует или не LEAF', v_line_id;
END IF;
ELSIF p_sheet = 'OTCH9F' THEN
IF NOT EXISTS (SELECT 1 FROM v3.fixed_asset_report
WHERE id = v_line_id AND budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'fixed_asset_report.id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_RENT' THEN
IF NOT EXISTS (SELECT 1 FROM v3.rent_detail rd
JOIN v3.budget_line bl ON bl.id = rd.line_id
WHERE rd.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'rent_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_UTILITY' THEN
IF NOT EXISTS (SELECT 1 FROM v3.utility_detail ud
JOIN v3.budget_line bl ON bl.id = ud.line_id
WHERE ud.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'utility_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_SECURITY' THEN
IF NOT EXISTS (SELECT 1 FROM v3.security_detail sd
JOIN v3.budget_line bl ON bl.id = sd.line_id
WHERE sd.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'security_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSE
IF NOT EXISTS (SELECT 1 FROM v3.budget_line
WHERE id = v_line_id AND budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'budget_line id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
END IF;
IF NOT (v_line_id = ANY(v_changed_lines)) THEN
v_changed_lines := v_changed_lines || v_line_id;
END IF;
END LOOP;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
PERFORM v3._apply_form_cell(
(v_change->>'line_id')::INT,
v_change->>'column',
v_change->'value',
p_sheet,
p_form_id);
END LOOP;
RETURN QUERY
SELECT r.row_type, r.depth, r.sort_order, r.data
FROM v3._form_affected_rows(p_form_id, p_sheet, v_changed_lines, p_direction, p_sections) r
ORDER BY r.sort_order;
END;
$function$
;

View File

@ -0,0 +1,22 @@
"""Переиспользуемая проверка контракта ответа на правку ячеек.
Каждая строка ответа PATCH /cell(s) должна совпасть (data и sort_order) со строкой
того же (row_type, depth, sort_order) из последующего GET /sheet.
Используется в AURORA-1844 и далее (AURORA-1850: пересчёт только затронутого поддерева).
"""
def row_key(row: dict) -> tuple:
return (row["row_type"], row["depth"], row["sort_order"])
def assert_patch_rows_match_sheet(patch_rows: list[dict], sheet_rows: list[dict]) -> None:
"""Строки ответа на правку целиком совпадают со строками листа."""
sheet_by_key = {row_key(r): r for r in sheet_rows}
assert patch_rows, "ответ на правку пуст"
keys = [row_key(r) for r in patch_rows]
assert len(keys) == len(set(keys)), "в ответе дубли строк"
for row in patch_rows:
key = row_key(row)
assert key in sheet_by_key, f"строки {key} нет в GET /sheet"
assert row["data"] == sheet_by_key[key]["data"], f"data строки {key} расходится с GET"

View File

@ -0,0 +1,261 @@
"""AURORA-1844: v3.upd_form_cells возвращает затронутые строки целиком."""
import uuid
import pytest
from sqlalchemy import text
from src.db.session import get_db
from src.main import app
from sheet_contract import assert_patch_rows_match_sheet
API = "/api/v1/form"
@pytest.fixture
def headers(admin_tokens, auth_headers):
return auth_headers(admin_tokens)
@pytest.fixture
def db_exec(client):
"""Выполняет SQL в той же (откатываемой) сессии, что и HTTP-запросы клиента."""
state = {}
def run(sql: str, **params):
async def _go():
if "db" not in state:
state["gen"] = app.dependency_overrides[get_db]()
state["db"] = await state["gen"].__anext__()
res = await state["db"].execute(text(sql), params)
return res.all() if res.returns_rows else []
return client.portal.call(_go)
return run
def _qs(direction=None, sections=None):
params = []
if direction:
params.append(f"direction={direction}")
if sections:
params.append(f"sections={sections}")
return ("?" + "&".join(params)) if params else ""
def _get_sheet(client, headers, form_id, sheet, direction=None, sections=None):
r = client.get(f"{API}/{form_id}/sheet/{sheet}{_qs(direction, sections)}", headers=headers)
assert r.status_code == 200, r.text
return r.json()["result"]
def _patch_cell(client, headers, form_id, sheet, line_id, column, value, direction=None, sections=None):
r = client.patch(
f"{API}/{form_id}/sheet/{sheet}/cell{_qs(direction, sections)}",
json={"line_id": line_id, "column": column, "value": value},
headers=headers,
)
assert r.status_code == 200, r.text
return r.json()["result"]
def _new_form(client, headers, form_type, year, org=1):
r = client.post(
f"{API}/",
json={"year": year, "form_type_code": form_type, "org_unit_ids": [org]},
headers=headers,
)
assert r.status_code == 200, r.text
return r.json()["result"][0]["id"]
def _leaf_items(db_exec, form_type, sheet, n):
"""n листовых статей одного родителя (чтобы у строк был общий предок-агрегат)."""
rows = db_exec(
"""
SELECT ei.id FROM v3.expense_item ei
JOIN v3.expense_item_form_type t ON t.expense_item_id = ei.id AND t.form_type_code = :ft
WHERE ei.sheet = :sheet AND ei.direction = 'Support'
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c WHERE c.parent_id = ei.id)
AND ei.parent_id IN (
SELECT ei2.parent_id FROM v3.expense_item ei2
JOIN v3.expense_item_form_type t2 ON t2.expense_item_id = ei2.id AND t2.form_type_code = :ft
WHERE ei2.sheet = :sheet AND ei2.direction = 'Support'
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c2 WHERE c2.parent_id = ei2.id)
GROUP BY ei2.parent_id HAVING count(*) >= :n
ORDER BY ei2.parent_id LIMIT 1)
ORDER BY ei.id LIMIT :n
""",
ft=form_type, sheet=sheet, n=n,
)
assert len(rows) == n
return [r[0] for r in rows]
def _add_line(client, headers, form_id, sheet, item_id, direction=None, **extra):
body = {"expense_item_id": item_id, **extra}
if direction:
body["direction"] = direction
r = client.post(f"{API}/{form_id}/sheet/{sheet}/line{_qs(direction)}", json=body, headers=headers)
assert r.status_code == 200, r.text
for row in r.json()["result"]:
if row["row_type"] == "INPUT":
return row["data"]["line_id"]
raise AssertionError("в ответе add_line нет INPUT")
def _plan_q1(row):
return row["data"]["plan"]["q1"]
def _aggregates(rows):
return [r for r in rows if r["row_type"] != "INPUT"]
def _two_lines_case(client, headers, db_exec, form_type, form_id, sheet, direction=None):
items = _leaf_items(db_exec, form_type, sheet, 2)
l1 = _add_line(client, headers, form_id, sheet, items[0], direction)
l2 = _add_line(client, headers, form_id, sheet, items[1], direction)
_patch_cell(client, headers, form_id, sheet, l1, "plan.q1", 10, direction)
patch = _patch_cell(client, headers, form_id, sheet, l2, "plan.q1", 20, direction)
return l1, l2, patch
@pytest.mark.parametrize(
"form_type,sheet,direction,year",
[
("FORM_1", "AHR", "Support", 2031),
("FORM_2", "AHR", None, 2032),
("FORM_2", "CAP", None, 2033),
("FORM_2", "OPER", None, 2034),
],
)
def test_patch_cell_returns_full_rows_matching_get(
client, headers, db_exec, form_type, sheet, direction, year
):
form_id = _new_form(client, headers, form_type, year)
l1, l2, patch = _two_lines_case(client, headers, db_exec, form_type, form_id, sheet, direction)
get_rows = _get_sheet(client, headers, form_id, sheet, direction)
assert_patch_rows_match_sheet(patch, get_rows)
inputs = [r for r in patch if r["row_type"] == "INPUT"]
assert [r["data"]["line_id"] for r in inputs] == [l2]
aggs = _aggregates(patch)
assert aggs, "нет предков-агрегатов"
# общий предок видит сумму обеих строк (форма новая, других строк нет)
assert any(_plan_q1(r) == 30 for r in aggs)
assert all(_plan_q1(r) in (30, 20, 10) for r in aggs if _plan_q1(r) is not None)
def test_unchanged_ancestor_is_returned_in_full(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_2", 2035)
l1, l2, _ = _two_lines_case(client, headers, db_exec, "FORM_2", form_id, "AHR")
patch = _patch_cell(client, headers, form_id, "AHR", l2, "header.justification", "обоснование")
get_rows = _get_sheet(client, headers, form_id, "AHR")
assert_patch_rows_match_sheet(patch, get_rows)
# суммы предков не менялись, но строки пришли целиком
assert any(_plan_q1(r) == 30 for r in _aggregates(patch))
def test_clearing_value_decreases_ancestor_sums(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_2", 2036)
l1, l2, _ = _two_lines_case(client, headers, db_exec, "FORM_2", form_id, "AHR")
patch = _patch_cell(client, headers, form_id, "AHR", l2, "plan.q1", None)
assert_patch_rows_match_sheet(patch, _get_sheet(client, headers, form_id, "AHR"))
inp = next(r for r in patch if r["row_type"] == "INPUT")
assert "q1" in inp["data"]["plan"] and inp["data"]["plan"]["q1"] in (None, 0)
assert all(_plan_q1(r) == 10 for r in _aggregates(patch) if _plan_q1(r) is not None)
def test_sections_filter_drops_other_sections_keys(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_2", 2037)
items = _leaf_items(db_exec, "FORM_2", "AHR", 1)
line = _add_line(client, headers, form_id, "AHR", items[0])
patch = _patch_cell(client, headers, form_id, "AHR", line, "plan.q1", 5, sections="plan")
assert patch
allowed = {"plan", "header", "line_id", "sort_path"}
excluded = {"seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2",
"q3", "q4", "totals", "contract_summary", "allocation", "collegial", "ckk"}
for row in patch:
assert not (set(row["data"]) & excluded), set(row["data"]) & excluded
assert "plan" in row["data"]
del allowed
def test_bad_column_in_batch_fails_and_applies_nothing(client, headers):
# строка фикстуры budget_line 1 (FORM_1 / AHR / Support)
before = _get_sheet(client, headers, 1, "AHR", "Support")
r = client.patch(
f"{API}/1/sheet/AHR/cells?direction=Support",
json={"changes": [
{"line_id": 1, "column": "plan.q1", "value": 7},
{"line_id": 1, "column": "bad.scope", "value": 1},
]},
headers=headers,
)
# unknown_column сейчас маппится в 400 (как в test_db_functions_integration)
assert r.status_code in (400, 422), r.text
after = _get_sheet(client, headers, 1, "AHR", "Support")
assert before == after
def test_form4_returns_only_own_program_and_project(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_4", 2039)
items = _leaf_items(db_exec, "FORM_4", "AHR", 2)
sec = db_exec("SELECT section_code FROM v3.expense_item WHERE id = :i", i=items[0])[0][0]
ids = {}
for pname in ("A", "B"):
prog = db_exec(
"INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) "
"VALUES (:n, 'program', NULL, :f, :s) RETURNING id",
n=f"prog{pname}", f=form_id, s=sec)[0][0]
prj = db_exec(
"INSERT INTO v3.form4_project (name, level, parent_id, form_id) "
"VALUES (:n, 'project', :p, :f) RETURNING id",
n=f"prj{pname}", p=prog, f=form_id)[0][0]
ids[pname] = (prog, prj)
lines = {}
for pname, item in zip(("A", "B"), items):
lines[pname] = _add_line(client, headers, form_id, "AHR", item, project_id=ids[pname][1])
patch = _patch_cell(client, headers, form_id, "AHR", lines["A"], "plan.q1", 15)
get_rows = _get_sheet(client, headers, form_id, "AHR")
assert_patch_rows_match_sheet(patch, get_rows)
prog_a, prj_a = ids["A"]
types = [r["row_type"] for r in patch]
assert "ROOT" in types and "GROUP" in types and "ITEM" in types
for r in patch:
h = r["data"].get("header") or {}
if r["row_type"] == "GROUP":
assert h["program_id"] == prog_a
if r["row_type"] == "ITEM":
assert h["project_id"] == prj_a
if r["row_type"] == "INPUT":
assert r["data"]["line_id"] == lines["A"]
# в листе есть чужие программа/проект, в ответе их нет
assert {r["data"]["header"].get("program_id") for r in get_rows if r["row_type"] == "GROUP"} >= {
ids["A"][0], ids["B"][0]}
def test_ahr_rent_full_row_and_synced_into_ahr(client, headers):
form_id = 4
vsp = 1
r = client.post(
f"{API}/{form_id}/sheet/AHR_RENT/line",
json={"vsp_id": vsp, "contract_number": f"C-{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert r.status_code == 200, r.text
rent_id = r.json()["result"][0]["data"]["id"]
patch = _patch_cell(client, headers, form_id, "AHR_RENT", rent_id, "plan.q1", 100)
assert_patch_rows_match_sheet(patch, _get_sheet(client, headers, form_id, "AHR_RENT"))
assert len(patch) == 1
assert patch[0]["data"]["id"] == rent_id
assert patch[0]["data"]["plan"]["q1"] == 100
ahr = _get_sheet(client, headers, form_id, "AHR")
assert any(
x["row_type"] != "META" and (x["data"].get("plan") or {}).get("q1") == 100 for x in ahr
), "синхронизированная сумма не видна в AHR"