projects-summary-handler: ручка для таблицы снизу страницы навигации #93

Merged
tsygankoviva merged 1 commits from projects-summary-handler into test 2026-08-14 09:12:39 +03:00
5 changed files with 278 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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