362 lines
13 KiB
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
|
|
}
|