From 7b40e7e480107a45bd5f660cd1081a9f914a15f8 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 6 Jun 2026 19:50:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf):=20wire=20=D0=98=D0=A0=D0=94-=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B9=20=D0=B2=20analyze=20=D0=B7=D0=B0=20=D1=84?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=D0=BE=D0=BC=20enable=5Fird=5Fanalyze=20(#110?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D9b capstone — поле `ird` в ответе analyze за флагом enable_ird_analyze (default OFF): parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент (C8b cache-first). Логика в self-contained ird_analyze.py; parcels.py +13 строк flag-gated, defense-in-depth ×3 graceful. Schema additive (ird Optional). Замыкает ИРД-эпик #1067 (#1078/#1090/#1092/#1099/#1100/#1058/#1060). 4 теста. Refs #1067. Co-authored-by: lekss361 Co-committed-by: lekss361 --- backend/app/api/v1/parcels.py | 13 ++ backend/app/core/config.py | 7 ++ backend/app/schemas/parcel.py | 4 + .../app/services/site_finder/ird_analyze.py | 114 ++++++++++++++++++ backend/tests/services/test_ird_analyze.py | 113 +++++++++++++++++ 5 files changed, 251 insertions(+) create mode 100644 backend/app/services/site_finder/ird_analyze.py create mode 100644 backend/tests/services/test_ird_analyze.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8059f863..c380d0f6 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -62,6 +62,7 @@ from app.services.site_finder.custom_pois import ( get_overlaps_for_scoring as _get_custom_poi_overlaps, ) from app.services.site_finder.gate_verdict import compute_gate_verdict +from app.services.site_finder.ird_analyze import build_ird_analyze_block from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7 from app.services.site_finder.quarter_dump_lookup import ( get_connection_points, @@ -2957,6 +2958,18 @@ def analyze_parcel( ) result_payload["forecast"] = {"status": "unavailable", "horizon": horizon} + # ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) + + # функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off): + # источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful — + # сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse. + if settings.enable_ird_analyze: + try: + result_payload["ird"] = build_ird_analyze_block( + db, geom_wkt, centroid_lon, centroid_lat + ) + except Exception: + logger.warning("ird block failed for cad=%s — analyze response unaffected", cad_num) + return result_payload diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 76e26058..d325bbf4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -134,5 +134,12 @@ class Settings(BaseSettings): # Если не задан — endpoint вернёт 503 с инструкцией по регистрации. openrouteservice_api_key: str = "" + # ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze — + # parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) + + # ПЗЗ-регламент зоны (C8b). Default OFF: источники 2-3 — live-зависимость от внешнего + # ЕКБ-геопортала (reverse-eng, без SLA) в hot-пути analyze. Включать после прогона + # harvest на проде (B6) и заполнения zone_regulation_cache. + enable_ird_analyze: bool = False + settings = Settings() diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index ca261b18..c97d7832 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -566,6 +566,10 @@ class AnalyzeResponse(BaseModel): # — §22-форсайт stub (#995): pending|unavailable, добавляется после persist — forecast: dict[str, Any] | None = None + # — ИРД-слой (#1067 D9b, flag enable_ird_analyze): ird_overlaps (м.132, incl opportunity) + + # functional_zone/krt (геопортал WFS) + zone_regulation (C8b). Отсутствует при флаге off — + ird: dict[str, Any] | None = None + # — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют — status: str | None = None job_id: int | None = None diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py new file mode 100644 index 00000000..de3f5fac --- /dev/null +++ b/backend/app/services/site_finder/ird_analyze.py @@ -0,0 +1,114 @@ +"""Сборка ИРД-блока для 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) + КРТ (#1060). + 3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через + ``get_or_fetch_zone_regulation`` — WFS index дёшев, urbanCard только на cache-miss). + +Вызывается ТОЛЬКО за feature-flag ``settings.enable_ird_analyze`` (default off): источники 2-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.orm import Session + +from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient +from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps +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 = 8 + + +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(client: EKBGeoportalClient, lon: float, lat: float) -> list[dict[str, Any]]: + """КРТ-объекты (#1060) в точке — properties всех WFS-фич. [] при отсутствии/сбое.""" + try: + feats = client.features_at_point("krt", lon, lat) + except Exception as exc: # внешний геопортал, graceful + logger.warning("ird_analyze: krt WFS failed: %s", exc) + return [] + return [f.properties for f in feats] + + +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, + *, + 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) для geoportal-резолва функц.зоны/КРТ/регламента. + client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию + создаётся с укороченным timeout. + + Returns: + dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*), + ``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'), + ``functional_zone`` (генплан, dict|None), ``krt`` (list), ``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, + "functional_zone": None, + "krt": [], + "zone_regulation": None, + } + # Geoportal-источники — только при валидном centroid (network, опц.). + if lon is not None and lat is not None: + client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S) + block["functional_zone"] = _functional_zone_at(client, lon, lat) + block["krt"] = _krt_at(client, lon, lat) + block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat) + return block + + +__all__ = ["build_ird_analyze_block"] diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py new file mode 100644 index 00000000..cd0c7504 --- /dev/null +++ b/backend/tests/services/test_ird_analyze.py @@ -0,0 +1,113 @@ +"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring). + +DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level) +и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию +каждого источника и skip geoportal при отсутствии centroid. +""" + +from __future__ import annotations + +from typing import Any + +from app.services.site_finder import ird_analyze + + +class _Feat: + def __init__(self, properties: dict[str, Any]) -> None: + self.properties = properties + + +class _FakeClient: + def __init__(self, *, fz: list[_Feat], krt: list[_Feat], boom: bool = False) -> None: + self._fz = fz + self._krt = krt + self._boom = boom + self.calls: list[tuple[str, float, float]] = [] + + def features_at_point(self, layer: str, lon: float, lat: float) -> list[_Feat]: + self.calls.append((layer, lon, lat)) + if self._boom: + raise RuntimeError("geoportal down") + return self._fz if layer == "functional_zone" else self._krt + + +_OVERLAY = { + "ird_overlaps": [ + {"layer_kind": "zouit_okn", "reg_numb_border": "66:41-6.1"}, + {"layer_kind": "opportunity_auction", "reg_numb_border": "66:41:1:10"}, + {"layer_kind": "opportunity_krt", "reg_numb_border": None}, + ], + "ird_by_kind": {"zouit_okn": 1, "opportunity_auction": 1, "opportunity_krt": 1}, +} + + +def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None: + monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay) + + +def test_full_block_assembly(monkeypatch: Any) -> None: + """Все источники → overlaps + split opportunity + функц.зона + КРТ + регламент.""" + _patch_overlay(monkeypatch, _OVERLAY) + monkeypatch.setattr( + ird_analyze, + "get_or_fetch_zone_regulation", + lambda db, lon, lat, client=None: {"zone_index": "Ц-1"}, + ) + client = _FakeClient(fz=[_Feat({"name": "Ж-зона"})], krt=[_Feat({"num_oks_krt": "КРТ-7"})]) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) + + assert len(block["ird_overlaps"]) == 3 + assert block["ird_by_kind"]["opportunity_auction"] == 1 + assert [o["layer_kind"] for o in block["opportunity_overlaps"]] == [ + "opportunity_auction", + "opportunity_krt", + ] + assert block["functional_zone"] == {"name": "Ж-зона"} + assert block["krt"] == [{"num_oks_krt": "КРТ-7"}] + assert block["zone_regulation"] == {"zone_index": "Ц-1"} + + +def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None: + """Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели.""" + _patch_overlay(monkeypatch, _OVERLAY) + + def _boom(*a: Any, **kw: Any) -> Any: + raise RuntimeError("zone reg down") + + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom) + client = _FakeClient(fz=[], krt=[], boom=True) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) + + assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет + assert block["functional_zone"] is None + assert block["krt"] == [] + assert block["zone_regulation"] is None + + +def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None: + """lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст).""" + _patch_overlay(monkeypatch, _OVERLAY) + client = _FakeClient(fz=[_Feat({"x": 1})], krt=[]) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client) + + assert client.calls == [] + assert block["functional_zone"] is None + assert block["krt"] == [] + assert block["zone_regulation"] is None + assert len(block["ird_overlaps"]) == 3 + + +def test_empty_overlay(monkeypatch: Any) -> None: + """Пустой overlay → пустые списки, без KeyError.""" + _patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}}) + client = _FakeClient(fz=[], krt=[]) + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) + + block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client) + + assert block["ird_overlaps"] == [] + assert block["opportunity_overlaps"] == [] + assert block["ird_by_kind"] == {}