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>
This commit is contained in:
Suriya
2026-08-11 15:06:24 +05:30
parent 2cbc9e5b13
commit 0288fb7af8
7 changed files with 451 additions and 10 deletions

View File

@@ -22,6 +22,11 @@ type Config struct {
NatsPassword string
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
// RouteOptimizerURL is the Route Optimization API that orders a rider's
// stops (Valhalla-backed road sequencing). Empty disables sequencing: stops
// stay unordered rather than assignment failing.
RouteOptimizerURL string
// TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that
// are allowed to set X-Forwarded-For. Rate limiting keys on the client IP,
// so behind a proxy this MUST be set — otherwise every request appears to
@@ -53,12 +58,14 @@ func Load() *Config {
NatsUser: getEnv("NATS_USER", "doormile"),
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnv("SMTP_PORT", "465"),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", ""),
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"),
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnv("SMTP_PORT", "465"),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", ""),
}
}

View File

@@ -13,6 +13,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -2018,13 +2019,54 @@ func HubBatchAssign(c *fiber.Ctx) error {
assignedCount++
}
// Batch assignment is exactly the case stop-ordering exists for: a rider
// walks out of here with several bookings and, until now, no indication of
// what order to run them in. Sequence each rider that actually got work.
//
// Best-effort and deliberately after the assignments are committed: the
// optimizer is a separate service over the network, and it failing must
// leave the bookings assigned rather than undoing the batch.
sequenced := 0
for _, r := range ridersAssigned(results) {
if _, err := routing.SequenceMilerStops(r); err != nil {
utils.Warn("HubBatchAssign: stop sequencing failed",
"miler_userid", r, "error", err)
continue
}
sequenced++
}
return utils.OK(c, fiber.Map{
"assigned": assignedCount,
"skipped": skippedCount,
"results": results,
"assigned": assignedCount,
"skipped": skippedCount,
"riderssequenced": sequenced,
"results": results,
})
}
// ridersAssigned pulls the distinct miler user ids out of a batch result set,
// so each rider is sequenced once rather than once per booking they received.
func ridersAssigned(results []fiber.Map) []int {
seen := make(map[int]struct{})
var ids []int
for _, r := range results {
ok, _ := r["assigned"].(bool)
if !ok {
continue
}
id, isInt := r["mileruserid"].(int)
if !isInt {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
// --------------------
// HUB REPORT EXPORT
// --------------------

View File

@@ -326,9 +326,14 @@ func GetMilerAssignments(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var assignments []models.BookingAssignment
// Sequenced stops come first, in the road order internal/routing worked out,
// because that is the order the rider should actually ride them. Anything
// not yet sequenced (step 0 — a single stop, or the optimizer being
// unreachable) falls back to newest-first, which is the old behaviour.
if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Order("assignedat DESC").Find(&assignments).Error; err != nil {
Order("CASE WHEN step > 0 THEN 0 ELSE 1 END ASC, step ASC, assignedat DESC").
Find(&assignments).Error; err != nil {
return utils.Internal(c, "failed to fetch assignments")
}

View File

@@ -0,0 +1,300 @@
// 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
}

View File

@@ -0,0 +1,68 @@
package routing
import "testing"
// The optimizer returns the same logical field as a number in one place and a
// string in another — previouskms came back as 4, actualkms as "5.09", eta as
// "20". Binding those to concrete types would have silently zeroed half the
// response, so the coercion is worth pinning.
func TestAsFloat(t *testing.T) {
cases := []struct {
name string
in interface{}
want float64
}{
{"number", float64(4), 4},
{"decimal string", "5.09", 5.09},
{"integer string", "14", 14},
{"zero string the API sends for missing coords", "0.0", 0},
{"nil", nil, 0},
{"unparseable", "n/a", 0},
{"wrong type", true, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := asFloat(tc.in); got != tc.want {
t.Fatalf("asFloat(%#v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestAsInt(t *testing.T) {
cases := []struct {
name string
in interface{}
want int
}{
{"number", float64(3), 3},
{"integer string", "20", 20},
// Atoi would fail on this and yield 0, which as a step number would
// silently drop the stop from the sequence.
{"decimal string", "20.0", 20},
{"truncates rather than rounds", "20.9", 20},
{"nil", nil, 0},
{"unparseable", "", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := asInt(tc.in); got != tc.want {
t.Fatalf("asInt(%#v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// Coordinates must go out as strings with real precision. Formatting with too
// few decimals would collapse neighbouring delivery addresses onto the same
// point and make the ordering meaningless.
func TestCoordKeepsPrecision(t *testing.T) {
if got := coord(11.0045); got != "11.004500" {
t.Fatalf("coord(11.0045) = %q", got)
}
// ~11m apart in Coimbatore; these must not format identically.
a, b := coord(11.004500), coord(11.004600)
if a == b {
t.Fatalf("distinct coordinates formatted identically: %q", a)
}
}

View File

@@ -13,6 +13,7 @@ import (
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/internal/worker"
"doormile/middlewares"
"doormile/migrations"
@@ -149,6 +150,9 @@ func main() {
// durable consumer, so JetStream hands each booking to exactly one of them.
go assignment.StartAssignmentWorker()
// 9. Point the stop sequencer at the Route Optimization API.
routing.BaseURL = cfg.RouteOptimizerURL
// 7. Startup server in a background thread
go func() {
utils.Info("Server starting", "port", cfg.Port)

View File

@@ -125,6 +125,21 @@ type BookingAssignment struct {
Riderkms float64 `json:"riderkms" gorm:"column:riderkms;default:0"`
Ridercharges float64 `json:"ridercharges" gorm:"column:ridercharges;default:0"`
Bonuspoints int `json:"bonuspoints" gorm:"column:bonuspoints;default:0"`
// Stop sequencing, written by internal/routing from the Route Optimization
// API's road-network ordering. Step is 1..N across a rider's currently
// active assignments — it says what order to run them in, which nothing in
// Doormile decided before: assignment picked *who*, never *in what order*.
//
// Step 0 means "not sequenced yet", not "first". A rider with a single stop
// is never sequenced, and sequencing is best-effort, so 0 is common and must
// not be read as a position.
Step int `json:"step" gorm:"column:step;default:0"`
Previouskms float64 `json:"previouskms" gorm:"column:previouskms;default:0"`
Cumulativekms float64 `json:"cumulativekms" gorm:"column:cumulativekms;default:0"`
Etaminutes int `json:"etaminutes" gorm:"column:etaminutes;default:0"`
Cumulativeeta int `json:"cumulativeeta" gorm:"column:cumulativeeta;default:0"`
Sequencedat *time.Time `json:"sequencedat" gorm:"column:sequencedat"`
}
func (BookingAssignment) TableName() string {