feat(parcels): GET /{cad_num}/connection-points — engineering points (Forgejo #115)
Some checks failed
CI / backend (pull_request) Failing after 1m0s
CI / frontend (pull_request) Successful in 2m19s

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).
This commit is contained in:
lekss361 2026-05-16 01:03:42 +03:00
parent ae0fce3528
commit 3c8bea38d0
4 changed files with 644 additions and 1 deletions

View file

@ -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: радиус поиска инженерных структур, 502000 м (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"})

View file

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

View file

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

View file

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