updates on the api endpoints on the customer page and more
This commit is contained in:
71
internal/legs/legs.go
Normal file
71
internal/legs/legs.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package legs answers one question, in one place: does a parcel go straight to
|
||||
// its receiver, or does it go via a base first?
|
||||
//
|
||||
// It exists because two packages need that answer and neither can import the
|
||||
// other. controllers decides it at pickup-complete (which state the consignment
|
||||
// lands in, and which base the rider is sent to); internal/routing needs it when
|
||||
// it sequences a rider's stops, because a hub-routed parcel's next stop is the
|
||||
// BASE, not the address on the booking. controllers already imports
|
||||
// internal/routing, so routing cannot import back — and a second copy of the
|
||||
// rule in routing is exactly the kind of duplication this codebase has been
|
||||
// bitten by before (the rival status table in dispatchShared, the batch windows
|
||||
// that drifted between two pages).
|
||||
//
|
||||
// So the rule lives here and both call it. Neither owns it.
|
||||
package legs
|
||||
|
||||
import "math"
|
||||
|
||||
// HaversineKM is the straight-line distance between two points, in kilometres.
|
||||
// One definition for the whole codebase — controllers.haversineKM delegates to
|
||||
// it rather than keeping a second copy.
|
||||
func HaversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadiusKM = 6371.0
|
||||
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||||
dLat := toRad(lat2 - lat1)
|
||||
dLon := toRad(lon2 - lon1)
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
// MaxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
||||
// a booking with a missing or unusable pincode is still treated as hyperlocal.
|
||||
// It is ONLY consulted when the pincode rule cannot decide: console-created
|
||||
// kitchen→customer bookings frequently carry accurate coordinates and no
|
||||
// delivery pincode, and must not be routed through a base because of it.
|
||||
const MaxHyperlocalKM = 30.0
|
||||
|
||||
// SamePostalArea reports whether two pincodes fall in the same 3-digit postal
|
||||
// area. Pincodes shorter than 3 characters are treated as unknown rather than
|
||||
// matching, so bad data falls back to the safe hub route instead of quietly
|
||||
// claiming two parcels belong together.
|
||||
func SamePostalArea(pickupPincode, deliveryPincode string) bool {
|
||||
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
||||
return false
|
||||
}
|
||||
return pickupPincode[:3] == deliveryPincode[:3]
|
||||
}
|
||||
|
||||
// IsHyperlocal decides whether a parcel can skip the base and be carried
|
||||
// straight to the receiver by the collecting rider.
|
||||
//
|
||||
// The pincode-prefix rule decides outright when both pincodes are present — a
|
||||
// matching prefix is hyperlocal, a differing one is genuinely inter-area, and
|
||||
// distance does not get a vote. Pollachi is 40km from Coimbatore, closer than
|
||||
// plenty of runs treated as local, and it is still a different postal area.
|
||||
//
|
||||
// Only when a pincode is missing or too short does the straight-line distance
|
||||
// decide instead. With neither pincodes nor coordinates the answer is false,
|
||||
// which routes via a base — the safe direction to be wrong in, because a parcel
|
||||
// that reaches a base can still be forwarded, while one handed to a rider who
|
||||
// cannot reach the receiver is stuck.
|
||||
func IsHyperlocal(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
||||
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
|
||||
return SamePostalArea(pickupPincode, deliveryPincode)
|
||||
}
|
||||
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
|
||||
return HaversineKM(pLat, pLng, dLat, dLng) <= MaxHyperlocalKM
|
||||
}
|
||||
return false
|
||||
}
|
||||
117
internal/legs/legs_test.go
Normal file
117
internal/legs/legs_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package legs
|
||||
|
||||
import "testing"
|
||||
|
||||
// The one definition of "does this parcel go via a base?".
|
||||
//
|
||||
// It lives in its own package because two callers need it and neither can
|
||||
// import the other: controllers decides it at pickup-complete, internal/routing
|
||||
// needs it to know where a rider's leg ends. A second copy in routing was the
|
||||
// alternative, and this codebase has been bitten by that shape before — a rival
|
||||
// status table that drifted until the map and the list showed the same parcel
|
||||
// two different colours.
|
||||
//
|
||||
// So the rule is tested here, once, and both callers inherit it.
|
||||
|
||||
func TestSamePostalArea(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pickup string
|
||||
delivery string
|
||||
want bool
|
||||
}{
|
||||
{"same 3-digit area is one zone", "641012", "641004", true},
|
||||
{"an identical pincode is trivially one zone", "641012", "641012", true},
|
||||
{"Coimbatore to Chennai is not", "641012", "600001", false},
|
||||
{"Coimbatore to Pollachi is not, though it is close", "641012", "642001", false},
|
||||
{"a short pincode proves nothing", "64", "641004", false},
|
||||
{"an empty pincode proves nothing", "", "641004", false},
|
||||
{"two empty pincodes do not match each other", "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := SamePostalArea(tc.pickup, tc.delivery); got != tc.want {
|
||||
t.Errorf("SamePostalArea(%q, %q) = %v, want %v", tc.pickup, tc.delivery, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalPrefersThePincodeRule(t *testing.T) {
|
||||
// Coimbatore pickup, Pollachi delivery: ~40 km apart, which is inside the
|
||||
// distance fallback — but they are different postal areas, and when both
|
||||
// pincodes are present the prefix rule decides outright. Distance does not
|
||||
// get a vote, or a parcel would be routed one way on Tuesday and another on
|
||||
// Wednesday because a geocoder moved a pin.
|
||||
if IsHyperlocal("641025", "642001", 11.0168, 76.9558, 10.658, 77.008) {
|
||||
t.Error("a different postal area must route via a base even when it is close")
|
||||
}
|
||||
|
||||
// The mirror: same area, far apart. A 3-digit area can be large, and the
|
||||
// prefix still decides.
|
||||
if !IsHyperlocal("641025", "641999", 11.0168, 76.9558, 11.6, 77.5) {
|
||||
t.Error("the same postal area must stay hyperlocal even when it is a long way across")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalFallsBackToDistance(t *testing.T) {
|
||||
// Console-created bookings frequently carry accurate coordinates and no
|
||||
// delivery pincode. Routing those through a base because of a missing field
|
||||
// would send a kitchen-to-doorstep lunch order on a tour of the network.
|
||||
if !IsHyperlocal("641025", "", 11.0168, 76.9558, 11.05, 77.01) {
|
||||
t.Error("a short hop with no pincode should stay hyperlocal on the distance fallback")
|
||||
}
|
||||
if IsHyperlocal("641025", "", 11.0168, 76.9558, 13.0827, 80.2707) {
|
||||
t.Error("a 430km haul with no pincode is not hyperlocal whatever the fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalIsFalseWhenNothingCanBeProven(t *testing.T) {
|
||||
// No usable pincodes and no usable coordinates. False routes via a base,
|
||||
// which is the safe direction to be wrong in: a parcel that reaches a base
|
||||
// can still be forwarded, while one handed to a rider who cannot reach the
|
||||
// receiver is simply stuck.
|
||||
if IsHyperlocal("", "", 0, 0, 0, 0) {
|
||||
t.Error("with nothing to decide on, the answer must be the safe one")
|
||||
}
|
||||
if IsHyperlocal("64", "60", 0, 0, 0, 0) {
|
||||
t.Error("unusable pincodes and no coordinates must not resolve to hyperlocal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalTreatsAMissingCoordinateAsMissing(t *testing.T) {
|
||||
// 0,0 is in the Gulf of Guinea. Treating it as a real position would make
|
||||
// every un-geocoded booking look like a 7,000 km haul — or, worse, let two
|
||||
// of them look like neighbours.
|
||||
if IsHyperlocal("641025", "", 11.0168, 76.9558, 0, 0) {
|
||||
t.Error("a 0,0 delivery point is an unset value, not a location near anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaversineKM(t *testing.T) {
|
||||
// A known pair: Coimbatore to Chennai is roughly 430 km great-circle.
|
||||
km := HaversineKM(11.0168, 76.9558, 13.0827, 80.2707)
|
||||
if km < 400 || km > 460 {
|
||||
t.Errorf("Coimbatore→Chennai = %.0f km, expected roughly 430", km)
|
||||
}
|
||||
|
||||
if d := HaversineKM(11.0168, 76.9558, 11.0168, 76.9558); d != 0 {
|
||||
t.Errorf("a point is %v km from itself, want 0", d)
|
||||
}
|
||||
|
||||
// Symmetric, or distance-based decisions would depend on argument order.
|
||||
a := HaversineKM(11.0168, 76.9558, 12.9716, 77.5946)
|
||||
b := HaversineKM(12.9716, 77.5946, 11.0168, 76.9558)
|
||||
if a != b {
|
||||
t.Errorf("distance is not symmetric: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHyperlocalKMBoundary(t *testing.T) {
|
||||
// The fallback threshold is a real operational number, not a magic
|
||||
// constant — a rider is expected to carry a parcel this far, and not
|
||||
// further. Guarding it stops a silent widening.
|
||||
if MaxHyperlocalKM != 30.0 {
|
||||
t.Errorf("MaxHyperlocalKM = %v; changing it changes which parcels riders carry end to end", MaxHyperlocalKM)
|
||||
}
|
||||
}
|
||||
141
internal/routing/dropforleg_test.go
Normal file
141
internal/routing/dropforleg_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/internal/legs"
|
||||
)
|
||||
|
||||
// Where a rider's leg actually ends.
|
||||
//
|
||||
// The bug these tests exist to prevent, found while testing the base-handover
|
||||
// flow with a bulk sheet of intercity orders: the sequencer read
|
||||
// pickupbookings.deliverylatitude for every active assignment, with no idea
|
||||
// whether the parcel was hub-routed. For a Coimbatore → Chennai booking that
|
||||
// told the route optimizer the rider was riding 430 km to the receiver, when
|
||||
// the rider's real next stop is a base a few kilometres away.
|
||||
//
|
||||
// Wrong on its own — a 430 km "stop" is not a stop anyone makes — and worse in
|
||||
// company: one such destination in a rider's set drags the ordering of every
|
||||
// genuine local stop beside it, because the solver is optimising a journey
|
||||
// nobody is going to make.
|
||||
|
||||
// Coimbatore pickup, and the local base a rider hands parcels to.
|
||||
const (
|
||||
pickPin = "641025"
|
||||
pickLat, pickLng = 11.0168, 76.9558
|
||||
baseLat, baseLng = 11.0272, 76.9905
|
||||
localPin = "641004"
|
||||
localLat, localLng = 11.029, 76.993
|
||||
chennaiPin = "600001"
|
||||
chennaiLat, chennaiLng = 13.091, 80.285
|
||||
)
|
||||
|
||||
func TestDropForLeg(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
deliveryPin string
|
||||
dLat, dLng float64
|
||||
baseLat, baseLng float64
|
||||
wantLat, wantLng float64
|
||||
wantOK bool
|
||||
why string
|
||||
}{
|
||||
{
|
||||
name: "a hyperlocal parcel ends at the receiver",
|
||||
deliveryPin: localPin, dLat: localLat, dLng: localLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: localLat, wantLng: localLng, wantOK: true,
|
||||
why: "same postal area — the collecting rider carries it all the way",
|
||||
},
|
||||
{
|
||||
name: "an intercity parcel ends at the base, not in Chennai",
|
||||
deliveryPin: chennaiPin, dLat: chennaiLat, dLng: chennaiLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "this is the whole fix — the rider rides to the base",
|
||||
},
|
||||
{
|
||||
name: "a nearby but different postal area still ends at the base",
|
||||
deliveryPin: "642001", dLat: 10.658, dLng: 77.008,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "Pollachi is 40km away; the prefix rule decides, not the distance",
|
||||
},
|
||||
{
|
||||
name: "no pincode and a short hop still ends at the receiver",
|
||||
deliveryPin: "", dLat: 11.05, dLng: 77.01,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: 11.05, wantLng: 77.01, wantOK: true,
|
||||
why: "the distance fallback puts it inside 30km",
|
||||
},
|
||||
{
|
||||
name: "no pincode and a long haul ends at the base",
|
||||
deliveryPin: "", dLat: chennaiLat, dLng: chennaiLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "the distance fallback puts it far outside 30km",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
lat, lng, ok := dropForLeg(pickPin, tc.deliveryPin,
|
||||
pickLat, pickLng, tc.dLat, tc.dLng, tc.baseLat, tc.baseLng)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v — %s", ok, tc.wantOK, tc.why)
|
||||
}
|
||||
if lat != tc.wantLat || lng != tc.wantLng {
|
||||
t.Errorf("drop = (%v, %v), want (%v, %v) — %s", lat, lng, tc.wantLat, tc.wantLng, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegNeverSendsARiderInterstate(t *testing.T) {
|
||||
// The regression guard, stated as the thing that actually matters rather
|
||||
// than as a coordinate comparison: whatever the destination, the drop the
|
||||
// sequencer is given must be somewhere a rider can plausibly ride to.
|
||||
for _, d := range []struct {
|
||||
name string
|
||||
pin string
|
||||
lat, lng float64
|
||||
}{
|
||||
{"Chennai", "600001", 13.091, 80.285},
|
||||
{"Hyderabad", "500081", 17.44, 78.3489},
|
||||
{"Bengaluru", "560066", 12.9698, 77.75},
|
||||
{"Madurai", "625001", 9.9195, 78.119},
|
||||
} {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
lat, lng, ok := dropForLeg(pickPin, d.pin, pickLat, pickLng, d.lat, d.lng, baseLat, baseLng)
|
||||
if !ok {
|
||||
t.Fatal("a stop with a usable base must still be sequenced")
|
||||
}
|
||||
if lat == d.lat && lng == d.lng {
|
||||
t.Fatalf("%s was passed to the optimizer as the rider's own drop", d.name)
|
||||
}
|
||||
if km := legs.HaversineKM(pickLat, pickLng, lat, lng); km > 50 {
|
||||
t.Errorf("drop is %.0f km from the pickup — no rider is making that leg", km)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegSkipsAHubRoutedStopWithNoBase(t *testing.T) {
|
||||
// Falling back to the receiver here would be the original bug wearing a
|
||||
// different hat: better to leave one stop unsequenced than to skew the
|
||||
// ordering of every other stop the rider is carrying.
|
||||
_, _, ok := dropForLeg(pickPin, chennaiPin, pickLat, pickLng, chennaiLat, chennaiLng, 0, 0)
|
||||
if ok {
|
||||
t.Error("a hub-routed stop with no base coordinates must be left out, not pointed at the receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegKeepsAHyperlocalStopWithNoBase(t *testing.T) {
|
||||
// A hyperlocal parcel never needed a base, so a missing one is irrelevant
|
||||
// to it and must not cost the rider a stop.
|
||||
lat, lng, ok := dropForLeg(pickPin, localPin, pickLat, pickLng, localLat, localLng, 0, 0)
|
||||
if !ok || lat != localLat || lng != localLng {
|
||||
t.Errorf("hyperlocal stop = (%v, %v, %v), want the receiver and ok", lat, lng, ok)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/legs"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
@@ -183,13 +184,31 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
||||
Pickuplongitude float64
|
||||
Deliverylatitude float64
|
||||
Deliverylongitude float64
|
||||
Pickuppincode string
|
||||
Deliverypincode string
|
||||
// The base this rider would hand a hub-routed parcel to: the one the
|
||||
// booking was routed to if anything set it, otherwise the rider's own.
|
||||
// Same order of preference as controllers.resolveHandoverHub.
|
||||
Baselatitude float64
|
||||
Baselongitude float64
|
||||
}
|
||||
|
||||
// A rider's leg does NOT always end at the address on the booking. For an
|
||||
// intercity parcel the rider carries it to a base and hands it over there;
|
||||
// the receiver is somebody else's problem, on another vehicle, days later.
|
||||
// The base coordinates are joined in here so the sequencer can use them as
|
||||
// the real end of the leg — see the substitution below.
|
||||
if err := db.DB.Table("bookingassignments AS ba").
|
||||
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
|
||||
b.pickuplatitude, b.pickuplongitude,
|
||||
b.deliverylatitude, b.deliverylongitude`).
|
||||
b.deliverylatitude, b.deliverylongitude,
|
||||
b.pickuppincode, b.deliverypincode,
|
||||
COALESCE(bh.latitude, rh.latitude, 0) AS baselatitude,
|
||||
COALESCE(bh.longitude, rh.longitude, 0) AS baselongitude`).
|
||||
Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid").
|
||||
Joins("LEFT JOIN hubs AS bh ON bh.hubid = b.nearesthubid AND bh.deletedat IS NULL").
|
||||
Joins("LEFT JOIN milerprofiles AS mp ON mp.userid = ba.mileruserid").
|
||||
Joins("LEFT JOIN hubs AS rh ON rh.hubid = mp.hubid AND rh.deletedat IS NULL").
|
||||
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?",
|
||||
milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
@@ -209,19 +228,60 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
||||
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
||||
continue
|
||||
}
|
||||
dropLat, dropLng, ok := dropForLeg(
|
||||
r.Pickuppincode, r.Deliverypincode,
|
||||
r.Pickuplatitude, r.Pickuplongitude,
|
||||
r.Deliverylatitude, r.Deliverylongitude,
|
||||
r.Baselatitude, r.Baselongitude)
|
||||
if !ok {
|
||||
// Routed to a base, but no base has usable coordinates. Ordering it
|
||||
// against the far-away receiver would distort every other stop, so it
|
||||
// is left out of the route rather than allowed to skew it.
|
||||
utils.Warn("routing: hub-routed stop has no base coordinates, leaving it unsequenced",
|
||||
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
||||
continue
|
||||
}
|
||||
|
||||
stops = append(stops, stop{
|
||||
AssignmentID: r.Bookingassignmentid,
|
||||
BookingID: r.Bookingid,
|
||||
BookingNo: r.Bookingno,
|
||||
PickupLat: r.Pickuplatitude,
|
||||
PickupLng: r.Pickuplongitude,
|
||||
DeliveryLat: r.Deliverylatitude,
|
||||
DeliveryLng: r.Deliverylongitude,
|
||||
DeliveryLat: dropLat,
|
||||
DeliveryLng: dropLng,
|
||||
})
|
||||
}
|
||||
return stops, nil
|
||||
}
|
||||
|
||||
// dropForLeg is where THIS rider's leg ends — which is not always the address
|
||||
// on the booking.
|
||||
//
|
||||
// A hyperlocal parcel ends at the receiver. A hub-routed one ends at a base: the
|
||||
// rider hands it over there and the receiver is somebody else's leg, on another
|
||||
// vehicle, possibly days later. Sequencing a hub-routed parcel against the
|
||||
// receiver's coordinates asks the optimizer to plan a ride to another state —
|
||||
// wrong on its own terms, since a 430km "stop" is not a stop a rider makes, and
|
||||
// worse in company: one intercity destination in the set drags the ordering of
|
||||
// every genuine local stop beside it, because the solver is optimising a journey
|
||||
// nobody is going to make.
|
||||
//
|
||||
// The final destination is not lost. It is simply not this leg.
|
||||
//
|
||||
// ok is false when the parcel is hub-routed and no base has usable coordinates.
|
||||
// The caller drops the stop rather than falling back to the receiver, because a
|
||||
// stop in the wrong country is more damaging to the route than a missing one.
|
||||
func dropForLeg(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng, baseLat, baseLng float64) (lat, lng float64, ok bool) {
|
||||
if legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) {
|
||||
return dLat, dLng, true
|
||||
}
|
||||
if baseLat != 0 || baseLng != 0 {
|
||||
return baseLat, baseLng, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// optimize calls the Route Optimization API and maps its answer back onto our
|
||||
// assignment ids.
|
||||
func optimize(stops []stop) ([]Result, error) {
|
||||
|
||||
Reference in New Issue
Block a user