feat(site-finder): P2 cad_buildings соседи + overlap check (#46) #91
4 changed files with 595 additions and 1 deletions
|
|
@ -289,6 +289,182 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
|||
}
|
||||
|
||||
|
||||
# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда
|
||||
# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны
|
||||
# эмпирически для ЕКБ.
|
||||
_COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода
|
||||
_COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier
|
||||
|
||||
|
||||
def _parse_floors(raw: str | None) -> int | None:
|
||||
"""cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2', '5-7').
|
||||
|
||||
Возвращаем верхнюю границу (более консервативный сосед-высотка).
|
||||
NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для
|
||||
multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation).
|
||||
"""
|
||||
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. Дефенсивный try/except: если cost_value/area
|
||||
# придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без
|
||||
# этого guard весь endpoint вернёт 500.
|
||||
try:
|
||||
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"])
|
||||
if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX:
|
||||
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
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning("neighbors aggregation failed: %s", e)
|
||||
return {
|
||||
"data_available": False,
|
||||
"note": f"neighbors aggregation failed: {e}",
|
||||
}
|
||||
|
||||
# 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 +1245,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,
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
371
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal file
371
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"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);
|
||||
}
|
||||
|
||||
// Русский plural: 1 → "здание", 2-4 → "здания", 5+ → "зданий".
|
||||
// Также корректно для 11-14 (zданий), 21 (здание), 22 (здания) etc.
|
||||
function pluralBuildings(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "зданий";
|
||||
if (mod10 === 1) return "здание";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "здания";
|
||||
return "зданий";
|
||||
}
|
||||
|
||||
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} {pluralBuildings(count)}
|
||||
</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 >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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue