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:
parent
3777a77a42
commit
60d53bbf7d
4 changed files with 567 additions and 1 deletions
|
|
@ -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]:
|
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||||||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||||||
|
|
||||||
|
|
@ -1069,6 +1228,8 @@ def analyze_parcel(
|
||||||
"hydrology": hydrology,
|
"hydrology": hydrology,
|
||||||
"utilities": utilities,
|
"utilities": utilities,
|
||||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
"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,
|
"market_trend": market_trend,
|
||||||
"zoning": zoning,
|
"zoning": zoning,
|
||||||
"success_recommendation": success_recommendation,
|
"success_recommendation": success_recommendation,
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,25 @@
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { GeologyBlock } from "./GeologyBlock";
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
|
import { NeighborsBlock } from "./NeighborsBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LandTab({ data }: Props) {
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* P2 (#46) — Соседи + overlap warning */}
|
||||||
|
{data.neighbors_summary && (
|
||||||
|
<NeighborsBlock data={data.neighbors_summary} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoning note */}
|
{/* Zoning note */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
360
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal file
360
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal 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 >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;
|
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 {
|
export interface MarketTrend {
|
||||||
recent_avg_price_per_m2: number;
|
recent_avg_price_per_m2: number;
|
||||||
prior_avg_price_per_m2: number;
|
prior_avg_price_per_m2: number;
|
||||||
|
|
@ -184,6 +218,8 @@ export interface ParcelAnalysis {
|
||||||
score_without_center?: number;
|
score_without_center?: number;
|
||||||
location?: ParcelLocation;
|
location?: ParcelLocation;
|
||||||
success_recommendation?: ParcelSuccessRecommendation | null;
|
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||||
|
// P2 (#46) — cad_buildings соседи + overlap check
|
||||||
|
neighbors_summary?: NeighborsSummary;
|
||||||
// X2 (#48) — confidence indicator
|
// X2 (#48) — confidence indicator
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
confidence_label?: "high" | "medium" | "low";
|
confidence_label?: "high" | "medium" | "low";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue