Files
doormile_backend/controllers/cxPickupFanout.go

252 lines
8.8 KiB
Go

package controllers
import (
"math"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Pickup fan-out: one visit becomes N orders.
//
// This is the change §1 of the customer contract turns on. A pickup booking
// used to convert into exactly one consignment, because a booking carried
// exactly one delivery address in its own columns. A customer-app booking
// carries 1..N destinations, and each of those has to become its own
// consignment with its own tracking number and its own journey — three parcels
// collected in one visit for Chennai, Kochi and Bengaluru travel three separate
// routes the moment they leave the door.
//
// A booking with no destination rows — every console-created express booking,
// and every row written before this table existed — produces exactly one leg
// built from the flat delivery columns, which is byte-for-byte the behaviour
// that was there before. Single-destination is not a special case in either
// direction: it is one leg, through the same code.
// cxPickupLeg is one journey to create at pickup completion.
type cxPickupLeg struct {
// Destination is nil for a booking that has no destination rows.
Destination *models.BookingDestination
DeliveryLatitude float64
DeliveryLongitude float64
DeliveryPincode string
CodAmount float64
// Parcels are the packages going to this destination, already weighed by
// the miler.
Parcels []models.BookingParcel
}
// cxPickupLegs splits a booking into the journeys its parcels are about to
// take.
func cxPickupLegs(tx *gorm.DB, booking *models.PickupBooking) ([]cxPickupLeg, error) {
var destinations []models.BookingDestination
if err := tx.Where("bookingid = ?", booking.Bookingid).
Order("seq ASC").Find(&destinations).Error; err != nil {
return nil, err
}
var parcels []models.BookingParcel
if err := tx.Where("bookingid = ?", booking.Bookingid).Find(&parcels).Error; err != nil {
return nil, err
}
if len(destinations) == 0 {
// The pre-existing shape: one consignment from the booking's own
// delivery columns, carrying every parcel on the booking.
return []cxPickupLeg{{
DeliveryLatitude: booking.Deliverylatitude,
DeliveryLongitude: booking.Deliverylongitude,
DeliveryPincode: booking.Deliverypincode,
Parcels: parcels,
}}, nil
}
byDestination := map[int][]models.BookingParcel{}
unassigned := []models.BookingParcel{}
for _, p := range parcels {
if p.Bookingdestinationid == nil {
unassigned = append(unassigned, p)
continue
}
byDestination[*p.Bookingdestinationid] = append(byDestination[*p.Bookingdestinationid], p)
}
legs := make([]cxPickupLeg, 0, len(destinations))
for i := range destinations {
d := destinations[i]
lat, lng := cxDestinationPoint(&d)
leg := cxPickupLeg{
Destination: &destinations[i],
DeliveryLatitude: lat,
DeliveryLongitude: lng,
DeliveryPincode: d.Pincode,
CodAmount: d.Codamount,
Parcels: byDestination[d.Bookingdestinationid],
}
// Parcels the miler added at the door without naming a destination go
// with the first one. Attributing them to nothing would drop them out
// of every weight total, and the first destination is the one the rider
// was shown.
if i == 0 {
leg.Parcels = append(leg.Parcels, unassigned...)
}
legs = append(legs, leg)
}
return legs, nil
}
// cxDestinationPoint gives a destination the best coordinates it has: the
// customer's own map pin if they dropped one, otherwise the district centre.
// Only state and district are required at booking time, so the centre is
// frequently all there is — and routing skips stops sitting at 0,0.
func cxDestinationPoint(d *models.BookingDestination) (lat, lng float64) {
if d.Pinlatitude != nil && d.Pinlongitude != nil &&
(*d.Pinlatitude != 0 || *d.Pinlongitude != 0) {
return *d.Pinlatitude, *d.Pinlongitude
}
var district models.ServiceableDistrict
if err := db.DB.Select("centrelatitude, centrelongitude").
Where("districtcode = ?", d.Districtcode).First(&district).Error; err == nil {
return district.Centrelatitude, district.Centrelongitude
}
return 0, 0
}
// cxLegWeights totals a leg's packages the way the consignment records them.
// A leg whose packages were never weighed falls back to the same 0.5 kg
// placeholder the single-consignment path already used, so an unweighed pickup
// still produces a chargeable consignment rather than a zero-weight one.
func cxLegWeights(leg cxPickupLeg) (deadWeight, chargeable, maxL, maxW, maxH float64) {
for _, p := range leg.Parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
deadWeight += p.Weight
chargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
}
if len(leg.Parcels) == 0 || chargeable == 0 {
deadWeight, chargeable = 0.5, 0.5
}
return
}
// cxLinkLegToOrder records that a destination has become an order: its tracking
// number, its consignment, and the delivery date the customer is promised.
func cxLinkLegToOrder(tx *gorm.DB, leg cxPickupLeg, consignmentID int, trackingNo string, expected *time.Time, at time.Time) error {
if leg.Destination == nil {
return nil
}
updates := map[string]interface{}{
"consignmentid": consignmentID,
"trackingno": trackingNo,
"stage": constants.CxStageOrderCreated,
"updatedat": at,
}
if expected != nil {
updates["expecteddeliveryat"] = *expected
}
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", leg.Destination.Bookingdestinationid).
Updates(updates).Error
}
// cxRecordVerification stores what the miler measured at the door: the weight
// the price settles on, when, and who took the reading. It is the evidence half
// of the receipt, and it is per destination because each order is weighed
// separately.
func cxRecordVerification(tx *gorm.DB, destinationID int, weightKg float64, byUserID int, at time.Time) error {
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinationID).
Updates(map[string]interface{}{
"verifiedweightkg": weightKg,
"verifiedat": at,
"verifiedbyuserid": byUserID,
"updatedat": at,
}).Error
}
// cxDestinationForConsignment finds which order a consignment belongs to.
//
// The delivery handlers used to reach the booking with
// `WHERE consignmentid = ?` on pickupbookings, which held exactly one
// consignment id. With a fan-out that column only names the FIRST order, so
// that lookup silently found nothing for destinations 2..N — no customer
// notification, no stage advance, on every multi-destination booking. The join
// goes through bookingdestinations now, which is the table that actually knows.
func cxDestinationForConsignment(consignmentID int) (*models.BookingDestination, *models.PickupBooking, bool) {
var dest models.BookingDestination
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&dest).Error; err == nil {
var booking models.PickupBooking
if err := db.DB.First(&booking, dest.Bookingid).Error; err == nil {
return &dest, &booking, true
}
return &dest, nil, false
}
// No destination row: a console-created booking, or one written before the
// fan-out existed. The legacy link still answers for those.
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&booking).Error; err != nil {
return nil, nil, false
}
return nil, &booking, true
}
// cxDestinationIDFor returns the destination id to attribute a per-order stage
// to, or nil when the booking has no destination rows.
func cxDestinationIDFor(dest *models.BookingDestination) *int {
if dest == nil {
return nil
}
id := dest.Bookingdestinationid
return &id
}
// cxEstimatedDelivery is the promise shown to the customer, derived from the
// serving district's own promise text rather than a single platform-wide SLA —
// "Next-day delivery" into Chennai and "2-day delivery" into a district two
// states away are different promises and must not resolve to the same date.
func cxEstimatedDelivery(leg cxPickupLeg, from time.Time) *time.Time {
days := 2
if leg.Destination != nil {
var district models.ServiceableDistrict
if err := db.DB.Select("promise").
Where("districtcode = ?", leg.Destination.Districtcode).
First(&district).Error; err == nil {
switch district.Promise {
case "Same-day delivery":
days = 0
case "Next-day delivery":
days = 1
case "2-day delivery":
days = 2
case "3-day delivery":
days = 3
}
}
}
// Delivered by end of the promised day, expressed in the wall clock this
// database stores.
target := from.AddDate(0, 0, days)
expected := time.Date(target.Year(), target.Month(), target.Day(), 20, 0, 0, 0, time.UTC)
if utils.EpochMillis(expected) < utils.EpochMillis(from) {
expected = from
}
return &expected
}