svod: свод 1
This commit is contained in:
parent
0549f18dc4
commit
6f6f35c8b1
82
api/alembic/versions/0021_summary_views.py
Normal file
82
api/alembic/versions/0021_summary_views.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0021"
|
||||||
|
down_revision = "0020"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dollar_tag(line: str) -> str | None:
|
||||||
|
m = _DOLLAR_TAG_RE.search(line.strip())
|
||||||
|
return m.group(0) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_statements(sql: str) -> list[str]:
|
||||||
|
statements: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
in_dollar = False
|
||||||
|
dollar_tag: str | None = None
|
||||||
|
|
||||||
|
for line in sql.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("--"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_dollar:
|
||||||
|
tag = _find_dollar_tag(stripped)
|
||||||
|
if tag and tag.endswith("$") and tag.startswith("$"):
|
||||||
|
dollar_tag = tag
|
||||||
|
in_dollar = True
|
||||||
|
current.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
||||||
|
after = stripped[len(dollar_tag):].strip()
|
||||||
|
if after == ";" or after == "":
|
||||||
|
in_dollar = False
|
||||||
|
dollar_tag = None
|
||||||
|
if after == ";":
|
||||||
|
current.append(line)
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_dollar and stripped.rstrip().endswith(";"):
|
||||||
|
current.append(line)
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(line)
|
||||||
|
|
||||||
|
remaining = "\n".join(current).strip()
|
||||||
|
if remaining:
|
||||||
|
statements.append(remaining)
|
||||||
|
|
||||||
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0021_summary_views.sql")
|
||||||
|
with open(ddl_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
statements = _split_statements(content)
|
||||||
|
for stmt in statements:
|
||||||
|
stripped = stmt.strip().rstrip(";").strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||||
|
continue
|
||||||
|
op.execute(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
1833
api/alembic/versions/sql/0021_summary_views.sql
Normal file
1833
api/alembic/versions/sql/0021_summary_views.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.api.v1 import (
|
from src.api.v1 import (
|
||||||
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item
|
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item, summary
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.api.v1 import websocket
|
from src.api.v1 import websocket
|
||||||
@ -20,3 +20,4 @@ api_router.include_router(websocket.router)
|
|||||||
api_router.include_router(org_unit.router)
|
api_router.include_router(org_unit.router)
|
||||||
api_router.include_router(vsp.router)
|
api_router.include_router(vsp.router)
|
||||||
api_router.include_router(expense_item.router)
|
api_router.include_router(expense_item.router)
|
||||||
|
api_router.include_router(summary.router)
|
||||||
|
|||||||
88
api/src/api/v1/summary.py
Normal file
88
api/src/api/v1/summary.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.deps import get_current_active_user_with_set_db
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.session import get_db
|
||||||
|
from src.domain.schemas import BaseListResponse, SheetResponse
|
||||||
|
from src.services.summary_service import SummaryService
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/svod/1", tags=["svod"])
|
||||||
|
|
||||||
|
VALID_SUMMARY_SHEETS = {
|
||||||
|
"FORM_1",
|
||||||
|
"FORM_2",
|
||||||
|
"FORM_3",
|
||||||
|
"FORM_4",
|
||||||
|
"MAIN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
parsed = [int(x.strip()) for x in org_ids.split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "org_ids должен быть списком целых чисел")
|
||||||
|
return parsed or None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sheet}/{year}")
|
||||||
|
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_active_user_with_set_db),
|
||||||
|
) -> BaseListResponse[SheetResponse]:
|
||||||
|
if sheet not in VALID_SUMMARY_SHEETS:
|
||||||
|
raise HTTPException(
|
||||||
|
404,
|
||||||
|
f"Лист свода {sheet} должен быть одни из: {', '.join(sorted(VALID_SUMMARY_SHEETS))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
summary_service = SummaryService(db)
|
||||||
|
rows = await summary_service.get_rows(
|
||||||
|
year=year,
|
||||||
|
sheet=sheet,
|
||||||
|
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",
|
||||||
|
"time_ms": f"{db_ms:.2f}",
|
||||||
|
"handler": "get_summary",
|
||||||
|
"sheet": sheet,
|
||||||
|
"year": year,
|
||||||
|
"org_ids": org_ids,
|
||||||
|
"user": current_user.id,
|
||||||
|
}
|
||||||
|
logger.info(f"db_time: {extra}", extra=extra)
|
||||||
|
|
||||||
|
return BaseListResponse(
|
||||||
|
count=len(rows),
|
||||||
|
result=_rows_to_sheet_response(rows),
|
||||||
|
)
|
||||||
30
api/src/repository/summary_repository.py
Normal file
30
api/src/repository/summary_repository.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_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[])
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
await self.db.execute(
|
||||||
|
query,
|
||||||
|
{"year": year, "sheet": sheet, "org_ids": org_ids},
|
||||||
|
)
|
||||||
|
).all()
|
||||||
21
api/src/services/summary_service.py
Normal file
21
api/src/services/summary_service.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.repository.summary_repository import SummaryRepository
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.summary_repo = SummaryRepository(db)
|
||||||
|
|
||||||
|
async def get_rows(
|
||||||
|
self,
|
||||||
|
year: int,
|
||||||
|
sheet: str,
|
||||||
|
org_ids: list[int] | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.summary_repo.get_rows(
|
||||||
|
year=year,
|
||||||
|
sheet=sheet,
|
||||||
|
org_ids=org_ids,
|
||||||
|
)
|
||||||
40
api/tests/integration/test_summary_api_smoke.py
Normal file
40
api/tests/integration/test_summary_api_smoke.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sheet",
|
||||||
|
["FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"],
|
||||||
|
)
|
||||||
|
def test_summary_sheets_smoke(client, admin_tokens, auth_headers, sheet):
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/svod/1/{sheet}/2026",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, f"sheet={sheet}: {response.text}"
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_sheet_with_org_ids(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/svod/1/FORM_1/2026?org_ids=1,2,3",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert payload["result"]
|
||||||
|
row = payload["result"][0]
|
||||||
|
assert "row_type" in row
|
||||||
|
assert "depth" in row
|
||||||
|
assert "data" in row
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_sheet_unknown(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/svod/1/unknown_sheet/2026",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
Loading…
x
Reference in New Issue
Block a user