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} баллов"
|
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(
|
def _apply_osrm_road_distances(
|
||||||
pois: list[dict[str, Any]],
|
pois: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -1717,7 +1762,8 @@ def analyze_parcel(
|
||||||
p.geom::geography,
|
p.geom::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
) AS distance_m,
|
) AS distance_m,
|
||||||
last_osm_edit_date
|
last_osm_edit_date,
|
||||||
|
tags
|
||||||
FROM osm_poi_ekb p
|
FROM osm_poi_ekb p
|
||||||
WHERE ST_DWithin(
|
WHERE ST_DWithin(
|
||||||
p.geom::geography,
|
p.geom::geography,
|
||||||
|
|
@ -1816,6 +1862,10 @@ def analyze_parcel(
|
||||||
"last_edit": (
|
"last_edit": (
|
||||||
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
|
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 шуму не нужен.
|
# 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 },
|
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)
|
// Geometry centroid (bounding-box center — sufficient for zoom-to fit)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -153,11 +161,7 @@ interface Props {
|
||||||
|
|
||||||
// Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958).
|
// Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958).
|
||||||
export type MarketLayerKey =
|
export type MarketLayerKey =
|
||||||
| "competitors"
|
"competitors" | "pipeline" | "risk" | "opportunity" | "redlines";
|
||||||
| "pipeline"
|
|
||||||
| "risk"
|
|
||||||
| "opportunity"
|
|
||||||
| "redlines";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Map click handler sub-component (must be inside MapContainer)
|
// Map click handler sub-component (must be inside MapContainer)
|
||||||
|
|
@ -504,37 +508,97 @@ export function SiteMap({
|
||||||
if (!style) return [];
|
if (!style) return [];
|
||||||
return pois
|
return pois
|
||||||
.filter((poi) => poi.lat !== 0 && poi.lon !== 0)
|
.filter((poi) => poi.lat !== 0 && poi.lon !== 0)
|
||||||
.map((poi, idx) => (
|
.map((poi, idx) => {
|
||||||
<CircleMarker
|
const websiteUrl = safeHttpUrl(poi.website);
|
||||||
key={`${cat}-${idx}`}
|
return (
|
||||||
center={[poi.lat, poi.lon]}
|
<CircleMarker
|
||||||
radius={style.radius}
|
key={`${cat}-${idx}`}
|
||||||
pathOptions={{
|
center={[poi.lat, poi.lon]}
|
||||||
color: style.color,
|
radius={style.radius}
|
||||||
fillColor: style.color,
|
pathOptions={{
|
||||||
fillOpacity: 0.85,
|
color: style.color,
|
||||||
weight: 1.5,
|
fillColor: style.color,
|
||||||
}}
|
fillOpacity: 0.85,
|
||||||
>
|
weight: 1.5,
|
||||||
<Popup>
|
}}
|
||||||
<div style={{ fontSize: 13, lineHeight: 1.5 }}>
|
>
|
||||||
<strong>{style.label}</strong>
|
<Popup>
|
||||||
<br />
|
<div
|
||||||
{poi.name ?? <em>без названия</em>}
|
style={{ fontSize: 13, lineHeight: 1.5, minWidth: 160 }}
|
||||||
<br />
|
>
|
||||||
{poi.distance_m.toFixed(0)} м
|
<strong>{style.label}</strong>
|
||||||
{poi.last_edit && (
|
<br />
|
||||||
<>
|
{poi.name ?? <em>без названия</em>}
|
||||||
<br />
|
<br />
|
||||||
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
<span style={{ color: "#374151" }}>
|
||||||
OSM: {poi.last_edit}
|
{poi.distance_m.toFixed(0)} м от участка
|
||||||
</span>
|
</span>
|
||||||
</>
|
{poi.address && (
|
||||||
)}
|
<>
|
||||||
</div>
|
<br />
|
||||||
</Popup>
|
<span style={{ color: "#374151" }}>
|
||||||
</CircleMarker>
|
Адрес: {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 */}
|
{/* Connection points layer — rendered on top of POI markers */}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,13 @@ export interface ParcelAnalysisPoi {
|
||||||
last_edit: string | null;
|
last_edit: string | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: 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 {
|
export interface ParcelAnalysisWeather {
|
||||||
|
|
@ -325,10 +332,7 @@ export interface Velocity {
|
||||||
export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data";
|
export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data";
|
||||||
|
|
||||||
export type GateVerdictLabel =
|
export type GateVerdictLabel =
|
||||||
| "Можно"
|
"Можно" | "Нельзя" | "С ограничениями" | "Нужна проверка";
|
||||||
| "Нельзя"
|
|
||||||
| "С ограничениями"
|
|
||||||
| "Нужна проверка";
|
|
||||||
|
|
||||||
export interface GateBlocker {
|
export interface GateBlocker {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
@ -359,10 +363,7 @@ export interface ParcelLocation {
|
||||||
// P1 (#45) — physical suitability участка.
|
// P1 (#45) — physical suitability участка.
|
||||||
// label — base value один из BaseLabel'ов либо combo "микро, <first-penalty>"
|
// label — base value один из BaseLabel'ов либо combo "микро, <first-penalty>"
|
||||||
export type GeometrySuitabilityBaseLabel =
|
export type GeometrySuitabilityBaseLabel =
|
||||||
| "микро"
|
"микро" | "подходящий" | "сложная форма" | "слабо подходит";
|
||||||
| "подходящий"
|
|
||||||
| "сложная форма"
|
|
||||||
| "слабо подходит";
|
|
||||||
|
|
||||||
export interface GeometrySuitability {
|
export interface GeometrySuitability {
|
||||||
data_available: boolean;
|
data_available: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue