From 1096dfa578385321c2b19e7d22817813a70e9577 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Thu, 3 Sep 2026 12:07:17 +0530 Subject: [PATCH] feat: Nagercoil single-rider assignment endpoint Adds POST /api/v1/optimization/nagercoil/riderassign. Nagercoil runs with one rider, so this skips the matching/VRP/pattern logic in riderassign and straight- assigns every order to the single rider, then reuses the shared TSP sequencer (_sync_optimize_route) so stop order, cumulative kms and per-stop ETAs match the main endpoint's response shape. Rider defaults to Sivakumar Subramani (userid 852, 8248176099) via the NAGERCOIL_RIDER constant; a `rider` object in the request body overrides it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018jhRcey2fxhwz2SuPVm7c5 --- app/main.py | 3 +- app/routes/__init__.py | 2 + app/routes/nagercoil.py | 248 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 app/routes/nagercoil.py diff --git a/app/main.py b/app/main.py index 04439ce..cf99107 100644 --- a/app/main.py +++ b/app/main.py @@ -28,7 +28,7 @@ from app.core.exception_handlers import ( ) from app.core.exceptions import APIException from app.middleware.request_id import RequestIDMiddleware -from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router, doormile_router +from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router, doormile_router, nagercoil_router # --------------------------------------------------------------------------- # Logging @@ -184,6 +184,7 @@ app.include_router(ml_web_router) app.include_router(batch_analytics_router) app.include_router(riders_router) app.include_router(doormile_router) +app.include_router(nagercoil_router) @app.get("/", tags=["Root"]) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 47faef2..cc9d14d 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -7,6 +7,7 @@ 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 from .doormile import router as doormile_router +from .nagercoil import router as nagercoil_router __all__ = [ "optimization_router", @@ -17,4 +18,5 @@ __all__ = [ "batch_analytics_router", "riders_router", "doormile_router", + "nagercoil_router", ] diff --git a/app/routes/nagercoil.py b/app/routes/nagercoil.py new file mode 100644 index 0000000..b4656fb --- /dev/null +++ b/app/routes/nagercoil.py @@ -0,0 +1,248 @@ +"""Nagercoil single-rider assignment endpoint. + +Nagercoil currently operates with exactly one active rider, so the full +matching / VRP / pattern-preassignment machinery in ``riderassign`` is +unnecessary here: every created order goes to that one rider. This endpoint +keeps the same request/response contract as +``POST /api/v1/optimization/riderassign`` but replaces the assignment step +with a straight "all orders → the single rider" hand-off, then reuses the +existing TSP sequencer so the rider still gets an optimised stop order and +per-stop ETAs. + +If Nagercoil later grows to more than one rider, switch its caller back to the +main ``riderassign`` endpoint (or add real matching here) — this route +deliberately assumes a single rider and will reject an ambiguous roster. +""" + +import asyncio +import logging +import time +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Body, Query, status +from dateutil.parser import parse as parse_date + +# Reuse the exact same sequencer + thread pool the main endpoint uses, so the +# stop ordering and cumulative-km output are identical. +from app.routes.optimization import _sync_optimize_route, _route_executor + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/optimization/nagercoil", tags=["Nagercoil"]) + +# Nagercoil currently runs with exactly one rider. Every order goes to him +# unless the caller overrides via a `rider` object in the request body. +NAGERCOIL_RIDER = { + "userid": 852, + "username": "Sivakumar Subramani", + "contactno": "8248176099", +} + + +def _rider_id(r: dict) -> int: + """Extract a rider's numeric id, tolerating the roster's field variants.""" + try: + return int(r.get("userid") or r.get("riderid") or r.get("id") or 0) + except (TypeError, ValueError): + return 0 + + +@router.post( + "/riderassign", + status_code=status.HTTP_200_OK, + summary="Assign all created orders to Nagercoil's single rider", + description=""" + Straight-assigns every order to Nagercoil's one active rider, sequences the + stops with the shared TSP optimizer, and returns the same shape as the main + `/api/v1/optimization/riderassign` endpoint. + + Rider resolution (first match wins): + 1. `rider` object in the body — `{"userid": 555, "username": "...", "contactno": "..."}` + 2. otherwise the single active rider returned by the live roster; if the + roster returns zero or more than one rider this endpoint returns a 200 + with `status: false` and a message, since Nagercoil is single-rider. + + Orders: + - `deliveries` in the body (or a legacy flat list) are used as-is. + - if empty, all `created` orders are fetched from the external API. + """, + responses={ + 200: { + "content": { + "application/json": { + "example": { + "code": 200, + "details": [{"orderid": "...", "userid": 555, "step": 1}], + "message": "Success", + "status": True, + } + } + } + } + }, +) +async def nagercoil_assign( + body: Any = Body(default=None), + reshuffle: bool = Query(False, alias="reshuffle"), +): + from app.services.rider.get_active_riders import fetch_created_orders + from app.services.routing.empirical_eta_calculator import EmpiricalETACalculator + from app.services.routing.realistic_eta_calculator import get_time_of_day_category + + eta_calculator = EmpiricalETACalculator() + _t0 = time.time() + + try: + # ── Parse payload: {"deliveries": [...], "rider": {...}} or flat list ── + payload_rider = None + if isinstance(body, dict): + payload_rider = body.get("rider") or None + orders = body.get("deliveries") or [] + elif isinstance(body, list): + orders = body + else: + orders = [] + + # ── Resolve the single rider ───────────────────────────────────────── + rider = None + if isinstance(payload_rider, dict) and _rider_id(payload_rider): + rider = payload_rider + if not orders: + orders = await fetch_created_orders() + else: + # No rider in the payload — default to Nagercoil's fixed single rider. + rider = dict(NAGERCOIL_RIDER) + if not orders: + orders = await fetch_created_orders() + + rid = _rider_id(rider) + rider_name = rider.get("username", "") or rider.get("name", "") + rider_contact = rider.get("contactno", "") + + if not orders: + logger.info("[Nagercoil] No orders to assign.") + return { + "code": 200, + "details": [], + "message": "No created orders to assign.", + "status": True, + "meta": { + "total_orders": 0, + "assigned_orders": 0, + "rider": {"userid": rid, "username": rider_name}, + }, + } + + logger.info( + f"[Nagercoil] Straight-assigning {len(orders)} orders to rider " + f"{rid} ({rider_name})." + ) + + # ── Sequence the single rider's stops with the shared TSP optimizer ── + loop = asyncio.get_running_loop() + optimized_route = await loop.run_in_executor( + _route_executor, _sync_optimize_route, orders + ) or orders # fall back to input order if the optimizer returns nothing + + # Total distance for the rider (mirrors riderassign) + try: + total_rider_kms = max( + float(o.get("cumulativekms", 0)) for o in optimized_route + ) + except Exception: + total_rider_kms = sum( + float(o.get("actualkms", o.get("kms", 0))) for o in optimized_route + ) + + # ── Enrich each order exactly like riderassign does ────────────────── + for order in optimized_route: + order["userid"] = rid + order["username"] = rider_name + order["rider"] = rider_name + order["ridercontactno"] = rider_contact + order["riderkms"] = str(round(total_rider_kms, 2)) + + pickup_slot_str = ( + order.get("pickupSlot") + or order.get("pickupslot") + or order.get("pickup_slot") + or order.get("pickuptime") + ) + if pickup_slot_str: + try: + pickup_time = parse_date(str(pickup_slot_str)) + if order.get("cumulative_eta"): + eta_mins = int(order["cumulative_eta"]) + else: + dist_km = float( + order.get("cumulativekms") + or order.get("actualkms", order.get("kms", 0)) + ) + step = int(order.get("step", 1)) + order_type = order.get("ordertype", "Economy") + _dcoords = None + try: + _dlat = float(order.get("deliverylat") or order.get("droplat") or 0) + _dlon = float(order.get("deliverylong") or order.get("droplon") or 0) + if _dlat and _dlon: + _dcoords = (_dlat, _dlon) + except (TypeError, ValueError): + _dcoords = None + eta_mins = eta_calculator.calculate_eta( + distance_km=dist_km, + is_first_order=(step == 1), + order_type=order_type, + time_of_day=get_time_of_day_category(), + kitchen=order.get("pickupcustomer") or order.get("locationname"), + drop_coords=_dcoords, + rider_id=rid, + ) + expected_time = pickup_time + timedelta(minutes=eta_mins) + order["expectedDeliveryTime"] = expected_time.strftime( + "%Y-%m-%d %I:%M %p" + ) + order["transitMinutes"] = eta_mins + order["calculationDistanceKm"] = round( + float(order.get("cumulativekms") or order.get("actualkms", 0)), 2 + ) + except Exception as e: + logger.warning( + f"[Nagercoil] ETA calc failed for pickupSlot " + f"'{pickup_slot_str}': {e}" + ) + + _elapsed_ms = (time.time() - _t0) * 1000.0 + logger.info( + f"[API] nagercoil/riderassign ◀ assigned={len(optimized_route)}/" + f"{len(orders)} rider={rid} elapsed={round(_elapsed_ms)}ms" + ) + + return { + "code": 200, + "details": optimized_route, + "message": "Success", + "status": True, + "meta": { + "total_orders": len(orders), + "assigned_orders": len(optimized_route), + "unassigned_orders": 0, + "rider": { + "userid": rid, + "username": rider_name, + "contactno": rider_contact, + }, + "rider_total_kms": round(total_rider_kms, 2), + "reshuffle_mode": reshuffle, + "elapsed_ms": round(_elapsed_ms, 1), + }, + } + + except Exception as e: + logger.error(f"[Nagercoil] assignment error: {e}", exc_info=True) + return { + "code": 500, + "details": [], + "message": "Internal server error during Nagercoil assignment.", + "status": False, + }