gendesign/backend/app/api/v1/cadastral.py
lekss361 bd6b8a2e76 feat(tradein): cadastral reverse geocode via main backend cad_buildings
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.
2026-05-24 10:22:06 +03:00

101 lines
3.2 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.

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