package controllers import ( "math" "doormile/constants" "doormile/db" "doormile/internal/routing" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) // The shared batch-assign solver. // // This was the body of HubBatchAssign. It is lifted out so the admin console // can run the same solver, because the console's agent layer needs it and the // hub route is unreachable from an admin login. // // ─── Why the console could not use any existing route ────────────────────── // // The ops-layer skills raise findings that propose assigning a set of orders // (SlaGuardianSkill's `assignMiler`), and that proposal has had no executor at // all. The two candidate routes both failed, for different reasons: // // POST /hub/bookings/batch-assign sits behind HubStaffAuth, which refuses // every token whose role is not 6. From the // admin console it 403s on every click. // POST /admin/bookings/:id/assign-miler // needs a CHOSEN rider per booking // ({mileruserid}), and a finding does not // pick one — it names orders, not riders. // // So the console needed the hub route's SOLVER (which picks riders itself) with // the admin route's AUTH. Extracting the solver gives both routes one // implementation; a second copy would be a third definition of "who gets this // booking", after internal/assignment's AI path and AssignMilerToBooking. // // ─── What this is NOT ────────────────────────────────────────────────────── // // A greedy nearest-available-rider heuristic over haversine distance, capped // per rider. Deliberately not internal/assignment's selectMilerWithAI (which // reasons about hub load and on-time rate), and deliberately not a multi-stop // VRP solver. It clears a queue; it does not optimise one. Stop ORDER comes // afterwards from the Route Optimization API. // batchRiderScope decides which riders are candidates and which bookings are in // play. The hub and admin routes differ only here, which is the entire reason // this split works. type batchRiderScope struct { // Bookings narrows the pending-booking query. Hub: its own pincode prefix // and hub staff's tenant. Admin: the console login's tenant, if any. Bookings func(*gorm.DB) *gorm.DB // Riders narrows the rider query. Hub: riders on duty AT that hub. Admin: // riders on duty anywhere, because an admin batch is not hub-bound. Riders func(*gorm.DB) *gorm.DB // ActorID is recorded as the assigner on every BookingAssignment, so an // agent-proposed assignment is attributable to the operator who confirmed // it rather than appearing to come from nowhere. ActorID int } // BatchAssignResult is what both routes return. type BatchAssignResult struct { Assigned int `json:"assigned"` Skipped int `json:"skipped"` Riderssequenced int `json:"riderssequenced"` Results []fiber.Map `json:"results"` } // RunBatchAssign is the solver. bookingIDs empty means "everything the scope // allows", which is how the hub route clears its whole queue; the console // always passes an explicit set. func RunBatchAssign(bookingIDs []int, capPerRider int, scope batchRiderScope) (BatchAssignResult, error) { if capPerRider <= 0 { capPerRider = defaultBatchAssignCapPerRider } bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup) if len(bookingIDs) > 0 { bookingQuery = bookingQuery.Where("bookingid IN ?", bookingIDs) } if scope.Bookings != nil { bookingQuery = scope.Bookings(bookingQuery) } var bookings []models.PickupBooking if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil { return BatchAssignResult{}, err } if len(bookings) == 0 { return BatchAssignResult{Results: []fiber.Map{}}, nil } // Every rider on duty, not only the idle ones. capPerRider is what limits a // round; requiring Available made that limit unreachable, because a rider // stopped being Available the moment they took the first booking of the // very batch being built. riderQuery := db.DB.Where("availabilitystatus IN ?", constants.MilerWorkingStatuses) if scope.Riders != nil { riderQuery = scope.Riders(riderQuery) } var riderProfiles []models.MilerProfile if err := riderQuery.Find(&riderProfiles).Error; err != nil { return BatchAssignResult{}, err } candidates := make([]*batchRiderCandidate, 0, len(riderProfiles)) for _, mp := range riderProfiles { // Seed the count with what the rider is ALREADY holding. capPerRider // has to mean "stops in hand", not "stops added by this call" — now // that busy riders are eligible, counting only this call's additions // would hand five more to someone already carrying five. var openStops int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{ constants.AssignmentAssigned, constants.AssignmentAccepted, }). Count(&openStops) candidates = append(candidates, &batchRiderCandidate{ userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude, assigned: int(openStops), }) } results := make([]fiber.Map, 0, len(bookings)) assignedCount, skippedCount := 0, 0 for _, b := range bookings { var nearest *batchRiderCandidate nearestDist := math.MaxFloat64 for _, cand := range candidates { if cand.assigned >= capPerRider { continue } d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon) if d < nearestDist { nearestDist = d nearest = cand } } if nearest == nil { results = append(results, fiber.Map{ "bookingid": b.Bookingid, "bookingno": b.Bookingno, "assigned": false, "reason": "no available rider under capacity", }) skippedCount++ continue } actor := scope.ActorID if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &actor); err != nil { results = append(results, fiber.Map{ "bookingid": b.Bookingid, "bookingno": b.Bookingno, "assigned": false, "reason": err.Error(), }) skippedCount++ continue } nearest.assigned++ results = append(results, fiber.Map{ "bookingid": b.Bookingid, "bookingno": b.Bookingno, "assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist, }) assignedCount++ } // Batch assignment is exactly the case stop-ordering exists for: a rider // walks out of here with several bookings and otherwise no indication of // what order to run them in. // // 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("BatchAssign: stop sequencing failed", "miler_userid", r, "error", err) continue } sequenced++ } return BatchAssignResult{ Assigned: assignedCount, Skipped: skippedCount, Riderssequenced: sequenced, Results: results, }, nil } // AdminBatchAssign — POST /api/v1/admin/bookings/batch-assign // // The admin counterpart of HubBatchAssign, and the executor behind the console // ops layer's `assignMiler` proposal. Same solver, admin auth, and scoped to // the console login's own tenant when there is one (a client login must not // assign another client's parcels). // // Note on behaviour, because it differs from every other console write in the // agent layer: this COMMITS. There is no preview/reconcile step — the same is // true of the hub route it reuses. The console's proposal gate is therefore the // only thing between a finding and a real assignment, which is why the UI must // keep requiring an explicit click. func AdminBatchAssign(c *fiber.Ctx) error { var req struct { Bookingids []int `json:"bookingids"` MaxPerRider int `json:"max_per_rider"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } // Unlike the hub route, an empty set is refused here. The hub's empty case // means "clear this hub's queue", bounded by its pincode prefix; an admin // login has no such bound, so an empty body would mean "assign every // pending booking in the system" — never what a caller intended. if len(req.Bookingids) == 0 { return utils.BadRequest(c, "bookingids is required") } actorID, _ := c.Locals("userid").(int) // The route is registered behind middlewares.DoormileStaffOnly, so a // partner-tenant login never reaches this handler and `own` is always 0 // today. The scoping below is therefore unreachable — kept deliberately, // as defence in depth: assignment is a Fleet Ops write and staff-only is // the current decision, but if that guard is ever relaxed the handler must // not silently start letting one client assign another's parcels. The same // belt-and-braces reasoning the ops-layer intents use for their domain // guards. own := consoleTenantID(c) result, err := RunBatchAssign(req.Bookingids, req.MaxPerRider, batchRiderScope{ ActorID: actorID, Bookings: func(q *gorm.DB) *gorm.DB { if own == 0 { return q // Doormile staff } // A client login sees only its own bookings. A booking with no // tenant can't be proven to belong to them, so it stays invisible — // the same rule canAccessBooking applies to a single booking. return q.Where("tenantid = ?", own) }, // Riders are not narrowed by tenant: riders are Doormile's, not a // client's, and a client login assigning its own parcels still draws // from the whole on-duty fleet. Riders: nil, }) if err != nil { utils.Error("AdminBatchAssign: failed", "error", err) return utils.Internal(c, "failed to assign bookings") } return utils.OK(c, fiber.Map{ "assigned": result.Assigned, "skipped": result.Skipped, "riderssequenced": result.Riderssequenced, "results": result.Results, }) }