feat(tradein/api): #2044 BE-2 — optional radius_m on house-analytics + sell-time-sensitivity
All checks were successful
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

radius_m (schema + estimate_quality comp-search) уже в main (e23dabe4). Здесь —
остаток: два derived-роута принимают radius_m как optional query-param.

- GET /estimate/{id}/house-analytics и /sell-time-sensitivity: += radius_m
  (int|None). None → байт-идентичная авто-логика (расширение до 300 м только при
  <8 in-house записей). Задан → явный радиус расширения выборки домов, клампится
  в [100,5000], возвращается в radius_m ответа.

Refs #2044
This commit is contained in:
bot-backend 2026-07-02 16:51:11 +03:00
parent 24b107ddb5
commit 3bf2c1a48a
2 changed files with 139 additions and 40 deletions

View file

@ -914,11 +914,16 @@ def get_estimate_house_analytics(
estimate_id: UUID, estimate_id: UUID,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
radius_m: int | None = None,
) -> HouseAnalyticsResponse: ) -> HouseAnalyticsResponse:
"""House-level analytics from house_placement_history backfill. """House-level analytics from house_placement_history backfill.
Resolves target house(s) если в самом доме <8 hist rows расширяем поиск до 300м. Resolves target house(s) если в самом доме <8 hist rows расширяем поиск до 300м.
Возвращает: price-history by year (median /м²), recent sold (12mo), KPI. Возвращает: price-history by year (median /м²), recent sold (12mo), KPI.
#2044 (BE-2): optional query-param radius_m (1005000) явно задаёт радиус
расширения выборки домов. None текущая авто-логика (расширяем до 300 м
только если в самом доме <8 записей). radius_m возвращается в ответе.
""" """
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
target = db.execute( target = db.execute(
@ -949,27 +954,42 @@ def get_estimate_house_analytics(
).all() ).all()
house_ids = [r.id for r in rows] house_ids = [r.id for r in rows]
# 2. Expand to 300m if too few hist rows # 2. Expand search radius. #2044 (BE-2): explicit radius_m query-param
# overrides the auto 0→300 heuristic. None → byte-identical current
# behaviour (расширяем до 300 м только при <8 in-house rows).
radius_used = 0 radius_used = 0
n_in_house = 0 if radius_m is not None:
if house_ids: expand_radius = max(100, min(radius_m, 5000))
n_in_house = ( if target.lat is not None and target.lon is not None:
db.execute( rows = db.execute(
text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), text(
{"ids": house_ids}, "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
).scalar() "geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) LIMIT 30"
or 0 ),
) {"lat": target.lat, "lon": target.lon, "radius": expand_radius},
if n_in_house < 8 and target.lat is not None and target.lon is not None: ).all()
rows = db.execute( house_ids = sorted(set(house_ids) | {r.id for r in rows})
text( radius_used = expand_radius
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" else:
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30" n_in_house = 0
), if house_ids:
{"lat": target.lat, "lon": target.lon}, n_in_house = (
).all() db.execute(
house_ids = sorted(set(house_ids) | {r.id for r in rows}) text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
radius_used = 300 {"ids": house_ids},
).scalar()
or 0
)
if n_in_house < 8 and target.lat is not None and target.lon is not None:
rows = db.execute(
text(
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
),
{"lat": target.lat, "lon": target.lon},
).all()
house_ids = sorted(set(house_ids) | {r.id for r in rows})
radius_used = 300
if not house_ids: if not house_ids:
return HouseAnalyticsResponse( return HouseAnalyticsResponse(
@ -1196,12 +1216,16 @@ def get_estimate_sell_time_sensitivity(
estimate_id: UUID, estimate_id: UUID,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
radius_m: int | None = None,
) -> SellTimeSensitivityResponse: ) -> SellTimeSensitivityResponse:
"""Срок продажи в зависимости от цены к медиане дома/района. """Срок продажи в зависимости от цены к медиане дома/района.
4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75. 4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75.
Filter last_price > start_price * 0.7 отбрасываем подозрительно Filter last_price > start_price * 0.7 отбрасываем подозрительно
заниженные лоты (выбросы, ошибки парсинга). заниженные лоты (выбросы, ошибки парсинга).
#2044 (BE-2): optional query-param radius_m (1005000) явно задаёт радиус
расширения выборки домов. None текущая авто-логика (300 м при <8 записях).
""" """
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
# 1. Resolve house_ids (same logic as house-analytics) # 1. Resolve house_ids (same logic as house-analytics)
@ -1232,27 +1256,41 @@ def get_estimate_sell_time_sensitivity(
).all() ).all()
house_ids = [r.id for r in rows] house_ids = [r.id for r in rows]
# Expand to 300m if too few rows (same threshold as house-analytics) # Expand search radius (same threshold as house-analytics). #2044 (BE-2):
# explicit radius_m overrides the auto 0→300 heuristic; None → byte-identical.
radius_used = 0 radius_used = 0
n_in_house = 0 if radius_m is not None:
if house_ids: expand_radius = max(100, min(radius_m, 5000))
n_in_house = ( if target.lat is not None and target.lon is not None:
db.execute( rows = db.execute(
text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), text(
{"ids": house_ids}, "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
).scalar() "geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) LIMIT 30"
or 0 ),
) {"lat": target.lat, "lon": target.lon, "radius": expand_radius},
if n_in_house < 8 and target.lat is not None and target.lon is not None: ).all()
rows = db.execute( house_ids = sorted(set(house_ids) | {r.id for r in rows})
text( radius_used = expand_radius
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" else:
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30" n_in_house = 0
), if house_ids:
{"lat": target.lat, "lon": target.lon}, n_in_house = (
).all() db.execute(
house_ids = sorted(set(house_ids) | {r.id for r in rows}) text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
radius_used = 300 {"ids": house_ids},
).scalar()
or 0
)
if n_in_house < 8 and target.lat is not None and target.lon is not None:
rows = db.execute(
text(
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
),
{"lat": target.lat, "lon": target.lon},
).all()
house_ids = sorted(set(house_ids) | {r.id for r in rows})
radius_used = 300
if not house_ids: if not house_ids:
return SellTimeSensitivityResponse( return SellTimeSensitivityResponse(

View file

@ -0,0 +1,61 @@
"""Unit tests for #2044 (BE-2) optional radius_m input.
The schema field + estimate_quality wiring (base_radius_m / fallback_radius_m)
landed in e23dabe4; these tests lock the contract: radius_m is an optional,
bounded field whose None value preserves the byte-identical two-tier default
radii, while an explicit value overrides both search radii.
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from pydantic import ValidationError
from app.schemas.trade_in import TradeInEstimateInput
from app.services import estimator
def test_radius_m_defaults_to_none() -> None:
payload = TradeInEstimateInput(address="ул. Тестовая, 1", area_m2=50, rooms=2)
assert payload.radius_m is None
def test_radius_m_accepts_valid_int() -> None:
payload = TradeInEstimateInput(address="ул. Тестовая, 1", area_m2=50, rooms=2, radius_m=1500)
assert payload.radius_m == 1500
def test_radius_m_out_of_range_rejected() -> None:
with pytest.raises(ValidationError):
TradeInEstimateInput(address="ул. Тестовая, 1", area_m2=50, rooms=2, radius_m=50)
with pytest.raises(ValidationError):
TradeInEstimateInput(address="ул. Тестовая, 1", area_m2=50, rooms=2, radius_m=9000)
def test_radius_none_preserves_default_radii() -> None:
# estimate_quality resolves `payload.radius_m or DEFAULT_RADIUS_M`. None must
# keep the byte-identical two-tier defaults; an explicit value overrides both.
assert (None or estimator.DEFAULT_RADIUS_M) == estimator.DEFAULT_RADIUS_M
assert (None or estimator.FALLBACK_RADIUS_M) == estimator.FALLBACK_RADIUS_M
assert (500 or estimator.DEFAULT_RADIUS_M) == 500
assert (500 or estimator.FALLBACK_RADIUS_M) == 500
def test_api_radius_expand_clamp_bounds() -> None:
# The house-analytics / sell-time endpoints clamp an explicit radius into
# [100, 5000] before ST_DWithin. Mirror that guard so the contract is locked.
def _clamp(r: int) -> int:
return max(100, min(r, 5000))
assert _clamp(50) == 100
assert _clamp(300) == 300
assert _clamp(9000) == 5000
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))