feat(tradein): MapPicker snap marker на canonical building point (PR H, #582 Phase 5)
Backend /api/v1/geocode/reverse теперь возвращает snapped_lat/snapped_lon центра matched здания (от Yandex Point.pos / Nominatim response.lat-lon / cadastral row) + precision. Раньше endpoint echo'ил input lat/lon — адрес resolved'ился до canonical «ул. Малышева, 125», но marker оставался где кликнул user (часто в проезде / дворе). Backend: - reverse_geocode: str | None → ReverseGeocodeResult | None (address + snapped_lat/snapped_lon + precision + provider). Provider fallback Cadastral FDW → Yandex (если key) → Nominatim. Yandex /reverse дёргаем с kind=house чтобы сразу получить здание. - /api/v1/geocode/reverse: новый Pydantic ReverseResponse model. - snap_precision_useful() helper для frontend logic parity. - Legacy _cadastral_reverse_sync сохранён для backward compat. Frontend MapPicker: - После reverse — если precision in (exact, number, cadastral) и snapped >5m от клика → marker.setLatLng + map.panTo с animate - precision=street/range/locality → marker остаётся где кликнули (не врём что нашли точное здание). - Hint «Точка дома по Яндексу» под address с aria-live="polite" (var(--fg-tertiary) — token compliance). - haversineMeters helper для distance check (6371000m Earth radius). Tests: 37 backend pass, ruff + tsc clean. Code-reviewer LGTM, 1 nit applied inline (hex → var(--fg-tertiary)). Closes #582 (Phase 5).
This commit is contained in:
parent
aa70e9e155
commit
74026ef1d9
5 changed files with 677 additions and 37 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
228
tradein-mvp/backend/tests/test_geocode_reverse_api.py
Normal file
228
tradein-mvp/backend/tests/test_geocode_reverse_api.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -57,6 +94,10 @@ export function MapPicker({ onPick, onClose }: Props) {
|
|||
const [address, setAddress] = useState<string | null>(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<ReverseResponse>) : 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 ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
Точка дома по Яндексу
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue