Files
doormile_backend/controllers/cxBookingView.go

761 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}