package prediction import ( "time" "doormile/constants" "doormile/models" "doormile/utils" "gorm.io/gorm" ) // Refining a promise after the customer has already been given one. // // ─── Why this cannot happen at booking-create time ───────────────────────── // // The obvious place to use a calibrated ETA is where the promise is first // written — controllers/adminController.go:2738 and // controllers/cxPickupFanout.go:224. It does not work there, and not because // the calibration is cold: at that moment there is structurally no routed // duration to calibrate. The order of events is // // booking created -> estimateddeliveryat written from the promise table // -> rider assigned // -> internal/routing sequences -> etaminutes written // // so RoutedMinutes is 0 on every create and ETAMinutes would return false // forever. Wiring it there would be dead code. // // The routed duration first exists at sequencing. So refining the promise means // revising a number the customer may already have seen. // // ─── What this does and deliberately does not do ─────────────────────────── // // It updates consignments.estimateddeliveryat — the "arrives by" the customer // is shown — and never touches sladueat. That split is the whole design: // // estimateddeliveryat our best current belief. Allowed to improve. // sladueat the commitment made at booking. Must not move, or it // stops being a commitment and every breach can be // explained away by moving the target. // // It also only ever moves the estimate when the calibration is trustworthy // (ETAMinutes true), so with ROUTE_OPTIMIZER_URL unset — the state today — // this function reads some rows and writes nothing. // // A customer watching the tracking page will see the time change. Whether that // should also emit a stage event is an open product question // (docs/prediction-plan.md §3a); this writes the column and does not notify, // because inventing a notification is the more consequential of the two // choices to get wrong. // RefinedETA is one estimate this pass revised, for logging. type RefinedETA struct { Consignmentid int Was *time.Time Now time.Time Source string } // refineRow is a consignment awaiting delivery, joined to the routed duration // of its booking's latest sequenced assignment. // // The join goes through consignment_booking because consignments has no // bookingid column, and the legacy pickupbookings.consignmentid link names only // the FIRST order of a multi-destination pickup — hazard H4. DISTINCT ON picks // one assignment per booking: a booking passes through several // (Assigned/Rejected/Reassigned) and the newest sequenced one is the routed ETA // actually in force. type refineRow struct { Consignmentid int Deliverypincode string Createdat time.Time Estimateddeliveryat *time.Time Etaminutes int Sequencedat *time.Time } const refineSQL = ` WITH assigned AS ( SELECT DISTINCT ON (ba.bookingid) ba.bookingid, ba.etaminutes, ba.sequencedat FROM bookingassignments ba WHERE ba.etaminutes > 0 ORDER BY ba.bookingid, ba.sequencedat DESC NULLS LAST, ba.bookingassignmentid DESC ) SELECT c.consignmentid, COALESCE(c.deliverypincode, '') AS deliverypincode, c.createdat, c.estimateddeliveryat, a.etaminutes, a.sequencedat FROM consignments c JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid JOIN assigned a ON a.bookingid = cb.bookingid WHERE c.status NOT IN ? AND c.deletedat IS NULL ORDER BY a.sequencedat DESC NULLS LAST LIMIT ? ` // refineBatch caps one pass. The sweeper runs often enough that a backlog // clears over a few ticks, and an unbounded UPDATE loop on a hot table is the // kind of thing that shows up as a latency spike somewhere unrelated. const refineBatch = 300 // terminal statuses — a parcel that has arrived, come back, or been written off // has nothing left to estimate. func terminalStatuses() []string { return []string{ constants.ConsignmentDelivered, "Returned_to_Sender", "Missing", "Damaged", } } // RefineInFlight recomputes estimateddeliveryat for consignments that are still // moving and whose rider has a routed duration. Returns what it changed. // // Safe to call when nothing is calibrated: ETAMinutes returns false for every // row and this writes nothing. func RefineInFlight(gdb *gorm.DB, now time.Time) ([]RefinedETA, error) { if gdb == nil { return nil, nil } if ok, _, _ := Loaded(); !ok { return nil, nil // nothing to refine with; the promise tables stand } var rows []refineRow if err := gdb.Raw(refineSQL, terminalStatuses(), refineBatch).Scan(&rows).Error; err != nil { return nil, err } changed := make([]RefinedETA, 0, len(rows)) for _, r := range rows { // The estimate is anchored on when sequencing happened, not on the // consignment's creation: the routed duration describes the journey // from the rider's current position onward. anchor := r.Createdat if r.Sequencedat != nil { anchor = *r.Sequencedat } eta, res, ok := ETAAt(Input{ RoutedMinutes: r.Etaminutes, DeliveryPincode: r.Deliverypincode, At: anchor, }, anchor) if !ok { continue } // Skip a no-op write. Without this every pass rewrites every in-flight // row with the same value, which churns WAL and makes the log useless // for seeing what actually moved. if r.Estimateddeliveryat != nil && withinAMinute(*r.Estimateddeliveryat, eta) { continue } if err := gdb.Model(&models.Consignment{}). Where("consignmentid = ?", r.Consignmentid). Update("estimateddeliveryat", eta).Error; err != nil { utils.Warn("prediction: could not refine ETA", "consignmentid", r.Consignmentid, "error", err) continue } changed = append(changed, RefinedETA{ Consignmentid: r.Consignmentid, Was: r.Estimateddeliveryat, Now: eta, Source: res.Source, }) } return changed, nil } func withinAMinute(a, b time.Time) bool { d := a.Sub(b) if d < 0 { d = -d } return d < time.Minute }