Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
"""Buildings — страница «доля квартир дома в продаже» (мигр. 143).
|
|
|
|
Дома вторичного рынка, где active_secondary / flat_count_effective >= threshold%.
|
|
Данные берём из готового view v_building_sale_share (логику доли не пересчитываем).
|
|
|
|
Endpoints:
|
|
- GET /api/v1/buildings/sale-share — список домов
|
|
- GET /api/v1/buildings/{house_id}/listings — активные вторичные объявления дома
|
|
- GET /api/v1/buildings/sale-share/summary — сводка для слайдера % + coverage
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Path, Query
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.db import get_db
|
|
from app.schemas.buildings import (
|
|
BuildingListing,
|
|
BuildingSaleShare,
|
|
HistogramBucket,
|
|
SaleShareSummary,
|
|
)
|
|
from app.services.buildings_query import (
|
|
HISTOGRAM_BUCKETS,
|
|
build_listings_query,
|
|
build_sale_share_query,
|
|
build_summary_histogram_query,
|
|
build_summary_scalars_query,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/sale-share", response_model=list[BuildingSaleShare])
|
|
async def list_sale_share(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
min_pct: Annotated[float, Query(ge=0, le=1000)] = 5.0,
|
|
max_pct: Annotated[float | None, Query(ge=0, le=1000)] = None,
|
|
city: Annotated[str | None, Query(max_length=120)] = None,
|
|
price_min: Annotated[int | None, Query(ge=0)] = None,
|
|
price_max: Annotated[int | None, Query(ge=0)] = None,
|
|
year_min: Annotated[int | None, Query(ge=1800, le=2100)] = None,
|
|
year_max: Annotated[int | None, Query(ge=1800, le=2100)] = None,
|
|
house_type: Annotated[str | None, Query(max_length=40)] = None,
|
|
sort: Annotated[
|
|
str,
|
|
Query(
|
|
pattern="^(share_desc|share_45d_desc|active_desc|count_45d_desc"
|
|
"|exposure_desc|price_asc|price_desc)$"
|
|
),
|
|
] = "share_desc",
|
|
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
|
) -> list[BuildingSaleShare]:
|
|
"""Дома вторички, где ЛЮБОЙ из двух % (now / 45д) >= min_pct (+ фильтры)."""
|
|
sql, args = build_sale_share_query(
|
|
min_pct=min_pct,
|
|
max_pct=max_pct,
|
|
city=city,
|
|
price_min=price_min,
|
|
price_max=price_max,
|
|
year_min=year_min,
|
|
year_max=year_max,
|
|
house_type=house_type,
|
|
sort=sort,
|
|
limit=limit,
|
|
)
|
|
rows = db.execute(text(sql), args).mappings().all()
|
|
return [BuildingSaleShare.model_validate(r) for r in rows]
|
|
|
|
|
|
@router.get("/{house_id}/listings", response_model=list[BuildingListing])
|
|
async def list_building_listings(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
house_id: Annotated[int, Path(ge=1)],
|
|
) -> list[BuildingListing]:
|
|
"""Активные вторичные объявления в одном доме (для drawer), ORDER BY price."""
|
|
sql, args = build_listings_query(house_id)
|
|
rows = db.execute(text(sql), args).mappings().all()
|
|
return [BuildingListing.model_validate(r) for r in rows]
|
|
|
|
|
|
@router.get("/sale-share/summary", response_model=SaleShareSummary)
|
|
async def sale_share_summary(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
) -> SaleShareSummary:
|
|
"""Сводка распределения обоих % (now + 45д): coverage, max, p95, две гистограммы."""
|
|
scalars = db.execute(text(build_summary_scalars_query())).mappings().one()
|
|
|
|
def _histogram(column: str) -> list[HistogramBucket]:
|
|
rows = db.execute(text(build_summary_histogram_query(column))).mappings().all()
|
|
counts = {str(r["bucket"]): int(r["count"]) for r in rows}
|
|
return [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS]
|
|
|
|
def _opt_float(key: str) -> float | None:
|
|
return float(scalars[key]) if scalars[key] is not None else None
|
|
|
|
return SaleShareSummary(
|
|
total_secondary_buildings=int(scalars["total_secondary_buildings"]),
|
|
buildings_with_denominator=int(scalars["buildings_with_denominator"]),
|
|
coverage_pct=float(scalars["coverage_pct"]),
|
|
max_pct=_opt_float("max_pct"),
|
|
p95_pct=_opt_float("p95_pct"),
|
|
max_pct_45d=_opt_float("max_pct_45d"),
|
|
p95_pct_45d=_opt_float("p95_pct_45d"),
|
|
histogram=_histogram("sale_share_pct"),
|
|
histogram_45d=_histogram("sale_share_pct_45d"),
|
|
)
|