feat(site-finder): §1 POI-точки карты показывают максимум информации #2110

Merged
bot-backend merged 1 commit from feat/poi-popup-max-info into main 2026-06-30 12:42:14 +00:00
4 changed files with 229 additions and 45 deletions

View file

@ -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 шуму не нужен.

View file

@ -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(" Реальное значение ") == "Реальное значение"

View file

@ -86,6 +86,14 @@ export const CATEGORY_STYLES: Record<string, CategoryStyle> = {
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) => (
<CircleMarker
key={`${cat}-${idx}`}
center={[poi.lat, poi.lon]}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.85,
weight: 1.5,
}}
>
<Popup>
<div style={{ fontSize: 13, lineHeight: 1.5 }}>
<strong>{style.label}</strong>
<br />
{poi.name ?? <em>без названия</em>}
<br />
{poi.distance_m.toFixed(0)} м
{poi.last_edit && (
<>
<br />
<span style={{ color: "#6b7280", fontSize: 11 }}>
OSM: {poi.last_edit}
</span>
</>
)}
</div>
</Popup>
</CircleMarker>
));
.map((poi, idx) => {
const websiteUrl = safeHttpUrl(poi.website);
return (
<CircleMarker
key={`${cat}-${idx}`}
center={[poi.lat, poi.lon]}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.85,
weight: 1.5,
}}
>
<Popup>
<div
style={{ fontSize: 13, lineHeight: 1.5, minWidth: 160 }}
>
<strong>{style.label}</strong>
<br />
{poi.name ?? <em>без названия</em>}
<br />
<span style={{ color: "#374151" }}>
{poi.distance_m.toFixed(0)} м от участка
</span>
{poi.address && (
<>
<br />
<span style={{ color: "#374151" }}>
Адрес: {poi.address}
</span>
</>
)}
{poi.operator && (
<>
<br />
<span style={{ color: "#374151" }}>
Оператор: {poi.operator}
</span>
</>
)}
{poi.opening_hours && (
<>
<br />
<span style={{ color: "#374151" }}>
Часы: {poi.opening_hours}
</span>
</>
)}
{poi.phone && (
<>
<br />
<a href={`tel:${poi.phone.replace(/[^\d+]/g, "")}`}>
{poi.phone}
</a>
</>
)}
{websiteUrl ? (
<>
<br />
<a
href={websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
Сайт
</a>
</>
) : (
poi.website && (
<>
<br />
<span style={{ color: "#374151" }}>
{poi.website}
</span>
</>
)
)}
{poi.last_edit && (
<>
<br />
<span style={{ color: "#6b7280", fontSize: 11 }}>
OSM: {poi.last_edit}
</span>
</>
)}
</div>
</Popup>
</CircleMarker>
);
});
})}
{/* Connection points layer — rendered on top of POI markers */}

View file

@ -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 "микро, <first-penalty>"
export type GeometrySuitabilityBaseLabel =
| "микро"
| "подходящий"
| "сложная форма"
| "слабо подходит";
"микро" | "подходящий" | "сложная форма" | "слабо подходит";
export interface GeometrySuitability {
data_available: boolean;