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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jhRcey2fxhwz2SuPVm7c5
This commit is contained in:
2026-09-03 12:07:17 +05:30
parent 75b175b16f
commit 1096dfa578
3 changed files with 252 additions and 1 deletions

View File

@@ -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"])

View File

@@ -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",
]

248
app/routes/nagercoil.py Normal file
View File

@@ -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,
}