feat(site-finder): max-info popups — конкурент/§3-таблица/польз.точка/соседи (#2111)
Audit #2111 (MEDIUM/LOW): попапы/карточки показывали данные, которые есть в ответе, но недоливались до UI. Все поля едут через существующие loose dict[str, Any] passthrough'ы (competitors, neighbors_summary) — без изменения контракта/codegen. - Конкурент (MarketLayers): «Застройщик» (dev_name) + «Ср. площадь кв.» (avg_area_pd — SQL уже считал, прокидываем явно в _competitor_with_coords). - §3 «Инж. сооружения рядом» (NspdEngineeringNearbyBlock): вторичная строка Назначение/Характеристики/Адрес/№ ЕГРН (EngineeringStructure уже несёт эти поля). - Польз. точка (CustomPoiLayer): scope-метка (глобальная/привязана к участку) + добавлена/изменена (formatDate с guard). - Соседи (NeighborsBlock): Назначение + Статус (cad_buildings.purpose/status, добавлены в neighbors CTE SELECT + neighbor dict; прод-verified ~100% заполнено). - Utility material → RU («сталь/металл/бетон», unknown → passthrough) — «человеческим языком» (ui-microcopy). Юнит-тесты material RU + avg_area_pd passthrough. Примечание: neighbor purpose/status покрыты только DB-gated EXPLAIN-тестом (integration), unit-покрытия нет by design.
This commit is contained in:
parent
6d6e0ce0ca
commit
9183829d08
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["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]
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue