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