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:
Suriyakumarvijayanayagam
2026-08-21 11:22:01 +05:30
parent f09efcaf59
commit 531185cf66
4 changed files with 240 additions and 9 deletions

View File

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

View File

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