Files
doormile_backend/internal/routing/optimizer.go

362 lines
13 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/internal/legs"
"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
}
// SequenceMilerStopsAsync re-sequences a rider's active stops in the background.
// Fire-and-forget: the assignment paths call this after they commit, so a rider
// who now holds two or more stops gets an ordered route — without the optimizer's
// network call ever sitting inside, or being able to fail, the assignment request
// itself. Below two active stops SequenceMilerStops is a no-op, so calling it
// after every single assignment is safe and cheap.
func SequenceMilerStopsAsync(milerUserID int) {
go func() {
defer func() {
if r := recover(); r != nil {
utils.Error("routing: async sequencing panicked",
"miler_userid", milerUserID, "error", r)
}
}()
if _, err := SequenceMilerStops(milerUserID); err != nil {
utils.Warn("routing: async sequencing failed",
"miler_userid", milerUserID, "error", err)
}
}()
}
// 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
Pickuppincode string
Deliverypincode string
// The base this rider would hand a hub-routed parcel to: the one the
// booking was routed to if anything set it, otherwise the rider's own.
// Same order of preference as controllers.resolveHandoverHub.
Baselatitude float64
Baselongitude float64
}
// A rider's leg does NOT always end at the address on the booking. For an
// intercity parcel the rider carries it to a base and hands it over there;
// the receiver is somebody else's problem, on another vehicle, days later.
// The base coordinates are joined in here so the sequencer can use them as
// the real end of the leg — see the substitution below.
if err := db.DB.Table("bookingassignments AS ba").
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
b.pickuplatitude, b.pickuplongitude,
b.deliverylatitude, b.deliverylongitude,
b.pickuppincode, b.deliverypincode,
COALESCE(bh.latitude, rh.latitude, 0) AS baselatitude,
COALESCE(bh.longitude, rh.longitude, 0) AS baselongitude`).
Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid").
Joins("LEFT JOIN hubs AS bh ON bh.hubid = b.nearesthubid AND bh.deletedat IS NULL").
Joins("LEFT JOIN milerprofiles AS mp ON mp.userid = ba.mileruserid").
Joins("LEFT JOIN hubs AS rh ON rh.hubid = mp.hubid AND rh.deletedat IS NULL").
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
}
dropLat, dropLng, ok := dropForLeg(
r.Pickuppincode, r.Deliverypincode,
r.Pickuplatitude, r.Pickuplongitude,
r.Deliverylatitude, r.Deliverylongitude,
r.Baselatitude, r.Baselongitude)
if !ok {
// Routed to a base, but no base has usable coordinates. Ordering it
// against the far-away receiver would distort every other stop, so it
// is left out of the route rather than allowed to skew it.
utils.Warn("routing: hub-routed stop has no base coordinates, leaving it unsequenced",
"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: dropLat,
DeliveryLng: dropLng,
})
}
return stops, nil
}
// dropForLeg is where THIS rider's leg ends — which is not always the address
// on the booking.
//
// A hyperlocal parcel ends at the receiver. A hub-routed one ends at a base: the
// rider hands it over there and the receiver is somebody else's leg, on another
// vehicle, possibly days later. Sequencing a hub-routed parcel against the
// receiver's coordinates asks the optimizer to plan a ride to another state —
// wrong on its own terms, since a 430km "stop" is not a stop a rider makes, and
// worse in company: one intercity destination in the set drags the ordering of
// every genuine local stop beside it, because the solver is optimising a journey
// nobody is going to make.
//
// The final destination is not lost. It is simply not this leg.
//
// ok is false when the parcel is hub-routed and no base has usable coordinates.
// The caller drops the stop rather than falling back to the receiver, because a
// stop in the wrong country is more damaging to the route than a missing one.
func dropForLeg(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng, baseLat, baseLng float64) (lat, lng float64, ok bool) {
if legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) {
return dLat, dLng, true
}
if baseLat != 0 || baseLng != 0 {
return baseLat, baseLng, true
}
return 0, 0, false
}
// 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
}