267 lines
10 KiB
Go
267 lines
10 KiB
Go
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,
|
|
})
|
|
}
|