From 4708acbd20dd0c9155e26da8eb245264159907a0 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 27 Aug 2026 14:00:38 +0300 Subject: [PATCH] =?UTF-8?q?svod:=20=D1=81=D0=B2=D0=BE=D0=B4=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0022_summary2_contract_rows.py | 61 +++ .../versions/sql/0021_summary_views.sql | 66 +-- .../sql/0022_summary2_contract_rows.sql | 460 ++++++++++++++++++ api/src/api/v1/summary.py | 68 ++- api/src/repository/summary_repository.py | 46 ++ api/src/services/summary_service.py | 20 + .../integration/test_summary_api_smoke.py | 101 ++++ 7 files changed, 765 insertions(+), 57 deletions(-) create mode 100644 api/alembic/versions/0022_summary2_contract_rows.py create mode 100644 api/alembic/versions/sql/0022_summary2_contract_rows.sql diff --git a/api/alembic/versions/0022_summary2_contract_rows.py b/api/alembic/versions/0022_summary2_contract_rows.py new file mode 100644 index 0000000..67223de --- /dev/null +++ b/api/alembic/versions/0022_summary2_contract_rows.py @@ -0,0 +1,61 @@ +import os +import re + +from alembic import op + + +revision = "0022" +down_revision = "0021" +branch_labels = None +depends_on = None + + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _split_statements(sql: str) -> list[str]: + statements: list[str] = [] + current: list[str] = [] + dollar_tag: str | None = None + + for line in sql.splitlines(): + stripped = line.strip() + if stripped.startswith("--"): + continue + + if dollar_tag is None: + match = _DOLLAR_TAG_RE.search(stripped) + if match: + dollar_tag = match.group(0) + elif stripped.startswith(dollar_tag): + dollar_tag = None + + current.append(line) + if dollar_tag is None and stripped.endswith(";"): + statements.append("\n".join(current)) + current = [] + + if remaining := "\n".join(current).strip(): + statements.append(remaining) + return statements + + +def upgrade() -> None: + ddl_path = os.path.join( + os.path.dirname(__file__), "sql", "0022_summary2_contract_rows.sql" + ) + with open(ddl_path) as ddl_file: + statements = _split_statements(ddl_file.read()) + for statement in statements: + op.execute(statement.rstrip(";").strip()) + + +def downgrade() -> None: + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar)") + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar)") + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer)") + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer)") + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[])") + op.execute("DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[])") + op.execute("DROP INDEX IF EXISTS v3.ix_v3_budget_line_svod2") + op.execute("DROP INDEX IF EXISTS v3.ix_v3_budget_form_svod2") diff --git a/api/alembic/versions/sql/0021_summary_views.sql b/api/alembic/versions/sql/0021_summary_views.sql index fc4cd4e..16ac57b 100644 --- a/api/alembic/versions/sql/0021_summary_views.sql +++ b/api/alembic/versions/sql/0021_summary_views.sql @@ -1238,50 +1238,10 @@ FROM rolled AS r; DROP FUNCTION IF EXISTS v3.v_main_format(integer); -CREATE OR REPLACE FUNCTION v3.v_main_format(p_year integer, p_org_unit_ids integer[] DEFAULT NULL) - RETURNS TABLE( - section_code character varying, - name character varying, - plan_support_q1 numeric, - plan_support_q2 numeric, - plan_support_q3 numeric, - plan_support_q4 numeric, - plan_support_year numeric, - plan_development_q1 numeric, - plan_development_q2 numeric, - plan_development_q3 numeric, - plan_development_q4 numeric, - plan_development_year numeric, - plan_total_year numeric, - approved_support_q1 numeric, - approved_support_q2 numeric, - approved_support_q3 numeric, - approved_support_q4 numeric, - approved_support_year numeric, - approved_development_q1 numeric, - approved_development_q2 numeric, - approved_development_q3 numeric, - approved_development_q4 numeric, - approved_development_year numeric, - approved_total_year numeric, - fact_support_q1 numeric, - fact_support_q2 numeric, - fact_support_q3 numeric, - fact_support_q4 numeric, - fact_support_year numeric, - fact_development_q1 numeric, - fact_development_q2 numeric, - fact_development_q3 numeric, - fact_development_q4 numeric, - fact_development_year numeric, - fact_total_year numeric, - corrected_support_q2 numeric, - corrected_support_q3 numeric, - corrected_support_q4 numeric, - corrected_development_q2 numeric, - corrected_development_q3 numeric, - corrected_development_q4 numeric - ) +-- DROP FUNCTION v3.v_main_format(int4, _int4); + +CREATE OR REPLACE FUNCTION v3.v_main_format(p_year integer, p_org_unit_ids integer[] DEFAULT NULL::integer[]) + RETURNS TABLE(section_code character varying, name character varying, plan_support_q1 numeric, plan_support_q2 numeric, plan_support_q3 numeric, plan_support_q4 numeric, plan_support_year numeric, plan_development_q1 numeric, plan_development_q2 numeric, plan_development_q3 numeric, plan_development_q4 numeric, plan_development_year numeric, plan_total_year numeric, approved_support_q1 numeric, approved_support_q2 numeric, approved_support_q3 numeric, approved_support_q4 numeric, approved_support_year numeric, approved_development_q1 numeric, approved_development_q2 numeric, approved_development_q3 numeric, approved_development_q4 numeric, approved_development_year numeric, approved_total_year numeric, fact_support_q1 numeric, fact_support_q2 numeric, fact_support_q3 numeric, fact_support_q4 numeric, fact_support_year numeric, fact_development_q1 numeric, fact_development_q2 numeric, fact_development_q3 numeric, fact_development_q4 numeric, fact_development_year numeric, fact_total_year numeric, corrected_support_q2 numeric, corrected_support_q3 numeric, corrected_support_q4 numeric, corrected_development_q2 numeric, corrected_development_q3 numeric, corrected_development_q4 numeric) LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' @@ -1350,18 +1310,22 @@ summed AS ( SELECT s.section_code, s.name, s.plan_support_q1, s.plan_support_q2, s.plan_support_q3, s.plan_support_q4, s.plan_support_year, - s.approved_support_q1, s.approved_support_q2, s.approved_support_q3, s.approved_support_q4, s.approved_support_year, + s.plan_development_q1, s.plan_development_q2, s.plan_development_q3, s.plan_development_q4, s.plan_development_year, s.plan_support_year + s.plan_development_year AS plan_total_year, - s.fact_support_q1, s.fact_support_q2, s.fact_support_q3, s.fact_support_q4, s.fact_support_year, - s.corrected_support_q2, s.corrected_support_q3, s.corrected_support_q4, s.plan_development_q1, s.plan_development_q2, + s.approved_support_q1, s.approved_support_q2, s.approved_support_q3, s.approved_support_q4, s.approved_support_year, + s.approved_development_q1, s.approved_development_q2, s.approved_development_q3, s.approved_development_q4, s.approved_development_year, s.approved_support_year + s.approved_development_year AS approved_total_year, - s.plan_development_q3, s.plan_development_q4, s.plan_development_year, s.approved_development_q1, s.approved_development_q2, - s.approved_development_q3, s.approved_development_q4, s.approved_development_year, s.fact_development_q1, s.fact_development_q2, + + s.fact_support_q1, s.fact_support_q2, s.fact_support_q3, s.fact_support_q4, s.fact_support_year, + s.fact_development_q1, s.fact_development_q2, s.fact_development_q3, s.fact_development_q4, s.fact_development_year, s.fact_support_year + s.fact_development_year AS fact_total_year, - s.fact_development_q3, s.fact_development_q4, s.fact_development_year, + + s.corrected_support_q2, s.corrected_support_q3, s.corrected_support_q4, s.corrected_development_q2, s.corrected_development_q3, s.corrected_development_q4 FROM summed AS s; -$function$; +$function$ +; + -- ============================================================================= -- main_format — VIEW diff --git a/api/alembic/versions/sql/0022_summary2_contract_rows.sql b/api/alembic/versions/sql/0022_summary2_contract_rows.sql new file mode 100644 index 0000000..c6756f9 --- /dev/null +++ b/api/alembic/versions/sql/0022_summary2_contract_rows.sql @@ -0,0 +1,460 @@ +-- «Свод 2»: детальные строки без агрегации по статьям. +-- ГО: FORM_1 + FORM_4; РФ: FORM_2 + FORM_3. +-- Пагинация выполняется по узкому набору ключей до тяжёлых JOIN и сборки JSON. + +CREATE INDEX IF NOT EXISTS ix_v3_budget_form_svod2 + ON v3.budget_form (year, form_type_code, org_unit_id, id); + +CREATE INDEX IF NOT EXISTS ix_v3_budget_line_svod2 + ON v3.budget_line (budget_form_id, id) INCLUDE (expense_item_id); + +DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[]); +DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer); +DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar); + +CREATE OR REPLACE FUNCTION v3.v_svod2_go( + p_year integer, + p_org_unit_ids integer[] DEFAULT NULL, + p_offset integer DEFAULT NULL, + p_limit integer DEFAULT NULL, + p_sort_by varchar DEFAULT 'org_name', + p_sort_direction varchar DEFAULT 'asc' +) +RETURNS TABLE( + source_form varchar, source_id integer, source_org_unit_id integer, + org_name varchar, smeta_type varchar, + source_line_id integer, sort_order bigint, data jsonb +) +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + (to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('plan_year', NULL) AS plan, + (to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb)) + - 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration, + (to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('amount_year', NULL) AS reserve, + jsonb_build_object( + 'approved_q1', NULL, 'approved_q2', NULL, + 'approved_q3', NULL, 'approved_q4', NULL, + 'approved_year', NULL + ) AS approved, + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation, + to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial, + to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail, + to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking, + to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb)) + - 'line_id' - 'quarter' AS quarter +), eligible_lines AS ( + SELECT + bl.id AS line_id, + bf.form_type_code, + bf.id AS form_id, + bf.org_unit_id, + ou.title AS org_name, + ei.section_code, + ei.item_id, + CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВП' + WHEN 'OPER' THEN 'Операц' END::varchar AS smeta_type + FROM v3.budget_form bf + JOIN v3.budget_line bl ON bl.budget_form_id = bf.id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.contract_detail cd ON cd.line_id = bl.id + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.year = p_year + AND bf.form_type_code IN ('FORM_1', 'FORM_4') + AND ei.sheet IN ('AHR', 'CAP', 'OPER') + AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 + OR bf.org_unit_id = ANY(p_org_unit_ids)) +), numbered_lines AS ( + SELECT el.*, + row_number() OVER ( + ORDER BY + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.org_name END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.org_name END DESC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.item_id END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.item_id END DESC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc' + THEN el.section_code END ASC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc' + THEN el.section_code END DESC NULLS LAST, + el.form_type_code, el.form_id, el.line_id + ) AS sort_order + FROM eligible_lines el +), page AS MATERIALIZED ( + SELECT * FROM numbered_lines + ORDER BY sort_order + LIMIT p_limit OFFSET COALESCE(p_offset, 0) +), booking_data AS ( + SELECT b.line_id, + jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking + FROM page pg + JOIN v3.booking b ON b.line_id = pg.line_id + GROUP BY b.line_id +) +SELECT + pg.form_type_code, + pg.form_id, + pg.org_unit_id, + pg.org_name, + pg.smeta_type, + pg.line_id, + pg.sort_order, + jsonb_build_object( + 'line_id', bl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', pg.form_type_code, 'source_id', pg.form_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', COALESCE( + (to_jsonb(pl) - 'line_id') || jsonb_build_object( + 'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)), + e.plan || jsonb_build_object('plan_year', NULL)), + 'sequestration', jsonb_build_object( + 'DFIP', COALESCE( + (to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL)), + 'SSP_GO', COALESCE( + (to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL))), + 'reserve', COALESCE( + (to_jsonb(rv) - 'line_id') || jsonb_build_object( + 'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)), + e.reserve || jsonb_build_object('amount_year', NULL)), + 'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL + AND rv.line_id IS NULL + THEN e.approved + ELSE jsonb_build_object( + 'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0) + + COALESCE(rv.amount_q1, 0), + 'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(rv.amount_q2, 0), + 'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0) + + COALESCE(rv.amount_q3, 0), + 'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q4, 0), + 'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0) + + COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END, + 'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary), + 'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation), + 'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial), + 'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk), + 'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail), + 'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)), + 'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.quarter), + 'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.quarter), + 'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.quarter), + 'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.quarter) + ) AS data +FROM page pg +JOIN v3.budget_line bl ON bl.id = pg.line_id +JOIN v3.expense_item ei ON ei.id = bl.expense_item_id +JOIN v3.contract_detail cd ON cd.line_id = bl.id +CROSS JOIN empty_sections e +LEFT JOIN v3.plan pl ON pl.line_id = bl.id +LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP' +LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO' +LEFT JOIN v3.reserve rv ON rv.line_id = bl.id +LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id +LEFT JOIN v3.allocation al ON al.line_id = bl.id +LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id +LEFT JOIN v3.ckk ck ON ck.line_id = bl.id +LEFT JOIN booking_data bk ON bk.line_id = bl.id +LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1 +LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2 +LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3 +LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4 +ORDER BY pg.sort_order; +$function$; + +COMMENT ON FUNCTION v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar) IS +'Свод 2 / ГО: договорные строки FORM_1 и FORM_4; пагинация до сборки JSON'; + +DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[]); +DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer); +DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar); + +CREATE OR REPLACE FUNCTION v3.v_svod2_rf( + p_year integer, + p_org_unit_ids integer[] DEFAULT NULL, + p_offset integer DEFAULT NULL, + p_limit integer DEFAULT NULL, + p_sort_by varchar DEFAULT 'org_name', + p_sort_direction varchar DEFAULT 'asc' +) +RETURNS TABLE( + source_form varchar, source_id integer, source_org_unit_id integer, + org_name varchar, smeta_type varchar, + source_line_id integer, sort_order bigint, data jsonb +) +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + (to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('plan_year', NULL) AS plan, + (to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb)) + - 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration, + (to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('amount_year', NULL) AS reserve, + jsonb_build_object( + 'approved_q1', NULL, 'approved_q2', NULL, + 'approved_q3', NULL, 'approved_q4', NULL, + 'approved_year', NULL + ) AS approved, + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation, + to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial, + to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail, + to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking, + to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb)) + - 'line_id' - 'quarter' AS budget_quarter, + to_jsonb(jsonb_populate_record(NULL::v3.rf_project_report_quarter, '{}'::jsonb)) + - 'rf_project_report_line_id' - 'quarter' AS report_quarter +), eligible_lines AS ( + SELECT + 'FORM_2'::varchar AS form_type_code, + bl.id AS line_id, + bf.id AS source_id, + bf.org_unit_id, + ou.title AS org_name, + ei.section_code, + ei.item_id, + CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВП' + WHEN 'OPER' THEN 'Операц' END::varchar AS smeta_type + FROM v3.budget_form bf + JOIN v3.budget_line bl ON bl.budget_form_id = bf.id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.contract_detail cd ON cd.line_id = bl.id + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.year = p_year AND bf.form_type_code = 'FORM_2' + AND ei.sheet IN ('AHR', 'CAP', 'OPER') + AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 + OR bf.org_unit_id = ANY(p_org_unit_ids)) + UNION ALL + SELECT + 'FORM_3'::varchar, + rrl.id, + rr.id, + p.org_unit_id, + ou.title, + ei.section_code, + ei.item_id, + CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВП' + WHEN 'OPER' THEN 'Операц' END::varchar + FROM v3.rf_project_report rr + JOIN v3.rf_project_year rpy ON rpy.id = rr.rf_project_year_id + JOIN v3.project p ON p.id = rr.project_id + JOIN v3.rf_project_report_line rrl ON rrl.rf_project_report_id = rr.id + JOIN v3.expense_item ei ON ei.id = rrl.expense_item_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpy.year = p_year AND ei.sheet IN ('AHR', 'CAP', 'OPER') + AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 + OR p.org_unit_id = ANY(p_org_unit_ids)) +), numbered_lines AS ( + SELECT el.*, + row_number() OVER ( + ORDER BY + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.org_name END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.org_name END DESC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.item_id END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.item_id END DESC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc' + THEN el.section_code END ASC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc' + THEN el.section_code END DESC NULLS LAST, + el.form_type_code, el.source_id, el.line_id + ) AS sort_order + FROM eligible_lines el +), page AS MATERIALIZED ( + SELECT * FROM numbered_lines + ORDER BY sort_order + LIMIT p_limit OFFSET COALESCE(p_offset, 0) +), booking_data AS ( + SELECT b.line_id, + jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking + FROM page pg + JOIN v3.booking b ON b.line_id = pg.line_id + WHERE pg.form_type_code = 'FORM_2' + GROUP BY b.line_id +), form2_data AS ( + SELECT + pg.form_type_code AS source_form, pg.source_id, + pg.org_unit_id AS source_org_unit_id, pg.org_name, pg.smeta_type, + pg.line_id AS source_line_id, pg.sort_order, + jsonb_build_object( + 'line_id', bl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', pg.form_type_code, 'source_id', pg.source_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', COALESCE( + (to_jsonb(pl) - 'line_id') || jsonb_build_object( + 'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)), + e.plan || jsonb_build_object('plan_year', NULL)), + 'sequestration', jsonb_build_object( + 'DFIP', COALESCE( + (to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL)), + 'SSP_GO', COALESCE( + (to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL))), + 'reserve', COALESCE( + (to_jsonb(rv) - 'line_id') || jsonb_build_object( + 'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)), + e.reserve || jsonb_build_object('amount_year', NULL)), + 'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL + AND ss.id IS NULL AND rv.line_id IS NULL + THEN e.approved + ELSE jsonb_build_object( + 'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0) + + COALESCE(ss.adj_q1, 0) + COALESCE(rv.amount_q1, 0), + 'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(ss.adj_q2, 0) + COALESCE(rv.amount_q2, 0), + 'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(rv.amount_q3, 0), + 'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(ss.adj_q4, 0) + COALESCE(rv.amount_q4, 0), + 'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0) + + COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0) + + COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END, + 'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary), + 'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation), + 'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial), + 'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk), + 'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail), + 'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)), + 'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.budget_quarter), + 'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.budget_quarter), + 'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.budget_quarter), + 'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.budget_quarter) + ) AS data + FROM page pg + JOIN v3.budget_line bl ON bl.id = pg.line_id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.contract_detail cd ON cd.line_id = bl.id + CROSS JOIN empty_sections e + LEFT JOIN v3.plan pl ON pl.line_id = bl.id + LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP' + LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO' + LEFT JOIN v3.reserve rv ON rv.line_id = bl.id + LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id + LEFT JOIN v3.allocation al ON al.line_id = bl.id + LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id + LEFT JOIN v3.ckk ck ON ck.line_id = bl.id + LEFT JOIN booking_data bk ON bk.line_id = bl.id + LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1 + LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2 + LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3 + LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4 + WHERE pg.form_type_code = 'FORM_2' +), form3_data AS ( + SELECT + pg.form_type_code AS source_form, pg.source_id, + pg.org_unit_id AS source_org_unit_id, pg.org_name, pg.smeta_type, + pg.line_id AS source_line_id, pg.sort_order, + jsonb_build_object( + 'line_id', rrl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', ei.name, + 'expense_item_id', ei.id, 'project_id', rr.project_id, + 'report_type', rr.report_type, + 'smeta_direction', NULL, + 'source_form', pg.form_type_code, 'source_id', pg.source_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', e.plan, + 'sequestration', jsonb_build_object( + 'DFIP', e.sequestration, 'SSP_GO', e.sequestration), + 'reserve', e.reserve, 'contract_summary', e.contract_summary, + 'approved', e.approved, + 'allocation', e.allocation, 'collegial', e.collegial, + 'ckk', e.ckk, 'contract_detail', e.contract_detail, + 'booking', jsonb_build_array(e.booking), + 'q1', COALESCE(to_jsonb(q1) - 'rf_project_report_line_id' - 'quarter', e.report_quarter), + 'q2', COALESCE(to_jsonb(q2) - 'rf_project_report_line_id' - 'quarter', e.report_quarter), + 'q3', COALESCE(to_jsonb(q3) - 'rf_project_report_line_id' - 'quarter', e.report_quarter), + 'q4', COALESCE(to_jsonb(q4) - 'rf_project_report_line_id' - 'quarter', e.report_quarter) + ) AS data + FROM page pg + JOIN v3.rf_project_report_line rrl ON rrl.id = pg.line_id + JOIN v3.rf_project_report rr ON rr.id = pg.source_id + AND rr.id = rrl.rf_project_report_id + JOIN v3.expense_item ei ON ei.id = rrl.expense_item_id + CROSS JOIN empty_sections e + LEFT JOIN v3.rf_project_report_quarter q1 + ON q1.rf_project_report_line_id = rrl.id AND q1.quarter = 1 + LEFT JOIN v3.rf_project_report_quarter q2 + ON q2.rf_project_report_line_id = rrl.id AND q2.quarter = 2 + LEFT JOIN v3.rf_project_report_quarter q3 + ON q3.rf_project_report_line_id = rrl.id AND q3.quarter = 3 + LEFT JOIN v3.rf_project_report_quarter q4 + ON q4.rf_project_report_line_id = rrl.id AND q4.quarter = 4 + WHERE pg.form_type_code = 'FORM_3' +), result_rows AS ( + SELECT * FROM form2_data + UNION ALL + SELECT * FROM form3_data +) +SELECT rr.source_form, rr.source_id, rr.source_org_unit_id, rr.org_name, rr.smeta_type, + rr.source_line_id, rr.sort_order, rr.data +FROM result_rows rr +ORDER BY rr.sort_order; +$function$; + +COMMENT ON FUNCTION v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar) IS +'Свод 2 / РФ: строки FORM_2 и FORM_3; пагинация до сборки JSON'; diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index 26481a3..2e7bbd5 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -1,11 +1,11 @@ import logging import time -from typing import Optional +from typing import Literal, Optional -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.ext.asyncio import AsyncSession -from src.api.v1.deps import get_current_active_user_with_set_db +from src.api.v1.deps import require_admin from src.db.models.app_user import AppUser from src.db.session import get_db from src.domain.schemas import BaseListResponse, SheetResponse @@ -14,7 +14,7 @@ from src.services.summary_service import SummaryService logger = logging.getLogger() -router = APIRouter(prefix="/svod/1", tags=["svod"]) +router = APIRouter(prefix="/svod", tags=["svod"]) VALID_SUMMARY_SHEETS = { "FORM_1", @@ -23,6 +23,7 @@ VALID_SUMMARY_SHEETS = { "FORM_4", "MAIN", } +VALID_SUMMARY2_SHEETS = {"GO", "RF"} def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]: @@ -46,14 +47,14 @@ def _parse_org_ids(org_ids: Optional[str]) -> Optional[list[int]]: return parsed or None -@router.get("/{sheet}/{year}") +@router.get("/1/{sheet}/{year}") async def get_summary( sheet: str, year: int, response: Response, org_ids: Optional[str] = None, db: AsyncSession = Depends(get_db), - current_user: AppUser = Depends(get_current_active_user_with_set_db), + current_user: AppUser = Depends(require_admin), ) -> BaseListResponse[SheetResponse]: if sheet not in VALID_SUMMARY_SHEETS: raise HTTPException( @@ -86,3 +87,58 @@ async def get_summary( count=len(rows), result=_rows_to_sheet_response(rows), ) + + +@router.get("/2/{sheet}/{year}") +async def get_summary2( + sheet: str, + year: int, + response: Response, + org_ids: Optional[str] = None, + offset: Optional[int] = Query(default=None, ge=0), + limit: Optional[int] = Query(default=None, ge=1), + sort_by: Literal["org_name", "section_code"] = "org_name", + sort_direction: Literal["asc", "desc"] = "asc", + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> BaseListResponse[SheetResponse]: + sheet = sheet.upper() + if sheet not in VALID_SUMMARY2_SHEETS: + raise HTTPException( + 404, + f"Лист свода {sheet} должен быть одним из: {', '.join(sorted(VALID_SUMMARY2_SHEETS))}", + ) + + t0 = time.perf_counter() + summary_service = SummaryService(db) + rows = await summary_service.get_svod2_rows( + year=year, + sheet=sheet, + org_ids=_parse_org_ids(org_ids), + offset=offset, + limit=limit, + sort_by=sort_by, + sort_direction=sort_direction, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + + extra = { + "type": "db_time", + "time_ms": f"{db_ms:.2f}", + "handler": "get_summary2", + "sheet": sheet, + "year": year, + "org_ids": org_ids, + "offset": offset, + "limit": limit, + "sort_by": sort_by, + "sort_direction": sort_direction, + "user": current_user.id, + } + logger.info(f"db_time: {extra}", extra=extra) + + return BaseListResponse( + count=len(rows), + result=_rows_to_sheet_response(rows), + ) diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 1cb1e99..1354f3f 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -28,3 +28,49 @@ class SummaryRepository: {"year": year, "sheet": sheet, "org_ids": org_ids}, ) ).all() + + async def get_svod2_rows( + self, + year: int, + sheet: str, + org_ids: list[int] | None = None, + offset: int | None = None, + limit: int | None = None, + sort_by: str = "org_name", + sort_direction: str = "asc", + ) -> list[tuple]: + function_name = { + "GO": "v3.v_svod2_go", + "RF": "v3.v_svod2_rf", + }[sheet] + query = text( + f""" + SELECT + 'INPUT'::VARCHAR AS row_type, + 3 AS depth, + sort_order, + data + FROM {function_name}( + CAST(:year AS INT), + CAST(:org_ids AS INT[]), + CAST(:offset AS INT), + CAST(:limit AS INT), + CAST(:sort_by AS VARCHAR), + CAST(:sort_direction AS VARCHAR) + ) + ORDER BY sort_order + """ + ) + return ( + await self.db.execute( + query, + { + "year": year, + "org_ids": org_ids, + "offset": offset, + "limit": limit, + "sort_by": sort_by, + "sort_direction": sort_direction, + }, + ) + ).all() diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 6086043..2af8c92 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -19,3 +19,23 @@ class SummaryService: sheet=sheet, org_ids=org_ids, ) + + async def get_svod2_rows( + self, + year: int, + sheet: str, + org_ids: list[int] | None = None, + offset: int | None = None, + limit: int | None = None, + sort_by: str = "org_name", + sort_direction: str = "asc", + ) -> list[tuple]: + return await self.summary_repo.get_svod2_rows( + year=year, + sheet=sheet, + org_ids=org_ids, + offset=offset, + limit=limit, + sort_by=sort_by, + sort_direction=sort_direction, + ) diff --git a/api/tests/integration/test_summary_api_smoke.py b/api/tests/integration/test_summary_api_smoke.py index a96376b..2aab9da 100644 --- a/api/tests/integration/test_summary_api_smoke.py +++ b/api/tests/integration/test_summary_api_smoke.py @@ -38,3 +38,104 @@ def test_summary_sheet_unknown(client, admin_tokens, auth_headers): headers=auth_headers(admin_tokens), ) assert response.status_code == 404 + + +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +def test_summary2_sheets_smoke(client, admin_tokens, auth_headers, sheet): + response = client.get( + f"/api/v1/svod/2/{sheet}/2026", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, f"sheet={sheet}: {response.text}" + payload = response.json() + assert "result" in payload + assert "count" in payload + assert isinstance(payload["result"], list) + if payload["result"]: + row = payload["result"][0] + assert row["row_type"] == "INPUT" + assert row["depth"] == 3 + assert "sort_order" in row + assert "data" in row + data = row["data"] + header = data["header"] + assert { + "org_name", + "source_id", + "smeta_type", + "source_form", + "source_org_unit_id", + "smeta_direction", + } <= header.keys() + assert "source_sheet" not in data + assert "source_sheet" not in header + assert { + "plan", + "sequestration", + "reserve", + "contract_summary", + "allocation", + "collegial", + "ckk", + "contract_detail", + "booking", + "q1", + "q2", + "q3", + "q4", + } <= data.keys() + + +def test_summary2_sheet_with_org_ids(client, admin_tokens, auth_headers): + response = client.get( + "/api/v1/svod/2/GO/2026?org_ids=1,2,3", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + + +def test_summary2_pagination(client, admin_tokens, auth_headers): + response = client.get( + "/api/v1/svod/2/GO/2026?offset=0&limit=2", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["count"] <= 2 + assert len(payload["result"]) <= 2 + + +@pytest.mark.parametrize("sort_by", ["org_name", "section_code"]) +@pytest.mark.parametrize("sort_direction", ["asc", "desc"]) +def test_summary2_sorting( + client, + admin_tokens, + auth_headers, + sort_by, + sort_direction, +): + response = client.get( + f"/api/v1/svod/2/GO/2026?limit=2&sort_by={sort_by}&sort_direction={sort_direction}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "query", + ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad"], +) +def test_summary2_invalid_pagination(client, admin_tokens, auth_headers, query): + response = client.get( + f"/api/v1/svod/2/GO/2026?{query}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 422 + + +def test_summary2_sheet_unknown(client, admin_tokens, auth_headers): + response = client.get( + "/api/v1/svod/2/unknown/2026", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 404