diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index a9e9a5f8..2c4da301 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -6,7 +6,7 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.db import get_db @@ -71,17 +71,49 @@ async def suggest_addresses( ]) -@router.get("/reverse") +class ReverseResponse(BaseModel): + """Reverse-geocode response для MapPicker'а. + + `lat`/`lon` — echo входной точки клика (для логики «отодвинули ли далеко»). + `snapped_lat`/`snapped_lon` — центр matched здания от provider'а. Если + `precision in ("exact","number")` фронт двигает marker на snapped point — + пользователь видит «магнит к дому». Для остальных precision snapped == input. + """ + + address: str + lat: float = Field(..., description="Исходная latitude клика") + lon: float = Field(..., description="Исходная longitude клика") + snapped_lat: float = Field(..., description="Latitude центра matched здания") + snapped_lon: float = Field(..., description="Longitude центра matched здания") + precision: str = Field( + ..., + description=( + "Yandex-style: exact/number/street/range/near/locality/other/cadastral. " + "Фронт двигает marker только если exact/number/cadastral." + ), + ) + provider: str = Field(..., description="cadastral | yandex | nominatim") + + +@router.get("/reverse", response_model=ReverseResponse) async def reverse( lat: Annotated[float, Query(ge=-90, le=90)], lon: Annotated[float, Query(ge=-180, le=180)], db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] -) -> dict[str, object]: - """Обратный геокодинг — координаты с карты → адрес (map-picker). +) -> ReverseResponse: + """Обратный геокодинг — координаты с карты → адрес + snapped точка здания. Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057 """ - address = await reverse_geocode(lat, lon, db=db) - if address is None: + result = await reverse_geocode(lat, lon, db=db) + if result is None: raise HTTPException(status_code=404, detail="address not found for coordinates") - return {"address": address, "lat": lat, "lon": lon} + return ReverseResponse( + address=result.address, + lat=lat, + lon=lon, + snapped_lat=result.snapped_lat, + snapped_lon=result.snapped_lon, + precision=result.precision, + provider=result.provider, + ) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index aba0aaba..31ce9212 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -649,6 +649,40 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # ── Reverse: координаты → адрес (для map-picker'а) ────────────────────────── +# Precision levels which we treat as "снап к зданию имеет смысл": +# - exact — точный матч на здание (Yandex) +# - number — найден дом с номером (то что нам надо для квартирного оценщика) +# - cadastral — Cadastral FDW row (то же по точности что Yandex "number") +# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике. +_SNAP_PRECISIONS = {"exact", "number", "cadastral"} + + +@dataclass(frozen=True, slots=True) +class ReverseGeocodeResult: + """Reverse-геокодинг с snapped координатами matched здания. + + - `address` — текстовый адрес (улица + дом + город). + - `snapped_lat` — координата центра здания если provider дал её, + иначе echo `lat` входной точки (для precision=street/locality). + - `snapped_lon` — то же. + - `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/ + `locality`/`other`/`cadastral`. Используется фронтом чтобы + решить — двигать marker (exact/number) или нет. + - `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`). + + Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number` + и snapped >5m от click point — пересаживает marker на snapped point + (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе). + Для остальных precision marker остаётся где кликнули — не врём что нашли + точное здание. + """ + address: str + snapped_lat: float + snapped_lon: float + precision: str + provider: Literal["yandex", "nominatim", "cadastral"] + + def _format_reverse_address(addr: dict) -> str | None: """Собирает чистый уличный адрес из Nominatim address-объекта. @@ -683,8 +717,84 @@ def _format_reverse_address(addr: dict) -> str | None: @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _nominatim_reverse(lat: float, lon: float) -> str | None: - """Nominatim /reverse — extracted for fallback usage. @retry handles transient errors.""" +async def _yandex_reverse( + lat: float, lon: float, api_key: str +) -> ReverseGeocodeResult | None: + """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision. + + Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html + Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!). + """ + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + "https://geocode-maps.yandex.ru/1.x/", + params={ + "apikey": api_key, + "geocode": f"{lon},{lat}", + "format": "json", + "results": "1", + "kind": "house", # просим именно здание (house), не улицу + "lang": "ru_RU", + }, + ) + response.raise_for_status() + data = response.json() + + members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) + if not members: + return None + obj = members[0].get("GeoObject", {}) + try: + lon_str, lat_str = obj["Point"]["pos"].split() + snapped_lat = float(lat_str) + snapped_lon = float(lon_str) + except (KeyError, ValueError): + return None + meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) + precision = str(meta.get("precision", "other")) + address_text = str(meta.get("text") or obj.get("name") or "") + # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix, + # оставляем «улица, дом, город» для consistency с Nominatim/cadastral. + if address_text: + # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51» + # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street) + parts = [p.strip() for p in address_text.split(",") if p.strip()] + filtered = [ + p for p in parts + if p not in {"Россия", "Свердловская область"} + and not p.startswith("городской округ") + ] + # Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец + locality = None + rest: list[str] = [] + for p in filtered: + if locality is None and p in {"Екатеринбург"}: + locality = p + else: + rest.append(p) + if locality and rest: + address_text = ", ".join([*rest, locality]) + else: + address_text = ", ".join(filtered) + + if not address_text: + return None + return ReverseGeocodeResult( + address=address_text, + snapped_lat=snapped_lat, + snapped_lon=snapped_lon, + precision=precision, + provider="yandex", + ) + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: + """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. + + Nominatim возвращает координаты центра matched feature (building/way/node). + precision выводим из наличия `house_number` в addressdetails. + """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", @@ -706,31 +816,121 @@ async def _nominatim_reverse(lat: float, lon: float) -> str | None: if not isinstance(data, dict) or "error" in data: return None addr = data.get("address") + address_text: str | None = None if isinstance(addr, dict): - street = _format_reverse_address(addr) - if street: - return street - return data.get("display_name") + address_text = _format_reverse_address(addr) + if not address_text: + display = data.get("display_name") + if not display: + return None + address_text = str(display) + # snapped coords — то что вернул Nominatim (центр matched feature) + try: + snapped_lat = float(data["lat"]) + snapped_lon = float(data["lon"]) + except (KeyError, ValueError, TypeError): + snapped_lat, snapped_lon = lat, lon + has_house = isinstance(addr, dict) and bool(addr.get("house_number")) + precision = "number" if has_house else "street" + return ReverseGeocodeResult( + address=address_text, + snapped_lat=snapped_lat, + snapped_lon=snapped_lon, + precision=precision, + provider="nominatim", + ) -async def reverse_geocode(lat: float, lon: float, db: Session | None = None) -> str | None: - """Cadastral FDW → Nominatim fallback. +def _cadastral_reverse_sync_full( + db: Session, lat: float, lon: float, radius_m: int = 200 +) -> tuple[str, float, float] | None: + """Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon). + + Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес) + остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`. + """ + try: + row = db.execute( + text(""" + WITH candidates AS ( + SELECT cad_num, readable_address, lat, lon, + 111320.0 * sqrt( + pow(CAST(:lat AS double precision) - lat, 2) + + pow( + cos(radians(CAST(:lat AS double precision))) + * (CAST(:lon AS double precision) - lon), 2 + ) + ) AS dist_m + FROM gendesign_cad_buildings + WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025 + AND CAST(:lat AS double precision) + 0.0025 + AND lon BETWEEN CAST(:lon AS double precision) - 0.005 + AND CAST(:lon AS double precision) + 0.005 + AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)' + ) + SELECT readable_address, lat, lon, dist_m + FROM candidates + WHERE dist_m < CAST(:radius AS double precision) + ORDER BY dist_m ASC + LIMIT 1 + """), + {"lat": lat, "lon": lon, "radius": float(radius_m)}, + ).first() + except Exception: + logger.warning( + "cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True + ) + return None + if row is None: + return None + return (str(row.readable_address), float(row.lat), float(row.lon)) + + +async def reverse_geocode( + lat: float, lon: float, db: Session | None = None +) -> ReverseGeocodeResult | None: + """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords. Возвращает None если ни один источник не дал адрес. Endpoint api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError наверх — раньше Nominatim 403 → RetryError → FastAPI 500. + Snapped lat/lon — это центр matched здания (от provider'а), не echo + входных координат. Фронт по precision решает — двигать marker (exact/number) + или оставить на клике (street/locality). + db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier). """ # 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI) if db is not None: - cadastral = await asyncio.to_thread(_cadastral_reverse_sync, db, lat, lon) - if cadastral: - return cadastral + cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon) + if cad is not None: + address, snap_lat, snap_lon = cad + return ReverseGeocodeResult( + address=address, + snapped_lat=snap_lat, + snapped_lon=snap_lon, + precision="number", # cadastral row = здание с house number + provider="cadastral", + ) - # 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) + # 2. Yandex — основной источник snap'а (его Point.pos = центр здания) + if settings.yandex_geocoder_api_key: + try: + result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key) + if result is not None: + return result + except Exception: + logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon) + + # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) try: return await _nominatim_reverse(lat, lon) except Exception: logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon) return None + + +def snap_precision_useful(precision: str) -> bool: + """True если precision означает «нашли точное здание» — фронт двигает marker.""" + return precision in _SNAP_PRECISIONS diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py index f238f93d..7e2e3026 100644 --- a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -23,6 +23,7 @@ sys.modules.setdefault("weasyprint", _wp_mock) from app.services.geocoder import ( # noqa: E402 GeocodeSuggestion, + ReverseGeocodeResult, _cadastral_forward_sync, _cadastral_reverse_sync, geocode, @@ -157,54 +158,121 @@ def test_reverse_sync_returns_none_on_db_error() -> None: # ── reverse_geocode ─────────────────────────────────────────────────────────── +def _nom_result(address: str) -> ReverseGeocodeResult: + return ReverseGeocodeResult( + address=address, + snapped_lat=56.838, + snapped_lon=60.605, + precision="number", + provider="nominatim", + ) + + async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None: - """Cadastral returns address → Nominatim is never called.""" + """Cadastral returns address → Yandex/Nominatim never called.""" db = MagicMock() with ( patch( - "app.services.geocoder._cadastral_reverse_sync", - return_value="ул. Малышева, 30, Екатеринбург", + "app.services.geocoder._cadastral_reverse_sync_full", + return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052), ) as mock_cad, + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._yandex_reverse", + new_callable=AsyncMock, + ) as mock_yandex, patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, ) as mock_nom, ): + mock_settings.yandex_geocoder_api_key = "fake-key" result = await reverse_geocode(56.838, 60.605, db=db) - assert result == "ул. Малышева, 30, Екатеринбург" + assert result is not None + assert result.address == "ул. Малышева, 30, Екатеринбург" + assert result.snapped_lat == 56.8385 + assert result.snapped_lon == 60.6052 + assert result.precision == "number" + assert result.provider == "cadastral" mock_cad.assert_called_once_with(db, 56.838, 60.605) + mock_yandex.assert_not_called() + mock_nom.assert_not_called() + + +async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None: + """Cadastral None + Yandex key set → Yandex called, Nominatim skipped.""" + db = MagicMock() + yandex_result = ReverseGeocodeResult( + address="улица Малышева, 51, Екатеринбург", + snapped_lat=56.838004, + snapped_lon=60.586155, + precision="exact", + provider="yandex", + ) + with ( + patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._yandex_reverse", + new_callable=AsyncMock, + return_value=yandex_result, + ) as mock_yandex, + patch( + "app.services.geocoder._nominatim_reverse", + new_callable=AsyncMock, + ) as mock_nom, + ): + mock_settings.yandex_geocoder_api_key = "fake-key" + result = await reverse_geocode(56.838, 60.586, db=db) + + assert result is not None + assert result.provider == "yandex" + assert result.precision == "exact" + assert result.snapped_lat == 56.838004 + mock_yandex.assert_called_once() mock_nom.assert_not_called() async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() -> None: - """Cadastral returns None → Nominatim called and result returned.""" + """Cadastral None + no Yandex key → Nominatim called.""" db = MagicMock() with ( - patch("app.services.geocoder._cadastral_reverse_sync", return_value=None), + patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), + patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, - return_value="Трамвайный переулок, 2, Екатеринбург", + return_value=_nom_result("Трамвайный переулок, 2, Екатеринбург"), ) as mock_nom, ): + mock_settings.yandex_geocoder_api_key = None result = await reverse_geocode(56.838, 60.605, db=db) - assert result == "Трамвайный переулок, 2, Екатеринбург" + assert result is not None + assert result.address == "Трамвайный переулок, 2, Екатеринбург" + assert result.provider == "nominatim" mock_nom.assert_called_once_with(56.838, 60.605) -async def test_reverse_geocode_returns_none_when_both_fail() -> None: - """Both cadastral and Nominatim fail → returns None, no exception raised (500 fix).""" +async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None: + """Cadastral + Yandex + Nominatim — все падают → returns None, no exception.""" db = MagicMock() with ( - patch("app.services.geocoder._cadastral_reverse_sync", return_value=None), + patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._yandex_reverse", + new_callable=AsyncMock, + side_effect=RuntimeError("Yandex 429 rate-limited"), + ), patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"), ), ): + mock_settings.yandex_geocoder_api_key = "fake-key" result = await reverse_geocode(56.838, 60.605, db=db) # Must return None, not raise @@ -212,21 +280,49 @@ async def test_reverse_geocode_returns_none_when_both_fail() -> None: async def test_reverse_geocode_without_db_skips_cadastral() -> None: - """When db=None, cadastral tier is skipped entirely → goes straight to Nominatim.""" + """When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim.""" with ( patch( - "app.services.geocoder._cadastral_reverse_sync", + "app.services.geocoder._cadastral_reverse_sync_full", ) as mock_cad, + patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, - return_value="ул. Ленина, 1, Екатеринбург", + return_value=_nom_result("ул. Ленина, 1, Екатеринбург"), ), ): + mock_settings.yandex_geocoder_api_key = None result = await reverse_geocode(56.838, 60.605, db=None) mock_cad.assert_not_called() - assert result == "ул. Ленина, 1, Екатеринбург" + assert result is not None + assert result.address == "ул. Ленина, 1, Екатеринбург" + + +async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None: + """Yandex raises → Nominatim still tried.""" + db = MagicMock() + with ( + patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._yandex_reverse", + new_callable=AsyncMock, + side_effect=RuntimeError("Yandex 500"), + ), + patch( + "app.services.geocoder._nominatim_reverse", + new_callable=AsyncMock, + return_value=_nom_result("ул. Дублёр, 1"), + ) as mock_nom, + ): + mock_settings.yandex_geocoder_api_key = "fake-key" + result = await reverse_geocode(56.838, 60.605, db=db) + + assert result is not None + assert result.provider == "nominatim" + mock_nom.assert_called_once() # ── geocode: cadastral as first tier ───────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_geocode_reverse_api.py b/tradein-mvp/backend/tests/test_geocode_reverse_api.py new file mode 100644 index 00000000..a50bb838 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocode_reverse_api.py @@ -0,0 +1,228 @@ +"""Tests for /api/v1/geocode/reverse — snapped coords + precision in response. + +Bug context (issue #582 Phase 5): +До этого PR'а endpoint echo'ил input lat/lon в ответе. После reverse-геокодинга +адрес resolved'ился до canonical здания (ул. Малышева, 125), но marker на фронте +оставался где user кликнул — иногда в проезде / дворе. + +Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания +от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision +in (exact, number, cadastral). +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# DATABASE_URL required by config before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint stub — not installed in CI without GTK. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.api.v1 import geocode as geocode_module # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.services.geocoder import ( # noqa: E402 + ReverseGeocodeResult, + snap_precision_useful, +) + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(geocode_module.router, prefix="/api/v1/geocode") + application.dependency_overrides[get_db] = lambda: MagicMock() + return application + + +# ── Endpoint response shape ────────────────────────────────────────────────── + +def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None: + """Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision.""" + client = TestClient(app) + fake = ReverseGeocodeResult( + address="улица Малышева, 51, Екатеринбург", + snapped_lat=56.838004, + snapped_lon=60.586155, + precision="exact", + provider="yandex", + ) + with patch( + "app.api.v1.geocode.reverse_geocode", + new_callable=AsyncMock, + return_value=fake, + ): + r = client.get("/api/v1/geocode/reverse?lat=56.8381&lon=60.5860") + + assert r.status_code == 200 + body = r.json() + # Echo input + assert body["lat"] == 56.8381 + assert body["lon"] == 60.5860 + # Snap from provider + assert body["address"] == "улица Малышева, 51, Екатеринбург" + assert body["snapped_lat"] == 56.838004 + assert body["snapped_lon"] == 60.586155 + assert body["precision"] == "exact" + assert body["provider"] == "yandex" + + +def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None: + """Provider returned None → endpoint должен дать 404, не 500.""" + client = TestClient(app) + with patch( + "app.api.v1.geocode.reverse_geocode", + new_callable=AsyncMock, + return_value=None, + ): + r = client.get("/api/v1/geocode/reverse?lat=56.0&lon=60.0") + assert r.status_code == 404 + + +def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAPI) -> None: + """Для precision=street snapped_lat/lon всё равно присутствуют (могут == input).""" + client = TestClient(app) + fake = ReverseGeocodeResult( + address="улица Малышева, Екатеринбург", + snapped_lat=56.84, + snapped_lon=60.61, + precision="street", + provider="nominatim", + ) + with patch( + "app.api.v1.geocode.reverse_geocode", + new_callable=AsyncMock, + return_value=fake, + ): + r = client.get("/api/v1/geocode/reverse?lat=56.84&lon=60.61") + assert r.status_code == 200 + body = r.json() + assert "snapped_lat" in body + assert "snapped_lon" in body + assert body["precision"] == "street" + + +# ── snap_precision_useful helper ───────────────────────────────────────────── + +def test_snap_precision_useful_exact_and_number() -> None: + assert snap_precision_useful("exact") is True + assert snap_precision_useful("number") is True + + +def test_snap_precision_useful_rejects_street_and_other() -> None: + assert snap_precision_useful("street") is False + assert snap_precision_useful("range") is False + assert snap_precision_useful("near") is False + assert snap_precision_useful("locality") is False + assert snap_precision_useful("other") is False + assert snap_precision_useful("") is False + + +# ── Yandex reverse parsing ─────────────────────────────────────────────────── + +async def test_yandex_reverse_parses_snapped_point_and_precision() -> None: + """`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty.""" + import httpx + + from app.services.geocoder import _yandex_reverse + + sample = { + "response": { + "GeoObjectCollection": { + "featureMember": [ + { + "GeoObject": { + "metaDataProperty": { + "GeocoderMetaData": { + "precision": "exact", + "text": ( + "Россия, Свердловская область, " + "Екатеринбург, улица Малышева, 51" + ), + "kind": "house", + } + }, + "name": "улица Малышева, 51", + "Point": {"pos": "60.586155 56.838004"}, + } + } + ] + } + } + } + + class _FakeResp: + status_code = 200 + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return sample + + class _FakeClient: + async def __aenter__(self) -> _FakeClient: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def get(self, *_: object, **__: object) -> _FakeResp: + return _FakeResp() + + with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()): + result = await _yandex_reverse(56.8381, 60.5860, api_key="fake") + + assert result is not None + # Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155 + assert abs(result.snapped_lat - 56.838004) < 1e-6 + assert abs(result.snapped_lon - 60.586155) < 1e-6 + assert result.precision == "exact" + assert result.provider == "yandex" + # Address text должен быть очищен от "Россия, Свердловская область" + assert "Россия" not in result.address + assert "Свердловская область" not in result.address + assert "Малышева" in result.address + assert "51" in result.address + + +async def test_yandex_reverse_returns_none_on_empty_results() -> None: + """Empty featureMember → None.""" + import httpx + + from app.services.geocoder import _yandex_reverse + + sample = {"response": {"GeoObjectCollection": {"featureMember": []}}} + + class _FakeResp: + status_code = 200 + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return sample + + class _FakeClient: + async def __aenter__(self) -> _FakeClient: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def get(self, *_: object, **__: object) -> _FakeResp: + return _FakeResp() + + with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()): + result = await _yandex_reverse(56.0, 60.0, api_key="fake") + + assert result is None diff --git a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx index 194ce389..2836dac8 100644 --- a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx @@ -13,6 +13,43 @@ const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; const EKB_CENTER: [number, number] = [56.8389, 60.6057]; +/** Precision значения от reverse endpoint, при которых имеет смысл двигать + * marker на snapped point (центр здания от Yandex / нашего FDW). Для + * street/range/near/locality marker остаётся на клике — иначе соврём + * пользователю, что нашли точное здание. */ +const SNAP_PRECISIONS = new Set(["exact", "number", "cadastral"]); + +/** Минимальная дистанция click→snap в метрах для перемещения marker'а: + * меньше — не двигаем (иначе marker «дёргается» при кликах прямо в дом). */ +const SNAP_MIN_DISTANCE_M = 5; + +/** Расстояние в метрах между двумя точками (haversine, достаточно точно для <1km). */ +function haversineMeters( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const R = 6_371_000; // м + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +interface ReverseResponse { + address: string; + lat: number; + lon: number; + snapped_lat: number; + snapped_lon: number; + precision: string; + provider: string; +} + /** Подгружает Leaflet с CDN один раз, резолвит window.L. */ function loadLeaflet(): Promise { return new Promise((resolve, reject) => { @@ -57,6 +94,10 @@ export function MapPicker({ onPick, onClose }: Props) { const [address, setAddress] = useState(null); const [loading, setLoading] = useState(false); const [mapError, setMapError] = useState(false); + // True если последний reverse snap'нул marker на точку здания. Показываем + // тонкий hint «Точка дома по Яндексу», чтобы user понимал почему marker + // переехал на пару метров от его клика. + const [snapped, setSnapped] = useState(false); useEffect(() => { let map: any = null; @@ -86,12 +127,43 @@ export function MapPicker({ onPick, onClose }: Props) { fillOpacity: 0.45, }).addTo(map); setAddress(null); + setSnapped(false); setLoading(true); fetch(`${API_BASE_URL}/api/v1/geocode/reverse?lat=${lat}&lon=${lon}`) - .then((r) => (r.ok ? r.json() : null)) - .then((d) => setAddress((d?.address as string) ?? null)) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((d) => { + if (cancelled || !d) { + setAddress(null); + return; + } + setAddress(d.address); + // Snap marker → центр здания если provider дал точное precision + // и snapped >5m от клика (иначе скачок незаметен / создаёт jitter). + if ( + SNAP_PRECISIONS.has(d.precision) && + typeof d.snapped_lat === "number" && + typeof d.snapped_lon === "number" + ) { + const dist = haversineMeters( + lat, + lon, + d.snapped_lat, + d.snapped_lon, + ); + if (dist >= SNAP_MIN_DISTANCE_M && marker && map) { + marker.setLatLng([d.snapped_lat, d.snapped_lon]); + map.panTo([d.snapped_lat, d.snapped_lon], { + animate: true, + duration: 0.3, + }); + setSnapped(true); + } + } + }) .catch(() => setAddress(null)) - .finally(() => setLoading(false)); + .finally(() => { + if (!cancelled) setLoading(false); + }); }); }) .catch(() => setMapError(true)); @@ -215,6 +287,18 @@ export function MapPicker({ onPick, onClose }: Props) { : address ? address : "Кликните по дому на карте"} + {address && snapped && !loading ? ( +
+ Точка дома по Яндексу +
+ ) : null}