svod: свод 2

This commit is contained in:
tsygankoviva 2026-08-27 14:00:38 +03:00
parent 6f6f35c8b1
commit 4708acbd20
7 changed files with 765 additions and 57 deletions

View File

@ -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")

View File

@ -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

View File

@ -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';

View File

@ -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),
)

View File

@ -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()

View File

@ -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,
)

View File

@ -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