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:
commit
f2c503c292
@ -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).
|
||||
|
||||
## Модули
|
||||
|
||||
|
||||
15
api/alembic/versions/0051_upd_form_cells_full_rows.py
Normal file
15
api/alembic/versions/0051_upd_form_cells_full_rows.py
Normal 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
|
||||
194
api/alembic/versions/sql/0051_upd_form_cells_full_rows.sql
Normal file
194
api/alembic/versions/sql/0051_upd_form_cells_full_rows.sql
Normal 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$
|
||||
;
|
||||
22
api/tests/integration/sheet_contract.py
Normal file
22
api/tests/integration/sheet_contract.py
Normal 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"
|
||||
261
api/tests/integration/test_upd_form_cells_full_rows.py
Normal file
261
api/tests/integration/test_upd_form_cells_full_rows.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user