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,
|
"deliverylongitude": b.Deliverylongitude,
|
||||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||||
"customerphone": customer.Phone,
|
"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,
|
"parcels": b.Parcels,
|
||||||
"serviceoptions": b.ServiceOptions,
|
"serviceoptions": b.ServiceOptions,
|
||||||
"codamount": codAmount,
|
"codamount": codAmount,
|
||||||
|
|||||||
@@ -676,6 +676,15 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid booking ID")
|
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()
|
tx := db.DB.Begin()
|
||||||
|
|
||||||
var booking models.PickupBooking
|
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")
|
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
|
// Persist the arrival as a FACT (timestamp + GPS), not as a status change.
|
||||||
// availability flag — the reached action used to leave no durable trace, so
|
// The rider app derives its "Arrived" rung from Pickup_Scheduled + a non-null
|
||||||
// a rider who reopened the app after "I've arrived" was back at the pre-
|
// reachedat, so the booking status is deliberately left untouched here —
|
||||||
// arrival step. Gated with the rest of the new flow: the Arrived_At_Pickup
|
// introducing an Arrived_At_Pickup status would break that derivation (the
|
||||||
// status only appears once the app understands it. Only advance from a genuine
|
// app classifies strictly by the known status strings) and would need a
|
||||||
// pre-pickup state; never walk an already-picked-up booking back.
|
// console mapping. Keeping only the fact makes Arrived survive an app restart
|
||||||
if collectedStateEnabled() &&
|
// with no new status and no console dependency. reachedat is refreshed on a
|
||||||
booking.Status != constants.BookingPickedUp &&
|
// repeat call; GPS is stored only when the app supplies it.
|
||||||
booking.Status != constants.BookingConvertedConsignment {
|
now := time.Now()
|
||||||
booking.Status = constants.BookingArrivedAtPickup
|
booking.Arrivedat = &now
|
||||||
booking.Updatedat = time.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 {
|
if err := tx.Save(&booking).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to record arrival")
|
return utils.Internal(c, "failed to record arrival")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||||
Update("availabilitystatus", constants.MilerAtCustomer).Error; err != nil {
|
Update("availabilitystatus", constants.MilerAtCustomer).Error; err != nil {
|
||||||
@@ -713,9 +726,96 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
|||||||
return utils.OK(c, fiber.Map{
|
return utils.OK(c, fiber.Map{
|
||||||
"bookingid": booking.Bookingid,
|
"bookingid": booking.Bookingid,
|
||||||
"status": booking.Status,
|
"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 {
|
func BookingParcelConfirm(c *fiber.Ctx) error {
|
||||||
milerUserID := c.Locals("userid").(int)
|
milerUserID := c.Locals("userid").(int)
|
||||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ type PickupBooking struct {
|
|||||||
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
|
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
|
||||||
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
|
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
|
||||||
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
|
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"`
|
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
|
||||||
// Consignmentstatus is not a column — it is filled in by handlers that want
|
// 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
|
// the live consignment status alongside the booking (e.g. the admin bookings
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
milerAuth.Post("/assignments/:id/reject", controllers.RejectMilerAssignment)
|
milerAuth.Post("/assignments/:id/reject", controllers.RejectMilerAssignment)
|
||||||
|
|
||||||
milerAuth.Post("/bookings/:bookingid/reached", controllers.BookingReachedCustomer)
|
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)
|
milerAuth.Post("/bookings/:bookingid/parcel", controllers.BookingParcelConfirm)
|
||||||
// Money and state-conversion mutations are idempotent: a rider retry over a
|
// Money and state-conversion mutations are idempotent: a rider retry over a
|
||||||
// bad connection with the same Idempotency-Key replays the first response
|
// bad connection with the same Idempotency-Key replays the first response
|
||||||
|
|||||||
Reference in New Issue
Block a user