diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index de3f5fac..ca685a2a 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -27,6 +27,7 @@ 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.planning_lookup import parcel_planning_overlaps from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation logger = logging.getLogger(__name__) @@ -88,6 +89,7 @@ def build_ird_analyze_block( Returns: dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*), ``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'), + ``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085), ``functional_zone`` (генплан, dict|None), ``krt`` (list), ``zone_regulation`` (dict|None). Любой источник деградирует независимо — блок всегда well-formed. """ @@ -98,6 +100,7 @@ def build_ird_analyze_block( "ird_overlaps": overlaps, "ird_by_kind": overlay.get("ird_by_kind", {}), "opportunity_overlaps": opportunity, + "planning_projects": parcel_planning_overlaps(db, geom_wkt), "functional_zone": None, "krt": [], "zone_regulation": None, diff --git a/backend/app/services/site_finder/planning_lookup.py b/backend/app/services/site_finder/planning_lookup.py new file mode 100644 index 00000000..26141f8d --- /dev/null +++ b/backend/app/services/site_finder/planning_lookup.py @@ -0,0 +1,56 @@ +"""ППТ/ПМТ planning-overlay lookup для analyze_parcel (#1085 analyze-wiring). + +Читает ``planning_projects`` (м.134, наполняется planning_harvest #1085) и отдаёт проекты +планировки/межевания, пересекающие участок: что/где утверждено к застройке (future-supply). +Комплементарно ИРД-overlay (``ird_overlay_lookup``): тот — ограничения участка, этот — граддоки. + +Graceful: если таблица ``planning_projects`` ещё не задеплоена (pre-migration) или БД-ошибка — +возвращает пустой список (analyze не падает). Wiring в analyze — за тем же флагом, что ИРД (D9b). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# ППТ/ПМТ overlap по участку: ST_Intersects через GIST idx_planning_projects_geom. geom уже 4326 +# (harvest стор WFS geoloc как 4326). Участок — WKT в 4326. CAST psycopg v3. +_PLANNING_OVERLAP_SQL = text( + """ + SELECT project_type, doc_status_name, full_name, project_name, dmd_actual_year + FROM planning_projects + WHERE ST_Intersects(geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)) + ORDER BY project_type, dmd_actual_year DESC NULLS LAST + """ +) + + +def parcel_planning_overlaps(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]: + """ППТ/ПМТ, пересекающие участок. + + Args: + db: сессия. + parcel_wkt: геометрия участка в WKT (EPSG:4326). None → пустой список. + + Returns: + list[{project_type, doc_status_name, full_name, project_name, dmd_actual_year}]. + Пусто при отсутствии участка/таблицы/пересечений (analyze не падает). + """ + if not parcel_wkt: + return [] + try: + rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + except (OperationalError, ProgrammingError) as exc: + # Таблица ещё не задеплоена / БД-ошибка — graceful degrade. + logger.warning("parcel_planning_overlaps: planning_projects недоступна, skip: %s", exc) + return [] + return [dict(r) for r in rows] + + +__all__ = ["parcel_planning_overlaps"] diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index cd0c7504..49f572cf 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -41,8 +41,12 @@ _OVERLAY = { } +_PLANNING = [{"project_type": "ppt", "doc_status_name": "действующий", "full_name": "ПАГЕ №1"}] + + def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None: monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay) + monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING)) def test_full_block_assembly(monkeypatch: Any) -> None: @@ -66,6 +70,7 @@ def test_full_block_assembly(monkeypatch: Any) -> None: assert block["functional_zone"] == {"name": "Ж-зона"} assert block["krt"] == [{"num_oks_krt": "КРТ-7"}] assert block["zone_regulation"] == {"zone_index": "Ц-1"} + assert block["planning_projects"] == _PLANNING def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None: diff --git a/backend/tests/services/test_planning_lookup.py b/backend/tests/services/test_planning_lookup.py new file mode 100644 index 00000000..3207f5d6 --- /dev/null +++ b/backend/tests/services/test_planning_lookup.py @@ -0,0 +1,58 @@ +"""Тесты parcel_planning_overlaps (#1085 analyze-wiring) — ППТ/ПМТ overlap по участку. + +БД не дёргается: фейковая сессия с .execute().mappings().all(). Проверяем graceful на +отсутствие участка/таблицы и проброс строк. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.exc import ProgrammingError + +from app.services.site_finder.planning_lookup import parcel_planning_overlaps + + +class _Result: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict[str, Any]]: + return self._rows + + +class _DB: + def __init__(self, rows: list[dict[str, Any]] | Exception) -> None: + self._rows = rows + + def execute(self, sql: Any, params: dict[str, Any]) -> _Result: + if isinstance(self._rows, Exception): + raise self._rows + return _Result(self._rows) + + +def test_returns_overlaps() -> None: + rows = [ + { + "project_type": "ppt", + "doc_status_name": "действующий", + "full_name": "ПАГЕ №1", + "project_name": "Галактика", + "dmd_actual_year": 2021, + }, + ] + out = parcel_planning_overlaps(_DB(rows), "POINT(60 56)") # type: ignore[arg-type] + assert out == rows + + +def test_empty_wkt_returns_empty() -> None: + assert parcel_planning_overlaps(_DB([]), None) == [] # type: ignore[arg-type] + + +def test_missing_table_is_graceful() -> None: + """planning_projects ещё не задеплоена → ProgrammingError → [] (analyze не падает).""" + db = _DB(ProgrammingError("stmt", {}, Exception("relation planning_projects does not exist"))) + assert parcel_planning_overlaps(db, "POINT(60 56)") == [] # type: ignore[arg-type]