""" 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