perf(api): свод 2 — страницы и варианты фильтров в репозитории и сервисе
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5bccc5ba2
commit
e3a9a08e84
@ -361,6 +361,23 @@ class SheetResponse(BaseModel):
|
|||||||
sort_order: int | None = None
|
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):
|
class CellPatch(BaseModel):
|
||||||
line_id: int
|
line_id: int
|
||||||
column: str
|
column: str
|
||||||
|
|||||||
@ -76,6 +76,122 @@ class SummaryRepository:
|
|||||||
)
|
)
|
||||||
).all()
|
).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(
|
async def get_svod2_go_export_rows(
|
||||||
self,
|
self,
|
||||||
year: int,
|
year: int,
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import copy
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.db.models.app_user import AppUser
|
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
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_SVOD2_FACETS = {
|
||||||
|
"total": 0,
|
||||||
|
"search_total": 0,
|
||||||
|
"smeta_type": [],
|
||||||
|
"smeta_direction": [],
|
||||||
|
"org_name": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SummaryService:
|
class SummaryService:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
@ -52,6 +63,72 @@ class SummaryService:
|
|||||||
sort_direction=sort_direction,
|
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(
|
async def get_svod2_go_export_rows(
|
||||||
self,
|
self,
|
||||||
year: int,
|
year: int,
|
||||||
|
|||||||
79
api/tests/unit/test_summary2_service.py
Normal file
79
api/tests/unit/test_summary2_service.py
Normal 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="Орг",
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user