diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index b51889ea..777fce6d 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 44e5d480..d8bb487e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/tests/test_estimator_client_coords.py b/tradein-mvp/backend/tests/test_estimator_client_coords.py new file mode 100644 index 00000000..80411698 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_client_coords.py @@ -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)