gendesign/backend/app/services/site_finder/ird_analyze.py
bot-backend c15f3524bf
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m25s
feat(sf): вшить функц.зоны генплана (ekb_genplan_functional_zone) в analyze (#1058)
2026-06-07 15:22:46 +03:00

176 lines
9.8 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.

"""Сборка ИРД-блока для 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.ird_overlay_lookup import parcel_ird_overlaps
from app.services.site_finder.krt_lookup import parcel_krt_requisites
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).
Любой источник деградирует независимо — блок всегда 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),
# КРТ-реквизиты (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"]