"""ОКН-lookup для analyze_parcel (#1066, #1159). Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия в заданном радиусе от участка — [{source_id, category, distance_m, name, address}]. ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326. ``name``/``address`` извлекаются из ``raw_props`` (обогащаются воркером через detail-карточку ЕГРКН). Если detail недоступен — возвращается None для этих полей, остальные поля сохраняются. Без detail-фикса (#1159 acceptance 2) analyze-таблица показывала «ОКН рядом» без указания КАКОГО — критично для ИРД. Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration) или БД-ошибка — возвращает пустой список (analyze не падает). """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # Пространственный запрос: ST_DWithin по geography даёт расстояние в метрах. # CAST psycopg v3 (никогда :param::type — vault Pattern_CAST_AS_Type). # raw_props->>'name'/'address' — JSON-string accessor (NULL если ключа нет # или raw_props=NULL). Безопасно для строк без detail-обогащения. _OKN_DWITHIN_SQL = text(""" SELECT source_id, category, ROUND( ST_Distance( geom::geography, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography ) )::integer AS distance_m, raw_props->>'name' AS name, raw_props->>'address' AS address FROM okn_objects WHERE ST_DWithin( geom::geography, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography, CAST(:radius_m AS double precision) ) ORDER BY distance_m ASC """) def parcel_okn_objects( db: Session, parcel_wkt: str | None, radius_m: float = 100.0, ) -> list[dict[str, Any]]: """ОКН-объекты в радиусе radius_m от участка. Args: db: SQLAlchemy-сессия. parcel_wkt: геометрия участка в WKT (EPSG:4326). None → пустой список. radius_m: радиус поиска в метрах (по умолчанию 100 м). Returns: Список dict [{source_id, category, distance_m, name, address}], отсортированный по расстоянию ASC. ``name``/``address`` могут быть None если detail-карточка не догружена. Пустой список при отсутствии участка / таблицы / объектов в радиусе (analyze не падает). """ if not parcel_wkt: return [] try: rows = db.execute( _OKN_DWITHIN_SQL, {"parcel_wkt": parcel_wkt, "radius_m": radius_m}, ).fetchall() except (OperationalError, ProgrammingError) as exc: logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc) return [] except Exception as exc: logger.warning("parcel_okn_objects: ошибка запроса okn_objects: %s", exc) return [] result: list[dict[str, Any]] = [] for row in rows: result.append( { "source_id": row[0], "category": row[1], "distance_m": row[2], "name": row[3], "address": row[4], } ) return result __all__ = ["parcel_okn_objects"]