Files
doormile_backend/controllers/milerAppController.go
Suriyakumarvijayanayagam f6d339a33f feat: miler delivery-leg fixes — consignmentid, auto route sequencing, admin consignment status
Miler app P0 + contract gaps found in the live audit:

- GET /miler/bookings now returns consignmentid + consignmentstatus on every
  row (nullable), so the app can call deliver/skip/start-delivery straight from
  the list. /miler/assignments is the active-only queue, so this is the
  authoritative fix for stops that have moved onto the delivery leg.
- GET /miler/bookings now returns sequencedat per row: non-null means the
  console/optimizer fixed this stop's order and the app follows step exactly;
  null means no route assigned and the app may fall back to nearest-first.
- Route sequencing (internal/routing) now runs automatically after every
  assignment — customer auto-assign, express auto-assign, manual assign, and
  accept — via SequenceMilerStopsAsync (fire-and-forget, no-op below two active
  stops). Previously only hub batch-assign sequenced, so most riders saw step=0.
- GET /admin/bookings now surfaces the live consignmentstatus alongside the
  frozen booking status, so a Converted_To_Consignment booking can still show
  Out_for_Delivery / Delivered instead of a generic "Active".

Two-step hyperlocal flow (Arrived_At_Pickup, Collected_By_Miler, start-delivery)
stays gated behind MILER_COLLECTED_STATE_ENABLED (default off) until the app
ships; consignmentid/status, GET /miler/consignments/:id, stable error codes and
Idempotency-Key handling are unconditional and safe on the current app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
2026-08-24 10:34:12 +05:30

1064 lines
37 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// --------------------
// DUTY MANAGEMENT
// --------------------
func MilerStartDuty(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var req struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
midnight := todayMidnight()
var existing models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight).
First(&existing).Error; err == nil {
return utils.BadRequest(c, "already on duty, end current duty first")
}
now := time.Now()
dutyLog := models.MilerDutyLog{
Userid: milerUserID,
Loginat: now,
Onduty: true,
Startlat: req.Lat,
Startlon: req.Lon,
Lastlat: req.Lat,
Lastlon: req.Lon,
}
if err := db.DB.Create(&dutyLog).Error; err != nil {
return utils.Internal(c, "failed to start duty")
}
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Updates(map[string]interface{}{
"availabilitystatus": constants.MilerAvailable,
"currentlatitude": req.Lat,
"currentlongitude": req.Lon,
"lastlocationupdatedat": now,
})
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1)
return utils.OK(c, fiber.Map{
"dutylogid": dutyLog.Dutylogid,
"loginat": dutyLog.Loginat,
})
}
func MilerEndDuty(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var req struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
var dutyLog models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true).
Order("loginat DESC").First(&dutyLog).Error; err != nil {
return utils.BadRequest(c, "not currently on duty")
}
var activeAssignments int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Count(&activeAssignments)
if activeAssignments > 0 {
return utils.BadRequest(c, "cannot go off duty with active assignments")
}
now := time.Now()
dutyLog.Logoutat = &now
dutyLog.Onduty = false
dutyLog.Lastlat = req.Lat
dutyLog.Lastlon = req.Lon
if err := db.DB.Save(&dutyLog).Error; err != nil {
return utils.Internal(c, "failed to end duty")
}
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerOffline)
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 0)
return utils.OK(c, fiber.Map{
"dutylogid": dutyLog.Dutylogid,
"logoutat": dutyLog.Logoutat,
"totalkms": dutyLog.Totalkms,
})
}
func MilerGetDutyStatus(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
midnight := todayMidnight()
var dutyLog models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight).
Order("loginat DESC").First(&dutyLog).Error; err != nil {
return utils.OK(c, fiber.Map{"onduty": false})
}
var profile models.MilerProfile
db.DB.Where("userid = ?", milerUserID).First(&profile)
return utils.OK(c, fiber.Map{
"onduty": true,
"dutylogid": dutyLog.Dutylogid,
"loginat": dutyLog.Loginat,
"totalkms": dutyLog.Totalkms,
"availabilitystatus": profile.Availabilitystatus,
})
}
// --------------------
// BREAK MANAGEMENT
// --------------------
func MilerStartBreak(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var req struct {
Breaktype string `json:"breaktype"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Breaktype == "" {
req.Breaktype = "Personal"
}
var dutyLog models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true).
Order("loginat DESC").First(&dutyLog).Error; err != nil {
return utils.BadRequest(c, "not on duty")
}
var openBreak models.MilerBreakLog
if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID).First(&openBreak).Error; err == nil {
return utils.BadRequest(c, "break already active")
}
breakLog := models.MilerBreakLog{
Userid: milerUserID,
Dutylogid: dutyLog.Dutylogid,
Breaktype: req.Breaktype,
Startat: time.Now(),
}
if err := db.DB.Create(&breakLog).Error; err != nil {
return utils.Internal(c, "failed to start break")
}
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerBreak)
return utils.OK(c, fiber.Map{
"breaklogid": breakLog.Breaklogid,
"startat": breakLog.Startat,
})
}
func MilerEndBreak(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var breakLog models.MilerBreakLog
if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID).
Order("startat DESC").First(&breakLog).Error; err != nil {
return utils.BadRequest(c, "no active break")
}
now := time.Now()
breakLog.Endat = &now
if err := db.DB.Save(&breakLog).Error; err != nil {
return utils.Internal(c, "failed to end break")
}
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAvailable)
return utils.OK(c, fiber.Map{
"breaklogid": breakLog.Breaklogid,
"endat": breakLog.Endat,
})
}
// --------------------
// 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")
dateStr := c.Query("date")
query := db.DB.Where("assignedmileruserid = ?", milerUserID)
if status != "" && status != "All" {
query = query.Where("status = ?", status)
}
if dateStr != "" {
if date, err := time.Parse("2006-01-02", dateStr); err == nil {
query = query.Where("createdat >= ? AND createdat < ?", date, date.Add(24*time.Hour))
}
}
var bookings []models.PickupBooking
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)
// 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
}
}
}
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
var consignmentStatus string
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
consignmentStatus = cn.Status
}
}
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
"consignmentstatus": consignmentStatus,
"pickupaddress": b.Pickupaddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": b.Deliveryaddress,
"deliverylatitude": b.Deliverylatitude,
"deliverylongitude": b.Deliverylongitude,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"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.
// sequencedat is the authoritative-order signal: non-null means the
// console/optimizer fixed this stop's position and the app must follow
// step exactly; null means no route was assigned and the app is free to
// fall back to its own nearest-first ordering.
"step": 0,
"cumulativekms": 0.0,
"etaminutes": 0,
"cumulativeeta": 0,
"sequencedat": nil,
}
if a, ok := seqByBooking[b.Bookingid]; ok {
row["step"] = a.Step
row["cumulativekms"] = a.Cumulativekms
row["etaminutes"] = a.Etaminutes
row["cumulativeeta"] = a.Cumulativeeta
row["sequencedat"] = a.Sequencedat
}
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)))
}
// milerConsignmentForRider loads a consignment and confirms it belongs to this
// rider — either it is linked to a booking currently assigned to them, or they
// are the one who collected it (Createdby). Returns a stable error code on
// failure so the app can branch without parsing messages.
func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignment, string, error) {
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return nil, constants.ErrConsignmentNotFound, err
}
var count int64
db.DB.Model(&models.PickupBooking{}).
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
Count(&count)
if count == 0 && consignment.Createdby != milerUserID {
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
}
return &consignment, "", nil
}
// MilerGetConsignment returns a consignment's current state to the app so it can
// decide what the rider may do next (deliver, skip, wait) without replaying the
// whole logs history. Failure cases carry stable codes.
func MilerGetConsignment(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("consignmentid"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"status": consignment.Status,
"attemptcount": consignment.Attemptcount,
"paymentmode": consignment.Paymentmode,
"codamount": consignment.Codamount,
"codcollected": consignment.Codcollected,
// Derived flags so the app doesn't hardcode the status vocabulary.
"collected": consignment.Status == constants.ConsignmentCollectedByMiler,
"out_for_delivery": consignment.Status == constants.ConsignmentOutForDelivery,
"delivered": consignment.Status == constants.ConsignmentDelivered,
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
})
}
// MilerStartDelivery moves a hyperlocal consignment the rider is carrying from
// Collected_By_Miler to Out_for_Delivery — the explicit "I'm heading to the
// customer now" tap. This is where the receiver OTP is issued (for clients that
// use one) and where the customer is told the parcel is on its way, so neither
// happens a holding-period early at pickup.
func MilerStartDelivery(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
if consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
fmt.Sprintf("consignment is %s, not %s", consignment.Status, constants.ConsignmentCollectedByMiler))
}
tx := db.DB.Begin()
// Issue the receiver OTP now, only for clients that ask for one.
if consignment.Deliveryotp == "" {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignment.Tenantid).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
}
consignment.Status = constants.ConsignmentOutForDelivery
consignment.Updatedat = time.Now()
if err := tx.Save(consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to start delivery")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: consignment.Currenthubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentOutForDelivery,
Remarks: "Rider started final-mile delivery",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record delivery start")
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerOnDelivery).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to start delivery")
}
// Tell the customer it's on the way, and hand the receiver their OTP (only the
// receiver — the rider is told it at the door).
var booking models.PickupBooking
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
var customer models.AppCustomer
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
body := "Your parcel is out for delivery."
payload := map[string]string{"consignment_id": strconv.Itoa(consignment.Consignmentid)}
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Out for Delivery", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on delivery start", "consignment_id", consignment.Consignmentid, "error", notifyErr)
}
}
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"status": consignment.Status,
})
}
// --------------------
// DELIVERY CONFIRMATION
// --------------------
func MilerDeliverConsignment(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment ID")
}
var req struct {
Otp string `json:"otp"`
Deliveredtoname string `json:"deliveredtoname"`
Photourl string `json:"photourl"`
Receiversignatureurl string `json:"receiversignatureurl"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Deliveredtoname == "" {
return utils.BadRequest(c, "deliveredtoname is required")
}
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "assigned consignment not found")
}
if consignment.Status != constants.ConsignmentOutForDelivery {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
"consignment is not out for delivery — start delivery first")
}
// An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp),
// so an empty one means this delivery was never meant to need a code — that
// covers food clients like DailyGrubs as well as parcels already in the
// network from before OTPs existed, which would otherwise be unclosable.
if consignment.Deliveryotp != "" {
if req.Otp == "" {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpRequired, "otp is required for this delivery")
}
if req.Otp != consignment.Deliveryotp {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpInvalid, "incorrect delivery OTP")
}
}
tx := db.DB.Begin()
proof := models.DeliveryProof{
Consignmentid: consignment.Consignmentid,
Deliveredat: time.Now(),
Deliveredtoname: req.Deliveredtoname,
Receiversignatureurl: req.Receiversignatureurl,
Photourl: req.Photourl,
Otpverified: consignment.Deliveryotp != "",
Geolatitude: req.Lat,
Geolongitude: req.Lon,
Createdby: milerUserID,
}
if err := tx.Create(&proof).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record delivery proof")
}
consignment.Status = constants.ConsignmentDelivered
// Cleared once redeemed so the same code can't close out a second attempt.
consignment.Deliveryotp = ""
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to mark consignment delivered")
}
// Every other state change on a consignment writes a history row; delivery
// did not, so a customer following the tracking timeline never saw the
// parcel arrive — it just stopped at Out_for_Delivery.
deliveredEvent := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: consignment.Currenthubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentDelivered,
Remarks: fmt.Sprintf("Delivered to %s at (%.5f, %.5f)",
req.Deliveredtoname, req.Lat, req.Lon),
}
if err := tx.Create(&deliveredEvent).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record delivery history")
}
// riderkms is the distance actually ridden for this booking, measured from
// the pickup point to where the rider stood when they confirmed delivery
// (falling back to the booking's delivery coordinates if the app sent none).
// ridercharges is the order amount the tenant is billed, passed in at
// creation as finalprice and stored on the service option — Doormile does
// not compute it. Both were only ever read by GET /miler/earnings and never
// written, so every completed job reported zero distance and zero value.
dropLat, dropLon := req.Lat, req.Lon
if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = consignment.Deliverylatitude, consignment.Deliverylongitude
}
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
var serviceOpt models.BookingServiceOption
orderAmount := 0.0
if tx.Where("bookingid = ?", booking.Bookingid).
Order("createdat DESC").First(&serviceOpt).Error == nil {
orderAmount = serviceOpt.Estimatedprice
}
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": time.Now(),
"riderkms": riderKms,
"ridercharges": orderAmount,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm delivery")
}
if db.Js != nil {
payload := map[string]interface{}{
"bookingid": booking.Bookingid,
"outcome": "on_time",
"consignmentid": consignment.Consignmentid,
"mileruserid": milerUserID,
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.outcome", data); err != nil {
utils.Warn("MilerDeliverConsignment: NATS publish failed", "consignment_id", consignment.Consignmentid, "error", err)
}
}
}
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Delivered",
"Your parcel has been delivered!",
map[string]string{
"booking_id": strconv.Itoa(booking.Bookingid),
"consignment_id": strconv.Itoa(consignment.Consignmentid),
},
); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on delivery", "booking_id", booking.Bookingid, "error", notifyErr)
}
}
return c.JSON(fiber.Map{
"success": true,
"message": "delivery confirmed",
"data": fiber.Map{
"proofid": proof.Proofid,
"deliveredat": proof.Deliveredat,
},
})
}
// MilerSkipDelivery records a failed/incomplete final-mile delivery attempt
// (customer unavailable, gate locked, wrong address, etc.) without closing
// out the consignment — the miler still holds the parcel, the consignment
// stays Out_for_Delivery, and the attempt is logged for a retry. This is the
// "skipped" half of the old system's 8-in-1 status endpoint; MilerDeliverConsignment
// above is the "delivered" half, and MilerCancelAssignment (milerController.go)
// is the pre-pickup "cancelled"/"rejected" half.
func MilerSkipDelivery(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment 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 consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
// A failed attempt can be reported once the rider is carrying the parcel —
// whether they had already tapped start-delivery (Out_for_Delivery) or not
// (Collected_By_Miler). Both mean "in hand, delivery didn't happen".
if consignment.Status != constants.ConsignmentOutForDelivery &&
consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
"consignment is not in a deliverable state")
}
tx := db.DB.Begin()
consignment.Attemptcount += 1
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record skipped attempt")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Userid: &milerUserID,
Eventstatus: "Delivery_Skipped",
Remarks: fmt.Sprintf("Attempt %d skipped at (%.5f, %.5f): %s", consignment.Attemptcount, req.Lat, req.Lon, req.Reason),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to log skipped attempt")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit skipped attempt")
}
// After 3 failed attempts, flag it for hub attention rather than leaving
// it silently retrying forever.
if consignment.Attemptcount >= 3 {
exception := models.ConsignmentException{
Consignmentid: consignment.Consignmentid,
Reportedbyuserid: &milerUserID,
Exceptiontype: constants.ExceptionUndeliverable,
Severity: "Medium",
Description: fmt.Sprintf("3 delivery attempts failed. Last reason: %s", req.Reason),
}
db.DB.Create(&exception)
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"attemptcount": consignment.Attemptcount,
"status": consignment.Status,
})
}
// 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
// --------------------
type milerEarningsSummary struct {
CompletedStops int64 `gorm:"column:completed_stops"`
TotalKms float64 `gorm:"column:total_kms"`
TotalEarnings float64 `gorm:"column:total_earnings"`
TotalBonus int `gorm:"column:total_bonus"`
}
func MilerGetEarnings(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
period := c.Query("period", "daily")
var start, end time.Time
// Database-local, not container-local — see utils.DBNow. The rest of the
// range logic already moved off the container clock; this branch would
// otherwise put a rider in the wrong month for 5h30m either side of it.
now := utils.DBNow()
switch period {
case "weekly":
start = todayMidnight().Add(-6 * 24 * time.Hour)
end = todayMidnight().Add(24 * time.Hour)
case "monthly":
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 1, 0)
default:
period = "daily"
day := todayMidnight()
if dateStr := c.Query("date"); dateStr != "" {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
day = parsed
}
}
start = day
end = day.Add(24 * time.Hour)
}
var summary milerEarningsSummary
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?",
milerUserID, constants.AssignmentCompleted, start, end).
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).
Order("completedat DESC").Find(&breakdown)
breakdownResp := make([]fiber.Map, 0, len(breakdown))
for _, a := range breakdown {
breakdownResp = append(breakdownResp, fiber.Map{
"bookingid": a.Bookingid,
"riderkms": a.Riderkms,
"ridercharges": a.Ridercharges,
"bonuspoints": a.Bonuspoints,
"completedat": a.Completedat,
})
}
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,
"breakdown": breakdownResp,
}
if period == "daily" {
data["date"] = start.Format("2006-01-02")
}
return utils.OK(c, data)
}
// --------------------
// NOTIFICATIONS (synthetic — no dedicated table yet)
// --------------------
// MilerGetNotifications generates notifications from real assignment events,
// mirroring GetHubNotifications' synthetic-notification pattern.
func MilerGetNotifications(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
midnight := todayMidnight()
last24h := time.Now().Add(-24 * time.Hour)
var notifications []notificationEntry
var assigned []models.BookingAssignment
db.DB.Where("mileruserid = ? AND assignedat >= ?", milerUserID, midnight).Find(&assigned)
for _, a := range assigned {
notifications = append(notifications, notificationEntry{
Title: fmt.Sprintf("New pickup assigned — Booking #%d", a.Bookingid),
Type: "assignment",
Time: a.Assignedat,
})
}
var completed []models.BookingAssignment
db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ?", milerUserID, constants.AssignmentCompleted, midnight).Find(&completed)
for _, a := range completed {
t := a.Assignedat
if a.Completedat != nil {
t = *a.Completedat
}
notifications = append(notifications, notificationEntry{
Title: fmt.Sprintf("Booking #%d completed", a.Bookingid),
Type: "completed",
Time: t,
})
}
var rejected []models.BookingAssignment
db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND assignedat >= ?", milerUserID, constants.AssignmentRejected, last24h).Find(&rejected)
for _, a := range rejected {
notifications = append(notifications, notificationEntry{
Title: fmt.Sprintf("Assignment rejected — Booking #%d", a.Bookingid),
Type: "rejected",
Time: a.Assignedat,
})
}
sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) })
if len(notifications) > 20 {
notifications = notifications[:20]
}
response := make([]fiber.Map, 0, len(notifications))
for i, n := range notifications {
response = append(response, fiber.Map{
"id": i + 1,
"title": n.Title,
"type": n.Type,
"time": humanizeRelativeTime(n.Time),
"read": false,
})
}
return utils.List(c, response, int64(len(response)))
}
// MilerMarkNotificationRead is a stub until a notifications table with
// read-state exists — always succeeds without persisting anything, matching
// MarkNotificationRead's hub-console equivalent.
func MilerMarkNotificationRead(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"success": true})
}
// --------------------
// SUPPORT
// --------------------
func MilerCreateTicket(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var req struct {
Subject string `json:"subject"`
Description string `json:"description"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Subject == "" || req.Description == "" {
return utils.BadRequest(c, "subject and description are required")
}
ticket := models.MilerSupportTicket{
Userid: milerUserID,
Subject: req.Subject,
Description: req.Description,
Status: "Open",
}
if err := db.DB.Create(&ticket).Error; err != nil {
return utils.Internal(c, "failed to create support ticket")
}
return utils.Created(c, fiber.Map{
"ticketid": ticket.Ticketid,
"status": ticket.Status,
})
}
func MilerGetTickets(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var tickets []models.MilerSupportTicket
if err := db.DB.Where("userid = ?", milerUserID).Order("createdat DESC").Find(&tickets).Error; err != nil {
return utils.Internal(c, "failed to fetch support tickets")
}
return utils.List(c, tickets, int64(len(tickets)))
}