gendesign/backend/app/services/site_finder/ird_analyze.py
bot-backend 01360e9c3d
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
fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) (#2468)
2026-07-08 06:46:03 +00:00

229 lines
15 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.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"]