feat(tradein/api): #2044 BE-2 — optional radius_m on house-analytics + sell-time-sensitivity
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:
parent
24b107ddb5
commit
3bf2c1a48a
2 changed files with 139 additions and 40 deletions
|
|
@ -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 (100–5000) явно задаёт радиус
|
||||||
|
расширения выборки домов. 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 (100–5000) явно задаёт радиус
|
||||||
|
расширения выборки домов. 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(
|
||||||
|
|
|
||||||
61
tradein-mvp/backend/tests/test_estimator_radius_m_2044.py
Normal file
61
tradein-mvp/backend/tests/test_estimator_radius_m_2044.py
Normal 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"]))
|
||||||
Loading…
Add table
Reference in a new issue