feat(site-finder): v3.1 — cad_parcels_geom, analyze fallback, POI lat/lon, OSM expand

- nspd_geo: add _save_parcel() for thematic_id=1 → cad_parcels_geom (UPSERT,
  ST_Transform from Web Mercator); _persist_target now handles 1/2/5
- parcels.py: analyze endpoint geom lookup extended with cad_parcels_geom as
  3rd fallback source (after cad_quarters_geom, cad_buildings); both SELECT
  and WKT subqueries updated
- parcels.py: POI score_breakdown items now include lat/lon for map markers
- poi_loader: OSM_CATEGORIES expanded — college+university→school,
  hypermarket→shop_supermarket; coverage +3 tag pairs
This commit is contained in:
lekss361 2026-05-11 19:52:19 +03:00
parent ea3d58fa7d
commit f419900968
3 changed files with 48 additions and 4 deletions

View file

@ -68,6 +68,12 @@ def analyze_parcel(
'cad_building' AS source
FROM cad_buildings b
WHERE b.cad_num = :c
UNION ALL
SELECT ST_AsGeoJSON(p.geom) AS geom_geojson,
p.geom AS geom_wkb,
'cad_parcel' AS source
FROM cad_parcels_geom p
WHERE p.cad_num = :c
LIMIT 1
"""),
{"c": cad_num},
@ -95,6 +101,8 @@ def analyze_parcel(
SELECT g.geom FROM cad_quarters_geom g WHERE g.cad_number = :c
UNION ALL
SELECT b.geom FROM cad_buildings b WHERE b.cad_num = :c
UNION ALL
SELECT p.geom FROM cad_parcels_geom p WHERE p.cad_num = :c
) g
LIMIT 1
"""),
@ -171,6 +179,8 @@ def analyze_parcel(
{
"name": p["name"],
"distance_m": round(float(p["distance_m"])),
"lat": float(p["lat"]) if p["lat"] is not None else None,
"lon": float(p["lon"]) if p["lon"] is not None else None,
"last_edit": (
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
),

View file

@ -21,20 +21,23 @@ EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Маппинг OSM-тег → нормализованная category
OSM_CATEGORIES: dict[tuple[str, str], str] = {
# amenity tags
# amenity tags — школы расширены (school/college/university)
("amenity", "school"): "school",
("amenity", "college"): "school",
("amenity", "university"): "school",
("amenity", "kindergarten"): "kindergarten",
("amenity", "pharmacy"): "pharmacy",
("amenity", "hospital"): "hospital",
("amenity", "clinic"): "hospital",
# shop tags
# shop tags — supermarket расширен
("shop", "mall"): "shop_mall",
("shop", "supermarket"): "shop_supermarket",
("shop", "hypermarket"): "shop_supermarket",
("shop", "convenience"): "shop_small",
("shop", "bakery"): "shop_small",
# leisure
("leisure", "park"): "park",
# transit (railway tram_stop приоритет над public_transport)
# transit
("railway", "tram_stop"): "tram_stop",
("highway", "bus_stop"): "bus_stop",
# метро (одна линия в ЕКБ, но добавляем для полноты)

View file

@ -207,13 +207,44 @@ def _save_building(db: Session, payload: dict, cad_num: str) -> int:
return n
def _save_parcel(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для земельного участка и UPSERT'ит в cad_parcels_geom."""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
f = feats[0]
geom_geojson = f.get("geometry")
if not geom_geojson:
return 0
db.execute(
text(
"""
INSERT INTO cad_parcels_geom (cad_num, geom, raw_props, fetched_at)
VALUES (:cad, ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326),
CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_num) DO UPDATE
SET geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cad": cad_num,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"props": json.dumps(f.get("properties") or {}, ensure_ascii=False),
},
)
return 1
def _persist_target(db: Session, thematic_id: int, payload: dict, cad_num: str) -> tuple[int, str]:
"""Returns (n_features_saved, table_name). Полиморфно по thematic_id."""
if thematic_id == 1:
return _save_parcel(db, payload, cad_num), "cad_parcels_geom"
if thematic_id == 2:
return _save_quarter(db, payload, cad_num), "cad_quarters_geom"
if thematic_id == 5:
return _save_building(db, payload, cad_num), "cad_buildings"
# Для parcel (1) пока нет нашей normalized-таблицы — только raw в targets.result_features
return 0, ""