Fixes 500 on /trade-in/api/v1/geocode/reverse when Nominatim bans prod IP after geocode_missing batch. Cadastral lookup via main backend new endpoint /api/v1/cadastral/reverse is primary (37k buildings in ЕКБ, <10ms, no external API). Nominatim is fallback wrapped in try/except so HTTPStatusError no longer bubbles to FastAPI 500. Architecture: - main backend: new GET /api/v1/cadastral/reverse — cad_buildings nearest within radius, filters out гаражи/СНТ. Added to gendesign_shared network with alias gendesign-backend for cross-stack DNS. - tradein backend: _cadastral_reverse() calls main backend, returns None on any error. reverse_geocode prefers cadastral → falls back to Nominatim → returns None (endpoint becomes 404 instead of 500). Env: MAIN_BACKEND_URL=http://gendesign-backend:8000 in tradein prod. Tests: 7 new cases covering cadastral/nominatim/fallback/error paths.
101 lines
3.2 KiB
Python
101 lines
3.2 KiB
Python
"""Cadastral lookup endpoints — reverse geocode via cad_buildings.
|
||
|
||
Used by trade-in backend (and potentially other internal services) to resolve
|
||
координаты → адрес жилого дома без зависимости от внешних геокодеров.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class CadastralReverseResult(BaseModel):
|
||
address: str
|
||
lat: float
|
||
lon: float
|
||
distance_m: float
|
||
cad_num: str | None = None
|
||
|
||
|
||
@router.get("/reverse", response_model=CadastralReverseResult)
|
||
async def reverse(
|
||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||
radius_m: Annotated[int, Query(ge=10, le=2000)] = 200,
|
||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||
) -> CadastralReverseResult:
|
||
"""Координаты → ближайшее здание из cad_buildings.
|
||
|
||
Filters out гаражи/СНТ/садовод/товарищества — для trade-in нужны жилые дома.
|
||
|
||
Returns 404 если в радиусе нет подходящего здания.
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
readable_address,
|
||
cad_num,
|
||
ST_Y(ST_Centroid(geom)) AS lat,
|
||
ST_X(ST_Centroid(geom)) AS lon,
|
||
ST_Distance(
|
||
geom::geography,
|
||
ST_SetSRID(
|
||
ST_MakePoint(
|
||
CAST(:lon AS double precision),
|
||
CAST(:lat AS double precision)
|
||
),
|
||
4326
|
||
)::geography
|
||
) AS distance_m
|
||
FROM cad_buildings
|
||
WHERE ST_DWithin(
|
||
geom::geography,
|
||
ST_SetSRID(
|
||
ST_MakePoint(
|
||
CAST(:lon AS double precision),
|
||
CAST(:lat AS double precision)
|
||
),
|
||
4326
|
||
)::geography,
|
||
CAST(:radius AS double precision)
|
||
)
|
||
AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
|
||
ORDER BY ST_Distance(
|
||
geom::geography,
|
||
ST_SetSRID(
|
||
ST_MakePoint(
|
||
CAST(:lon AS double precision),
|
||
CAST(:lat AS double precision)
|
||
),
|
||
4326
|
||
)::geography
|
||
)
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"lat": lat, "lon": lon, "radius": float(radius_m)},
|
||
).first()
|
||
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="no cadastral building found within radius")
|
||
|
||
return CadastralReverseResult(
|
||
address=row.readable_address,
|
||
lat=float(row.lat),
|
||
lon=float(row.lon),
|
||
distance_m=round(float(row.distance_m), 1),
|
||
cad_num=row.cad_num,
|
||
)
|