From b0248645180683c06b3e470f346ce1e49bb87146 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 30 Jun 2026 12:42:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=C2=A71=20POI-=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D1=83=D0=BC=20=D0=B8=D0=BD=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20(#2110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 52 ++++++- .../tests/api/v1/test_poi_detail_from_tags.py | 69 +++++++++ .../src/components/site-finder/SiteMap.tsx | 136 +++++++++++++----- frontend/src/types/site-finder.ts | 17 +-- 4 files changed, 229 insertions(+), 45 deletions(-) create mode 100644 backend/tests/api/v1/test_poi_detail_from_tags.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index a57b09ec..28ba4a01 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -253,6 +253,51 @@ def _verbal_for_poi( return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов" +def _clean_tag(value: Any) -> str | None: + """Нормализовать строковое значение OSM-тега: trim + отбросить пустое/мусорное.""" + if not isinstance(value, str): + return None + s = value.strip() + if not s or s.lower() in {"yes", "no", "unknown", "fixme", "?"}: + return None + return s + + +def _poi_detail_from_tags(tags: dict[str, Any] | None) -> dict[str, str | None]: + """Извлечь человекочитаемые детали POI из OSM tags jsonb. + + Возвращает фиксированный набор ключей (всегда присутствуют, None если нет), + чтобы фронтенд-попап карты §1 показывал «максимум информации» по точке: + адрес, оператор/бренд, часы работы, сайт, телефон. + + Сайт/телефон берём из `contact:*` приоритетно, затем из «голых» тегов — + OSM по факту хранит и так, и так. Сайт не валидируем здесь (это делает + фронтенд через safeUrl при рендере href). + """ + # jsonb-колонка всегда приходит dict'ом, но тип допускает None — fail-safe. + t = tags if isinstance(tags, dict) else {} + street = _clean_tag(t.get("addr:street")) + house = _clean_tag(t.get("addr:housenumber")) + address = ", ".join(p for p in (street, house) if p) or None + + operator = _clean_tag(t.get("operator")) or _clean_tag(t.get("brand")) + opening_hours = _clean_tag(t.get("opening_hours")) + website = ( + _clean_tag(t.get("contact:website")) + or _clean_tag(t.get("website")) + or _clean_tag(t.get("url")) + ) + phone = _clean_tag(t.get("contact:phone")) or _clean_tag(t.get("phone")) + + return { + "address": address, + "operator": operator, + "opening_hours": opening_hours, + "website": website, + "phone": phone, + } + + def _apply_osrm_road_distances( pois: list[dict[str, Any]], *, @@ -1717,7 +1762,8 @@ def analyze_parcel( p.geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m, - last_osm_edit_date + last_osm_edit_date, + tags FROM osm_poi_ekb p WHERE ST_DWithin( p.geom::geography, @@ -1816,6 +1862,10 @@ def analyze_parcel( "last_edit": ( p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None ), + # «Максимум информации» по точке (#по запросу): адрес, оператор, + # часы работы, сайт, телефон — из OSM tags jsonb. Фронтенд-попап + # карты §1 показывает то, что не None. + **_poi_detail_from_tags(p.get("tags")), } ) # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен. diff --git a/backend/tests/api/v1/test_poi_detail_from_tags.py b/backend/tests/api/v1/test_poi_detail_from_tags.py new file mode 100644 index 00000000..60c45301 --- /dev/null +++ b/backend/tests/api/v1/test_poi_detail_from_tags.py @@ -0,0 +1,69 @@ +"""Тесты `_poi_detail_from_tags` / `_clean_tag` — обогащение POI-точек §1 карты +«максимумом информации» из OSM tags jsonb (адрес, оператор, часы, сайт, телефон). + +Покрывает: +(a) полный набор тегов → все поля заполнены, адрес склеен «улица, дом»; +(b) приоритет contact:* над «голым» website/phone, brand как fallback оператора; +(c) фиксированный набор ключей (всегда 5) с None при отсутствии тега; +(d) мусорные значения («yes», пустые строки, не-строки) отбрасываются. +""" + +from __future__ import annotations + +from app.api.v1.parcels import _clean_tag, _poi_detail_from_tags + +_KEYS = {"address", "operator", "opening_hours", "website", "phone"} + + +def test_full_tags_all_fields_populated() -> None: + out = _poi_detail_from_tags( + { + "addr:street": "улица Маяковского", + "addr:housenumber": "12", + "operator": 'ООО "Аптека+"', + "opening_hours": "Mo-Su 08:00-22:00", + "contact:website": "https://apteka.example", + "contact:phone": "+7 343 000-00-00", + } + ) + assert set(out) == _KEYS + assert out["address"] == "улица Маяковского, 12" + assert out["operator"] == 'ООО "Аптека+"' + assert out["opening_hours"] == "Mo-Su 08:00-22:00" + assert out["website"] == "https://apteka.example" + assert out["phone"] == "+7 343 000-00-00" + + +def test_contact_priority_and_brand_fallback() -> None: + out = _poi_detail_from_tags( + { + "brand": "Пятёрочка", # operator отсутствует → берём brand + "website": "http://bare.example", + "contact:website": "https://contact.example", # приоритетнее + "phone": "+7 000", + "contact:phone": "+7 111", # приоритетнее + } + ) + assert out["operator"] == "Пятёрочка" + assert out["website"] == "https://contact.example" + assert out["phone"] == "+7 111" + assert out["address"] is None + + +def test_fixed_keyset_with_none_when_absent() -> None: + for tags in (None, {}, {"amenity": "pharmacy"}): + out = _poi_detail_from_tags(tags) + assert set(out) == _KEYS + assert all(v is None for v in out.values()) + + +def test_house_only_address() -> None: + assert _poi_detail_from_tags({"addr:housenumber": "5А"})["address"] == "5А" + + +def test_clean_tag_drops_junk() -> None: + assert _clean_tag("yes") is None + assert _clean_tag(" ") is None + assert _clean_tag(None) is None + assert _clean_tag(123) is None + assert _clean_tag(" Реальное значение ") == "Реальное значение" diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 309fc7d4..43a1a70f 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -86,6 +86,14 @@ export const CATEGORY_STYLES: Record = { metro_stop: { color: "#1e40af", label: "Метро", radius: 8 }, }; +// Allowlist http(s) перед записью в href (XSS-защита, frontend.md). Возвращает +// null если схема не http/https — тогда показываем сайт как текст, не ссылку. +function safeHttpUrl(url: string | null | undefined): string | null { + if (!url) return null; + const t = url.trim(); + return t.startsWith("https://") || t.startsWith("http://") ? t : null; +} + // --------------------------------------------------------------------------- // Geometry centroid (bounding-box center — sufficient for zoom-to fit) // --------------------------------------------------------------------------- @@ -153,11 +161,7 @@ interface Props { // Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958). export type MarketLayerKey = - | "competitors" - | "pipeline" - | "risk" - | "opportunity" - | "redlines"; + "competitors" | "pipeline" | "risk" | "opportunity" | "redlines"; // --------------------------------------------------------------------------- // Map click handler sub-component (must be inside MapContainer) @@ -504,37 +508,97 @@ export function SiteMap({ if (!style) return []; return pois .filter((poi) => poi.lat !== 0 && poi.lon !== 0) - .map((poi, idx) => ( - - -
- {style.label} -
- {poi.name ?? без названия} -
- {poi.distance_m.toFixed(0)} м - {poi.last_edit && ( - <> -
- - OSM: {poi.last_edit} - - - )} -
-
-
- )); + .map((poi, idx) => { + const websiteUrl = safeHttpUrl(poi.website); + return ( + + +
+ {style.label} +
+ {poi.name ?? без названия} +
+ + {poi.distance_m.toFixed(0)} м от участка + + {poi.address && ( + <> +
+ + Адрес: {poi.address} + + + )} + {poi.operator && ( + <> +
+ + Оператор: {poi.operator} + + + )} + {poi.opening_hours && ( + <> +
+ + Часы: {poi.opening_hours} + + + )} + {poi.phone && ( + <> +
+ + {poi.phone} + + + )} + {websiteUrl ? ( + <> +
+ + Сайт + + + ) : ( + poi.website && ( + <> +
+ + {poi.website} + + + ) + )} + {poi.last_edit && ( + <> +
+ + OSM: {poi.last_edit} + + + )} +
+
+
+ ); + }); })} {/* Connection points layer — rendered on top of POI markers */} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index ffdea0e1..19234167 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -73,6 +73,13 @@ export interface ParcelAnalysisPoi { last_edit: string | null; lat: number; lon: number; + // «Максимум информации» по точке из OSM tags (backend `_poi_detail_from_tags`). + // Все опциональны — присутствуют только когда тег есть в OSM. + address?: string | null; + operator?: string | null; + opening_hours?: string | null; + website?: string | null; + phone?: string | null; } export interface ParcelAnalysisWeather { @@ -325,10 +332,7 @@ export interface Velocity { export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data"; export type GateVerdictLabel = - | "Можно" - | "Нельзя" - | "С ограничениями" - | "Нужна проверка"; + "Можно" | "Нельзя" | "С ограничениями" | "Нужна проверка"; export interface GateBlocker { code: string; @@ -359,10 +363,7 @@ export interface ParcelLocation { // P1 (#45) — physical suitability участка. // label — base value один из BaseLabel'ов либо combo "микро, " export type GeometrySuitabilityBaseLabel = - | "микро" - | "подходящий" - | "сложная форма" - | "слабо подходит"; + "микро" | "подходящий" | "сложная форма" | "слабо подходит"; export interface GeometrySuitability { data_available: boolean;