From f419900968ecc81c1a02444bc272324f81ed7c9d Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 19:52:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20v3.1=20=E2=80=94=20cad=5Fp?= =?UTF-8?q?arcels=5Fgeom,=20analyze=20fallback,=20POI=20lat/lon,=20OSM=20e?= =?UTF-8?q?xpand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/v1/parcels.py | 10 ++++++ .../app/services/site_finder/poi_loader.py | 9 +++-- backend/app/workers/tasks/nspd_geo.py | 33 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 903623de..fc1bc919 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 ), diff --git a/backend/app/services/site_finder/poi_loader.py b/backend/app/services/site_finder/poi_loader.py index 821702dd..f3cd1d2d 100644 --- a/backend/app/services/site_finder/poi_loader.py +++ b/backend/app/services/site_finder/poi_loader.py @@ -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", # метро (одна линия в ЕКБ, но добавляем для полноты) diff --git a/backend/app/workers/tasks/nspd_geo.py b/backend/app/workers/tasks/nspd_geo.py index c3000b23..f0b17c74 100644 --- a/backend/app/workers/tasks/nspd_geo.py +++ b/backend/app/workers/tasks/nspd_geo.py @@ -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, ""