// 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 }