1342 lines
49 KiB
Go
1342 lines
49 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)
|
|
}
|
|
}
|
|
|
|
// A customer-app pickup fans out into one consignment per destination, and
|
|
// each of those is a SEPARATE delivery the rider has to make. Without this
|
|
// the queue showed one row per booking keyed on pickupbookings.consignmentid
|
|
// — which names only the first order — so on a three-destination pickup two
|
|
// parcels would exist in the rider's bag with no stop, no deliver button and
|
|
// no way to close them.
|
|
//
|
|
// Bookings with no destination rows (every console/express booking, and
|
|
// everything written before the fan-out) are untouched: they still produce
|
|
// exactly one row, built from the booking's own columns.
|
|
destinationsByBooking := map[int][]models.BookingDestination{}
|
|
if len(bookingIDs) > 0 {
|
|
var destinations []models.BookingDestination
|
|
db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations)
|
|
for _, d := range destinations {
|
|
destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d)
|
|
if d.Consignmentid != nil {
|
|
consignmentIDs = append(consignmentIDs, *d.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)
|
|
|
|
// Where this parcel is collected FROM, and what kind of place that is, so
|
|
// Home can title the stop correctly. Without pickup_source_type every
|
|
// logistics pickup was grouped under the rider's own base name and a
|
|
// collection at a shop looked identical to one at a house.
|
|
//
|
|
// sourceid is null for a customer door and that is a real answer, not a
|
|
// missing one — the type is what makes the difference legible. pickuplocationid
|
|
// mirrors sourceid: the app has used both spellings for the same thing, and
|
|
// the booking row never carried either before now.
|
|
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
|
|
customer.Firstname+" "+customer.Lastname)
|
|
|
|
stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid])
|
|
|
|
for _, stop := range stops {
|
|
// 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 := ""
|
|
var consignmentStatus string
|
|
|
|
// next_action / next_hub: the leg this parcel is on, rebuilt from server
|
|
// state on every poll. pickup-complete used to be the only place that ever
|
|
// said it, so a restart mid-leg left the app with nothing authoritative to
|
|
// read — consignment status alone cannot separate a hub-routed parcel from
|
|
// a freshly-collected hyperlocal one, since both can sit on Created.
|
|
nextAction := constants.NextActionPickup
|
|
var nextHub fiber.Map
|
|
|
|
if stop.consignmentID != nil {
|
|
if cn, ok := codByConsignment[*stop.consignmentID]; ok {
|
|
consignmentStatus = cn.Status
|
|
paymentMode = cn.Paymentmode
|
|
codAmount = cn.Codamount - cn.Codcollected
|
|
if codAmount < 0 {
|
|
codAmount = 0
|
|
}
|
|
nextAction = nextActionForConsignment(cn.Status)
|
|
nextHub = nextHubForConsignment(&cn)
|
|
} else {
|
|
// The consignment row did not load. Fall back to what this
|
|
// destination asked for rather than to zero — a rider shown
|
|
// ₹0 collects nothing, and the customer's money is the one
|
|
// thing that must not silently vanish from a stop.
|
|
codAmount = stop.codAmount
|
|
}
|
|
} else if stop.codAmount > 0 {
|
|
// Not yet collected: the collection is what the customer
|
|
// requested for this door.
|
|
codAmount = stop.codAmount
|
|
}
|
|
if b.Status == constants.BookingCancelled {
|
|
nextAction = constants.NextActionNone
|
|
}
|
|
|
|
// The pickup fee is charged once for the visit, so it is only offered
|
|
// against the first stop — asking a rider to collect it again at the
|
|
// second parcel would double-charge the customer.
|
|
if codAmount == 0 && stop.seq == 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),
|
|
// consignmentid/consignmentstatus so the app can call deliver, skip and
|
|
// start-delivery straight from the list without a per-order lookup.
|
|
"consignmentid": stop.consignmentID,
|
|
"consignmentstatus": consignmentStatus,
|
|
"trackingno": stop.trackingNo,
|
|
// Which of the booking's destinations this stop is, and how many
|
|
// there are in total, so the app can say "Stop 2 of 3" instead of
|
|
// showing three identical-looking rows. Always 0 and 1 for a
|
|
// single-destination or console booking.
|
|
"destinationseq": stop.seq,
|
|
"destinationcount": len(stops),
|
|
"next_action": nextAction,
|
|
"next_hub": nextHub,
|
|
|
|
"pickup_source_type": sourceType,
|
|
"sourceid": sourceID,
|
|
"pickuplocationid": sourceID,
|
|
"pickup_source_name": sourceName,
|
|
"pickupaddress": sourceAddress,
|
|
"pickuplatitude": b.Pickuplatitude,
|
|
"pickuplongitude": b.Pickuplongitude,
|
|
|
|
"deliveryaddress": stop.deliveryAddress,
|
|
"deliverylatitude": stop.deliveryLat,
|
|
"deliverylongitude": stop.deliveryLng,
|
|
"recipientname": stop.recipientName,
|
|
"recipientphone": stop.recipientPhone,
|
|
|
|
"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": stop.parcels,
|
|
"serviceoptions": b.ServiceOptions,
|
|
"codamount": codAmount,
|
|
// collectionamt is the same number under the name the rider app
|
|
// reads. Added rather than renamed: `codamount` is what this
|
|
// endpoint has always emitted and the deployed build parses it,
|
|
// so removing it would break every stop on every existing
|
|
// device. Both are written from one variable, so they cannot
|
|
// drift apart.
|
|
"collectionamt": 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. Stops from one
|
|
// booking share its step, so they stay adjacent and in destination order.
|
|
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)))
|
|
}
|
|
|
|
// milerStop is one thing the rider actually has to do with one parcel — a
|
|
// pickup before collection, a delivery leg after it.
|
|
type milerStop struct {
|
|
seq int
|
|
consignmentID *int
|
|
trackingNo string
|
|
deliveryAddress string
|
|
deliveryLat float64
|
|
deliveryLng float64
|
|
recipientName string
|
|
recipientPhone string
|
|
// codAmount is the collection the CUSTOMER asked for at THIS door, read off
|
|
// the destination row. The consignment's own figure wins at delivery time
|
|
// because it also knows what has already been collected — but this is what
|
|
// makes the per-stop split provable without a database, and it is the value
|
|
// used if the consignment row could not be loaded. Money must never fall
|
|
// back to another destination's number.
|
|
codAmount float64
|
|
parcels []models.BookingParcel
|
|
}
|
|
|
|
// milerStopsForBooking decides how many stops a booking is worth to the rider.
|
|
//
|
|
// Before the parcels are collected it is always ONE stop: the rider makes a
|
|
// single visit to the customer's door, whatever it is carrying away. After
|
|
// collection a customer-app pickup becomes one stop per destination, because
|
|
// each parcel now has its own journey, its own tracking number and its own
|
|
// deliver/skip action.
|
|
//
|
|
// A booking with no destination rows — every console/express booking, and
|
|
// everything written before the fan-out — is always one stop built from the
|
|
// booking's own columns, exactly as before.
|
|
func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop {
|
|
single := []milerStop{{
|
|
seq: 0,
|
|
consignmentID: b.Consignmentid,
|
|
deliveryAddress: b.Deliveryaddress,
|
|
deliveryLat: b.Deliverylatitude,
|
|
deliveryLng: b.Deliverylongitude,
|
|
parcels: b.Parcels,
|
|
}}
|
|
// A booking with exactly one destination row still carries its COD there.
|
|
if len(destinations) == 1 {
|
|
single[0].codAmount = destinations[0].Codamount
|
|
}
|
|
|
|
if len(destinations) == 0 {
|
|
return single
|
|
}
|
|
|
|
// Not collected yet: one visit, one stop. The destinations are still only an
|
|
// intention, and showing three rows for a pickup that has not happened would
|
|
// have the rider drive to the same door three times.
|
|
collected := false
|
|
for _, d := range destinations {
|
|
if d.Consignmentid != nil {
|
|
collected = true
|
|
break
|
|
}
|
|
}
|
|
if !collected {
|
|
// Destination 0's address is already mirrored onto the booking, so the
|
|
// single pre-pickup stop is correct as built above.
|
|
return single
|
|
}
|
|
|
|
parcelsByDestination := map[int][]models.BookingParcel{}
|
|
for _, p := range b.Parcels {
|
|
if p.Bookingdestinationid != nil {
|
|
parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p)
|
|
}
|
|
}
|
|
|
|
// Stop order is `seq`, guaranteed here rather than inherited from whatever
|
|
// ORDER BY the caller happened to use. The queue query does sort by seq
|
|
// today, so this changes nothing — but the ordering IS the route the rider
|
|
// drives, and leaving it as an unstated precondition means the next caller,
|
|
// or an edited query, silently reorders someone's afternoon. Sorted on a
|
|
// copy so the caller's slice is never mutated underneath it.
|
|
ordered := make([]models.BookingDestination, len(destinations))
|
|
copy(ordered, destinations)
|
|
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq })
|
|
destinations = ordered
|
|
|
|
stops := make([]milerStop, 0, len(destinations))
|
|
for _, d := range destinations {
|
|
lat, lng := 0.0, 0.0
|
|
if d.Pinlatitude != nil && d.Pinlongitude != nil {
|
|
lat, lng = *d.Pinlatitude, *d.Pinlongitude
|
|
}
|
|
stops = append(stops, milerStop{
|
|
seq: d.Seq,
|
|
consignmentID: d.Consignmentid,
|
|
trackingNo: d.Trackingno,
|
|
deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename),
|
|
deliveryLat: lat,
|
|
deliveryLng: lng,
|
|
recipientName: d.Recipientname,
|
|
recipientPhone: d.Recipientphone,
|
|
codAmount: d.Codamount,
|
|
parcels: parcelsByDestination[d.Bookingdestinationid],
|
|
})
|
|
}
|
|
|
|
// Destination 0's coordinates are mirrored onto the booking and may have
|
|
// been corrected there by the rider at the door, so prefer those when the
|
|
// destination itself never got a pin.
|
|
if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 {
|
|
stops[0].deliveryLat = b.Deliverylatitude
|
|
stops[0].deliveryLng = b.Deliverylongitude
|
|
}
|
|
|
|
return stops
|
|
}
|
|
|
|
// 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)
|
|
|
|
// pickupbookings.consignmentid names only the FIRST order of a
|
|
// multi-destination pickup, so the count above misses orders 2..N entirely.
|
|
// The destination table is what actually knows which booking a consignment
|
|
// belongs to.
|
|
if count == 0 {
|
|
db.DB.Model(&models.BookingDestination{}).
|
|
Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid").
|
|
Where("bookingdestinations.consignmentid = ? AND pickupbookings.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,
|
|
// The same leg information the queue read and pickup-complete give, so a
|
|
// single-consignment refresh is as authoritative as a full poll.
|
|
"next_action": nextActionForConsignment(consignment.Status),
|
|
"next_hub": nextHubForConsignment(consignment),
|
|
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
|
|
"inwardedat": consignment.Inwardedat,
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
|
|
constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID,
|
|
"POST /miler/consignments/{id}/start-delivery")
|
|
if err != nil {
|
|
tx.Rollback()
|
|
utils.Error("MilerStartDelivery: could not record out_for_delivery",
|
|
"consignment_id", consignment.Consignmentid, "error", err)
|
|
return utils.Internal(c, "failed to start delivery")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to start delivery")
|
|
}
|
|
|
|
notifyOutForDelivery()
|
|
|
|
// 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).
|
|
//
|
|
// Resolved through bookingdestinations: pickupbookings.consignmentid names
|
|
// only the first order of a multi-destination pickup, so joining on it meant
|
|
// no customer was ever told about orders 2..N going out for delivery.
|
|
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
|
|
booking := *bookingPtr
|
|
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")
|
|
}
|
|
|
|
// Ownership and the booking behind the parcel, via the one helper that
|
|
// understands a multi-destination pickup. This used to be a direct
|
|
// `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings —
|
|
// which names only the FIRST order of a pickup, so a rider delivering the
|
|
// second parcel of a three-destination visit was told "assigned consignment
|
|
// not found" and could not close the delivery at all.
|
|
// Both failures answer 404 with the exact messages this endpoint has always
|
|
// returned. milerConsignmentForRider distinguishes "no such consignment"
|
|
// from "not yours" and start-delivery reports that as a 403, but the
|
|
// deployed rider app was built against a 404 here and changing a live
|
|
// endpoint's status code is not this work's business. Only the LOOKUP is
|
|
// fixed; the contract is byte-identical.
|
|
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
|
|
if err != nil {
|
|
if code == constants.ErrConsignmentNotFound {
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
return utils.NotFound(c, "assigned consignment not found")
|
|
}
|
|
consignment := *consignmentPtr
|
|
|
|
_, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid)
|
|
if !ok || bookingPtr == nil {
|
|
return utils.NotFound(c, "assigned consignment not found")
|
|
}
|
|
booking := *bookingPtr
|
|
|
|
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")
|
|
}
|
|
|
|
// The customer's Delivered milestone, against THIS order. The booking only
|
|
// reads as completed once every destination has landed — cxstage rolls the
|
|
// booking up from the least-advanced order, so a two-parcel pickup with one
|
|
// still in transit stays "In transit" rather than telling the customer
|
|
// everything arrived.
|
|
notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
|
|
constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID,
|
|
"POST /miler/consignments/{id}/deliver")
|
|
if err != nil {
|
|
tx.Rollback()
|
|
utils.Error("MilerDeliverConsignment: could not record delivered",
|
|
"consignment_id", consignment.Consignmentid, "error", err)
|
|
return utils.Internal(c, "failed to confirm delivery")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to confirm delivery")
|
|
}
|
|
|
|
notifyDelivered()
|
|
|
|
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)))
|
|
}
|