From 55ba4e7e96358dcb81b25017fa3e2918c9be585a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:33:58 +0400 Subject: [PATCH 1/4] =?UTF-8?q?test(form4):=20=D1=81=D1=83=D0=BC=D0=BC?= =?UTF-8?q?=D1=8B=20ROOT/GROUP/ITEM=20=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20CAP?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20v3.jsonb=5Fsum=20?= =?UTF-8?q?=D0=B8=20Python,=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0=20db=5Fsession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/integration/conftest.py | 9 +- .../integration/test_form4_sheet_sums.py | 344 ++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 api/tests/integration/test_form4_sheet_sums.py diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 81cb26d..6c067f5 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -13,8 +13,15 @@ from src.main import app @pytest.fixture -def client(): +def db_session(): + """Сессия БД теста: её же использует client, всё откатывается в конце теста.""" db_ref = SessionLocal() + yield db_ref + + +@pytest.fixture +def client(db_session): + db_ref = db_session loop_ref = None async def _get_db(): diff --git a/api/tests/integration/test_form4_sheet_sums.py b/api/tests/integration/test_form4_sheet_sums.py new file mode 100644 index 0000000..b3a35bf --- /dev/null +++ b/api/tests/integration/test_form4_sheet_sums.py @@ -0,0 +1,344 @@ +"""AURORA-1846: суммы ROOT/GROUP/ITEM на листе FORM_4/CAP. + +Два эталона: +1. старый рекурсивный агрегат v3.jsonb_sum поверх INPUT-строк (точное совпадение, + включая масштаб чисел и правило «текст сохраняется при одном вхождении»); +2. независимая сумма числовых листьев на Python. +""" +import json +from decimal import Decimal +from itertools import count + +import pytest +from sqlalchemy import text + +SHEET = "CAP" +CAP_ITEM = 525 # статья CAP depth=2 +BLOCKS = [ + "plan", "seq_dfip", "approved", "contract_summary", "allocation", "reserve", + "collegial", "ckk", "contract", "booking", "q1", "q2", "q3", "q4", "totals", +] +_names = count(1) + + +def _loads(s): + return json.loads(s, parse_float=Decimal) + + +def canon(x): + """Decimal -> str (с масштабом), чтобы 3.50 и 3.5 считались разными.""" + if isinstance(x, Decimal): + return str(x) + if isinstance(x, dict): + return {k: canon(v) for k, v in x.items()} + if isinstance(x, list): + return [canon(v) for v in x] + return x + + +def canon_num(x): + """Числа -> нормализованный Decimal: API отдаёт float, масштаб теряется.""" + if isinstance(x, bool): + return x + if isinstance(x, (int, float, Decimal)): + return Decimal(str(x)).normalize() + if isinstance(x, dict): + return {k: canon_num(v) for k, v in x.items()} + if isinstance(x, list): + return [canon_num(v) for v in x] + return x + + +def leaves(block): + """(путь, значение) для объекта глубиной до 2.""" + if not isinstance(block, dict): + return + for k1, v1 in block.items(): + if isinstance(v1, dict): + for k2, v2 in v1.items(): + yield (k1, k2), v2 + else: + yield (k1,), v1 + + +def get_path(block, path): + for k in path: + if not isinstance(block, dict) or k not in block: + return None + block = block[k] + return block + + +class F4: + def __init__(self, client, db_session, headers): + self.client, self.db, self.h = client, db_session, headers + resp = client.post( + "/api/v1/form/", + json={"form_type_code": "FORM_4", "year": 2026, "org_unit_ids": [2]}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + self.form_id = resp.json()["result"][0]["id"] + + def sql(self, query, **params): + async def _go(): + r = await self.db.execute(text(query), params) + return r.fetchall() if r.returns_rows else [] + + return self.client.portal.call(_go) + + def program(self, name=None): + name = name or f"prog{next(_names)}" + return self.sql( + "SELECT project_id FROM v3.add_form4_program(:n, :f, 'CAP') LIMIT 1", + n=name, f=self.form_id, + )[0][0] + + def project(self, program_id, name=None): + name = name or f"prj{next(_names)}" + return self.sql( + "SELECT project_id FROM v3.add_form4_project(:n, :p) LIMIT 1", + n=name, p=program_id, + )[0][0] + + def line(self, project_id): + before = {r[0] for r in self.sql("SELECT id FROM v3.budget_line WHERE budget_form_id=:f", f=self.form_id)} + resp = self.client.post( + f"/api/v1/form/{self.form_id}/sheet/{SHEET}/line", + json={"expense_item_id": CAP_ITEM, "project_id": project_id, "name": f"line{next(_names)}"}, + headers=self.h, + ) + assert resp.status_code == 200, resp.text + new = {r[0] for r in self.sql("SELECT id FROM v3.budget_line WHERE budget_form_id=:f", f=self.form_id)} - before + assert len(new) == 1 + return new.pop() + + def cell(self, line_id, column, value): + self.sql( + "SELECT v3._apply_form_cell(:l, :c, CAST(:v AS jsonb), 'CAP', :f)", + l=line_id, c=column, v=json.dumps(value), f=self.form_id, + ) + + def cells(self, line_id, values): + for col, val in values.items(): + self.cell(line_id, col, val) + + def sheet(self, sections=None): + params = {"sections": ",".join(sections)} if sections else {} + resp = self.client.get(f"/api/v1/form/{self.form_id}/sheet/{SHEET}", params=params, headers=self.h) + assert resp.status_code == 200, resp.text + return _loads(resp.text)["result"] + + def reference(self, sections=None): + """Эталон 1: старый v3.jsonb_sum по INPUT-строкам, по уровням.""" + sums = ", ".join(f"'{b}', v3.jsonb_sum(data->'{b}')" for b in BLOCKS) + q = f""" + SELECT lvl, gid, jsonb_build_object({sums})::text + FROM ( + SELECT 'P' AS lvl, (data->'header'->>'program_id')::int AS gid, data + FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT' + AND data->'header'->>'program_id' IS NOT NULL + UNION ALL + SELECT 'J', (data->'header'->>'project_id')::int, data + FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT' + AND data->'header'->>'project_id' IS NOT NULL + UNION ALL + SELECT 'R', 0, data + FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT' + ) x + GROUP BY lvl, gid + """ + rows = self.sql(q, f=self.form_id, s=sections) + return {(lvl, gid): _loads(js) for lvl, gid, js in rows} + + +@pytest.fixture +def f4(client, db_session, admin_tokens, auth_headers): + return F4(client, db_session, auth_headers(admin_tokens)) + + +def _split(rows): + root = [r for r in rows if r["row_type"] == "ROOT"] + assert len(root) == 1 + groups = {r["data"]["header"]["program_id"]: r["data"] for r in rows if r["row_type"] == "GROUP"} + items = {r["data"]["header"]["project_id"]: r["data"] for r in rows if r["row_type"] == "ITEM"} + inputs = [r["data"] for r in rows if r["row_type"] == "INPUT"] + return root[0]["data"], groups, items, inputs + + +def check_sums(f4, sections=None): + """Сверка листа с обоими эталонами. Возвращает (root, groups, items, inputs).""" + rows = f4.sheet(sections) + root, groups, items, inputs = _split(rows) + ref = f4.reference(sections) + + # Эталон 1a: ответ API совпадает с v3.jsonb_sum (числа сравниваются по значению) + assert canon_num({b: root[b] for b in BLOCKS}) == canon_num(ref[("R", 0)]) + for pid, data in groups.items(): + if ("P", pid) in ref: + assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("P", pid)]), f"program {pid}" + for jid, data in items.items(): + if ("J", jid) in ref: + assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("J", jid)]), f"project {jid}" + + # Эталон 1b: прямой вызов функции совпадает с v3.jsonb_sum точно, включая масштаб чисел + direct = f4.sql( + "SELECT row_type, data::text FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[]))", + f=f4.form_id, s=sections, + ) + d_root, d_groups, d_items, _ = _split([{"row_type": t, "data": _loads(js)} for t, js in direct]) + assert canon({b: d_root[b] for b in BLOCKS}) == canon(ref[("R", 0)]) + for pid, data in d_groups.items(): + if ("P", pid) in ref: + assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("P", pid)]), f"program {pid}" + for jid, data in d_items.items(): + if ("J", jid) in ref: + assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("J", jid)]), f"project {jid}" + + # Эталон 2: сумма числовых листьев на Python + def expect(level_inputs, agg): + for b in BLOCKS: + paths = {p for inp in level_inputs for p, _ in leaves(inp[b])} + for path in paths: + nums = [ + Decimal(str(v)) + for inp in level_inputs + for p, v in leaves(inp[b]) + if p == path and isinstance(v, (int, Decimal)) and not isinstance(v, bool) + ] + if nums: + assert Decimal(str(get_path(agg[b], path))) == sum(nums), (b, path) + + expect(inputs, root) + for pid, data in groups.items(): + lvl_inputs = [i for i in inputs if i["header"]["program_id"] == pid] + if lvl_inputs: + expect(lvl_inputs, data) + for jid, data in items.items(): + lvl_inputs = [i for i in inputs if i["header"]["project_id"] == jid] + if lvl_inputs: + expect(lvl_inputs, data) + return root, groups, items, inputs + + +def test_program_with_two_projects(f4): + prog = f4.program() + p1, p2 = f4.project(prog), f4.project(prog) + for prj, vals in ((p1, (10, 20)), (p1, (5, 7)), (p2, (100, 200)), (p2, (1, 2))): + ln = f4.line(prj) + f4.cells(ln, {"plan.q1": vals[0], "plan.q2": vals[1], "reserve.q1": vals[0]}) + root, groups, items, _ = check_sums(f4) + assert Decimal(str(items[p1]["plan"]["q1"])) == 15 + assert Decimal(str(items[p2]["plan"]["q2"])) == 202 + assert Decimal(str(groups[prog]["plan"]["q1"])) == 116 + assert Decimal(str(root["plan"]["q1"])) == 116 + + +def test_lines_directly_on_program(f4): + prog = f4.program() + prj = f4.project(prog) + direct = f4.line(prog) + in_prj = f4.line(prj) + f4.cell(direct, "plan.q1", 40) + f4.cell(in_prj, "plan.q1", 2) + root, groups, items, _ = check_sums(f4) + assert Decimal(str(groups[prog]["plan"]["q1"])) == 42 + assert Decimal(str(items[prj]["plan"]["q1"])) == 2 + assert Decimal(str(root["plan"]["q1"])) == 42 + + +def test_single_line_project_keeps_text_two_lines_null(f4): + prog = f4.program() + single, pair = f4.project(prog), f4.project(prog) + ln = f4.line(single) + f4.cells(ln, {"contract.counterparty": "ООО Один", "q1.adj_comment": "комментарий", "plan.q1": 3}) + for txt in ("ООО А", "ООО Б"): + f4.cells(f4.line(pair), {"contract.counterparty": txt, "q1.adj_comment": txt, "plan.q1": 1}) + _, groups, items, _ = check_sums(f4) + assert items[single]["contract"]["counterparty"] == "ООО Один" + assert items[single]["q1"]["adj_comment"] == "комментарий" + assert items[pair]["contract"]["counterparty"] is None + assert items[pair]["q1"]["adj_comment"] is None + assert groups[prog]["contract"]["counterparty"] is None + + +def test_single_line_program_keeps_text(f4): + prog = f4.program() + f4.cells(f4.line(prog), {"contract.counterparty": "ООО Одна", "plan.q1": 1}) + _, groups, _, _ = check_sums(f4) + assert groups[prog]["contract"]["counterparty"] == "ООО Одна" + + +def test_empty_program_and_project(f4): + filled = f4.program() + f4.cell(f4.line(filled), "plan.q1", 9) + empty_prog = f4.program() + empty_prj = f4.project(empty_prog) + _, groups, items, _ = check_sums(f4) + zero_plan = {"q1": 0, "q2": 0, "q3": 0, "q4": 0, "year": 0, "comment": None} + assert canon(groups[empty_prog]["plan"]) == canon(zero_plan) + assert canon(items[empty_prj]["plan"]) == canon(zero_plan) + + +def test_decimals_negative_and_null(f4): + prog = f4.program() + prj = f4.project(prog) + other = f4.project(prog) + a, b, c, d = f4.line(prj), f4.line(prj), f4.line(prj), f4.line(other) + f4.cells(a, {"plan.q1": "1.50", "plan.q2": -3, "plan.q3": "0.125", "reserve.q1": 7}) + f4.cells(b, {"plan.q1": 2, "plan.q2": "-0.5", "plan.q3": "10.1", "reserve.q1": None}) + f4.cells(c, {"plan.q1": None, "plan.q2": "2.000", "plan.q3": None}) + f4.cells(d, {"plan.q1": "-1.5", "plan.q2": 0}) + _, groups, items, _ = check_sums(f4) + assert Decimal(str(items[prj]["plan"]["q1"])) == Decimal("3.5") + assert Decimal(str(items[prj]["plan"]["q2"])) == Decimal("-1.5") + assert Decimal(str(groups[prog]["plan"]["q1"])) == 2 + + +def test_booking_second_level(f4): + prog = f4.program() + prj = f4.project(prog) + for q1, q2, nx in ((1, "2.5", 4), (10, None, "0.75"), (None, 3, 5)): + f4.cells(f4.line(prj), {"booking.y2026.q1": q1, "booking.y2026.q2": q2, "booking.y2027.q1": nx}) + _, groups, items, _ = check_sums(f4) + bk = items[prj]["booking"] + assert Decimal(str(bk["y2026"]["q1"])) == 11 + assert Decimal(str(bk["y2026"]["q2"])) == Decimal("5.5") + assert Decimal(str(bk["y2027"]["q1"])) == Decimal("9.75") + assert Decimal(str(groups[prog]["booking"]["y2027"]["q1"])) == Decimal("9.75") + + +def test_sections_filter(f4): + prog = f4.program() + prj = f4.project(prog) + for v in (1, 2): + f4.cells(f4.line(prj), {"plan.q1": v, "booking.y2026.q1": v * 10, "reserve.q1": v, "contract.counterparty": "x"}) + root, groups, items, _ = check_sums(f4, sections=["plan", "booking"]) + assert Decimal(str(items[prj]["plan"]["q1"])) == 3 + assert Decimal(str(items[prj]["booking"]["y2026"]["q1"])) == 30 + for b in BLOCKS: + if b not in ("plan", "booking"): + assert root[b] is None and groups[prog][b] is None and items[prj][b] is None + + +def test_cell_patch_returns_updated_ancestor_sums(f4, client): + prog = f4.program() + prj = f4.project(prog) + l1, l2 = f4.line(prj), f4.line(prj) + f4.cells(l1, {"plan.q1": 10}) + f4.cells(l2, {"plan.q1": 5}) + resp = client.patch( + f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell", + json={"line_id": l1, "column": "plan.q1", "value": 20}, + headers=f4.h, + ) + assert resp.status_code == 200, resp.text + rows = _loads(resp.text)["result"] + by_type = {r["row_type"]: r["data"] for r in rows} + assert {"ROOT", "GROUP", "ITEM"} <= set(by_type), set(by_type) + for t in ("ROOT", "GROUP", "ITEM"): + assert Decimal(str(by_type[t]["plan"]["q1"])) == 25, t + # то, что отдал PATCH, совпадает со свежим GET + _, groups, items, _ = check_sums(f4) + assert Decimal(str(groups[prog]["plan"]["q1"])) == 25 + assert Decimal(str(items[prj]["plan"]["q1"])) == 25 -- 2.47.2 From b2a022953d3fc296f59f10ce6e8540a65163ed11 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:33:58 +0400 Subject: [PATCH 2/4] =?UTF-8?q?perf(form4):=20v=5Fform4=5Fsheet=5Fjsonb=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=80=D0=B5=D0=BA=D1=83=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20jsonb=5Fsum=20(GROUPING=20SETS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/alembic/versions/0050_form4_sheet_sum.py | 15 + .../versions/sql/0050_form4_sheet_sum.sql | 602 ++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 api/alembic/versions/0050_form4_sheet_sum.py create mode 100644 api/alembic/versions/sql/0050_form4_sheet_sum.sql diff --git a/api/alembic/versions/0050_form4_sheet_sum.py b/api/alembic/versions/0050_form4_sheet_sum.py new file mode 100644 index 0000000..1f10da6 --- /dev/null +++ b/api/alembic/versions/0050_form4_sheet_sum.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0050" +down_revision = "0049" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0050_form4_sheet_sum.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0050_form4_sheet_sum.sql b/api/alembic/versions/sql/0050_form4_sheet_sum.sql new file mode 100644 index 0000000..5261c66 --- /dev/null +++ b/api/alembic/versions/sql/0050_form4_sheet_sum.sql @@ -0,0 +1,602 @@ +-- AURORA-1846: FORM_4, замена рекурсивного агрегата v3.jsonb_sum в +-- v3.v_form4_sheet_jsonb на разворот листьев jsonb + GROUPING SETS. +-- Тело — копия sql/0012_del_project.sql:1091-1617, изменён только блок +-- «4. Агрегаты». v3.jsonb_sum / v3.jsonb_numeric_sum не удаляются (эталон в тестах). + +CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE sql + STABLE +AS $function$ + -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ + WITH inp_raw AS ( + SELECT j.* + FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j + WHERE j.row_type = 'INPUT' + ), + inp AS ( + SELECT + i.*, + bl.project_id AS line_project_id, + + -- --- разрешение проекта --- + -- A. bl.project_id → project (level='project') + -- B. bl.project_id → program (level='program') — тогда проекта нет + CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id, + CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name, + + -- --- разрешение программы --- + -- A. через проект: prog_via_prj + -- B. напрямую: prog_direct + COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id, + COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name, + + -- --- section (depth=0) --- + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + + FROM inp_raw i + JOIN v3.budget_line bl ON bl.id = i.line_id + + -- проект (level='project') + LEFT JOIN v3.form4_project prj + ON prj.id = bl.project_id AND prj.level = 'project' + + -- программа через проект + LEFT JOIN v3.form4_project prog_via_prj + ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program' + + -- программа напрямую (bl.project_id → program) + LEFT JOIN v3.form4_project prog_direct + ON prog_direct.id = bl.project_id AND prog_direct.level = 'program' + + -- раздел depth=0 (через expense_item) + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN LATERAL ( + WITH RECURSIVE up AS ( + SELECT id, parent_id, name, section_code, depth + FROM v3.expense_item WHERE id = ei.id + UNION ALL + SELECT e.id, e.parent_id, e.name, e.section_code, e.depth + FROM v3.expense_item e JOIN up u ON u.parent_id = e.id + ) + SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1 + ) sec ON TRUE + ), + tp_agg AS ( + SELECT 1 AS tp_id, + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, 'comment', null + ) AS tp_plan, + + + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, + 'justification', null + ) AS tp_seq_dfip, + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0 + ) AS tp_approved, + + jsonb_build_object( + 'total', null, + 'reference', null, + 'counterparty', null, + 'deadline', null, + 'comment', null, + 'future_y1', null, + 'future_y2', null, + 'other_ssp', null, + 'centralized_flag', null + ) AS tp_contract_summary, + + jsonb_build_object( + 'contract_ref', null, + 'allocation_purpose', null + ) AS tp_allocation, + + jsonb_build_object( + 'q1', 0, 'q2', 0, + 'q3', 0, 'q4', 0, + 'year', 0, + 'justification', null + ) AS tp_reserve, + + jsonb_build_object( + 'approved', null, + 'protocol', null, + 'note', null + ) AS tp_collegial, + + jsonb_build_object( + 'ceiling', null, + 'q1', null, + 'q2', null, + 'q3', null, + 'q4', null, + 'rf_schedule', null, + 'delivery_deadline', null, + 'proc_plan', null, + 'proc_method', null, + 'comment', null + ) AS tp_ckk, + + jsonb_build_object( + 'act', null, + 'date', null, + 'scheme', null, + 'addenda', null, + 'ceiling', null, + 'comment', null, + 'subject', null, + 'currency', null, + 'deadline', null, + 'vat_rate', null, + 'reference', null, + 'q1', null, + 'q2', null, + 'q3', null, + 'q4', null, + 'rf_schedule', null, + 'counterparty', null, + 'exchange_rate', null, + 'amount_foreign', null + ) AS tp_contract, + + jsonb_build_object( + 'y2026', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null), + 'y2027', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null) + ) AS tp_booking, + + jsonb_build_object( + 'total', 0, + 'adj_rf', null, + 'pay_ho', null, + 'pay_rf', null, + 'adj_ssp', null, + 'booking', null, + 'pay_act', null, + 'pay_date', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'pay_amount', null, + 'adj_comment', null, + 'adj_current', null, + 'adj_reserve', null, + 'pay_comment', null, + 'transfer_q2', null, + 'transfer_q3', null, + 'transfer_q4', null, + 'transfer_econ', null, + 'actual_quarter', 0, + 'corrected_plan', 0, + 'residual_after_actual', 0, + 'residual_after_booking', 0 + ) AS tp_q1, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_q3', null, + 'transfer_q4', null, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q2, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_q4', null, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q3, + + jsonb_build_object( + 'target_change', null, + 'base_correction', null, + 'base_correction_comment', null, + 'revision_inc', null, + 'revision_seq', null, + 'revision_comment', null, + 'new_plan', 0, + 'adj_current', null, + 'adj_ssp', null, + 'adj_rf', null, + 'adj_reserve', null, + 'adj_comment', null, + 'corrected_plan', 0, + 'pay_date', null, + 'pay_amount', null, + 'pay_ho', null, + 'pay_rf', null, + 'pay_comment', null, + 'pay_act', null, + 'booking', null, + 'actual_m1', null, + 'actual_m2', null, + 'actual_m3', null, + 'actual_spod', null, + 'actual_quarter', 0, + 'residual_after_booking', 0, + 'residual_after_actual', 0, + 'transfer_econ', null, + 'total', 0 + ) AS tp_q4, + jsonb_build_object( + 'fact_year', 0 + ) AS tp_totals + + + + + + ), + -- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════ + all_programs AS ( + SELECT + fp.id AS prog_id, + fp.name AS prog_name, + fp.section_code, + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + FROM v3.form4_project fp + LEFT JOIN LATERAL ( + SELECT id, section_code, name + FROM v3.expense_item + WHERE section_code = fp.section_code AND depth = 0 + LIMIT 1 + ) sec ON TRUE + WHERE fp.level = 'program' + AND (p_sections IS NULL OR fp.section_code = ANY(p_sections)) + AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id + ), + + -- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════ + all_projects AS ( + SELECT + prj.id AS prj_id, + prj.name AS prj_name, + prj.parent_id AS prog_id, + ap.sec_id, ap.sec_code, ap.sec_name + FROM v3.form4_project prj + JOIN all_programs ap ON ap.prog_id = prj.parent_id + WHERE prj.level = 'project' AND (ap.section_code = p_sheet) + ), + + -- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════ + -- Замена рекурсивного агрегата v3.jsonb_sum (AURORA-1846): листья jsonb + -- разворачиваются в строки, суммируются GROUPING SETS по трём уровням + -- (раздел / программа / проект) и собираются обратно в jsonb. + -- Правила те же, что у v3.jsonb_numeric_sum: числа складываются, нечисловые + -- игнорируются, если по пути есть число; текст/null сохраняются, если путь + -- встретился в группе один раз; иначе JSON null; NULL-секция даёт NULL. + + -- Имена групп (без jsonb) + prj_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + prj_id, MAX(prj_name) AS prj_name + FROM inp WHERE prj_id IS NOT NULL + GROUP BY sec_id, prog_id, prj_id + ), + prog_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name + FROM inp WHERE prog_id IS NOT NULL + GROUP BY sec_id, prog_id + ), + root_names AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name + FROM inp + GROUP BY sec_id + ), + + -- Листья jsonb: (колонка, ключ 1-го уровня, ключ 2-го уровня или NULL, значение) + leaves AS ( + SELECT + i.sec_id, i.prog_id, i.prj_id, + c.col, e1.key AS k1, e2.key AS k2, + COALESCE(e2.value, e1.value) AS val + FROM inp i + CROSS JOIN LATERAL (VALUES + ('plan', i.plan_data), + ('seq_dfip', i.seq_dfip_data), + ('approved', i.approved_data), + ('contract_summary', i.contract_summary_data), + ('allocation', i.allocation_data), + ('reserve', i.reserve_data), + ('collegial', i.collegial_data), + ('ckk', i.ckk_data), + ('contract', i.contract_data), + ('booking', i.booking_data), + ('q1', i.q1_data), + ('q2', i.q2_data), + ('q3', i.q3_data), + ('q4', i.q4_data), + ('totals', i.totals_data) + ) AS c(col, v) + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(c.v) = 'object' THEN c.v END + ) AS e1 + LEFT JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(e1.value) = 'object' THEN e1.value END + ) AS e2 ON TRUE + ), + + -- Суммы листьев сразу по трём уровням (lvl: 3 раздел, 1 программа, 0 проект) + leaf_agg AS ( + SELECT + GROUPING(prog_id, prj_id) AS lvl, + sec_id, prog_id, prj_id, col, k1, k2, + COUNT(*) AS n, + COUNT(*) FILTER (WHERE jsonb_typeof(val) = 'number') AS n_num, + SUM((val #>> '{}')::numeric) FILTER (WHERE jsonb_typeof(val) = 'number') AS s, + (array_agg(val))[1] AS one + FROM leaves + GROUP BY GROUPING SETS ( + (sec_id, col, k1, k2), + (sec_id, prog_id, col, k1, k2), + (sec_id, prog_id, prj_id, col, k1, k2) + ) + ), + leaf_val AS ( + SELECT + lvl, sec_id, prog_id, prj_id, col, k1, k2, + CASE + WHEN n_num > 0 THEN to_jsonb(s) + WHEN n = 1 THEN one + ELSE 'null'::jsonb + END AS v + FROM leaf_agg + WHERE NOT (lvl = 1 AND prog_id IS NULL) + AND NOT (lvl = 0 AND prj_id IS NULL) + ), + -- Сборка второго уровня вложенности (booking.y2026.q1 → объект по ключу k1) + k1_val AS ( + SELECT + lvl, sec_id, prog_id, prj_id, col, k1, + CASE + WHEN bool_or(k2 IS NOT NULL) THEN jsonb_object_agg(k2, v) FILTER (WHERE k2 IS NOT NULL) + ELSE (array_agg(v))[1] + END AS v + FROM leaf_val + GROUP BY lvl, sec_id, prog_id, prj_id, col, k1 + ), + -- Сборка по колонкам: одна строка на группу + lvl_data AS ( + SELECT + lvl, sec_id, prog_id, prj_id, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'plan') AS plan_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'seq_dfip') AS seq_dfip_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'approved') AS approved_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract_summary') AS contract_summary_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'allocation') AS allocation_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'reserve') AS reserve_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'collegial') AS collegial_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'ckk') AS ckk_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract') AS contract_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'booking') AS booking_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q1') AS q1_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q2') AS q2_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q3') AS q3_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'q4') AS q4_data, + jsonb_object_agg(k1, v) FILTER (WHERE col = 'totals') AS totals_data + FROM k1_val + GROUP BY lvl, sec_id, prog_id, prj_id + ), + + prj_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM prj_names n + LEFT JOIN lvl_data d + ON d.lvl = 0 AND d.sec_id = n.sec_id AND d.prog_id = n.prog_id AND d.prj_id = n.prj_id + ), + prog_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM prog_names n + LEFT JOIN lvl_data d + ON d.lvl = 1 AND d.sec_id = n.sec_id AND d.prog_id = n.prog_id + ), + root_agg AS ( + SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data + FROM root_names n + LEFT JOIN lvl_data d + ON d.lvl = 3 AND d.sec_id = n.sec_id + ), + + -- Нулевой JSONB-шаблон (для пустых программ/проектов) + zero AS ( + SELECT '{}'::jsonb AS z + ), + + -- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════ + unioned AS ( + -- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк) + SELECT + 'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id, + jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header, + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path + FROM root_agg + + UNION ALL + + -- GROUP — из агрегатов (программы с бюджетными строками) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM prog_agg + + UNION ALL + + -- GROUP — пустые программы (нет ни одной budget_line) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved + tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve + tp_collegial, tp_ckk, -- collegial, ckk + tp_contract, tp_booking, -- contract, booking + tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM all_programs ap + JOIN tp_agg ON tp_agg.tp_id = 1 + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND ( + bl.project_id = ap.prog_id -- прямая привязка к программе + OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект + WHERE pr.parent_id = ap.prog_id AND pr.level = 'project') + ) + ) + + UNION ALL + + -- ITEM — из агрегатов (проекты с бюджетными строками) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM prj_agg + + UNION ALL + + -- ITEM — пустые проекты (нет budget_line) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved + tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve + tp_collegial, tp_ckk, -- collegial, ckk + tp_contract, tp_booking, -- contract, booking + tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM all_projects apr + JOIN tp_agg ON tp_agg.tp_id = 1 + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND bl.project_id = apr.prj_id + ) + + UNION ALL + + -- INPUT — все строки бюджета + SELECT + 'INPUT', 3, line_id, + header || jsonb_build_object( + 'project_id', prj_id, 'project_name', prj_name, + 'program_id', prog_id, 'program_name', prog_name + ), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(CASE WHEN prj_id is null and prog_id is not null THEN 0 ELSE prj_id END, 999999), line_id]::INT[] + FROM inp + ) + + SELECT + u.row_type, u.depth, + ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order, + jsonb_build_object( + 'sort_path', u.sort_path, + 'line_id', u.line_id, + 'header', u.header, + 'plan', u.plan_data, + 'seq_dfip', u.seq_dfip_data, + 'approved', u.approved_data, + 'contract_summary', u.contract_summary_data, + 'allocation', u.allocation_data, + 'reserve', u.reserve_data, + 'collegial', u.collegial_data, + 'ckk', u.ckk_data, + 'contract', u.contract_data, + 'booking', u.booking_data, + 'q1', u.q1_data, + 'q2', u.q2_data, + 'q3', u.q3_data, + 'q4', u.q4_data, + 'totals', u.totals_data + ) AS data + FROM unioned u + ORDER BY u.sort_path; +$function$ +; -- 2.47.2 From 95661c0ae51f9af2f02a734bb2a0fcff557a1cbb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:33:58 +0400 Subject: [PATCH 3/4] =?UTF-8?q?perf(analysis):=20form4=5Fcap=5Fsheet=20?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D1=82=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=200050?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- perf/analysis/explain_manual.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/perf/analysis/explain_manual.py b/perf/analysis/explain_manual.py index 8a9bde3..a05ee80 100644 --- a/perf/analysis/explain_manual.py +++ b/perf/analysis/explain_manual.py @@ -71,8 +71,8 @@ def queries(f2: int, f4: int) -> list[dict]: "sql": f"SELECT row_type, depth, sort_order, data FROM v3.v_form_view({f2}, 'AHR', NULL::text[], NULL::varchar)", "variants": [("", {}), (".jit_off", {"jit": "off"}), (".work_mem_64MB", {"work_mem": "64MB"}), (".rerun_default", {})], "user": True}, {"name": "form4_cap_sheet", "what": "FORM_4/CAP: тело v3.v_form4_sheet_jsonb (300 строк после grow)", - "src": "api/alembic/versions/sql/0012_del_project.sql:1097-…", - "sql": None, "func_body": ("0012_del_project.sql", 1097, "WITH inp_raw AS ("), "form_id": f4, "sheet": "CAP", + "src": "api/alembic/versions/sql/0050_form4_sheet_sum.sql:12-…", + "sql": None, "func_body": ("0050_form4_sheet_sum.sql", 12, "WITH inp_raw AS ("), "form_id": f4, "sheet": "CAP", "variants": [("", {}), (".jit_off", {"jit": "off"})]}, {"name": "form_view_form4_cap", "what": "FORM_4/CAP: вызов v3.v_form_view (как GET листа)", "src": "api/src/repository (sheet_repository)", -- 2.47.2 From 05fbfd1e1d2787973a23a2d3dc1ebf568457bce7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 12:11:25 +0400 Subject: [PATCH 4/4] =?UTF-8?q?perf(form4):=20jit=3Doff=20=D0=BD=D0=B0=20v?= =?UTF-8?q?=5Fform4=5Fsheet=5Fjsonb=20=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=B8=D0=BB=D1=8F=D1=86=D0=B8=D1=8F=20JIT=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- api/alembic/versions/sql/0050_form4_sheet_sum.sql | 1 + api/tests/integration/test_form4_sheet_sums.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/api/alembic/versions/sql/0050_form4_sheet_sum.sql b/api/alembic/versions/sql/0050_form4_sheet_sum.sql index 5261c66..46bea3d 100644 --- a/api/alembic/versions/sql/0050_form4_sheet_sum.sql +++ b/api/alembic/versions/sql/0050_form4_sheet_sum.sql @@ -7,6 +7,7 @@ CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(p_form_id integer, p_sheet cha RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) LANGUAGE sql STABLE + SET jit TO 'off' -- оценка плана выше порога JIT, компиляция дороже выполнения (AURORA-1846) AS $function$ -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ WITH inp_raw AS ( diff --git a/api/tests/integration/test_form4_sheet_sums.py b/api/tests/integration/test_form4_sheet_sums.py index b3a35bf..7b8423a 100644 --- a/api/tests/integration/test_form4_sheet_sums.py +++ b/api/tests/integration/test_form4_sheet_sums.py @@ -342,3 +342,14 @@ def test_cell_patch_returns_updated_ancestor_sums(f4, client): _, groups, items, _ = check_sums(f4) assert Decimal(str(groups[prog]["plan"]["q1"])) == 25 assert Decimal(str(items[prj]["plan"]["q1"])) == 25 + + +def test_jit_off_on_sheet_jsonb(f4): + """AURORA-1846: v_form4_sheet_jsonb выполняется с jit=off (оценка плана выше порога JIT).""" + rows = f4.sql( + """SELECT p.proname, COALESCE(p.proconfig, '{}') FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'v3' AND p.proname = 'v_form4_sheet_jsonb'""" + ) + assert len(rows) == 1, rows + assert "jit=off" in rows[0][1], f"нет SET jit = off: {rows[0][1]}" -- 2.47.2