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:
Suriyakumarvijayanayagam
2026-08-25 17:49:59 +05:30
parent b0f733ae38
commit aeb859d80b
4 changed files with 134 additions and 16 deletions

View File

@@ -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,

View File

@@ -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,22 +693,26 @@ 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()
// 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).
Update("availabilitystatus", constants.MilerAtCustomer).Error; err != nil {
@@ -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"))

View File

@@ -43,6 +43,13 @@ type PickupBooking struct {
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
// Arrival trace: written by the reached action when the rider is at the
// pickup point. Nullable — a booking that was never reached has no arrival.
// arrivallatitude/longitude are the rider's actual GPS at the moment they
// tapped "arrived", kept as a durable record separate from live telemetry.
Arrivedat *time.Time `json:"arrivedat" gorm:"column:arrivedat"`
Arrivallatitude *float64 `json:"arrivallatitude" gorm:"column:arrivallatitude"`
Arrivallongitude *float64 `json:"arrivallongitude" gorm:"column:arrivallongitude"`
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
// Consignmentstatus is not a column — it is filled in by handlers that want
// the live consignment status alongside the booking (e.g. the admin bookings

View File

@@ -139,6 +139,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Post("/assignments/:id/reject", controllers.RejectMilerAssignment)
milerAuth.Post("/bookings/:bookingid/reached", controllers.BookingReachedCustomer)
// Partial correction of pickup/delivery address, pincode, coords, city at the
// door. Only non-empty fields are written; rejected once picked up.
milerAuth.Patch("/bookings/:bookingid/addresses", controllers.BookingUpdateAddresses)
milerAuth.Post("/bookings/:bookingid/parcel", controllers.BookingParcelConfirm)
// Money and state-conversion mutations are idempotent: a rider retry over a
// bad connection with the same Idempotency-Key replays the first response