updates on the ai agents and time series prediction and updates on the api to

This commit is contained in:
2026-10-09 13:50:30 +05:30
parent 29690c56f2
commit 153be40e5c
42 changed files with 4889 additions and 186 deletions

View 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,
})
}