From f80479186405ee7a8397f5ef96cb07e4d661eb67 Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 11 Aug 2026 15:37:13 +0530 Subject: [PATCH] feat: Doormile stop-sequencing endpoint POST /api/v1/optimization/doormile/sequence. Same optimizer, same riders, same kitchens, same step semantics as the provider flow -- it only differs in the shape of the payload. Doormile keys on bookingid and sends pickuplatitude; the provider flow keys on deliveryid and sends pickuplat. Rather than making Doormile translate itself into jupiter's vocabulary on the way in and back out, it gets an endpoint in its own terms. The routing is not reimplemented: this maps onto the internal shape, calls the same optimize_provider_payload, and maps back. start_coords is passed through, so a run starts from the kitchen instead of being inferred from whichever stop happens to be first. /optimization/createdeliveries is deliberately untouched -- jupiter is live on it and tightening its body would break real deliveries. But that endpoint takes list[dict], so a payload with the wrong field names returns HTTP 200 "Success" with every coordinate defaulted to 0.0, no reordering and all distances zero. Nothing depends on the new endpoint yet, so it validates strictly and rejects that outright. It also rejects 0,0 coordinates: inside the valid range, but a point in the Atlantic that drags an entire route toward it. Single stop short-circuits to step 1 rather than spending an OR-Tools solve. Duplicate bookingids are refused, since results are keyed back by bookingid and a duplicate makes the caller's mapping ambiguous. Steps for bookings the caller never sent are dropped -- callers write these onto their own rows. Co-Authored-By: Claude Opus 5 (1M context) --- app/main.py | 3 +- app/routes/__init__.py | 2 + app/routes/doormile.py | 259 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 app/routes/doormile.py diff --git a/app/main.py b/app/main.py index dde5008..b95af96 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 +from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router, doormile_router # --------------------------------------------------------------------------- # Logging @@ -162,6 +162,7 @@ app.include_router(ml_router) app.include_router(ml_web_router) app.include_router(batch_analytics_router) app.include_router(riders_router) +app.include_router(doormile_router) @app.get("/", tags=["Root"]) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 870e2ce..47faef2 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -6,6 +6,7 @@ from .cache import router as cache_router 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 __all__ = [ "optimization_router", @@ -15,4 +16,5 @@ __all__ = [ "ml_web_router", "batch_analytics_router", "riders_router", + "doormile_router", ] diff --git a/app/routes/doormile.py b/app/routes/doormile.py new file mode 100644 index 0000000..5b6cc3e --- /dev/null +++ b/app/routes/doormile.py @@ -0,0 +1,259 @@ +""" +Doormile stop sequencing. + +Same optimizer, same riders, same kitchens, same step semantics as the provider +(jupiter) flow — this only differs in the shape of the payload it speaks. +Doormile's booking model uses `pickuplatitude` / `deliverylatitude` and keys on +`bookingid`; jupiter uses `pickuplat` / `deliverylat` and `deliveryid`. Rather +than making Doormile translate itself into jupiter's vocabulary on the way in +and back out again, it gets an endpoint in its own terms. + +The routing itself is not reimplemented: this maps Doormile's fields onto the +internal shape, hands them to the same RouteOptimizer the provider flow uses, +and maps the answer back. + +Deliberate difference from /optimization/createdeliveries: this endpoint +validates its body strictly. That endpoint takes `list[dict]` and must keep +doing so — jupiter is live on it and tightening it would break real deliveries. +The consequence there is that a payload with the wrong field names returns +HTTP 200 "Success" with every coordinate silently defaulted to 0.0, no +reordering, and all distances zero. Nothing is on this endpoint yet, so it can +reject that outright instead of pretending to have worked. +""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, status +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.controllers.route_controller import RouteController + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/optimization/doormile", tags=["Doormile"]) + + +def get_route_controller() -> RouteController: + return RouteController() + + +# --------------------------------------------------------------------------- +# Request +# --------------------------------------------------------------------------- + +class DoormileStop(BaseModel): + """One Doormile booking awaiting sequencing.""" + + bookingid: int = Field(..., description="Doormile bookingid; echoed back so the caller can key on it", examples=[101]) + bookingno: Optional[str] = Field(None, description="Human-readable booking number", examples=["DM-BK-A119BA6F-85923"]) + bookingassignmentid: Optional[int] = Field( + None, description="Assignment row to write the step onto, when the caller tracks stops per assignment" + ) + + pickuplatitude: float = Field(..., ge=-90, le=90, examples=[11.004500]) + pickuplongitude: float = Field(..., ge=-180, le=180, examples=[76.961200]) + deliverylatitude: float = Field(..., ge=-90, le=90, examples=[11.051000]) + deliverylongitude: float = Field(..., ge=-180, le=180, examples=[76.930000]) + + @model_validator(mode="after") + def reject_null_island(self): + """ + 0,0 is inside the valid coordinate range but is a point in the Atlantic, + not a delivery address. It is what a missing or unparsed field looks + like, and letting one through drags the whole route toward it and + silently wrecks the ordering for every other stop. + """ + if self.pickuplatitude == 0 and self.pickuplongitude == 0: + raise ValueError(f"booking {self.bookingid}: pickup coordinates are 0,0") + if self.deliverylatitude == 0 and self.deliverylongitude == 0: + raise ValueError(f"booking {self.bookingid}: delivery coordinates are 0,0") + return self + + +class DoormileSequenceRequest(BaseModel): + tenantid: Optional[int] = Field(None, examples=[13]) + tenantlocationid: Optional[int] = Field(None, description="Client site (e.g. a DailyGrubs kitchen) the run starts from", examples=[20]) + mileruserid: Optional[int] = Field(None, description="Rider these stops belong to; carried through to logs only", examples=[38]) + + startlatitude: Optional[float] = Field(None, ge=-90, le=90, description="Where the rider starts — normally the kitchen. Defaults to the first stop's pickup.") + startlongitude: Optional[float] = Field(None, ge=-180, le=180) + + bookings: List[DoormileStop] = Field(..., min_length=1) + + @field_validator("bookings") + @classmethod + def reject_duplicate_bookings(cls, v): + """ + Results are keyed back by bookingid. A duplicate would make the caller's + mapping ambiguous — two steps claiming the same booking — so refuse it + rather than pick one arbitrarily. + """ + ids = [b.bookingid for b in v] + dupes = {i for i in ids if ids.count(i) > 1} + if dupes: + raise ValueError(f"duplicate bookingid(s) in request: {sorted(dupes)}") + return v + + +# --------------------------------------------------------------------------- +# Response +# --------------------------------------------------------------------------- + +class DoormileSequencedStop(BaseModel): + bookingid: int + bookingno: Optional[str] = None + bookingassignmentid: Optional[int] = None + step: int = Field(..., description="1..N — the order to run the stops in") + previouskms: float = Field(..., description="Road distance from the previous stop") + cumulativekms: float + actualkms: float = Field(..., description="Direct pickup-to-delivery distance for this stop") + etaminutes: int + cumulativeeta: int + + +class DoormileSequenceResponse(BaseModel): + success: bool = True + tenantid: Optional[int] = None + mileruserid: Optional[int] = None + stopcount: int + totalkms: float + totaleta: int + stops: List[DoormileSequencedStop] + + +# --------------------------------------------------------------------------- +# Coercion helpers +# +# The optimizer returns the same logical value as a float in one field and a +# string in another (previouskms as a number, actualkms and eta as strings, +# sometimes decimal). Bind those to concrete types and half the response +# silently becomes zero. +# --------------------------------------------------------------------------- + +def _as_float(v) -> float: + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def _as_int(v) -> int: + # Parse as float first: "20.0" is common and int("20.0") raises, which would + # turn a real step number into 0 and drop the stop. + try: + return int(float(v)) + except (TypeError, ValueError): + return 0 + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + +@router.post( + "/sequence", + response_model=DoormileSequenceResponse, + status_code=status.HTTP_200_OK, + summary="Order a Doormile rider's stops", + description=( + "Takes Doormile bookings and returns them in the order they should be run, " + "with road distances and ETAs. Same optimizer as the provider flow; only the " + "payload shape differs. Send a rider's whole active stop set — sequencing a " + "subset produces an order that is optimal for the subset and wrong for the run." + ), +) +async def sequence_doormile_stops( + payload: DoormileSequenceRequest, + controller: RouteController = Depends(get_route_controller), +) -> DoormileSequenceResponse: + # A single stop has no ordering to compute. Return it as step 1 rather than + # spending an OR-Tools solve on it. + if len(payload.bookings) == 1: + b = payload.bookings[0] + return DoormileSequenceResponse( + tenantid=payload.tenantid, + mileruserid=payload.mileruserid, + stopcount=1, + totalkms=0.0, + totaleta=0, + stops=[ + DoormileSequencedStop( + bookingid=b.bookingid, + bookingno=b.bookingno, + bookingassignmentid=b.bookingassignmentid, + step=1, + previouskms=0.0, + cumulativekms=0.0, + actualkms=0.0, + etaminutes=0, + cumulativeeta=0, + ) + ], + ) + + # Doormile field names -> the internal shape the optimizer reads. + # deliveryid carries bookingid out and back: it is the field the optimizer + # echoes, so it is how the answer is keyed to the caller's rows. + by_id = {b.bookingid: b for b in payload.bookings} + orders = [ + { + "deliveryid": b.bookingid, + "orderid": b.bookingno or str(b.bookingid), + "pickuplat": b.pickuplatitude, + "pickuplong": b.pickuplongitude, + "deliverylat": b.deliverylatitude, + "deliverylong": b.deliverylongitude, + } + for b in payload.bookings + ] + + start_coords = None + if payload.startlatitude is not None and payload.startlongitude is not None: + start_coords = (payload.startlatitude, payload.startlongitude) + + optimized = await controller.route_optimizer.optimize_provider_payload( + orders, start_coords=start_coords + ) + + stops: List[DoormileSequencedStop] = [] + for item in optimized: + bid = _as_int(item.get("deliveryid")) + src = by_id.get(bid) + if src is None: + # Never return a step for a booking the caller did not send: the + # caller writes these straight onto its own rows. + logger.warning("Doormile sequencing: optimizer returned unknown bookingid %s", bid) + continue + step = _as_int(item.get("step")) + if step <= 0: + continue + stops.append( + DoormileSequencedStop( + bookingid=bid, + bookingno=src.bookingno, + bookingassignmentid=src.bookingassignmentid, + step=step, + previouskms=_as_float(item.get("previouskms")), + cumulativekms=_as_float(item.get("cumulativekms")), + actualkms=_as_float(item.get("actualkms")), + etaminutes=_as_int(item.get("eta")), + cumulativeeta=_as_int(item.get("cumulative_eta")), + ) + ) + + stops.sort(key=lambda s: s.step) + + logger.info( + "Doormile sequencing: tenant=%s rider=%s in=%d out=%d", + payload.tenantid, payload.mileruserid, len(payload.bookings), len(stops), + ) + + return DoormileSequenceResponse( + tenantid=payload.tenantid, + mileruserid=payload.mileruserid, + stopcount=len(stops), + totalkms=stops[-1].cumulativekms if stops else 0.0, + totaleta=stops[-1].cumulativeeta if stops else 0, + stops=stops, + )