diff --git a/config/config.go b/config/config.go index 904108f..2318c27 100644 --- a/config/config.go +++ b/config/config.go @@ -22,6 +22,11 @@ type Config struct { NatsPassword string AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082) + // RouteOptimizerURL is the Route Optimization API that orders a rider's + // stops (Valhalla-backed road sequencing). Empty disables sequencing: stops + // stay unordered rather than assignment failing. + RouteOptimizerURL string + // TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that // are allowed to set X-Forwarded-For. Rate limiting keys on the client IP, // so behind a proxy this MUST be set — otherwise every request appears to @@ -53,12 +58,14 @@ func Load() *Config { NatsUser: getEnv("NATS_USER", "doormile"), NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), - TrustedProxies: getEnv("TRUSTED_PROXIES", ""), - SMTPHost: getEnv("SMTP_HOST", ""), - SMTPPort: getEnv("SMTP_PORT", "465"), - SMTPUser: getEnv("SMTP_USER", ""), - SMTPPassword: getEnv("SMTP_PASSWORD", ""), - SMTPFrom: getEnv("SMTP_FROM", ""), + + RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"), + TrustedProxies: getEnv("TRUSTED_PROXIES", ""), + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnv("SMTP_PORT", "465"), + SMTPUser: getEnv("SMTP_USER", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", ""), } } diff --git a/controllers/hubController.go b/controllers/hubController.go index 1404519..e25cf0e 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -13,6 +13,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -2018,13 +2019,54 @@ func HubBatchAssign(c *fiber.Ctx) error { assignedCount++ } + // Batch assignment is exactly the case stop-ordering exists for: a rider + // walks out of here with several bookings and, until now, no indication of + // what order to run them in. Sequence each rider that actually got work. + // + // Best-effort and deliberately after the assignments are committed: the + // optimizer is a separate service over the network, and it failing must + // leave the bookings assigned rather than undoing the batch. + sequenced := 0 + for _, r := range ridersAssigned(results) { + if _, err := routing.SequenceMilerStops(r); err != nil { + utils.Warn("HubBatchAssign: stop sequencing failed", + "miler_userid", r, "error", err) + continue + } + sequenced++ + } + return utils.OK(c, fiber.Map{ - "assigned": assignedCount, - "skipped": skippedCount, - "results": results, + "assigned": assignedCount, + "skipped": skippedCount, + "riderssequenced": sequenced, + "results": results, }) } +// ridersAssigned pulls the distinct miler user ids out of a batch result set, +// so each rider is sequenced once rather than once per booking they received. +func ridersAssigned(results []fiber.Map) []int { + seen := make(map[int]struct{}) + var ids []int + for _, r := range results { + ok, _ := r["assigned"].(bool) + if !ok { + continue + } + id, isInt := r["mileruserid"].(int) + if !isInt { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} + // -------------------- // HUB REPORT EXPORT // -------------------- diff --git a/controllers/milerController.go b/controllers/milerController.go index 926cd29..f11de4b 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -326,9 +326,14 @@ func GetMilerAssignments(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var assignments []models.BookingAssignment + // Sequenced stops come first, in the road order internal/routing worked out, + // because that is the order the rider should actually ride them. Anything + // not yet sequenced (step 0 — a single stop, or the optimizer being + // unreachable) falls back to newest-first, which is the old behaviour. if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). - Order("assignedat DESC").Find(&assignments).Error; err != nil { + Order("CASE WHEN step > 0 THEN 0 ELSE 1 END ASC, step ASC, assignedat DESC"). + Find(&assignments).Error; err != nil { return utils.Internal(c, "failed to fetch assignments") } diff --git a/internal/routing/optimizer.go b/internal/routing/optimizer.go new file mode 100644 index 0000000..6eb8242 --- /dev/null +++ b/internal/routing/optimizer.go @@ -0,0 +1,300 @@ +// 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 +} diff --git a/internal/routing/optimizer_test.go b/internal/routing/optimizer_test.go new file mode 100644 index 0000000..9dfd2a2 --- /dev/null +++ b/internal/routing/optimizer_test.go @@ -0,0 +1,68 @@ +package routing + +import "testing" + +// The optimizer returns the same logical field as a number in one place and a +// string in another — previouskms came back as 4, actualkms as "5.09", eta as +// "20". Binding those to concrete types would have silently zeroed half the +// response, so the coercion is worth pinning. +func TestAsFloat(t *testing.T) { + cases := []struct { + name string + in interface{} + want float64 + }{ + {"number", float64(4), 4}, + {"decimal string", "5.09", 5.09}, + {"integer string", "14", 14}, + {"zero string the API sends for missing coords", "0.0", 0}, + {"nil", nil, 0}, + {"unparseable", "n/a", 0}, + {"wrong type", true, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := asFloat(tc.in); got != tc.want { + t.Fatalf("asFloat(%#v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestAsInt(t *testing.T) { + cases := []struct { + name string + in interface{} + want int + }{ + {"number", float64(3), 3}, + {"integer string", "20", 20}, + // Atoi would fail on this and yield 0, which as a step number would + // silently drop the stop from the sequence. + {"decimal string", "20.0", 20}, + {"truncates rather than rounds", "20.9", 20}, + {"nil", nil, 0}, + {"unparseable", "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := asInt(tc.in); got != tc.want { + t.Fatalf("asInt(%#v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +// Coordinates must go out as strings with real precision. Formatting with too +// few decimals would collapse neighbouring delivery addresses onto the same +// point and make the ordering meaningless. +func TestCoordKeepsPrecision(t *testing.T) { + if got := coord(11.0045); got != "11.004500" { + t.Fatalf("coord(11.0045) = %q", got) + } + // ~11m apart in Coimbatore; these must not format identically. + a, b := coord(11.004500), coord(11.004600) + if a == b { + t.Fatalf("distinct coordinates formatted identically: %q", a) + } +} diff --git a/main.go b/main.go index 0a22e79..bd43cf8 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "doormile/db" "doormile/internal/assignment" "doormile/internal/notify" + "doormile/internal/routing" "doormile/internal/worker" "doormile/middlewares" "doormile/migrations" @@ -149,6 +150,9 @@ func main() { // durable consumer, so JetStream hands each booking to exactly one of them. go assignment.StartAssignmentWorker() + // 9. Point the stop sequencer at the Route Optimization API. + routing.BaseURL = cfg.RouteOptimizerURL + // 7. Startup server in a background thread go func() { utils.Info("Server starting", "port", cfg.Port) diff --git a/models/booking.go b/models/booking.go index 5285132..4964836 100644 --- a/models/booking.go +++ b/models/booking.go @@ -125,6 +125,21 @@ type BookingAssignment struct { Riderkms float64 `json:"riderkms" gorm:"column:riderkms;default:0"` Ridercharges float64 `json:"ridercharges" gorm:"column:ridercharges;default:0"` Bonuspoints int `json:"bonuspoints" gorm:"column:bonuspoints;default:0"` + + // Stop sequencing, written by internal/routing from the Route Optimization + // API's road-network ordering. Step is 1..N across a rider's currently + // active assignments — it says what order to run them in, which nothing in + // Doormile decided before: assignment picked *who*, never *in what order*. + // + // Step 0 means "not sequenced yet", not "first". A rider with a single stop + // is never sequenced, and sequencing is best-effort, so 0 is common and must + // not be read as a position. + Step int `json:"step" gorm:"column:step;default:0"` + Previouskms float64 `json:"previouskms" gorm:"column:previouskms;default:0"` + Cumulativekms float64 `json:"cumulativekms" gorm:"column:cumulativekms;default:0"` + Etaminutes int `json:"etaminutes" gorm:"column:etaminutes;default:0"` + Cumulativeeta int `json:"cumulativeeta" gorm:"column:cumulativeeta;default:0"` + Sequencedat *time.Time `json:"sequencedat" gorm:"column:sequencedat"` } func (BookingAssignment) TableName() string {