From e3a9a08e84018fbe714ca51593465bf4319519c7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:53:16 +0400 Subject: [PATCH] =?UTF-8?q?perf(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/schemas.py | 17 ++++ api/src/repository/summary_repository.py | 116 +++++++++++++++++++++++ api/src/services/summary_service.py | 77 +++++++++++++++ api/tests/unit/test_summary2_service.py | 79 +++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 api/tests/unit/test_summary2_service.py diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index c7ef75c..e7e9302 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -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 diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 88285bb..3d3ed7f 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -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, diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 1408e04..4cf31ba 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -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, diff --git a/api/tests/unit/test_summary2_service.py b/api/tests/unit/test_summary2_service.py new file mode 100644 index 0000000..8d70540 --- /dev/null +++ b/api/tests/unit/test_summary2_service.py @@ -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="Орг", + )