Adds the backend half of the ExpressDispatchAgent flow. Express orders can now be created batch by batch (bulk create only accumulates them, unassigned), then an operator hits one endpoint to hand the whole pending set to the agent for tenant-scoped assignment + road sequencing. The normal B2C flow is untouched. - POST /admin/expressbooking/dispatch: manual trigger. Console-auth, tenant- scoped; gathers the tenant's pending unassigned express orders (or a chosen subset) and publishes express.dispatch_requested. - internal API for the agent: GET /internal/express/riders (tenant's available riders), GET /internal/express/bookings, POST /internal/express/assign (writes the agent's decided assignments with their sequence; re-checks the already- assigned guard so the agent can't double-assign). - booking_assignment_service.go: extracted a behavior-preserving assignMilerTx core; AssignMilerToBooking is unchanged in behavior. assignExpressStops writes a batch, one FCM per rider instead of one per stop. - EXPRESS JetStream stream / express.dispatch_requested subject. - Gated behind EXPRESS_AGENT_ENABLED (default off): deploying this changes nothing until the agent is confirmed running and the flag is flipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
8.3 KiB
Go
237 lines
8.3 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/internal/notify"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// milerStopSequence carries the road-optimized ordering the Route Optimization
|
|
// API produced for one stop. Passed to assignMilerTx when the caller already
|
|
// knows the sequence (the express-batch path), nil when it does not (a plain
|
|
// manual assignment, which leaves the stop unsequenced at step 0).
|
|
type milerStopSequence struct {
|
|
Step int
|
|
Previouskms float64
|
|
Cumulativekms float64
|
|
Etaminutes int
|
|
Cumulativeeta int
|
|
}
|
|
|
|
// assignMilerTx performs the DB half of a miler assignment inside the given
|
|
// transaction: flip the booking to Miler_Assigned, create the BookingAssignment
|
|
// (optionally already sequenced), and mark the miler Assigned. It does not
|
|
// commit, publish, or notify — those are the caller's job, so the same writes
|
|
// can be reused by the single-booking path (AssignMilerToBooking) and the
|
|
// batch express path (assignExpressStops) without duplicating the SQL or firing
|
|
// one notification per stop.
|
|
func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *int, seq *milerStopSequence) (*models.PickupBooking, error) {
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, bookingID).Error; err != nil {
|
|
return nil, fmt.Errorf("booking not found")
|
|
}
|
|
|
|
booking.Status = constants.BookingMilerAssigned
|
|
booking.Assignedmileruserid = &milerUserID
|
|
booking.Updatedat = time.Now()
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update booking: %w", err)
|
|
}
|
|
|
|
assignment := models.BookingAssignment{
|
|
Bookingid: booking.Bookingid,
|
|
Mileruserid: milerUserID,
|
|
Assignedbyuserid: assignedByUserID,
|
|
Assignmentstatus: constants.AssignmentAssigned,
|
|
}
|
|
if seq != nil {
|
|
now := time.Now()
|
|
assignment.Step = seq.Step
|
|
assignment.Previouskms = seq.Previouskms
|
|
assignment.Cumulativekms = seq.Cumulativekms
|
|
assignment.Etaminutes = seq.Etaminutes
|
|
assignment.Cumulativeeta = seq.Cumulativeeta
|
|
assignment.Sequencedat = &now
|
|
}
|
|
if err := tx.Create(&assignment).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create assignment: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update miler availability: %w", err)
|
|
}
|
|
|
|
return &booking, nil
|
|
}
|
|
|
|
// publishAssignmentUpdate emits the best-effort booking.update NATS event and
|
|
// pushes the FCM notification for one assignment. Shared by both assignment
|
|
// paths so the outward side effects stay identical.
|
|
func publishAssignmentUpdate(booking *models.PickupBooking, milerUserID int) {
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"status": booking.Status,
|
|
"miler_id": milerUserID,
|
|
"updated_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var miler models.MilerProfile
|
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
|
if err := notify.SendToDevice(
|
|
miler.Devicetoken,
|
|
"New Pickup Assigned",
|
|
"New booking assigned — tap to view details",
|
|
map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)},
|
|
); err != nil {
|
|
utils.Warn("FCM: failed to notify miler on assignment",
|
|
"miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AssignMilerToBooking is the single source of truth for manually assigning a
|
|
// miler to a pickup booking — shared by the admin console (AdminAssignMiler)
|
|
// and the hub console (HubAssignMiler) so both go through identical DB
|
|
// updates, NATS publish, and FCM notify instead of duplicating the logic.
|
|
func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) {
|
|
tx := db.DB.Begin()
|
|
|
|
booking, err := assignMilerTx(tx, bookingID, milerUserID, assignedByUserID, nil)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, fmt.Errorf("failed to commit miler assignment: %w", err)
|
|
}
|
|
|
|
publishAssignmentUpdate(booking, milerUserID)
|
|
return booking, nil
|
|
}
|
|
|
|
// ExpressStop is one already-decided assignment from the ExpressDispatchAgent:
|
|
// which miler carries which booking, in what road-optimized order. The agent
|
|
// chose the miler and called the Route Optimization API for the sequence; this
|
|
// struct is the writeback contract.
|
|
type ExpressStop struct {
|
|
BookingID int `json:"booking_id"`
|
|
MilerUserID int `json:"miler_user_id"`
|
|
Step int `json:"step"`
|
|
Previouskms float64 `json:"previouskms"`
|
|
Cumulativekms float64 `json:"cumulativekms"`
|
|
Etaminutes int `json:"etaminutes"`
|
|
Cumulativeeta int `json:"cumulativeeta"`
|
|
}
|
|
|
|
// ExpressAssignResult is the per-booking outcome of a batch writeback.
|
|
type ExpressAssignResult struct {
|
|
BookingID int `json:"booking_id"`
|
|
MilerUserID int `json:"miler_user_id"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// assignExpressStops writes a batch of agent-decided assignments. Each stop is
|
|
// its own transaction so one bad booking id cannot roll back the whole batch —
|
|
// the same per-row-independence the bulk-create endpoint gives. Each carries its
|
|
// sequence, so the assignment lands already ordered rather than needing a second
|
|
// sequencing pass. A miler is notified once for the whole batch, not once per
|
|
// stop, so a rider handed five stops gets one push, not five.
|
|
func assignExpressStops(stops []ExpressStop) []ExpressAssignResult {
|
|
results := make([]ExpressAssignResult, 0, len(stops))
|
|
// Preserve first-seen miler order so the summary notification is deterministic.
|
|
notifyBooking := map[int]*models.PickupBooking{}
|
|
notifyOrder := []int{}
|
|
|
|
for _, s := range stops {
|
|
tx := db.DB.Begin()
|
|
booking, err := assignMilerTx(tx, s.BookingID, s.MilerUserID, nil, &milerStopSequence{
|
|
Step: s.Step,
|
|
Previouskms: s.Previouskms,
|
|
Cumulativekms: s.Cumulativekms,
|
|
Etaminutes: s.Etaminutes,
|
|
Cumulativeeta: s.Cumulativeeta,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: "commit failed"})
|
|
continue
|
|
}
|
|
|
|
// booking.update per stop keeps live trackers accurate; the FCM push is
|
|
// deferred and coalesced per miler below.
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"status": booking.Status,
|
|
"miler_id": s.MilerUserID,
|
|
"updated_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, seen := notifyBooking[s.MilerUserID]; !seen {
|
|
notifyOrder = append(notifyOrder, s.MilerUserID)
|
|
}
|
|
notifyBooking[s.MilerUserID] = booking
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: true})
|
|
}
|
|
|
|
for _, milerUserID := range notifyOrder {
|
|
count := 0
|
|
for _, r := range results {
|
|
if r.MilerUserID == milerUserID && r.Success {
|
|
count++
|
|
}
|
|
}
|
|
var miler models.MilerProfile
|
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
|
msg := "New pickups assigned — tap to view your route"
|
|
if count == 1 {
|
|
msg = "New booking assigned — tap to view details"
|
|
}
|
|
if err := notify.SendToDevice(
|
|
miler.Devicetoken,
|
|
"New Pickups Assigned",
|
|
msg,
|
|
map[string]string{"count": fmt.Sprintf("%d", count)},
|
|
); err != nil {
|
|
utils.Warn("FCM: failed to notify miler on express batch assignment",
|
|
"miler_id", milerUserID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|