gendesign/tradein-mvp/backend/app/api/v1/geocode.py
Light1YT 74026ef1d9 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).
2026-05-27 14:29:51 +05:00

119 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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