feat(analyze): competitor + pipeline lat/lon для слоёв карты (#999 Step 1) #1074

Merged
bot-backend merged 1 commit from feat/analyze-market-coords-999 into main 2026-06-05 11:45:06 +00:00

View file

@ -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 расчётов.