761 lines
25 KiB
Go
761 lines
25 KiB
Go
package controllers
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"doormile/constants"
|
||
"doormile/db"
|
||
"doormile/internal/storage"
|
||
"doormile/models"
|
||
"doormile/utils"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
)
|
||
|
||
// The canonical booking object — §9.3.
|
||
//
|
||
// The tracking screen and the receipt are both rendered from this one shape, so
|
||
// it is built in exactly one place and every read that returns a booking goes
|
||
// through it. A list row and a detail read differing in shape is how a client
|
||
// ends up with two parsers for one object.
|
||
//
|
||
// Two rules the client's type declarations depend on, and which will throw in
|
||
// its parser if broken:
|
||
// - pickup and slotId are present on EVERY booking, cancelled ones included.
|
||
// - destinations[].stateName / districtName are always populated; the client
|
||
// renders "Chennai, Tamil Nadu" from them and never looks a code up.
|
||
|
||
// cxPhotoTTL is how long a parcel-photo link stays valid. Long enough to open
|
||
// the receipt, read it and come back; short enough that a link forwarded on is
|
||
// dead by the time it is opened.
|
||
const cxPhotoTTL = 30 * time.Minute
|
||
|
||
// cxBookingBundle is everything one or more bookings need in order to render,
|
||
// loaded in batch. Building it per booking would put six queries behind a
|
||
// tracking poll that runs every few seconds.
|
||
type cxBookingBundle struct {
|
||
destinations map[int][]models.BookingDestination
|
||
events map[int][]models.BookingStageEvent
|
||
photos map[int][]models.BookingParcelPhoto // keyed by destination id
|
||
milers map[int]cxAgent // keyed by miler user id
|
||
assignedTo map[int]int // booking id -> miler user id
|
||
deliveryAgent map[int]int // destination id -> agent user id
|
||
payments map[int]float64 // booking id -> settled rupees
|
||
districts map[string]models.ServiceableDistrict
|
||
hubNames map[int]string
|
||
// single marks a one-booking read (the tracking screen or the receipt), as
|
||
// opposed to a page of them. A couple of fields are worth a live lookup for
|
||
// one booking and are not worth twenty of them for a list.
|
||
single bool
|
||
}
|
||
|
||
type cxAgent struct {
|
||
Name string
|
||
Vehicle string
|
||
Phone string
|
||
Rating float64
|
||
Trips int
|
||
VehicleType string
|
||
Lat, Lng float64
|
||
}
|
||
|
||
// renderCxBooking projects one booking into the customer contract.
|
||
func renderCxBooking(b *models.PickupBooking, bundle *cxBookingBundle) fiber.Map {
|
||
destinations := bundle.destinations[b.Bookingid]
|
||
|
||
stage := b.Customerstage
|
||
if stage == "" {
|
||
// A booking written before this surface existed, or one created through
|
||
// the console. Deriving a stage from the operational status is honest
|
||
// about where the parcel is; inventing history for it is not, so its
|
||
// timeline stays as short as the events actually recorded.
|
||
stage = deriveStageFromStatus(b)
|
||
}
|
||
status := b.Customerstatus
|
||
if status == "" {
|
||
status = deriveStatus(b, stage)
|
||
}
|
||
// The OPERATIONAL status is the authority on cancellation, whatever the
|
||
// stored customer status says.
|
||
//
|
||
// Three console paths cancel a booking by writing pickupbookings.status
|
||
// directly — AdminCancelBooking, AdminBulkCancelBookings and
|
||
// AdminUpdateBookingStatus (which accepts an arbitrary status string). None
|
||
// of them knows this projection exists. Without this line a customer whose
|
||
// pickup ops cancelled would keep seeing it as active and cancellable
|
||
// forever, because customerstatus was written as "active" at booking time
|
||
// and the empty-string fallback above never fires.
|
||
//
|
||
// Done here rather than only at those call sites so that a cancel path
|
||
// added later cannot reintroduce the same divergence.
|
||
if b.Status == constants.BookingCancelled {
|
||
status = constants.CxStatusCancelled
|
||
}
|
||
|
||
out := fiber.Map{
|
||
"reference": b.Bookingno,
|
||
"stage": stage,
|
||
"status": status,
|
||
"cancellable": status == constants.CxStatusActive && constants.CxCancellable(stage),
|
||
"createdAt": utils.EpochMillis(b.Createdat),
|
||
|
||
"pickup": fiber.Map{
|
||
"title": cxPickupTitle(b),
|
||
"sub": cxPickupSub(b),
|
||
"lat": b.Pickuplatitude,
|
||
"lng": b.Pickuplongitude,
|
||
},
|
||
"slotId": b.Slotid,
|
||
|
||
"destinations": renderCxDestinations(destinations, bundle),
|
||
|
||
"miler": nil,
|
||
"deliveryAgent": nil,
|
||
|
||
"milerDistanceKm": nil,
|
||
"milerEtaMinutes": nil,
|
||
"milersInZone": 0,
|
||
|
||
"routeKm": b.Routekm,
|
||
"expectedDelivery": cxExpectedDelivery(destinations),
|
||
|
||
"fare": fiber.Map{
|
||
"min": b.Estimateminrupees,
|
||
"max": b.Estimatemaxrupees,
|
||
"paymentMethod": "UPI · Cash at doorstep",
|
||
"parcel": describeParcels(totalPackages(destinations)),
|
||
},
|
||
"amountPaid": nil,
|
||
"deliveredAt": nil,
|
||
"cancelReason": nil,
|
||
|
||
"history": renderCxHistory(bundle.events[b.Bookingid]),
|
||
}
|
||
|
||
if b.Cancelreason != "" {
|
||
out["cancelReason"] = b.Cancelreason
|
||
}
|
||
|
||
// The assigned miler, from the stage they are assigned onward.
|
||
if milerUserID, ok := bundle.assignedTo[b.Bookingid]; ok {
|
||
if agent, found := bundle.milers[milerUserID]; found {
|
||
out["miler"] = renderCxAgent(agent)
|
||
|
||
// Distance and ETA are live facts about a rider en route, so they
|
||
// are computed from the rider's current position rather than
|
||
// stored. Only meaningful while they are actually coming: after
|
||
// pickup the number would describe a journey that already ended.
|
||
if stage == constants.CxStageOnTheWay || stage == constants.CxStageArrived {
|
||
km, eta := cxRiderApproach(agent, b, stage)
|
||
out["milerDistanceKm"] = km
|
||
out["milerEtaMinutes"] = eta
|
||
}
|
||
}
|
||
} else if stage == constants.CxStageBooked && bundle.single {
|
||
// Nobody assigned yet — the tracking screen shows how many riders are
|
||
// in the zone instead, which is the only honest thing to say while
|
||
// searching.
|
||
//
|
||
// Only on a single-booking read. This is a Redis GEOSEARCH per booking,
|
||
// and running it across a 20-row Orders list would put twenty of them
|
||
// behind one page load to fill a line the list does not render.
|
||
out["milersInZone"] = milersWithin(b.Pickuplatitude, b.Pickuplongitude, cxMilersNearbyRadiusKM)
|
||
}
|
||
|
||
// The delivering rider, once any order is out for delivery. Taken from the
|
||
// first destination that has one, since the booking-level field describes
|
||
// the leg the customer is currently watching.
|
||
for _, d := range destinations {
|
||
if agentUserID, ok := bundle.deliveryAgent[d.Bookingdestinationid]; ok {
|
||
if agent, found := bundle.milers[agentUserID]; found {
|
||
out["deliveryAgent"] = renderCxAgent(agent)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// amountPaid is present from picked_up. The fare block stays on the booking
|
||
// forever alongside it — the receipt renders amountPaid − fare.min as the
|
||
// weight adjustment, so losing the original estimate would lose the
|
||
// explanation for the difference.
|
||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
|
||
if paid, ok := bundle.payments[b.Bookingid]; ok {
|
||
out["amountPaid"] = int(paid)
|
||
}
|
||
}
|
||
|
||
if delivered := cxAllDeliveredAt(destinations); delivered != nil {
|
||
out["deliveredAt"] = utils.EpochMillis(*delivered)
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
func renderCxAgent(a cxAgent) fiber.Map {
|
||
return fiber.Map{
|
||
"name": a.Name,
|
||
"vehicle": a.Vehicle,
|
||
"phone": a.Phone,
|
||
"rating": a.Rating,
|
||
"trips": a.Trips,
|
||
"vehicleType": a.VehicleType,
|
||
}
|
||
}
|
||
|
||
func renderCxDestinations(destinations []models.BookingDestination, bundle *cxBookingBundle) []fiber.Map {
|
||
out := make([]fiber.Map, 0, len(destinations))
|
||
for i := range destinations {
|
||
d := destinations[i]
|
||
|
||
row := fiber.Map{
|
||
"stateCode": d.Statecode,
|
||
"stateName": d.Statename,
|
||
"districtCode": d.Districtcode,
|
||
"districtName": d.Districtname,
|
||
"packageCount": d.Packagecount,
|
||
"district": nil,
|
||
"details": renderCxDetails(&d),
|
||
"trackingId": nil,
|
||
"stage": nil,
|
||
"verification": nil,
|
||
}
|
||
|
||
if district, ok := bundle.districts[d.Districtcode]; ok {
|
||
card := fiber.Map{
|
||
"code": district.Districtcode,
|
||
"name": district.Districtname,
|
||
"available": district.Available,
|
||
}
|
||
if district.Hubid != nil {
|
||
if name, found := bundle.hubNames[*district.Hubid]; found {
|
||
card["hub"] = name
|
||
}
|
||
}
|
||
if district.Promise != "" {
|
||
card["promise"] = district.Promise
|
||
}
|
||
row["district"] = card
|
||
}
|
||
|
||
// Null until order_created — there is no order to track before the
|
||
// parcels have actually been collected.
|
||
if d.Trackingno != "" {
|
||
row["trackingId"] = d.Trackingno
|
||
}
|
||
if d.Stage != "" {
|
||
row["stage"] = d.Stage
|
||
}
|
||
|
||
// Null until picked_up: the weight and the photographs are what the
|
||
// miler recorded at the door and cannot exist before they were there.
|
||
if d.Verifiedweightkg != nil && d.Verifiedat != nil {
|
||
verification := fiber.Map{
|
||
"weightKg": *d.Verifiedweightkg,
|
||
"photos": cxPhotoURLs(bundle.photos[d.Bookingdestinationid]),
|
||
"capturedAt": utils.EpochMillis(*d.Verifiedat),
|
||
"capturedBy": "",
|
||
}
|
||
if d.Verifiedbyuserid != nil {
|
||
if agent, ok := bundle.milers[*d.Verifiedbyuserid]; ok {
|
||
verification["capturedBy"] = agent.Name
|
||
}
|
||
}
|
||
row["verification"] = verification
|
||
}
|
||
|
||
out = append(out, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// renderCxDetails returns only the fields that were actually filled in. The UI
|
||
// renders a missing one as "Not added — the Miler can confirm this at pickup",
|
||
// which is a real state and not an error: a customer may legitimately book with
|
||
// nothing but a state and a district.
|
||
func renderCxDetails(d *models.BookingDestination) fiber.Map {
|
||
details := fiber.Map{}
|
||
if d.Street != "" {
|
||
details["street"] = d.Street
|
||
}
|
||
if d.Building != "" {
|
||
details["building"] = d.Building
|
||
}
|
||
if d.Landmark != "" {
|
||
details["landmark"] = d.Landmark
|
||
}
|
||
if d.Recipientname != "" {
|
||
details["recipientName"] = d.Recipientname
|
||
}
|
||
if d.Recipientphone != "" {
|
||
details["recipientPhone"] = d.Recipientphone
|
||
}
|
||
if d.Instructions != "" {
|
||
details["instructions"] = d.Instructions
|
||
}
|
||
if d.Pinlatitude != nil && d.Pinlongitude != nil {
|
||
details["pin"] = fiber.Map{"lat": *d.Pinlatitude, "lng": *d.Pinlongitude}
|
||
}
|
||
return details
|
||
}
|
||
|
||
// cxPhotoURLs signs each parcel photograph for the length of a receipt view.
|
||
func cxPhotoURLs(photos []models.BookingParcelPhoto) []string {
|
||
urls := make([]string, 0, len(photos))
|
||
for _, p := range photos {
|
||
url, err := storage.PresignGet(p.Objectkey, cxPhotoTTL)
|
||
if err != nil {
|
||
utils.Warn("cxPhotoURLs: could not sign parcel photo", "key", p.Objectkey, "error", err)
|
||
continue
|
||
}
|
||
urls = append(urls, url)
|
||
}
|
||
return urls
|
||
}
|
||
|
||
// renderCxHistory turns the event log into the timeline. Ordered oldest-first
|
||
// and carrying only real timestamps — every entry on the customer's timeline
|
||
// comes from a row that a real write created.
|
||
func renderCxHistory(events []models.BookingStageEvent) []fiber.Map {
|
||
// One entry per stage. A multi-destination pickup emits a per-order stage
|
||
// once per destination, so those have to collapse — and WHICH of them the
|
||
// timeline shows is not arbitrary:
|
||
//
|
||
// * Booking-level stages (booked..order_created) happen once for the whole
|
||
// pickup, so the first event is the only event.
|
||
// * Per-order stages (in_transit..delivered) are reached by the booking
|
||
// when its SLOWEST order gets there, matching how cxstage rolls the
|
||
// booking up. Taking the first would timestamp "Delivered" at the moment
|
||
// the earliest parcel landed while deliveredAt reports the last one —
|
||
// the same screen contradicting itself.
|
||
at := map[string]time.Time{}
|
||
order := make([]string, 0, len(events))
|
||
|
||
for _, e := range events {
|
||
// Cancellation and release rows are audit records rather than progress —
|
||
// they carry a stage key only because the table needs one. Putting them
|
||
// on the timeline would show the customer "Pickup booked" a second time
|
||
// when a rider handed their pickup back.
|
||
if strings.HasPrefix(e.Remarks, "cancelled:") || strings.HasPrefix(e.Remarks, "released:") {
|
||
continue
|
||
}
|
||
|
||
existing, seen := at[e.Stage]
|
||
if !seen {
|
||
at[e.Stage] = e.Occurredat
|
||
order = append(order, e.Stage)
|
||
continue
|
||
}
|
||
if constants.CxStageRank(e.Stage) >= constants.CxStageOrder[constants.CxStageInTransit] &&
|
||
e.Occurredat.After(existing) {
|
||
at[e.Stage] = e.Occurredat
|
||
}
|
||
}
|
||
|
||
out := make([]fiber.Map, 0, len(order))
|
||
for _, stage := range order {
|
||
out = append(out, fiber.Map{
|
||
"stage": stage,
|
||
"at": utils.EpochMillis(at[stage]),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ── Derivations ──────────────────────────────────────────────────────────────
|
||
|
||
// deriveStageFromStatus gives a stage to a booking that has none: rows written
|
||
// before this surface existed, and console-created express bookings that never
|
||
// went through the customer flow.
|
||
func deriveStageFromStatus(b *models.PickupBooking) string {
|
||
switch b.Status {
|
||
case constants.BookingCancelled:
|
||
return constants.CxStageBooked
|
||
case constants.BookingPickedUp:
|
||
return constants.CxStagePickedUp
|
||
case constants.BookingConvertedConsignment:
|
||
return constants.CxStageOrderCreated
|
||
case constants.BookingMilerAssigned, constants.BookingPickupScheduled:
|
||
// reachedat is the only durable record that the rider actually got
|
||
// there — the operational flow records arrival as a fact rather than a
|
||
// status, so this is the one place it can be read from.
|
||
if b.Arrivedat != nil {
|
||
return constants.CxStageArrived
|
||
}
|
||
return constants.CxStageAssigned
|
||
default:
|
||
return constants.CxStageBooked
|
||
}
|
||
}
|
||
|
||
func deriveStatus(b *models.PickupBooking, stage string) string {
|
||
if b.Status == constants.BookingCancelled {
|
||
return constants.CxStatusCancelled
|
||
}
|
||
if stage == constants.CxStageDelivered {
|
||
return constants.CxStatusCompleted
|
||
}
|
||
return constants.CxStatusActive
|
||
}
|
||
|
||
// cxPickupTitle / cxPickupSub give the pickup block its two lines. The stored
|
||
// title/sub pair is used when the booking came through the customer app; a
|
||
// console-created booking only has one flat address string, so it is split
|
||
// rather than left half-empty — the client types both as non-nullable.
|
||
func cxPickupTitle(b *models.PickupBooking) string {
|
||
if b.Pickuptitle != "" {
|
||
return b.Pickuptitle
|
||
}
|
||
address := strings.TrimSpace(b.Pickupaddress)
|
||
if address == "" {
|
||
return "Pickup address"
|
||
}
|
||
if i := strings.Index(address, ","); i > 0 {
|
||
return strings.TrimSpace(address[:i])
|
||
}
|
||
if len([]rune(address)) > 32 {
|
||
return string([]rune(address)[:32])
|
||
}
|
||
return address
|
||
}
|
||
|
||
func cxPickupSub(b *models.PickupBooking) string {
|
||
if b.Pickupsub != "" {
|
||
return b.Pickupsub
|
||
}
|
||
sub := joinNonEmpty(", ", strings.TrimSpace(b.Pickupaddress), b.Pickuppincode)
|
||
if sub == "" {
|
||
return "Address not recorded"
|
||
}
|
||
return sub
|
||
}
|
||
|
||
// cxExpectedDelivery is a display string, formatted server-side in IST, so the
|
||
// client never has to know the operating timezone. The latest promise across
|
||
// the destinations is the one shown: a booking is not fully delivered until its
|
||
// last parcel is.
|
||
func cxExpectedDelivery(destinations []models.BookingDestination) string {
|
||
var latest *time.Time
|
||
for i := range destinations {
|
||
d := destinations[i]
|
||
if d.Expecteddeliveryat == nil {
|
||
continue
|
||
}
|
||
if latest == nil || d.Expecteddeliveryat.After(*latest) {
|
||
latest = d.Expecteddeliveryat
|
||
}
|
||
}
|
||
if latest == nil {
|
||
return ""
|
||
}
|
||
return utils.FormatISTDate(*latest)
|
||
}
|
||
|
||
// cxAllDeliveredAt returns when the LAST parcel landed, or nil while any is
|
||
// still moving.
|
||
func cxAllDeliveredAt(destinations []models.BookingDestination) *time.Time {
|
||
if len(destinations) == 0 {
|
||
return nil
|
||
}
|
||
var latest *time.Time
|
||
for i := range destinations {
|
||
d := destinations[i]
|
||
if d.Deliveredat == nil {
|
||
return nil
|
||
}
|
||
if latest == nil || d.Deliveredat.After(*latest) {
|
||
latest = d.Deliveredat
|
||
}
|
||
}
|
||
return latest
|
||
}
|
||
|
||
func totalPackages(destinations []models.BookingDestination) int {
|
||
n := 0
|
||
for _, d := range destinations {
|
||
n += d.Packagecount
|
||
}
|
||
if n == 0 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// cxRiderApproach reports how far the rider still is and roughly how long that
|
||
// takes. At the door both are zero — a rider standing at the address is not
|
||
// "0.4 km away", and the screen says Arrived.
|
||
func cxRiderApproach(agent cxAgent, b *models.PickupBooking, stage string) (float64, int) {
|
||
if stage == constants.CxStageArrived {
|
||
return 0, 0
|
||
}
|
||
lat, lng := agent.Lat, agent.Lng
|
||
if lat == 0 && lng == 0 {
|
||
return 0, 0
|
||
}
|
||
km := calculateDistance(lat, lng, b.Pickuplatitude, b.Pickuplongitude)
|
||
km = float64(int(km*10+0.5)) / 10
|
||
|
||
// 18 km/h is a two-wheeler in Indian city traffic, plus a two-minute floor
|
||
// for parking and finding the door. Deliberately a rough number: the app
|
||
// shows it as an approximation and a precise-looking ETA that slips reads
|
||
// worse than an honest one.
|
||
const avgSpeedKMH = 18.0
|
||
eta := int(km/avgSpeedKMH*60) + 2
|
||
return km, eta
|
||
}
|
||
|
||
// ── Bundle loading ───────────────────────────────────────────────────────────
|
||
|
||
// loadCxBundle fetches everything a page of bookings needs, in a fixed number
|
||
// of queries regardless of how many bookings or destinations are involved.
|
||
func loadCxBundle(bookings []models.PickupBooking) *cxBookingBundle {
|
||
bundle := &cxBookingBundle{
|
||
destinations: map[int][]models.BookingDestination{},
|
||
events: map[int][]models.BookingStageEvent{},
|
||
photos: map[int][]models.BookingParcelPhoto{},
|
||
milers: map[int]cxAgent{},
|
||
assignedTo: map[int]int{},
|
||
deliveryAgent: map[int]int{},
|
||
payments: map[int]float64{},
|
||
districts: map[string]models.ServiceableDistrict{},
|
||
hubNames: map[int]string{},
|
||
}
|
||
bundle.single = len(bookings) == 1
|
||
|
||
if len(bookings) == 0 {
|
||
return bundle
|
||
}
|
||
|
||
bookingIDs := make([]int, 0, len(bookings))
|
||
for _, b := range bookings {
|
||
bookingIDs = append(bookingIDs, b.Bookingid)
|
||
}
|
||
|
||
var destinations []models.BookingDestination
|
||
if err := db.DB.Where("bookingid IN ?", bookingIDs).
|
||
Order("bookingid ASC, seq ASC").Find(&destinations).Error; err != nil {
|
||
utils.Error("loadCxBundle: destinations query failed", "error", err)
|
||
}
|
||
destIDs := make([]int, 0, len(destinations))
|
||
districtCodes := map[string]bool{}
|
||
for _, d := range destinations {
|
||
bundle.destinations[d.Bookingid] = append(bundle.destinations[d.Bookingid], d)
|
||
destIDs = append(destIDs, d.Bookingdestinationid)
|
||
if d.Districtcode != "" {
|
||
districtCodes[d.Districtcode] = true
|
||
}
|
||
}
|
||
|
||
var events []models.BookingStageEvent
|
||
if err := db.DB.Where("bookingid IN ?", bookingIDs).
|
||
Order("occurredat ASC, stageeventid ASC").Find(&events).Error; err != nil {
|
||
utils.Error("loadCxBundle: stage events query failed", "error", err)
|
||
}
|
||
for _, e := range events {
|
||
bundle.events[e.Bookingid] = append(bundle.events[e.Bookingid], e)
|
||
}
|
||
|
||
if len(destIDs) > 0 {
|
||
var photos []models.BookingParcelPhoto
|
||
if err := db.DB.Where("bookingdestinationid IN ?", destIDs).
|
||
Order("capturedat ASC").Find(&photos).Error; err != nil {
|
||
utils.Warn("loadCxBundle: parcel photos query failed", "error", err)
|
||
}
|
||
for _, p := range photos {
|
||
if p.Bookingdestinationid != nil {
|
||
bundle.photos[*p.Bookingdestinationid] = append(bundle.photos[*p.Bookingdestinationid], p)
|
||
}
|
||
}
|
||
}
|
||
|
||
milerIDs := map[int]bool{}
|
||
|
||
// The live assignment, if any. Rejected and cancelled assignments are not
|
||
// the current rider and must not be shown as one.
|
||
var assignments []models.BookingAssignment
|
||
if err := db.DB.Where("bookingid IN ? AND assignmentstatus IN ?", bookingIDs,
|
||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted, constants.AssignmentCompleted}).
|
||
Order("assignedat ASC").Find(&assignments).Error; err != nil {
|
||
utils.Warn("loadCxBundle: assignments query failed", "error", err)
|
||
}
|
||
for _, a := range assignments {
|
||
bundle.assignedTo[a.Bookingid] = a.Mileruserid
|
||
milerIDs[a.Mileruserid] = true
|
||
}
|
||
|
||
// Who is delivering each order — the rider who recorded the out-for-delivery
|
||
// event on that consignment.
|
||
consignmentToDest := map[int]int{}
|
||
consignmentIDs := make([]int, 0, len(destinations))
|
||
for _, d := range destinations {
|
||
if d.Consignmentid != nil {
|
||
consignmentToDest[*d.Consignmentid] = d.Bookingdestinationid
|
||
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
|
||
}
|
||
}
|
||
if len(consignmentIDs) > 0 {
|
||
var history []models.ConsignmentHistory
|
||
if err := db.DB.Where("consignmentid IN ? AND eventstatus = ?",
|
||
consignmentIDs, constants.ConsignmentOutForDelivery).
|
||
Order("createdat ASC").Find(&history).Error; err != nil {
|
||
utils.Warn("loadCxBundle: consignment history query failed", "error", err)
|
||
}
|
||
for _, h := range history {
|
||
if h.Userid == nil {
|
||
continue
|
||
}
|
||
if destID, ok := consignmentToDest[h.Consignmentid]; ok {
|
||
bundle.deliveryAgent[destID] = *h.Userid
|
||
milerIDs[*h.Userid] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, d := range destinations {
|
||
if d.Verifiedbyuserid != nil {
|
||
milerIDs[*d.Verifiedbyuserid] = true
|
||
}
|
||
}
|
||
bundle.milers = loadCxAgents(milerIDs)
|
||
|
||
// What the customer actually paid. Summed from the payment rows rather than
|
||
// stored on the booking, so money has one home and a second collection
|
||
// cannot silently disagree with a cached total.
|
||
type paidRow struct {
|
||
Bookingid int
|
||
Total float64
|
||
}
|
||
var paid []paidRow
|
||
if err := db.DB.Model(&models.BookingPayment{}).
|
||
Select("bookingid, sum(amount) as total").
|
||
Where("bookingid IN ? AND paymentstatus = ?", bookingIDs, constants.PaymentStatusPaid).
|
||
Group("bookingid").Scan(&paid).Error; err != nil {
|
||
utils.Warn("loadCxBundle: payment totals query failed", "error", err)
|
||
}
|
||
for _, p := range paid {
|
||
bundle.payments[p.Bookingid] = p.Total
|
||
}
|
||
|
||
if len(districtCodes) > 0 {
|
||
codes := make([]string, 0, len(districtCodes))
|
||
for code := range districtCodes {
|
||
codes = append(codes, code)
|
||
}
|
||
var districts []models.ServiceableDistrict
|
||
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
|
||
utils.Warn("loadCxBundle: district query failed", "error", err)
|
||
}
|
||
for _, d := range districts {
|
||
bundle.districts[d.Districtcode] = d
|
||
}
|
||
bundle.hubNames = hubNamesFor(districts)
|
||
}
|
||
|
||
return bundle
|
||
}
|
||
|
||
// loadCxAgents resolves rider display data — and their live position, which
|
||
// comes from Redis because it changes every few seconds and has no business in
|
||
// Postgres.
|
||
func loadCxAgents(ids map[int]bool) map[int]cxAgent {
|
||
out := map[int]cxAgent{}
|
||
if len(ids) == 0 {
|
||
return out
|
||
}
|
||
list := make([]int, 0, len(ids))
|
||
for id := range ids {
|
||
list = append(list, id)
|
||
}
|
||
|
||
var profiles []models.MilerProfile
|
||
if err := db.DB.Where("userid IN ?", list).Find(&profiles).Error; err != nil {
|
||
utils.Warn("loadCxAgents: profile query failed", "error", err)
|
||
return out
|
||
}
|
||
|
||
vehicleIDs := make([]int, 0, len(profiles))
|
||
for _, p := range profiles {
|
||
if p.Vehicleid != nil {
|
||
vehicleIDs = append(vehicleIDs, *p.Vehicleid)
|
||
}
|
||
}
|
||
vehicles := map[int]models.Vehicle{}
|
||
if len(vehicleIDs) > 0 {
|
||
var rows []models.Vehicle
|
||
if err := db.DB.Where("vehicleid IN ?", vehicleIDs).Find(&rows).Error; err == nil {
|
||
for _, v := range rows {
|
||
vehicles[v.Vehicleid] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, p := range profiles {
|
||
agent := cxAgent{
|
||
Name: p.Displayname,
|
||
Phone: cxMilerContact(p.Phone),
|
||
Rating: p.Rating,
|
||
Trips: p.Totalcompletedpickups,
|
||
VehicleType: p.Defaultvehicletype,
|
||
Lat: p.Currentlatitude,
|
||
Lng: p.Currentlongitude,
|
||
}
|
||
if p.Vehicleid != nil {
|
||
if v, ok := vehicles[*p.Vehicleid]; ok {
|
||
agent.Vehicle = v.Vehicleno
|
||
if agent.VehicleType == "" {
|
||
agent.VehicleType = v.Vehicletype
|
||
}
|
||
}
|
||
}
|
||
if lat, lng, ok := cxLiveRiderPosition(p.Userid); ok {
|
||
agent.Lat, agent.Lng = lat, lng
|
||
}
|
||
out[p.Userid] = agent
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cxLiveRiderPosition reads the rider's current position from the same Redis
|
||
// GEO index the assignment engine searches, so the distance the customer sees
|
||
// and the distance the dispatcher used are the same number. Falls back to the
|
||
// profile's last-known coordinates when Redis has nothing.
|
||
func cxLiveRiderPosition(milerUserID int) (lat, lng float64, ok bool) {
|
||
if db.Rdb == nil {
|
||
return 0, 0, false
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
positions, err := db.Rdb.GeoPos(ctx, "milers:locations", cxRiderGeoMember(milerUserID)).Result()
|
||
if err != nil || len(positions) == 0 || positions[0] == nil {
|
||
return 0, 0, false
|
||
}
|
||
return positions[0].Latitude, positions[0].Longitude, true
|
||
}
|
||
|
||
// cxRiderGeoMember is the member name riders are stored under in the GEO index
|
||
// (see UpdateMilerLocation, which GEOADDs under the rider's user id).
|
||
func cxRiderGeoMember(milerUserID int) string {
|
||
return strconv.Itoa(milerUserID)
|
||
}
|
||
|
||
// cxMilerContact decides what phone number the customer is given for their
|
||
// rider.
|
||
//
|
||
// A masked-calling proxy is preferred, and MILER_CALL_PROXY configures one:
|
||
// handing a customer a rider's personal mobile makes that number permanently
|
||
// theirs, and riders on comparable platforms have been contacted long after the
|
||
// delivery on numbers given out this way. With no proxy configured the real
|
||
// number is returned, because a Call Miler button that dials nothing is worse
|
||
// than one that dials a rider — but this is a setting to close before launch,
|
||
// and §13 of the contract asks product to confirm which it is.
|
||
func cxMilerContact(phone string) string {
|
||
if proxy := strings.TrimSpace(os.Getenv("MILER_CALL_PROXY")); proxy != "" {
|
||
return proxy
|
||
}
|
||
return phone
|
||
}
|