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