gendesign/backend/app/services/site_finder/custom_pois.py
bot-backend 73739671a9
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
fix(site_finder): SAVEPOINT-isolate supply/market db.execute + kill 2 bare rollbacks (#2464)
Convert two bare db.rollback() calls on the shared per-request session
(best_layouts _fetch_supply_only_layouts + Step 2b core-attribution) to
db.begin_nested() — a bare rollback there discards ALL uncommitted work of
the request and orphans the outer SessionTransaction. Also wrap the swallow
sites in market_metrics (_query_stock, _query_sales_window, _price_sensitivity,
compute_offer_price_trend), best_layouts step-5 supply, future_supply
_query_supply_rows, own_portfolio _query_current/_query_future, and
custom_pois get_overlaps_for_scoring so a failed statement no longer poisons
the shared tx for later steps. Per-site regression tests assert fallback
preserved, begin_nested used, bare rollback NOT called, and session usable
after failure.

Refs #2464 (cluster A session-poisoning, Wave 2).
EOF2
echo "HANDLED"
2026-07-08 11:29:31 +05: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 []