feat(parcels): GET /{cad_num}/connection-points — engineering points (Forgejo #115)
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:
parent
ae0fce3528
commit
3c8bea38d0
4 changed files with 644 additions and 1 deletions
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
267
backend/tests/api/v1/test_parcel_connection_points.py
Normal file
267
backend/tests/api/v1/test_parcel_connection_points.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue