feat(site-finder): P2 cad_buildings соседи + overlap check (#46)

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 фильтрах).
This commit is contained in:
lekss361 2026-05-12 01:12:29 +03:00
parent 3777a77a42
commit 60d53bbf7d
4 changed files with 567 additions and 1 deletions

View file

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

View file

@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* P2 (#46) — Соседи + overlap warning */}
{data.neighbors_summary && (
<NeighborsBlock data={data.neighbors_summary} />
)}
{/* Zoning note */}
<div
style={{

View file

@ -0,0 +1,360 @@
"use client";
import { useState } from "react";
import type { NeighborsSummary } from "@/types/site-finder";
interface Props {
data: NeighborsSummary;
}
function fmtPrice(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 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 (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "12px 16px",
background: "#f9fafb",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Соседи (100 м)
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{data.note ?? "Данные о соседних зданиях недоступны"}
</div>
</div>
);
}
const count = data.count_buildings_100m ?? 0;
const hasOverlap = !!data.has_existing_buildings;
return (
<div
style={{
border: hasOverlap ? "1px solid #fca5a5" : "1px solid #e5e7eb",
background: "#fff",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Соседи (100 м)
</span>
<span
style={{
fontSize: 12,
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{count} {count === 1 ? "здание" : "зданий"}
</span>
</div>
{/* Overlap warning — hard warn */}
{hasOverlap &&
data.overlap_buildings &&
data.overlap_buildings.length > 0 && (
<div
style={{
padding: "10px 14px",
background: "#fee2e2",
border: "1px solid #fca5a5",
borderRadius: 8,
color: "#b91c1c",
fontSize: 13,
}}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>
На участке уже есть здания (overlap &gt;50 м²)
</div>
<ul
style={{
margin: 0,
paddingLeft: 18,
fontSize: 12,
lineHeight: 1.5,
}}
>
{data.overlap_buildings.map((b) => (
<li key={b.cad_num}>
{b.building_name ?? b.readable_address ?? b.cad_num}
{b.floors ? `, ${b.floors} эт.` : ""}
{b.overlap_m2 ? ` — пересечение ~${b.overlap_m2} м²` : ""}
</li>
))}
</ul>
<div style={{ marginTop: 6, fontSize: 11, fontStyle: "italic" }}>
Инвестиции невозможны без сноса.
</div>
</div>
)}
{/* Summary metrics */}
{count > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
gap: 10,
fontSize: 12,
color: "#374151",
}}
>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>
Средн. этажность
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.avg_floors_100m ?? "—"}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Макс. этажей</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.max_floors_100m ?? "—"}
</div>
</div>
<div>
<div
style={{ color: "#9ca3af", fontSize: 11 }}
title="Медиана кадастровой стоимости / м² — proxy для «premium quarter»"
>
Медиана цены
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtPrice(data.median_cost_per_m2_100m)}
</div>
</div>
</div>
)}
{/* Toggle neighbor list */}
{data.neighbors && data.neighbors.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть список"
: `Показать ${data.neighbors.length} ближайших`}
</button>
{expanded && (
<div
style={{
marginTop: 10,
maxHeight: 280,
overflowY: "auto",
border: "1px solid #f3f4f6",
borderRadius: 6,
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead
style={{
background: "#f9fafb",
position: "sticky",
top: 0,
}}
>
<tr>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
Здание
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Эт.
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Год
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
/м²
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Дист.
</th>
</tr>
</thead>
<tbody>
{data.neighbors.map((n) => (
<tr
key={n.cad_num}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td style={{ padding: "6px 10px", color: "#374151" }}>
{n.building_name ?? n.readable_address ?? n.cad_num}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{n.floors ?? "—"}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{fmtYearBuilt(n.year_built)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{fmtPrice(n.cost_per_m2)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{n.distance_m} м
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{data.note && (
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
{data.note}
</div>
)}
</div>
);
}

View file

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