From 75875d266df0e7c212af36865f863815bf0b0af3 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Fri, 14 Aug 2026 09:10:42 +0300 Subject: [PATCH] =?UTF-8?q?projects-summary-handler:=20=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D1=81=D0=BD=D0=B8=D0=B7=D1=83=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0017_project_summary.py | 82 ++++++++++ .../versions/sql/0017_project_summary.sql | 149 ++++++++++++++++++ api/src/api/v1/projects.py | 26 +++ api/src/repository/project_repository.py | 11 ++ api/src/services/project_service.py | 10 ++ 5 files changed, 278 insertions(+) create mode 100644 api/alembic/versions/0017_project_summary.py create mode 100644 api/alembic/versions/sql/0017_project_summary.sql diff --git a/api/alembic/versions/0017_project_summary.py b/api/alembic/versions/0017_project_summary.py new file mode 100644 index 0000000..7b21a95 --- /dev/null +++ b/api/alembic/versions/0017_project_summary.py @@ -0,0 +1,82 @@ +import os +import re + +from alembic import op + + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _find_dollar_tag(line: str) -> str | None: + m = _DOLLAR_TAG_RE.search(line.strip()) + return m.group(0) if m else None + + +def _split_statements(sql: str) -> list[str]: + statements: list[str] = [] + current: list[str] = [] + in_dollar = False + dollar_tag: str | None = None + + for line in sql.split("\n"): + stripped = line.strip() + if stripped.startswith("--"): + continue + + if not in_dollar: + tag = _find_dollar_tag(stripped) + if tag and tag.endswith("$") and tag.startswith("$"): + dollar_tag = tag + in_dollar = True + current.append(line) + continue + + if in_dollar and dollar_tag and stripped.startswith(dollar_tag): + after = stripped[len(dollar_tag):].strip() + if after == ";" or after == "": + in_dollar = False + dollar_tag = None + if after == ";": + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + if not in_dollar and stripped.rstrip().endswith(";"): + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + current.append(line) + + remaining = "\n".join(current).strip() + if remaining: + statements.append(remaining) + + return statements + + +def upgrade() -> None: + ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0017_project_summary.sql") + with open(ddl_path) as f: + content = f.read() + + statements = _split_statements(content) + for stmt in statements: + stripped = stmt.strip().rstrip(";").strip() + if not stripped: + continue + if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")): + continue + op.execute(stripped) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0017_project_summary.sql b/api/alembic/versions/sql/0017_project_summary.sql new file mode 100644 index 0000000..e57395d --- /dev/null +++ b/api/alembic/versions/sql/0017_project_summary.sql @@ -0,0 +1,149 @@ +-- DROP FUNCTION v3.v_project_summary_jsonb(int4); + +CREATE OR REPLACE FUNCTION v3.v_project_summary_jsonb(p_project_id integer) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE sql + STABLE +AS $function$ +WITH + project AS ( + SELECT p.id, p.name + FROM v3.project p + WHERE p.id = p_project_id + ), + base AS ( + SELECT + rpy.year, + r.report_type, + ei.sheet, + q.quarter, + q.adj_by_items, + q.adj_increase, + q.actual_m1, q.actual_m2, q.actual_m3 + FROM v3.rf_project_year rpy + LEFT JOIN v3.rf_project_report r ON r.rf_project_year_id = rpy.id + LEFT JOIN v3.rf_project_report_line l ON l.rf_project_report_id = r.id + LEFT JOIN v3.rf_project_report_quarter q ON q.rf_project_report_line_id = l.id + LEFT JOIN v3.expense_item ei ON ei.id = l.expense_item_id + WHERE rpy.project_id = p_project_id + ), + py AS ( + SELECT + year, + -- Объем финансирования по контрольному листу (годовой базовый план = adj_by_items) + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR'), 0) AS fin_ahr, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR' AND report_type = 'LIMIT'), 0) AS fin_ahr_limit, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR' AND report_type = 'CURRENT_EXPENSES'), 0) AS fin_ahr_current, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'CAP' AND report_type = 'LIMIT'), 0) AS fin_kv, + -- Базовый период 01.01-31.03 (базовый план, квартал 1) + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR' AND quarter = 1), 0) AS bq1_ahr, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR' AND quarter = 1 AND report_type = 'LIMIT'), 0) AS bq1_ahr_limit, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'AHR' AND quarter = 1 AND report_type = 'CURRENT_EXPENSES'), 0) AS bq1_ahr_current, + COALESCE(SUM(adj_by_items) FILTER (WHERE sheet = 'CAP' AND quarter = 1 AND report_type = 'LIMIT'), 0) AS bq1_kv, + -- Скорректированный период 01.04-30.06 (квартал 2, corrected_plan = adj_by_items + adj_increase) + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2), 0) AS cq2_ahr, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2 AND report_type = 'LIMIT'), 0) AS cq2_ahr_limit, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2 AND report_type = 'CURRENT_EXPENSES'), 0) AS cq2_ahr_current, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 2 AND report_type = 'LIMIT'), 0) AS cq2_kv, + -- Скорректированный период 01.07-30.09 (квартал 3) + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3), 0) AS cq3_ahr, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3 AND report_type = 'LIMIT'), 0) AS cq3_ahr_limit, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3 AND report_type = 'CURRENT_EXPENSES'), 0) AS cq3_ahr_current, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 3 AND report_type = 'LIMIT'), 0) AS cq3_kv, + -- Скорректированный период 01.10-31.12 (квартал 4) + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4), 0) AS cq4_ahr, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4 AND report_type = 'LIMIT'), 0) AS cq4_ahr_limit, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4 AND report_type = 'CURRENT_EXPENSES'), 0) AS cq4_ahr_current, + COALESCE(SUM(COALESCE(adj_by_items,0)+COALESCE(adj_increase,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 4 AND report_type = 'LIMIT'), 0) AS cq4_kv, + -- Фактические расходы АХР по кварталам (actual = m1+m2+m3) + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 1), 0) AS aq1_ahr, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 1 AND report_type = 'LIMIT'), 0) AS aq1_ahr_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 1 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq1_ahr_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2), 0) AS aq2_ahr, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2 AND report_type = 'LIMIT'), 0) AS aq2_ahr_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 2 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq2_ahr_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3), 0) AS aq3_ahr, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3 AND report_type = 'LIMIT'), 0) AS aq3_ahr_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 3 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq3_ahr_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4), 0) AS aq4_ahr, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4 AND report_type = 'LIMIT'), 0) AS aq4_ahr_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'AHR' AND quarter = 4 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq4_ahr_current, + -- Фактические расходы КВ по кварталам + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 1), 0) AS aq1_kv, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 1 AND report_type = 'LIMIT'), 0) AS aq1_kv_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 1 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq1_kv_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 2), 0) AS aq2_kv, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 2 AND report_type = 'LIMIT'), 0) AS aq2_kv_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 2 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq2_kv_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 3), 0) AS aq3_kv, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 3 AND report_type = 'LIMIT'), 0) AS aq3_kv_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 3 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq3_kv_current, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 4), 0) AS aq4_kv, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 4 AND report_type = 'LIMIT'), 0) AS aq4_kv_limit, + COALESCE(SUM(COALESCE(actual_m1,0)+COALESCE(actual_m2,0)+COALESCE(actual_m3,0)) FILTER (WHERE sheet = 'CAP' AND quarter = 4 AND report_type = 'CURRENT_EXPENSES'), 0) AS aq4_kv_current + FROM base + GROUP BY year + ), + rows AS ( + SELECT 'PROJECT'::varchar AS row_type, NULL::int AS year, + NULL::numeric AS fin_ahr, NULL::numeric AS fin_ahr_limit, NULL::numeric AS fin_ahr_current, NULL::numeric AS fin_kv, + NULL::numeric AS bq1_ahr, NULL::numeric AS bq1_ahr_limit, NULL::numeric AS bq1_ahr_current, NULL::numeric AS bq1_kv, + NULL::numeric AS cq2_ahr, NULL::numeric AS cq2_ahr_limit, NULL::numeric AS cq2_ahr_current, NULL::numeric AS cq2_kv, + NULL::numeric AS cq3_ahr, NULL::numeric AS cq3_ahr_limit, NULL::numeric AS cq3_ahr_current, NULL::numeric AS cq3_kv, + NULL::numeric AS cq4_ahr, NULL::numeric AS cq4_ahr_limit, NULL::numeric AS cq4_ahr_current, NULL::numeric AS cq4_kv, + NULL::numeric AS aq1_ahr, NULL::numeric AS aq1_ahr_limit, NULL::numeric AS aq1_ahr_current, + NULL::numeric AS aq2_ahr, NULL::numeric AS aq2_ahr_limit, NULL::numeric AS aq2_ahr_current, + NULL::numeric AS aq3_ahr, NULL::numeric AS aq3_ahr_limit, NULL::numeric AS aq3_ahr_current, + NULL::numeric AS aq4_ahr, NULL::numeric AS aq4_ahr_limit, NULL::numeric AS aq4_ahr_current, + NULL::numeric AS aq1_kv, NULL::numeric AS aq1_kv_limit, NULL::numeric AS aq1_kv_current, + NULL::numeric AS aq2_kv, NULL::numeric AS aq2_kv_limit, NULL::numeric AS aq2_kv_current, + NULL::numeric AS aq3_kv, NULL::numeric AS aq3_kv_limit, NULL::numeric AS aq3_kv_current, + NULL::numeric AS aq4_kv, NULL::numeric AS aq4_kv_limit, NULL::numeric AS aq4_kv_current + UNION ALL + SELECT 'YEAR'::varchar, year, + fin_ahr, fin_ahr_limit, fin_ahr_current, fin_kv, + bq1_ahr, bq1_ahr_limit, bq1_ahr_current, bq1_kv, + cq2_ahr, cq2_ahr_limit, cq2_ahr_current, cq2_kv, + cq3_ahr, cq3_ahr_limit, cq3_ahr_current, cq3_kv, + cq4_ahr, cq4_ahr_limit, cq4_ahr_current, cq4_kv, + aq1_ahr, aq1_ahr_limit, aq1_ahr_current, + aq2_ahr, aq2_ahr_limit, aq2_ahr_current, + aq3_ahr, aq3_ahr_limit, aq3_ahr_current, + aq4_ahr, aq4_ahr_limit, aq4_ahr_current, + aq1_kv, aq1_kv_limit, aq1_kv_current, + aq2_kv, aq2_kv_limit, aq2_kv_current, + aq3_kv, aq3_kv_limit, aq3_kv_current, + aq4_kv, aq4_kv_limit, aq4_kv_current + FROM py + ) +SELECT + r.row_type, + CASE r.row_type WHEN 'PROJECT' THEN 0 ELSE 1 END AS depth, + ROW_NUMBER() OVER ( + ORDER BY CASE r.row_type WHEN 'PROJECT' THEN 0 WHEN 'YEAR' THEN 1 ELSE 2 END, + COALESCE(r.year, 0) + ) AS sort_order, + jsonb_build_object( + 'header', jsonb_build_object('project_id', p_project_id, 'name', p.name, 'year', r.year), + 'financing', jsonb_build_object('ahr', r.fin_ahr, 'ahr_limit', r.fin_ahr_limit, 'ahr_current', r.fin_ahr_current, 'kv', r.fin_kv), + 'base_q1', jsonb_build_object('ahr', r.bq1_ahr, 'ahr_limit', r.bq1_ahr_limit, 'ahr_current', r.bq1_ahr_current, 'kv', r.bq1_kv), + 'corrected_q2', jsonb_build_object('ahr', r.cq2_ahr, 'ahr_limit', r.cq2_ahr_limit, 'ahr_current', r.cq2_ahr_current, 'kv', r.cq2_kv), + 'corrected_q3', jsonb_build_object('ahr', r.cq3_ahr, 'ahr_limit', r.cq3_ahr_limit, 'ahr_current', r.cq3_ahr_current, 'kv', r.cq3_kv), + 'corrected_q4', jsonb_build_object('ahr', r.cq4_ahr, 'ahr_limit', r.cq4_ahr_limit, 'ahr_current', r.cq4_ahr_current, 'kv', r.cq4_kv), + 'actual_ahr', jsonb_build_object( + 'q1', jsonb_build_object('ahr', r.aq1_ahr, 'ahr_limit', r.aq1_ahr_limit, 'ahr_current', r.aq1_ahr_current), + 'q2', jsonb_build_object('ahr', r.aq2_ahr, 'ahr_limit', r.aq2_ahr_limit, 'ahr_current', r.aq2_ahr_current), + 'q3', jsonb_build_object('ahr', r.aq3_ahr, 'ahr_limit', r.aq3_ahr_limit, 'ahr_current', r.aq3_ahr_current), + 'q4', jsonb_build_object('ahr', r.aq4_ahr, 'ahr_limit', r.aq4_ahr_limit, 'ahr_current', r.aq4_ahr_current) + ), + 'actual_kv', jsonb_build_object( + 'q1', jsonb_build_object('kv', r.aq1_kv, 'kv_limit', r.aq1_kv_limit, 'kv_current', r.aq1_kv_current), + 'q2', jsonb_build_object('kv', r.aq2_kv, 'kv_limit', r.aq2_kv_limit, 'kv_current', r.aq2_kv_current), + 'q3', jsonb_build_object('kv', r.aq3_kv, 'kv_limit', r.aq3_kv_limit, 'kv_current', r.aq3_kv_current), + 'q4', jsonb_build_object('kv', r.aq4_kv, 'kv_limit', r.aq4_kv_limit, 'kv_current', r.aq4_kv_current) + ) + ) AS data +FROM rows r +CROSS JOIN project p; +$function$ +; diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 93bef9f..e32b5b7 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -175,6 +175,32 @@ async def get_rf_rollup( return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) +@router.get("/projects/{project_id}/summary") +async def get_project_summary( + project_id: int, + response: Response, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + project_service = ProjectService(db) + t0 = time.perf_counter() + rows = await project_service.get_project_summary_rows( + project_id=project_id, + user=current_user, + ) + 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_project_summary", + "project_id": project_id, + "user": current_user.id, + } + logger.info(f"db_time: {extra}", extra=extra) + return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + + @router.patch("/projects/{project_id}/report/{year}/{report_type}/cell") async def upd_form3_cell( project_id: int, diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 059fd08..6431638 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -334,6 +334,17 @@ class ProjectRepository: ) ).all() + async def get_project_summary_rows(self, project_id: int) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.v_project_summary_jsonb(CAST(:project_id AS INT)) + """ + ) + return ( + await self.db.execute(query, {"project_id": project_id}) + ).all() + async def upd_form3_cell( self, report_id: int, diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index 8c1fc38..0a9b02e 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -113,6 +113,16 @@ class ProjectService: sections=sections, ) + async def get_project_summary_rows( + self, + project_id: int, + user: AppUser, + ) -> list[tuple]: + project = await self.get_instance(project_id, user) + if not project: + raise ValidationException("Проект не найден") + return await self.project_repo.get_project_summary_rows(project_id=project_id) + async def upd_form3_cell( self, project_id: int,