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) <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-11 15:37:13 +05:30
parent 7d60556131
commit f804791864
3 changed files with 263 additions and 1 deletions

View File

@@ -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 from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router, doormile_router
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -162,6 +162,7 @@ app.include_router(ml_router)
app.include_router(ml_web_router) 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.get("/", tags=["Root"]) @app.get("/", tags=["Root"])

View File

@@ -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 .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
__all__ = [ __all__ = [
"optimization_router", "optimization_router",
@@ -15,4 +16,5 @@ __all__ = [
"ml_web_router", "ml_web_router",
"batch_analytics_router", "batch_analytics_router",
"riders_router", "riders_router",
"doormile_router",
] ]

259
app/routes/doormile.py Normal file
View File

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