From 531185cf66fc102e001ebe3a41acd06f7005d7ae Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 21 Aug 2026 11:22:01 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20miler=20app=20contract=20gaps=20?= =?UTF-8?q?=E2=80=94=20stop=20type,=20COD,=20pre-pickup=20skip,=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the gaps the miler-app dev flagged against the deployed contract. - GET /miler/bookings: return stoptype (pickup|delivery, from status), step + road-optimized sequence (cumulativekms/etaminutes/cumulativeeta), and codamount/paymentmode. List sorted by step, unsequenced last. Lookups batched to avoid N+1. - POST /miler/bookings/:bookingid/skip: pre-pickup skip that keeps the booking assigned and resumable — the "route back" the consignment-only delivery skip couldn't give a not-yet-picked-up booking. - GET /miler/earnings: add cancelled_stops + total_stops for success rate. - PUT /miler/profile: persist email (to appusers, 409 on unique clash) and a new nullable milerprofiles.address column. - POST /miler/assignments/:id/reject: accept reason from body OR ?reason=. Notifications read-state and bonuspoints deliberately left as-is — both need a product/business decision, not code. Co-Authored-By: Claude Opus 4.8 --- controllers/milerAppController.go | 198 +++++++++++++++++++++++++++++- controllers/milerController.go | 43 ++++++- models/users.go | 5 + routes/routes.go | 3 + 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 8015400..674ddd1 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -206,6 +206,22 @@ func MilerEndBreak(c *fiber.Ctx) error { // MILER'S OWN BOOKINGS // -------------------- +// milerStopType tells the app whether the actionable leg of a booking is a +// pickup or a delivery, so the mixed-route screen no longer has to infer it +// structurally and default everything to "pickup". It is derived from the +// booking's own status: before the parcel is in the rider's hands the next +// action is the pickup; once picked up (or converted to a consignment) the +// next action is the delivery. +func milerStopType(status string) string { + switch status { + case constants.BookingPickedUp, constants.BookingConvertedConsignment: + return "delivery" + default: + // Created, Miler_Assigned, Pickup_Scheduled and anything else pre-pickup. + return "pickup" + } +} + func MilerGetMyBookings(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) status := c.Query("status") @@ -222,19 +238,86 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } var bookings []models.PickupBooking - if err := query.Preload("Parcels").Preload("ServiceOptions").Order("createdat DESC").Find(&bookings).Error; err != nil { + if err := query.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"). + Order("createdat DESC").Find(&bookings).Error; err != nil { return utils.Internal(c, "failed to fetch bookings") } + // Batch the per-booking lookups so the route stays a handful of queries + // regardless of how many stops a rider is carrying. + bookingIDs := make([]int, 0, len(bookings)) + consignmentIDs := make([]int, 0, len(bookings)) + for _, b := range bookings { + bookingIDs = append(bookingIDs, b.Bookingid) + if b.Consignmentid != nil { + consignmentIDs = append(consignmentIDs, *b.Consignmentid) + } + } + + // step / road-optimized sequence lives on the active assignment row, written + // by the express route optimizer. Step 0 = not sequenced (single-stop or + // optimizer down), never a position — passed through verbatim. + seqByBooking := map[int]models.BookingAssignment{} + if len(bookingIDs) > 0 { + var assignments []models.BookingAssignment + db.DB.Where("mileruserid = ? AND bookingid IN ? AND assignmentstatus IN ?", + milerUserID, bookingIDs, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Order("assignedat DESC").Find(&assignments) + for _, a := range assignments { + if _, seen := seqByBooking[a.Bookingid]; !seen { + seqByBooking[a.Bookingid] = a + } + } + } + + // COD becomes authoritative on the consignment once picked up; before that it + // is whatever cash the booking's payment row says is due. + codByConsignment := map[int]models.Consignment{} + if len(consignmentIDs) > 0 { + var cons []models.Consignment + db.DB.Where("consignmentid IN ?", consignmentIDs).Find(&cons) + for _, cn := range cons { + codByConsignment[cn.Consignmentid] = cn + } + } + response := make([]fiber.Map, 0, len(bookings)) for _, b := range bookings { var customer models.AppCustomer db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) - response = append(response, fiber.Map{ + // Cash-to-collect: prefer the consignment's COD once it exists, otherwise + // fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0. + codAmount := 0.0 + paymentMode := "" + if b.Consignmentid != nil { + if cn, ok := codByConsignment[*b.Consignmentid]; ok { + paymentMode = cn.Paymentmode + codAmount = cn.Codamount - cn.Codcollected + if codAmount < 0 { + codAmount = 0 + } + } + } + if codAmount == 0 { + for _, p := range b.Payments { + if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending { + codAmount = p.Amount + paymentMode = p.Paymentmode + break + } + if paymentMode == "" { + paymentMode = p.Paymentmode + } + } + } + + row := fiber.Map{ "bookingid": b.Bookingid, "bookingreference": b.Bookingno, "status": b.Status, + "stoptype": milerStopType(b.Status), "pickupaddress": b.Pickupaddress, "pickuplatitude": b.Pickuplatitude, "pickuplongitude": b.Pickuplongitude, @@ -245,10 +328,40 @@ func MilerGetMyBookings(c *fiber.Ctx) error { "customerphone": customer.Phone, "parcels": b.Parcels, "serviceoptions": b.ServiceOptions, + "codamount": codAmount, + "paymentmode": paymentMode, "createdat": b.Createdat, - }) + // Route sequencing — 0/empty when the stop was never sequenced. + "step": 0, + "cumulativekms": 0.0, + "etaminutes": 0, + "cumulativeeta": 0, + } + if a, ok := seqByBooking[b.Bookingid]; ok { + row["step"] = a.Step + row["cumulativekms"] = a.Cumulativekms + row["etaminutes"] = a.Etaminutes + row["cumulativeeta"] = a.Cumulativeeta + } + response = append(response, row) } + // Sequenced stops ascend by step; unsequenced (step 0) fall to the end while + // keeping the newest-first order the app already relied on. + sort.SliceStable(response, func(i, j int) bool { + si, sj := response[i]["step"].(int), response[j]["step"].(int) + switch { + case si == 0 && sj == 0: + return false // preserve existing createdat DESC order + case si == 0: + return false + case sj == 0: + return true + default: + return si < sj + } + }) + return utils.List(c, response, int64(len(response))) } @@ -509,6 +622,71 @@ func MilerSkipDelivery(c *fiber.Ctx) error { }) } +// MilerSkipPickup is the pre-pickup counterpart of MilerSkipDelivery: a rider +// defers a booking they were assigned but have not yet picked up (kitchen not +// ready, gate shut, arrived too early) without giving it up. Unlike +// MilerCancelAssignment this does NOT release the booking for reassignment — +// it stays assigned to the same rider so it can be resumed, which is the "route +// back" the app had no endpoint for: the delivery-side skip requires a +// consignment that a not-yet-picked-up booking does not have. +// +// The status is left untouched (still pre-pickup and actionable); the attempt +// is appended to the booking notes for ops audit, since a pre-pickup booking +// has no consignment-history table to write to yet. +func MilerSkipPickup(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 { + Reason string `json:"reason"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Reason == "" { + return utils.BadRequest(c, "reason is required") + } + + var booking models.PickupBooking + if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID). + First(&booking).Error; err != nil { + return utils.NotFound(c, "assigned booking not found") + } + + // Once the parcel is in the network this is the wrong endpoint — the rider + // must use the consignment delivery-skip instead. + if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment { + return utils.BadRequest(c, "booking already picked up — use the delivery skip on its consignment") + } + + note := fmt.Sprintf("[%s] pickup skipped by miler %d at (%.5f, %.5f): %s", + time.Now().Format("2006-01-02 15:04"), milerUserID, req.Lat, req.Lon, req.Reason) + if booking.Notes != "" { + booking.Notes = booking.Notes + "\n" + note + } else { + booking.Notes = note + } + booking.Updatedat = time.Now() + if err := db.DB.Model(&booking). + Updates(map[string]interface{}{"notes": booking.Notes, "updatedat": booking.Updatedat}).Error; err != nil { + return utils.Internal(c, "failed to record skipped pickup") + } + + // Booking stays assigned and pre-pickup — the app keeps it in the list and + // the rider resumes the normal pickup flow when able. + return utils.OK(c, fiber.Map{ + "bookingid": booking.Bookingid, + "status": booking.Status, + "stoptype": milerStopType(booking.Status), + "resumable": true, + }) +} + // -------------------- // EARNINGS // -------------------- @@ -556,6 +734,18 @@ func MilerGetEarnings(c *fiber.Ctx) error { Select("COUNT(*) as completed_stops, COALESCE(SUM(riderkms),0) as total_kms, COALESCE(SUM(ridercharges),0) as total_earnings, COALESCE(SUM(bonuspoints),0) as total_bonus"). Scan(&summary) + // Cancelled/rejected assignments carry no completedat, so they are counted + // over the same window by assignedat. total_stops lets the app show a real + // success rate (completed / total) instead of inferring it from completions + // alone. + var cancelledStops int64 + db.DB.Model(&models.BookingAssignment{}). + Where("mileruserid = ? AND assignmentstatus IN ? AND assignedat >= ? AND assignedat < ?", + milerUserID, + []string{constants.AssignmentCancelled, constants.AssignmentRejected}, + start, end). + Count(&cancelledStops) + var breakdown []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?", milerUserID, constants.AssignmentCompleted, start, end). @@ -575,6 +765,8 @@ func MilerGetEarnings(c *fiber.Ctx) error { data := fiber.Map{ "period": period, "completed_stops": summary.CompletedStops, + "cancelled_stops": cancelledStops, + "total_stops": summary.CompletedStops + cancelledStops, "total_kms": summary.TotalKms, "total_earnings": summary.TotalEarnings, "total_bonus": summary.TotalBonus, diff --git a/controllers/milerController.go b/controllers/milerController.go index f11de4b..094545e 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "strconv" + "strings" "time" "doormile/config" @@ -206,11 +207,16 @@ func UpdateMilerProfile(c *fiber.Ctx) error { return utils.NotFound(c, "miler profile not found") } + // Email and Address are pointers so an omitted field is left untouched rather + // than blanked. Email lives on AppUser (the login identity), Address on the + // profile — the edit screen collects both, so both are persisted here. type ProfileUpdate struct { - Displayname string `json:"displayname"` - Profilephotourl string `json:"profilephotourl"` - Defaultvehicletype string `json:"defaultvehicletype"` - Phone string `json:"phone"` + Displayname string `json:"displayname"` + Profilephotourl string `json:"profilephotourl"` + Defaultvehicletype string `json:"defaultvehicletype"` + Phone string `json:"phone"` + Email *string `json:"email"` + Address *string `json:"address"` } req := new(ProfileUpdate) @@ -224,10 +230,31 @@ func UpdateMilerProfile(c *fiber.Ctx) error { if req.Phone != "" { profile.Phone = req.Phone } + if req.Address != nil { + profile.Address = *req.Address + } profile.Profilephotourl = req.Profilephotourl profile.Defaultvehicletype = req.Defaultvehicletype profile.Updatedat = time.Now() + // Email is unique on appusers, so a collision must fail cleanly rather than + // 500. Only touch it when a non-empty value that actually changed is sent. + if req.Email != nil && *req.Email != "" { + var user models.AppUser + if err := db.DB.First(&user, milerUserID).Error; err == nil && !strings.EqualFold(user.Email, *req.Email) { + var clash int64 + 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") + } + if err := db.DB.Model(&models.AppUser{}). + Where("userid = ?", milerUserID).Update("email", *req.Email).Error; err != nil { + return utils.Internal(c, "failed to update email") + } + } + } + if err := db.DB.Save(&profile).Error; err != nil { return utils.Internal(c, "failed to update profile") } @@ -434,11 +461,15 @@ func RejectMilerAssignment(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid assignment ID") } + // The reason may arrive in the JSON body or as a ?reason= query string — the + // deployed contract and the app disagreed on which, so accept both and prefer + // whichever is non-empty. An absent/invalid body is not an error here. var req struct { Reason string `json:"reason"` } - if err := c.BodyParser(&req); err != nil { - return utils.BadRequest(c, "invalid request body") + _ = c.BodyParser(&req) + if req.Reason == "" { + req.Reason = c.Query("reason") } if req.Reason == "" { req.Reason = "Rejected by rider" diff --git a/models/users.go b/models/users.go index d758895..f2441c0 100644 --- a/models/users.go +++ b/models/users.go @@ -99,6 +99,11 @@ type MilerProfile struct { // cutover. Nil for riders created natively in Doormile. Legacyuserid *int `json:"legacyuserid,omitempty" gorm:"column:legacyuserid;index"` Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` + // Address is the rider's home/base address, editable from the app's profile + // screen. App-only until now, so it had nowhere durable to land. Nullable/ + // additive. (Email is not duplicated here — it lives on AppUser.Email, the + // login identity, and the profile update path writes it there.) + Address string `json:"address" gorm:"column:address"` Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"` diff --git a/routes/routes.go b/routes/routes.go index bf24ba1..9627d8c 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -144,6 +144,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { milerAuth.Post("/bookings/:bookingid/pickup-complete", controllers.BookingPickupComplete) milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate) milerAuth.Post("/bookings/:bookingid/cancel", controllers.MilerCancelAssignment) + // Pre-pickup skip: defer a not-yet-picked-up booking without releasing it, + // so the rider can resume it (the consignment skip needs a consignment). + milerAuth.Post("/bookings/:bookingid/skip", controllers.MilerSkipPickup) // Redis periodic telemetry and status logs milerAuth.Post("/logs", controllers.CreateMilerPeriodicLog)