updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
266
controllers/batchAssignService.go
Normal file
266
controllers/batchAssignService.go
Normal file
@@ -0,0 +1,266 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user