From 02b5f0b5d1fef08a7baa8c2401fbac320c8b9455 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 30 Jun 2026 13:45:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20max-info=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D1=8B=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D0=B5=D0=BD=D1=82=20/=20=C2=A73-=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20/=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7.?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20/=20=D1=81=D0=BE=D1=81=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=20(#2111)=20(#2113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 11 ++ .../utility_infrastructure_loader.py | 28 +++- .../api/v1/test_analyze_competitors_status.py | 40 +++++ .../services/test_utility_characteristics.py | 10 +- .../components/site-finder/CustomPoiLayer.tsx | 31 ++++ .../components/site-finder/MarketLayers.tsx | 11 ++ .../components/site-finder/NeighborsBlock.tsx | 14 ++ .../NspdEngineeringNearbyBlock.tsx | 157 +++++++++++++----- frontend/src/types/site-finder.ts | 9 + 9 files changed, 267 insertions(+), 44 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 28ba4a01..59ddaf29 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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] ], diff --git a/backend/app/services/site_finder/utility_infrastructure_loader.py b/backend/app/services/site_finder/utility_infrastructure_loader.py index ab1ef205..d292237f 100644 --- a/backend/app/services/site_finder/utility_infrastructure_loader.py +++ b/backend/app/services/site_finder/utility_infrastructure_loader.py @@ -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")) diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index 6175a38f..d4fd67be 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -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 diff --git a/backend/tests/services/test_utility_characteristics.py b/backend/tests/services/test_utility_characteristics.py index 13dc2d81..390bb885 100644 --- a/backend/tests/services/test_utility_characteristics.py +++ b/backend/tests/services/test_utility_characteristics.py @@ -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: diff --git a/frontend/src/components/site-finder/CustomPoiLayer.tsx b/frontend/src/components/site-finder/CustomPoiLayer.tsx index 599aa99f..53b8f541 100644 --- a/frontend/src/components/site-finder/CustomPoiLayer.tsx +++ b/frontend/src/components/site-finder/CustomPoiLayer.tsx @@ -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) { {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 ( )} +
+ {isGlobal + ? "Глобальная точка" + : `Привязана к участку ${poi.parcel_cad}`} +
+ {createdStr && ( +
+ Добавлена {createdStr} + {wasEdited && updatedStr ? ` · изменена ${updatedStr}` : ""} +
+ )}
diff --git a/frontend/src/components/site-finder/MarketLayers.tsx b/frontend/src/components/site-finder/MarketLayers.tsx index 0cd40d33..3986019e 100644 --- a/frontend/src/components/site-finder/MarketLayers.tsx +++ b/frontend/src/components/site-finder/MarketLayers.tsx @@ -387,6 +387,11 @@ export function MarketLayers({
{c.comm_name ?? "Без названия"}
+ {c.dev_name && ( +
+ Застройщик: {c.dev_name} +
+ )} {c.obj_class && (
Класс: {c.obj_class} @@ -401,6 +406,12 @@ export function MarketLayers({ {priceStr && (
{priceStr}
)} + {typeof c.avg_area_pd === "number" && + Number.isFinite(c.avg_area_pd) && ( +
+ Ср. площадь кв.: {c.avg_area_pd} м² +
+ )} {showSales && (
Продано: {c.units_sold ?? 0} diff --git a/frontend/src/components/site-finder/NeighborsBlock.tsx b/frontend/src/components/site-finder/NeighborsBlock.tsx index f83ec87a..829472da 100644 --- a/frontend/src/components/site-finder/NeighborsBlock.tsx +++ b/frontend/src/components/site-finder/NeighborsBlock.tsx @@ -311,6 +311,20 @@ export function NeighborsBlock({ data }: Props) { > {n.building_name ?? n.readable_address ?? n.cad_num} + {(n.purpose || n.status) && ( +
+ {n.purpose} + {n.purpose && n.status ? " · " : ""} + {n.status} +
+ )} - {deduped.map((row, idx) => ( - - { + // #2111 — расширенные поля (НСПД-дамп) одной вторичной строкой под + // основной; рендерим только присутствующие. + const hasDetail = + !!row.purpose || + !!row.characteristics || + !!row.readableAddress || + !!row.cadNum; + const isLast = idx === deduped.length - 1; + return ( + - {row.label} - - - {row.type ?? "—"} - - - {Math.round(row.distanceM)} м - - - ))} + +
+ + {row.label} + + + {row.type ?? "—"} + + + {Math.round(row.distanceM)} м + +
+ {hasDetail && ( +
+ {row.purpose && ( +
+ + Назначение: + {" "} + {row.purpose} +
+ )} + {row.characteristics && ( +
+ + Характеристики: + {" "} + {row.characteristics} +
+ )} + {row.readableAddress && ( +
+ Адрес:{" "} + {row.readableAddress} +
+ )} + {row.cadNum && ( +
+ № ЕГРН:{" "} + {row.cadNum} +
+ )} +
+ )} + + + ); + })} )} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 19234167..b9562e2d 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -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 {