perf(api): GET сводов 1 и 2 отдают готовый JSON из Postgres
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8dba2f7be
commit
ca7160124a
@ -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}"})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user