gendesign/backend/app/services/site_finder/ird_analyze.py
Light1YT 18942898d1
All checks were successful
CI / backend-tests (pull_request) Successful in 11m8s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m46s
fix(site-finder): memoize zone_index WFS lookup + dedupe resolve in /analyze (#1850)
- memoize coord→zone_index (bounded thread-safe LRU, ±1m key, negative caching) so
  repeat /analyze skips the live WFS round-trip; regulation content still read fresh
  from zone_regulation_cache (cache-update correctness preserved). Lock guards the
  module-global memo (sync /analyze runs in threadpool); WFS call stays outside lock.
- resolve once, thread into build_ird_analyze_block (resolved_zone_regulation kwarg)
  → no redundant 2nd resolve when both enable_zoning_regulation_in_analyze and
  enable_ird_analyze are on.
- unify divergent timeouts (parcels 3s / ird 4s) into settings.geoportal_timeout_s=4.
- document (not change) the mid-request db.commit() smell on cache-miss (separate task).

Closes #1850
2026-06-25 14:00:13 +05:00

227 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:
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,
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"]