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

@@ -2086,6 +2086,32 @@ func GetAdminBookings(c *fiber.Ctx) error {
return utils.Internal(c, "failed to fetch bookings")
}
// Surface the live consignment status next to the booking. Once a booking is
// picked up its own status stops moving (it sits at Converted_To_Consignment),
// while the parcel keeps advancing on the consignment — Out_for_Delivery,
// Delivered. Without this the console can only show the frozen booking status
// and a collected order reads as a generic "Active". Batched: one IN query for
// the whole page, not one per row.
consignmentIDs := make([]int, 0, len(bookings))
for _, b := range bookings {
if b.Consignmentid != nil {
consignmentIDs = append(consignmentIDs, *b.Consignmentid)
}
}
if len(consignmentIDs) > 0 {
var consignments []models.Consignment
db.DB.Select("consignmentid, status").Where("consignmentid IN ?", consignmentIDs).Find(&consignments)
statusByConsignment := make(map[int]string, len(consignments))
for _, cn := range consignments {
statusByConsignment[cn.Consignmentid] = cn.Status
}
for i := range bookings {
if bookings[i].Consignmentid != nil {
bookings[i].Consignmentstatus = statusByConsignment[*bookings[i].Consignmentid]
}
}
}
pages := int(math.Ceil(float64(total) / float64(pagesize)))
return c.JSON(fiber.Map{

View File

@@ -8,6 +8,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -124,6 +125,12 @@ func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*m
}
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
}

View File

@@ -1669,7 +1669,7 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map {
switch booking.Status {
case constants.BookingPickedUp, constants.BookingConvertedConsignment:
status = "completed"
case constants.BookingPickupScheduled:
case constants.BookingPickupScheduled, constants.BookingArrivedAtPickup:
status = "in_progress"
}

View File

@@ -313,11 +313,22 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
}
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
var consignmentStatus string
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
consignmentStatus = cn.Status
}
}
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
"consignmentstatus": consignmentStatus,
"pickupaddress": b.Pickupaddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
@@ -332,16 +343,22 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
"paymentmode": paymentMode,
"createdat": b.Createdat,
// Route sequencing — 0/empty when the stop was never sequenced.
// sequencedat is the authoritative-order signal: non-null means the
// console/optimizer fixed this stop's position and the app must follow
// step exactly; null means no route was assigned and the app is free to
// fall back to its own nearest-first ordering.
"step": 0,
"cumulativekms": 0.0,
"etaminutes": 0,
"cumulativeeta": 0,
"sequencedat": nil,
}
if a, ok := seqByBooking[b.Bookingid]; ok {
row["step"] = a.Step
row["cumulativekms"] = a.Cumulativekms
row["etaminutes"] = a.Etaminutes
row["cumulativeeta"] = a.Cumulativeeta
row["sequencedat"] = a.Sequencedat
}
response = append(response, row)
}
@@ -365,6 +382,148 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
return utils.List(c, response, int64(len(response)))
}
// milerConsignmentForRider loads a consignment and confirms it belongs to this
// rider — either it is linked to a booking currently assigned to them, or they
// are the one who collected it (Createdby). Returns a stable error code on
// failure so the app can branch without parsing messages.
func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignment, string, error) {
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return nil, constants.ErrConsignmentNotFound, err
}
var count int64
db.DB.Model(&models.PickupBooking{}).
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
Count(&count)
if count == 0 && consignment.Createdby != milerUserID {
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
}
return &consignment, "", nil
}
// MilerGetConsignment returns a consignment's current state to the app so it can
// decide what the rider may do next (deliver, skip, wait) without replaying the
// whole logs history. Failure cases carry stable codes.
func MilerGetConsignment(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("consignmentid"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"status": consignment.Status,
"attemptcount": consignment.Attemptcount,
"paymentmode": consignment.Paymentmode,
"codamount": consignment.Codamount,
"codcollected": consignment.Codcollected,
// Derived flags so the app doesn't hardcode the status vocabulary.
"collected": consignment.Status == constants.ConsignmentCollectedByMiler,
"out_for_delivery": consignment.Status == constants.ConsignmentOutForDelivery,
"delivered": consignment.Status == constants.ConsignmentDelivered,
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
})
}
// MilerStartDelivery moves a hyperlocal consignment the rider is carrying from
// Collected_By_Miler to Out_for_Delivery — the explicit "I'm heading to the
// customer now" tap. This is where the receiver OTP is issued (for clients that
// use one) and where the customer is told the parcel is on its way, so neither
// happens a holding-period early at pickup.
func MilerStartDelivery(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
if consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
fmt.Sprintf("consignment is %s, not %s", consignment.Status, constants.ConsignmentCollectedByMiler))
}
tx := db.DB.Begin()
// Issue the receiver OTP now, only for clients that ask for one.
if consignment.Deliveryotp == "" {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignment.Tenantid).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
}
consignment.Status = constants.ConsignmentOutForDelivery
consignment.Updatedat = time.Now()
if err := tx.Save(consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to start delivery")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: consignment.Currenthubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentOutForDelivery,
Remarks: "Rider started final-mile delivery",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record delivery start")
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerOnDelivery).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to start delivery")
}
// Tell the customer it's on the way, and hand the receiver their OTP (only the
// receiver — the rider is told it at the door).
var booking models.PickupBooking
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
var customer models.AppCustomer
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
body := "Your parcel is out for delivery."
payload := map[string]string{"consignment_id": strconv.Itoa(consignment.Consignmentid)}
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Out for Delivery", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on delivery start", "consignment_id", consignment.Consignmentid, "error", notifyErr)
}
}
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"status": consignment.Status,
})
}
// --------------------
// DELIVERY CONFIRMATION
// --------------------
@@ -403,7 +562,8 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
}
if consignment.Status != constants.ConsignmentOutForDelivery {
return utils.BadRequest(c, "consignment is not out for delivery")
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
"consignment is not out for delivery — start delivery first")
}
// An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp),
@@ -412,10 +572,10 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
// network from before OTPs existed, which would otherwise be unclosable.
if consignment.Deliveryotp != "" {
if req.Otp == "" {
return utils.BadRequest(c, "otp is required for this delivery")
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpRequired, "otp is required for this delivery")
}
if req.Otp != consignment.Deliveryotp {
return utils.BadRequest(c, "incorrect delivery OTP")
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpInvalid, "incorrect delivery OTP")
}
}
@@ -571,11 +731,16 @@ func MilerSkipDelivery(c *fiber.Ctx) error {
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "assigned consignment not found")
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
if consignment.Status != constants.ConsignmentOutForDelivery {
return utils.BadRequest(c, "consignment is not out for delivery")
// A failed attempt can be reported once the rider is carrying the parcel —
// whether they had already tapped start-delivery (Out_for_Delivery) or not
// (Collected_By_Miler). Both mean "in hand, delivery didn't happen".
if consignment.Status != constants.ConsignmentOutForDelivery &&
consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
"consignment is not in a deliverable state")
}
tx := db.DB.Begin()

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"))