feat(tradein): API домов «доли квартир в продаже» — /buildings/sale-share (+ listings, summary) (#2055)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m39s
Deploy Trade-In / build-backend (push) Successful in 8m5s
Deploy Trade-In / deploy (push) Successful in 52s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m39s
Deploy Trade-In / build-backend (push) Successful in 8m5s
Deploy Trade-In / deploy (push) Successful in 52s
This commit is contained in:
parent
94d01ed066
commit
f3e10a04ad
5 changed files with 599 additions and 1 deletions
104
tradein-mvp/backend/app/api/v1/buildings.py
Normal file
104
tradein-mvp/backend/app/api/v1/buildings.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""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|active_desc|exposure_desc|price_asc|price_desc)$"),
|
||||||
|
] = "share_desc",
|
||||||
|
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
||||||
|
) -> list[BuildingSaleShare]:
|
||||||
|
"""Дома вторички с долей квартир в продаже >= 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:
|
||||||
|
"""Сводка распределения sale_share_pct: coverage, max, p95, гистограмма."""
|
||||||
|
scalars = db.execute(text(build_summary_scalars_query())).mappings().one()
|
||||||
|
|
||||||
|
hist_rows = db.execute(text(build_summary_histogram_query())).mappings().all()
|
||||||
|
counts = {str(r["bucket"]): int(r["count"]) for r in hist_rows}
|
||||||
|
histogram = [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS]
|
||||||
|
|
||||||
|
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=(float(scalars["max_pct"]) if scalars["max_pct"] is not None else None),
|
||||||
|
p95_pct=(float(scalars["p95_pct"]) if scalars["p95_pct"] is not None else None),
|
||||||
|
histogram=histogram,
|
||||||
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
from app.api.v1 import admin, brand, geocode, me, search, trade_in
|
from app.api.v1 import admin, brand, buildings, geocode, me, search, trade_in
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
|
@ -167,5 +167,6 @@ app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
|
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
|
|
|
||||||
78
tradein-mvp/backend/app/schemas/buildings.py
Normal file
78
tradein-mvp/backend/app/schemas/buildings.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Pydantic-схемы страницы «доля квартир дома в продаже» (мигр. 143).
|
||||||
|
|
||||||
|
Источник данных — view v_building_sale_share + базовая таблица listings.
|
||||||
|
Список домов вторичного рынка, где active_secondary / flat_count_effective >= threshold%.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class BuildingSaleShare(BaseModel):
|
||||||
|
"""Один дом из v_building_sale_share для списка /buildings/sale-share."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
house_id: int
|
||||||
|
address: str | None = None
|
||||||
|
lat: float | None = None
|
||||||
|
lon: float | None = None
|
||||||
|
|
||||||
|
sale_share_pct: float | None = None
|
||||||
|
# implausible-матч (active_secondary > знаменателя) — скорее всего коллизия
|
||||||
|
# адреса при ГАР-матче; не прячем, а маркируем для UI.
|
||||||
|
over_100: bool = False
|
||||||
|
|
||||||
|
active_secondary: int | None = None
|
||||||
|
flat_count_effective: int | None = None
|
||||||
|
gar_match_method: str | None = None
|
||||||
|
|
||||||
|
median_price_rub: int | None = None
|
||||||
|
median_price_per_m2: int | None = None
|
||||||
|
avg_days_on_market: float | None = None
|
||||||
|
|
||||||
|
year_built: int | None = None
|
||||||
|
house_type: str | None = None
|
||||||
|
total_floors: int | None = None
|
||||||
|
series_name: str | None = None
|
||||||
|
is_emergency: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BuildingListing(BaseModel):
|
||||||
|
"""Активное вторичное объявление в доме (click-through drawer)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
listing_id: int
|
||||||
|
source: str
|
||||||
|
source_url: str | None = None
|
||||||
|
price_rub: int | None = None
|
||||||
|
price_per_m2: int | None = None
|
||||||
|
rooms: int | None = None
|
||||||
|
area_m2: float | None = None
|
||||||
|
floor: int | None = None
|
||||||
|
total_floors: int | None = None
|
||||||
|
days_on_market: int | None = None
|
||||||
|
listing_date: date | None = None
|
||||||
|
address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistogramBucket(BaseModel):
|
||||||
|
"""Корзина гистограммы распределения sale_share_pct."""
|
||||||
|
|
||||||
|
bucket: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SaleShareSummary(BaseModel):
|
||||||
|
"""Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary)."""
|
||||||
|
|
||||||
|
total_secondary_buildings: int
|
||||||
|
buildings_with_denominator: int
|
||||||
|
coverage_pct: float
|
||||||
|
max_pct: float | None = None
|
||||||
|
p95_pct: float | None = None
|
||||||
|
histogram: list[HistogramBucket]
|
||||||
166
tradein-mvp/backend/app/services/buildings_query.py
Normal file
166
tradein-mvp/backend/app/services/buildings_query.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143).
|
||||||
|
|
||||||
|
Читаем готовый view v_building_sale_share — логику доли НЕ пересчитываем.
|
||||||
|
Все запросы параметризованы (SQLAlchemy text + bind params, CAST(:x AS type) —
|
||||||
|
psycopg v3). Сортировка — строго по whitelist-колонкам, user-ввод в SQL не
|
||||||
|
интерполируется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# share_desc / active_desc / exposure_desc / price_asc / price_desc → безопасный ORDER BY.
|
||||||
|
# house_id как стабильный tiebreaker для детерминированной пагинации.
|
||||||
|
_SORT_SQL: dict[str, str] = {
|
||||||
|
"share_desc": "sale_share_pct DESC NULLS LAST, house_id",
|
||||||
|
"active_desc": "active_secondary DESC NULLS LAST, house_id",
|
||||||
|
"exposure_desc": "avg_days_on_market DESC NULLS LAST, house_id",
|
||||||
|
"price_asc": "median_price_rub ASC NULLS LAST, house_id",
|
||||||
|
"price_desc": "median_price_rub DESC NULLS LAST, house_id",
|
||||||
|
}
|
||||||
|
ALLOWED_SORTS: frozenset[str] = frozenset(_SORT_SQL)
|
||||||
|
|
||||||
|
# Гистограмма распределения sale_share_pct — фиксированный порядок корзин.
|
||||||
|
HISTOGRAM_BUCKETS: tuple[str, ...] = (
|
||||||
|
"0-5",
|
||||||
|
"5-10",
|
||||||
|
"10-20",
|
||||||
|
"20-30",
|
||||||
|
"30-50",
|
||||||
|
"50-100",
|
||||||
|
"100+",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Колонки view, отдаваемые в список (в порядке SELECT). median_* округляем до
|
||||||
|
# bigint — percentile_cont отдаёт double precision (может быть x.5), а Pydantic
|
||||||
|
# int-поле на дробном float падает.
|
||||||
|
_LIST_COLUMNS = (
|
||||||
|
"house_id, "
|
||||||
|
"COALESCE(short_address, full_address, address) AS address, "
|
||||||
|
"lat, lon, "
|
||||||
|
"sale_share_pct, "
|
||||||
|
"(sale_share_pct > 100) AS over_100, "
|
||||||
|
"active_secondary, "
|
||||||
|
"flat_count_effective, "
|
||||||
|
"gar_match_method, "
|
||||||
|
"CAST(round(median_price_rub) AS bigint) AS median_price_rub, "
|
||||||
|
"CAST(round(median_price_per_m2) AS bigint) AS median_price_per_m2, "
|
||||||
|
"avg_days_on_market, "
|
||||||
|
"year_built, house_type, total_floors, series_name, is_emergency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sale_share_query(
|
||||||
|
*,
|
||||||
|
min_pct: float = 5.0,
|
||||||
|
max_pct: float | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
price_min: int | None = None,
|
||||||
|
price_max: int | None = None,
|
||||||
|
year_min: int | None = None,
|
||||||
|
year_max: int | None = None,
|
||||||
|
house_type: str | None = None,
|
||||||
|
sort: str = "share_desc",
|
||||||
|
limit: int = 200,
|
||||||
|
) -> tuple[str, dict[str, object]]:
|
||||||
|
"""SELECT домов из v_building_sale_share по фильтрам.
|
||||||
|
|
||||||
|
Всегда требует sale_share_pct IS NOT NULL (строки без знаменателя — только
|
||||||
|
для coverage в summary, не в списке).
|
||||||
|
"""
|
||||||
|
where: list[str] = ["sale_share_pct IS NOT NULL"]
|
||||||
|
args: dict[str, object] = {}
|
||||||
|
|
||||||
|
where.append("sale_share_pct >= CAST(:min_pct AS numeric)")
|
||||||
|
args["min_pct"] = min_pct
|
||||||
|
|
||||||
|
if max_pct is not None:
|
||||||
|
where.append("sale_share_pct <= CAST(:max_pct AS numeric)")
|
||||||
|
args["max_pct"] = max_pct
|
||||||
|
|
||||||
|
if city:
|
||||||
|
# case-insensitive substring по всем адресным полям (город может быть в любом).
|
||||||
|
where.append(
|
||||||
|
"(COALESCE(short_address, '') || ' ' || COALESCE(full_address, '') "
|
||||||
|
"|| ' ' || COALESCE(address, '')) ILIKE CAST(:city_like AS text)"
|
||||||
|
)
|
||||||
|
args["city_like"] = f"%{city}%"
|
||||||
|
|
||||||
|
if price_min is not None:
|
||||||
|
where.append("median_price_rub >= CAST(:price_min AS bigint)")
|
||||||
|
args["price_min"] = price_min
|
||||||
|
if price_max is not None:
|
||||||
|
where.append("median_price_rub <= CAST(:price_max AS bigint)")
|
||||||
|
args["price_max"] = price_max
|
||||||
|
|
||||||
|
if year_min is not None:
|
||||||
|
where.append("year_built >= CAST(:year_min AS integer)")
|
||||||
|
args["year_min"] = year_min
|
||||||
|
if year_max is not None:
|
||||||
|
where.append("year_built <= CAST(:year_max AS integer)")
|
||||||
|
args["year_max"] = year_max
|
||||||
|
|
||||||
|
if house_type:
|
||||||
|
where.append("house_type = CAST(:house_type AS text)")
|
||||||
|
args["house_type"] = house_type
|
||||||
|
|
||||||
|
order_sql = _SORT_SQL.get(sort, _SORT_SQL["share_desc"])
|
||||||
|
args["limit"] = max(1, min(int(limit), 1000))
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
f"SELECT {_LIST_COLUMNS} "
|
||||||
|
"FROM v_building_sale_share "
|
||||||
|
f"WHERE {' AND '.join(where)} "
|
||||||
|
f"ORDER BY {order_sql} "
|
||||||
|
"LIMIT CAST(:limit AS integer)"
|
||||||
|
)
|
||||||
|
return sql, args
|
||||||
|
|
||||||
|
|
||||||
|
def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]:
|
||||||
|
"""Активные вторичные объявления одного дома (ORDER BY price_rub)."""
|
||||||
|
sql = (
|
||||||
|
"SELECT id AS listing_id, source, source_url, price_rub, price_per_m2, "
|
||||||
|
"rooms, CAST(area_m2 AS double precision) AS area_m2, floor, total_floors, "
|
||||||
|
"days_on_market, listing_date, address "
|
||||||
|
"FROM listings "
|
||||||
|
"WHERE house_id_fk = CAST(:house_id AS bigint) "
|
||||||
|
"AND is_active AND listing_segment = 'vtorichka' "
|
||||||
|
"ORDER BY price_rub ASC NULLS LAST, id"
|
||||||
|
)
|
||||||
|
return sql, {"house_id": house_id}
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_scalars_query() -> str:
|
||||||
|
"""Скаляры сводки: всего домов, с знаменателем, coverage, max, p95."""
|
||||||
|
return (
|
||||||
|
"SELECT "
|
||||||
|
"count(*) AS total_secondary_buildings, "
|
||||||
|
"count(*) FILTER (WHERE sale_share_pct IS NOT NULL) "
|
||||||
|
"AS buildings_with_denominator, "
|
||||||
|
"COALESCE(round("
|
||||||
|
"100.0 * count(*) FILTER (WHERE sale_share_pct IS NOT NULL) "
|
||||||
|
"/ NULLIF(count(*), 0), 1), 0) AS coverage_pct, "
|
||||||
|
"max(sale_share_pct) AS max_pct, "
|
||||||
|
"percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct) AS p95_pct "
|
||||||
|
"FROM v_building_sale_share"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_histogram_query() -> str:
|
||||||
|
"""Гистограмма sale_share_pct по фиксированным корзинам (только non-null)."""
|
||||||
|
return (
|
||||||
|
"SELECT "
|
||||||
|
"CASE "
|
||||||
|
"WHEN sale_share_pct < 5 THEN '0-5' "
|
||||||
|
"WHEN sale_share_pct < 10 THEN '5-10' "
|
||||||
|
"WHEN sale_share_pct < 20 THEN '10-20' "
|
||||||
|
"WHEN sale_share_pct < 30 THEN '20-30' "
|
||||||
|
"WHEN sale_share_pct < 50 THEN '30-50' "
|
||||||
|
"WHEN sale_share_pct <= 100 THEN '50-100' "
|
||||||
|
"ELSE '100+' "
|
||||||
|
"END AS bucket, "
|
||||||
|
"count(*) AS count "
|
||||||
|
"FROM v_building_sale_share "
|
||||||
|
"WHERE sale_share_pct IS NOT NULL "
|
||||||
|
"GROUP BY bucket"
|
||||||
|
)
|
||||||
249
tradein-mvp/backend/tests/test_buildings_api.py
Normal file
249
tradein-mvp/backend/tests/test_buildings_api.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""Тесты API «доля квартир дома в продаже» (мигр. 143).
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
1. SQL-билдеры (чистые функции) — shape, CAST(:x AS type), whitelist-сортировка,
|
||||||
|
clamp лимита, фильтры. БД не нужна.
|
||||||
|
2. Contract — минимальный app с замоканным get_db: 200 + правильная форма ответа
|
||||||
|
(RBAC middleware не монтируем, mirror trade_in_app из test_estimate_idor).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# psycopg v3 driver требует DATABASE_URL до импорта app.core.db.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.services.buildings_query import (
|
||||||
|
ALLOWED_SORTS,
|
||||||
|
HISTOGRAM_BUCKETS,
|
||||||
|
build_listings_query,
|
||||||
|
build_sale_share_query,
|
||||||
|
build_summary_histogram_query,
|
||||||
|
build_summary_scalars_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── SQL-билдеры ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sale_share_minimal_defaults():
|
||||||
|
sql, args = build_sale_share_query()
|
||||||
|
assert "FROM v_building_sale_share" in sql
|
||||||
|
assert "sale_share_pct IS NOT NULL" in sql
|
||||||
|
assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql
|
||||||
|
assert "(sale_share_pct > 100) AS over_100" in sql
|
||||||
|
assert "COALESCE(short_address, full_address, address) AS address" in sql
|
||||||
|
assert args["min_pct"] == 5.0
|
||||||
|
assert args["limit"] == 200
|
||||||
|
# default sort = share_desc
|
||||||
|
assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_sale_share_all_filters():
|
||||||
|
sql, args = build_sale_share_query(
|
||||||
|
min_pct=10,
|
||||||
|
max_pct=80,
|
||||||
|
city="Екатеринбург",
|
||||||
|
price_min=3_000_000,
|
||||||
|
price_max=12_000_000,
|
||||||
|
year_min=1990,
|
||||||
|
year_max=2015,
|
||||||
|
house_type="panel",
|
||||||
|
sort="price_asc",
|
||||||
|
)
|
||||||
|
assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
|
||||||
|
assert "ILIKE CAST(:city_like AS text)" in sql
|
||||||
|
assert args["city_like"] == "%Екатеринбург%"
|
||||||
|
assert "median_price_rub >= CAST(:price_min AS bigint)" in sql
|
||||||
|
assert "median_price_rub <= CAST(:price_max AS bigint)" in sql
|
||||||
|
assert "year_built >= CAST(:year_min AS integer)" in sql
|
||||||
|
assert "year_built <= CAST(:year_max AS integer)" in sql
|
||||||
|
assert "house_type = CAST(:house_type AS text)" in sql
|
||||||
|
assert "ORDER BY median_price_rub ASC NULLS LAST" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_sale_share_no_double_colon_cast():
|
||||||
|
"""psycopg v3: ни одного `:name::type` — только CAST(...)."""
|
||||||
|
sql, _ = build_sale_share_query(city="x", price_min=1, year_min=2000, house_type="brick")
|
||||||
|
import re
|
||||||
|
|
||||||
|
assert re.search(r":[a-z_]+::[a-z]", sql) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sale_share_limit_clamped():
|
||||||
|
_, args_hi = build_sale_share_query(limit=99999)
|
||||||
|
assert args_hi["limit"] == 1000
|
||||||
|
_, args_lo = build_sale_share_query(limit=0)
|
||||||
|
assert args_lo["limit"] == 1
|
||||||
|
_, args_neg = build_sale_share_query(limit=-5)
|
||||||
|
assert args_neg["limit"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sort", sorted(ALLOWED_SORTS))
|
||||||
|
def test_sale_share_sort_whitelisted(sort: str):
|
||||||
|
sql, _ = build_sale_share_query(sort=sort)
|
||||||
|
assert "ORDER BY" in sql
|
||||||
|
# tiebreaker house_id присутствует во всех сортировках
|
||||||
|
assert "house_id" in sql.split("ORDER BY", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sale_share_unknown_sort_falls_back():
|
||||||
|
"""Несуществующий sort не интерполируется — fallback на share_desc."""
|
||||||
|
sql, _ = build_sale_share_query(sort="DROP TABLE houses")
|
||||||
|
assert "DROP TABLE" not in sql
|
||||||
|
assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_listings_query_shape():
|
||||||
|
sql, args = build_listings_query(42)
|
||||||
|
assert "FROM listings" in sql
|
||||||
|
assert "house_id_fk = CAST(:house_id AS bigint)" in sql
|
||||||
|
assert "listing_segment = 'vtorichka'" in sql
|
||||||
|
assert "is_active" in sql
|
||||||
|
assert "ORDER BY price_rub" in sql
|
||||||
|
assert "id AS listing_id" in sql
|
||||||
|
assert args["house_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_scalars_query_shape():
|
||||||
|
sql = build_summary_scalars_query()
|
||||||
|
assert "total_secondary_buildings" in sql
|
||||||
|
assert "FILTER (WHERE sale_share_pct IS NOT NULL)" in sql
|
||||||
|
assert "NULLIF(count(*), 0)" in sql
|
||||||
|
assert "percentile_cont(0.95)" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_histogram_buckets_cover_spec():
|
||||||
|
sql = build_summary_histogram_query()
|
||||||
|
for boundary in ("'0-5'", "'5-10'", "'10-20'", "'20-30'", "'30-50'", "'50-100'", "'100+'"):
|
||||||
|
assert boundary in sql
|
||||||
|
assert HISTOGRAM_BUCKETS == ("0-5", "5-10", "10-20", "20-30", "30-50", "50-100", "100+")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contract (минимальный app, замоканный get_db) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def buildings_app() -> FastAPI:
|
||||||
|
"""FastAPI mounting только buildings router (без RBAC middleware)."""
|
||||||
|
from app.api.v1 import buildings as buildings_module
|
||||||
|
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(buildings_module.router, prefix="/api/v1/buildings")
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def _client_with_rows(app: FastAPI, *execute_results: object) -> TestClient:
|
||||||
|
"""Override get_db → mock, где db.execute даёт execute_results по порядку."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
if len(execute_results) == 1:
|
||||||
|
db.execute.return_value = execute_results[0]
|
||||||
|
else:
|
||||||
|
db.execute.side_effect = list(execute_results)
|
||||||
|
|
||||||
|
def _override_db():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapped(rows: object) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.mappings.return_value.all.return_value = rows
|
||||||
|
m.mappings.return_value.one.return_value = rows
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sale_share_list_200(buildings_app: FastAPI):
|
||||||
|
row = {
|
||||||
|
"house_id": 7,
|
||||||
|
"address": "ул. Ленина, 1",
|
||||||
|
"lat": 56.8,
|
||||||
|
"lon": 60.6,
|
||||||
|
"sale_share_pct": 12.5,
|
||||||
|
"over_100": False,
|
||||||
|
"active_secondary": 3,
|
||||||
|
"flat_count_effective": 24,
|
||||||
|
"gar_match_method": None,
|
||||||
|
"median_price_rub": 5_000_000,
|
||||||
|
"median_price_per_m2": 95_000,
|
||||||
|
"avg_days_on_market": 41.0,
|
||||||
|
"year_built": 1999,
|
||||||
|
"house_type": "panel",
|
||||||
|
"total_floors": 9,
|
||||||
|
"series_name": None,
|
||||||
|
"is_emergency": False,
|
||||||
|
}
|
||||||
|
client = _client_with_rows(buildings_app, _mapped([row]))
|
||||||
|
resp = client.get("/api/v1/buildings/sale-share?min_pct=10")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["house_id"] == 7
|
||||||
|
assert body[0]["over_100"] is False
|
||||||
|
assert body[0]["sale_share_pct"] == 12.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI):
|
||||||
|
"""ГАР не загружен → пустой список, но 200 (форма, не падение)."""
|
||||||
|
client = _client_with_rows(buildings_app, _mapped([]))
|
||||||
|
resp = client.get("/api/v1/buildings/sale-share")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_listings_200(buildings_app: FastAPI):
|
||||||
|
row = {
|
||||||
|
"listing_id": 101,
|
||||||
|
"source": "avito",
|
||||||
|
"source_url": "https://avito.ru/101",
|
||||||
|
"price_rub": 4_900_000,
|
||||||
|
"price_per_m2": 92_000,
|
||||||
|
"rooms": 2,
|
||||||
|
"area_m2": 53.2,
|
||||||
|
"floor": 4,
|
||||||
|
"total_floors": 9,
|
||||||
|
"days_on_market": 30,
|
||||||
|
"listing_date": "2026-05-01",
|
||||||
|
"address": "ул. Ленина, 1",
|
||||||
|
}
|
||||||
|
client = _client_with_rows(buildings_app, _mapped([row]))
|
||||||
|
resp = client.get("/api/v1/buildings/7/listings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body[0]["listing_id"] == 101
|
||||||
|
assert body[0]["source"] == "avito"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_summary_zero_fills_histogram(buildings_app: FastAPI):
|
||||||
|
scalars = _mapped(
|
||||||
|
{
|
||||||
|
"total_secondary_buildings": 10,
|
||||||
|
"buildings_with_denominator": 4,
|
||||||
|
"coverage_pct": 40.0,
|
||||||
|
"max_pct": 250.0,
|
||||||
|
"p95_pct": 33.3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}])
|
||||||
|
client = _client_with_rows(buildings_app, scalars, hist)
|
||||||
|
resp = client.get("/api/v1/buildings/sale-share/summary")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total_secondary_buildings"] == 10
|
||||||
|
assert body["buildings_with_denominator"] == 4
|
||||||
|
assert body["coverage_pct"] == 40.0
|
||||||
|
# все 7 корзин присутствуют, недостающие — count 0, порядок фиксирован
|
||||||
|
assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS)
|
||||||
|
counts = {b["bucket"]: b["count"] for b in body["histogram"]}
|
||||||
|
assert counts["0-5"] == 2
|
||||||
|
assert counts["100+"] == 1
|
||||||
|
assert counts["20-30"] == 0
|
||||||
Loading…
Add table
Reference in a new issue