svod: свод 1

This commit is contained in:
tsygankoviva 2026-08-20 16:48:30 +03:00
parent 0549f18dc4
commit 6f6f35c8b1
7 changed files with 2096 additions and 1 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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),
)

View 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()

View 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,
)

View 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