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