gendesign/tradein-mvp/backend/app/api/v1/buildings.py
lekss361 2a38561175
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
feat(tradein): второй % «за 45 дней» + zhkh-знаменатель slot (146) (#2071)
2026-06-28 17:51:38 +00:00

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