feat(tradein): estimate accepts client-provided coords, skips geocode (Variant A)
This commit is contained in:
parent
41b3f08303
commit
99e2a5d75c
3 changed files with 156 additions and 1 deletions
|
|
@ -22,6 +22,11 @@ class TradeInEstimateInput(BaseModel):
|
|||
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
|
||||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
||||
has_balcony: bool | None = None
|
||||
# Variant A: координаты, уже разрезолвленные автокомплитом/картой на фронте.
|
||||
# Если переданы (и в пределах ЕКБ) — estimate использует их напрямую, минуя
|
||||
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
||||
ownership_type: str | None = Field(default=None, max_length=100)
|
||||
has_mortgage: bool | None = None
|
||||
|
|
|
|||
|
|
@ -1863,7 +1863,28 @@ async def estimate_quality(
|
|||
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack
|
||||
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
|
||||
geo: GeocodeResult | None = None
|
||||
if payload.address:
|
||||
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
|
||||
# and inside the EKB bbox — skips the geocode() chain that fails on DaData-format
|
||||
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally.
|
||||
if (
|
||||
payload.lat is not None
|
||||
and payload.lon is not None
|
||||
and 60.40 <= payload.lon <= 60.85
|
||||
and 56.65 <= payload.lat <= 56.95
|
||||
):
|
||||
geo = GeocodeResult(
|
||||
lat=payload.lat,
|
||||
lon=payload.lon,
|
||||
full_address=payload.address,
|
||||
provider="cache",
|
||||
confidence="exact",
|
||||
)
|
||||
logger.info(
|
||||
"estimate: using client coords (%.5f, %.5f) — skipping geocode",
|
||||
payload.lat,
|
||||
payload.lon,
|
||||
)
|
||||
if geo is None and payload.address:
|
||||
geo = await _with_budget(
|
||||
geocode(payload.address, db),
|
||||
settings.estimate_geocode_budget_s,
|
||||
|
|
|
|||
129
tradein-mvp/backend/tests/test_estimator_client_coords.py
Normal file
129
tradein-mvp/backend/tests/test_estimator_client_coords.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Variant A — /estimate accepts client-provided coords, skips server geocode().
|
||||
|
||||
The address autocomplete/suggest already resolves lat/lon for addresses that the
|
||||
server-side geocode() chain can't (DaData-format inputs, dead Yandex key). When the
|
||||
client passes valid in-EKB coords we build the GeocodeResult directly and skip
|
||||
geocode() entirely — fixing the `address_not_geocoded` failure class.
|
||||
|
||||
Contracts locked here:
|
||||
1. Valid in-EKB client coords → geocode() is NOT awaited, still returns an estimate.
|
||||
2. Out-of-bbox coords (Moscow) → ignored, geocode() IS called.
|
||||
3. lat/lon both None → unchanged, geocode() IS called.
|
||||
|
||||
Style mirrors test_estimator_cian_budget.py.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
|
||||
def _make_fake_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload(lat=None, lon=None):
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=38.8,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
total_floors=16,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
def _patches(geocode_mock):
|
||||
"""Common downstream mocks so estimate_quality runs offline to completion."""
|
||||
return (
|
||||
patch("app.services.estimator.geocode", new=geocode_mock),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
)
|
||||
|
||||
|
||||
def test_client_coords_in_ekb_skip_geocode() -> None:
|
||||
"""Valid in-EKB client coords → geocode() NOT awaited, estimate still returned."""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload(lat=56.838, lon=60.595)
|
||||
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
||||
|
||||
async def _run() -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _patches(geocode_mock):
|
||||
stack.enter_context(cm)
|
||||
result = await estimate_quality(payload, db)
|
||||
|
||||
geocode_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
anyio.run(_run)
|
||||
|
||||
|
||||
def test_out_of_bbox_coords_ignored_geocode_called() -> None:
|
||||
"""Moscow coords are outside the EKB bbox → ignored, geocode() IS called."""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload(lat=55.75, lon=37.61) # Moscow — out of EKB bbox
|
||||
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
||||
|
||||
async def _run() -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _patches(geocode_mock):
|
||||
stack.enter_context(cm)
|
||||
result = await estimate_quality(payload, db)
|
||||
|
||||
geocode_mock.assert_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
anyio.run(_run)
|
||||
|
||||
|
||||
def test_no_coords_geocode_called() -> None:
|
||||
"""lat/lon both None → unchanged behaviour, geocode() IS called."""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload(lat=None, lon=None)
|
||||
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
||||
|
||||
async def _run() -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _patches(geocode_mock):
|
||||
stack.enter_context(cm)
|
||||
result = await estimate_quality(payload, db)
|
||||
|
||||
geocode_mock.assert_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
anyio.run(_run)
|
||||
Loading…
Add table
Reference in a new issue