feat(sf): wire ППТ/ПМТ planning_projects в analyze ird-блок (#1105)
planning_lookup.py (parcel_planning_overlaps, ST_Intersects через GIST idx_planning_projects_geom) + ird_analyze.py ключ planning_projects (DB-only). ППТ/ПМТ overlap участка в analyze ird-блоке. За флагом enable_ird_analyze (OFF), parcels.py не тронут. Зеркалит ird_overlay_lookup. Зависит от #1104 (м.134). 7 тестов. Refs #1085. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
9e4348629b
commit
53e76738b4
4 changed files with 122 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
56
backend/app/services/site_finder/planning_lookup.py
Normal file
56
backend/app/services/site_finder/planning_lookup.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
58
backend/tests/services/test_planning_lookup.py
Normal file
58
backend/tests/services/test_planning_lookup.py
Normal file
|
|
@ -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]
|
||||
Loading…
Add table
Reference in a new issue