diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 764f141c..dff19ea0 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -914,11 +914,16 @@ def get_estimate_house_analytics( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, + radius_m: int | None = None, ) -> HouseAnalyticsResponse: """House-level analytics from house_placement_history backfill. Resolves target house(s) — если в самом доме <8 hist rows — расширяем поиск до 300м. Возвращает: price-history by year (median ₽/м²), recent sold (12mo), KPI. + + #2044 (BE-2): optional query-param radius_m (100–5000) явно задаёт радиус + расширения выборки домов. None → текущая авто-логика (расширяем до 300 м + только если в самом доме <8 записей). radius_m возвращается в ответе. """ _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) target = db.execute( @@ -949,27 +954,42 @@ def get_estimate_house_analytics( ).all() 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 - n_in_house = 0 - if house_ids: - n_in_house = ( - db.execute( - text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), - {"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 radius_m is not None: + expand_radius = max(100, min(radius_m, 5000)) + if 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, :radius) LIMIT 30" + ), + {"lat": target.lat, "lon": target.lon, "radius": expand_radius}, + ).all() + house_ids = sorted(set(house_ids) | {r.id for r in rows}) + radius_used = expand_radius + else: + n_in_house = 0 + if house_ids: + n_in_house = ( + db.execute( + text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), + {"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: return HouseAnalyticsResponse( @@ -1196,12 +1216,16 @@ def get_estimate_sell_time_sensitivity( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, + radius_m: int | None = None, ) -> SellTimeSensitivityResponse: """Срок продажи в зависимости от цены к медиане дома/района. 4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75. Filter last_price > start_price * 0.7 — отбрасываем подозрительно заниженные лоты (выбросы, ошибки парсинга). + + #2044 (BE-2): optional query-param radius_m (100–5000) явно задаёт радиус + расширения выборки домов. None → текущая авто-логика (300 м при <8 записях). """ _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) # 1. Resolve house_ids (same logic as house-analytics) @@ -1232,27 +1256,41 @@ def get_estimate_sell_time_sensitivity( ).all() 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 - n_in_house = 0 - if house_ids: - n_in_house = ( - db.execute( - text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), - {"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 radius_m is not None: + expand_radius = max(100, min(radius_m, 5000)) + if 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, :radius) LIMIT 30" + ), + {"lat": target.lat, "lon": target.lon, "radius": expand_radius}, + ).all() + house_ids = sorted(set(house_ids) | {r.id for r in rows}) + radius_used = expand_radius + else: + n_in_house = 0 + if house_ids: + n_in_house = ( + db.execute( + text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), + {"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: return SellTimeSensitivityResponse( diff --git a/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py new file mode 100644 index 00000000..f7444f65 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py @@ -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"]))