gendesign/backend/app/services/site_finder/custom_pois.py
bot-backend d3f3370bed
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m45s
Deploy / deploy (push) Successful in 1m54s
fix(site_finder): SAVEPOINT supply/market + convert 2 bare rollbacks (#2464 Wave 2) (#2469)
2026-07-08 06:46:09 +00:00

281 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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:
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# остальных запросов analyze_parcel на этой же сессии.
with db.begin_nested():
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 []