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
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-24 10:34:12 +05:30
parent 531185cf66
commit f6d339a33f
14 changed files with 541 additions and 34 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -246,7 +248,7 @@ func UpdateMilerProfile(c *fiber.Ctx) error {
db.DB.Model(&models.AppUser{}).
Where("email = ? AND userid <> ?", *req.Email, milerUserID).Count(&clash)
if clash > 0 {
return utils.Conflict(c, "that email is already in use")
return utils.Fail(c, fiber.StatusConflict, constants.ErrEmailInUse, "that email is already in use")
}
if err := db.DB.Model(&models.AppUser{}).
Where("userid = ?", milerUserID).Update("email", *req.Email).Error; err != nil {
@@ -364,7 +366,70 @@ func GetMilerAssignments(c *fiber.Ctx) error {
return utils.Internal(c, "failed to fetch assignments")
}
return utils.List(c, assignments, int64(len(assignments)))
// Enrich each assignment with its booking's consignment id + status so the app
// can act on the consignment (deliver/skip/start-delivery) straight from this
// list, without the extra per-order lookup it does today. Batched to two
// queries regardless of how many stops the rider holds.
bookingIDs := make([]int, 0, len(assignments))
for _, a := range assignments {
bookingIDs = append(bookingIDs, a.Bookingid)
}
type bookingRow struct {
Bookingid int
Consignmentid *int
Status string
}
bookingByID := map[int]bookingRow{}
consignmentIDs := make([]int, 0, len(bookingIDs))
if len(bookingIDs) > 0 {
var rows []bookingRow
db.DB.Model(&models.PickupBooking{}).
Select("bookingid, consignmentid, status").
Where("bookingid IN ?", bookingIDs).Scan(&rows)
for _, r := range rows {
bookingByID[r.Bookingid] = r
if r.Consignmentid != nil {
consignmentIDs = append(consignmentIDs, *r.Consignmentid)
}
}
}
consignmentStatusByID := map[int]string{}
if len(consignmentIDs) > 0 {
type cnRow struct {
Consignmentid int
Status string
}
var rows []cnRow
db.DB.Model(&models.Consignment{}).
Select("consignmentid, status").
Where("consignmentid IN ?", consignmentIDs).Scan(&rows)
for _, r := range rows {
consignmentStatusByID[r.Consignmentid] = r.Status
}
}
// Embedded so every existing assignment field stays flat at the top level and
// the app's current parsing is unaffected; the three new keys are additive.
type enrichedAssignment struct {
models.BookingAssignment
Consignmentid *int `json:"consignmentid"`
Consignmentstatus string `json:"consignmentstatus"`
Bookingstatus string `json:"bookingstatus"`
}
out := make([]enrichedAssignment, 0, len(assignments))
for _, a := range assignments {
e := enrichedAssignment{BookingAssignment: a}
if b, ok := bookingByID[a.Bookingid]; ok {
e.Bookingstatus = b.Status
e.Consignmentid = b.Consignmentid
if b.Consignmentid != nil {
e.Consignmentstatus = consignmentStatusByID[*b.Consignmentid]
}
}
out = append(out, e)
}
return utils.List(c, out, int64(len(out)))
}
func GetMilerAssignmentDetails(c *fiber.Ctx) error {
@@ -437,6 +502,10 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to commit assignment acceptance")
}
// Accepting a stop moves it into the active set the optimizer orders over, so
// re-sequence the rider off the request path. No-op below two active stops.
routing.SequenceMilerStopsAsync(milerUserID)
if booking.Bookingid != 0 {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
@@ -612,7 +681,24 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
var booking models.PickupBooking
if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "assigned booking not found")
return utils.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
}
// Persist the arrival on the booking itself, not only on the miler's
// availability flag — the reached action used to leave no durable trace, so
// a rider who reopened the app after "I've arrived" was back at the pre-
// arrival step. Gated with the rest of the new flow: the Arrived_At_Pickup
// status only appears once the app understands it. Only advance from a genuine
// pre-pickup state; never walk an already-picked-up booking back.
if collectedStateEnabled() &&
booking.Status != constants.BookingPickedUp &&
booking.Status != constants.BookingConvertedConsignment {
booking.Status = constants.BookingArrivedAtPickup
booking.Updatedat = time.Now()
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record arrival")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
@@ -624,7 +710,10 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm arrival")
}
return utils.Message(c, "arrival at customer confirmed")
return utils.OK(c, fiber.Map{
"bookingid": booking.Bookingid,
"status": booking.Status,
})
}
func BookingParcelConfirm(c *fiber.Ctx) error {
@@ -744,6 +833,24 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool {
return pickupPincode[:3] == deliveryPincode[:3]
}
// collectedStateEnabled gates the two-step hyperlocal delivery flow
// (Collected_By_Miler → start-delivery → Out_for_Delivery) and its knock-on
// changes: OTP issued at start-delivery instead of pickup, and Arrived_At_Pickup
// persisted on the reached action. Default OFF so this code deploys without
// changing behaviour — a hyperlocal pickup still goes straight to
// Out_for_Delivery and the *current* rider app keeps working. It MUST stay off
// until a rider-app build that calls start-delivery is live; flipping it early
// would strand every collected parcel in a state the old app can't advance.
//
// Read at request time (env MILER_COLLECTED_STATE_ENABLED=true) so it can be
// turned on the moment the app ships without a redeploy. The additive parts of
// this work — consignmentid on the lists, the GET consignment endpoint, stable
// error codes, idempotency — are NOT gated; they are safe for the old app and
// are what fixes the deliver-button P0 on their own.
func collectedStateEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_COLLECTED_STATE_ENABLED"), "true")
}
func BookingPickupComplete(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
@@ -814,12 +921,22 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean
// no hub-to-hub tripsheet leg is needed, so the same miler goes straight
// to final-mile delivery instead of parking the consignment at the hub.
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
consignmentStatus := constants.ConsignmentInwardedAtHub
if isHyperlocal(booking.Pickuppincode, booking.Deliverypincode) {
consignmentStatus = constants.ConsignmentOutForDelivery
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// The consignment's tenant is the booking's own tenant (set explicitly at
@@ -871,10 +988,11 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
// A hyperlocal parcel goes straight out for delivery, so its receiver OTP has
// to exist before this transaction commits. Anything routed via a hub gets
// its OTP when it actually leaves for the final mile instead. Only issued
// for clients that ask for it — see Tenant.Requiredeliveryotp.
// A parcel that goes straight out for delivery here (collected-state flow off)
// needs its receiver OTP before commit — same as before. When the flow is on,
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
@@ -906,8 +1024,15 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to record consignment history")
}
// A hyperlocal parcel is still in the rider's hands (they will deliver it), so
// they stay Picked_Up and out of the assignment pool until they finish. A
// hub-routed parcel was dropped at the hub, so the rider frees up.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler {
postPickupAvailability = constants.MilerPickedUp
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
@@ -916,6 +1041,10 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to complete pickup")
}
// If an OTP was issued here (parcel went straight out for delivery), it goes to
// the receiver in this notification — the rider is told it at the door. When
// the collected-state flow is on, no OTP exists yet and the notification is
// just "collected"; the OTP rides the start-delivery notification instead.
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
@@ -923,8 +1052,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
// The OTP goes to the receiver and only the receiver — the rider has to
// be told it at the door, which is what makes it proof of handover.
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
@@ -935,12 +1062,31 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
return utils.OK(c, fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"booking_no": booking.Bookingno,
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": pickupNextAction(consignment.Status),
})
}
// pickupNextAction tells the app what the rider does next after a pickup, so it
// doesn't have to encode the hub-vs-hyperlocal branch itself:
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
// - Inwarded_at_Hub → handed to the hub, done for this rider
func pickupNextAction(consignmentStatus string) string {
switch consignmentStatus {
case constants.ConsignmentCollectedByMiler:
return "start_delivery"
case constants.ConsignmentOutForDelivery:
return "deliver"
default:
return "handed_to_hub"
}
}
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))