diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index fe848ea8..7b7489d5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3951,6 +3951,154 @@ def get_parcel_connection_capacity( ) +# ── neighbor-buildings (Forgejo #2180) ────────────────────────────────────── +# Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены «Размещение +# застройки» (§7 отчёта ПТИЦА). Отдаёт GeoJSON FeatureCollection упрощённых +# полигонов cad_buildings вокруг участка. Кэш не нужен — один GIST-запрос. +# +# Модуль-level `text(...)` (а не inline в хендлере) чтобы integration-EXPLAIN- +# gate мог проверить SQL против прод-PG (psycopg v3 `:x::type`-антипаттерн, +# несуществующие PostGIS-функции). ::geography приклеен к ЗАКРЫВАЮЩЕЙ скобке — +# это разрешённая форма (не `:bind::type`), bind-числа идут через CAST(:x AS float). +_NEIGHBOR_BUILDINGS_SQL = text(""" + WITH parcel AS ( + SELECT geom + FROM cad_parcels_geom + WHERE cad_num = CAST(:cad AS text) + AND geom IS NOT NULL + LIMIT 1 + ) + SELECT + cb.cad_num, + cb.floors, + cb.building_name, + cb.year_built, + ST_AsGeoJSON(ST_SimplifyPreserveTopology(cb.geom, 0.000005)) AS geojson, + ST_Distance(cb.geom::geography, (SELECT geom FROM parcel)::geography) AS distance_m + FROM cad_buildings cb, parcel + WHERE ST_DWithin( + cb.geom::geography, + parcel.geom::geography, + CAST(:radius AS float) + ) + AND NOT ST_Intersects(cb.geom, parcel.geom) + ORDER BY distance_m ASC + LIMIT 301 +""") + +# Высота этажа для грубой 3D-экструзии footprint'а (§7). Типовой МКД ≈ 3 м/этаж — +# достаточная точность для превью-сцены «Размещение застройки». +_NEIGHBOR_FLOOR_HEIGHT_M = 3.0 +# Отдаём максимум 300 соседей (LIMIT 301 в SQL — «взяли на один больше» чтобы +# без COUNT(*) понять, обрезали ли выборку → truncated). +_NEIGHBOR_MAX_FEATURES = 300 + + +@router.get( + "/{cad_num}/neighbor-buildings", + summary="Соседние здания (footprint'ы) для 3D-сцены §7 (Forgejo #2180)", +) +def get_parcel_neighbor_buildings( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=50, le=1000)] = 300, +) -> dict[str, Any]: + """GeoJSON FeatureCollection footprint'ов соседних зданий вокруг участка. + + Для 3D-сцены «Размещение застройки» (§7 отчёта ПТИЦА): рисуем существующую + застройку контекста, чтобы концепция вписывалась в окружение. Здания берём из + `cad_buildings` в радиусе `radius_m` (ST_DWithin по geography). Здания, + ПЕРЕСЕКАЮЩИЕ сам участок, исключаем — на его месте строим мы (их footprint'ы + только мешали бы сцене). + + Геометрия упрощается (ST_SimplifyPreserveTopology, ~0.5 м) — фронту не нужна + кадастровая точность контура для превью. properties каждой фичи: + cad_num, floors (int|null), height_m (floors×3 если floors>0, иначе null), + building_name (null если пусто), year_built (int|null), is_neighbor=true. + + Отдаём максимум 300 ближайших (ORDER BY расстоянию); `truncated=true` если в + радиусе их больше. Участок не найден (нет geom) → 404. + + Query params: + radius_m: радиус поиска, 50–1000 м (default 300). + """ + rows = ( + db.execute( + _NEIGHBOR_BUILDINGS_SQL, + {"cad": cad_num, "radius": float(radius_m)}, + ) + .mappings() + .all() + ) + + # Пустой результат может значить И «участка нет в БД», И «участок есть, но + # соседей в радиусе нет» — надо различать (404 vs пустой 200). Отдельный + # быстрый EXISTS-пробник участка: без него не отличить «нет geom» от «нет + # соседей». Только когда rows пуст (в норме — 1 дешёвый запрос сверху). + if not rows: + parcel_exists = db.execute( + text( + "SELECT 1 FROM cad_parcels_geom " + "WHERE cad_num = CAST(:cad AS text) AND geom IS NOT NULL LIMIT 1" + ), + {"cad": cad_num}, + ).first() + if parcel_exists is None: + raise HTTPException( + status_code=404, + detail=( + f"Геометрия участка {cad_num} не найдена в БД. " + "Запустите POST /analyze для дозагрузки." + ), + ) + + truncated = len(rows) > _NEIGHBOR_MAX_FEATURES + kept = rows[:_NEIGHBOR_MAX_FEATURES] + + features: list[dict[str, Any]] = [] + for r in kept: + # geojson — строка из ST_AsGeoJSON; парсим в dict для валидного вложения + # в FeatureCollection (иначе фронт получит geometry как escaped-строку). + geom_raw = r.get("geojson") + if not geom_raw: + # Топологически битый polygon → ST_AsGeoJSON(NULL). Пропускаем: одна + # плохая геометрия не должна ронять всю сцену. + continue + geometry = json.loads(geom_raw) + + floors = r.get("floors") + floors_int = int(floors) if floors is not None and int(floors) > 0 else None + year_built = r.get("year_built") + building_name = (r.get("building_name") or "").strip() or None + + features.append( + { + "type": "Feature", + "geometry": geometry, + "properties": { + "cad_num": r.get("cad_num"), + "floors": floors_int, + "height_m": ( + round(floors_int * _NEIGHBOR_FLOOR_HEIGHT_M, 1) + if floors_int is not None + else None + ), + "building_name": building_name, + "year_built": int(year_built) if year_built is not None else None, + "is_neighbor": True, + }, + } + ) + + return { + "type": "FeatureCollection", + "features": features, + "count": len(features), + "radius_m": radius_m, + "truncated": truncated, + } + + _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) diff --git a/backend/tests/api/v1/test_parcel_neighbor_buildings.py b/backend/tests/api/v1/test_parcel_neighbor_buildings.py new file mode 100644 index 00000000..1797b5e5 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_neighbor_buildings.py @@ -0,0 +1,244 @@ +"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180). + +Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение +застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг +участка. + +Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI +Depends держит оригинальную ссылку — unittest.mock.patch неэффективен). Хендлер +делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при +пустом результате — второй `db.execute(...).first()` (EXISTS-пробник участка, +404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает +autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing) +байпасится — auth-заголовок не нужен. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.db import get_db +from app.main import app + +_VALID_CAD = "66:41:0204016:10" + +# Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка). +_POLYGON_GEOJSON = json.dumps( + { + "type": "Polygon", + "coordinates": [ + [ + [60.640, 56.830], + [60.641, 56.830], + [60.641, 56.831], + [60.640, 56.831], + [60.640, 56.830], + ] + ], + } +) + + +def _make_building_row( + cad_num: str = "66:41:0204016:100", + floors: int | None = 9, + building_name: str | None = "Жилой дом", + year_built: int | None = 1998, + distance_m: float = 42.0, + geojson: str | None = _POLYGON_GEOJSON, +) -> dict[str, Any]: + """Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings()).""" + return { + "cad_num": cad_num, + "floors": floors, + "building_name": building_name, + "year_built": year_built, + "geojson": geojson, + "distance_m": distance_m, + } + + +def _build_mock_db( + rows: list[dict[str, Any]], + *, + parcel_exists: bool = True, +) -> MagicMock: + """Mock Session. + + Основной запрос соседей → .mappings().all() = rows. + Fallback EXISTS-пробник (вызывается лишь при пустом rows) → .first() = + (1,) если parcel_exists иначе None. + """ + mock_db = MagicMock() + + mappings_mock = MagicMock() + mappings_mock.all.return_value = rows + + execute_result = MagicMock() + execute_result.mappings.return_value = mappings_mock + # .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок, + # т.к. пробник запускается только когда rows пуст). + execute_result.first.return_value = (1,) if parcel_exists else None + + mock_db.execute.return_value = execute_result + return mock_db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) + + +# ── happy path: фичи + floors → height_m ────────────────────────────────── + + +def test_neighbor_buildings_happy_path(client: TestClient) -> None: + rows = [ + _make_building_row("66:41:0204016:100", floors=9, distance_m=30.0), + _make_building_row("66:41:0204016:101", floors=16, distance_m=85.0), + _make_building_row( + "66:41:0204016:102", + floors=None, # floors неизвестны → height_m null + building_name="", # пустое имя → null + year_built=None, + distance_m=120.0, + ), + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["type"] == "FeatureCollection" + assert body["count"] == 3 + assert body["radius_m"] == 300 + assert body["truncated"] is False + assert len(body["features"]) == 3 + + f0 = body["features"][0] + assert f0["type"] == "Feature" + assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped + props0 = f0["properties"] + assert props0["cad_num"] == "66:41:0204016:100" + assert props0["floors"] == 9 + assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0 + assert props0["building_name"] == "Жилой дом" + assert props0["year_built"] == 1998 + assert props0["is_neighbor"] is True + + # floors=16 → height_m 48.0 + assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0) + + # floors=None → height_m null, building_name пустой → null, year_built null + props2 = body["features"][2]["properties"] + assert props2["floors"] is None + assert props2["height_m"] is None + assert props2["building_name"] is None + assert props2["year_built"] is None + assert props2["is_neighbor"] is True + + +# ── пустой результат (участок есть, соседей нет) → 200 empty ─────────────── + + +def test_neighbor_buildings_empty_ok(client: TestClient) -> None: + """Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой.""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["type"] == "FeatureCollection" + assert body["features"] == [] + assert body["count"] == 0 + assert body["truncated"] is False + + +# ── участок не найден (нет geom) → 404 ───────────────────────────────────── + + +def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None: + """Пустой результат + EXISTS-пробник вернул None (нет geom) → 404.""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False)) + + r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings") + + assert r.status_code == 404 + assert "не найдена" in r.json()["detail"] + + +# ── truncated: >300 соседей → флаг + обрезка до 300 ──────────────────────── + + +def test_neighbor_buildings_truncated(client: TestClient) -> None: + """SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true.""" + rows = [ + _make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i)) + for i in range(301) + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["count"] == 300 + assert len(body["features"]) == 300 + assert body["truncated"] is True + + +# ── radius валидация → 422 ───────────────────────────────────────────────── + + +def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None: + """radius_m=10 (< min 50) → 422.""" + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10") + assert r.status_code == 422 + + +def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None: + """radius_m=2000 (> max 1000) → 422.""" + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000") + assert r.status_code == 422 + + +def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None: + """radius_m из query отражается в ответе (и внутри — в bind SQL).""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()])) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500") + + assert r.status_code == 200, r.text + assert r.json()["radius_m"] == 500 + + +# ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ─────── + + +def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None: + rows = [ + _make_building_row("66:41:0204016:100", geojson=None), # битый polygon + _make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON), + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["count"] == 1 + assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101" diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx index e4563827..2c246f5a 100644 --- a/frontend/src/components/concept/ConceptResultMap.tsx +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -14,6 +14,8 @@ import L from "leaflet"; import "leaflet/dist/leaflet.css"; +import { useNeighborBuildings } from "@/hooks/useNeighborBuildings"; + // ── Bounds helper ───────────────────────────────────────────────────────────── function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null { @@ -59,6 +61,11 @@ interface Props { /** Keying makes react-leaflet remount the buildings layer on variant switch. */ variantKey: string; height?: number; + /** + * Кадастровый номер участка — для слоя соседних зданий (макет квартала, #2180). + * Отсутствует на standalone /concept без кадастра → слой соседей не грузится. + */ + cadNum?: string | null; } export function ConceptResultMap({ @@ -66,6 +73,7 @@ export function ConceptResultMap({ buildings, variantKey, height = 360, + cadNum, }: Props) { // Memoize so FitBounds only re-fits when the parcel geometry actually // changes — recomputing inline returns a new array each render, which would @@ -76,6 +84,12 @@ export function ConceptResultMap({ ); const hasBuildings = buildings.features.length > 0; + // Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только + // при наличии cadNum (standalone /concept без кадастра → не грузим). + const neighbors = useNeighborBuildings(cadNum); + const neighborsData = neighbors.data; + const hasNeighbors = (neighborsData?.count ?? 0) > 0; + return (
+ {/* Neighbour context buildings (#2180) — neutral grey макет квартала под + слоем корпусов. Не интерактивный (interactive: false), без popup. + Рендерится ПЕРВЫМ → уходит под слои участка и корпусов. Цвета: fill + #E5E7EB, обводка #9CA3AF (нейтральные серые, ≈--fg-tertiary). */} + {hasNeighbors && neighborsData && ( +