From 3c8bea38d0e1cb63220953947cecbdde83eef91c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 01:03:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(parcels):=20GET=20/{cad=5Fnum}/connection-?= =?UTF-8?q?points=20=E2=80=94=20engineering=20points=20(Forgejo=20#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KILLER feature от Макса — точки подключения к инженерным сетям + охранные зоны как proxy для подбора инвест-участков. Phase 1 — публичные NSPD данные (cat 36328 + 37578). - ConnectionPointsResponse pydantic + 3 nested models - get_connection_points() + helpers (parcel WKT, structures by boundary, zouit overlaps) с ST_Distance(::geography) для метров - GET endpoint с Query(radius_m, ge=50, le=2000, default=500) - 7 tests Closes part of #115 (Phase 1 backend). --- backend/app/api/v1/parcels.py | 42 ++- backend/app/schemas/parcel.py | 41 +++ .../site_finder/quarter_dump_lookup.py | 295 ++++++++++++++++++ .../api/v1/test_parcel_connection_points.py | 267 ++++++++++++++++ 4 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 backend/tests/api/v1/test_parcel_connection_points.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 63a3cfba..0cd9bfa7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -14,7 +14,12 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db -from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse +from app.schemas.parcel import ( + ConnectionPointsResponse, + ParcelDetail, + ParcelSearchRequest, + ParcelSearchResponse, +) from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, find_or_enqueue_fetch, @@ -24,6 +29,7 @@ from app.services.site_finder.cadastre_fetch import ( ) from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.quarter_dump_lookup import ( + get_connection_points, get_quarter_dump_data, make_empty_result, ) @@ -1923,6 +1929,40 @@ def analyze_parcel( } +@router.get( + "/{cad_num}/connection-points", + response_model=ConnectionPointsResponse, + summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)", +) +async def get_parcel_connection_points( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=50, le=2000)] = 500, +) -> ConnectionPointsResponse: + """Инженерные структуры (ТП, ЦТП, ЛЭП) и охранные зоны коммуникаций вблизи участка. + + Источник данных: nspd_quarter_dumps (НСПД cat 36328 / 37578). + Если дамп для квартала ещё не загружен → dump_available=false, + пустые массивы (harvest запускается автоматически). + Если участок не найден в БД (нет geom) → 404. + + Query params: + radius_m: радиус поиска инженерных структур, 50–2000 м (default 500). + """ + try: + data = get_connection_points(db, cad_num, radius_m) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + return ConnectionPointsResponse( + engineering_structures=data["engineering_structures"], + zouit_engineering_overlaps=data["zouit_engineering_overlaps"], + summary=data["summary"], + dump_available=data["dump_available"], + dump_fetched_at=data["dump_fetched_at"], + ) + + _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 20e2c30c..63f3da54 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -2,6 +2,47 @@ from typing import Any from pydantic import BaseModel, Field +# ── Connection points schemas (issue #115) ──────────────────────────────────── + + +class EngineeringStructure(BaseModel): + name: str | None + type: str | None + cad_num: str | None + distance_to_boundary_m: float + geometry_geojson: dict[str, Any] + readable_address: str | None + raw_props: dict[str, Any] + source: str + + +class ZouitOverlap(BaseModel): + reg_numb_border: str | None + type_zone: str | None + subcategory: int | None + intersects_parcel: bool + geometry_geojson: dict[str, Any] + raw_props: dict[str, Any] + source: str + + +class ConnectionPointsSummary(BaseModel): + nearest_structure_distance_m: float | None + in_protection_zone: bool + protection_zones_intersecting: int + total_structures_in_radius: int + + +class ConnectionPointsResponse(BaseModel): + engineering_structures: list[EngineeringStructure] + zouit_engineering_overlaps: list[ZouitOverlap] + summary: ConnectionPointsSummary + dump_available: bool + dump_fetched_at: str | None + + +# ── Parcel search/detail schemas ────────────────────────────────────────────── + class ParcelFilter(BaseModel): vri: list[str] | None = None diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 72bd03ab..69b731bb 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -13,6 +13,7 @@ harvest_quarter.apply_async() и продолжает без dump-derived пол from __future__ import annotations +import json import logging from datetime import UTC, datetime, timedelta from typing import Any @@ -392,6 +393,300 @@ def _get_engineering_nearby( return [] +# ── Connection-points lookup (issue #115) ──────────────────────────────────── + + +def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dict[str, Any]: + """Получить точки инженерных подключений в radius_m от boundary участка. + + Источник: `nspd_quarter_dumps.features_json` для квартала cad_num — + layers `engineering_structures` (NSPD cat 36328, ТП/ЦТП/насосные/опоры ЛЭП) + и `zouit_engineering` (NSPD cat 37578 — охранные зоны инжен.коммуникаций). + + Args: + db: SQLAlchemy session. + cad_num: кадастровый номер участка (e.g. '66:41:0204016:10'). + radius_m: радиус поиска в метрах от boundary участка (default 500). + + Returns: + { + "engineering_structures": [...], + "zouit_engineering_overlaps": [...], + "summary": {...}, + "dump_available": bool, + "dump_fetched_at": str | None, + } + + Raises: + ValueError: если parcel не найден в cad_parcels_geom / cad_quarters_geom. + """ + quarter = derive_quarter_cad(cad_num) + if quarter is None: + raise ValueError(f"Невалидный формат кадастрового номера: {cad_num!r}") + + # Получаем WKT геометрию участка (boundary, не centroid) + parcel_wkt = _get_parcel_wkt(db, cad_num) + if parcel_wkt is None: + raise ValueError(f"Участок {cad_num!r} не найден в БД") + + # Проверяем наличие дампа + dump_row = db.execute( + text( + """ + SELECT fetched_at_utc, total_features + FROM nspd_quarter_dumps + WHERE quarter_cad = :q + ORDER BY fetched_at_utc DESC + LIMIT 1 + """ + ), + {"q": quarter}, + ).first() + + if dump_row is None: + _trigger_harvest(quarter) + return { + "engineering_structures": [], + "zouit_engineering_overlaps": [], + "summary": { + "nearest_structure_distance_m": None, + "in_protection_zone": False, + "protection_zones_intersecting": 0, + "total_structures_in_radius": 0, + }, + "dump_available": False, + "dump_fetched_at": None, + } + + fetched_at = dump_row[0] + if fetched_at is not None and getattr(fetched_at, "isoformat", None): + dump_fetched_at: str | None = fetched_at.isoformat() + else: + dump_fetched_at = str(fetched_at) if fetched_at is not None else None + + structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m) + zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt) + + nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None + protection_count = len(zouit_overlaps) + + return { + "engineering_structures": structures, + "zouit_engineering_overlaps": zouit_overlaps, + "summary": { + "nearest_structure_distance_m": nearest_dist, + "in_protection_zone": protection_count > 0, + "protection_zones_intersecting": protection_count, + "total_structures_in_radius": len(structures), + }, + "dump_available": True, + "dump_fetched_at": dump_fetched_at, + } + + +def _get_parcel_wkt(db: Session, cad_num: str) -> str | None: + """Получить WKT геометрию участка из cad_parcels_geom или fallback источников. + + Игнорирует строки с geom IS NULL (data quality) — иначе ST_AsText(NULL) + вернёт SQL NULL → str(None) = "None" → ST_GeomFromText упадёт, ошибка + тихо проглотится в caller'е, клиент получит пустые массивы без причины. + """ + row = db.execute( + text( + """ + SELECT ST_AsText(geom) AS wkt + FROM cad_parcels_geom + WHERE cad_num = :c AND geom IS NOT NULL + LIMIT 1 + """ + ), + {"c": cad_num}, + ).first() + if row is not None and row[0] is not None: + return str(row[0]) + + # Fallback: кварталы (более крупный объект — менее точно, но лучше чем ничего) + row = db.execute( + text( + """ + SELECT ST_AsText(geom) AS wkt + FROM cad_quarters_geom + WHERE cad_number = :c AND geom IS NOT NULL + LIMIT 1 + """ + ), + {"c": cad_num}, + ).first() + if row is not None and row[0] is not None: + return str(row[0]) + + return None + + +def _get_engineering_structures_by_boundary( + db: Session, + quarter: str, + parcel_wkt: str, + radius_m: int, +) -> list[dict[str, Any]]: + """Engineering structures из dump в radius_m от boundary участка. + + Использует ST_Distance к boundary (не centroid) для корректного расстояния. + Geometry трансформируется из EPSG:3857 (хранение dump) → 4326. + """ + try: + rows = db.execute( + text( + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'engineering_structures' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 50 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}, + ).fetchall() + except Exception as e: + logger.warning( + "engineering_structures query failed for quarter=%s: %s", + quarter, + e, + ) + return [] + + result: list[dict[str, Any]] = [] + for r in rows: + props: dict[str, Any] = r[0] if isinstance(r[0], dict) else {} + geom_raw: str | None = r[1] + distance_m = float(r[2]) if r[2] is not None else 0.0 + + # Попытка распарсить geometry как dict для GeoJSON поля + geom_dict: dict[str, Any] = {} + if geom_raw: + try: + geom_dict = json.loads(geom_raw) + except Exception: + geom_dict = {} + + result.append( + { + "name": props.get("name") or props.get("object_name"), + "type": props.get("purpose") or props.get("object_type") or props.get("type_zone"), + "cad_num": props.get("cad_num") or props.get("cadastral_number"), + "distance_to_boundary_m": round(distance_m, 1), + "geometry_geojson": geom_dict, + "readable_address": props.get("readable_address") or props.get("address"), + "raw_props": props, + "source": "nspd_36328", + } + ) + return result + + +def _get_zouit_engineering_overlaps( + db: Session, + quarter: str, + parcel_wkt: str, +) -> list[dict[str, Any]]: + """ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок. + + Использует слой 'zouit_engineering' из dump. + """ + try: + rows = db.execute( + text( + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'zouit_engineering' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() + except Exception as e: + logger.warning( + "zouit_engineering query failed for quarter=%s: %s", + quarter, + e, + ) + return [] + + result: list[dict[str, Any]] = [] + for r in rows: + props: dict[str, Any] = r[0] if isinstance(r[0], dict) else {} + geom_raw: str | None = r[1] + + geom_dict: dict[str, Any] = {} + if geom_raw: + try: + geom_dict = json.loads(geom_raw) + except Exception: + geom_dict = {} + + subcategory_raw = props.get("subcategory") + subcategory: int | None = None + if subcategory_raw is not None: + try: + subcategory = int(subcategory_raw) + except (ValueError, TypeError): + subcategory = None + + result.append( + { + "reg_numb_border": props.get("reg_numb_border"), + "type_zone": props.get("type_zone") or props.get("zone_name"), + "subcategory": subcategory, + "intersects_parcel": True, + "geometry_geojson": geom_dict, + "raw_props": props, + "source": "nspd_37578", + } + ) + return result + + # ── Harvest trigger ─────────────────────────────────────────────────────────── diff --git a/backend/tests/api/v1/test_parcel_connection_points.py b/backend/tests/api/v1/test_parcel_connection_points.py new file mode 100644 index 00000000..a26fa16d --- /dev/null +++ b/backend/tests/api/v1/test_parcel_connection_points.py @@ -0,0 +1,267 @@ +"""Тесты для GET /{cad_num}/connection-points (issue #115). + +Использует FastAPI TestClient с mock DB — без реального Postgres. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_VALID_CAD = "66:41:0204016:10" +_QUARTER = "66:41:0204016" + + +def _mock_get_db(db: Any): + """FastAPI dependency override factory.""" + + def _get_db_override(): + yield db + + return _get_db_override + + +def _make_structure(distance_m: float = 120.5) -> dict[str, Any]: + return { + "name": "ТП-101", + "type": "Трансформаторная подстанция", + "cad_num": None, + "distance_to_boundary_m": distance_m, + "geometry_geojson": {"type": "Point", "coordinates": [60.6, 56.8]}, + "readable_address": None, + "raw_props": {"name": "ТП-101"}, + "source": "nspd_36328", + } + + +def _make_zouit_overlap() -> dict[str, Any]: + return { + "reg_numb_border": "RN-001", + "type_zone": "Охранная зона ЛЭП", + "subcategory": 5, + "intersects_parcel": True, + "geometry_geojson": {"type": "Polygon", "coordinates": [[]]}, + "raw_props": {"type_zone": "Охранная зона ЛЭП"}, + "source": "nspd_37578", + } + + +def _make_summary( + nearest: float | None = 120.5, + in_zone: bool = False, + zones_count: int = 0, + total: int = 1, +) -> dict[str, Any]: + return { + "nearest_structure_distance_m": nearest, + "in_protection_zone": in_zone, + "protection_zones_intersecting": zones_count, + "total_structures_in_radius": total, + } + + +def _make_full_response( + structures: list[dict[str, Any]] | None = None, + overlaps: list[dict[str, Any]] | None = None, + summary: dict[str, Any] | None = None, + dump_available: bool = True, + dump_fetched_at: str | None = "2026-05-01T12:00:00+00:00", +) -> dict[str, Any]: + if structures is None: + structures = [_make_structure()] + if overlaps is None: + overlaps = [] + if summary is None: + summary = _make_summary(total=len(structures)) + return { + "engineering_structures": structures, + "zouit_engineering_overlaps": overlaps, + "summary": summary, + "dump_available": dump_available, + "dump_fetched_at": dump_fetched_at, + } + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_connection_points_no_dump_returns_empty() -> None: + """Квартал без dump → dump_available=false, пустые массивы, 200 OK.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + empty_response = { + "engineering_structures": [], + "zouit_engineering_overlaps": [], + "summary": { + "nearest_structure_distance_m": None, + "in_protection_zone": False, + "protection_zones_intersecting": 0, + "total_structures_in_radius": 0, + }, + "dump_available": False, + "dump_fetched_at": None, + } + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=empty_response, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200, response.text + body = response.json() + assert body["dump_available"] is False + assert body["engineering_structures"] == [] + assert body["zouit_engineering_overlaps"] == [] + assert body["summary"]["total_structures_in_radius"] == 0 + assert body["summary"]["nearest_structure_distance_m"] is None + finally: + app.dependency_overrides.clear() + + +def test_connection_points_parcel_not_found_404() -> None: + """cad_num не найден в БД (ValueError из сервиса) → 404.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"), + ): + client = TestClient(app) + response = client.get("/api/v1/parcels/66:41:9999999:1/connection-points") + + assert response.status_code == 404 + assert "не найден" in response.json()["detail"] + finally: + app.dependency_overrides.clear() + + +def test_connection_points_filters_by_radius() -> None: + """Фичи за radius_m не попадают в ответ (сервис фильтрует). + + Мокаем сервис: при radius_m=100 возвращаем только близкую структуру, + при radius_m=500 — обе. Проверяем что endpoint передаёт radius_m в сервис. + """ + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + close_only = _make_full_response(structures=[_make_structure(distance_m=80.0)]) + far_included = _make_full_response( + structures=[_make_structure(distance_m=80.0), _make_structure(distance_m=450.0)], + summary=_make_summary(nearest=80.0, total=2), + ) + + try: + with patch("app.api.v1.parcels.get_connection_points") as mock_svc: + mock_svc.return_value = close_only + client = TestClient(app) + r100 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=100") + assert r100.status_code == 200 + assert r100.json()["summary"]["total_structures_in_radius"] == 1 + # Проверяем что radius_m=100 передан в сервис + call_kwargs = mock_svc.call_args + assert call_kwargs[0][2] == 100 or call_kwargs[1].get("radius_m") == 100 + + mock_svc.return_value = far_included + r500 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=500") + assert r500.status_code == 200 + assert r500.json()["summary"]["total_structures_in_radius"] == 2 + finally: + app.dependency_overrides.clear() + + +def test_connection_points_zouit_intersects_flag() -> None: + """zouit_engineering_overlaps содержат intersects_parcel=true и правильные поля.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + overlap = _make_zouit_overlap() + full = _make_full_response( + overlaps=[overlap], + summary=_make_summary(in_zone=True, zones_count=1), + ) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=full, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200 + body = response.json() + assert body["summary"]["in_protection_zone"] is True + assert body["summary"]["protection_zones_intersecting"] == 1 + overlaps = body["zouit_engineering_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["intersects_parcel"] is True + assert overlaps[0]["reg_numb_border"] == "RN-001" + assert overlaps[0]["source"] == "nspd_37578" + finally: + app.dependency_overrides.clear() + + +def test_summary_nearest_distance() -> None: + """summary.nearest_structure_distance_m = расстояние до ближайшей структуры.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + s1 = _make_structure(distance_m=42.7) + s2 = _make_structure(distance_m=180.0) + full = _make_full_response( + structures=[s1, s2], + summary=_make_summary(nearest=42.7, total=2), + ) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=full, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200 + body = response.json() + assert body["summary"]["nearest_structure_distance_m"] == pytest.approx(42.7) + assert body["summary"]["total_structures_in_radius"] == 2 + assert body["engineering_structures"][0]["distance_to_boundary_m"] == pytest.approx(42.7) + finally: + app.dependency_overrides.clear() + + +def test_connection_points_radius_out_of_range_422() -> None: + """radius_m=10 (< min 50) → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=10") + assert response.status_code == 422 + + +def test_connection_points_radius_too_large_422() -> None: + """radius_m=5000 (> max 2000) → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=5000") + assert response.status_code == 422