186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
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
|
|
}
|