feat(site-finder): max-info попапы — конкурент / §3-таблица / польз.точка / соседи (#2111) #2113

Merged
bot-backend merged 1 commit from feat/secondary-max-info into main 2026-06-30 13:45:44 +00:00
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 = 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]
], ],

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 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"))

View file

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

View file

@ -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:

View file

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

View file

@ -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}

View file

@ -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={{

View file

@ -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>
)} )}

View file

@ -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 {