feat(parcels): connection-points endpoint (Forgejo #115 Phase 1 backend) (#190)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Failing after 0s
Deploy / build-worker (push) Successful in 5m11s
Deploy / deploy (push) Failing after 0s
Deploy / build-backend (push) Successful in 3m44s

This commit is contained in:
lekss361 2026-05-16 05:58:27 +00:00
parent e97f96afa7
commit 4deb1b0284
4 changed files with 643 additions and 0 deletions

View file

@ -17,6 +17,7 @@ from app.core.db import get_db
from app.schemas.parcel import (
CompetitorsRequest,
CompetitorsResponse,
ConnectionPointsResponse,
ParcelDetail,
ParcelSearchRequest,
ParcelSearchResponse,
@ -31,6 +32,7 @@ from app.services.site_finder.cadastre_fetch import (
from app.services.site_finder.competitors import get_competitors
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,
)
@ -1930,6 +1932,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, Literal
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,11 +13,13 @@ harvest_quarter.apply_async() и продолжает без dump-derived пол
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@ -392,6 +394,303 @@ 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: кварталы (более крупный объект — менее точно, но лучше чем ничего).
# cad_quarters_geom хранит 3-сегментные ключи (66:41:0204016), не 4-сегментные.
quarter_key = derive_quarter_cad(cad_num)
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": quarter_key},
).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 (OperationalError, ProgrammingError) 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 (ValueError, json.JSONDecodeError):
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)
)
LIMIT 100
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
except (OperationalError, ProgrammingError) 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 (ValueError, json.JSONDecodeError):
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