264 lines
9.3 KiB
Python
264 lines
9.3 KiB
Python
"""
|
||
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
|