"""CRUD-сервис для user_custom_pois (#254). API: - create_custom_poi(db, user_id, payload) → CustomPoiOut - list_custom_pois(db, user_id, parcel_cad?) → list[CustomPoiOut] - get_custom_poi(db, poi_id, user_id) → CustomPoiOut | None - update_custom_poi(db, poi_id, user_id, payload) → CustomPoiOut | None - delete_custom_poi(db, poi_id, user_id) → bool - get_overlaps_for_scoring(db, parcel_geom_wkt, user_id, parcel_cad?) → list[dict] Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate logger = logging.getLogger(__name__) # ── SQL ──────────────────────────────────────────────────────────────────────── _SELECT_COLS = """ id, user_id, parcel_cad, name, category, weight, lon, lat, notes, created_at, updated_at """ _INSERT_SQL = f""" INSERT INTO user_custom_pois (user_id, parcel_cad, name, category, weight, lon, lat, notes) VALUES (:user_id, :parcel_cad, :name, :category, CAST(:weight AS real), CAST(:lon AS double precision), CAST(:lat AS double precision), :notes) RETURNING {_SELECT_COLS} """ _SELECT_BY_USER_ALL = f""" SELECT {_SELECT_COLS} FROM user_custom_pois WHERE user_id = :user_id ORDER BY created_at DESC """ _SELECT_BY_USER_PARCEL = f""" SELECT {_SELECT_COLS} FROM user_custom_pois WHERE user_id = :user_id AND (parcel_cad = :parcel_cad OR parcel_cad IS NULL) ORDER BY created_at DESC """ _SELECT_BY_ID = f""" SELECT {_SELECT_COLS} FROM user_custom_pois WHERE id = :poi_id AND user_id = :user_id """ _DELETE_SQL = """ DELETE FROM user_custom_pois WHERE id = :poi_id AND user_id = :user_id RETURNING id """ # Запрос для scoring: custom POI в радиусе 1 км от центроида участка. # Возвращает POI (global + parcel-specific) пользователя с расстоянием. _OVERLAPS_SQL = """ SELECT p.id, p.name, p.category, p.weight, p.lon, p.lat, ST_Distance( p.geom, ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography ) AS distance_m FROM user_custom_pois p WHERE p.user_id = :user_id AND (p.parcel_cad IS NULL OR p.parcel_cad = :parcel_cad) AND ST_DWithin( p.geom, ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography, 1000 ) ORDER BY distance_m ASC """ # ── Row mapper ───────────────────────────────────────────────────────────────── def _row_to_out(r: Any) -> CustomPoiOut: return CustomPoiOut( id=int(r["id"]), user_id=r["user_id"], parcel_cad=r["parcel_cad"], name=r["name"], category=r["category"], weight=float(r["weight"]), lon=float(r["lon"]), lat=float(r["lat"]), notes=r["notes"], created_at=r["created_at"], updated_at=r["updated_at"], ) # ── CRUD ─────────────────────────────────────────────────────────────────────── def create_custom_poi(db: Any, user_id: str, payload: CustomPoiCreate) -> CustomPoiOut: """Создать кастомную POI точку для пользователя.""" row = ( db.execute( text(_INSERT_SQL), { "user_id": user_id, "parcel_cad": payload.parcel_cad, "name": payload.name, "category": payload.category, "weight": payload.weight, "lon": payload.lon, "lat": payload.lat, "notes": payload.notes, }, ) .mappings() .first() ) db.commit() assert row is not None, "INSERT RETURNING вернул пустой результат" logger.info( "custom_poi created: id=%s user=%s parcel=%s weight=%s", row["id"], user_id, payload.parcel_cad, payload.weight, ) return _row_to_out(row) def list_custom_pois(db: Any, user_id: str, parcel_cad: str | None = None) -> list[CustomPoiOut]: """Вернуть custom POI пользователя. Если parcel_cad задан — возвращает global (parcel_cad IS NULL) + parcel-specific. Если parcel_cad=None — возвращает все POI пользователя. """ if parcel_cad is not None: rows = ( db.execute( text(_SELECT_BY_USER_PARCEL), {"user_id": user_id, "parcel_cad": parcel_cad}, ) .mappings() .all() ) else: rows = db.execute(text(_SELECT_BY_USER_ALL), {"user_id": user_id}).mappings().all() return [_row_to_out(r) for r in rows] def get_custom_poi(db: Any, poi_id: int, user_id: str) -> CustomPoiOut | None: """Вернуть одну POI по id (scoped к user_id).""" row = db.execute(text(_SELECT_BY_ID), {"poi_id": poi_id, "user_id": user_id}).mappings().first() if row is None: return None return _row_to_out(row) def update_custom_poi( db: Any, poi_id: int, user_id: str, payload: CustomPoiUpdate ) -> CustomPoiOut | None: """PATCH-style обновление кастомной POI. Возвращает None если не найдена.""" existing = get_custom_poi(db, poi_id, user_id) if existing is None: return None sets: list[str] = ["updated_at = NOW()"] params: dict[str, Any] = {"poi_id": poi_id, "user_id": user_id} if payload.name is not None: sets.append("name = :name") params["name"] = payload.name if payload.category is not None: sets.append("category = :category") params["category"] = payload.category if payload.weight is not None: sets.append("weight = CAST(:weight AS real)") params["weight"] = payload.weight # lon/lat изменяем только вместе — geom GENERATED ALWAYS пересчитается автоматически if payload.lon is not None: sets.append("lon = CAST(:lon AS double precision)") params["lon"] = payload.lon if payload.lat is not None: sets.append("lat = CAST(:lat AS double precision)") params["lat"] = payload.lat if payload.parcel_cad is not None: sets.append("parcel_cad = :parcel_cad") params["parcel_cad"] = payload.parcel_cad if payload.notes is not None: sets.append("notes = :notes") params["notes"] = payload.notes if len(sets) > 1: db.execute( text( f"UPDATE user_custom_pois SET {', '.join(sets)}" " WHERE id = :poi_id AND user_id = :user_id" ), params, ) db.commit() return get_custom_poi(db, poi_id, user_id) def delete_custom_poi(db: Any, poi_id: int, user_id: str) -> bool: """Удалить кастомную POI. Возвращает True если удалена, False если не найдена.""" result = db.execute( text(_DELETE_SQL), {"poi_id": poi_id, "user_id": user_id}, ).first() if result is None: return False db.commit() logger.info("custom_poi deleted: id=%s user=%s", poi_id, user_id) return True def get_overlaps_for_scoring( db: Any, parcel_geom_wkt: str, user_id: str, parcel_cad: str | None = None, ) -> list[dict[str, Any]]: """Вернуть custom POI в радиусе 1 км с расстоянием для scoring. Включает global POI (parcel_cad IS NULL) + parcel-specific если parcel_cad задан. Distance decay применяется в parcels.py аналогично OSM POI. Returns: list[dict] с ключами: id, name, category, weight, lon, lat, distance_m """ _parcel_cad = parcel_cad or "" try: rows = ( db.execute( text(_OVERLAPS_SQL), {"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad}, ) .mappings() .all() ) return [ { "id": int(r["id"]), "name": r["name"], "category": r["category"], "weight": float(r["weight"]), "lon": float(r["lon"]), "lat": float(r["lat"]), "distance_m": float(r["distance_m"]), } for r in rows ] except Exception as e: logger.warning("get_overlaps_for_scoring failed user=%s: %s", user_id, e) return []