Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
229 lines
15 KiB
Python
229 lines
15 KiB
Python
"""Сборка ИРД-блока для 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.core.config import settings
|
||
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.ppt_tep_lookup import parcel_ppt_tep
|
||
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__)
|
||
|
||
# Sentinel: «регламент НЕ передан извне» — отличает «caller не резолвил» от «резолв дал None»
|
||
# (None — валидный результат: в точке нет терзоны). См. resolved_zone_regulation в
|
||
# build_ird_analyze_block (#1850 Fix 2).
|
||
_UNSET: Any = object()
|
||
|
||
# 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:
|
||
with db.begin_nested():
|
||
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
|
||
except Exception as exc: # таблица не существует / DB-сбой → graceful
|
||
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
|
||
return []
|
||
|
||
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
|
||
]
|
||
|
||
|
||
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,
|
||
resolved_zone_regulation: Any = _UNSET,
|
||
) -> 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.
|
||
resolved_zone_regulation: УЖЕ резолвленный ПЗЗ-регламент (dict|None) из parcels.py
|
||
zoning-merge блока (#1850 Fix 2). Если передан (включая None — «зоны нет») —
|
||
``_zone_regulation_at`` НЕ вызывается, используется переданное значение (исключает
|
||
повторный DB-read кэша на тот же centroid). Если НЕ передан (``_UNSET``, дефолт) —
|
||
блок резолвит сам (путь «enable_zoning_regulation_in_analyze off, enable_ird_analyze
|
||
on»). Network-стоимость второго резолва уже снята мемоизацией coord→zone_index
|
||
(Fix 1); этот параметр убирает оставшийся лишний DB-read.
|
||
|
||
Returns:
|
||
dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*),
|
||
``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'),
|
||
``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085),
|
||
``ppt_tep`` (list, ТЭП-контент ППТ/ПМТ из ekb_ppt_tep #1085/#1136: zone_balance / tep /
|
||
phasing для участков в границах ППТ/ПМТ; связка doc_ref↔source_key/doc_full_name,
|
||
graceful, пусто если ingest ещё не запускался или матча нет),
|
||
``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),
|
||
# ТЭП-контент ППТ/ПМТ (#1136 follow-up #1133, GG-форсайт #1067 / #1085):
|
||
# zone_balance / tep / phasing из ekb_ppt_tep, привязанные к planning_projects по
|
||
# doc_ref↔source_key/doc_full_name. DB JOIN, дёшево (GIST), graceful — пусто если
|
||
# таблица ekb_ppt_tep ещё пуста (нет ingest) или матча doc_ref нет. Идёт рядом с
|
||
# planning_projects/krt_requisites — DB-источники основного потока.
|
||
"ppt_tep": parcel_ppt_tep(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
|
||
|
||
# #1850 Fix 2: если регламент УЖЕ резолвлен в parcels.py (zoning-merge блок), берём его
|
||
# без повторного резолва — исключает лишний DB-read кэша на тот же centroid. None — тоже
|
||
# валидное переданное значение («зоны нет»), поэтому отличаем «передан» через _UNSET.
|
||
if resolved_zone_regulation is not _UNSET:
|
||
block["zone_regulation"] = resolved_zone_regulation
|
||
|
||
# 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-пути.
|
||
# Резолвим САМИ только если caller не передал готовый регламент (Fix 2).
|
||
# Выполняем в основном потоке (Session не потокобезопасна).
|
||
if resolved_zone_regulation is _UNSET:
|
||
client = client or EKBGeoportalClient(timeout=settings.geoportal_timeout_s)
|
||
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
|
||
|
||
return block
|
||
|
||
|
||
__all__ = ["build_ird_analyze_block"]
|