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:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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