Files
doormile_backend/controllers/booking_assignment_service.go
Suriyakumarvijayanayagam f6d339a33f feat: miler delivery-leg fixes — consignmentid, auto route sequencing, admin consignment status
Miler app P0 + contract gaps found in the live audit:

- GET /miler/bookings now returns consignmentid + consignmentstatus on every
  row (nullable), so the app can call deliver/skip/start-delivery straight from
  the list. /miler/assignments is the active-only queue, so this is the
  authoritative fix for stops that have moved onto the delivery leg.
- GET /miler/bookings now returns sequencedat per row: non-null means the
  console/optimizer fixed this stop's order and the app follows step exactly;
  null means no route assigned and the app may fall back to nearest-first.
- Route sequencing (internal/routing) now runs automatically after every
  assignment — customer auto-assign, express auto-assign, manual assign, and
  accept — via SequenceMilerStopsAsync (fire-and-forget, no-op below two active
  stops). Previously only hub batch-assign sequenced, so most riders saw step=0.
- GET /admin/bookings now surfaces the live consignmentstatus alongside the
  frozen booking status, so a Converted_To_Consignment booking can still show
  Out_for_Delivery / Delivered instead of a generic "Active".

Two-step hyperlocal flow (Arrived_At_Pickup, Collected_By_Miler, start-delivery)
stays gated behind MILER_COLLECTED_STATE_ENABLED (default off) until the app
ships; consignmentid/status, GET /miler/consignments/:id, stable error codes and
Idempotency-Key handling are unconditional and safe on the current app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
2026-08-24 10:34:12 +05:30

244 lines
8.6 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/internal/routing"
"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)
// Re-sequence the rider's stops now they hold one more. No-op below two active
// stops; runs off the request path so the optimizer's network call never
// blocks or fails a manual assignment.
routing.SequenceMilerStopsAsync(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
}