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
249 lines
9.8 KiB
Python
249 lines
9.8 KiB
Python
"""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,
|
|
}
|