From dc63173aba893b6ebace4579885355edf0caf63e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 7 Jun 2026 12:27:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf):=20=D0=B2=D1=88=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86.=D0=B7=D0=BE=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20(ekb=5Fgenpl?= =?UTF-8?q?an=5Ffunctional=5Fzone)=20=D0=B2=20analyze=20(#1140)=20Co-autho?= =?UTF-8?q?red-by:=20lekss361=20=20Co-committed-by?= =?UTF-8?q?:=20lekss361=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/site_finder/ird_analyze.py | 16 +++-- backend/tests/services/test_ird_analyze.py | 59 +++++++++++++++++-- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index bd28da8e..fdba8c23 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -31,6 +31,7 @@ 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 @@ -125,7 +126,8 @@ def build_ird_analyze_block( ``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085), ``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062), ``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060), - ``functional_zone`` (генплан, dict|None), + ``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. @@ -142,12 +144,20 @@ def build_ird_analyze_block( # КРТ-реквизиты (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: @@ -155,10 +165,6 @@ def build_ird_analyze_block( # Нет нужды в ThreadPool: krt теперь DB-вызов, не сеть. block["krt"] = _krt_at(db, lon, lat) - # Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058). - # Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest. - block["functional_zone"] = None - # zone_regulation остаётся единственным живым geoportal-вызовом в hot-пути. # Выполняем в основном потоке (Session не потокобезопасна). client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S) diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index 0f26d8ff..2acf71aa 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -1,11 +1,12 @@ -"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1130 krt-from-db). +"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1130 krt-from-db, #1058 fz-wiring). DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation + _krt_at -(module-level) и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, -graceful-деградацию каждого источника, skip geoportal при отсутствии centroid. ++ parcel_functional_zones (module-level) и передаём fake EKBGeoportalClient. +Проверяем сборку блока, split opportunity_*, graceful-деградацию каждого источника, +skip geoportal при отсутствии centroid. #1130: krt теперь из DB (ekb_krt_geometry) — клиент WFS больше НЕ вызывается для слоя «krt». -_krt_at(db, lon, lat) мокается на уровне модуля. +#1058: functional_zones из DB ekb_genplan_functional_zone; functional_zone — первая зона|None. """ from __future__ import annotations @@ -66,11 +67,13 @@ def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None: monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING)) monkeypatch.setattr(ird_analyze, "parcel_reservations", lambda db, cad_num: []) monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: list(_KRT_REQUISITES)) + # По умолчанию: пустой список — functional_zone=None (back-compat). + monkeypatch.setattr(ird_analyze, "parcel_functional_zones", lambda db, wkt: []) def test_full_block_assembly(monkeypatch: Any) -> None: """Все источники → overlaps + split opportunity + КРТ (из DB) + регламент. - functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06). + functional_zones из DB (пустой мок → functional_zone=None). krt из DB (ekb_krt_geometry), WFS больше не вызывается для слоя «krt». """ _patch_overlay(monkeypatch, _OVERLAY) @@ -91,7 +94,8 @@ def test_full_block_assembly(monkeypatch: Any) -> None: "opportunity_auction", "opportunity_krt", ] - # functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None + # parcel_functional_zones мокирован на [] → functional_zones=[], functional_zone=None + assert block["functional_zones"] == [] assert block["functional_zone"] is None # krt из DB, не из WFS — client.calls не содержит «krt» assert "krt" not in [layer for layer, _, _ in client.calls] @@ -320,3 +324,46 @@ def test_krt_requisites_empty_when_no_overlap(monkeypatch: Any) -> None: assert "krt_requisites" in block assert block["krt_requisites"] == [] + + +# --------------------------------------------------------------------------- +# Функциональные зоны генплана (#1058) +# --------------------------------------------------------------------------- + +_FZ_ROWS: list[dict[str, Any]] = [ + {"zone_type": "Ж", "status": "утверждён", "area_ha": 2.5, "zone_class_code": "2.1"}, + {"zone_type": "О", "status": "утверждён", "area_ha": 0.8, "zone_class_code": "3.0"}, +] + + +def test_functional_zones_two_zones(monkeypatch: Any) -> None: + """parcel_functional_zones → 2 зоны: functional_zones == список, functional_zone == первая.""" + _patch_overlay(monkeypatch, _OVERLAY) + monkeypatch.setattr(ird_analyze, "parcel_functional_zones", lambda db, wkt: list(_FZ_ROWS)) + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) + monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: []) + client = _FakeClient(fz=[]) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) + + assert block["functional_zones"] == _FZ_ROWS + assert len(block["functional_zones"]) == 2 + assert block["functional_zone"] == _FZ_ROWS[0] + assert block["functional_zone"]["zone_type"] == "Ж" + + +def test_functional_zones_empty(monkeypatch: Any) -> None: + """parcel_functional_zones пуст → functional_zones==[], functional_zone==None.""" + _patch_overlay(monkeypatch, _OVERLAY) + # _patch_overlay уже мокает parcel_functional_zones → [], переопределяем явно для ясности + monkeypatch.setattr(ird_analyze, "parcel_functional_zones", lambda db, wkt: []) + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) + monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: []) + client = _FakeClient(fz=[]) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) + + assert block["functional_zones"] == [] + assert block["functional_zone"] is None + assert "functional_zones" in block # ключ всегда присутствует + assert "functional_zone" in block