feat(site-finder): max-info попапы — конкурент / §3-таблица / польз.точка / соседи (#2111) #2113
9 changed files with 267 additions and 44 deletions
|
|
@ -467,6 +467,10 @@ def _competitor_with_coords(row: Any) -> dict[str, Any]:
|
||||||
out = dict(row)
|
out = dict(row)
|
||||||
out["lat"] = _coord_round(out.get("lat"))
|
out["lat"] = _coord_round(out.get("lat"))
|
||||||
out["lon"] = _coord_round(out.get("lon"))
|
out["lon"] = _coord_round(out.get("lon"))
|
||||||
|
# #2111 — средняя площадь квартиры конкурента (ROUND(AVG(area_pd),1) из
|
||||||
|
# obj_pricing CTE). dict(row) уже несёт ключ, но фиксируем явно: попап
|
||||||
|
# ожидает avg_area_pd, не завязываемся на спред-стиль выше.
|
||||||
|
out["avg_area_pd"] = out.get("avg_area_pd")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -804,6 +808,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
||||||
cost_value,
|
cost_value,
|
||||||
area,
|
area,
|
||||||
readable_address,
|
readable_address,
|
||||||
|
purpose,
|
||||||
|
status,
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
b.geom::geography,
|
b.geom::geography,
|
||||||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
|
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
|
||||||
|
|
@ -942,6 +948,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
||||||
),
|
),
|
||||||
"distance_m": round(float(r["distance_m"])),
|
"distance_m": round(float(r["distance_m"])),
|
||||||
"readable_address": r.get("readable_address"),
|
"readable_address": r.get("readable_address"),
|
||||||
|
# #2111 — функциональное назначение + статус здания (cad_buildings,
|
||||||
|
# populated bulk_harvest.py). purpose: TEXT категория, status:
|
||||||
|
# commissioned/under-construction и т.п.
|
||||||
|
"purpose": r.get("purpose"),
|
||||||
|
"status": r.get("status"),
|
||||||
}
|
}
|
||||||
for r in neighbor_rows[:20]
|
for r in neighbor_rows[:20]
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,30 @@ def _clean_utility_tag(value: object) -> str | None:
|
||||||
return s if s and s.lower() not in _JUNK_TAG_VALUES else None
|
return s if s and s.lower() not in _JUNK_TAG_VALUES else None
|
||||||
|
|
||||||
|
|
||||||
|
# OSM material (en) → RU («человеческим языком», правило ui-microcopy). Неизвестное
|
||||||
|
# значение остаётся как есть (лучше сырое, чем потерять).
|
||||||
|
_MATERIAL_RU: dict[str, str] = {
|
||||||
|
"metal": "металл",
|
||||||
|
"steel": "сталь",
|
||||||
|
"iron": "железо",
|
||||||
|
"cast_iron": "чугун",
|
||||||
|
"ductile_iron": "высокопрочный чугун",
|
||||||
|
"aluminium": "алюминий",
|
||||||
|
"aluminum": "алюминий",
|
||||||
|
"copper": "медь",
|
||||||
|
"concrete": "бетон",
|
||||||
|
"reinforced_concrete": "железобетон",
|
||||||
|
"wood": "дерево",
|
||||||
|
"wooden": "дерево",
|
||||||
|
"plastic": "пластик",
|
||||||
|
"pvc": "ПВХ",
|
||||||
|
"polyethylene": "полиэтилен",
|
||||||
|
"pe": "полиэтилен",
|
||||||
|
"asbestos_cement": "асбестоцемент",
|
||||||
|
"ceramic": "керамика",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _format_voltage(raw: str) -> str | None:
|
def _format_voltage(raw: str) -> str | None:
|
||||||
"""OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ».
|
"""OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ».
|
||||||
|
|
||||||
|
|
@ -498,7 +522,7 @@ def _utility_characteristics(kind: str, tags: dict | None) -> str | None:
|
||||||
Examples:
|
Examples:
|
||||||
power voltage=110000;10000 operator=«МРСК Урала» →
|
power voltage=110000;10000 operator=«МРСК Урала» →
|
||||||
«110 кВ/10 кВ · МРСК Урала»
|
«110 кВ/10 кВ · МРСК Урала»
|
||||||
gas diameter=500mm material=steel → «⌀ 500 мм · steel»
|
gas diameter=500mm material=steel → «⌀ 500 мм · сталь»
|
||||||
voltage=400 → «400 В»; voltage=yes → None
|
voltage=400 → «400 В»; voltage=yes → None
|
||||||
"""
|
"""
|
||||||
if not isinstance(tags, dict):
|
if not isinstance(tags, dict):
|
||||||
|
|
@ -523,7 +547,7 @@ def _utility_characteristics(kind: str, tags: dict | None) -> str | None:
|
||||||
|
|
||||||
material = _clean_utility_tag(tags.get("material"))
|
material = _clean_utility_tag(tags.get("material"))
|
||||||
if material:
|
if material:
|
||||||
parts.append(material)
|
parts.append(_MATERIAL_RU.get(material.lower(), material))
|
||||||
|
|
||||||
# Кол-во цепей/кабелей ЛЭП — только если значение чисто числовое.
|
# Кол-во цепей/кабелей ЛЭП — только если значение чисто числовое.
|
||||||
circuits = _clean_utility_tag(tags.get("circuits")) or _clean_utility_tag(tags.get("cables"))
|
circuits = _clean_utility_tag(tags.get("circuits")) or _clean_utility_tag(tags.get("cables"))
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,43 @@ class TestObjPricingPushdown:
|
||||||
assert (
|
assert (
|
||||||
"v_objective_lots_latest" not in sql
|
"v_objective_lots_latest" not in sql
|
||||||
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompetitorAvgAreaPd:
|
||||||
|
"""#2111: средняя площадь квартиры конкурента (avg_area_pd) доходит до попапа.
|
||||||
|
|
||||||
|
obj_pricing CTE считает ROUND(AVG(oll.area_pd),1) AS avg_area_pd, финальный
|
||||||
|
SELECT выбирает p.avg_area_pd, а _competitor_with_coords должен протолкнуть
|
||||||
|
ключ в loose-dict competitors[] (parcel.py:651). Mock-based, без БД.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_avg_area_pd_passes_through(self) -> None:
|
||||||
|
"""_competitor_with_coords сохраняет avg_area_pd из входной строки."""
|
||||||
|
row = {
|
||||||
|
"obj_id": 1,
|
||||||
|
"comm_name": "ЖК Тест",
|
||||||
|
"dev_name": "TestDev",
|
||||||
|
"avg_price_per_m2_rub": 150000,
|
||||||
|
"avg_area_pd": 47.3,
|
||||||
|
"units_sold": 10,
|
||||||
|
"units_available": 5,
|
||||||
|
"lat": 56.838011,
|
||||||
|
"lon": 60.597474,
|
||||||
|
}
|
||||||
|
out = parcels_module._competitor_with_coords(row)
|
||||||
|
assert out["avg_area_pd"] == 47.3
|
||||||
|
|
||||||
|
def test_avg_area_pd_none_when_absent(self) -> None:
|
||||||
|
"""Отсутствие avg_area_pd → ключ есть, значение None (graceful, ЖК без маппинга)."""
|
||||||
|
row = {"obj_id": 2, "comm_name": "ЖК Без Цен", "lat": None, "lon": None}
|
||||||
|
out = parcels_module._competitor_with_coords(row)
|
||||||
|
assert out["avg_area_pd"] is None
|
||||||
|
|
||||||
|
def test_sql_computes_and_selects_avg_area_pd(self) -> None:
|
||||||
|
"""Competitor-SQL вычисляет AVG(area_pd) и выбирает avg_area_pd в финале."""
|
||||||
|
src = inspect.getsource(parcels_module.analyze_parcel)
|
||||||
|
marker = "WITH latest_obj AS ("
|
||||||
|
start = src.index(marker)
|
||||||
|
block = re.sub(r"\s+", " ", src[start : src.index('"""', start)])
|
||||||
|
assert "ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd" in block
|
||||||
|
assert "p.avg_area_pd" in block
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,16 @@ def test_characteristics_power_voltage_and_operator() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_characteristics_pipeline_diameter_and_material() -> None:
|
def test_characteristics_pipeline_diameter_and_material() -> None:
|
||||||
|
# material переводится на RU («человеческим языком»): steel → сталь.
|
||||||
out = _utility_characteristics("gas", {"diameter": "500mm", "material": "steel"})
|
out = _utility_characteristics("gas", {"diameter": "500mm", "material": "steel"})
|
||||||
assert out == "⌀ 500 мм · steel"
|
assert out == "⌀ 500 мм · сталь"
|
||||||
|
|
||||||
|
|
||||||
|
def test_characteristics_material_ru_and_unknown_passthrough() -> None:
|
||||||
|
# known → RU; неизвестный материал остаётся сырым (лучше сырое, чем потерять).
|
||||||
|
assert _utility_characteristics("power", {"material": "metal"}) == "металл"
|
||||||
|
assert _utility_characteristics("power", {"material": "Concrete"}) == "бетон"
|
||||||
|
assert _utility_characteristics("gas", {"material": "unobtanium"}) == "unobtanium"
|
||||||
|
|
||||||
|
|
||||||
def test_characteristics_single_voltage_volts() -> None:
|
def test_characteristics_single_voltage_volts() -> None:
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ function weightLabel(weight: number): string {
|
||||||
return String(weight);
|
return String(weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Человекочитаемая дата (ru) с guard против null / невалидного ISO.
|
||||||
|
// Repo-convention: inline new Date(...).toLocaleDateString("ru-RU") + NaN-fallback
|
||||||
|
// (см. EgrnPropertyTable.formatDate).
|
||||||
|
function formatDate(raw: string | null | undefined): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -39,6 +49,14 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
|
||||||
<LayerGroup>
|
<LayerGroup>
|
||||||
{pois.map((poi) => {
|
{pois.map((poi) => {
|
||||||
const color = markerColor(poi.weight);
|
const color = markerColor(poi.weight);
|
||||||
|
const isGlobal = !poi.parcel_cad;
|
||||||
|
const createdStr = formatDate(poi.created_at);
|
||||||
|
const updatedStr = formatDate(poi.updated_at);
|
||||||
|
// «изменена» показываем только если updated_at отличается от created_at.
|
||||||
|
const wasEdited =
|
||||||
|
!!poi.updated_at &&
|
||||||
|
!!poi.created_at &&
|
||||||
|
poi.updated_at !== poi.created_at;
|
||||||
return (
|
return (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={poi.id}
|
key={poi.id}
|
||||||
|
|
@ -66,6 +84,13 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
|
||||||
{poi.category}
|
{poi.category}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{isGlobal
|
||||||
|
? "Глобальная точка"
|
||||||
|
: `Привязана к участку ${poi.parcel_cad}`}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
|
@ -115,6 +140,12 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{createdStr && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10, color: "#9ca3af" }}>
|
||||||
|
Добавлена {createdStr}
|
||||||
|
{wasEdited && updatedStr ? ` · изменена ${updatedStr}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,11 @@ export function MarketLayers({
|
||||||
<div style={{ marginBottom: 2 }}>
|
<div style={{ marginBottom: 2 }}>
|
||||||
<strong>{c.comm_name ?? "Без названия"}</strong>
|
<strong>{c.comm_name ?? "Без названия"}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{c.dev_name && (
|
||||||
|
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
||||||
|
Застройщик: {c.dev_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{c.obj_class && (
|
{c.obj_class && (
|
||||||
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
||||||
Класс: {c.obj_class}
|
Класс: {c.obj_class}
|
||||||
|
|
@ -401,6 +406,12 @@ export function MarketLayers({
|
||||||
{priceStr && (
|
{priceStr && (
|
||||||
<div style={{ color: "#374151" }}>{priceStr}</div>
|
<div style={{ color: "#374151" }}>{priceStr}</div>
|
||||||
)}
|
)}
|
||||||
|
{typeof c.avg_area_pd === "number" &&
|
||||||
|
Number.isFinite(c.avg_area_pd) && (
|
||||||
|
<div style={{ color: "#374151" }}>
|
||||||
|
Ср. площадь кв.: {c.avg_area_pd} м²
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showSales && (
|
{showSales && (
|
||||||
<div style={{ color: "#374151" }}>
|
<div style={{ color: "#374151" }}>
|
||||||
Продано: {c.units_sold ?? 0}
|
Продано: {c.units_sold ?? 0}
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,20 @@ export function NeighborsBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
<td style={{ padding: "6px 10px", color: "#374151" }}>
|
<td style={{ padding: "6px 10px", color: "#374151" }}>
|
||||||
{n.building_name ?? n.readable_address ?? n.cad_num}
|
{n.building_name ?? n.readable_address ?? n.cad_num}
|
||||||
|
{(n.purpose || n.status) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#9ca3af",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.purpose}
|
||||||
|
{n.purpose && n.status ? " · " : ""}
|
||||||
|
{n.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ interface MergedRow {
|
||||||
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
|
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
|
||||||
// (analyze — до центроида, connection-points — до границы участка).
|
// (analyze — до центроида, connection-points — до границы участка).
|
||||||
cadNum: string | null;
|
cadNum: string | null;
|
||||||
|
// #2111 — расширенные поля из connection-points (EngineeringStructure).
|
||||||
|
// analyze-источник их не отдаёт → null; рендерим вторичной строкой только когда есть.
|
||||||
|
purpose: string | null;
|
||||||
|
characteristics: string | null;
|
||||||
|
readableAddress: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
|
|
@ -33,6 +38,9 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
distanceM: item.distance_m,
|
distanceM: item.distance_m,
|
||||||
source: "analyze",
|
source: "analyze",
|
||||||
cadNum: null,
|
cadNum: null,
|
||||||
|
purpose: null,
|
||||||
|
characteristics: null,
|
||||||
|
readableAddress: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +53,9 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
distanceM: s.distance_to_boundary_m,
|
distanceM: s.distance_to_boundary_m,
|
||||||
source: "connection-points",
|
source: "connection-points",
|
||||||
cadNum: s.cad_num,
|
cadNum: s.cad_num,
|
||||||
|
purpose: s.purpose ?? null,
|
||||||
|
characteristics: s.characteristics ?? null,
|
||||||
|
readableAddress: s.readable_address,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,50 +136,114 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{deduped.map((row, idx) => (
|
{deduped.map((row, idx) => {
|
||||||
<tr
|
// #2111 — расширенные поля (НСПД-дамп) одной вторичной строкой под
|
||||||
key={idx}
|
// основной; рендерим только присутствующие.
|
||||||
style={{
|
const hasDetail =
|
||||||
borderBottom:
|
!!row.purpose ||
|
||||||
idx < deduped.length - 1 ? "1px solid #f3f4f6" : undefined,
|
!!row.characteristics ||
|
||||||
}}
|
!!row.readableAddress ||
|
||||||
>
|
!!row.cadNum;
|
||||||
<td
|
const isLast = idx === deduped.length - 1;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
style={{
|
style={{
|
||||||
padding: "5px 8px",
|
borderBottom: !isLast ? "1px solid #f3f4f6" : undefined,
|
||||||
color: "#1f2937",
|
|
||||||
maxWidth: 180,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.label}
|
<td
|
||||||
</td>
|
colSpan={3}
|
||||||
<td
|
style={{
|
||||||
style={{
|
padding: "5px 8px",
|
||||||
padding: "5px 8px",
|
}}
|
||||||
color: "#6b7280",
|
>
|
||||||
maxWidth: 140,
|
<div
|
||||||
overflow: "hidden",
|
style={{
|
||||||
textOverflow: "ellipsis",
|
display: "flex",
|
||||||
whiteSpace: "nowrap",
|
alignItems: "baseline",
|
||||||
}}
|
gap: 8,
|
||||||
>
|
}}
|
||||||
{row.type ?? "—"}
|
>
|
||||||
</td>
|
<span
|
||||||
<td
|
style={{
|
||||||
style={{
|
flex: 1,
|
||||||
padding: "5px 8px",
|
minWidth: 0,
|
||||||
color: "#374151",
|
color: "#1f2937",
|
||||||
textAlign: "right",
|
overflow: "hidden",
|
||||||
whiteSpace: "nowrap",
|
textOverflow: "ellipsis",
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
}}
|
||||||
{Math.round(row.distanceM)} м
|
>
|
||||||
</td>
|
{row.label}
|
||||||
</tr>
|
</span>
|
||||||
))}
|
<span
|
||||||
|
style={{
|
||||||
|
maxWidth: 140,
|
||||||
|
color: "#6b7280",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.type ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#374151",
|
||||||
|
textAlign: "right",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(row.distanceM)} м
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasDetail && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 3,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#6b7280",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.purpose && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "#9ca3af" }}>
|
||||||
|
Назначение:
|
||||||
|
</span>{" "}
|
||||||
|
{row.purpose}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.characteristics && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "#9ca3af" }}>
|
||||||
|
Характеристики:
|
||||||
|
</span>{" "}
|
||||||
|
{row.characteristics}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.readableAddress && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "#9ca3af" }}>Адрес:</span>{" "}
|
||||||
|
{row.readableAddress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.cadNum && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "#9ca3af" }}>№ ЕГРН:</span>{" "}
|
||||||
|
{row.cadNum}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ export interface ParcelAnalysisCompetitor {
|
||||||
avg_price_per_m2_rub?: number | null;
|
avg_price_per_m2_rub?: number | null;
|
||||||
units_sold?: number | null;
|
units_sold?: number | null;
|
||||||
units_available?: number | null;
|
units_available?: number | null;
|
||||||
|
// #2111 — средняя площадь квартиры (м²) из objective_lots. Backend кладёт через
|
||||||
|
// loose competitors[] dict-passthrough. Optional + nullable: тонкий стаб/старый
|
||||||
|
// кэш могут не содержать; popup рендерит только при наличии.
|
||||||
|
avg_area_pd?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParcelAnalysisDistrict {
|
export interface ParcelAnalysisDistrict {
|
||||||
|
|
@ -165,6 +169,11 @@ export interface NeighborBuilding {
|
||||||
cost_per_m2: number | null;
|
cost_per_m2: number | null;
|
||||||
distance_m: number;
|
distance_m: number;
|
||||||
readable_address: string | null;
|
readable_address: string | null;
|
||||||
|
// #2111 — cad_buildings.purpose / status через loose neighbors_summary
|
||||||
|
// passthrough. Optional + nullable: старый кэш / тонкий стаб могут не содержать;
|
||||||
|
// рендерим только при наличии.
|
||||||
|
purpose?: string | null;
|
||||||
|
status?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlapBuilding {
|
export interface OverlapBuilding {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue