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 }