Files
routesapi/app/routes/batch_analytics.py
2026-06-22 17:40:08 +05:30

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