feat: miler app contract gaps — stop type, COD, pre-pickup skip, profile
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user