diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index 84b2f8d..e3a2e9f 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -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}"}) diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 88285bb..8dc0070 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -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, diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 1408e04..71c9a08 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -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, diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py index b6158db..d60c0b9 100644 --- a/api/tests/integration/test_heavy_get_json.py +++ b/api/tests/integration/test_heavy_get_json.py @@ -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))