From aeb859d80b9ac2d96ea45f4ae2b5e1b894dfc6c9 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Tue, 25 Aug 2026 17:49:59 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20miler=20lifecycle=20=E2=80=94=20expose?= =?UTF-8?q?=20reachedat,=20arrival-fact=20reached,=20PATCH=20addresses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD --- controllers/milerAppController.go | 8 ++ controllers/milerController.go | 130 ++++++++++++++++++++++++++---- models/booking.go | 9 ++- routes/routes.go | 3 + 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 8c73870..9c4c4b8 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -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, diff --git a/controllers/milerController.go b/controllers/milerController.go index fdb3210..6e1f11e 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -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")) diff --git a/models/booking.go b/models/booking.go index f553ba3..f33bbc5 100644 --- a/models/booking.go +++ b/models/booking.go @@ -43,7 +43,14 @@ 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"` - Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"` + // 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 // list, so a "Converted_To_Consignment" booking can still show diff --git a/routes/routes.go b/routes/routes.go index d2ecde5..9b673d7 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -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