From a87a69af7c56909e7f68118f3fe324f758569366 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 5 Jun 2026 16:44:32 +0500 Subject: [PATCH] feat(analyze): expose competitor + pipeline lat/lon for map layers (#999) Add nullable lat/lon (EPSG:4326, 6 dp) to /analyze competitors[] and pipeline_24mo.top_objects[] so the frontend can plot Leaflet markers. Coords come from domrf_kn_objects.latitude/longitude (same source as distance_m). Purely additive: no existing field/shape changed. Frontend map layers follow in a separate PR. Part of EPIC #958 (958-B4). --- backend/app/api/v1/parcels.py | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 63a0ff38..833b1de7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -399,6 +399,36 @@ PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high PIPELINE_TOP_OBJECTS_LIMIT = 10 +def _coord_round(value: Any) -> float | None: + """#999 — привести координату (lat/lon) к float, округлённому до 6 dp. + + Источник — PostGIS latitude/longitude (float8/numeric → может прийти Decimal). + None/невалидное значение → None (graceful: объект без координат не ломает + ответ, фронт просто не рисует маркер). 6 dp ≈ 0.1м точности — достаточно + для карты. + """ + if value is None: + return None + try: + return round(float(value), 6) + except (TypeError, ValueError): + return None + + +def _competitor_with_coords(row: Any) -> dict[str, Any]: + """#999 (958-B4) — competitor-dict + nullable lat/lon (EPSG:4326). + + Additive shape: сохраняет ВСЕ существующие ключи competitor_rows через + {**dict(row)} (distance_m и пр. без изменений), затем перезаписывает lat/lon + округлёнными float|None (исходные SQL-алиасы lat/lon приходят сырыми + float/Decimal). Никакие текущие поля не удаляются и не меняются. + """ + out = dict(row) + out["lat"] = _coord_round(out.get("lat")) + out["lon"] = _coord_round(out.get("lon")) + return out + + def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: """D4 (#36) — собрать pipeline_24mo aggregate из rows domrf_kn_objects. @@ -488,6 +518,11 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: # упадёт в JSON serialization). "ready_dt": ready_dt.isoformat() if ready_dt else None, "distance_m": round(float(distance_m)) if distance_m is not None else None, + # #999 (958-B4): lat/lon (EPSG:4326) для Leaflet-слоёв. Источник — + # та же geom (latitude/longitude), что и distance_m. Nullable: объект + # без координат → None (latest_obj фильтрует, но guard на всякий). + "lat": _coord_round(r.get("lat")), + "lon": _coord_round(r.get("lon")), } ) @@ -1580,6 +1615,8 @@ def analyze_parcel( o.district_name, o.site_status, o.ready_dt, + o.latitude AS lat, + o.longitude AS lon, p.avg_price_per_m2_rub, p.avg_area_pd, p.units_sold, @@ -1636,6 +1673,8 @@ def analyze_parcel( ) AS obj_class, flat_count, ready_dt, + o.latitude AS lat, + o.longitude AS lon, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography @@ -2648,7 +2687,11 @@ def analyze_parcel( "ekb_center": {"lat": EKB_CENTER_LAT, "lon": EKB_CENTER_LON}, "note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0", }, - "competitors": [dict(c) for c in competitor_rows], + # #999 (958-B4): competitors несут lat/lon (EPSG:4326) для Leaflet-слоёв. + # Additive — все существующие поля сохранены через {**dict(c)}; lat/lon + # округлены до 6 dp (float|None). latest_obj фильтрует latitude IS NOT NULL, + # поэтому здесь координаты обычно заполнены, но _coord_round graceful к None. + "competitors": [_competitor_with_coords(c) for c in competitor_rows], # OBJ-3 fix: aggregate market metrics — только non-null competitors. # ЖК без objective_mapping остаются на карте (competitors list), # но исключены из avg/velocity/top_sellers расчётов. -- 2.45.3