The client was speaking jupiter's provider dialect -- deliveryid, pickuplat, coordinates as strings -- because that was the only endpoint the Route Optimization API offered. rider-bike now has /api/v1/optimization/doormile/sequence, which takes bookingid and pickuplatitude, so the translation layer is gone. The new endpoint validates its body; the provider one cannot, because jupiter is live on it and tightening it would break real deliveries. That matters here: sending the provider endpoint the wrong field names returns HTTP 200 "Success" with every coordinate defaulted to 0.0, no reordering and all distances zero. The Doormile endpoint rejects that outright, and rejects 0,0 coordinates, which are inside the valid range but are a point in the Atlantic that drags a whole route toward it. Responses now come back properly typed, so the loose float/string coercion is deleted rather than kept for a shape that no longer arrives. Tests replaced to match: they run the real client against a stub server and pin the outbound field names, the mapping back onto assignment ids, that steps for assignments we never sent are discarded, that step 0 is not persisted as a position, and that a failed optimise surfaces an error instead of quietly looking like success. Needs rider-bike deployed first; until then sequencing fails best-effort, which leaves bookings assigned but unordered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
9.0 KiB
Go
281 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"
|
|
"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 (
|
|
// Doormile's own endpoint on the Route Optimization API. It speaks
|
|
// Doormile's vocabulary (bookingid, pickuplatitude) rather than the
|
|
// provider one jupiter uses (deliveryid, pickuplat), validates its body,
|
|
// and returns properly typed numbers. The provider endpoint is left alone:
|
|
// jupiter is live on it.
|
|
optimizePath = "/api/v1/optimization/doormile/sequence"
|
|
|
|
// 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 Doormile endpoint takes real coordinates rather than the provider
|
|
// endpoint's strings, and rejects a body it cannot read instead of returning
|
|
// HTTP 200 with everything silently zeroed — which is what the provider
|
|
// endpoint does when the field names are wrong, and why this one exists.
|
|
type optimizeRequestItem struct {
|
|
Bookingid int `json:"bookingid"`
|
|
Bookingno string `json:"bookingno,omitempty"`
|
|
Bookingassignmentid int `json:"bookingassignmentid,omitempty"`
|
|
Pickuplatitude float64 `json:"pickuplatitude"`
|
|
Pickuplongitude float64 `json:"pickuplongitude"`
|
|
Deliverylatitude float64 `json:"deliverylatitude"`
|
|
Deliverylongitude float64 `json:"deliverylongitude"`
|
|
}
|
|
|
|
type optimizeRequest struct {
|
|
Tenantid int `json:"tenantid,omitempty"`
|
|
Mileruserid int `json:"mileruserid,omitempty"`
|
|
Bookings []optimizeRequestItem `json:"bookings"`
|
|
}
|
|
|
|
type optimizeResponseStop struct {
|
|
Bookingid int `json:"bookingid"`
|
|
Bookingassignmentid int `json:"bookingassignmentid"`
|
|
Step int `json:"step"`
|
|
Previouskms float64 `json:"previouskms"`
|
|
Cumulativekms float64 `json:"cumulativekms"`
|
|
Etaminutes int `json:"etaminutes"`
|
|
Cumulativeeta int `json:"cumulativeeta"`
|
|
}
|
|
|
|
type optimizeResponse struct {
|
|
Success bool `json:"success"`
|
|
Stopcount int `json:"stopcount"`
|
|
Totalkms float64 `json:"totalkms"`
|
|
Totaleta int `json:"totaleta"`
|
|
Stops []optimizeResponseStop `json:"stops"`
|
|
}
|
|
|
|
// 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{
|
|
Bookingid: s.BookingID,
|
|
Bookingno: s.BookingNo,
|
|
Bookingassignmentid: s.AssignmentID,
|
|
Pickuplatitude: s.PickupLat,
|
|
Pickuplongitude: s.PickupLng,
|
|
Deliverylatitude: s.DeliveryLat,
|
|
Deliverylongitude: s.DeliveryLng,
|
|
})
|
|
}
|
|
|
|
body, err := json.Marshal(optimizeRequest{Bookings: 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.Success || len(out.Stops) == 0 {
|
|
return nil, fmt.Errorf("optimizer returned no sequence")
|
|
}
|
|
|
|
known := make(map[int]struct{}, len(stops))
|
|
for _, s := range stops {
|
|
known[s.AssignmentID] = struct{}{}
|
|
}
|
|
|
|
results := make([]Result, 0, len(out.Stops))
|
|
for _, d := range out.Stops {
|
|
if _, ok := known[d.Bookingassignmentid]; !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 assignment",
|
|
"bookingassignmentid", d.Bookingassignmentid, "bookingid", d.Bookingid)
|
|
continue
|
|
}
|
|
if d.Step <= 0 {
|
|
continue
|
|
}
|
|
results = append(results, Result{
|
|
AssignmentID: d.Bookingassignmentid,
|
|
Step: d.Step,
|
|
PreviousKM: d.Previouskms,
|
|
CumulativeKM: d.Cumulativekms,
|
|
ETAMinutes: d.Etaminutes,
|
|
CumulativeETA: d.Cumulativeeta,
|
|
})
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return nil, fmt.Errorf("optimizer returned no usable steps")
|
|
}
|
|
return results, nil
|
|
}
|