feat(sf): вшить функц.зоны генплана (ekb_genplan_functional_zone) в analyze (#1058)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m25s

This commit is contained in:
bot-backend 2026-06-07 15:22:46 +03:00
parent 86b9a5a197
commit c15f3524bf
2 changed files with 64 additions and 11 deletions

View file

@ -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)

View file

@ -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