feat(site-finder): §1 POI-точки карты показывают максимум информации (#2110)
This commit is contained in:
parent
daf4ebefc2
commit
b024864518
4 changed files with 229 additions and 45 deletions
|
|
@ -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 шуму не нужен.
|
||||
|
|
|
|||
69
backend/tests/api/v1/test_poi_detail_from_tags.py
Normal file
69
backend/tests/api/v1/test_poi_detail_from_tags.py
Normal 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(" Реальное значение ") == "Реальное значение"
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue