perf(api): GET сводов 1 и 2 отдают готовый JSON из Postgres

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:16:36 +04:00
parent d8dba2f7be
commit ca7160124a
4 changed files with 212 additions and 66 deletions

View File

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_user
from src.db.models.app_user import AppUser
from src.api.v1.json_response import list_json_response
from src.db.session import get_db
from src.domain.schemas import BaseListResponse, SheetResponse
from src.services.summary_service import SummaryService
@ -26,17 +27,6 @@ VALID_SUMMARY_SHEETS = {
VALID_SUMMARY2_SHEETS = {"GO", "RF"}
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
return [
SheetResponse(
row_type=el[0],
depth=el[1],
sort_order=el[2],
data=el[3],
) for el in result
]
def _parse_org_ids(org_ids: Optional[str]) -> Optional[list[int]]:
if not org_ids:
return None
@ -47,15 +37,14 @@ def _parse_org_ids(org_ids: Optional[str]) -> Optional[list[int]]:
return parsed or None
@router.get("/1/{sheet}/{year}")
@router.get("/1/{sheet}/{year}", response_model=BaseListResponse[SheetResponse])
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_user),
) -> BaseListResponse[SheetResponse]:
) -> Response:
if sheet not in VALID_SUMMARY_SHEETS:
raise HTTPException(
404,
@ -64,14 +53,13 @@ async def get_summary(
t0 = time.perf_counter()
summary_service = SummaryService(db)
rows = await summary_service.get_svod1_rows(
rows = await summary_service.get_svod1_json(
year=year,
sheet=sheet,
user=current_user,
org_ids=_parse_org_ids(org_ids),
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra = {
"type": "db_time",
@ -84,17 +72,13 @@ async def get_summary(
}
logger.info(f"db_time: {extra}", extra=extra)
return BaseListResponse(
count=len(rows),
result=_rows_to_sheet_response(rows),
)
return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})
@router.get("/2/{sheet}/{year}")
@router.get("/2/{sheet}/{year}", response_model=BaseListResponse[SheetResponse])
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),
@ -102,7 +86,7 @@ async def get_summary2(
sort_direction: Literal["asc", "desc"] = "asc",
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
) -> BaseListResponse[SheetResponse]:
) -> Response:
sheet = sheet.upper()
if sheet not in VALID_SUMMARY2_SHEETS:
raise HTTPException(
@ -112,7 +96,7 @@ async def get_summary2(
t0 = time.perf_counter()
summary_service = SummaryService(db)
rows = await summary_service.get_svod2_rows(
rows = await summary_service.get_svod2_json(
year=year,
sheet=sheet,
user=current_user,
@ -123,7 +107,6 @@ async def get_summary2(
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",
@ -140,7 +123,4 @@ async def get_summary2(
}
logger.info(f"db_time: {extra}", extra=extra)
return BaseListResponse(
count=len(rows),
result=_rows_to_sheet_response(rows),
)
return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})

View File

@ -3,27 +3,63 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select
class SummaryRepository:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _svod1_source() -> str:
return (
"FROM v3.v_summary_view("
"CAST(:year AS INT), "
"CAST(:sheet AS TEXT), "
"CAST(:org_ids AS INT[]))"
)
@staticmethod
def _svod2_source(sheet: str) -> str:
function_name = {
"GO": "v3.v_svod2_go",
"RF": "v3.v_svod2_rf",
}[sheet]
return (
f"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))"
)
@staticmethod
def _svod2_params(
year: int,
org_ids: list[int] | None,
offset: int | None,
limit: int | None,
sort_by: str,
sort_direction: str,
) -> dict:
return {
"year": year,
"org_ids": org_ids,
"offset": offset,
"limit": limit,
"sort_by": sort_by,
"sort_direction": sort_direction,
}
async def get_svod1_rows(
self,
year: int,
sheet: str,
org_ids: list[int] | None = None,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_summary_view(
CAST(:year AS INT),
CAST(:sheet AS TEXT),
CAST(:org_ids AS INT[])
)
"""
)
query = text(f"SELECT {ROW_COLUMNS} {self._svod1_source()}")
return (
await self.db.execute(
query,
@ -31,6 +67,19 @@ class SummaryRepository:
)
).all()
async def get_svod1_json(
self,
year: int,
sheet: str,
org_ids: list[int] | None = None,
) -> list[str]:
sql = f"{row_json_select()} {self._svod1_source()}"
return await fetch_row_texts(
self.db,
sql,
{"year": year, "sheet": sheet, "org_ids": org_ids},
)
async def get_svod2_rows(
self,
year: int,
@ -41,41 +90,34 @@ class SummaryRepository:
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)
)
"""
f"SELECT 'INPUT'::VARCHAR AS row_type, 3 AS depth, sort_order, data "
f"{self._svod2_source(sheet)}"
)
return (
await self.db.execute(
query,
{
"year": year,
"org_ids": org_ids,
"offset": offset,
"limit": limit,
"sort_by": sort_by,
"sort_direction": sort_direction,
},
self._svod2_params(year, org_ids, offset, limit, sort_by, sort_direction),
)
).all()
async def get_svod2_json(
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[str]:
select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3")
return await fetch_row_texts(
self.db,
f"{select} {self._svod2_source(sheet)}",
self._svod2_params(year, org_ids, offset, limit, sort_by, sort_direction),
)
async def get_svod2_go_export_rows(
self,
year: int,

View File

@ -52,6 +52,46 @@ class SummaryService:
sort_direction=sort_direction,
)
async def get_svod1_json(
self,
year: int,
sheet: str,
user: AppUser,
org_ids: list[int] | None = None,
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
return []
return await self.summary_repo.get_svod1_json(
year=year,
sheet=sheet,
org_ids=org_ids,
)
async def get_svod2_json(
self,
year: int,
sheet: str,
user: AppUser,
org_ids: list[int] | None = None,
offset: int | None = None,
limit: int | None = None,
sort_by: str = "org_name",
sort_direction: str = "asc",
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
return []
return await self.summary_repo.get_svod2_json(
year=year,
sheet=sheet,
org_ids=org_ids,
offset=offset,
limit=limit,
sort_by=sort_by,
sort_direction=sort_direction,
)
async def get_svod2_go_export_rows(
self,
year: int,

View File

@ -11,6 +11,8 @@ from sqlalchemy import text
from src.main import app
from src.repository.sheet_repository import SheetRepository
from src.repository.summary_repository import SummaryRepository
from src.services.summary_service import SummaryService
ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin
ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP
@ -122,3 +124,85 @@ def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, mon
raise AssertionError("GET листа не должен вызывать SheetRepository.get")
monkeypatch.setattr(SheetRepository, "get", boom)
get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))
SVOD2_PLAN_LINE_SQL = text(
"WITH bf AS (INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) "
"VALUES ('FORM_1', :year, 2) RETURNING id), "
"bl AS (INSERT INTO v3.budget_line (budget_form_id, expense_item_id) "
"SELECT id, :expense_item_id FROM bf RETURNING id) "
"INSERT INTO v3.plan (line_id, plan_q1) SELECT id, 1000 FROM bl"
)
@pytest.fixture
def svod2_plan_line(client, db_session):
"""Фикстура не даёт свода 2 строк за YEAR: вставляем FORM_1 с ненулевым планом.
Откатывается вместе с сессией теста (POST строки без плана свод 2 не учитывает)."""
async def _set():
await db_session.execute(SVOD2_PLAN_LINE_SQL, {"year": YEAR, "expense_item_id": 88})
client.portal.call(_set)
@pytest.mark.parametrize("sheet", ["FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"])
def test_svod1_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet):
result = get_ok(client, f"/api/v1/svod/1/{sheet}/{YEAR}", auth_headers(admin_tokens))
old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet=sheet, org_ids=None))
assert result == expected_sheet(old)
def test_svod1_org_ids_matches_old_path(client, db_session, admin_tokens, auth_headers):
result = get_ok(client, f"/api/v1/svod/1/FORM_1/{YEAR}", auth_headers(admin_tokens), {"org_ids": "1,2,3"})
old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet="FORM_1", org_ids=[1, 2, 3]))
assert result
assert result == expected_sheet(old)
SVOD2_CASES = [
("GO", {}, {}),
("RF", {}, {}),
("GO", {"org_ids": "1,2"}, {"org_ids": [1, 2]}),
("GO", {"offset": 1, "limit": 2}, {"offset": 1, "limit": 2}),
("RF", {"sort_by": "section_code", "sort_direction": "desc"}, {"sort_by": "section_code", "sort_direction": "desc"}),
]
@pytest.mark.usefixtures("svod2_plan_line")
@pytest.mark.parametrize("sheet,query,repo_kwargs", SVOD2_CASES)
def test_svod2_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet, query, repo_kwargs):
result = get_ok(client, f"/api/v1/svod/2/{sheet}/{YEAR}", auth_headers(admin_tokens), query)
old = old_rows(client, lambda: SummaryRepository(db_session).get_svod2_rows(year=YEAR, sheet=sheet, **repo_kwargs))
assert result == expected_sheet(old)
@pytest.mark.usefixtures("svod2_plan_line")
def test_svod2_fixture_not_empty(client, admin_tokens, auth_headers):
"""Иначе сравнения свода 2 выше проходят на пустых списках."""
go = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(admin_tokens))
rf = get_ok(client, f"/api/v1/svod/2/RF/{YEAR}", auth_headers(admin_tokens))
assert go or rf
@pytest.mark.usefixtures("svod2_plan_line")
def test_svod2_isp_matches_service_old_path(client, db_session, isp_tokens, auth_headers):
result = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(isp_tokens))
old = old_rows(client, lambda: SummaryService(db_session).get_svod2_rows(year=YEAR, sheet="GO", user=ISP))
assert result == expected_sheet(old)
def test_svod2_foreign_org_ids_empty_body(client, isp_tokens, auth_headers):
r = client.get(f"/api/v1/svod/2/GO/{YEAR}", headers=auth_headers(isp_tokens), params={"org_ids": "999999"})
assert r.status_code == 200
assert r.json() == {"success": True, "message": None, "result": [], "count": 0}
assert float(r.headers["x-db-time-ms"]) >= 0
@pytest.mark.parametrize("url,method", [
(f"/api/v1/svod/1/MAIN/{YEAR}", "get_svod1_rows"),
(f"/api/v1/svod/2/GO/{YEAR}", "get_svod2_rows"),
])
def test_svod_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch, url, method):
async def boom(*args, **kwargs):
raise AssertionError(f"GET свода не должен вызывать SummaryRepository.{method}")
monkeypatch.setattr(SummaryRepository, method, boom)
get_ok(client, url, auth_headers(admin_tokens))