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