"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-66). Заменяет TODO-прокси quarter-prefix из analyze_parcel (#105 Phase 5): после того как Phase 3 (geocoding → geom) наполнила `gisogd_permits.geom`, соседние РНС/РВЭ ищутся точным `ST_DWithin(..., radius_m)` по геометрии, а не грубым совпадением префикса кадастрового квартала. Старый quarter-prefix путь (`ekburg_construction_permits` → `recent_permits_in_quarter` / `permits_summary`) НЕ трогается — он остаётся параллельно как отдельный, более богатый полями источник; этот модуль лишь добавляет второй, геометрически честный ключ payload. `gisogd_permits`: id / doc_group ('RS' — разрешение на строительство, 'RV' — ввод в эксплуатацию) / doc_num / doc_name / date_doc / date_reg / approved_organization / cad_nums TEXT[] / geom geometry(MultiPolygon, 4326), GIST-индекс на geom. PURE-по-данным (никаких выдуманных чисел): пустой радиус — честный ноль (total_count=0, items=[]), НЕ ошибка и НЕ None. Вызывается из синхронного analyze_parcel с той же non-fatal семантикой. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # Максимум записей в списке items. Счётчики (total_count/rs_count/rv_count/ # nearest_distance_m) считаются по ПОЛНОЙ выборке радиуса — БЕЗ LIMIT в SQL, чтобы не было # тихого капа (проектная конвенция «без тихих капов»: тот же паттерн чинили в дедупе # объявлений и фрешнес-мониторе). LIMIT капает лишь выдачу items → items_truncated=True. _ITEMS_LIMIT = 30 # Радиус-запрос по образцу POI-в-радиусе (osm_poi_ekb) из analyze_parcel: ST_DWithin по # geography-центроиду участка. `::geography` приклеено к `)`, НЕ к bind-name → psycopg v3 # его не съедает (backend.md: исключение из :name::type-запрета). БЕЗ LIMIT: агрегаты честны # по всей выборке; в радиусе 500м GIST-индекс делает выборку дешёвой, тысяч строк не будет. _PERMITS_NEARBY_SQL = text(""" SELECT doc_group, doc_name, doc_num, date_doc, date_reg, approved_organization, ST_Distance( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m FROM gisogd_permits WHERE geom IS NOT NULL AND ST_DWithin( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, :radius_m ) ORDER BY distance_m ASC """) def _empty_result(radius_m: int) -> dict[str, Any]: """Честный нулевой агрегат: 0 разрешений в радиусе (НЕ ошибка, НЕ None).""" return { "radius_m": radius_m, "total_count": 0, "rs_count": 0, "rv_count": 0, "nearest_distance_m": None, "items": [], "items_truncated": False, "source": "gisogd66", } def get_permits_nearby(db: Session, geom_wkt: str, radius_m: int = 500) -> dict[str, Any]: """РНС/РВЭ из ГИСОГД-66 в радиусе `radius_m` метров от участка (точный ST_DWithin). Args: db: активная сессия SQLAlchemy. geom_wkt: WKT-геометрия анализируемого участка (та же переменная, что для POI). radius_m: радиус поиска в метрах (по умолчанию 500). Returns: Агрегат с ЧЕСТНЫМИ счётчиками (см. `_empty_result` для пустой формы): radius_m, total_count / rs_count ('RS') / rv_count ('RV') — по ВСЕЙ выборке радиуса (без тихого капа), nearest_distance_m (round 1, None если пусто), items (≤30 ближайших), items_truncated (True если total_count > len(items)), source="gisogd66". Пустой радиус → честный ноль. """ rows = [ dict(r) for r in db.execute( _PERMITS_NEARBY_SQL, {"wkt": geom_wkt, "radius_m": radius_m}, ) .mappings() .all() ] if not rows: return _empty_result(radius_m) # Счётчики — по ПОЛНОЙ выборке (все rows), список items — только первые _ITEMS_LIMIT. rs_count = sum(1 for r in rows if r["doc_group"] == "RS") rv_count = sum(1 for r in rows if r["doc_group"] == "RV") items: list[dict[str, Any]] = [] for r in rows[:_ITEMS_LIMIT]: date_doc = r["date_doc"] distance_m = r["distance_m"] items.append( { "doc_group": r["doc_group"], "doc_name": r["doc_name"], "doc_num": r["doc_num"], "date_doc": date_doc.isoformat() if date_doc is not None else None, "approved_organization": r["approved_organization"], "distance_m": round(distance_m, 1) if distance_m is not None else None, } ) nearest = items[0]["distance_m"] if items else None total_count = len(rows) return { "radius_m": radius_m, "total_count": total_count, "rs_count": rs_count, "rv_count": rv_count, "nearest_distance_m": nearest, "items": items, "items_truncated": total_count > len(items), "source": "gisogd66", }