Initial commit
This commit is contained in:
18
app/routes/__init__.py
Normal file
18
app/routes/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Routes package."""
|
||||
|
||||
from .optimization import router as optimization_router
|
||||
from .health import router as health_router
|
||||
from .cache import router as cache_router
|
||||
from .ml_admin import router as ml_router, web_router as ml_web_router
|
||||
from .batch_analytics import router as batch_analytics_router
|
||||
from .riders import router as riders_router
|
||||
|
||||
__all__ = [
|
||||
"optimization_router",
|
||||
"health_router",
|
||||
"cache_router",
|
||||
"ml_router",
|
||||
"ml_web_router",
|
||||
"batch_analytics_router",
|
||||
"riders_router",
|
||||
]
|
||||
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/batch_analytics.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/batch_analytics.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/cache.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/cache.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/health.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/health.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/ml_admin.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/ml_admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/optimization.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/optimization.cpython-312.pyc
Normal file
Binary file not shown.
263
app/routes/batch_analytics.py
Normal file
263
app/routes/batch_analytics.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Batch Efficiency Analytics Endpoint
|
||||
=====================================
|
||||
POST /api/v1/batch/efficiency
|
||||
|
||||
Analyses a delivery batch for idle-rider substitution opportunities.
|
||||
Supports named batch windows (morning / afternoon / evening) or custom
|
||||
time ranges so you can run the same analysis for any shift.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"batch": "morning", // "morning" | "afternoon" | "evening" | "custom"
|
||||
"date": "2026-05-28", // defaults to today
|
||||
"tenant_id": 916, // defaults to 916
|
||||
"from_time": "06:00", // only for batch="custom"
|
||||
"to_time": "09:00", // only for batch="custom"
|
||||
"deliveries": [...], // supply inline instead of DB fetch
|
||||
"rider_names": {"1036": "Vignesh S", ...},
|
||||
"config": {
|
||||
"idle_threshold_minutes": 30,
|
||||
"road_kmh": 13.0,
|
||||
"max_transfer_orders": 4
|
||||
}
|
||||
}
|
||||
|
||||
Batch windows (assigntime range, inclusive start / exclusive end):
|
||||
morning : 06:00 – 09:00 (breakfast + early lunch prep)
|
||||
afternoon : 11:00 – 15:00 (lunch)
|
||||
evening : 17:00 – 21:30 (dinner)
|
||||
custom : caller provides from_time / to_time
|
||||
|
||||
Header shorthand (all equivalent to body.batch):
|
||||
X-Batch-Window: morning | afternoon | evening
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import date as _date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, status
|
||||
|
||||
from app.services.routing.batch_efficiency import analyse_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/batch",
|
||||
tags=["Batch Analytics"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Named batch windows {name: (from_time, to_time)} — 24-h "HH:MM" strings
|
||||
# ---------------------------------------------------------------------------
|
||||
BATCH_WINDOWS: dict[str, tuple[str, str]] = {
|
||||
"morning": ("06:00", "09:00"),
|
||||
"afternoon": ("11:00", "15:00"),
|
||||
"evening": ("17:00", "21:30"),
|
||||
}
|
||||
|
||||
DEFAULT_BATCH = "morning"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_from_db(
|
||||
target_date: str,
|
||||
tenant_id: int,
|
||||
from_time: str,
|
||||
to_time: str,
|
||||
) -> tuple[list[dict], dict[int, str]]:
|
||||
"""
|
||||
Fetch deliveries assigned within [from_time, to_time) on target_date.
|
||||
Times are 24-h "HH:MM" strings, e.g. "06:00", "09:00".
|
||||
Returns (deliveries, rider_names) where rider_names maps userid → username.
|
||||
"""
|
||||
# Shared nearledb connector (single source of truth for DB_* creds).
|
||||
from app.services.routing.delivery_history_service import connect_nearledb
|
||||
|
||||
try:
|
||||
conn = connect_nearledb()
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="psycopg2 not installed — cannot fetch from DB.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"DB connection failed: {exc}",
|
||||
)
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
d.deliveryid,
|
||||
d.userid,
|
||||
d.pickupcustomer,
|
||||
d.assigntime,
|
||||
d.arrivaltime,
|
||||
d.pickuptime,
|
||||
d.deliverytime,
|
||||
COALESCE(d.droplat, d.deliverylat) AS dlat,
|
||||
COALESCE(d.droplon, d.deliverylong) AS dlon
|
||||
FROM deliveries d
|
||||
WHERE d.tenantid = %s
|
||||
AND DATE(d.assigntime::timestamp) = %s
|
||||
AND CAST(SPLIT_PART(d.assigntime, ' ', 2) AS TIME) >= %s
|
||||
AND CAST(SPLIT_PART(d.assigntime, ' ', 2) AS TIME) < %s
|
||||
AND COALESCE(d.droplat, d.deliverylat) IS NOT NULL
|
||||
AND d.userid IS NOT NULL
|
||||
ORDER BY d.userid, d.assigntime
|
||||
""",
|
||||
(tenant_id, target_date, from_time + ":00", to_time + ":00"),
|
||||
)
|
||||
cols = [c.name for c in cur.description]
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
deliveries = [dict(zip(cols, r)) for r in rows]
|
||||
|
||||
# Fetch rider names for the returned userids
|
||||
rider_names_db: dict[int, str] = {}
|
||||
try:
|
||||
unique_uids = list({int(r["userid"]) for r in deliveries if r.get("userid") is not None})
|
||||
if unique_uids:
|
||||
cur2 = conn.cursor()
|
||||
cur2.execute(
|
||||
"SELECT userid, username FROM users WHERE userid = ANY(%s)",
|
||||
(unique_uids,)
|
||||
)
|
||||
for uid, uname in cur2.fetchall():
|
||||
if uname:
|
||||
rider_names_db[int(uid)] = str(uname)
|
||||
cur2.close()
|
||||
except Exception:
|
||||
pass # names are non-critical; callers fall back to "Rider {uid}"
|
||||
|
||||
return deliveries, rider_names_db
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"DB query failed: {exc}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post(
|
||||
"/efficiency",
|
||||
summary="Batch efficiency analysis",
|
||||
description=(
|
||||
"Analyse a delivery batch for idle-rider substitution opportunities. "
|
||||
"Pass `batch` as 'morning', 'afternoon', or 'evening' to select the "
|
||||
"time window automatically, or use `batch='custom'` with `from_time`/`to_time`. "
|
||||
"Supply `deliveries` inline to skip the DB fetch entirely."
|
||||
),
|
||||
)
|
||||
async def batch_efficiency(
|
||||
body: Any = Body(default=None),
|
||||
x_batch_window: str | None = Header(default=None),
|
||||
):
|
||||
if body is None:
|
||||
body = {}
|
||||
|
||||
# ---- Parse inputs -------------------------------------------------------
|
||||
deliveries: list[dict] | None = None
|
||||
target_date: str | None = None
|
||||
tenant_id: int = 916
|
||||
rider_names: dict[int, str] = {}
|
||||
cfg: dict = {}
|
||||
batch_name: str = DEFAULT_BATCH
|
||||
from_time: str | None = None
|
||||
to_time: str | None = None
|
||||
|
||||
if isinstance(body, dict):
|
||||
deliveries = body.get("deliveries")
|
||||
target_date = body.get("date")
|
||||
tenant_id = int(body.get("tenant_id", 916))
|
||||
batch_name = (body.get("batch") or x_batch_window or DEFAULT_BATCH).lower()
|
||||
from_time = body.get("from_time")
|
||||
to_time = body.get("to_time")
|
||||
rider_names_raw = body.get("rider_names") or {}
|
||||
rider_names = {int(k): v for k, v in rider_names_raw.items()}
|
||||
cfg = body.get("config") or {}
|
||||
elif isinstance(body, list):
|
||||
deliveries = body
|
||||
batch_name = (x_batch_window or DEFAULT_BATCH).lower()
|
||||
|
||||
# ---- Resolve time window ------------------------------------------------
|
||||
if batch_name == "custom":
|
||||
if not from_time or not to_time:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="batch='custom' requires from_time and to_time (e.g. '11:00', '15:00').",
|
||||
)
|
||||
elif batch_name in BATCH_WINDOWS:
|
||||
from_time, to_time = BATCH_WINDOWS[batch_name]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Unknown batch '{batch_name}'. "
|
||||
f"Valid values: {list(BATCH_WINDOWS.keys())} or 'custom'."
|
||||
),
|
||||
)
|
||||
|
||||
# ---- Fetch from DB if no inline deliveries ------------------------------
|
||||
if not deliveries:
|
||||
if not target_date:
|
||||
target_date = str(_date.today())
|
||||
|
||||
logger.info(
|
||||
f"[BatchEfficiency] Fetching batch={batch_name} "
|
||||
f"date={target_date} window={from_time}-{to_time} tenant={tenant_id}"
|
||||
)
|
||||
deliveries, db_rider_names = _fetch_from_db(target_date, tenant_id, from_time, to_time)
|
||||
|
||||
if not deliveries:
|
||||
return {
|
||||
"batch": batch_name,
|
||||
"window": {"from": from_time, "to": to_time},
|
||||
"date": target_date,
|
||||
"fleet_summary": {},
|
||||
"rider_timelines": [],
|
||||
"substitution_opportunities": [],
|
||||
"top_recommendation": None,
|
||||
"message": (
|
||||
f"No {batch_name}-batch orders found for {target_date} "
|
||||
f"between {from_time} and {to_time}."
|
||||
),
|
||||
}
|
||||
|
||||
# Merge: DB-fetched names as base, request-provided names take precedence
|
||||
rider_names = {**db_rider_names, **rider_names}
|
||||
|
||||
logger.info(
|
||||
f"[BatchEfficiency] Analysing {len(deliveries)} deliveries — "
|
||||
f"batch={batch_name} date={target_date or 'inline'}"
|
||||
)
|
||||
|
||||
# ---- Run analysis -------------------------------------------------------
|
||||
result = analyse_batch(
|
||||
deliveries=deliveries,
|
||||
rider_names=rider_names,
|
||||
road_kmh=float(cfg.get("road_kmh", 13.0)),
|
||||
idle_threshold_min=float(cfg.get("idle_threshold_minutes", 30.0)),
|
||||
max_transfer=int(cfg.get("max_transfer_orders", 4)),
|
||||
)
|
||||
|
||||
result["batch"] = batch_name
|
||||
result["window"] = {"from": from_time, "to": to_time}
|
||||
result["date"] = target_date or "inline"
|
||||
result["input_delivery_count"] = len(deliveries)
|
||||
return result
|
||||
79
app/routes/cache.py
Normal file
79
app/routes/cache.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Cache management API endpoints."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.services import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/cache", tags=["Cache Management"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=Dict[str, Any])
|
||||
async def get_cache_stats():
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
- hits: Number of cache hits
|
||||
- misses: Number of cache misses
|
||||
- sets: Number of cache writes
|
||||
- total_keys: Current number of cached route keys
|
||||
- enabled: Whether Redis cache is enabled
|
||||
"""
|
||||
try:
|
||||
stats = cache.get_stats()
|
||||
# Calculate hit rate
|
||||
total_requests = stats.get("hits", 0) + stats.get("misses", 0)
|
||||
if total_requests > 0:
|
||||
stats["hit_rate"] = round(stats.get("hits", 0) / total_requests * 100, 2)
|
||||
else:
|
||||
stats["hit_rate"] = 0.0
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache stats: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_cache_keys(pattern: str = "routes:*"):
|
||||
"""
|
||||
List cache keys matching pattern.
|
||||
|
||||
- **pattern**: Redis key pattern (default: "routes:*")
|
||||
"""
|
||||
try:
|
||||
keys = cache.get_keys(pattern)
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"count": len(keys),
|
||||
"keys": keys[:100] # Limit to first 100 for response size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing cache keys: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/clear")
|
||||
async def clear_cache(pattern: str = "routes:*"):
|
||||
"""
|
||||
Clear cache keys matching pattern.
|
||||
|
||||
- **pattern**: Redis key pattern to delete (default: "routes:*")
|
||||
|
||||
[WARN] **Warning**: This will delete cached route optimizations!
|
||||
"""
|
||||
try:
|
||||
deleted_count = cache.delete(pattern)
|
||||
logger.info(f"Cleared {deleted_count} cache keys matching pattern: {pattern}")
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"Cleared {deleted_count} cache keys"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
98
app/routes/health.py
Normal file
98
app/routes/health.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Professional health check endpoints."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/health", tags=["Health"])
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
status: str = Field(..., description="Service status")
|
||||
uptime_seconds: float = Field(..., description="Service uptime in seconds")
|
||||
version: str = Field("2.0.0", description="API version")
|
||||
timestamp: str = Field(..., description="Health check timestamp (ISO 8601)")
|
||||
request_id: Optional[str] = Field(None, description="Request ID for tracing")
|
||||
|
||||
|
||||
@router.get("/", response_model=HealthResponse)
|
||||
async def health_check(request: Request):
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns the current health status of the API service including:
|
||||
- Service status (healthy/unhealthy)
|
||||
- Uptime in seconds
|
||||
- API version
|
||||
- Timestamp
|
||||
"""
|
||||
try:
|
||||
uptime = time.time() - start_time
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
uptime_seconds=round(uptime, 2),
|
||||
version="2.0.0",
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
request_id=request_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}", exc_info=True)
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
return HealthResponse(
|
||||
status="unhealthy",
|
||||
uptime_seconds=0.0,
|
||||
version="2.0.0",
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def readiness_check(request: Request):
|
||||
"""
|
||||
Readiness check endpoint for load balancers.
|
||||
|
||||
Returns 200 if the service is ready to accept requests.
|
||||
"""
|
||||
try:
|
||||
# Check if critical services are available
|
||||
# Add your service health checks here
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Readiness check failed: {e}")
|
||||
return {
|
||||
"status": "not_ready",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def liveness_check(request: Request):
|
||||
"""
|
||||
Liveness check endpoint for container orchestration.
|
||||
|
||||
Returns 200 if the service is alive.
|
||||
"""
|
||||
return {
|
||||
"status": "alive",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
586
app/routes/ml_admin.py
Normal file
586
app/routes/ml_admin.py
Normal file
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
Analytics & ML Admin API
|
||||
=========================
|
||||
Exposes historical assignment quality data, the empirical-ETA pipeline, and the
|
||||
autonomous road-sequencing / rider-affinity agents. (The XGBoost/Optuna hypertuner,
|
||||
ID3 risk tree, and profit predictor have all been retired — they never affected
|
||||
assignment.)
|
||||
|
||||
Endpoints:
|
||||
GET /api/v1/ml/status – quality trend, analytics DB + history stats
|
||||
GET /api/v1/ml/analytics – hourly stats, zone stats, histogram
|
||||
GET /api/v1/ml/config – active config values
|
||||
PATCH /api/v1/ml/config – manual config override
|
||||
POST /api/v1/ml/reset – reset config to defaults
|
||||
POST /api/v1/ml/strategy – change optimization strategy
|
||||
POST /api/v1/ml/refresh-eta – sync nearledb + rebuild empirical ETA stats
|
||||
GET /api/v1/ml/eta-accuracy – formula vs empirical ETA backtest
|
||||
GET/POST /api/v1/ml/road-eval – autonomous road-sequencing decision
|
||||
GET /api/v1/ml/rider-affinity – learned vs configured rider→kitchen affinity
|
||||
GET /api/v1/ml/export – download assignment log as CSV
|
||||
GET /api/v1/ml/history – delivery-history FAISS store stats
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi.responses import PlainTextResponse, FileResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/ml",
|
||||
tags=["Analytics & ML"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
web_router = APIRouter(tags=["ML Monitor Web Dashboard"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard (HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@web_router.get("/ml-ops", summary="Visual ML monitoring dashboard")
|
||||
def ml_dashboard():
|
||||
path = os.path.join(os.getcwd(), "app/templates/ml_dashboard.html")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail=f"Dashboard template not found at {path}")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status", summary="Assignment quality trend & store stats")
|
||||
def ml_status():
|
||||
"""
|
||||
Returns:
|
||||
- How many assignment events are logged
|
||||
- Recent quality score trend (last 50 calls)
|
||||
- Delivery history FAISS store record count
|
||||
- Active config values
|
||||
"""
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
from app.config.dynamic_config import get_config
|
||||
|
||||
collector = get_collector()
|
||||
history = get_delivery_history_store()
|
||||
cfg = get_config()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_records": collector.count_records(),
|
||||
"quality_trend": collector.get_recent_quality_trend(last_n=50),
|
||||
"delivery_history": {
|
||||
"record_count": history.record_count(),
|
||||
"status": "ready" if history.record_count() > 0 else "empty",
|
||||
},
|
||||
"config": cfg.get_all(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] status: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/analytics", summary="Hourly stats, zone stats, quality histogram")
|
||||
def ml_analytics():
|
||||
"""Operational analytics from historical assignment logs."""
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
collector = get_collector()
|
||||
return {
|
||||
"status": "ok",
|
||||
"hourly_stats": collector.get_hourly_stats(),
|
||||
"zone_stats": collector.get_zone_stats(),
|
||||
"quality_histogram": collector.get_quality_histogram(),
|
||||
"strategy_comparison": collector.get_strategy_comparison(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] analytics: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/config", summary="Current active configuration values")
|
||||
def ml_config():
|
||||
from app.config.dynamic_config import get_config, DEFAULTS
|
||||
try:
|
||||
cfg = get_config()
|
||||
all_values = cfg.get_all()
|
||||
cached_keys = set(cfg._cache.keys())
|
||||
annotated = {
|
||||
k: {"value": v, "source": "override" if k in cached_keys else "default"}
|
||||
for k, v in all_values.items()
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"hyperparameters": annotated,
|
||||
"total_params": len(annotated),
|
||||
"override_count": sum(1 for x in annotated.values() if x["source"] == "override"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] config: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/config", summary="Override specific config values")
|
||||
def ml_config_patch(payload: dict = Body(...)):
|
||||
"""Manually set any config key, e.g. {\"road_factor\": 1.4}"""
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().set_bulk(payload, source="ml_admin")
|
||||
return {"status": "ok", "updated": list(payload.keys())}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] config patch: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/reset", summary="Reset all config overrides to factory defaults")
|
||||
def ml_reset():
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().reset_to_defaults()
|
||||
return {"status": "ok", "message": "All config values reset to factory defaults."}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] reset: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /strategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/strategy", summary="Change the optimization strategy")
|
||||
def ml_strategy(strategy: str = Body(default="balanced", embed=True)):
|
||||
"""
|
||||
Choices: balanced | fuel_saver | aggressive_speed | zone_strict
|
||||
Affects how the quality score is computed in analytics only.
|
||||
"""
|
||||
valid = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"]
|
||||
if strategy not in valid:
|
||||
raise HTTPException(400, f"Invalid strategy. Choose from {valid}")
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().set("ml_strategy", strategy)
|
||||
return {"status": "ok", "strategy": strategy}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] strategy: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auto-tune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/auto-tune", summary="Run SQL-based strategy auto-tuner")
|
||||
def ml_auto_tune():
|
||||
"""
|
||||
Analyses the assignment log and picks the best-performing ml_strategy
|
||||
based on average quality score across all recorded calls.
|
||||
|
||||
Rules:
|
||||
- A strategy needs ≥ 10 calls to be considered.
|
||||
- At least 2 strategies must have enough data to compare.
|
||||
- If a better strategy is found it is written to DynamicConfig
|
||||
immediately and takes effect on the next /riderassign call.
|
||||
- Also returns per-hour breakdown so you can see peak-hour patterns.
|
||||
|
||||
Safe to call any time. Runs in < 100ms.
|
||||
"""
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
from app.config.dynamic_config import get_config, DEFAULTS
|
||||
try:
|
||||
collector = get_collector()
|
||||
cfg = get_config()
|
||||
comparison = collector.get_strategy_comparison()
|
||||
hourly = collector.get_hourly_stats()
|
||||
total_records = collector.count_records()
|
||||
|
||||
if total_records == 0:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": "No assignment events logged yet. "
|
||||
"Call /riderassign a few times first.",
|
||||
"total_records": 0,
|
||||
}
|
||||
|
||||
qualified = [s for s in comparison if s["call_count"] >= 10]
|
||||
current_strategy = cfg.get("ml_strategy", "balanced")
|
||||
action = "no_change"
|
||||
recommendation = None
|
||||
|
||||
if len(qualified) >= 2:
|
||||
best = max(qualified, key=lambda x: x["avg_quality"])
|
||||
recommendation = best["strategy"]
|
||||
if best["strategy"] != current_strategy:
|
||||
cfg.set("ml_strategy", best["strategy"], source="auto_tuner")
|
||||
action = "updated"
|
||||
logger.info(
|
||||
f"[AutoTune API] Strategy: '{current_strategy}' → "
|
||||
f"'{best['strategy']}' (quality={best['avg_quality']:.1f})"
|
||||
)
|
||||
elif len(comparison) > 0:
|
||||
action = "insufficient_data"
|
||||
recommendation = comparison[0]["strategy"] # best so far even if < 10 calls
|
||||
|
||||
# Per-hour best strategy (informational — not auto-applied)
|
||||
# Shows which strategy logged the highest quality at each hour
|
||||
hour_best: list = []
|
||||
if hourly:
|
||||
for h in hourly:
|
||||
# Find which strategy performed best in this hour block
|
||||
# (simple: use the dominant strategy for that hour from comparison)
|
||||
hour_best.append({
|
||||
"hour": h["hour"],
|
||||
"avg_quality": h["avg_quality"],
|
||||
"call_count": h["call_count"],
|
||||
"sla_breaches": h["sla_breaches"],
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"current_strategy": cfg.get("ml_strategy", "balanced"),
|
||||
"recommendation": recommendation,
|
||||
"total_records": total_records,
|
||||
"strategy_comparison": comparison,
|
||||
"hourly_quality": hour_best,
|
||||
"message": (
|
||||
f"Strategy updated to '{recommendation}'."
|
||||
if action == "updated"
|
||||
else "Current strategy is already optimal."
|
||||
if action == "no_change"
|
||||
else "More data needed (≥ 10 calls per strategy to compare)."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] auto-tune: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /bandit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/bandit", summary="Thompson Sampling bandit — posterior stats per context")
|
||||
def ml_bandit():
|
||||
"""
|
||||
Shows the current state of the RL strategy bandit.
|
||||
|
||||
Each context (time_band|load_band) has 4 arms (strategies).
|
||||
For each arm:
|
||||
mean_reward — expected quality / 100 based on posterior mean
|
||||
observations — number of observed calls (excluding prior)
|
||||
alpha / beta — Beta distribution parameters
|
||||
|
||||
The bandit uses Thompson Sampling to select strategies automatically
|
||||
on every /riderassign call, balancing exploration vs exploitation.
|
||||
"""
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit
|
||||
return {"status": "ok", **get_bandit().get_stats()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] bandit: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /rider-efficiency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/rider-efficiency", summary="Per-rider efficiency scores from 30-day CSV history")
|
||||
def ml_rider_efficiency():
|
||||
"""
|
||||
Computes efficiency scores for each rider from the delivery_details.csv.
|
||||
|
||||
Metrics:
|
||||
delivery_count — total deliveries in the 30-day window
|
||||
avg_km — average km per delivery
|
||||
unique_zones — number of distinct 1.1 km delivery cells served
|
||||
efficiency_score — normalised 0..1 composite (high = efficient)
|
||||
|
||||
Used internally as tiebreaker during solo rider consolidation
|
||||
and Phase-0 pattern pre-assignment host selection.
|
||||
"""
|
||||
try:
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
scores = get_delivery_history_store().get_rider_efficiency_scores()
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1]["efficiency_score"], reverse=True)
|
||||
return {
|
||||
"status": "ok",
|
||||
"rider_count": len(scores),
|
||||
"riders": [{"rider_id": rid, **data} for rid, data in ranked],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] rider-efficiency: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/history", summary="Delivery-history FAISS store stats + pattern table")
|
||||
def ml_history(patterns: bool = False):
|
||||
"""
|
||||
Shows the delivery history store status.
|
||||
|
||||
Add ?patterns=true to include the full pattern table
|
||||
(all clear dominant-rider zones, sorted by pattern score).
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import (
|
||||
get_delivery_history_store, _INDEX_PATH, _META_PATH, CSV_PATH
|
||||
)
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
meta = {}
|
||||
if os.path.isfile(_META_PATH):
|
||||
with open(_META_PATH, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
|
||||
resp = {
|
||||
"status": "ok",
|
||||
"record_count": store.record_count(),
|
||||
"pattern_count": store.pattern_count(),
|
||||
"index_ready": store.record_count() > 0,
|
||||
"disk_index": os.path.isfile(_INDEX_PATH),
|
||||
"csv_path": CSV_PATH,
|
||||
"csv_exists": os.path.isfile(CSV_PATH),
|
||||
"saved_meta": meta,
|
||||
}
|
||||
if patterns:
|
||||
resp["patterns"] = store.get_pattern_stats()
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] history: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inject-corrections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/inject-corrections", summary="Inject manually-corrected delivery CSV as high-weight priors")
|
||||
def ml_inject_corrections(
|
||||
corrections_path: str = Body(default="delivery_corrections.csv", embed=True),
|
||||
weight: int = Body(default=5, embed=True),
|
||||
):
|
||||
"""
|
||||
Loads a manually-corrected delivery CSV and injects it into the live
|
||||
delivery-history pattern table as high-weight prior evidence.
|
||||
|
||||
Each record in the corrections file is counted `weight` times (default 5),
|
||||
so 5 correction votes easily override 1-2 noise votes from regular history.
|
||||
|
||||
The correction CSV must have the same columns as delivery_details.csv:
|
||||
pickupcustomer, pickuplat, pickuplon, deliverylat, deliverylong,
|
||||
userid, ridername
|
||||
|
||||
After injection the pattern table is rebuilt in-memory and saved to disk.
|
||||
No server restart needed.
|
||||
|
||||
Parameters:
|
||||
corrections_path Path inside the container (default: delivery_corrections.csv)
|
||||
weight How many times each correction record is counted (default: 5)
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
result = store.inject_corrections(corrections_path, weight=weight)
|
||||
return {"status": "ok", **result}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] inject-corrections: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /reload-history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/reload-history", summary="Rebuild FAISS delivery-history index from CSV")
|
||||
def ml_reload_history():
|
||||
"""
|
||||
Forces a full rebuild of the delivery-history FAISS index from
|
||||
delivery_details.csv, then saves the new index to disk.
|
||||
|
||||
Call this whenever you update the CSV with a fresh 30-day export.
|
||||
No server restart needed — the in-memory store is hot-swapped.
|
||||
|
||||
Returns the number of records now loaded.
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
n = store.reload_from_csv()
|
||||
return {
|
||||
"status": "ok",
|
||||
"record_count": n,
|
||||
"message": f"FAISS history index rebuilt from CSV — {n} records loaded and saved to disk.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] reload-history: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /refresh-eta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/refresh-eta", summary="Sync nearledb -> local mirror and rebuild empirical ETA stats")
|
||||
def ml_refresh_eta(
|
||||
days: int = Body(default=None, embed=True),
|
||||
tenant_id: int = Body(default=916, embed=True),
|
||||
full: bool = Body(default=False, embed=True),
|
||||
):
|
||||
"""
|
||||
Manually trigger the empirical-ETA pipeline (the autonomous agent does this
|
||||
on a schedule too): incremental READ-ONLY pull of new completed deliveries
|
||||
from nearledb into the local mirror, then rebuild the learned medians.
|
||||
|
||||
`days` defaults to `eta_history_days`. Set `full=true` to force a full
|
||||
backfill of the window instead of an incremental sync.
|
||||
"""
|
||||
from app.services.routing.delivery_history_service import get_delivery_history_service
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
window = int(days if days is not None else get_config().get("eta_history_days", 14))
|
||||
result = get_delivery_history_service().refresh_eta_stats(
|
||||
days=window, tenant_id=tenant_id, full=full
|
||||
)
|
||||
return {"status": "ok", "result": result}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] refresh-eta: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /eta-accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/eta-accuracy", summary="Backtest: formula ETA MAE vs empirical ETA MAE")
|
||||
def ml_eta_accuracy(days: int = None, tenant_id: int = 916):
|
||||
"""
|
||||
Honest time-split backtest. Trains empirical medians on the older 80% of the
|
||||
history window and reports mean-absolute-error (minutes) of the formula vs
|
||||
the empirical model on the held-out recent 20%.
|
||||
|
||||
Use this as the gate before trusting empirical ETAs: if `interpretation` is
|
||||
not "empirical_better", leave `eta_empirical_enabled=false` and investigate.
|
||||
"""
|
||||
from app.services.routing.delivery_history_service import get_delivery_history_service
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
cfg = get_config()
|
||||
window = int(days if days is not None else cfg.get("eta_history_days", 14))
|
||||
result = get_delivery_history_service().backtest(
|
||||
days=window,
|
||||
tenant_id=tenant_id,
|
||||
min_samples=int(cfg.get("eta_min_samples", 20)),
|
||||
stat=str(cfg.get("eta_stat", "median")),
|
||||
)
|
||||
return {"status": "ok", "backtest": result}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] eta-accuracy: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/POST /road-eval (autonomous road-sequencing decision agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/road-eval", summary="Last autonomous road-sequencing decision")
|
||||
def ml_road_eval_get():
|
||||
"""
|
||||
Show the road-sequencing agent's most recent decision: whether road-aware
|
||||
ordering beat straight-line ordering on real batches, the measured mean
|
||||
travel-time gain, and whether it auto-enabled/disabled `routing_use_road_distance`.
|
||||
"""
|
||||
from app.services.routing.road_sequencing_agent import get_road_agent
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
agent = get_road_agent()
|
||||
return {
|
||||
"status": "ok",
|
||||
"road_distance_enabled": bool(get_config().get("routing_use_road_distance", False)),
|
||||
"auto_manage": bool(get_config().get("routing_auto_manage", True)),
|
||||
"last_decision": agent.last_decision or get_config().get("routing_road_eval", {}),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] road-eval get: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/road-eval", summary="Run the road-sequencing decision now")
|
||||
def ml_road_eval_run():
|
||||
"""
|
||||
Trigger an immediate evaluation + autonomous decision (the agent also does
|
||||
this on a schedule). Measures road vs aerial sequencing on sampled real
|
||||
batches and may flip `routing_use_road_distance` based on the measured gain.
|
||||
Also reports whether we beat the riders' actual delivered order.
|
||||
"""
|
||||
from app.services.routing.road_sequencing_agent import get_road_agent
|
||||
try:
|
||||
return {"status": "ok", "decision": get_road_agent().decide_and_apply()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] road-eval run: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /rider-affinity (learned vs configured rider→kitchen affinity)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/rider-affinity", summary="Learned vs configured rider→kitchen affinity")
|
||||
def ml_rider_affinity(refresh: bool = False):
|
||||
"""
|
||||
Show the learned rider→kitchen affinity (from real delivery history) merged
|
||||
with the curated config. `?refresh=true` recomputes from the local mirror
|
||||
first. Learned data only augments SOFT steering — hard kitchen locks and
|
||||
BLOCKED_RIDERS stay sourced from the curated config.
|
||||
"""
|
||||
from app.services.routing.rider_affinity_service import get_rider_affinity
|
||||
try:
|
||||
aff = get_rider_affinity()
|
||||
if refresh:
|
||||
aff.refresh()
|
||||
return {"status": "ok", "affinity": aff.get_summary()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] rider-affinity: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/export", summary="Download all assignment logs as CSV")
|
||||
def ml_export():
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
try:
|
||||
csv_data = get_collector().export_csv()
|
||||
response = PlainTextResponse(content=csv_data, media_type="text/csv")
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="assignment_log.csv"'
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] export: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
1373
app/routes/optimization.py
Normal file
1373
app/routes/optimization.py
Normal file
File diff suppressed because it is too large
Load Diff
149
app/routes/riders.py
Normal file
149
app/routes/riders.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Riders Admin API
|
||||
================
|
||||
Endpoints for the operations team to manage rider substitutions.
|
||||
|
||||
POST /api/v1/riders/substitution – register one or many substitutions
|
||||
GET /api/v1/riders/substitution – list upcoming/active subs
|
||||
DELETE /api/v1/riders/substitution/{sub_date}/{absent_rider_id} – cancel one
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Path
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/riders",
|
||||
tags=["Riders & Substitutions"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
|
||||
class SubstitutionEntry(BaseModel):
|
||||
sub_date: str
|
||||
absent_rider_id: int
|
||||
sub_rider_id: int
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_entry(self):
|
||||
try:
|
||||
date.fromisoformat(self.sub_date)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid date format '{self.sub_date}'. Use YYYY-MM-DD.")
|
||||
if self.absent_rider_id == self.sub_rider_id:
|
||||
raise ValueError("absent_rider_id and sub_rider_id must be different.")
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/substitution", summary="Register one or multiple rider substitutions")
|
||||
def register_substitution(entries: List[SubstitutionEntry] = Body(...)):
|
||||
"""
|
||||
Register rider substitutions for the operations team. Accepts a list so
|
||||
you can submit all absent riders for a day in one call.
|
||||
|
||||
Example — single:
|
||||
```json
|
||||
[{"sub_date": "2026-06-20", "absent_rider_id": 101, "sub_rider_id": 205}]
|
||||
```
|
||||
|
||||
Example — batch (3 riders absent same day):
|
||||
```json
|
||||
[
|
||||
{"sub_date": "2026-06-20", "absent_rider_id": 101, "sub_rider_id": 205},
|
||||
{"sub_date": "2026-06-20", "absent_rider_id": 102, "sub_rider_id": 206},
|
||||
{"sub_date": "2026-06-21", "absent_rider_id": 103, "sub_rider_id": 207}
|
||||
]
|
||||
```
|
||||
|
||||
Each sub rider **must** appear in that day's getriderlogs response.
|
||||
The assignment engine copies the absent rider's kitchen ownership, soft
|
||||
preferences, and home location onto the sub rider automatically — reverts
|
||||
the next day with no action needed.
|
||||
|
||||
Posting the same (sub_date, absent_rider_id) again updates sub_rider_id.
|
||||
"""
|
||||
if not entries:
|
||||
raise HTTPException(status_code=400, detail="Request body must be a non-empty list.")
|
||||
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
svc = get_substitution_service()
|
||||
results = [svc.register(e.sub_date, e.absent_rider_id, e.sub_rider_id) for e in entries]
|
||||
return {
|
||||
"status": "ok",
|
||||
"registered": len(results),
|
||||
"substitutions": results,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] register_substitution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/substitution", summary="List upcoming and active rider substitutions")
|
||||
def list_substitutions(from_date: str = None):
|
||||
"""
|
||||
Returns all substitution records on or after `from_date` (default: today).
|
||||
Use `?from_date=2026-06-01` to look back further.
|
||||
"""
|
||||
if from_date is not None:
|
||||
try:
|
||||
date.fromisoformat(from_date)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid date format '{from_date}'. Use YYYY-MM-DD.",
|
||||
)
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
records = get_substitution_service().list_all(from_date)
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(records),
|
||||
"substitutions": records,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] list_substitutions: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/substitution/{sub_date}/{absent_rider_id}",
|
||||
summary="Cancel a rider substitution",
|
||||
)
|
||||
def cancel_substitution(
|
||||
sub_date: str = Path(..., example="2026-06-20"),
|
||||
absent_rider_id: int = Path(..., example=101),
|
||||
):
|
||||
"""
|
||||
Remove a substitution record. The sub rider will no longer inherit the
|
||||
absent rider's profile on that date.
|
||||
"""
|
||||
try:
|
||||
date.fromisoformat(sub_date)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid date format '{sub_date}'. Use YYYY-MM-DD.",
|
||||
)
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
removed = get_substitution_service().cancel(sub_date, absent_rider_id)
|
||||
if not removed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No substitution found for absent_rider_id={absent_rider_id} on {sub_date}.",
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Substitution cancelled: rider {absent_rider_id} on {sub_date}.",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] cancel_substitution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Reference in New Issue
Block a user