perf(api): свод 2 — страницы и варианты фильтров в репозитории и сервисе

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:53:16 +04:00
parent f5bccc5ba2
commit e3a9a08e84
4 changed files with 289 additions and 0 deletions

View File

@ -361,6 +361,23 @@ class SheetResponse(BaseModel):
sort_order: int | None = None
class Svod2PageResponse(BaseListResponse[SheetResponse]):
total: int
class Svod2FacetValue(BaseModel):
value: str
count: int
class Svod2Facets(BaseModel):
total: int
search_total: int
smeta_type: list[Svod2FacetValue]
smeta_direction: list[Svod2FacetValue]
org_name: list[Svod2FacetValue]
class CellPatch(BaseModel):
line_id: int
column: str

View File

@ -76,6 +76,122 @@ class SummaryRepository:
)
).all()
async def get_svod2_page(
self,
year: int,
sheet: str,
org_ids: list[int] | None,
offset: int | None,
limit: int | None,
sort_by: str,
sort_direction: str,
search: str | None,
smeta_type: str | None,
smeta_direction: str | None,
org_name: str | None,
compact: bool,
) -> tuple[list[tuple], int]:
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,
total_count
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),
CAST(:search AS TEXT),
CAST(:smeta_type AS TEXT),
CAST(:smeta_direction AS TEXT),
CAST(:org_name AS TEXT),
CAST(:compact AS BOOLEAN)
)
"""
)
result = (
await self.db.execute(
query,
{
"year": year,
"org_ids": org_ids,
"offset": offset,
"limit": limit,
"sort_by": sort_by,
"sort_direction": sort_direction,
"search": search,
"smeta_type": smeta_type,
"smeta_direction": smeta_direction,
"org_name": org_name,
"compact": compact,
},
)
).all()
total = int(result[0][4]) if result else 0
return [tuple(row[:4]) for row in result], total
async def get_svod2_facets(
self,
year: int,
sheet: str,
org_ids: list[int] | None,
search: str | None,
smeta_type: str | None,
smeta_direction: str | None,
org_name: str | None,
) -> dict:
query = text(
"""
SELECT kind, value, n
FROM v3.v_svod2_facets(
CAST(:sheet AS VARCHAR),
CAST(:year AS INT),
CAST(:org_ids AS INT[]),
CAST(:search AS TEXT),
CAST(:smeta_type AS TEXT),
CAST(:smeta_direction AS TEXT),
CAST(:org_name AS TEXT)
)
ORDER BY kind, value
"""
)
rows = (
await self.db.execute(
query,
{
"sheet": sheet,
"year": year,
"org_ids": org_ids,
"search": search,
"smeta_type": smeta_type,
"smeta_direction": smeta_direction,
"org_name": org_name,
},
)
).all()
facets: dict = {
"total": 0,
"search_total": 0,
"smeta_type": [],
"smeta_direction": [],
"org_name": [],
}
for kind, value, count in rows:
if kind in ("total", "search_total"):
facets[kind] = int(count)
elif kind in facets:
facets[kind].append({"value": value, "count": int(count)})
return facets
async def get_svod2_go_export_rows(
self,
year: int,

View File

@ -1,3 +1,5 @@
import copy
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
@ -6,6 +8,15 @@ from src.repository.summary_repository import SummaryRepository
from src.repository.user_repository import UserRepository
EMPTY_SVOD2_FACETS = {
"total": 0,
"search_total": 0,
"smeta_type": [],
"smeta_direction": [],
"org_name": [],
}
class SummaryService:
def __init__(self, db: AsyncSession):
self.db = db
@ -52,6 +63,72 @@ class SummaryService:
sort_direction=sort_direction,
)
async def get_svod2_page(
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",
search: str | None = None,
smeta_type: str | None = None,
smeta_direction: str | None = None,
org_name: str | None = None,
view: str = "full",
) -> tuple[list[tuple], int]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
return [], 0
async def fetch(page_offset: int | None, page_limit: int | None):
return await self.summary_repo.get_svod2_page(
year=year,
sheet=sheet,
org_ids=org_ids,
offset=page_offset,
limit=page_limit,
sort_by=sort_by,
sort_direction=sort_direction,
search=search,
smeta_type=smeta_type,
smeta_direction=smeta_direction,
org_name=org_name,
compact=view == "compact",
)
rows, total = await fetch(offset, limit)
if not rows and offset:
# Страница за концом выборки: total берётся из первой строки, которой нет.
_, total = await fetch(0, 1)
return rows, total
async def get_svod2_facets(
self,
year: int,
sheet: str,
user: AppUser,
org_ids: list[int] | None = None,
search: str | None = None,
smeta_type: str | None = None,
smeta_direction: str | None = None,
org_name: str | None = None,
) -> dict:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
return copy.deepcopy(EMPTY_SVOD2_FACETS)
return await self.summary_repo.get_svod2_facets(
year=year,
sheet=sheet,
org_ids=org_ids,
search=search,
smeta_type=smeta_type,
smeta_direction=smeta_direction,
org_name=org_name,
)
async def get_svod2_go_export_rows(
self,
year: int,

View File

@ -0,0 +1,79 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.services.summary_service import EMPTY_SVOD2_FACETS, SummaryService
ROW = ("INPUT", 3, 1, {"line_id": 1})
def make_service(monkeypatch, allowed):
service = SummaryService.__new__(SummaryService)
service.summary_repo = AsyncMock()
service.user_repo = AsyncMock()
monkeypatch.setattr(service, "_allowed_org_ids", AsyncMock(return_value=allowed))
return service
USER = SimpleNamespace(id=1)
async def test_page_passes_params_and_compact_flag(monkeypatch):
service = make_service(monkeypatch, [1, 2])
service.summary_repo.get_svod2_page.return_value = ([ROW], 7)
result = await service.get_svod2_page(
2026, "GO", USER, org_ids=[1, 2], offset=0, limit=50,
sort_by="name", sort_direction="desc", search="x",
smeta_type="КВ", smeta_direction="Support", org_name="Орг", view="compact",
)
assert result == ([ROW], 7)
service.summary_repo.get_svod2_page.assert_awaited_once_with(
year=2026, sheet="GO", org_ids=[1, 2], offset=0, limit=50,
sort_by="name", sort_direction="desc", search="x",
smeta_type="КВ", smeta_direction="Support", org_name="Орг", compact=True,
)
async def test_page_empty_intersection_skips_sql(monkeypatch):
service = make_service(monkeypatch, [])
assert await service.get_svod2_page(2026, "GO", USER, offset=0, limit=10) == ([], 0)
service.summary_repo.get_svod2_page.assert_not_awaited()
async def test_page_past_end_second_call_for_total(monkeypatch):
service = make_service(monkeypatch, None)
service.summary_repo.get_svod2_page.side_effect = [([], 0), ([ROW], 42)]
result = await service.get_svod2_page(2026, "GO", USER, offset=600, limit=50)
assert result == ([], 42)
second = service.summary_repo.get_svod2_page.await_args_list[1].kwargs
assert second["offset"] == 0 and second["limit"] == 1
async def test_page_empty_first_page_no_second_call(monkeypatch):
service = make_service(monkeypatch, None)
service.summary_repo.get_svod2_page.return_value = ([], 0)
assert await service.get_svod2_page(2026, "GO", USER, offset=0, limit=50) == ([], 0)
assert service.summary_repo.get_svod2_page.await_count == 1
async def test_facets_empty_intersection_zeros_without_sql(monkeypatch):
service = make_service(monkeypatch, [])
result = await service.get_svod2_facets(2026, "GO", USER, org_ids=[5])
assert result == EMPTY_SVOD2_FACETS
assert result is not EMPTY_SVOD2_FACETS
service.summary_repo.get_svod2_facets.assert_not_awaited()
async def test_facets_passes_allowed_org_ids_and_filters(monkeypatch):
service = make_service(monkeypatch, [1])
service.summary_repo.get_svod2_facets.return_value = {"total": 3}
result = await service.get_svod2_facets(
2026, "RF", USER, org_ids=[1, 9], search="x", smeta_type="КВ",
smeta_direction="Support", org_name="Орг",
)
assert result == {"total": 3}
service.summary_repo.get_svod2_facets.assert_awaited_once_with(
year=2026, sheet="RF", org_ids=[1], search="x", smeta_type="КВ",
smeta_direction="Support", org_name="Орг",
)