feat: miler lifecycle — expose reachedat, arrival-fact reached, PATCH addresses
- GET /miler/bookings now returns reachedat + arrivallatitude/arrivallongitude on every row, so the app reconstructs "Arrived" (Pickup_Scheduled + reachedat) after a restart with no new status. - reached records arrival as a FACT (timestamp + GPS) and no longer flips the booking to Arrived_At_Pickup — the status stays Pickup_Scheduled, matching the rider app's derive-from-reachedat model and dropping the console mapping need. - New PATCH /miler/bookings/:id/addresses: partial pickup/delivery address, pincode, coords, city correction before pickup-complete (INVALID_STATE after). - pickupbookings gains nullable arrivedat/arrivallatitude/arrivallongitude (AutoMigrate, additive). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
@@ -337,6 +337,14 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
||||
"deliverylongitude": b.Deliverylongitude,
|
||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"customerphone": customer.Phone,
|
||||
// Arrival fact: the rider app derives its "Arrived" rung from
|
||||
// pickup-scheduled + a non-null reachedat, so this survives an app
|
||||
// restart without a separate booking status. Null until the rider hits
|
||||
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
||||
// at that moment (null if the app sent none).
|
||||
"reachedat": b.Arrivedat,
|
||||
"arrivallatitude": b.Arrivallatitude,
|
||||
"arrivallongitude": b.Arrivallongitude,
|
||||
"parcels": b.Parcels,
|
||||
"serviceoptions": b.ServiceOptions,
|
||||
"codamount": codAmount,
|
||||
|
||||
@@ -676,6 +676,15 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
// Rider's actual GPS at the moment of arrival. Optional in the body but the
|
||||
// app sends it; stored as a durable arrival record, distinct from live
|
||||
// telemetry. Zero is treated as "not supplied" and left null.
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
var booking models.PickupBooking
|
||||
@@ -684,21 +693,25 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||
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")
|
||||
}
|
||||
// Persist the arrival as a FACT (timestamp + GPS), not as a status change.
|
||||
// The rider app derives its "Arrived" rung from Pickup_Scheduled + a non-null
|
||||
// reachedat, so the booking status is deliberately left untouched here —
|
||||
// introducing an Arrived_At_Pickup status would break that derivation (the
|
||||
// app classifies strictly by the known status strings) and would need a
|
||||
// console mapping. Keeping only the fact makes Arrived survive an app restart
|
||||
// with no new status and no console dependency. reachedat is refreshed on a
|
||||
// repeat call; GPS is stored only when the app supplies it.
|
||||
now := time.Now()
|
||||
booking.Arrivedat = &now
|
||||
if req.Latitude != 0 && req.Longitude != 0 {
|
||||
lat, lng := req.Latitude, req.Longitude
|
||||
booking.Arrivallatitude = &lat
|
||||
booking.Arrivallongitude = &lng
|
||||
}
|
||||
booking.Updatedat = 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).
|
||||
@@ -713,9 +726,96 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||
return utils.OK(c, fiber.Map{
|
||||
"bookingid": booking.Bookingid,
|
||||
"status": booking.Status,
|
||||
"reachedat": booking.Arrivedat,
|
||||
})
|
||||
}
|
||||
|
||||
// BookingUpdateAddresses lets the rider correct the pickup/delivery address,
|
||||
// pincode, coordinates and delivery city at the door, before the parcel is
|
||||
// converted to a consignment. Only fields actually sent are written (pointer
|
||||
// DTO), so a partial update never wipes an existing value — a request that
|
||||
// carries just corrected delivery coordinates leaves the address untouched.
|
||||
//
|
||||
// Must run before pickup-complete: once the booking is picked up / converted,
|
||||
// the shipment's addresses are frozen on the consignment and this is rejected
|
||||
// with INVALID_STATE. Correcting delivery coordinates here also feeds route
|
||||
// sequencing, which skips stops sitting at 0,0.
|
||||
func BookingUpdateAddresses(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Pickupaddress *string `json:"pickupaddress"`
|
||||
Pickuppincode *string `json:"pickuppincode"`
|
||||
Pickuplatitude *float64 `json:"pickuplatitude"`
|
||||
Pickuplongitude *float64 `json:"pickuplongitude"`
|
||||
Deliveryaddress *string `json:"deliveryaddress"`
|
||||
Deliverypincode *string `json:"deliverypincode"`
|
||||
Deliverylatitude *float64 `json:"deliverylatitude"`
|
||||
Deliverylongitude *float64 `json:"deliverylongitude"`
|
||||
Deliverycity *string `json:"deliverycity"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
|
||||
}
|
||||
|
||||
if booking.Status == constants.BookingPickedUp ||
|
||||
booking.Status == constants.BookingConvertedConsignment {
|
||||
return utils.Fail(c, fiber.StatusConflict, constants.ErrInvalidState,
|
||||
"addresses can only be edited before pickup-complete")
|
||||
}
|
||||
|
||||
// Build only the columns actually supplied. A non-empty string or a non-zero
|
||||
// coordinate counts as supplied; an omitted field (nil pointer) is left as-is.
|
||||
updates := map[string]interface{}{}
|
||||
if req.Pickupaddress != nil && strings.TrimSpace(*req.Pickupaddress) != "" {
|
||||
updates["pickupaddress"] = strings.TrimSpace(*req.Pickupaddress)
|
||||
}
|
||||
if req.Pickuppincode != nil && strings.TrimSpace(*req.Pickuppincode) != "" {
|
||||
updates["pickuppincode"] = strings.TrimSpace(*req.Pickuppincode)
|
||||
}
|
||||
if req.Pickuplatitude != nil && *req.Pickuplatitude != 0 {
|
||||
updates["pickuplatitude"] = *req.Pickuplatitude
|
||||
}
|
||||
if req.Pickuplongitude != nil && *req.Pickuplongitude != 0 {
|
||||
updates["pickuplongitude"] = *req.Pickuplongitude
|
||||
}
|
||||
if req.Deliveryaddress != nil && strings.TrimSpace(*req.Deliveryaddress) != "" {
|
||||
updates["deliveryaddress"] = strings.TrimSpace(*req.Deliveryaddress)
|
||||
}
|
||||
if req.Deliverypincode != nil && strings.TrimSpace(*req.Deliverypincode) != "" {
|
||||
updates["deliverypincode"] = strings.TrimSpace(*req.Deliverypincode)
|
||||
}
|
||||
if req.Deliverylatitude != nil && *req.Deliverylatitude != 0 {
|
||||
updates["deliverylatitude"] = *req.Deliverylatitude
|
||||
}
|
||||
if req.Deliverylongitude != nil && *req.Deliverylongitude != 0 {
|
||||
updates["deliverylongitude"] = *req.Deliverylongitude
|
||||
}
|
||||
if req.Deliverycity != nil && strings.TrimSpace(*req.Deliverycity) != "" {
|
||||
updates["deliverycity"] = strings.TrimSpace(*req.Deliverycity)
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return utils.BadRequest(c, "no address fields supplied to update")
|
||||
}
|
||||
updates["updatedat"] = time.Now()
|
||||
|
||||
if err := db.DB.Model(&booking).Updates(updates).Error; err != nil {
|
||||
return utils.Internal(c, "failed to update addresses")
|
||||
}
|
||||
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
|
||||
func BookingParcelConfirm(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
|
||||
Reference in New Issue
Block a user