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