feat(tradein): /estimate принимает координаты от автокомплита, минует geocode (Variant A, backend) #1782

Merged
lekss361 merged 1 commit from feat/tradein-estimate-accept-coords into main 2026-06-19 11:16:52 +00:00
3 changed files with 156 additions and 1 deletions

View file

@ -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

View file

@ -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,

View 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)