From 60d53bbf7de0b5939bf18054054e36e7bc8fc980 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 01:12:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20P2=20cad=5Fbuildings=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=B8=20+=20overlap=20check=20(#?= =?UTF-8?q?46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (parcels.py): - _parse_floors() helper для TEXT column (cad_buildings.floors хранится как строка, могут быть диапазоны "5-7"). Возвращает верхнюю границу. - _neighbors_summary(db, geom_wkt, our_cad) — query соседей в 100м (GIST): cad_num, building_name, floors, year_built, cost_value, area, address, distance. Aggregate: avg_floors_100m, max_floors_100m, median_cost_per_m2_100m, count_buildings_100m. Outliers cost/m² фильтруются (1k < x < 500k). - Overlap check: ST_Intersects + ST_Area(ST_Intersection) > 50 m² (transformed to UTM 32641 для метров). Если есть → has_existing_buildings: true + overlap_buildings list. - В response → neighbors_summary. Frontend: - Новый NeighborsBlock.tsx: hard red warn block для overlap (с building names + overlap_m2 + "Инвестиции невозможны без сноса"); summary metrics (avg/max floors, median price); toggle "Показать N ближайших" → таблица. - Border меняется на красный при has_existing_buildings — visual cue. - Добавлен в LandTab выше "Зонирование (ПЗЗ)". - TS типы: NeighborBuilding, OverlapBuilding, NeighborsSummary. Closes #46. Closes #21 (cad_buildings в Site Finder фильтрах). --- backend/app/api/v1/parcels.py | 161 ++++++++ .../src/components/site-finder/LandTab.tsx | 11 +- .../components/site-finder/NeighborsBlock.tsx | 360 ++++++++++++++++++ frontend/src/types/site-finder.ts | 36 ++ 4 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/NeighborsBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 88142ab0..dc59b20c 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -289,6 +289,165 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +def _parse_floors(raw: str | None) -> int | None: + """cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2'). + + Возвращаем верхнюю границу (более консервативный сосед-высотка). + """ + if not raw: + return None + raw = raw.strip() + # range like "5-7" → 7 + if "-" in raw: + parts = raw.split("-") + try: + return max(int(p.strip()) for p in parts if p.strip().isdigit()) + except ValueError: + return None + # single int + try: + return int(raw) + except ValueError: + return None + + +def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]: + """P2 (#46) — cad_buildings соседи в 100м + overlap check. + + Возвращает aggregate (avg/max floors, median cost/m², count) + плоский + список соседей для UI + флаг has_existing_buildings (overlap >50 м²). + + Использует GIST на cad_buildings.geom (уже создан в schema). + """ + try: + neighbor_rows = ( + db.execute( + text(""" + SELECT cad_num, + building_name, + floors, + year_built, + cost_value, + area, + readable_address, + ST_Distance( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM cad_buildings b + WHERE ST_DWithin( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography, + 100 + ) + AND b.cad_num != :our_cad + ORDER BY distance_m ASC + LIMIT 30 + """), + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .all() + ) + except Exception as e: + logger.warning("neighbors query failed: %s", e) + return {"data_available": False, "note": f"neighbors query failed: {e}"} + + # Aggregate floors + cost + floors_parsed: list[int] = [] + costs_per_m2: list[float] = [] + for r in neighbor_rows: + f = _parse_floors(r.get("floors")) + if f is not None and f > 0: + floors_parsed.append(f) + if r.get("cost_value") and r.get("area") and float(r["area"]) > 0: + cost_per_m2 = float(r["cost_value"]) / float(r["area"]) + # sanity filter — кадастровая стоимость должна быть >0 + <500K ₽/м² + if 1000 < cost_per_m2 < 500_000: + costs_per_m2.append(cost_per_m2) + + avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None + max_floors = max(floors_parsed) if floors_parsed else None + median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None + + # Overlap check — что-то построено непосредственно на нашем участке. + # Если хоть один building пересекается с площадью >50 м² — hard warn. + try: + overlap_row = ( + db.execute( + text(""" + SELECT cad_num, + building_name, + floors, + readable_address, + ST_Area( + ST_Intersection( + ST_Transform(b.geom, 32641), + ST_Transform(ST_GeomFromText(:wkt, 4326), 32641) + ) + ) AS overlap_m2 + FROM cad_buildings b + WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326)) + AND b.cad_num != :our_cad + ORDER BY overlap_m2 DESC NULLS LAST + LIMIT 5 + """), + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .all() + ) + except Exception as e: + logger.warning("overlap check failed: %s", e) + overlap_row = [] + + overlap_buildings = [ + { + "cad_num": o["cad_num"], + "building_name": o.get("building_name"), + "floors": o.get("floors"), + "readable_address": o.get("readable_address"), + "overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None, + } + for o in overlap_row + if o.get("overlap_m2") and float(o["overlap_m2"]) > 50 + ] + has_existing = len(overlap_buildings) > 0 + + return { + "data_available": True, + "radius_m": 100, + "count_buildings_100m": len(neighbor_rows), + "avg_floors_100m": avg_floors, + "max_floors_100m": max_floors, + "median_cost_per_m2_100m": median_cost, + "neighbors": [ + { + "cad_num": r["cad_num"], + "building_name": r.get("building_name"), + "floors": r.get("floors"), + "floors_parsed": _parse_floors(r.get("floors")), + "year_built": r.get("year_built"), + "area_m2": round(float(r["area"])) if r.get("area") else None, + "cost_per_m2": ( + round(float(r["cost_value"]) / float(r["area"])) + if r.get("cost_value") and r.get("area") and float(r["area"]) > 0 + else None + ), + "distance_m": round(float(r["distance_m"])), + "readable_address": r.get("readable_address"), + } + for r in neighbor_rows[:20] + ], + "has_existing_buildings": has_existing, + "overlap_buildings": overlap_buildings, + "note": ( + "Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — " + "agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная." + ), + } + + def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]: """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость. @@ -1069,6 +1228,8 @@ def analyze_parcel( "hydrology": hydrology, "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), + # P2 (#46) — соседи-здания + overlap check + "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, "zoning": zoning, "success_recommendation": success_recommendation, diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 20273656..7cd76b64 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -3,16 +3,25 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; +import { NeighborsBlock } from "./NeighborsBlock"; interface Props { data: ParcelAnalysis; } export function LandTab({ data }: Props) { - const hasAny = data.geotech_risk !== undefined || data.geology !== undefined; + const hasAny = + data.geotech_risk !== undefined || + data.geology !== undefined || + data.neighbors_summary !== undefined; return (
+ {/* P2 (#46) — Соседи + overlap warning */} + {data.neighbors_summary && ( + + )} + {/* Zoning note */}
= 1000) { + return `${(n / 1000).toFixed(0)} тыс ₽/м²`; + } + return `${n.toLocaleString("ru-RU")} ₽/м²`; +} + +function fmtYearBuilt(y: number | null | undefined): string { + if (y == null || y === 0) return "—"; + return String(y); +} + +export function NeighborsBlock({ data }: Props) { + const [expanded, setExpanded] = useState(false); + + if (!data.data_available) { + return ( +
+
+ Соседи (100 м) +
+
+ {data.note ?? "Данные о соседних зданиях недоступны"} +
+
+ ); + } + + const count = data.count_buildings_100m ?? 0; + const hasOverlap = !!data.has_existing_buildings; + + return ( +
+
+ + Соседи (100 м) + + + {count} {count === 1 ? "здание" : "зданий"} + +
+ + {/* Overlap warning — hard warn */} + {hasOverlap && + data.overlap_buildings && + data.overlap_buildings.length > 0 && ( +
+
+ На участке уже есть здания (overlap >50 м²) +
+
    + {data.overlap_buildings.map((b) => ( +
  • + {b.building_name ?? b.readable_address ?? b.cad_num} + {b.floors ? `, ${b.floors} эт.` : ""} + {b.overlap_m2 ? ` — пересечение ~${b.overlap_m2} м²` : ""} +
  • + ))} +
+
+ Инвестиции невозможны без сноса. +
+
+ )} + + {/* Summary metrics */} + {count > 0 && ( +
+
+
+ Средн. этажность +
+
+ {data.avg_floors_100m ?? "—"} +
+
+
+
Макс. этажей
+
+ {data.max_floors_100m ?? "—"} +
+
+
+
+ Медиана цены +
+
+ {fmtPrice(data.median_cost_per_m2_100m)} +
+
+
+ )} + + {/* Toggle neighbor list */} + {data.neighbors && data.neighbors.length > 0 && ( +
+ + {expanded && ( +
+ + + + + + + + + + + + {data.neighbors.map((n) => ( + + + + + + + + ))} + +
+ Здание + + Эт. + + Год + + ₽/м² + + Дист. +
+ {n.building_name ?? n.readable_address ?? n.cad_num} + + {n.floors ?? "—"} + + {fmtYearBuilt(n.year_built)} + + {fmtPrice(n.cost_per_m2)} + + {n.distance_m} м +
+
+ )} +
+ )} + + {data.note && ( +
+ {data.note} +
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index a2a83ec1..a9310812 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -127,6 +127,40 @@ export interface GeotechRisk { note: string; } +// P2 (#46) — cad_buildings соседи + overlap +export interface NeighborBuilding { + cad_num: string; + building_name: string | null; + floors: string | null; + floors_parsed: number | null; + year_built: number | null; + area_m2: number | null; + cost_per_m2: number | null; + distance_m: number; + readable_address: string | null; +} + +export interface OverlapBuilding { + cad_num: string; + building_name: string | null; + floors: string | null; + readable_address: string | null; + overlap_m2: number | null; +} + +export interface NeighborsSummary { + data_available: boolean; + radius_m?: number; + count_buildings_100m?: number; + avg_floors_100m?: number | null; + max_floors_100m?: number | null; + median_cost_per_m2_100m?: number | null; + neighbors?: NeighborBuilding[]; + has_existing_buildings?: boolean; + overlap_buildings?: OverlapBuilding[]; + note?: string; +} + export interface MarketTrend { recent_avg_price_per_m2: number; prior_avg_price_per_m2: number; @@ -184,6 +218,8 @@ export interface ParcelAnalysis { score_without_center?: number; location?: ParcelLocation; success_recommendation?: ParcelSuccessRecommendation | null; + // P2 (#46) — cad_buildings соседи + overlap check + neighbors_summary?: NeighborsSummary; // X2 (#48) — confidence indicator confidence?: number; confidence_label?: "high" | "medium" | "low";