feat(sf): wire ППТ/ПМТ planning_projects в analyze ird-блок (#1085)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 5m48s
CI / backend-tests (pull_request) Successful in 5m46s

This commit is contained in:
bot-backend 2026-06-06 23:44:27 +03:00
parent 9e4348629b
commit ffab64aa6a
4 changed files with 122 additions and 0 deletions

View file

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

View 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"]

View file

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

View 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]