feat(site-finder): max-info попапы — конкурент / §3-таблица / польз.точка / соседи (#2111) (#2113)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m26s
Deploy / build-worker (push) Successful in 3m41s
Deploy / deploy (push) Successful in 1m30s
Deploy / build-frontend (push) Successful in 4m2s

This commit is contained in:
bot-backend 2026-06-30 13:45:43 +00:00
parent 6d6e0ce0ca
commit 02b5f0b5d1
9 changed files with 267 additions and 44 deletions

View file

@ -467,6 +467,10 @@ def _competitor_with_coords(row: Any) -> dict[str, Any]:
out = dict(row)
out["lat"] = _coord_round(out.get("lat"))
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
@ -804,6 +808,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
cost_value,
area,
readable_address,
purpose,
status,
ST_Distance(
b.geom::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"])),
"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]
],

View file

@ -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
# 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:
"""OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ».
@ -498,7 +522,7 @@ def _utility_characteristics(kind: str, tags: dict | None) -> str | None:
Examples:
power voltage=110000;10000 operator=«МРСК Урала»
«110 кВ/10 кВ · МРСК Урала»
gas diameter=500mm material=steel « 500 мм · steel»
gas diameter=500mm material=steel « 500 мм · сталь»
voltage=400 «400 В»; voltage=yes None
"""
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"))
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"))

View file

@ -207,3 +207,43 @@ class TestObjPricingPushdown:
assert (
"v_objective_lots_latest" not in sql
), "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

View file

@ -77,8 +77,16 @@ def test_characteristics_power_voltage_and_operator() -> None:
def test_characteristics_pipeline_diameter_and_material() -> None:
# material переводится на RU («человеческим языком»): 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:

View file

@ -28,6 +28,16 @@ function weightLabel(weight: number): string {
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
// ---------------------------------------------------------------------------
@ -39,6 +49,14 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
<LayerGroup>
{pois.map((poi) => {
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 (
<CircleMarker
key={poi.id}
@ -66,6 +84,13 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
{poi.category}
</div>
)}
<div
style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}
>
{isGlobal
? "Глобальная точка"
: `Привязана к участку ${poi.parcel_cad}`}
</div>
<div
style={{
fontWeight: 700,
@ -115,6 +140,12 @@ export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
Удалить
</button>
</div>
{createdStr && (
<div style={{ marginTop: 8, fontSize: 10, color: "#9ca3af" }}>
Добавлена {createdStr}
{wasEdited && updatedStr ? ` · изменена ${updatedStr}` : ""}
</div>
)}
</div>
</Popup>
</CircleMarker>

View file

@ -387,6 +387,11 @@ export function MarketLayers({
<div style={{ marginBottom: 2 }}>
<strong>{c.comm_name ?? "Без названия"}</strong>
</div>
{c.dev_name && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Застройщик: {c.dev_name}
</div>
)}
{c.obj_class && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Класс: {c.obj_class}
@ -401,6 +406,12 @@ export function MarketLayers({
{priceStr && (
<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 && (
<div style={{ color: "#374151" }}>
Продано: {c.units_sold ?? 0}

View file

@ -311,6 +311,20 @@ export function NeighborsBlock({ data }: Props) {
>
<td style={{ padding: "6px 10px", color: "#374151" }}>
{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
style={{

View file

@ -17,6 +17,11 @@ interface MergedRow {
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
// (analyze — до центроида, connection-points — до границы участка).
cadNum: string | null;
// #2111 — расширенные поля из connection-points (EngineeringStructure).
// analyze-источник их не отдаёт → null; рендерим вторичной строкой только когда есть.
purpose: string | null;
characteristics: string | null;
readableAddress: string | null;
}
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
@ -33,6 +38,9 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
distanceM: item.distance_m,
source: "analyze",
cadNum: null,
purpose: null,
characteristics: null,
readableAddress: null,
});
}
}
@ -45,6 +53,9 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
distanceM: s.distance_to_boundary_m,
source: "connection-points",
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>
</thead>
<tbody>
{deduped.map((row, idx) => (
<tr
key={idx}
style={{
borderBottom:
idx < deduped.length - 1 ? "1px solid #f3f4f6" : undefined,
}}
>
<td
{deduped.map((row, idx) => {
// #2111 — расширенные поля (НСПД-дамп) одной вторичной строкой под
// основной; рендерим только присутствующие.
const hasDetail =
!!row.purpose ||
!!row.characteristics ||
!!row.readableAddress ||
!!row.cadNum;
const isLast = idx === deduped.length - 1;
return (
<tr
key={idx}
style={{
padding: "5px 8px",
color: "#1f2937",
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
borderBottom: !isLast ? "1px solid #f3f4f6" : undefined,
}}
>
{row.label}
</td>
<td
style={{
padding: "5px 8px",
color: "#6b7280",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.type ?? "—"}
</td>
<td
style={{
padding: "5px 8px",
color: "#374151",
textAlign: "right",
whiteSpace: "nowrap",
}}
>
{Math.round(row.distanceM)} м
</td>
</tr>
))}
<td
colSpan={3}
style={{
padding: "5px 8px",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
}}
>
<span
style={{
flex: 1,
minWidth: 0,
color: "#1f2937",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.label}
</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>
</table>
)}

View file

@ -53,6 +53,10 @@ export interface ParcelAnalysisCompetitor {
avg_price_per_m2_rub?: number | null;
units_sold?: 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 {
@ -165,6 +169,11 @@ export interface NeighborBuilding {
cost_per_m2: number | null;
distance_m: number;
readable_address: string | null;
// #2111 — cad_buildings.purpose / status через loose neighbors_summary
// passthrough. Optional + nullable: старый кэш / тонкий стаб могут не содержать;
// рендерим только при наличии.
purpose?: string | null;
status?: string | null;
}
export interface OverlapBuilding {