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

@@ -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()