252 lines
8.8 KiB
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
|
|
}
|