Files
doormile_backend/internal/routing/optimizer.go
Suriya 0288fb7af8 feat: order a rider's stops using the Route Optimization API
Assignment decided who carried a booking but never what order to run
several of them in -- the one capability jupiter had that Doormile did
not. It turns out we already own the solver: routes.workolik.com is a
live in-house Route Optimization API backed by Valhalla road-network
routing, not the paid third party I had assumed. This is a client for
it, not a solver.

internal/routing posts a rider's active stops and writes back step,
previouskms, cumulativekms and ETA onto BookingAssignment. HubBatchAssign
calls it after committing a batch, which is exactly the case it exists
for: a rider used to walk away with several bookings and no order to run
them in. GetMilerAssignments now returns sequenced stops in step order,
falling back to newest-first for anything unsequenced.

Contract discovered by probing the live service -- the OpenAPI schema
types the body as a bare object array, so the field names are not
documented anywhere. They are pickuplat/pickuplong/deliverylat/
deliverylong, NOT pickuplatitude/deliverylatitude. Sending the wrong
names does not fail: it returns HTTP 200 with every coordinate defaulted
to "0.0", no reordering and all distances zero. That trap is recorded in
a comment so the next person does not lose an afternoon to it.

Numeric fields come back inconsistently typed -- previouskms as a number,
actualkms and eta as strings, some decimal -- so they are decoded loosely
and coerced, with tests pinning the coercion. Steps for deliveryids we
did not send are discarded rather than written, so an echoed or stale id
cannot reorder another rider's work.

Sequencing is best-effort throughout and runs after assignments commit.
The optimizer is a separate service over the network; it being down must
leave bookings assigned but unordered, never undo the batch. Step 0 means
"not sequenced", not "first".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:06:24 +05:30

301 lines
9.0 KiB
Go

// Package routing puts a rider's stops in the order they should actually be
// run. Assignment decides *who* carries a booking; nothing in Doormile decided
// *in what order* a rider with several stops should run them, which is the one
// capability jupiter had that Doormile did not.
//
// It does not solve the routing problem itself. The Route Optimization API
// (routes.workolik.com) already does, backed by Valhalla road-network routing
// rather than straight-line distance, so this is a client and a writer-back.
package routing
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
)
// BaseURL is set from config at startup. Empty disables sequencing entirely,
// which is the correct behaviour when the optimizer is not configured: stops
// simply stay unsequenced rather than assignment failing.
var BaseURL string
const (
optimizePath = "/api/v1/optimization/createdeliveries"
// Road-network sequencing is not instant — a real call for a handful of
// stops took several seconds against Valhalla — but it must not hold a
// console request open indefinitely.
optimizeTimeout = 30 * time.Second
// Below this there is nothing to order.
minStopsToSequence = 2
)
var httpClient = &http.Client{Timeout: optimizeTimeout}
// stop is one assignment awaiting sequencing.
type stop struct {
AssignmentID int
BookingID int
BookingNo string
PickupLat float64
PickupLng float64
DeliveryLat float64
DeliveryLng float64
}
// The optimizer's field names are load-bearing and easy to get wrong:
// pickuplat/deliverylat, NOT pickuplatitude/deliverylatitude. Sending the wrong
// names does not error — it returns HTTP 200 with every coordinate defaulted to
// "0.0", no reordering, and all distances zero. Verified against the live
// service on 2026-08-11. Coordinates go as strings, which is what it expects.
type optimizeRequestItem struct {
Deliveryid int `json:"deliveryid"`
Orderid string `json:"orderid"`
Pickuplat string `json:"pickuplat"`
Pickuplong string `json:"pickuplong"`
Deliverylat string `json:"deliverylat"`
Deliverylong string `json:"deliverylong"`
}
// Numeric fields come back inconsistently typed — previouskms as a number,
// actualkms and eta as strings — so everything numeric is decoded loosely and
// coerced rather than bound to a concrete type.
type optimizeResponse struct {
Code int `json:"code"`
Status bool `json:"status"`
Message string `json:"message"`
Details []map[string]interface{} `json:"details"`
}
// Result is one sequenced stop, keyed back to the assignment it came from.
type Result struct {
AssignmentID int
Step int
PreviousKM float64
CumulativeKM float64
ETAMinutes int
CumulativeETA int
}
// SequenceMilerStops orders the rider's currently active stops and writes the
// result onto their assignments.
//
// Best-effort by design: every failure path logs and returns an error the
// caller is free to ignore. A rider with unsequenced stops is a worse
// experience; a rider with no assignment at all is a broken delivery. The
// second must never be caused by the first.
func SequenceMilerStops(milerUserID int) ([]Result, error) {
if BaseURL == "" {
return nil, nil
}
stops, err := loadActiveStops(milerUserID)
if err != nil {
return nil, err
}
if len(stops) < minStopsToSequence {
return nil, nil
}
results, err := optimize(stops)
if err != nil {
return nil, err
}
now := time.Now()
for _, r := range results {
if err := db.DB.Model(&models.BookingAssignment{}).
Where("bookingassignmentid = ?", r.AssignmentID).
Updates(map[string]interface{}{
"step": r.Step,
"previouskms": r.PreviousKM,
"cumulativekms": r.CumulativeKM,
"etaminutes": r.ETAMinutes,
"cumulativeeta": r.CumulativeETA,
"sequencedat": now,
}).Error; err != nil {
utils.Error("routing: failed to persist stop order",
"assignment_id", r.AssignmentID, "error", err)
}
}
utils.Info("routing: sequenced rider stops",
"miler_userid", milerUserID, "stops", len(results))
return results, nil
}
// loadActiveStops returns the rider's assignments that still have to be run,
// with the coordinates needed to order them.
func loadActiveStops(milerUserID int) ([]stop, error) {
var rows []struct {
Bookingassignmentid int
Bookingid int
Bookingno string
Pickuplatitude float64
Pickuplongitude float64
Deliverylatitude float64
Deliverylongitude float64
}
if err := db.DB.Table("bookingassignments AS ba").
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
b.pickuplatitude, b.pickuplongitude,
b.deliverylatitude, b.deliverylongitude`).
Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid").
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?",
milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Order("ba.assignedat ASC").
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load active stops: %w", err)
}
stops := make([]stop, 0, len(rows))
for _, r := range rows {
// A stop with no coordinates cannot be ordered, and including it would
// let the optimizer treat 0,0 as a real position off the coast of
// Africa — which would wreck the ordering for every other stop.
if r.Pickuplatitude == 0 || r.Pickuplongitude == 0 ||
r.Deliverylatitude == 0 || r.Deliverylongitude == 0 {
utils.Warn("routing: skipping stop with missing coordinates",
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
continue
}
stops = append(stops, stop{
AssignmentID: r.Bookingassignmentid,
BookingID: r.Bookingid,
BookingNo: r.Bookingno,
PickupLat: r.Pickuplatitude,
PickupLng: r.Pickuplongitude,
DeliveryLat: r.Deliverylatitude,
DeliveryLng: r.Deliverylongitude,
})
}
return stops, nil
}
// optimize calls the Route Optimization API and maps its answer back onto our
// assignment ids.
func optimize(stops []stop) ([]Result, error) {
items := make([]optimizeRequestItem, 0, len(stops))
for _, s := range stops {
items = append(items, optimizeRequestItem{
// deliveryid carries our assignment id out and back — it is the only
// field the optimizer echoes that we can key on.
Deliveryid: s.AssignmentID,
Orderid: s.BookingNo,
Pickuplat: coord(s.PickupLat),
Pickuplong: coord(s.PickupLng),
Deliverylat: coord(s.DeliveryLat),
Deliverylong: coord(s.DeliveryLng),
})
}
body, err := json.Marshal(items)
if err != nil {
return nil, fmt.Errorf("marshal stops: %w", err)
}
req, err := http.NewRequest(http.MethodPost, BaseURL+optimizePath, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("call optimizer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("optimizer returned HTTP %d", resp.StatusCode)
}
var out optimizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode optimizer response: %w", err)
}
if !out.Status || len(out.Details) == 0 {
return nil, fmt.Errorf("optimizer reported failure: %s", out.Message)
}
known := make(map[int]struct{}, len(stops))
for _, s := range stops {
known[s.AssignmentID] = struct{}{}
}
results := make([]Result, 0, len(out.Details))
for _, d := range out.Details {
id := asInt(d["deliveryid"])
if _, ok := known[id]; !ok {
// Never write to an assignment we did not send. Without this an
// echoed or stale id could reorder some other rider's work.
utils.Warn("routing: optimizer returned unknown deliveryid", "deliveryid", id)
continue
}
step := asInt(d["step"])
if step <= 0 {
continue
}
results = append(results, Result{
AssignmentID: id,
Step: step,
PreviousKM: asFloat(d["previouskms"]),
CumulativeKM: asFloat(d["cumulativekms"]),
ETAMinutes: asInt(d["eta"]),
CumulativeETA: asInt(d["cumulative_eta"]),
})
}
if len(results) == 0 {
return nil, fmt.Errorf("optimizer returned no usable steps")
}
return results, nil
}
// coord formats a coordinate the way the optimizer expects: a string, with
// enough precision to distinguish neighbouring addresses.
func coord(f float64) string {
return strconv.FormatFloat(f, 'f', 6, 64)
}
func asFloat(v interface{}) float64 {
switch t := v.(type) {
case float64:
return t
case string:
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0
}
return f
}
return 0
}
func asInt(v interface{}) int {
switch t := v.(type) {
case float64:
return int(t)
case string:
// Some numeric fields arrive as strings, and a few of those are decimal
// ("20.0"), so parse as float and truncate rather than Atoi.
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0
}
return int(f)
}
return 0
}