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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user