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:
@@ -28,7 +28,7 @@ from app.core.exception_handlers import (
|
|||||||
)
|
)
|
||||||
from app.core.exceptions import APIException
|
from app.core.exceptions import APIException
|
||||||
from app.middleware.request_id import RequestIDMiddleware
|
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
|
# Logging
|
||||||
@@ -184,6 +184,7 @@ app.include_router(ml_web_router)
|
|||||||
app.include_router(batch_analytics_router)
|
app.include_router(batch_analytics_router)
|
||||||
app.include_router(riders_router)
|
app.include_router(riders_router)
|
||||||
app.include_router(doormile_router)
|
app.include_router(doormile_router)
|
||||||
|
app.include_router(nagercoil_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["Root"])
|
@app.get("/", tags=["Root"])
|
||||||
|
|||||||
@@ -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 .batch_analytics import router as batch_analytics_router
|
||||||
from .riders import router as riders_router
|
from .riders import router as riders_router
|
||||||
from .doormile import router as doormile_router
|
from .doormile import router as doormile_router
|
||||||
|
from .nagercoil import router as nagercoil_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"optimization_router",
|
"optimization_router",
|
||||||
@@ -17,4 +18,5 @@ __all__ = [
|
|||||||
"batch_analytics_router",
|
"batch_analytics_router",
|
||||||
"riders_router",
|
"riders_router",
|
||||||
"doormile_router",
|
"doormile_router",
|
||||||
|
"nagercoil_router",
|
||||||
]
|
]
|
||||||
|
|||||||
248
app/routes/nagercoil.py
Normal file
248
app/routes/nagercoil.py
Normal 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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user