feat(tradein): MapPicker snap marker — Phase 5 of #582 #594

Merged
Light1YT merged 1 commit from feat/tradein-mappicker-snap-marker into main 2026-05-27 09:30:17 +00:00
5 changed files with 677 additions and 37 deletions

View file

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

View file

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

View file

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

View 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

View file

@ -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"]);
/** Минимальная дистанция clicksnap в метрах для перемещения 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"