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).
119 lines
4.3 KiB
Python
119 lines
4.3 KiB
Python
"""Geocode endpoints — debug + frontend Suggest proxy."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/lookup", response_model=GeocodeResult)
|
||
async def lookup(
|
||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> GeocodeResult:
|
||
"""Геокодинг адреса → lat/lon.
|
||
|
||
Примеры:
|
||
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
||
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
|
||
"""
|
||
result = await geocode(address, db)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
||
return result
|
||
|
||
|
||
class SuggestItem(BaseModel):
|
||
label: str
|
||
full_address: str
|
||
lat: float
|
||
lon: float
|
||
kind: str
|
||
|
||
|
||
class SuggestResponse(BaseModel):
|
||
items: list[SuggestItem]
|
||
|
||
|
||
@router.get("/suggest", response_model=SuggestResponse)
|
||
async def suggest_addresses(
|
||
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||
) -> SuggestResponse:
|
||
"""Автокомплит адресов в пределах ЕКБ.
|
||
|
||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
|
||
|
||
Пример:
|
||
/api/v1/geocode/suggest?q=Малышева
|
||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||
"""
|
||
items = await suggest(q, db=db, limit=limit)
|
||
return SuggestResponse(items=[
|
||
SuggestItem(
|
||
label=s.label,
|
||
full_address=s.full_address,
|
||
lat=s.lat, lon=s.lon, kind=s.kind,
|
||
) for s in items
|
||
])
|
||
|
||
|
||
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]
|
||
) -> ReverseResponse:
|
||
"""Обратный геокодинг — координаты с карты → адрес + snapped точка здания.
|
||
|
||
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
|
||
"""
|
||
result = await reverse_geocode(lat, lon, db=db)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="address not found for coordinates")
|
||
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,
|
||
)
|