"""Сборка ИРД-блока для analyze_parcel (#1067 D9b — analyze-wiring). Единая точка, собирающая «слой ИРД/future-supply» участка для ответа analyze (GG-форсайт). Объединяет ТРИ источника, каждый — graceful (сбой одного не валит блок и тем более analyze): 1. ``parcel_ird_overlaps`` (DB, м.132 ird_overlays) — ограничения/обременения + opportunity-ЗУ, пересекающие участок (ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ + opportunity_* из #1086). Дёшево (GIST ST_Intersects), всегда безопасно. 2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058). 3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через ``get_or_fetch_zone_regulation`` — WFS index дёшев, urbanCard только на cache-miss). КРТ (#1060) перенесён из live WFS (≈4s) в DB (#1130): ``_krt_at`` читает ST_Intersects к ekb_krt_geometry — дёшево и без сетевой задержки. Harvest обновляет таблицу раз в неделю (krt_geometry_sync Celery-воркер). Пустая/отсутствующая таблица graceful → []. Вызывается ТОЛЬКО за feature-flag ``settings.enable_ird_analyze`` (default off): источник 3 — live-зависимость от внешнего геопортала (reverse-eng, без SLA) в hot-пути analyze. Флаг off → parcels.py даже не зовёт этот модуль. Сетевой timeout укорочен (geoportal_timeout) чтобы не держать sync-handler. Логика вынесена из parcels.py (HOT-файл) сюда — в parcels.py остаётся 1 import + gated-блок. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient from app.services.site_finder.functional_zone_lookup import parcel_functional_zones from app.services.site_finder.granddoc_lookup import parcel_granddoc from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps from app.services.site_finder.krt_lookup import parcel_krt_requisites from app.services.site_finder.okn_lookup import parcel_okn_objects from app.services.site_finder.pat_lookup import parcel_pat_subzones from app.services.site_finder.planning_lookup import parcel_planning_overlaps from app.services.site_finder.reservation_lookup import parcel_reservations from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation logger = logging.getLogger(__name__) # Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго). _GEOPORTAL_TIMEOUT_S = 4 # SQL: читаем КРТ-геометрии из БД (м.140 ekb_krt_geometry) по точке centroid участка. # ST_Intersects с GIST → дёшево (~1ms), без сетевой задержки. # Возвращает те же поля что ранее WFS: num_oks_krt, status, full_name, area. # «type» в WFS-свойствах слоя krt не задокументирован отдельным полем; status покрывает его. _KRT_AT_SQL = text(""" SELECT num_oks_krt, status, full_name, area FROM ekb_krt_geometry WHERE ST_Intersects(geom, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)) """) def _functional_zone_at( client: EKBGeoportalClient, lon: float, lat: float ) -> dict[str, Any] | None: """Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто.""" try: feats = client.features_at_point("functional_zone", lon, lat) except Exception as exc: # внешний геопортал, graceful logger.warning("ird_analyze: functional_zone WFS failed: %s", exc) return None return feats[0].properties if feats else None def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]: """КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry. Возвращает список properties-dicts в той же форме, что ранее WFS: num_oks_krt / status / full_name / area. [] при отсутствии пересечений или если таблица пуста/не существует. """ try: rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall() return [ { "num_oks_krt": row.num_oks_krt, "status": row.status, "full_name": row.full_name, "area": float(row.area) if row.area is not None else None, } for row in rows ] except Exception as exc: # таблица не существует / DB-сбой → graceful logger.warning("ird_analyze: _krt_at DB query failed: %s", exc) return [] def _zone_regulation_at( db: Session, client: EKBGeoportalClient, lon: float, lat: float ) -> dict[str, Any] | None: """ПЗЗ-регламент зоны (#1067 C8b) в точке — cache-first. None при отсутствии/сбое.""" try: return get_or_fetch_zone_regulation(db, lon, lat, client=client) except Exception as exc: # внешний геопортал/БД, graceful logger.warning("ird_analyze: zone_regulation failed: %s", exc) return None def build_ird_analyze_block( db: Session, geom_wkt: str | None, lon: float | None, lat: float | None, cad_num: str | None = None, *, client: EKBGeoportalClient | None = None, ) -> dict[str, Any]: """Собрать ИРД-блок участка для analyze (#1067 D9b). Args: db: сессия. geom_wkt: геометрия участка WKT (EPSG:4326) для DB-overlay ``ird_overlays``. lon/lat: centroid участка (EPSG:4326) для DB КРТ-lookup + geoportal zone_regulation. cad_num: кадастровый номер ЗУ для lookup изъятия/резервирования (м.136 land_reservation). None → reservations=[]. client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию создаётся с укороченным timeout. Returns: dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*), ``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'), ``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085), ``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062), ``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060), ``functional_zones`` (list, генплан-зоны из ekb_genplan_functional_zone, #1058), ``functional_zone`` (первая зона|None, back-compat; dict|None), ``krt`` (list, КРТ-геометрии из DB ekb_krt_geometry, #1130), ``zone_regulation`` (dict|None), ``okn_objects`` (list, ОКН в радиусе 100 м от участка из okn_objects, #1066; [{source_id, category, distance_m}], пусто при отсутствии объектов/таблицы). ``pat_subzones`` (list, приаэродромные подзоны ПАТ из pat_subzones, #1151; [{subzone_no, name, restriction, aerodrome}], пусто при отсутствии пересечений/таблицы). ``granddoc`` — разработанная граддокументация (ППТ/ПМТ) на участок: статус + реквизиты ПАГЕ (#1061); [{project_type, doc_status_name, is_active, paga_number, paga_date, subject, doc_full_name}]. Правообладатель/срок по КРТ-территориям отдаётся отдельно в ``krt_requisites`` (#1131). Любой источник деградирует независимо — блок всегда well-formed. """ overlay = parcel_ird_overlaps(db, geom_wkt) overlaps: list[dict[str, Any]] = overlay.get("ird_overlaps", []) opportunity = [o for o in overlaps if str(o.get("layer_kind", "")).startswith("opportunity_")] block: dict[str, Any] = { "ird_overlaps": overlaps, "ird_by_kind": overlay.get("ird_by_kind", {}), "opportunity_overlaps": opportunity, "planning_projects": parcel_planning_overlaps(db, geom_wkt), "reservations": parcel_reservations(db, cad_num), # Граддокументация (#1061): status «разработана» + реквизиты ПАГЕ из planning_projects. # DB, дёшево (ST_Intersects GIST), graceful. Вместе с planning_projects — один источник. # Правообладатель/срок КРТ-территорий → krt_requisites (#1131), не сюда. "granddoc": parcel_granddoc(db, geom_wkt), # ОКН-объекты (#1066): ST_DWithin к okn_objects (428 строк, GIST), дёшево, graceful. # Идёт рядом с reservations — DB-источники основного потока, без сетевых зависимостей. "okn_objects": parcel_okn_objects(db, geom_wkt), # ПАТ-подзоны (#1151/#1065): приаэродромные ограничения подзон 1-6 из pat_subzones. # DB ST_Intersects (GIST), дёшево, graceful. Рядом с okn_objects — DB-источник основного # потока. "pat_subzones": parcel_pat_subzones(db, geom_wkt), # КРТ-реквизиты (DB, дёшево, graceful): planning_projects ⋈ ekb_krt_sites (#1060). # Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части. "krt_requisites": parcel_krt_requisites(db, geom_wkt), # Функц.зоны генплана (#1058): DB-вызов ekb_genplan_functional_zone, дёшево, graceful. # functional_zones — полный список; functional_zone — первая зона|None (back-compat). "functional_zones": [], "functional_zone": None, # КРТ-геометрии (#1130): теперь из DB ekb_krt_geometry, не из живого WFS. # Заполняется ниже через _krt_at(db, lon, lat) — дёшевый DB-вызов. "krt": [], "zone_regulation": None, } # Функц.зоны генплана (#1058): DB ekb_genplan_functional_zone, дёшево, в основном потоке. fz = parcel_functional_zones(db, geom_wkt) block["functional_zones"] = fz block["functional_zone"] = fz[0] if fz else None # guard активен только в unit-тестах; в prod centroid_lon/lat всегда float # (EKB-fallback в parcels.py). if lon is not None and lat is not None: # КРТ из БД — дёшево, выполняем до geoportal-вызова (zone_regulation). # Нет нужды в ThreadPool: krt теперь DB-вызов, не сеть. block["krt"] = _krt_at(db, lon, lat) # zone_regulation остаётся единственным живым geoportal-вызовом в hot-пути. # Выполняем в основном потоке (Session не потокобезопасна). client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S) block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat) return block __all__ = ["build_ird_analyze_block"]