From 36738a046e577a46a596c24128adc651a5214069 Mon Sep 17 00:00:00 2001 From: Egor Stolbov Date: Wed, 6 May 2026 23:41:15 +0300 Subject: [PATCH 1/2] fix: sqlalchemy throwing because of text method --- src/routers/forecasts.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routers/forecasts.py b/src/routers/forecasts.py index 9c06687..5bdb830 100644 --- a/src/routers/forecasts.py +++ b/src/routers/forecasts.py @@ -3,7 +3,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import text +from sqlalchemy import text, func from sqlalchemy.orm import Session from ..database import get_db @@ -135,8 +135,8 @@ def list_forecasts( # Выбираем прогноз с predicted_for ближайшим к at (не позже чем at + 30 мин) latest_sq = ( db.query( - Forecast.zone_id, - text("MAX(generated_at) AS max_gen"), + Forecast.zone_id.label("zone_id"), + func.max(Forecast.generated_at).label("max_gen"), ) .filter(Forecast.predicted_for <= at) .group_by(Forecast.zone_id) @@ -164,9 +164,9 @@ def list_forecasts( elif latest_model_only: latest_sq = ( db.query( - Forecast.zone_id, - Forecast.predicted_for, - text("MAX(generated_at) AS max_gen"), + Forecast.zone_id.label("zone_id"), + Forecast.predicted_for.label("predicted_for"), + func.max(Forecast.generated_at).label("max_gen"), ) .group_by(Forecast.zone_id, Forecast.predicted_for) .subquery() From 547dc9511ca6246e714ae6c999faf6e479af8d26 Mon Sep 17 00:00:00 2001 From: Egor Stolbov Date: Thu, 7 May 2026 00:06:02 +0300 Subject: [PATCH 2/2] feat: add routing --- src/db_models.py | 45 +++++ src/dependencies.py | 5 +- src/routers/routing.py | 374 +++++++++++++++++++++++++++++++++++++++ src/schemas/routing.py | 129 ++++++++++++++ src/services/__init__.py | 0 src/services/routing.py | 261 +++++++++++++++++++++++++++ 6 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 src/routers/routing.py create mode 100644 src/schemas/routing.py create mode 100644 src/services/__init__.py create mode 100644 src/services/routing.py diff --git a/src/db_models.py b/src/db_models.py index 62512d8..ee92b8b 100644 --- a/src/db_models.py +++ b/src/db_models.py @@ -346,3 +346,48 @@ class Forecast(Base): def __repr__(self) -> str: return f"" + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +class RouteStatus(str, enum.Enum): + active = "active" + completed = "completed" + cancelled = "cancelled" + replaced = "replaced" + + +class RouteMode(str, enum.Enum): + find_parking = "find_parking" + route_to_destination = "route_to_destination" + + +class Route(Base): + __tablename__ = "routes" + + route_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False) + mode = Column(Enum(RouteMode, name="route_modes"), nullable=False) + provider = Column(String(50), nullable=False, default="internal") + origin_latitude = Column(Double, nullable=False) + origin_longitude = Column(Double, nullable=False) + destination_latitude = Column(Double, nullable=True) + destination_longitude = Column(Double, nullable=True) + selected_zone_id = Column(Integer, ForeignKey("parking_zones.parking_zone_id", + ondelete="SET NULL"), nullable=True) + selected_candidate = Column(JSONB, nullable=True) # снапшот RouteCandidate + eta_seconds = Column(Integer, nullable=True) + arrival_time = Column(DateTime(timezone=True), nullable=True) + polyline = Column(Text, nullable=True) + deeplink_url = Column(Text, nullable=True) + status = Column(Enum(RouteStatus, name="route_statuses"), + nullable=False, default=RouteStatus.active) + created_at = Column(DateTime(timezone=True), default=_now) + updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now) + + user = relationship("User", foreign_keys=[user_id]) + selected_zone = relationship("ParkingZone", foreign_keys=[selected_zone_id]) + + def __repr__(self) -> str: + return f"" \ No newline at end of file diff --git a/src/dependencies.py b/src/dependencies.py index f3ce234..530c845 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -90,7 +90,10 @@ def decode_access_token(token: str) -> int: "feedback.create", "sources.view", "occupancy.view", - "forecasts.view" + "forecasts.view", + "routing.create", + "routing.view", + "routing.delete" }) BASE_ADMIN_PERMISSIONS: frozenset[str] = frozenset({ diff --git a/src/routers/routing.py b/src/routers/routing.py new file mode 100644 index 0000000..1b53374 --- /dev/null +++ b/src/routers/routing.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..db_models import GlobalRole, ParkingZone, Route, RouteMode, RouteStatus, User +from ..dependencies import require +from ..schemas.routing import ( + CreateRouteRequest, + GeoPoint, + RouteCandidate, + RouteListResponse, + RouteResponse, + SearchRoutingRequest, + SearchRoutingResponse, + UpdateRouteRequest, +) +from ..services.routing import build_deeplink, find_candidates + +router = APIRouter(prefix="/routing", tags=["Routing"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _serialize_route(r: Route) -> RouteResponse: + candidate: RouteCandidate | None = None + if r.selected_candidate: + candidate = RouteCandidate.model_validate(r.selected_candidate) + + destination: GeoPoint | None = None + if r.destination_latitude is not None and r.destination_longitude is not None: + destination = GeoPoint( + latitude=r.destination_latitude, + longitude=r.destination_longitude, + ) + + return RouteResponse( + route_id=r.route_id, + user_id=r.user_id, + mode=r.mode.value, + provider=r.provider, + origin=GeoPoint(latitude=r.origin_latitude, longitude=r.origin_longitude), + destination=destination, + selected_zone_id=r.selected_zone_id, + selected_candidate=candidate, + eta_seconds=r.eta_seconds, + arrival_time=r.arrival_time, + polyline=r.polyline, + deeplink_url=r.deeplink_url, + status=r.status.value, + created_at=r.created_at, + updated_at=r.updated_at, + ) + + +def _get_route_or_404(db: Session, route_id: int) -> Route: + route = db.query(Route).filter(Route.route_id == route_id).one_or_none() + if route is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": "Route not found"}, + ) + return route + + +def _assert_owner_or_admin(route: Route, current_user: User) -> None: + """Пользователь может видеть/менять только свои маршруты; admin — любые.""" + if current_user.global_role != GlobalRole.admin and route.user_id != current_user.user_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={"error_description": "Access denied: not your route"}, + ) + + +def _build_candidate_from_zone( + zone: ParkingZone, + origin: GeoPoint, + destination: GeoPoint | None, + db: Session, + use_forecast: bool, +) -> RouteCandidate: + """Строим одного кандидата для конкретной зоны (используется при PUT с новым zone_id).""" + candidates = find_candidates( + db=db, + origin=origin, + destination=destination, + mode="find_parking", + max_pay=None, + min_free_count=None, + min_confidence=None, + max_distance_to_destination_meters=None, + max_duration_from_origin_seconds=None, + include_accessible=None, + use_forecast=use_forecast, + limit=1, + selected_zone_id=zone.parking_zone_id, + ) + if not candidates: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": "Cannot build route to the selected zone"}, + ) + return candidates[0] + + +# --------------------------------------------------------------------------- +# POST /routing/search — поиск без сохранения +# --------------------------------------------------------------------------- + +@router.post("/search", response_model=SearchRoutingResponse) +def search_routing( + body: SearchRoutingRequest, + current_user: Annotated[User, require("routing.create")], + db: Annotated[Session, Depends(get_db)], +): + candidates = find_candidates( + db=db, + origin=body.origin, + destination=body.destination, + mode=body.mode, + max_pay=body.max_pay, + min_free_count=body.min_free_count, + min_confidence=body.min_confidence, + max_distance_to_destination_meters=body.max_distance_to_destination_meters, + max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, + include_accessible=body.include_accessible, + use_forecast=body.use_forecast, + limit=body.limit, + ) + + selected_zone_id = candidates[0].zone_id if candidates else None + + return SearchRoutingResponse( + mode=body.mode, + provider=body.provider, + generated_at=datetime.now(timezone.utc), + selected_zone_id=selected_zone_id, + total_candidates=len(candidates), + candidates=candidates, + ) + + +# --------------------------------------------------------------------------- +# POST /routing/new — построение и сохранение маршрута +# --------------------------------------------------------------------------- + +@router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) +def create_route( + body: CreateRouteRequest, + current_user: Annotated[User, require("routing.create")], + db: Annotated[Session, Depends(get_db)], +): + candidates = find_candidates( + db=db, + origin=body.origin, + destination=body.destination, + mode=body.mode, + max_pay=body.max_pay, + min_free_count=body.min_free_count, + min_confidence=body.min_confidence, + max_distance_to_destination_meters=body.max_distance_to_destination_meters, + max_duration_from_origin_seconds=body.max_duration_from_origin_seconds, + include_accessible=body.include_accessible, + use_forecast=body.use_forecast, + limit=body.limit, + selected_zone_id=body.selected_zone_id, + ) + + if not candidates: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": "No suitable parking zones found"}, + ) + + best = candidates[0] + now = datetime.now(timezone.utc) + arrival_time = best.predicted_for_arrival + + # Deeplink до выбранной зоны + zone = db.query(ParkingZone).filter( + ParkingZone.parking_zone_id == best.zone_id + ).one_or_none() + z_lat = zone.geometry["coordinates"][0][0][1] if zone else best.zone_id + z_lon = zone.geometry["coordinates"][0][0][0] if zone else best.zone_id + try: + coords = zone.geometry["coordinates"][0] + z_lat = sum(c[1] for c in coords) / len(coords) + z_lon = sum(c[0] for c in coords) / len(coords) + except Exception: + z_lat, z_lon = 0.0, 0.0 + + deeplink = build_deeplink(body.provider, z_lat, z_lon) + + route = Route( + user_id=current_user.user_id, + mode=RouteMode(body.mode), + provider=body.provider, + origin_latitude=body.origin.latitude, + origin_longitude=body.origin.longitude, + destination_latitude=body.destination.latitude if body.destination else None, + destination_longitude=body.destination.longitude if body.destination else None, + selected_zone_id=best.zone_id, + selected_candidate=best.model_dump(mode="json"), + eta_seconds=best.duration_from_origin_seconds, + arrival_time=arrival_time, + polyline=None, + deeplink_url=deeplink, + status=RouteStatus.active, + created_at=now, + updated_at=now, + ) + db.add(route) + db.commit() + db.refresh(route) + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# GET /routing — маршруты текущего пользователя +# --------------------------------------------------------------------------- + +@router.get("", response_model=RouteListResponse) +def list_routes( + current_user: Annotated[User, require("routing.view")], + db: Annotated[Session, Depends(get_db)], + route_status: str | None = None, + mode: str | None = None, + top: int = 20, + offset: int = 0, +): + query = db.query(Route) + + # Обычный пользователь видит только свои; admin — все + if current_user.global_role != GlobalRole.admin: + query = query.filter(Route.user_id == current_user.user_id) + + if route_status is not None: + try: + query = query.filter(Route.status == RouteStatus(route_status)) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": f"Unknown status: {route_status}"}, + ) + if mode is not None: + try: + query = query.filter(Route.mode == RouteMode(mode)) + except ValueError: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"error_description": f"Unknown mode: {mode}"}, + ) + + total = query.count() + routes = query.order_by(Route.created_at.desc()).offset(offset).limit(top).all() + + return RouteListResponse( + items=[_serialize_route(r) for r in routes], + total=total, + top=top, + offset=offset, + ) + + +# --------------------------------------------------------------------------- +# GET /routing/{route_id} +# --------------------------------------------------------------------------- + +@router.get("/{route_id}", response_model=RouteResponse) +def get_route( + route_id: int, + current_user: Annotated[User, require("routing.view")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# PUT /routing/{route_id} +# --------------------------------------------------------------------------- + +@router.put("/{route_id}", response_model=RouteResponse) +def update_route( + route_id: int, + body: UpdateRouteRequest, + current_user: Annotated[User, require("routing.create")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + + if body.status is not None: + route.status = RouteStatus(body.status) + + if body.provider is not None: + route.provider = body.provider + + # Перестроение маршрута при смене зоны + if body.selected_zone_id is not None: + zone = db.query(ParkingZone).filter( + ParkingZone.parking_zone_id == body.selected_zone_id + ).one_or_none() + if zone is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": f"Zone {body.selected_zone_id} not found"}, + ) + + origin = GeoPoint( + latitude=route.origin_latitude, + longitude=route.origin_longitude, + ) + destination = None + if route.destination_latitude is not None: + destination = GeoPoint( + latitude=route.destination_latitude, + longitude=route.destination_longitude, + ) + + candidate = _build_candidate_from_zone( + zone=zone, + origin=origin, + destination=destination, + db=db, + use_forecast=True, + ) + + # Пересчитываем deeplink + try: + coords = zone.geometry["coordinates"][0] + z_lat = sum(c[1] for c in coords) / len(coords) + z_lon = sum(c[0] for c in coords) / len(coords) + except Exception: + z_lat, z_lon = 0.0, 0.0 + + provider = body.provider or route.provider + route.selected_zone_id = body.selected_zone_id + route.selected_candidate = candidate.model_dump(mode="json") + route.eta_seconds = candidate.duration_from_origin_seconds + route.arrival_time = candidate.predicted_for_arrival + route.deeplink_url = build_deeplink(provider, z_lat, z_lon) + route.polyline = None # пересчёт polyline — задача внешнего провайдера + + route.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(route) + return _serialize_route(route) + + +# --------------------------------------------------------------------------- +# DELETE /routing/{route_id} — мягкое удаление (статус cancelled) +# --------------------------------------------------------------------------- + +@router.delete("/{route_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_route( + route_id: int, + current_user: Annotated[User, require("routing.delete")], + db: Annotated[Session, Depends(get_db)], +): + route = _get_route_or_404(db, route_id) + _assert_owner_or_admin(route, current_user) + + route.status = RouteStatus.cancelled + route.updated_at = datetime.now(timezone.utc) + db.commit() + return None diff --git a/src/schemas/routing.py b/src/schemas/routing.py new file mode 100644 index 0000000..4d539cb --- /dev/null +++ b/src/schemas/routing.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +# --------------------------------------------------------------------------- +# Вспомогательные типы +# --------------------------------------------------------------------------- + +class GeoPoint(BaseModel): + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + + +# --------------------------------------------------------------------------- +# RouteCandidate +# --------------------------------------------------------------------------- + +class RouteCandidate(BaseModel): + zone_id: int + camera_id: int | None + geometry: Any # GeoJSON Polygon + zone_type: str + location_type: str | None + is_accessible: bool | None + pay: int + capacity: int + current_occupied: int + current_free_count: int + current_confidence: float + predicted_for_arrival: datetime + predicted_occupied: int | None + predicted_free_count: int | None + probability_free_space: float | None + forecast_confidence: float | None + distance_from_origin_meters: int + duration_from_origin_seconds: int + distance_to_destination_meters: int | None + duration_to_destination_seconds: int | None + score: float + rank: int + + +# --------------------------------------------------------------------------- +# Route (полная модель) +# --------------------------------------------------------------------------- + +class RouteResponse(BaseModel): + route_id: int + user_id: int + mode: str + provider: str + origin: GeoPoint + destination: GeoPoint | None + selected_zone_id: int | None + selected_candidate: RouteCandidate | None + eta_seconds: int | None + arrival_time: datetime | None + polyline: str | None + deeplink_url: str | None + status: str + created_at: datetime + updated_at: datetime + + +class RouteListResponse(BaseModel): + items: list[RouteResponse] + total: int + top: int + offset: int + + +# --------------------------------------------------------------------------- +# Запросы — общая база для search и new +# --------------------------------------------------------------------------- + +class RoutingRequestBase(BaseModel): + mode: Literal["find_parking", "route_to_destination"] + origin: GeoPoint + destination: GeoPoint | None = None + max_pay: int | None = Field(None, ge=0) + min_free_count: int | None = Field(None, ge=0) + min_confidence: float | None = Field(None, ge=0.0, le=1.0) + max_distance_to_destination_meters: int | None = Field(None, ge=0) + max_duration_from_origin_seconds: int | None = Field(None, ge=0) + include_accessible: bool | None = None + limit: int = Field(10, ge=1, le=50) + use_forecast: bool = False + provider: str = "internal" + + @model_validator(mode="after") + def destination_required_for_route_mode(self) -> "RoutingRequestBase": + if self.mode == "route_to_destination" and self.destination is None: + raise ValueError("destination is required for mode=route_to_destination") + return self + + +class SearchRoutingRequest(RoutingRequestBase): + pass + + +class CreateRouteRequest(RoutingRequestBase): + selected_zone_id: int | None = None + + +# --------------------------------------------------------------------------- +# Ответ /routing/search +# --------------------------------------------------------------------------- + +class SearchRoutingResponse(BaseModel): + mode: str + provider: str + generated_at: datetime + selected_zone_id: int | None + total_candidates: int + candidates: list[RouteCandidate] + + +# --------------------------------------------------------------------------- +# Обновление маршрута +# --------------------------------------------------------------------------- + +class UpdateRouteRequest(BaseModel): + status: Literal["active", "completed", "cancelled", "replaced"] | None = None + selected_zone_id: int | None = None + provider: str | None = None diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/routing.py b/src/services/routing.py new file mode 100644 index 0000000..37c4f4d --- /dev/null +++ b/src/services/routing.py @@ -0,0 +1,261 @@ +""" +Сервис поиска кандидатов парковки. + +Логика: +1. Берём все активные зоны из БД, опционально фильтруем. +2. Считаем расстояние/время от origin до зоны через Haversine (MVP). +3. Если use_forecast=True — подтягиваем ближайший прогноз к arrival_time. +4. Рассчитываем score и ранжируем кандидатов. +5. Возвращаем список RouteCandidate. + +Deeplink для Yandex Navigator: + yandexnavi://build_route_on_map?lat_to={lat}&lon_to={lon} +""" + +from __future__ import annotations + +import math +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from ..db_models import Forecast, ParkingZone +from ..schemas.routing import GeoPoint, RouteCandidate + +if TYPE_CHECKING: + pass + +# --------------------------------------------------------------------------- +# Haversine +# --------------------------------------------------------------------------- + +_EARTH_RADIUS_M = 6_371_000.0 +_WALKING_SPEED_MPS = 1.4 # м/с (~5 км/ч) +_DRIVING_SPEED_MPS = 8.33 # м/с (~30 км/ч в городе) + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Возвращает расстояние в метрах между двумя точками.""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + return 2 * _EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def _zone_centroid(zone: ParkingZone) -> tuple[float, float]: + """Возвращает (lat, lon) центра зоны из GeoJSON Polygon.""" + try: + coords = zone.geometry["coordinates"][0] # внешнее кольцо + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + return sum(lats) / len(lats), sum(lons) / len(lons) + except (KeyError, TypeError, IndexError, ZeroDivisionError): + return zone.geometry.get("lat", 0.0), zone.geometry.get("lon", 0.0) + + +def _duration_seconds(distance_m: float) -> int: + """Грубая оценка времени поездки на автомобиле.""" + return max(30, int(distance_m / _DRIVING_SPEED_MPS)) + + +def _duration_on_foot(distance_m: float) -> int: + """Грубая оценка времени пешей прогулки.""" + return max(10, int(distance_m / _WALKING_SPEED_MPS)) + + +# --------------------------------------------------------------------------- +# Deeplink +# --------------------------------------------------------------------------- + +def build_deeplink(provider: str, lat: float, lon: float) -> str | None: + if provider == "yandex": + return f"yandexnavi://build_route_on_map?lat_to={lat}&lon_to={lon}" + return None + + +# --------------------------------------------------------------------------- +# Прогноз к моменту прибытия +# --------------------------------------------------------------------------- + +def _get_forecast_for_arrival( + db: Session, + zone_id: int, + arrival_time: datetime, +) -> Forecast | None: + """ + Выбираем прогноз с predicted_for ближайшим к arrival_time (не позже чем +30 мин), + из самой последней генерации. + """ + window_start = arrival_time - timedelta(minutes=30) + window_end = arrival_time + timedelta(minutes=30) + + return ( + db.query(Forecast) + .filter( + Forecast.zone_id == zone_id, + Forecast.predicted_for >= window_start, + Forecast.predicted_for <= window_end, + ) + .order_by(Forecast.generated_at.desc(), Forecast.predicted_for.asc()) + .first() + ) + + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- + +def _score( + free_count: int, + capacity: int, + confidence: float, + distance_m: float, + pay: int, + max_distance: float, + probability_free_space: float | None, +) -> float: + """ + Итоговый score в диапазоне 0..1. + Учитывает: доступность мест, уверенность, расстояние, стоимость. + """ + occupancy_score = (free_count / max(capacity, 1)) * 0.35 + confidence_score = confidence * 0.20 + distance_score = max(0.0, 1.0 - distance_m / max(max_distance, 1)) * 0.30 + pay_score = (1.0 / (1.0 + pay / 100)) * 0.10 + forecast_score = (probability_free_space or 0.0) * 0.05 + + return round( + occupancy_score + confidence_score + distance_score + pay_score + forecast_score, + 4, + ) + + +# --------------------------------------------------------------------------- +# Публичный интерфейс +# --------------------------------------------------------------------------- + +def find_candidates( + db: Session, + origin: GeoPoint, + destination: GeoPoint | None, + mode: str, + max_pay: int | None, + min_free_count: int | None, + min_confidence: float | None, + max_distance_to_destination_meters: int | None, + max_duration_from_origin_seconds: int | None, + include_accessible: bool | None, + use_forecast: bool, + limit: int, + selected_zone_id: int | None = None, +) -> list[RouteCandidate]: + """ + Возвращает отсортированный список кандидатов. + Если selected_zone_id указан — возвращает ровно этого кандидата первым + (если проходит фильтры). + """ + now = datetime.now(timezone.utc) + + query = db.query(ParkingZone).filter(ParkingZone.is_active.is_(True)) + + if max_pay is not None: + query = query.filter(ParkingZone.pay <= max_pay) + if min_free_count is not None: + query = query.filter((ParkingZone.capacity - ParkingZone.occupied) >= min_free_count) + if min_confidence is not None: + query = query.filter(ParkingZone.confidence >= min_confidence) + if include_accessible is True: + query = query.filter(ParkingZone.is_accessible.is_(True)) + if selected_zone_id is not None: + query = query.filter(ParkingZone.parking_zone_id == selected_zone_id) + + zones: list[ParkingZone] = query.all() + + if not zones: + return [] + + # Максимальное расстояние от origin для нормировки score + max_dist_for_score = 5000.0 + + raw: list[tuple[float, RouteCandidate]] = [] + + for zone in zones: + z_lat, z_lon = _zone_centroid(zone) + + # --- расстояние от origin до зоны --- + dist_origin = _haversine(origin.latitude, origin.longitude, z_lat, z_lon) + dur_origin = _duration_seconds(dist_origin) + + if max_duration_from_origin_seconds and dur_origin > max_duration_from_origin_seconds: + continue + + # --- расстояние от зоны до destination --- + dist_dest = None + dur_dest = None + if destination: + dist_dest = _haversine(z_lat, z_lon, destination.latitude, destination.longitude) + dur_dest = _duration_on_foot(dist_dest) + if max_distance_to_destination_meters and dist_dest > max_distance_to_destination_meters: + continue + + # --- прогноз к моменту прибытия --- + arrival_time = now + timedelta(seconds=dur_origin) + forecast = _get_forecast_for_arrival(db, zone.parking_zone_id, arrival_time) if use_forecast else None + pred_occupied = forecast.predicted_occupied if forecast else None + pred_free = (zone.capacity - forecast.predicted_occupied) if forecast else None + prob_free = forecast.probability_free_space if forecast else None + forecast_conf = forecast.confidence if forecast else None + predicted_for_at = forecast.predicted_for if forecast else arrival_time + + # --- score --- + effective_free = pred_free if pred_free is not None else (zone.capacity - zone.occupied) + effective_conf = forecast_conf if forecast_conf is not None else (zone.confidence or 0.0) + score = _score( + free_count=effective_free, + capacity=zone.capacity, + confidence=effective_conf, + distance_m=dist_origin, + pay=zone.pay, + max_distance=max_dist_for_score, + probability_free_space=prob_free, + ) + + candidate = RouteCandidate( + zone_id=zone.parking_zone_id, + camera_id=zone.camera_id, + geometry=zone.geometry, + zone_type=zone.zone_type.value, + location_type=zone.location_type.value if zone.location_type else None, + is_accessible=zone.is_accessible, + pay=zone.pay, + capacity=zone.capacity, + current_occupied=zone.occupied, + current_free_count=zone.capacity - zone.occupied, + current_confidence=zone.confidence or 0.0, + predicted_for_arrival=predicted_for_at, + predicted_occupied=pred_occupied, + predicted_free_count=pred_free, + probability_free_space=prob_free, + forecast_confidence=forecast_conf, + distance_from_origin_meters=int(dist_origin), + duration_from_origin_seconds=dur_origin, + distance_to_destination_meters=int(dist_dest) if dist_dest is not None else None, + duration_to_destination_seconds=dur_dest, + score=score, + rank=0, # проставим после сортировки + ) + raw.append((score, candidate)) + + # Сортировка: если selected_zone_id задан — он первый; остальные по убыванию score + raw.sort(key=lambda x: x[0], reverse=True) + if selected_zone_id is not None: + raw.sort(key=lambda x: (0 if x[1].zone_id == selected_zone_id else 1, -x[0])) + + results = [] + for rank, (_, candidate) in enumerate(raw[:limit], start=1): + candidate.rank = rank + results.append(candidate) + + return results