backend requirements onthe xustomer app
This commit is contained in:
760
controllers/cxBookingView.go
Normal file
760
controllers/cxBookingView.go
Normal file
@@ -0,0 +1,760 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user