updates on the api endpoints on the customer page and more
This commit is contained in:
64
CLAUDE.md
64
CLAUDE.md
@@ -45,7 +45,7 @@ customers.
|
||||
prior sessions]**
|
||||
|
||||
- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`.
|
||||
200 registered routes **[verified this session, exact count]** — see §7.
|
||||
220 registered routes **[verified 2026-09-02, exact count]** — see §7.
|
||||
This is the primary booking/assignment API and the primary trigger for
|
||||
miler assignment, calling the AI decision layer with a 5-second timeout
|
||||
fallback so a slow AI response never blocks a booking.
|
||||
@@ -196,7 +196,8 @@ websocket routes. **[verified this session]**
|
||||
`MilerSkipDelivery`, added this session). `ConsignmentHistory` (event
|
||||
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
|
||||
Missing_Contents/Undeliverable).
|
||||
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`.
|
||||
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. `Hub` is what
|
||||
the rider app calls a **Base** — same row, different word (see §12).
|
||||
- `AppUser` (`appusers`) — shared login table for staff/miler/admin roles
|
||||
(`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a
|
||||
separate `HubStaffAccount` table). `MilerProfile` — actual rider profile
|
||||
@@ -402,6 +403,65 @@ code compiled correctly the first time it hit a real toolchain.
|
||||
|
||||
---
|
||||
|
||||
## 8.4 Logistics pickup-source & base-handover flow (2026-09-02)
|
||||
|
||||
**[verified this session]** — closes requests 25–31 on the Miler logistics line.
|
||||
Full contract, state-transition tables and wire values:
|
||||
[`docs/logistics-base-handover.md`](docs/logistics-base-handover.md).
|
||||
|
||||
**Vocabulary.** The wire says *hub*; the rider app renders it as *Base*. Never
|
||||
change a wire value to match the app's wording: `inward_at_hub`,
|
||||
`Inwarded_at_Hub`, `next_hub`, `pickup_source_type: "hub"` stay exactly as spelt.
|
||||
|
||||
**Feature flag `MILER_HUB_HANDOVER_ENABLED`** (default **off**, read per request,
|
||||
same pattern as `MILER_COLLECTED_STATE_ENABLED`). On, a hub-routed parcel stops
|
||||
at `Created` at pickup-complete and only reaches `Inwarded_at_Hub` when the
|
||||
handover is recorded. Off (today), pickup-complete marks it `Inwarded_at_Hub`
|
||||
immediately — which is what the deployed rider app expects. **Do not turn it on
|
||||
until a rider build that calls `inward-at-hub` is live**, or every intercity
|
||||
parcel strands on `Created` with no way to advance it. Everything else in this
|
||||
work is ungated.
|
||||
|
||||
**New endpoints (4):**
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
| POST | `/miler/consignments/:id/inward-at-hub` | `MilerInwardConsignmentAtHub` |
|
||||
| GET | `/miler/bases` | `MilerGetBases` |
|
||||
| GET | `/hub/inbound/expected` | `GetHubInboundExpected` |
|
||||
| POST | `/hub/inbound/:id/reconcile` | `ReconcileHubInbound` |
|
||||
|
||||
**New columns** (additive, nullable, `AutoMigrate`; no CHECK constraint needed
|
||||
widening — `Created` was already permitted on `consignments`):
|
||||
`pickupbookings.pickupsourcetype`, `pickupbookings.pickuphubid`,
|
||||
`consignments.inwardedat`.
|
||||
|
||||
**Conventions added — reuse these, don't reimplement:**
|
||||
- `renderBase(hub)` (`controllers/logisticsHandoverController.go`) is the ONE
|
||||
shape a base is returned in — all six fields, everywhere. A test enforces the
|
||||
count, because five of six leaves a rider unable to navigate.
|
||||
- `nextActionForConsignment(status)` is the ONE definition of what a rider does
|
||||
next. pickup-complete, the queue read and the consignment read all call it, so
|
||||
a poll can never disagree with the pivot.
|
||||
- `resolveHandoverHub(booking, riderHubID)` decides which base a parcel goes to.
|
||||
Backend decides; the app never picks a base.
|
||||
- `pickupSource(booking, customerName)` resolves type/id/name/address for any
|
||||
booking row, in the miler queue, the hub dispatch board and the admin detail.
|
||||
- `scopeConsignmentsToOwnTenant(c, query)` (`hubInboundController.go`) is the
|
||||
consignment counterpart of `scopeBookingsToOwnTenant` — use it on any new
|
||||
hub-console consignment query.
|
||||
|
||||
**Two pre-existing bugs fixed in passing:** a hub-routed pickup left its
|
||||
`BookingAssignment` open forever, so the rider could never go off duty
|
||||
(`MilerEndDuty` refuses while any assignment is Assigned/Accepted); and the
|
||||
no-rider-hub fallback took whichever hub row an unordered query returned first,
|
||||
now nearest-active-base by haversine.
|
||||
|
||||
**Not verified:** no integration test has hit the 4 new endpoints; the migration
|
||||
has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass.
|
||||
|
||||
---
|
||||
|
||||
## 9. Current blockers & open work (whole-project level)
|
||||
|
||||
**[carried forward]**
|
||||
|
||||
@@ -75,6 +75,37 @@ const (
|
||||
ErrOtpInvalid = "OTP_INVALID"
|
||||
ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running
|
||||
ErrEmailInUse = "EMAIL_IN_USE"
|
||||
ErrHubNotFound = "HUB_NOT_FOUND" // hub_id on a handover does not resolve to an active base
|
||||
ErrHubRequired = "HUB_REQUIRED" // handover attempted with no base to hand over to
|
||||
)
|
||||
|
||||
// Pickup source types — what kind of place a booking is collected FROM. Sent
|
||||
// on every miler booking row as pickup_source_type so the rider app can title a
|
||||
// stop correctly instead of guessing from the source name, the pincode or the
|
||||
// rider's own base. "customer" is a real value, never an omission: a front-door
|
||||
// pickup has no configured location id, and "no location because it is a front
|
||||
// door" must be distinguishable from "no location because nobody filled it in".
|
||||
//
|
||||
// The rider app renders "hub" as Base — the wire value stays hub.
|
||||
const (
|
||||
PickupSourceHub = "hub"
|
||||
PickupSourceCustomer = "customer"
|
||||
PickupSourceMerchant = "merchant"
|
||||
PickupSourceStore = "store"
|
||||
)
|
||||
|
||||
// Next actions — what the rider does next with a parcel. Returned by
|
||||
// pickup-complete and, so a poll or a cold restart can rebuild the leg without
|
||||
// a local cache, on every GET /miler/bookings row. Consignment status alone
|
||||
// cannot carry this: a hub-routed parcel and a freshly-collected hyperlocal one
|
||||
// can both sit on Created.
|
||||
const (
|
||||
NextActionPickup = "pickup" // not collected yet — the stop is the pickup
|
||||
NextActionStartDelivery = "start_delivery" // collected, hyperlocal, not yet out for delivery
|
||||
NextActionDeliver = "deliver" // carry it to the receiver
|
||||
NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over
|
||||
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
|
||||
NextActionNone = "none" // terminal (delivered, cancelled, returned)
|
||||
)
|
||||
|
||||
// Payment Modes
|
||||
|
||||
@@ -2142,7 +2142,23 @@ type AdminBookingRequest struct {
|
||||
// callers written against the earlier docs. It is never stored as-is: the
|
||||
// column of that name foreign-keys to appcustomerlocations, not to a
|
||||
// client's sites.
|
||||
Pickuplocationid *int `json:"pickuplocationid"`
|
||||
Pickuplocationid *int `json:"pickuplocationid"`
|
||||
// PickupSourceType says what kind of place this booking is collected from —
|
||||
// one of constants.PickupSource*. Optional: left blank it is classified from
|
||||
// what the payload carries (a base id, a client site id, or neither), so
|
||||
// existing console callers keep working unchanged. Send it explicitly to
|
||||
// create a Base/Hub-origin booking.
|
||||
PickupSourceType string `json:"pickup_source_type"`
|
||||
// Pickuphubid names the base a Base → Customer booking is collected FROM.
|
||||
// Required when pickup_source_type is "hub"; supplying it is also enough on
|
||||
// its own, since a booking that names a base is a base-origin booking. The
|
||||
// base's own address, pincode and coordinates fill in whatever the caller
|
||||
// left blank, so the dispatch board never has to retype a gate address.
|
||||
Pickuphubid *int `json:"pickuphubid"`
|
||||
// Sourceid is accepted as an alias for whichever id the source type implies —
|
||||
// the app and the console have both used this spelling. With
|
||||
// pickup_source_type "hub" it is a base id; otherwise a client-site id.
|
||||
Sourceid *int `json:"sourceid"`
|
||||
Pickupaddress string `json:"pickupaddress"`
|
||||
Pickuppincode string `json:"pickuppincode"`
|
||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
||||
@@ -2202,7 +2218,38 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
// against the earlier documentation. It is the wrong column: it foreign-keys
|
||||
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
|
||||
// Both names resolve to Tenantlocationid.
|
||||
// A base-origin pickup (Base/Hub → Customer). The base is the actual place the
|
||||
// rider collects from, so its address and coordinates become the booking's
|
||||
// pickup point and the row records both the type and the base id — that pair
|
||||
// is what the rider app reads to title the stop as a Base rather than as the
|
||||
// rider's own office, and what the dispatch board reads back on the row.
|
||||
baseID := req.Pickuphubid
|
||||
if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) {
|
||||
baseID = req.Sourceid
|
||||
}
|
||||
if baseID != nil {
|
||||
var hub models.Hub
|
||||
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil {
|
||||
return nil, &expressBookingValidationError{"pickuphubid does not match a known base"}
|
||||
}
|
||||
req.PickupSourceType = constants.PickupSourceHub
|
||||
req.Pickuphubid = &hub.Hubid
|
||||
if req.Pickupaddress == "" {
|
||||
req.Pickupaddress = hub.Address
|
||||
}
|
||||
if req.Pickuppincode == "" {
|
||||
req.Pickuppincode = hub.Pincode
|
||||
}
|
||||
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
|
||||
req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude
|
||||
}
|
||||
}
|
||||
|
||||
siteID := req.Tenantlocationid
|
||||
// sourceid doubles as the client-site id when the source is not a base.
|
||||
if siteID == nil && baseID == nil {
|
||||
siteID = req.Sourceid
|
||||
}
|
||||
if siteID == nil {
|
||||
siteID = req.Pickuplocationid
|
||||
}
|
||||
@@ -2238,10 +2285,34 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
// pickup actually is. Without this the field stays null — as it did on every
|
||||
// booking in the system — and per-site reporting has nothing to group by,
|
||||
// because the console sends a kitchen's address rather than its id.
|
||||
if req.Tenantlocationid == nil {
|
||||
if req.Tenantlocationid == nil && req.Pickuphubid == nil {
|
||||
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
|
||||
}
|
||||
|
||||
// Classify the source once, here, rather than leaving every reader to guess.
|
||||
// A caller-supplied type wins as long as it is one we know; an unknown word is
|
||||
// dropped rather than stored, so the column never holds something the app has
|
||||
// no meaning for. A door pickup is recorded as "customer" explicitly — the
|
||||
// whole point of the column is that a blank cannot be told apart from an
|
||||
// address nobody filled in.
|
||||
switch {
|
||||
case req.Pickuphubid != nil:
|
||||
req.PickupSourceType = constants.PickupSourceHub
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore):
|
||||
req.PickupSourceType = constants.PickupSourceStore
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer):
|
||||
req.PickupSourceType = constants.PickupSourceCustomer
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant):
|
||||
// Honoured even with no site id attached. A merchant collection with no
|
||||
// configured location is still a shop, and telling the rider "customer
|
||||
// door" would send them looking for a person who is not there.
|
||||
req.PickupSourceType = constants.PickupSourceMerchant
|
||||
case req.Tenantlocationid != nil:
|
||||
req.PickupSourceType = constants.PickupSourceMerchant
|
||||
default:
|
||||
req.PickupSourceType = constants.PickupSourceCustomer
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
customerID := req.Appcustomerid
|
||||
@@ -2276,6 +2347,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
Appcustomerid: customerID,
|
||||
Pickuplocationid: req.Pickuplocationid,
|
||||
Tenantlocationid: req.Tenantlocationid,
|
||||
Pickupsourcetype: req.PickupSourceType,
|
||||
Pickuphubid: req.Pickuphubid,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
Pickuplatitude: req.Pickuplatitude,
|
||||
@@ -2525,7 +2598,65 @@ func GetAdminBookingDetails(c *fiber.Ctx) error {
|
||||
if err := q.First(&booking, id).Error; err != nil {
|
||||
return utils.NotFound(c, "booking not found")
|
||||
}
|
||||
return utils.OK(c, booking)
|
||||
|
||||
// The routing decision and the inputs it was made from, so a support call
|
||||
// about "why does this say handover instead of delivery" is a lookup rather
|
||||
// than a reconstruction. Everything here is derived from stored state — no
|
||||
// new columns, and it stays right if the routing rule changes, because it
|
||||
// reads the same helpers the pivot does.
|
||||
var customer models.AppCustomer
|
||||
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking,
|
||||
customer.Firstname+" "+customer.Lastname)
|
||||
|
||||
routing := fiber.Map{
|
||||
"pickup_source_type": sourceType,
|
||||
"pickup_source_id": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"from_address": sourceAddress,
|
||||
"from_pincode": booking.Pickuppincode,
|
||||
"to_address": booking.Deliveryaddress,
|
||||
"destination_pincode": booking.Deliverypincode,
|
||||
// hyperlocal: same postal area, so no base leg — the collecting rider
|
||||
// carries it to the receiver. Otherwise it goes through a base. This is the
|
||||
// decision pickup-complete makes, shown with the inputs it makes it from.
|
||||
"is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
booking.Deliverylatitude, booking.Deliverylongitude),
|
||||
}
|
||||
|
||||
// Before pickup the decision has not been taken yet, so the routing result is
|
||||
// a projection; after pickup it is fact, read off the consignment.
|
||||
if booking.Consignmentid != nil {
|
||||
var cn models.Consignment
|
||||
if db.DB.First(&cn, *booking.Consignmentid).Error == nil {
|
||||
booking.Consignmentstatus = cn.Status
|
||||
routing["consignment_state"] = cn.Status
|
||||
routing["next_action"] = nextActionForConsignment(cn.Status)
|
||||
routing["next_hub"] = renderBase(loadHub(cn.Currenthubid))
|
||||
routing["inwardedat"] = cn.Inwardedat
|
||||
routing["decided"] = true
|
||||
}
|
||||
} else {
|
||||
routing["consignment_state"] = ""
|
||||
routing["next_action"] = constants.NextActionPickup
|
||||
routing["next_hub"] = nil
|
||||
routing["decided"] = false
|
||||
}
|
||||
|
||||
// routing rides alongside the booking's own fields rather than nesting them
|
||||
// under a new key — the console reads this response as a booking object today,
|
||||
// and moving those fields would break every screen that does.
|
||||
raw, err := json.Marshal(booking)
|
||||
if err != nil {
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
payload["routing"] = routing
|
||||
return utils.OK(c, payload)
|
||||
}
|
||||
|
||||
func AdminAssignMiler(c *fiber.Ctx) error {
|
||||
|
||||
@@ -421,18 +421,23 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
|
||||
tx := db.DB.Begin()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingno: generateBookingNo(),
|
||||
Appcustomerid: customerID,
|
||||
Pickuplocationid: req.Pickuplocationid,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
Pickuplatitude: req.Pickuplatitude,
|
||||
Pickuplongitude: req.Pickuplongitude,
|
||||
Deliveryaddress: req.Deliveryaddress,
|
||||
Deliverypincode: req.Deliverypincode,
|
||||
Deliverylatitude: req.Deliverylatitude,
|
||||
Deliverylongitude: req.Deliverylongitude,
|
||||
Bookingsource: "Customer_App",
|
||||
Bookingno: generateBookingNo(),
|
||||
Appcustomerid: customerID,
|
||||
Pickuplocationid: req.Pickuplocationid,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
Pickuplatitude: req.Pickuplatitude,
|
||||
Pickuplongitude: req.Pickuplongitude,
|
||||
Deliveryaddress: req.Deliveryaddress,
|
||||
Deliverypincode: req.Deliverypincode,
|
||||
Deliverylatitude: req.Deliverylatitude,
|
||||
Deliverylongitude: req.Deliverylongitude,
|
||||
Bookingsource: "Customer_App",
|
||||
// A B2C booking is always collected at the sender's own door, so the source
|
||||
// type is recorded rather than left blank — the rider app titles the stop
|
||||
// with the sender's name and address instead of grouping it under the
|
||||
// rider's own base.
|
||||
Pickupsourcetype: constants.PickupSourceCustomer,
|
||||
Status: constants.BookingPendingPickup,
|
||||
Preferredpickupfrom: req.Preferredpickupfrom,
|
||||
Preferredpickupto: req.Preferredpickupto,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/legs"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
@@ -70,14 +71,11 @@ func zoneName(pincode string) string {
|
||||
}
|
||||
|
||||
// haversineKM returns the great-circle distance between two lat/lon points in km.
|
||||
// haversineKM stays the name the whole controllers package calls, and now
|
||||
// delegates to internal/legs so the route sequencer measures distance with the
|
||||
// identical implementation rather than a second copy of it.
|
||||
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))
|
||||
return legs.HaversineKM(lat1, lon1, lat2, lon2)
|
||||
}
|
||||
|
||||
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
||||
@@ -315,15 +313,27 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
||||
var customer models.AppCustomer
|
||||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||||
|
||||
// What kind of place this is collected from, and which one. A Base/Hub
|
||||
// pickup carries the base id, so the dispatch board can show that the
|
||||
// parcel starts at a base rather than at a customer's door — and the row
|
||||
// the rider app receives carries the same pair.
|
||||
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
|
||||
response = append(response, fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"parcels": b.Parcels,
|
||||
"created_at": b.Createdat,
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": customerName,
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickup_address": sourceAddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"delivery_pincode": b.Deliverypincode,
|
||||
"parcels": b.Parcels,
|
||||
"created_at": b.Createdat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -389,17 +399,25 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
|
||||
response = append(response, fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"parcels": b.Parcels,
|
||||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||||
"milername": milerName,
|
||||
"created_at": b.Createdat,
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": customerName,
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickup_address": sourceAddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"delivery_pincode": b.Deliverypincode,
|
||||
"parcels": b.Parcels,
|
||||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||||
"milername": milerName,
|
||||
"created_at": b.Createdat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map {
|
||||
"temperature": "N/A",
|
||||
"status": cs.Status,
|
||||
"updatedat": cs.Updatedat,
|
||||
// The physical-receipt fact, distinct from updatedat, which moves on any
|
||||
// write. Null on rows inwarded before this column existed.
|
||||
"inwardedat": cs.Inwardedat,
|
||||
"inbound_status": "received",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,11 +561,18 @@ func CreateInboundScan(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
consignment.Condition = req.Condition
|
||||
consignment.Shelf = recommendedShelf
|
||||
consignment.Updatedat = time.Now()
|
||||
consignment.Updatedat = now
|
||||
// The inbound scan is a physical receipt, so it stamps the same received-at
|
||||
// fact the rider handover does. Kept first-write-wins: a second scan of the
|
||||
// same parcel must not move the time it actually arrived.
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
return utils.Internal(c, "failed to update consignment")
|
||||
|
||||
286
controllers/hubInboundController.go
Normal file
286
controllers/hubInboundController.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE INBOUND — what is on its way in, and confirming it arrived
|
||||
//
|
||||
// The console side of the rider handover. Wire vocabulary stays hub
|
||||
// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base.
|
||||
// --------------------
|
||||
|
||||
// scopeConsignmentsToOwnTenant is the consignment counterpart of
|
||||
// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's
|
||||
// parcels, Doormile staff see everything. Same rule, different table — without
|
||||
// it a partner's staff would read every other client's parcels passing through
|
||||
// the same base.
|
||||
func scopeConsignmentsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil || isDoormileStaff(staff) {
|
||||
return query
|
||||
}
|
||||
return query.Where("tenantid = ?", *staff.Tenantid)
|
||||
}
|
||||
|
||||
// canHubStaffAccessConsignment proves ownership of a consignment addressed by id
|
||||
// before it is written to, rather than trusting the path parameter.
|
||||
func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isDoormileStaff(staff) {
|
||||
return true
|
||||
}
|
||||
return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid
|
||||
}
|
||||
|
||||
// GetHubInboundExpected lists parcels a rider is currently carrying towards this
|
||||
// base — collected, routed here, not yet handed over. Between a rider collecting
|
||||
// an intercity parcel and inwarding it, nobody at the destination base could see
|
||||
// it was coming; this is that view.
|
||||
//
|
||||
// It reads consignments on Created — collected, in a rider's hands, with a base
|
||||
// as the next leg — whose current base is this one. Under the compatibility flow
|
||||
// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears
|
||||
// here; that is expected, and GetHubInboundToday covers those.
|
||||
func GetHubInboundExpected(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
|
||||
query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL",
|
||||
hubID, constants.ConsignmentCreated)
|
||||
query = scopeConsignmentsToOwnTenant(c, query)
|
||||
|
||||
var consignments []models.Consignment
|
||||
if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch expected inbound consignments")
|
||||
}
|
||||
|
||||
// Batched lookups — this stays a handful of queries however many parcels are
|
||||
// in flight towards the base.
|
||||
ids := make([]int, 0, len(consignments))
|
||||
for _, cs := range consignments {
|
||||
ids = append(ids, cs.Consignmentid)
|
||||
}
|
||||
bookingByConsignment := map[int]models.PickupBooking{}
|
||||
riderIDs := []int{}
|
||||
customerIDs := []int{}
|
||||
if len(ids) > 0 {
|
||||
var bookings []models.PickupBooking
|
||||
db.DB.Where("consignmentid IN ?", ids).Find(&bookings)
|
||||
for _, b := range bookings {
|
||||
if b.Consignmentid != nil {
|
||||
bookingByConsignment[*b.Consignmentid] = b
|
||||
}
|
||||
if b.Assignedmileruserid != nil {
|
||||
riderIDs = append(riderIDs, *b.Assignedmileruserid)
|
||||
}
|
||||
customerIDs = append(customerIDs, b.Appcustomerid)
|
||||
}
|
||||
}
|
||||
riderByID := map[int]models.AppUser{}
|
||||
if len(riderIDs) > 0 {
|
||||
var riders []models.AppUser
|
||||
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
|
||||
for _, r := range riders {
|
||||
riderByID[r.Userid] = r
|
||||
}
|
||||
}
|
||||
customerByID := map[int]models.AppCustomer{}
|
||||
if len(customerIDs) > 0 {
|
||||
var customers []models.AppCustomer
|
||||
db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers)
|
||||
for _, cu := range customers {
|
||||
customerByID[cu.Appcustomerid] = cu
|
||||
}
|
||||
}
|
||||
|
||||
response := make([]fiber.Map, 0, len(consignments))
|
||||
for i := range consignments {
|
||||
cs := consignments[i]
|
||||
row := fiber.Map{
|
||||
"consignmentid": cs.Consignmentid,
|
||||
"trackingno": cs.Trackingno,
|
||||
// destination_base is the base this parcel moves on to after here, and
|
||||
// is only known once something routes it onward — null more often than
|
||||
// not. The delivery pincode is the reliable statement of where it ends
|
||||
// up, so both are given rather than one standing in for the other.
|
||||
"destination_base": renderBase(loadHub(cs.Destinationhubid)),
|
||||
"final_destination": cs.Deliverypincode,
|
||||
"delivery_pincode": cs.Deliverypincode,
|
||||
"chargeableweight": cs.Chargeableweight,
|
||||
"current_state": cs.Status,
|
||||
"inbound_status": "expected",
|
||||
"next_action": nextActionForConsignment(cs.Status),
|
||||
"collected_at": cs.Createdat,
|
||||
"pickup_pincode": cs.Pickuppincode,
|
||||
}
|
||||
if b, ok := bookingByConsignment[cs.Consignmentid]; ok {
|
||||
customerName := ""
|
||||
if cu, ok := customerByID[b.Appcustomerid]; ok {
|
||||
customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname)
|
||||
}
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
row["bookingid"] = b.Bookingid
|
||||
row["bookingno"] = b.Bookingno
|
||||
row["customer_name"] = customerName
|
||||
row["pickup_source_type"] = sourceType
|
||||
row["pickup_source_id"] = sourceID
|
||||
row["source"] = sourceName
|
||||
row["pickup_address"] = sourceAddress
|
||||
row["destination_address"] = b.Deliveryaddress
|
||||
if b.Assignedmileruserid != nil {
|
||||
row["rider_userid"] = *b.Assignedmileruserid
|
||||
if r, ok := riderByID[*b.Assignedmileruserid]; ok {
|
||||
row["rider"] = r.Authname
|
||||
row["rider_phone"] = r.Contactno
|
||||
}
|
||||
}
|
||||
}
|
||||
response = append(response, row)
|
||||
}
|
||||
|
||||
return utils.List(c, response, int64(len(response)))
|
||||
}
|
||||
|
||||
// ReconcileHubInbound is the base's side of the rider handover: staff either
|
||||
// confirm the parcel is physically here, or record that it never arrived despite
|
||||
// a rider marking it handed over.
|
||||
//
|
||||
// received=true is the ordinary case and is idempotent — confirming a parcel
|
||||
// that is already inwarded re-affirms it rather than failing, because staff
|
||||
// working through a pile will hit some rows twice.
|
||||
//
|
||||
// received=false is the reconciliation path. It deliberately does not quietly
|
||||
// move the parcel backwards: it raises an exception naming the discrepancy, so a
|
||||
// parcel a rider swears was handed over and staff never saw becomes a tracked
|
||||
// open item rather than a disagreement nobody owns.
|
||||
func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
// HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an
|
||||
// appusers.userid. consignmentexceptions.reportedbyuserid and
|
||||
// consignmenthistory.userid both carry a real FK to appusers(userid), so
|
||||
// writing a hub-staff id into either violates it — the row is rejected and the
|
||||
// whole action 500s. The staff identity goes into the free-text fields
|
||||
// instead, and the FK-bearing columns are left null. createdby on
|
||||
// consignmentexceptions carries no FK, so it can hold the staff id.
|
||||
staffAccountID, _ := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
// Pointer so an omitted field is never read as "not received".
|
||||
Received *bool `json:"received"`
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Received == nil {
|
||||
return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID).
|
||||
First(&consignment).Error; err != nil {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
if !canHubStaffAccessConsignment(c, &consignment) {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if !*req.Received {
|
||||
description := strings.TrimSpace(req.Remarks)
|
||||
if description == "" {
|
||||
description = "Rider recorded a handover at this base but the parcel was not physically received."
|
||||
}
|
||||
exception := models.ConsignmentException{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
// Lost is the closest type the consignmentexceptions CHECK constraint
|
||||
// already allows, and it is honest: a parcel recorded as handed over
|
||||
// that nobody can find is lost until it turns up. A dedicated
|
||||
// Handover_Not_Received type would need that constraint widened first.
|
||||
Exceptiontype: constants.ExceptionLost,
|
||||
Severity: "High",
|
||||
Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID),
|
||||
Status: constants.ExceptionOpen,
|
||||
Createdby: staffAccountID,
|
||||
}
|
||||
if err := db.DB.Create(&exception).Error; err != nil {
|
||||
return utils.Internal(c, "failed to raise handover exception")
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: consignment.Status,
|
||||
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
|
||||
staffAccountID, description),
|
||||
})
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"received": false,
|
||||
"exceptionid": exception.Exceptionid,
|
||||
"exceptiontype": exception.Exceptiontype,
|
||||
})
|
||||
}
|
||||
|
||||
alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub
|
||||
if !alreadyInwarded {
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hubID
|
||||
}
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = staffAccountID
|
||||
}
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
return utils.Internal(c, "failed to record receipt")
|
||||
}
|
||||
|
||||
if !alreadyInwarded {
|
||||
remarks := strings.TrimSpace(req.Remarks)
|
||||
if remarks == "" {
|
||||
remarks = "Physical receipt confirmed at base"
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"received": true,
|
||||
"already_received": alreadyInwarded,
|
||||
})
|
||||
}
|
||||
514
controllers/logisticsHandoverController.go
Normal file
514
controllers/logisticsHandoverController.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE / HUB HANDOVER — the logistics next-leg surface
|
||||
//
|
||||
// Vocabulary note, because two words are in play for one thing: the wire says
|
||||
// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and
|
||||
// the rider app renders that as Base. Nothing here changes a wire value to suit
|
||||
// the app's wording, and nothing in the app's wording should leak back in here.
|
||||
// --------------------
|
||||
|
||||
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
|
||||
// Created — collected, in the rider's hands, on its way to a base — and only
|
||||
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
|
||||
// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console
|
||||
// inbound scan).
|
||||
//
|
||||
// Default OFF, and it must stay off until a rider-app build that calls the
|
||||
// handover endpoint is live. With it off, pickup-complete keeps marking a
|
||||
// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not
|
||||
// true of where the parcel physically is, but is what the current app and the
|
||||
// console's inbound views expect. Flipping it early would leave every intercity
|
||||
// parcel sitting on Created with no button in the rider's app to advance it and
|
||||
// no row in the base's inbound list.
|
||||
//
|
||||
// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned
|
||||
// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else
|
||||
// in this file — next_hub, the handover endpoint itself, next_action on the
|
||||
// queue read, base master data, inbound visibility — is ungated and safe for the
|
||||
// current app.
|
||||
func hubHandoverEnabled() bool {
|
||||
return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true")
|
||||
}
|
||||
|
||||
// renderBase is the one shape a base is ever returned in, so pickup-complete,
|
||||
// the booking rows, the handover response and GET /miler/bases cannot drift
|
||||
// apart. All six fields every time: the id keys the handover, the name is the
|
||||
// heading the rider reads, address and pincode are what they read at the gate,
|
||||
// and the coordinates are the only thing that can drive Navigate. Five of six
|
||||
// still leaves a rider unable to get there.
|
||||
func renderBase(hub *models.Hub) fiber.Map {
|
||||
if hub == nil {
|
||||
return nil
|
||||
}
|
||||
return fiber.Map{
|
||||
"id": hub.Hubid,
|
||||
"name": hub.Hubname,
|
||||
"address": hub.Address,
|
||||
"pincode": hub.Pincode,
|
||||
"latitude": hub.Latitude,
|
||||
"longitude": hub.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather
|
||||
// than an error for a missing id so callers can treat "no base" and "unknown
|
||||
// base" the same way where that is the right call.
|
||||
func loadHub(hubID *int) *models.Hub {
|
||||
if hubID == nil || *hubID == 0 {
|
||||
return nil
|
||||
}
|
||||
var hub models.Hub
|
||||
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &hub
|
||||
}
|
||||
|
||||
// nearestActiveHub finds the closest active base to a point. Used only as a last
|
||||
// resort when neither the booking nor the rider names one — a parcel with
|
||||
// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil
|
||||
// when no active base has usable coordinates.
|
||||
func nearestActiveHub(lat, lon float64) *models.Hub {
|
||||
if lat == 0 && lon == 0 {
|
||||
return nil
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var best *models.Hub
|
||||
bestKM := 0.0
|
||||
for i := range hubs {
|
||||
h := &hubs[i]
|
||||
if h.Latitude == 0 && h.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(lat, lon, h.Latitude, h.Longitude)
|
||||
if best == nil || d < bestKM {
|
||||
best, bestKM = h, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// resolveHandoverHub decides which base a hub-routed parcel is carried to. The
|
||||
// decision is the backend's, never the app's — the app is told where to go and
|
||||
// navigates there.
|
||||
//
|
||||
// Order, most authoritative first:
|
||||
// 1. the base the booking was routed to (nearesthubid), when the console or the
|
||||
// dispatch layer set one. Nothing populates this column today; it is checked
|
||||
// first so that the moment something does, it wins without another change here.
|
||||
// 2. the collecting rider's own base — the operational default: a rider brings
|
||||
// the parcel back to where they work out of.
|
||||
// 3. the active base nearest the pickup point, for a rider with no base set.
|
||||
// 4. any base at all, so a parcel is never left with nowhere to go.
|
||||
func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub {
|
||||
if hub := loadHub(booking.Nearesthubid); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := loadHub(riderHubID); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil {
|
||||
return hub
|
||||
}
|
||||
var hub models.Hub
|
||||
if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil {
|
||||
return warnIfUnnavigable(&hub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfUnnavigable flags a base the rider cannot actually be routed to. The
|
||||
// correct base is still returned — sending a rider to a different base because
|
||||
// this one has bad master data would be worse than sending them to the right one
|
||||
// with a missing pin. It is a data problem, and it needs to be visible as one.
|
||||
func warnIfUnnavigable(hub *models.Hub) *models.Hub {
|
||||
if hub.Latitude == 0 && hub.Longitude == 0 {
|
||||
utils.Warn("base has no coordinates — Navigate will not work for riders sent here",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
if strings.TrimSpace(hub.Address) == "" {
|
||||
utils.Warn("base has no address — the rider has nothing to read at the gate",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
// derivePickupSourceType classifies where a booking is collected from for rows
|
||||
// written before pickupsourcetype existed, and as a safety net for any writer
|
||||
// that forgets to set it. A stored value always wins — this only fills a blank.
|
||||
//
|
||||
// A base-origin booking names a base; a client-site pickup names a tenant
|
||||
// location (a kitchen, branch or depot — "merchant"); everything else is a
|
||||
// person's door. "customer" is the honest answer for the last case and is
|
||||
// returned as a value, never as an omission.
|
||||
func derivePickupSourceType(b *models.PickupBooking) string {
|
||||
if b.Pickupsourcetype != "" {
|
||||
return b.Pickupsourcetype
|
||||
}
|
||||
if b.Pickuphubid != nil {
|
||||
return constants.PickupSourceHub
|
||||
}
|
||||
if b.Tenantlocationid != nil {
|
||||
return constants.PickupSourceMerchant
|
||||
}
|
||||
return constants.PickupSourceCustomer
|
||||
}
|
||||
|
||||
// pickupSource resolves the source-type, id, name and address the rider app puts
|
||||
// at the top of a pickup stop. customerName is the booking's customer, used for
|
||||
// the door-pickup case so a collection at a house is titled with the sender's
|
||||
// name rather than the rider's own base name.
|
||||
//
|
||||
// sourceID is nil for a customer pickup — there is no configured location and
|
||||
// inventing one would be a lie. That is precisely why pickup_source_type is
|
||||
// carried on the row: the app can then tell "no id because it is a front door"
|
||||
// from "no id because nobody filled it in".
|
||||
func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) {
|
||||
sourceType = derivePickupSourceType(b)
|
||||
address = b.Pickupaddress
|
||||
|
||||
switch sourceType {
|
||||
case constants.PickupSourceHub:
|
||||
sourceID = b.Pickuphubid
|
||||
if hub := loadHub(b.Pickuphubid); hub != nil {
|
||||
name = hub.Hubname
|
||||
if address == "" {
|
||||
address = hub.Address
|
||||
}
|
||||
}
|
||||
case constants.PickupSourceMerchant, constants.PickupSourceStore:
|
||||
sourceID = b.Tenantlocationid
|
||||
if b.Tenantlocationid != nil {
|
||||
var loc models.TenantLocation
|
||||
if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil {
|
||||
name = loc.Locationname
|
||||
if address == "" {
|
||||
address = loc.Address
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Customer door: the sender's own name and the address on the booking.
|
||||
name = strings.TrimSpace(customerName)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.Providerlocation)
|
||||
}
|
||||
return sourceType, sourceID, name, address
|
||||
}
|
||||
|
||||
// nextActionForConsignment maps a consignment's state to what the rider does
|
||||
// next with it. This is the single definition — pickup-complete and the queue
|
||||
// read both call it, so a poll can never disagree with the answer the pivot
|
||||
// gave. Anything terminal returns "none" so a finished parcel retires from the
|
||||
// rider's screen instead of lingering.
|
||||
func nextActionForConsignment(status string) string {
|
||||
switch status {
|
||||
case constants.ConsignmentCreated:
|
||||
// Collected and still in the rider's hands, routed to a base: carry it
|
||||
// there and hand it over. Under the compatibility flow a hub-routed
|
||||
// parcel never sits here — it is already Inwarded_at_Hub.
|
||||
return constants.NextActionInwardAtHub
|
||||
case constants.ConsignmentCollectedByMiler:
|
||||
return constants.NextActionStartDelivery
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return constants.NextActionDeliver
|
||||
case constants.ConsignmentInwardedAtHub:
|
||||
return constants.NextActionHandedToHub
|
||||
default:
|
||||
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
|
||||
// Damaged — all past this rider's leg.
|
||||
return constants.NextActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// nextHubForConsignment names the base a parcel is on its way to, for a
|
||||
// consignment still in a rider's hands. A parcel that has already been inwarded
|
||||
// has no next base — it is at one.
|
||||
func nextHubForConsignment(cn *models.Consignment) fiber.Map {
|
||||
if cn == nil || cn.Status != constants.ConsignmentCreated {
|
||||
return nil
|
||||
}
|
||||
return renderBase(loadHub(cn.Currenthubid))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// GET /miler/bases — base master data on a rider token
|
||||
//
|
||||
// The rider app could previously only see GET /admin/tenants/:id/locations,
|
||||
// which is a different dataset entirely (a client's own sites) and is closed to
|
||||
// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so
|
||||
// that route answers 401 for them by design, not by oversight.
|
||||
// --------------------
|
||||
|
||||
func MilerGetBases(c *fiber.Ctx) error {
|
||||
query := db.DB.Where("deletedat IS NULL")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
query = query.Where("status = ?", "Active")
|
||||
}
|
||||
if appLocationID := c.Query("applocationid"); appLocationID != "" {
|
||||
query = query.Where("applocationid = ?", appLocationID)
|
||||
}
|
||||
|
||||
var hubs []models.Hub
|
||||
if err := query.Find(&hubs).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bases")
|
||||
}
|
||||
|
||||
// Ordered nearest-first from wherever the rider last reported being, so the
|
||||
// base they are most likely to want is at the top. Falls back to id order
|
||||
// when the rider has no position yet.
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
var profile models.MilerProfile
|
||||
hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil &&
|
||||
(profile.Currentlatitude != 0 || profile.Currentlongitude != 0)
|
||||
|
||||
rows := make([]fiber.Map, 0, len(hubs))
|
||||
for i := range hubs {
|
||||
row := renderBase(&hubs[i])
|
||||
if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) {
|
||||
row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude,
|
||||
hubs[i].Latitude, hubs[i].Longitude)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if hasPos {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
di, oki := rows[i]["distance_km"].(float64)
|
||||
dj, okj := rows[j]["distance_km"].(float64)
|
||||
switch {
|
||||
case oki && okj:
|
||||
return di < dj
|
||||
case oki:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return utils.List(c, rows, int64(len(rows)))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// POST /miler/consignments/:id/inward-at-hub — the rider handover
|
||||
//
|
||||
// The authoritative record that a rider physically handed a parcel in at a base.
|
||||
// Idempotent (retries at a loading bay with bad signal are normal, and the route
|
||||
// also carries the shared Idempotency-Key middleware), and it answers with the
|
||||
// resulting state rather than a bare 200 — every lifecycle transition the app
|
||||
// makes is checked against the state that comes back.
|
||||
// --------------------
|
||||
|
||||
func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
HubID *int `json:"hub_id"`
|
||||
// hubid accepted as an alias: the same value has been spelled both ways
|
||||
// across this API's history and a handover is not worth failing over a
|
||||
// missing underscore.
|
||||
HubIDAlt *int `json:"hubid"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
}
|
||||
// A body is optional — a rider handing a parcel into the base it is already
|
||||
// routed to needs to send nothing at all.
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
|
||||
if err != nil {
|
||||
if code == constants.ErrConsignmentNotFound {
|
||||
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
|
||||
}
|
||||
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
|
||||
}
|
||||
|
||||
hubID := req.HubID
|
||||
if hubID == nil {
|
||||
hubID = req.HubIDAlt
|
||||
}
|
||||
if hubID == nil {
|
||||
// Nothing named: hand it into the base it was routed to.
|
||||
hubID = consignment.Currenthubid
|
||||
}
|
||||
if hubID == nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired,
|
||||
"hub_id is required — this consignment is not routed to a base")
|
||||
}
|
||||
hub := loadHub(hubID)
|
||||
if hub == nil {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
|
||||
}
|
||||
|
||||
// Already inwarded: answer with the state that stands rather than failing, so
|
||||
// a retry after a dropped response confirms rather than errors. This is also
|
||||
// what a rider on the compatibility flow hits every time — there,
|
||||
// pickup-complete already marked the parcel Inwarded_at_Hub.
|
||||
if consignment.Status == constants.ConsignmentInwardedAtHub {
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(loadHub(consignment.Currenthubid)),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": true,
|
||||
})
|
||||
}
|
||||
|
||||
// Only a parcel actually in this rider's hands can be handed over. A parcel
|
||||
// already out for delivery has to be delivered or skipped; a delivered or
|
||||
// returned one is past this leg entirely.
|
||||
if consignment.Status != constants.ConsignmentCreated &&
|
||||
consignment.Status != constants.ConsignmentCollectedByMiler {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hub.Hubid
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hub.Hubid
|
||||
}
|
||||
consignment.Inwardedat = &now
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = milerUserID
|
||||
if err := tx.Save(consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hub.Hubid,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)",
|
||||
hub.Hubname, lat, lon),
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record handover history")
|
||||
}
|
||||
|
||||
// The rider's leg ends here, so the assignment closes and they return to the
|
||||
// pool. riderkms is the distance actually ridden on this leg — pickup point to
|
||||
// the base gate — and ridercharges the order amount, both written the same way
|
||||
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
|
||||
// intercity rider's every job reported zero distance and zero value.
|
||||
var booking models.PickupBooking
|
||||
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
// Best-effort, on an already-bound subject — a dropped event must never fail
|
||||
// a handover the rider has physically completed.
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"status": constants.ConsignmentInwardedAtHub,
|
||||
"hubid": hub.Hubid,
|
||||
"mileruserid": milerUserID,
|
||||
"inwardedat": now.UnixMilli(),
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("booking.status.updated", data); err != nil {
|
||||
utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(hub),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": false,
|
||||
})
|
||||
}
|
||||
149
controllers/logisticsHandover_test.go
Normal file
149
controllers/logisticsHandover_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestNextActionForConsignment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The whole point of request 27: a hub-routed parcel sits on Created
|
||||
// while it is being carried to a base, and the app must be able to
|
||||
// rebuild that leg from server state after a restart.
|
||||
name: "collected and routed to a base means carry it there",
|
||||
status: constants.ConsignmentCreated,
|
||||
want: constants.NextActionInwardAtHub,
|
||||
},
|
||||
{
|
||||
name: "collected hyperlocal parcel waits for start-delivery",
|
||||
status: constants.ConsignmentCollectedByMiler,
|
||||
want: constants.NextActionStartDelivery,
|
||||
},
|
||||
{
|
||||
name: "out for delivery means deliver",
|
||||
status: constants.ConsignmentOutForDelivery,
|
||||
want: constants.NextActionDeliver,
|
||||
},
|
||||
{
|
||||
name: "already handed in at a base leaves the rider nothing to do",
|
||||
status: constants.ConsignmentInwardedAtHub,
|
||||
want: constants.NextActionHandedToHub,
|
||||
},
|
||||
{
|
||||
name: "delivered is past this rider's leg",
|
||||
status: constants.ConsignmentDelivered,
|
||||
want: constants.NextActionNone,
|
||||
},
|
||||
{
|
||||
name: "in transit between bases is not a rider action",
|
||||
status: constants.ConsignmentInTransit,
|
||||
want: constants.NextActionNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := nextActionForConsignment(tc.status); got != tc.want {
|
||||
t.Errorf("nextActionForConsignment(%q) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePickupSourceType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "a stored type always wins",
|
||||
booking: models.PickupBooking{Pickupsourcetype: constants.PickupSourceStore, Tenantlocationid: intPtr(7)},
|
||||
want: constants.PickupSourceStore,
|
||||
},
|
||||
{
|
||||
name: "a booking naming a base is a base pickup",
|
||||
booking: models.PickupBooking{Pickuphubid: intPtr(1)},
|
||||
want: constants.PickupSourceHub,
|
||||
},
|
||||
{
|
||||
name: "a booking naming a client site is a merchant pickup",
|
||||
booking: models.PickupBooking{Tenantlocationid: intPtr(7)},
|
||||
want: constants.PickupSourceMerchant,
|
||||
},
|
||||
{
|
||||
// The case the whole column exists for: a front-door pickup has no
|
||||
// location id, and "customer" must be a value rather than a blank.
|
||||
name: "a booking naming no location at all is a customer door",
|
||||
booking: models.PickupBooking{Pickupaddress: "12 Race Course Road"},
|
||||
want: constants.PickupSourceCustomer,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := derivePickupSourceType(&tc.booking); got != tc.want {
|
||||
t.Errorf("derivePickupSourceType() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBaseCarriesAllSixFields(t *testing.T) {
|
||||
// Five of six leaves a rider unable to get there: the id keys the handover,
|
||||
// the name is the heading, address and pincode are read at the gate, and the
|
||||
// coordinates are the only thing that can drive Navigate.
|
||||
hub := models.Hub{
|
||||
Hubid: 1,
|
||||
Hubname: "Coimbatore Hub",
|
||||
Address: "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
Pincode: "641004",
|
||||
Latitude: 11.0272,
|
||||
Longitude: 76.9905,
|
||||
}
|
||||
|
||||
got := renderBase(&hub)
|
||||
for _, field := range []string{"id", "name", "address", "pincode", "latitude", "longitude"} {
|
||||
if _, ok := got[field]; !ok {
|
||||
t.Errorf("renderBase() is missing %q", field)
|
||||
}
|
||||
}
|
||||
if len(got) != 6 {
|
||||
t.Errorf("renderBase() returned %d fields, want exactly 6: %v", len(got), got)
|
||||
}
|
||||
|
||||
if renderBase(nil) != nil {
|
||||
t.Error("renderBase(nil) should be nil, so an unresolved base is absent rather than empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHandoverEnabled(t *testing.T) {
|
||||
// Default OFF matters operationally: turning it on before a rider-app build
|
||||
// that can hand a parcel over would strand every intercity parcel on Created
|
||||
// with no button to advance it.
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "")
|
||||
os.Unsetenv("MILER_HUB_HANDOVER_ENABLED")
|
||||
if hubHandoverEnabled() {
|
||||
t.Error("hub handover must default to off when the env var is unset")
|
||||
}
|
||||
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "TRUE")
|
||||
if !hubHandoverEnabled() {
|
||||
t.Error("hub handover should be on for TRUE, matching the case-insensitive read used elsewhere")
|
||||
}
|
||||
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "1")
|
||||
if hubHandoverEnabled() {
|
||||
t.Error(`only "true" turns the flow on — "1" must not`)
|
||||
}
|
||||
}
|
||||
137
controllers/logisticsRouting_test.go
Normal file
137
controllers/logisticsRouting_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/constants"
|
||||
)
|
||||
|
||||
// The bulk-upload test sheet, checked against the rule that actually decides.
|
||||
//
|
||||
// krow_talent_app/tests/fixtures/doormile-logistics-test.xlsx carries 16 rows
|
||||
// and an "Expected Routing" column saying, in words, what each one should do.
|
||||
// That column is only a claim until something checks it, and the thing that
|
||||
// decides is here, in Go — so it is checked here rather than restated in a
|
||||
// JavaScript test, which would only prove that two copies of the rule agree
|
||||
// with each other.
|
||||
//
|
||||
// If a row of the sheet is edited, this table is what says whether the sheet is
|
||||
// still testing what it says it tests.
|
||||
//
|
||||
// Pickup is the sheet's single sender: Jayanthi's kitchen, Edayarpalayam,
|
||||
// Coimbatore 641025.
|
||||
const (
|
||||
sheetPickupPincode = "641025"
|
||||
sheetPickupLat = 11.0168
|
||||
sheetPickupLng = 76.9558
|
||||
)
|
||||
|
||||
func TestBulkTestSheetRoutesAsDocumented(t *testing.T) {
|
||||
cases := []struct {
|
||||
row int
|
||||
receiver string
|
||||
pincode string
|
||||
lat, lng float64
|
||||
wantLocal bool
|
||||
wantAction string
|
||||
wantConsStat string // with the hub-handover flow ON
|
||||
why string
|
||||
}{
|
||||
// Customer -> Base. A different postal area, so the parcel cannot be
|
||||
// carried to the receiver by the collecting rider.
|
||||
{1, "Suresh Kumar", "600001", 13.091, 80.285, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
|
||||
{2, "Priya Raghavan", "600028", 13.018, 80.256, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
|
||||
{3, "Anil Reddy", "500081", 17.44, 78.3489, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Hyderabad"},
|
||||
{4, "Meera Krishnan", "500032", 17.4156, 78.3378, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
|
||||
{5, "Rahul Menon", "560001", 12.975, 77.606, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Bengaluru"},
|
||||
{6, "Divya Nair", "560066", 12.9698, 77.75, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
|
||||
{7, "Karthik Subramani", "625001", 9.9195, 78.119, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
|
||||
{8, "Lakshmi Devi", "636001", 11.664, 78.146, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
|
||||
|
||||
// Customer -> Customer. The control group: if any of these routes to a
|
||||
// base, the prefix rule has broken.
|
||||
{9, "Ganesh Iyer", "641004", 11.029, 76.993, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
|
||||
{10, "Revathi Balaji", "641012", 11.018, 76.966, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area, COD"},
|
||||
{11, "Vignesh Murugan", "641025", 11.008, 76.928, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "identical pincode"},
|
||||
{12, "Anitha Selvam", "641038", 11.023, 76.945, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
|
||||
|
||||
// No pincode: the decision falls to straight-line distance. Both
|
||||
// outcomes are present, because a fallback that only ever answers one
|
||||
// way is not being tested.
|
||||
{13, "Mohan Das", "", 11.05, 77.01, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "~8km, inside the 30km fallback"},
|
||||
{14, "Sridhar Venkat", "", 13.0827, 80.2707, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~430km, far outside the fallback"},
|
||||
{15, "Bhavani Shankar", "64", 13.06, 80.24, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "2-digit pincode is unusable, distance decides"},
|
||||
|
||||
// The row that proves the prefix rule outranks distance.
|
||||
{16, "Ramesh Palanisamy", "642001", 10.658, 77.008, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~40km but 642 is a different area"},
|
||||
}
|
||||
|
||||
if len(cases) != 16 {
|
||||
t.Fatalf("the sheet has 16 rows, this table has %d — they must not drift apart", len(cases))
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.receiver, func(t *testing.T) {
|
||||
gotLocal := isHyperlocalBooking(
|
||||
sheetPickupPincode, tc.pincode,
|
||||
sheetPickupLat, sheetPickupLng,
|
||||
tc.lat, tc.lng,
|
||||
)
|
||||
if gotLocal != tc.wantLocal {
|
||||
t.Fatalf("row %d (%s): hyperlocal = %v, want %v — %s",
|
||||
tc.row, tc.receiver, gotLocal, tc.wantLocal, tc.why)
|
||||
}
|
||||
|
||||
// What the rider is actually told to do with it, from the same
|
||||
// helper pickup-complete and the queue read both use.
|
||||
status := constants.ConsignmentCreated
|
||||
if gotLocal {
|
||||
status = constants.ConsignmentOutForDelivery
|
||||
}
|
||||
if status != tc.wantConsStat {
|
||||
t.Errorf("row %d: consignment state %s, want %s", tc.row, status, tc.wantConsStat)
|
||||
}
|
||||
if got := nextActionForConsignment(status); got != tc.wantAction {
|
||||
t.Errorf("row %d: next_action %s, want %s", tc.row, got, tc.wantAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet is only worth uploading if it actually splits both ways. A file
|
||||
// that turned out to be all-hyperlocal would pass every assertion above and
|
||||
// still test nothing — which is exactly the problem with the tenant's own
|
||||
// export that this sheet was written to replace.
|
||||
func TestBulkTestSheetExercisesBothLegs(t *testing.T) {
|
||||
type dest struct {
|
||||
pincode string
|
||||
lat, lng float64
|
||||
}
|
||||
dests := []dest{
|
||||
{"600001", 13.091, 80.285}, {"600028", 13.018, 80.256},
|
||||
{"500081", 17.44, 78.3489}, {"500032", 17.4156, 78.3378},
|
||||
{"560001", 12.975, 77.606}, {"560066", 12.9698, 77.75},
|
||||
{"625001", 9.9195, 78.119}, {"636001", 11.664, 78.146},
|
||||
{"641004", 11.029, 76.993}, {"641012", 11.018, 76.966},
|
||||
{"641025", 11.008, 76.928}, {"641038", 11.023, 76.945},
|
||||
{"", 11.05, 77.01}, {"", 13.0827, 80.2707},
|
||||
{"64", 13.06, 80.24}, {"642001", 10.658, 77.008},
|
||||
}
|
||||
|
||||
var local, hub int
|
||||
for _, d := range dests {
|
||||
if isHyperlocalBooking(sheetPickupPincode, d.pincode, sheetPickupLat, sheetPickupLng, d.lat, d.lng) {
|
||||
local++
|
||||
} else {
|
||||
hub++
|
||||
}
|
||||
}
|
||||
|
||||
if hub < 5 {
|
||||
t.Errorf("only %d rows route through a base — too few to exercise the handover flow", hub)
|
||||
}
|
||||
if local < 3 {
|
||||
t.Errorf("only %d rows go direct to the customer — no control group", local)
|
||||
}
|
||||
t.Logf("sheet splits %d base-handover / %d direct-to-customer", hub, local)
|
||||
}
|
||||
@@ -322,34 +322,68 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Where this parcel is collected FROM, and what kind of place that is, so
|
||||
// Home can title the stop correctly. Without pickup_source_type every
|
||||
// logistics pickup was grouped under the rider's own base name and a
|
||||
// collection at a shop looked identical to one at a house.
|
||||
//
|
||||
// sourceid is null for a customer door and that is a real answer, not a
|
||||
// missing one — the type is what makes the difference legible. pickuplocationid
|
||||
// mirrors sourceid: the app has used both spellings for the same thing, and
|
||||
// the booking row never carried either before now.
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
|
||||
customer.Firstname+" "+customer.Lastname)
|
||||
|
||||
// next_action / next_hub: the leg this parcel is on, rebuilt from server
|
||||
// state on every poll. pickup-complete used to be the only place that ever
|
||||
// said it, so a restart mid-leg left the app with nothing authoritative to
|
||||
// read — consignment status alone cannot separate a hub-routed parcel from
|
||||
// a freshly-collected hyperlocal one, since both can sit on Created.
|
||||
nextAction := constants.NextActionPickup
|
||||
var nextHub fiber.Map
|
||||
if b.Status == constants.BookingCancelled {
|
||||
nextAction = constants.NextActionNone
|
||||
} else if b.Consignmentid != nil {
|
||||
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
|
||||
nextAction = nextActionForConsignment(cn.Status)
|
||||
nextHub = nextHubForConsignment(&cn)
|
||||
}
|
||||
}
|
||||
|
||||
row := fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingreference": b.Bookingno,
|
||||
"status": b.Status,
|
||||
"stoptype": milerStopType(b.Status),
|
||||
"consignmentid": b.Consignmentid,
|
||||
"consignmentstatus": consignmentStatus,
|
||||
"pickupaddress": b.Pickupaddress,
|
||||
"pickuplatitude": b.Pickuplatitude,
|
||||
"pickuplongitude": b.Pickuplongitude,
|
||||
"deliveryaddress": b.Deliveryaddress,
|
||||
"deliverylatitude": b.Deliverylatitude,
|
||||
"deliverylongitude": b.Deliverylongitude,
|
||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"customerphone": customer.Phone,
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingreference": b.Bookingno,
|
||||
"status": b.Status,
|
||||
"stoptype": milerStopType(b.Status),
|
||||
"consignmentid": b.Consignmentid,
|
||||
"consignmentstatus": consignmentStatus,
|
||||
"next_action": nextAction,
|
||||
"next_hub": nextHub,
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickupaddress": sourceAddress,
|
||||
"pickuplatitude": b.Pickuplatitude,
|
||||
"pickuplongitude": b.Pickuplongitude,
|
||||
"deliveryaddress": b.Deliveryaddress,
|
||||
"deliverylatitude": b.Deliverylatitude,
|
||||
"deliverylongitude": b.Deliverylongitude,
|
||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"customerphone": customer.Phone,
|
||||
// Arrival fact: the rider app derives its "Arrived" rung from
|
||||
// pickup-scheduled + a non-null reachedat, so this survives an app
|
||||
// restart without a separate booking status. Null until the rider hits
|
||||
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
||||
// at that moment (null if the app sent none).
|
||||
"reachedat": b.Arrivedat,
|
||||
"arrivallatitude": b.Arrivallatitude,
|
||||
"arrivallongitude": b.Arrivallongitude,
|
||||
"parcels": b.Parcels,
|
||||
"serviceoptions": b.ServiceOptions,
|
||||
"codamount": codAmount,
|
||||
"paymentmode": paymentMode,
|
||||
"createdat": b.Createdat,
|
||||
"reachedat": b.Arrivedat,
|
||||
"arrivallatitude": b.Arrivallatitude,
|
||||
"arrivallongitude": b.Arrivallongitude,
|
||||
"parcels": b.Parcels,
|
||||
"serviceoptions": b.ServiceOptions,
|
||||
"codamount": codAmount,
|
||||
"paymentmode": paymentMode,
|
||||
"createdat": b.Createdat,
|
||||
// Route sequencing — 0/empty when the stop was never sequenced.
|
||||
// sequencedat is the authoritative-order signal: non-null means the
|
||||
// console/optimizer fixed this stop's position and the app must follow
|
||||
@@ -442,6 +476,12 @@ func MilerGetConsignment(c *fiber.Ctx) error {
|
||||
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
|
||||
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
|
||||
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
|
||||
// The same leg information the queue read and pickup-complete give, so a
|
||||
// single-consignment refresh is as authoritative as a full poll.
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"next_hub": nextHubForConsignment(consignment),
|
||||
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/legs"
|
||||
"doormile/internal/notify"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
@@ -957,10 +958,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error {
|
||||
// final-mile delivery. Pincodes shorter than 3 characters are treated as
|
||||
// unknown rather than matching, so bad data falls back to the safe hub route.
|
||||
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
||||
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
||||
return false
|
||||
}
|
||||
return pickupPincode[:3] == deliveryPincode[:3]
|
||||
return legs.SamePostalArea(pickupPincode, deliveryPincode)
|
||||
}
|
||||
|
||||
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
||||
@@ -969,7 +967,7 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
||||
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
|
||||
// coordinates but no delivery pincode, and must not be wrongly routed through a
|
||||
// hub. When both pincodes are present the prefix rule still wins.
|
||||
const maxHyperlocalKM = 30.0
|
||||
const maxHyperlocalKM = legs.MaxHyperlocalKM
|
||||
|
||||
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
|
||||
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
|
||||
@@ -977,18 +975,7 @@ const maxHyperlocalKM = 30.0
|
||||
// between the pickup and delivery coordinates when a pincode is missing — so a
|
||||
// same-area booking whose address carried no pincode isn't sent to a hub.
|
||||
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
||||
// Both pincodes present: the prefix rule decides definitively (a matching
|
||||
// pincode is hyperlocal, a differing one is genuinely inter-area — don't let
|
||||
// distance override that).
|
||||
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
|
||||
return isHyperlocal(pickupPincode, deliveryPincode)
|
||||
}
|
||||
// A pincode is missing: fall back to straight-line distance when we have both
|
||||
// coordinates.
|
||||
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
|
||||
return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM
|
||||
}
|
||||
return false
|
||||
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
|
||||
}
|
||||
|
||||
// collectedStateEnabled gates the two-step hyperlocal delivery flow
|
||||
@@ -1068,15 +1055,29 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
|
||||
trackingNo := generateTrackingNo()
|
||||
|
||||
// The base this parcel belongs to. Backend decides — the app is told where to
|
||||
// go and never picks a base itself. resolveHandoverHub prefers the base the
|
||||
// booking was routed to, then the collecting rider's own base (the previous
|
||||
// behaviour, and still the operational default), then the nearest active base
|
||||
// to the pickup point — that last step replaces a fallback that took whichever
|
||||
// hub row happened to come back first.
|
||||
handoverHub := resolveHandoverHub(&booking, profile.Hubid)
|
||||
var defaultHubID *int
|
||||
if profile.Hubid != nil {
|
||||
defaultHubID = profile.Hubid
|
||||
if handoverHub != nil {
|
||||
defaultHubID = &handoverHub.Hubid
|
||||
} else {
|
||||
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||
var hub models.Hub
|
||||
if tx.First(&hub).Error == nil {
|
||||
defaultHubID = &hub.Hubid
|
||||
}
|
||||
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||
}
|
||||
|
||||
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
|
||||
// collected, in the rider's hands, on its way to a base — and only reaches
|
||||
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
|
||||
// (default, and what the current app expects) it is marked Inwarded_at_Hub
|
||||
// here, which is not where the parcel physically is but is what the current
|
||||
// app and the console's inbound views read.
|
||||
consignmentStatus := constants.ConsignmentInwardedAtHub
|
||||
if hubHandoverEnabled() {
|
||||
consignmentStatus = constants.ConsignmentCreated
|
||||
}
|
||||
|
||||
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
|
||||
@@ -1088,7 +1089,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
// to Out_for_Delivery, which lets the console tell "collected" from "actively
|
||||
// delivering". With it OFF (default, and what the current app expects) it goes
|
||||
// straight to Out_for_Delivery exactly as before.
|
||||
consignmentStatus := constants.ConsignmentInwardedAtHub
|
||||
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
booking.Deliverylatitude, booking.Deliverylongitude) {
|
||||
@@ -1138,6 +1138,14 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
Currenthubid: defaultHubID,
|
||||
}
|
||||
|
||||
// Under the compatibility flow the parcel is treated as received at the base
|
||||
// the moment it is collected, so the received-at fact is stamped here too —
|
||||
// otherwise every parcel inwarded this way would have a null handover time
|
||||
// and base reconciliation would have nothing to compare against.
|
||||
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
|
||||
var payment models.BookingPayment
|
||||
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
||||
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
||||
@@ -1188,9 +1196,31 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
// they stay Picked_Up and out of the assignment pool until they finish. A
|
||||
// hub-routed parcel was dropped at the hub, so the rider frees up.
|
||||
postPickupAvailability := constants.MilerAvailable
|
||||
if consignmentStatus == constants.ConsignmentCollectedByMiler {
|
||||
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
|
||||
consignmentStatus == constants.ConsignmentCreated {
|
||||
// Created here means hub-routed and still in the rider's hands: they are
|
||||
// carrying it to a base, so they are not free yet.
|
||||
postPickupAvailability = constants.MilerPickedUp
|
||||
}
|
||||
|
||||
// A parcel that is already inwarded at the base ends this rider's leg, so the
|
||||
// assignment closes with it. Without this the assignment stayed open forever
|
||||
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
|
||||
// refuses while any assignment is still Assigned/Accepted. On the handover
|
||||
// flow the assignment stays open on purpose and closes at inward-at-hub.
|
||||
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
bookingID, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -1221,30 +1251,28 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
// next_action says what the rider does next; next_hub says where. An
|
||||
// inward_at_hub with no base named leaves a rider holding a parcel with
|
||||
// nowhere to take it, so the two travel together — and next_hub carries all
|
||||
// six fields, because the coordinates are the only thing that can drive
|
||||
// Navigate and the address and pincode are what the rider reads at the gate.
|
||||
//
|
||||
// consignment_id is always present: the delivery leg is keyed on it, and
|
||||
// without it the app cannot name the parcel it is about to act on.
|
||||
resp := fiber.Map{
|
||||
"tracking_no": trackingNo,
|
||||
"consignment_id": consignment.Consignmentid,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"status": consignment.Status,
|
||||
"booking_no": booking.Bookingno,
|
||||
"booking_status": booking.Status,
|
||||
"next_action": pickupNextAction(consignment.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// pickupNextAction tells the app what the rider does next after a pickup, so it
|
||||
// doesn't have to encode the hub-vs-hyperlocal branch itself:
|
||||
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
|
||||
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
|
||||
// - Inwarded_at_Hub → handed to the hub, done for this rider
|
||||
func pickupNextAction(consignmentStatus string) string {
|
||||
switch consignmentStatus {
|
||||
case constants.ConsignmentCollectedByMiler:
|
||||
return "start_delivery"
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return "deliver"
|
||||
default:
|
||||
return "handed_to_hub"
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
}
|
||||
if consignment.Status == constants.ConsignmentCreated ||
|
||||
consignment.Status == constants.ConsignmentInwardedAtHub {
|
||||
resp["next_hub"] = renderBase(handoverHub)
|
||||
}
|
||||
return utils.OK(c, resp)
|
||||
}
|
||||
|
||||
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
|
||||
|
||||
271
docs/DEV_ONBOARDING.md
Normal file
271
docs/DEV_ONBOARDING.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Doormile Backend — Developer Onboarding & Working Memory
|
||||
|
||||
Read this before touching the backend. It is the portable version of knowledge
|
||||
that otherwise lives only in one machine's Claude session memory. It covers two
|
||||
things a new dev (or a fresh Claude session on another machine) needs:
|
||||
|
||||
1. **How we use Claude on this project** — the project-memory file, the skills,
|
||||
the working conventions.
|
||||
2. **Operational knowledge that isn't in the code** — deploy topology, build
|
||||
gotchas, production landmines, and the incident history behind current
|
||||
design choices.
|
||||
|
||||
Related docs already in this repo:
|
||||
- [`CLAUDE.md`](../CLAUDE.md) — the full project memory (architecture, data
|
||||
model, route surface). Start there for *what the system is*.
|
||||
- [`docs/doormile-flow.md`](doormile-flow.md) — end-to-end booking/assignment flow.
|
||||
- [`docs/miler-app-api.md`](miler-app-api.md), [`docs/express-console-api.md`](express-console-api.md) — API contracts.
|
||||
- [`docs/logistics-base-handover.md`](logistics-base-handover.md) — the pickup-source
|
||||
and base-handover flow (requests 25–31), including the state transitions.
|
||||
- [`docs/jupiter2doormile.md`](jupiter2doormile.md) — the legacy→new migration map.
|
||||
- [`docs/test-booking-runbook.md`](test-booking-runbook.md) — how to run a test booking.
|
||||
- [`skills.md`](../skills.md) — note on the installed Claude skill pack.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — How Claude is used on this project
|
||||
|
||||
### 1.1 Project memory (`CLAUDE.md`)
|
||||
`CLAUDE.md` at the repo root is the single source of project context, loaded
|
||||
automatically into every Claude session in this repo. It is checked into git, so
|
||||
it travels to every machine and every dev. If you change how the system works,
|
||||
update `CLAUDE.md` in the same change — it is treated as authoritative.
|
||||
|
||||
Sections in `CLAUDE.md` are tagged **[verified this session]** (confirmed
|
||||
against source) vs **[carried forward]** (reported by a prior session, not
|
||||
re-verified). Respect the distinction — don't treat carried-forward claims as
|
||||
confirmed.
|
||||
|
||||
### 1.2 Session memory (machine-local — this is why this doc exists)
|
||||
Claude also keeps per-fact memory files under
|
||||
`~/.claude/projects/<project>/memory/`, indexed by `MEMORY.md`. These are
|
||||
**not in git** and **do not travel** to another machine or dev. They accumulate
|
||||
operational facts, incidents, and gotchas across sessions.
|
||||
|
||||
Part 2 below is a distillation of those files into a form the whole team can
|
||||
read. When a memory fact changes, update *both* the memory file (for Claude) and
|
||||
this doc (for humans).
|
||||
|
||||
### 1.3 Claude skills in use
|
||||
The repo has the `addyosmani/agent-skills` pack installed at `.agents/skills/`
|
||||
and symlinked into `.claude/skills/`. These are third-party, broad-trigger
|
||||
skills that run with full agent permissions. Roster (see `skills.md` for the
|
||||
full table):
|
||||
|
||||
`api-and-interface-design`, `browser-testing-with-devtools`,
|
||||
`ci-cd-and-automation`, `code-review-and-quality`, `code-simplification`,
|
||||
`context-engineering`, `debugging-and-error-recovery`,
|
||||
`deprecation-and-migration`, `documentation-and-adrs`,
|
||||
`doubt-driven-development`, `frontend-ui-engineering`,
|
||||
`git-workflow-and-versioning`, `idea-refine`, `incremental-implementation`,
|
||||
`interview-me`, `observability-and-instrumentation`,
|
||||
`performance-optimization`, `planning-and-task-breakdown`,
|
||||
`security-and-hardening`, `shipping-and-launch`, `source-driven-development`,
|
||||
`spec-driven-development`, `test-driven-development`, `using-agent-skills`.
|
||||
|
||||
**How we treat them:** available on request, *not* auto-adopted over the
|
||||
conventions in `CLAUDE.md` §7. The established conventions win: use the `utils`
|
||||
response helpers, the `constants` enums, reuse `AssignMilerToBooking` and
|
||||
`haversineKM`, prefer minimal-leverage fixes. Invoke a skill explicitly when its
|
||||
scope fits; don't let a broad trigger override house style.
|
||||
|
||||
### 1.4 Standing working preferences (from `CLAUDE.md`)
|
||||
- Direct, honest technical assessments. Don't declare victory early.
|
||||
- Production-grade from the start, minimal-effort highest-leverage fixes.
|
||||
- **Warn before any consequential server/schema change.**
|
||||
- Once a decision is made, proceed and report — but re-ask on money/data-correctness.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Operational knowledge (not derivable from the code)
|
||||
|
||||
### 2.1 Build & toolchain
|
||||
- Go **1.25** (`go.mod` says `go 1.25.0`). Module `doormile`.
|
||||
- On the primary dev Mac, Go is installed at `/Users/tenext/go` but **not on
|
||||
PATH**. Prefix: `export PATH="/Users/tenext/go/bin:$PATH"` before any `go`
|
||||
command, or a bare `go build` returns "command not found".
|
||||
- `go build ./...`, `go vet ./...`, `go test ./...` all pass repo-wide.
|
||||
- `scratch/*.go` are throwaway `func main()` scripts tagged `//go:build ignore`
|
||||
so the toolchain skips them. If a `main redeclared` error appears, it's a
|
||||
stripped build tag under `scratch/`, **not** a real app problem.
|
||||
- `go build .` emits a ~65MB `doormile` binary in the repo root; it's
|
||||
gitignored — delete it, don't commit it.
|
||||
- Tests exist only for pure logic (`isHyperlocal`, `calculateVolumetricWeight`,
|
||||
`ParsePage`). Everything else is verified by build+vet, review, or live test.
|
||||
|
||||
### 2.2 Deploy topology — how a change reaches production
|
||||
- Live backend runs on **k3s** (not plain docker), server `66.116.225.226` port
|
||||
**4422** (SSH, key-based auth from the primary Mac; `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`).
|
||||
- Workload: **statefulset `doormile`**, namespace `doormile`, **3 replicas**.
|
||||
Binary is `/app/server` in the pod; image
|
||||
`docker.io/doormile/doormile-backend:latest`, `imagePullPolicy: Always`.
|
||||
Pods report `APP_ENV=staging`.
|
||||
- **There is NO CI.** Nothing watches the repo. A `kubectl rollout restart`
|
||||
alone rebuilds nothing — it re-pulls the same image digest.
|
||||
- **A change reaches prod only by a manual build+push** (needs Docker daemon up
|
||||
+ Docker Hub creds):
|
||||
```
|
||||
docker build -t doormile/doormile-backend:latest .
|
||||
docker push doormile/doormile-backend:latest
|
||||
kubectl rollout restart statefulset/doormile -n doormile
|
||||
```
|
||||
- **The image builds from the working tree** (`ADD . /app/`). Uncommitted local
|
||||
edits and any throwaway file under `cmd/`/`scratch/` get baked in. **Always
|
||||
`git status` before building**, and **confirm code is committed *after* a
|
||||
deploy** — `git log origin/main` tells you nothing about what's running.
|
||||
- **Verify what's actually live** rather than assuming: `kubectl exec -n
|
||||
doormile doormile-0 -- ls -la /app` shows the binary's build date; or probe a
|
||||
response field only the new code emits. Group auth (`/admin/*`, `/hub/*`,
|
||||
`/miler/*`) returns 401 for unmatched routes, so a 404-vs-401 probe can't tell
|
||||
you if a route exists — use an authenticated request.
|
||||
|
||||
### 2.3 Env vars & the source-of-truth manifest
|
||||
- Env is **inline on the statefulset spec** (`.spec.template.spec.containers[0].env`),
|
||||
no ConfigMap/envFrom. Set a flag with
|
||||
`kubectl -n doormile set env statefulset/doormile KEY=value` (auto-rolls). A
|
||||
plain restart does **not** add a var that isn't already in the spec.
|
||||
- **Source-of-truth manifest**: `/opt/kubernetes/manifests/doormile/miletruth.yaml`
|
||||
(git repo at `/opt/kubernetes/.git`; second copy under `/root/kubernetes/...`).
|
||||
A `kubectl apply` of it **overwrites** live `set env` changes — so any live
|
||||
flag change must also be written into this manifest or the next deploy reverts
|
||||
it.
|
||||
- The manifest supplies DB/Redis/NATS passwords via `secretKeyRef`
|
||||
(`doormile-secrets`) while the live pods carry literals — verify that secret
|
||||
exists before relying on `kubectl apply`.
|
||||
- **Known live flag:** `MILER_COLLECTED_STATE_ENABLED=true` (hyperlocal two-step
|
||||
pickup). `TRUSTED_PROXIES` must be set (api.doormile.com sits behind a
|
||||
reverse proxy) or per-IP rate limits collapse all clients into one bucket.
|
||||
- **`MILER_HUB_HANDOVER_ENABLED` — default off, and must stay off** until a rider
|
||||
build that calls `POST /miler/consignments/:id/inward-at-hub` is live. On, a
|
||||
hub-routed parcel stops at `Created` until the rider records the handover; off,
|
||||
pickup-complete marks it `Inwarded_at_Hub` immediately, which is what the
|
||||
deployed app expects. Flipping it early strands every intercity parcel on
|
||||
`Created` with no button in the app to advance it and no row in any base's
|
||||
received list. See [`logistics-base-handover.md`](logistics-base-handover.md).
|
||||
|
||||
### 2.4 Timezone convention (subtle — read before touching any time field)
|
||||
The DB and backend time helpers run on **IST (Asia/Kolkata) wall-clock**.
|
||||
`dbLocation = Asia/Kolkata`; `DBNow()`/`DBToday()` (`utils/helper.go`) return
|
||||
IST wall-clock digits *tagged as UTC*. Consequence: clients (e.g. the miler app)
|
||||
that send a timestamp — such as `logdate` on `POST /miler/logs` — should send
|
||||
**IST wall-clock (phone local time in India), not UTC**. Sending UTC misaligns
|
||||
Redis zset scores and time-window queries by 5h30m.
|
||||
|
||||
### 2.5 Postgres CHECK constraints predate the codebase
|
||||
Status-column CHECK constraints are **not** created by GORM AutoMigrate — they
|
||||
predate this codebase. Adding a new status *constant* in Go is not enough; the
|
||||
DB rejects it with **SQLSTATE 23514**. Before adding any status enum value,
|
||||
widen the matching `*_status_check` constraint in `migrations/migrate.go`. This
|
||||
is also why the base-handover reconciliation path raises a `Lost` exception
|
||||
rather than a more precise `Handover_Not_Received` — the latter would need
|
||||
`consignmentexceptions` widened first.
|
||||
Constraints exist on: `consignments`, `pickupbookings`,
|
||||
`milerprofiles.availabilitystatus`, `bookingassignments`,
|
||||
`consignmentexceptions`, `tripsheets`. `consignmenthistory` has no status check.
|
||||
(This bit us live: `consignments_status_check` was missing `Collected_By_Miler`
|
||||
and `Cancelled`, 500-ing every hyperlocal pickup and admin cancel.)
|
||||
|
||||
### 2.6 Miler telemetry pipeline (HTTP → Redis; there is NO MQTT)
|
||||
MQTT is a jupiter concept; Doormile has none. Miler telemetry is 4 HTTP
|
||||
endpoints, identity always taken from `c.Locals("userid")` (never the body):
|
||||
- `PUT /miler/location` → Postgres (`MilerProfile` lat/lng/pincode) + Redis
|
||||
(`miler:gps:{userid}` 30min TTL, `milers:locations` GEO set feeding dispatch).
|
||||
- `POST /miler/logs` → **Redis-only** periodic telemetry point
|
||||
(`miler_periodic_log:{userid}:{ts}` + zsets, scored by timestamp).
|
||||
- `POST /miler/status` → Redis-only (`miler_status:{userid}`).
|
||||
- `POST /miler/consignments/logs` → Redis (list+zset) + Postgres
|
||||
`ConsignmentHistory`; takes an **array** (batch).
|
||||
|
||||
Device sensors (GPS/speed/heading/accuracy, battery/is_charging, connection)
|
||||
come from Flutter plugins regardless of transport — MQTT isn't needed to collect
|
||||
them. Console reads of the trail use **newest-first** fetch
|
||||
(`ZRevRangeByScore`), then reverse to chronological, so a `?limit=N` window
|
||||
keeps the *latest* fixes (fixed in `6e5da09` — previously `?limit=1` returned
|
||||
the day's first blank early-boot ping).
|
||||
|
||||
### 2.7 Route optimizer
|
||||
`routes.workolik.com` (env `ROUTE_OPTIMIZER_URL`) = the rider-bike FastAPI
|
||||
service (OR-Tools + Valhalla). Doormile endpoint
|
||||
`POST /api/v1/optimization/doormile/sequence`, contract matches
|
||||
`internal/routing/optimizer.go`. As of `f6d339a`,
|
||||
`routing.SequenceMilerStopsAsync` fires after every assignment path. A `step=0`
|
||||
in prod is never the service being down — it means the rider had <2 active
|
||||
stops, a stop had missing coords, or the assignment predated the wiring.
|
||||
|
||||
### 2.8 NATS ownership
|
||||
Doormile has its **own** NATS (`nats://66.116.226.161:4223`, user `doormile`),
|
||||
separate from jupiter's (`nats.workolik.com:4222`). Streams are declared by the
|
||||
Go app in `db/streams.go` (`EnsureStreams`, add-only — never deletes/drops).
|
||||
**Adding a `js.Publish` without adding its subject to `streamSubjects` silently
|
||||
drops the event.** Publishing is best-effort: `if db.Js != nil { ... }`,
|
||||
warn-log on failure, never fail the request. Do not run the old Python
|
||||
`setup_jetstream.py` scripts — they used to clobber the subject list (now
|
||||
neutered to read-only, but that change lives only on disk, not in git).
|
||||
|
||||
### 2.9 Config gotcha: milers need `configid = 1001`
|
||||
`LoginMiler`/`VerifyMilerPin` look up `WHERE contactno = ? AND configid = 1001`.
|
||||
`AppUser.configid` column-defaults to `1`, so any miler created without
|
||||
explicitly setting configid authenticates against nothing and returns a
|
||||
misleading `404 no miler account found` even though the row exists and is
|
||||
Active. `CreateMiler` now defaults it to 1001. **When a miler "doesn't exist"
|
||||
but the row is visibly there, check `configid` first.** Same trap applies to
|
||||
`AppCustomer`.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Access control status (know before adding client logins)
|
||||
|
||||
- **Admin/express console (`/admin/*`) has NO tenant scoping.** `LoginAdmin`
|
||||
hardcodes `tenantid = 0` in the JWT and no admin handler filters by tenant —
|
||||
every console login sees every tenant's data. **Do not create a client-facing
|
||||
`doormile_auth` login** until this is fixed (mirror the `HubStaffAccount.Tenantid
|
||||
*int` pattern: nil = Doormile staff/unrestricted, set = client/scoped).
|
||||
- **Hub console** is only partially scoped: `scopeBookingsToOwnTenant` is applied
|
||||
at ~3 of ~20 hub handlers that return booking/consignment data.
|
||||
- Recurring flaw class in this codebase: **trusting a client-supplied
|
||||
identifier** (body `userid`, path `:userid`, request `tenantid`). When
|
||||
reviewing any handler, confirm identity comes from the token and ownership is
|
||||
proven before read/write. (Several account-takeover/IDOR bugs of this shape
|
||||
were fixed 2026-08-05.)
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Deliberate decisions (do not re-raise unprompted)
|
||||
|
||||
These are conscious calls by the project owner, recorded so they aren't
|
||||
re-litigated:
|
||||
- **`.env` and the Firebase key are committed to git.** Flagged as critical,
|
||||
deliberately deferred. Config also hardcodes the same values as `getEnv`
|
||||
fallbacks. Don't re-raise unless the owner opens the topic. **Do not add new
|
||||
secret literals to any committed file.**
|
||||
- **`/crm/*` stays unauthenticated** — the field-sales Flutter app sends no
|
||||
token. Revisit only when that app can send a key.
|
||||
- **B2C `PickupBooking.Tenantid` stays nil** — whether to attribute
|
||||
direct-to-consumer traffic to a Doormile-ops tenant is a business decision.
|
||||
- **Delivery OTP stays off for DailyGrubs.**
|
||||
- Hyperlocal is decided per-booking with a 30km coord fallback (Option A); a
|
||||
tenant-level service-type flag (Option B) was deferred.
|
||||
- `PartnerInfo` vs `Tenant` naming, and `Customer` (legacy) vs `AppCustomer`
|
||||
(new B2C) duplication — flagged, not acted on.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Credentials & test accounts (where they live, not the values)
|
||||
|
||||
Secrets are **not** reproduced here (see Part 4). Pointers:
|
||||
- **App/DB/Redis/NATS secrets** — `.env` (committed) and the `doormile-secrets`
|
||||
k8s secret referenced by `miletruth.yaml`.
|
||||
- **Live production DB** — `logistics` on `31.97.228.132:5433`. Read-only
|
||||
inspection via a throwaway Go program using `.env` creds is the safe path.
|
||||
- **Live backend Redis** — the manifest and `.env` have historically drifted
|
||||
(stale `31.97.228.132:6379` vs live `66.116.226.255:6380`); confirm which the
|
||||
running pods actually use before trusting either.
|
||||
- **Test rosters** live in Claude session memory (machine-local): the Coimbatore
|
||||
miler roster (6 riders, PIN `1234`), 3 customer app test accounts, and the
|
||||
DailyGrubs onboarding (tenant 13, master admin `developer@doormile.com`). Ask
|
||||
the owner for current values rather than assuming — they rotate.
|
||||
|
||||
> **Note on production writes:** the auto-mode classifier blocks production
|
||||
> writes (kubectl set env, DB UPDATE/ALTER) inconsistently. Do **not** route
|
||||
> around a block — hand the exact command to the owner to run. Read-only
|
||||
> inspection is fine.
|
||||
360
docs/logistics-base-handover.md
Normal file
360
docs/logistics-base-handover.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Logistics pickup-source and base-handover flow
|
||||
|
||||
The backend contract for requests 25–31 on the Miler logistics line. Written as
|
||||
the answer to that register: what shipped, what the wire values are, and the
|
||||
state transitions for each of the three journeys.
|
||||
|
||||
**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`,
|
||||
`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*.
|
||||
Nothing here changes a wire value to match the app's wording, and the app's
|
||||
wording never leaks back into this API. Console and backend keep saying hub.
|
||||
|
||||
---
|
||||
|
||||
## The flag
|
||||
|
||||
`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**).
|
||||
|
||||
| | off (today) | on |
|
||||
|---|---|---|
|
||||
| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands |
|
||||
| `next_action` returned | `handed_to_hub` | `inward_at_hub` |
|
||||
| Rider's assignment | closed at pickup-complete | closed at the handover |
|
||||
| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) |
|
||||
| Base sees it on `/hub/inbound/expected` | no — it is already received | yes |
|
||||
|
||||
Off is not a placeholder: it is what the currently deployed rider app expects. A
|
||||
build that cannot call the handover endpoint would, with the flag on, collect an
|
||||
intercity parcel and have no way to advance it — the parcel would sit on
|
||||
`Created` in the rider's queue and appear in no base's received list. Turn it on
|
||||
when a rider build that calls `inward-at-hub` is live:
|
||||
|
||||
```bash
|
||||
kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true
|
||||
```
|
||||
|
||||
Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same
|
||||
time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3).
|
||||
|
||||
**Everything else below is ungated** and live regardless of the flag: `next_hub`,
|
||||
the handover endpoint, `next_action`/`next_hub` on the queue read,
|
||||
`pickup_source_type`, base master data, inbound visibility, reconciliation and
|
||||
the routing block.
|
||||
|
||||
---
|
||||
|
||||
## State transitions — the three journeys
|
||||
|
||||
`consignmentstatus` is the consignment's own state; `booking.status` moves to
|
||||
`Converted_To_Consignment` at pickup-complete in all three and stops there.
|
||||
|
||||
### Base/Hub H1 → Customer
|
||||
|
||||
Pickup source is a base; the parcel then goes to a person. Routing is decided by
|
||||
pincode, exactly as for any other pickup — a base-origin booking delivering into
|
||||
the same postal area is hyperlocal.
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` |
|
||||
|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` |
|
||||
| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` |
|
||||
| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` |
|
||||
| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` |
|
||||
|
||||
The booking row carries `pickup_source_type: "hub"` and `sourceid` /
|
||||
`pickuplocationid` = the base id, so Home names the base as the pickup source
|
||||
rather than the rider's own office.
|
||||
|
||||
With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to
|
||||
`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is
|
||||
already `true` in production.
|
||||
|
||||
### Customer → Customer (hyperlocal)
|
||||
|
||||
Identical to the table above from pickup-complete onward; the only difference is
|
||||
`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name
|
||||
and address on the row.
|
||||
|
||||
### Customer → Base (intercity / interstate)
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` | `next_hub` |
|
||||
|---|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` | null |
|
||||
| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields |
|
||||
| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
|
||||
After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's —
|
||||
tripsheet, transit, and a final-mile rider at the other end.
|
||||
|
||||
**With the flag off**, the middle row does not exist: pickup-complete returns
|
||||
`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so
|
||||
the app can name the base. `inward-at-hub` called against such a parcel answers
|
||||
200 with the state that stands and `already_inwarded: true`, rather than failing.
|
||||
|
||||
---
|
||||
|
||||
## What changed, request by request
|
||||
|
||||
### 25 — `next_hub` on pickup-complete
|
||||
|
||||
`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever
|
||||
the parcel's next leg is a base, with all six fields:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tracking_no": "DM...",
|
||||
"consignment_id": 4821, // always present
|
||||
"consignmentstatus": "Created",
|
||||
"status": "Created", // alias, same value
|
||||
"booking_no": "BK...",
|
||||
"booking_status": "Converted_To_Consignment",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": {
|
||||
"id": 1,
|
||||
"name": "Coimbatore Hub",
|
||||
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pincode": "641004",
|
||||
"latitude": 11.0272,
|
||||
"longitude": 76.9905
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`next_hub` is absent for a hyperlocal parcel — there is no base leg.
|
||||
|
||||
**Which base.** `resolveHandoverHub`, in order: the base the booking was routed
|
||||
to (`nearesthubid`, nothing populates this column today — it is checked first so
|
||||
that it wins the moment something does), then the collecting rider's own base
|
||||
(the operational default), then the nearest **active** base to the pickup point,
|
||||
then any base at all. The app never chooses; it navigates to what it is given.
|
||||
|
||||
The nearest-active-base step replaced a fallback that took whichever hub row came
|
||||
back first from an unordered query.
|
||||
|
||||
### 26 — the handover mutation
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/inward-at-hub
|
||||
Idempotency-Key: <optional, same middleware as pickup-complete>
|
||||
|
||||
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
|
||||
```
|
||||
|
||||
`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for
|
||||
`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel
|
||||
is handed into the base it was already routed to.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"consignmentid": 4821,
|
||||
"trackingno": "DM...",
|
||||
"consignmentstatus": "Inwarded_at_Hub",
|
||||
"inwardedat": "2026-09-02T14:22:10Z",
|
||||
"hub": { "id": 1, "name": "...", "address": "...", "pincode": "...",
|
||||
"latitude": 11.0272, "longitude": 76.9905 },
|
||||
"next_action": "handed_to_hub",
|
||||
"already_inwarded": false
|
||||
}
|
||||
```
|
||||
|
||||
It names the resulting state, per the rule request 15 exists for. Idempotent
|
||||
twice over: the route carries the shared `Idempotency-Key` middleware, and a
|
||||
parcel already inwarded answers 200 with `already_inwarded: true` rather than a
|
||||
4xx — a retry after a dropped response confirms instead of erroring.
|
||||
|
||||
Side effects, all in one transaction: status and `currenthubid` set, `inwardedat`
|
||||
stamped, a `consignmenthistory` row written with the rider's coordinates, the
|
||||
`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and
|
||||
`ridercharges`, and the rider returned to `Available`. Before this, an intercity
|
||||
rider's every job reported zero distance and zero value on `/miler/earnings`.
|
||||
|
||||
Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403),
|
||||
`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel
|
||||
already out for delivery or past this leg.
|
||||
|
||||
### 27 — `next_action` and `next_hub` on the queue read
|
||||
|
||||
Every row of `GET /miler/bookings` now carries both, derived from server state on
|
||||
each read by the same helper pickup-complete uses — the pivot's answer and the
|
||||
poll's answer cannot drift.
|
||||
|
||||
| consignment state | `next_action` | `next_hub` |
|
||||
|---|---|---|
|
||||
| no consignment yet | `pickup` | null |
|
||||
| `Created` | `inward_at_hub` | the base |
|
||||
| `Collected_By_Miler` | `start_delivery` | null |
|
||||
| `Out_for_Delivery` | `deliver` | null |
|
||||
| `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
| anything terminal | `none` | null |
|
||||
|
||||
`GET /miler/consignments/:consignmentid` carries the same pair, plus
|
||||
`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as
|
||||
authoritative as a full poll.
|
||||
|
||||
### 28 — `pickup_source_type` on the booking row
|
||||
|
||||
On the row, never on a location master — a customer-door pickup has no location
|
||||
id at all, so a type held against locations could never classify one.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"bookingid": 4821,
|
||||
"pickup_source_type": "hub", // hub | customer | merchant | store
|
||||
"sourceid": 1, // null for a customer door
|
||||
"pickuplocationid": 1, // alias, same value
|
||||
"pickup_source_name": "Coimbatore Hub",
|
||||
"pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pickuppincode": "641004",
|
||||
"deliverypincode": "600001"
|
||||
}
|
||||
```
|
||||
|
||||
Sent on every booking, with `"customer"` as a value rather than an omission.
|
||||
`pickuplocationid` on this row is the source id — not the `pickuplocationid`
|
||||
column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a
|
||||
different concept. The booking row never carried either spelling before, so
|
||||
nothing is being redefined out from under a reader.
|
||||
|
||||
Storage is the new `pickupbookings.pickupsourcetype` column, written at creation.
|
||||
Rows created before it existed are classified on read: names a base → `hub`,
|
||||
names a client site → `merchant`, otherwise → `customer`. A stored value always
|
||||
wins. An unrecognised type is dropped at write rather than stored, so the column
|
||||
never holds a word the app has no meaning for.
|
||||
|
||||
### 29 — base master data
|
||||
|
||||
`Hub` already carried all six fields; what was missing was a route a rider token
|
||||
could read. `/admin/tenants/:id/locations` is a different dataset — a client's own
|
||||
sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so
|
||||
that 401 is by design, not an oversight.
|
||||
|
||||
```
|
||||
GET /miler/bases ?status=Active (default) &applocationid=
|
||||
```
|
||||
|
||||
Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
|
||||
`distance_km` and nearest-first ordering when the rider has reported a position.
|
||||
|
||||
`GET /admin/hubs` (console) already returns full hub rows and is unchanged.
|
||||
|
||||
### 30 — inbound visibility and receiving
|
||||
|
||||
```
|
||||
GET /hub/inbound/expected on the way in, not yet handed over
|
||||
POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." }
|
||||
```
|
||||
|
||||
`expected` lists consignments on `Created` whose current base is this one —
|
||||
rider, source and source type, customer, pickup and destination address,
|
||||
destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped:
|
||||
partner-tenant staff see only their own client's parcels
|
||||
(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing
|
||||
booking scoping).
|
||||
|
||||
`reconcile` is the receiving side. `received: true` inwards the parcel and is
|
||||
idempotent — staff working through a pile will hit rows twice. `received: false`
|
||||
is the dispute path: it does **not** quietly move the parcel backwards, it raises
|
||||
an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears
|
||||
was handed over and staff never saw becomes a tracked item rather than an
|
||||
argument nobody owns.
|
||||
|
||||
The exception type is `Lost` — the closest value the `consignmentexceptions`
|
||||
CHECK constraint already permits. A dedicated `Handover_Not_Received` type would
|
||||
need that constraint widened first (see `DEV_ONBOARDING.md` §2.5).
|
||||
|
||||
The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still
|
||||
works and now stamps `inwardedat` too.
|
||||
|
||||
### 31 — the routing decision on booking detail
|
||||
|
||||
`GET /admin/bookings/:id` keeps every field it returned and adds `routing`
|
||||
alongside them:
|
||||
|
||||
```jsonc
|
||||
"routing": {
|
||||
"pickup_source_type": "customer",
|
||||
"pickup_source_id": null,
|
||||
"pickup_source_name": "Anitha R",
|
||||
"from_address": "12 Race Course Road, Coimbatore",
|
||||
"from_pincode": "641018",
|
||||
"to_address": "44 Mount Road, Chennai",
|
||||
"destination_pincode": "600002",
|
||||
"is_hyperlocal": false,
|
||||
"consignment_state": "Created",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": { "id": 1, "name": "Coimbatore Hub", ... },
|
||||
"inwardedat": null,
|
||||
"decided": true
|
||||
}
|
||||
```
|
||||
|
||||
`decided` is false before pickup, when the routing result is a projection from
|
||||
the captured from/to rather than a decision that has been taken. `is_hyperlocal`
|
||||
is computed by the same helper pickup-complete uses, so the shown reason cannot
|
||||
disagree with the actual routing.
|
||||
|
||||
---
|
||||
|
||||
## Route sequencing knows about the base
|
||||
|
||||
`internal/routing` orders a rider's active stops via the Route Optimization API.
|
||||
It read `pickupbookings.deliverylatitude` for every assignment, with no idea
|
||||
whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the
|
||||
optimizer the rider was riding 430 km to the receiver, when the real next stop is
|
||||
a base a few kilometres away. One such destination in a rider's set also drags
|
||||
the ordering of every genuine local stop beside it, because the solver is
|
||||
optimising a journey nobody is going to make.
|
||||
|
||||
`dropForLeg` now decides where THIS rider's leg ends: the receiver for a
|
||||
hyperlocal parcel, the base for a hub-routed one. The base comes from the same
|
||||
order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if
|
||||
anything set it, otherwise the rider's own base — joined in by the stop query. A
|
||||
hub-routed stop with no usable base coordinates is left unsequenced rather than
|
||||
pointed at the receiver: one missing stop is better than a skewed route.
|
||||
|
||||
The final destination is not lost. It is simply not this leg — it belongs to
|
||||
whoever carries the parcel out of the base.
|
||||
|
||||
**`internal/legs`** exists for this. The hyperlocal rule is needed by
|
||||
`controllers` (which state a consignment lands in) and by `internal/routing`
|
||||
(where the leg ends), and `controllers` already imports `internal/routing`, so
|
||||
routing cannot import back. Rather than keep a second copy of the rule — the
|
||||
shape that has bitten this codebase before — it lives in a package both import.
|
||||
`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin
|
||||
delegates, so their existing call sites and tests are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No
|
||||
CHECK constraint needed widening — `Created` was already permitted on
|
||||
`consignments`.
|
||||
|
||||
| Table | Column | Why |
|
||||
|---|---|---|
|
||||
| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 |
|
||||
| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** |
|
||||
| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write |
|
||||
|
||||
---
|
||||
|
||||
## Still open on this line
|
||||
|
||||
- **15** — `reached` persists the arrival fact (`arrivedat`, returned as
|
||||
`reachedat` on the booking row) but the booking status does not move to
|
||||
`Arrived_At_Pickup`. Half done; not touched by this work.
|
||||
- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`.
|
||||
- **14** — a failed-delivery outcome; `skip` still leaves the consignment
|
||||
`Out_for_Delivery`.
|
||||
- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a
|
||||
`milerprofiles.availabilitystatus` value, not a booking or consignment state,
|
||||
so it says where the *rider* is rather than where the *parcel* is, and the only
|
||||
thing that writes it is `POST /miler/bookings/:bookingid/reached`
|
||||
(`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action.
|
||||
Nothing sets it on a delivery leg; a rider heading to a receiver goes
|
||||
`On_Delivery`. The name is misleading and predates the current lifecycle.
|
||||
- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either
|
||||
way**: it parses the JSON body first and falls back to `?reason=`, defaulting to
|
||||
"Rejected by rider" if neither is present. The doc/deployed disagreement was
|
||||
settled by accepting both, so the app can keep sending both.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Doormile Miler App — API reference
|
||||
|
||||
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
|
||||
The rider-app surface only (`/miler/*`). 45 routes: 3 auth + 42 authenticated.
|
||||
Base URL `https://api.doormile.com/api/v1`.
|
||||
|
||||
This supersedes "Miler App API Contract v1.0" where the two disagree — several
|
||||
@@ -126,6 +126,44 @@ prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
|
||||
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
|
||||
the **booking's** tenant, not the rider's.
|
||||
|
||||
It returns `next_action` and, when the next leg is a base, `next_hub` with all
|
||||
six fields (`id, name, address, pincode, latitude, longitude`) — the app never
|
||||
picks a base itself. `consignment_id` is always present.
|
||||
|
||||
```jsonc
|
||||
{ "consignment_id": 4821, "consignmentstatus": "Created",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": { "id": 1, "name": "Coimbatore Hub",
|
||||
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pincode": "641004", "latitude": 11.0272, "longitude": 76.9905 } }
|
||||
```
|
||||
|
||||
## The base handover
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/inward-at-hub Idempotency-Key supported
|
||||
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
|
||||
→ { consignmentstatus: "Inwarded_at_Hub", inwardedat, hub, next_action,
|
||||
already_inwarded }
|
||||
```
|
||||
|
||||
The authoritative record that a rider handed a parcel in at a base. The body is
|
||||
optional (it defaults to the base the parcel is routed to); `hubid`, `lat` and
|
||||
`lon` are accepted as aliases. Answers with the resulting state, never a bare
|
||||
200. A parcel already inwarded answers 200 with `already_inwarded: true`.
|
||||
|
||||
```
|
||||
GET /miler/bases ?status=Active &applocationid=
|
||||
```
|
||||
|
||||
Base master data on a rider token — the six fields per base, plus `distance_km`
|
||||
and nearest-first ordering once the rider has reported a position.
|
||||
`/admin/tenants/:id/locations` is a different dataset (a client's own sites) and
|
||||
is closed to role 5 by design.
|
||||
|
||||
**Wording:** the wire says hub, the rider app says Base. Full contract and state
|
||||
transitions in [`logistics-base-handover.md`](logistics-base-handover.md).
|
||||
|
||||
## Delivery
|
||||
|
||||
| Method | Path | Body |
|
||||
@@ -151,6 +189,20 @@ the **booking's** tenant, not the rider's.
|
||||
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
|
||||
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
|
||||
|
||||
Every `/miler/bookings` row carries the leg and the pickup source, rebuilt from
|
||||
server state on each read, so a poll or a cold restart needs no local cache:
|
||||
|
||||
| Field | Values |
|
||||
|---|---|
|
||||
| `next_action` | `pickup`, `inward_at_hub`, `start_delivery`, `deliver`, `handed_to_hub`, `none` |
|
||||
| `next_hub` | the six base fields, or null when the next leg isn't a base |
|
||||
| `pickup_source_type` | `hub`, `customer`, `merchant`, `store` — always sent, `customer` is a value not an omission |
|
||||
| `sourceid` / `pickuplocationid` | the base or client-site id; null for a customer door |
|
||||
| `pickup_source_name` | the base/site name, or the sender's name for a door pickup |
|
||||
|
||||
An unrecognised `pickup_source_type` should be treated as a generic pickup — new
|
||||
values may be added.
|
||||
|
||||
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
|
||||
|
||||
## Telemetry (Redis-backed, high frequency)
|
||||
@@ -235,4 +287,9 @@ build a UI that depends on it.
|
||||
decided.
|
||||
2. `bonuspoints` is never written.
|
||||
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
|
||||
real request against them.
|
||||
real request against them. (`reject` accepts its reason in the body *or* as
|
||||
`?reason=`, preferring the body — both spellings are honoured.)
|
||||
4. `At_Customer` on `milerprofiles.availabilitystatus` means **arrived at the
|
||||
pickup** — it is written only by `POST /miler/bookings/:bookingid/reached`.
|
||||
The name predates the current lifecycle; a rider heading to a receiver is
|
||||
`On_Delivery`.
|
||||
|
||||
8
go.mod
8
go.mod
@@ -3,13 +3,17 @@ module doormile
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.20.0
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/gofiber/websocket/v2 v2.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/nats-io/nats.go v1.31.0
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
google.golang.org/api v0.279.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -25,7 +29,6 @@ require (
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.1 // indirect
|
||||
firebase.google.com/go/v4 v4.20.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
@@ -41,7 +44,6 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gofiber/websocket/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
@@ -58,7 +60,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/nats-io/nats.go v1.31.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.5 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
@@ -86,7 +87,6 @@ require (
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.279.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||
|
||||
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) {
|
||||
|
||||
@@ -71,8 +71,14 @@ type Consignment struct {
|
||||
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
||||
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
||||
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
||||
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||
// Inwardedat is when the parcel was physically received at a base — written
|
||||
// by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by
|
||||
// the console inbound scan. Distinct from Updatedat, which moves on every
|
||||
// write: the handover time is a business fact staff reconcile against, so it
|
||||
// needs a column of its own. Null until the parcel is actually received.
|
||||
Inwardedat *time.Time `json:"inwardedat" gorm:"column:inwardedat"`
|
||||
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||
// Deliveryotp is issued when the consignment goes out for delivery and is
|
||||
// given to the receiver, not the rider — it is the only proof the parcel
|
||||
// reached the right person. Never serialised outward: returning it in an API
|
||||
|
||||
@@ -24,7 +24,20 @@ type PickupBooking struct {
|
||||
// parcel came out of. Separate column because pickuplocationid points at a
|
||||
// different table entirely; writing a tenantlocations id into it violates
|
||||
// that foreign key. This is what per-site reporting groups by.
|
||||
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||
// Pickupsourcetype says what KIND of place this booking is collected from —
|
||||
// one of constants.PickupSource*. It is stored on the booking row, not looked
|
||||
// up from a location master, because a customer-door pickup has no location
|
||||
// id at all: only the row can tell "no location because it is a front door"
|
||||
// apart from "no location because nobody filled it in". Empty on rows written
|
||||
// before this column existed; derivePickupSourceType classifies those from
|
||||
// what they do carry, so the API never returns a blank type.
|
||||
Pickupsourcetype string `json:"pickup_source_type" gorm:"column:pickupsourcetype;size:20"`
|
||||
// Pickuphubid is set only when Pickupsourcetype is "hub" — the base the
|
||||
// parcel is collected FROM (Base → Customer). It is a separate column from
|
||||
// Nearesthubid, which is the base a parcel is routed TO. Conflating them
|
||||
// would make a base-origin booking look like a base-destination one.
|
||||
Pickuphubid *int `json:"pickuphubid" gorm:"column:pickuphubid;index"`
|
||||
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
||||
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
||||
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
||||
|
||||
@@ -193,6 +193,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
|
||||
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
||||
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
||||
// Rider handover at a base. The authoritative record that a hub-routed parcel
|
||||
// physically changed hands; answers with the resulting state rather than a
|
||||
// bare 200, and carries the shared idempotency middleware because riders retry
|
||||
// on bad signal at a loading bay.
|
||||
milerAuth.Post("/consignments/:id/inward-at-hub", middlewares.Idempotency(), controllers.MilerInwardConsignmentAtHub)
|
||||
|
||||
// Base master data on a rider token — id, name, address, pincode and
|
||||
// coordinates for every active base. /admin/tenants/:id/locations is a
|
||||
// different dataset (a client's own sites) and is closed to role 5 by design.
|
||||
milerAuth.Get("/bases", controllers.MilerGetBases)
|
||||
|
||||
// Earnings
|
||||
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
|
||||
@@ -366,6 +376,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
||||
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
|
||||
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
||||
// What riders are carrying towards this base but have not handed over yet,
|
||||
// and the matching receive/dispute action for when they arrive.
|
||||
hubAuth.Get("/inbound/expected", controllers.GetHubInboundExpected)
|
||||
hubAuth.Post("/inbound/:id/reconcile", controllers.ReconcileHubInbound)
|
||||
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
||||
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
||||
|
||||
257
routes/routes_logistics_test.go
Normal file
257
routes/routes_logistics_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/routes"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
// Real HTTP requests against the real router.
|
||||
//
|
||||
// WHAT THIS DOES AND DOES NOT PROVE.
|
||||
//
|
||||
// Every route in this file is behind AuthMiddleware + RoleCheckMiddleware, and
|
||||
// both reject before the handler runs — so a request with a wrong-role or absent
|
||||
// token never reaches a line that touches Postgres. That makes it possible to
|
||||
// exercise the entire HTTP surface with no database, and it is worth doing:
|
||||
// it catches a mistyped path, a route registered under the wrong group, a
|
||||
// missing middleware, and a param route shadowing a static one. Those are real
|
||||
// bugs that compile perfectly and that unit tests over pure functions cannot see.
|
||||
//
|
||||
// It does NOT prove a handler works. Nothing here reaches a query, a
|
||||
// transaction, or a response body built from real rows. A 200 from these
|
||||
// endpoints has never been observed and this file does not claim one.
|
||||
//
|
||||
// The line is drawn deliberately: everything up to the handler is tested here;
|
||||
// everything from the handler inwards needs a database and is still unverified.
|
||||
|
||||
const jwtSecret = "test-secret-for-routing-only"
|
||||
|
||||
func newApp() *fiber.App {
|
||||
// Recover is what main.go installs too. Here it also acts as a guardrail:
|
||||
// with no database configured, any request that DID reach a handler would
|
||||
// nil-panic and take the whole test binary down. Nothing in this file is
|
||||
// supposed to get that far — recover turns a mistake into a failed test
|
||||
// rather than a crashed run.
|
||||
app := fiber.New()
|
||||
app.Use(recover.New())
|
||||
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret})
|
||||
return app
|
||||
}
|
||||
|
||||
// token mints a valid JWT for a role, so the role gate can be exercised
|
||||
// independently of whether a token parses at all.
|
||||
func token(t *testing.T, userID, roleID int) string {
|
||||
t.Helper()
|
||||
tok, err := utils.GenerateToken(userID, "test@doormile.com", roleID, 0, 1001, jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("could not mint a %d-role token: %v", roleID, err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *fiber.App, method, path, bearer, body string) (int, string) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(out)
|
||||
}
|
||||
|
||||
// The routes added for the base-handover flow, with the role each one requires.
|
||||
var newRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/consignments/42/inward-at-hub", 5, `{"hub_id":7}`},
|
||||
{http.MethodGet, "/api/v1/miler/bases", 5, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/expected", 6, ""},
|
||||
{http.MethodPost, "/api/v1/hub/inbound/42/reconcile", 6, `{"received":true}`},
|
||||
}
|
||||
|
||||
// Routes that already existed and whose responses this work changed.
|
||||
var changedRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/bookings/42/pickup-complete", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/bookings", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/consignments/42", 5, ""},
|
||||
{http.MethodGet, "/api/v1/admin/bookings/42", 1, ""},
|
||||
{http.MethodPost, "/api/v1/admin/expressbooking", 1, `{"tenantid":1,"pickuppincode":"641004","parcels":[{"weight":1}]}`},
|
||||
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/today", 6, ""},
|
||||
{http.MethodPost, "/api/v1/customer/bookings", 9, `{"pickuppincode":"641004"}`},
|
||||
}
|
||||
|
||||
func allRoutes() []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
} {
|
||||
return append(append([]struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{}, newRoutes...), changedRoutes...)
|
||||
}
|
||||
|
||||
// A route that is registered rejects an anonymous request with 401. One that is
|
||||
// NOT registered falls through to Fiber's own 404 — which is exactly how a
|
||||
// mistyped path hides, since both "fail".
|
||||
func TestEveryRouteIsRegistered(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, r := range allRoutes() {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, "", r.body)
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("route is NOT registered — got 404: %s", body)
|
||||
}
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous request should be 401, got %d: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The gate that produced the "insufficient permissions" report: a valid token of
|
||||
// the wrong role must be refused, and refused BEFORE the handler runs — with no
|
||||
// database configured, a handler that executed would panic on a nil db.DB, so a
|
||||
// clean 403 is itself the proof that nothing downstream ran.
|
||||
func TestWrongRoleIsRefusedBeforeTheHandlerRuns(t *testing.T) {
|
||||
app := newApp()
|
||||
// One role from each group, so every case is covered by some wrong role.
|
||||
roles := map[int]string{1: "admin", 5: "miler", 6: "hub staff", 9: "customer"}
|
||||
|
||||
for _, r := range allRoutes() {
|
||||
for role, name := range roles {
|
||||
if role == r.wantRole {
|
||||
continue
|
||||
}
|
||||
// Admin roles 1/3/4 are interchangeable; only test a genuinely wrong one.
|
||||
if r.wantRole == 1 && (role == 3 || role == 4) {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%s as %s", r.path, name), func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, token(t, 1, role), r.body)
|
||||
if status != http.StatusForbidden && status != http.StatusUnauthorized {
|
||||
t.Errorf("a %s token on a role-%d route returned %d, want 403/401: %s",
|
||||
name, r.wantRole, status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal has to be machine-readable, not just a status code — the console
|
||||
// and the rider app both branch on the body.
|
||||
func TestRefusalBodyIsWellFormed(t *testing.T) {
|
||||
app := newApp()
|
||||
status, body := do(t, app, http.MethodGet, "/api/v1/miler/bases", token(t, 1, 1), "")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("admin token on a miler route: got %d, want 403", status)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("refusal body is not JSON: %q", body)
|
||||
}
|
||||
if parsed["success"] != false {
|
||||
t.Errorf(`refusal should carry "success": false, got %v`, parsed["success"])
|
||||
}
|
||||
if parsed["message"] != "insufficient permissions for this resource" {
|
||||
t.Errorf("unexpected refusal message: %v", parsed["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed or unsigned token must never be accepted as a valid session.
|
||||
func TestGarbageTokensAreRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, tok := range []string{
|
||||
"not-a-jwt",
|
||||
"eyJhbGciOiJub25lIn0.eyJyb2xlaWQiOjV9.", // alg:none, roleid 5
|
||||
"",
|
||||
} {
|
||||
status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", tok, "")
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("token %q returned %d, want 401", tok, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token signed with the wrong secret must not open a session — the check that
|
||||
// stops a token minted elsewhere from being trusted here.
|
||||
func TestTokenSignedWithAnotherSecretIsRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
foreign, err := utils.GenerateToken(1, "x@y.z", 5, 0, 1001, "a-different-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", foreign, ""); status != http.StatusUnauthorized {
|
||||
t.Errorf("foreign-signed token returned %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// `/miler/bases` is static and `/miler/consignments/:consignmentid` is dynamic.
|
||||
// Fiber matches in registration order, so a param route registered first would
|
||||
// swallow a static sibling — the bug the Orders route table has a comment about.
|
||||
func TestStaticRoutesAreNotShadowedByParamRoutes(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
// Probed with a token of the WRONG role on purpose. A 403 proves the request
|
||||
// matched this route and reached its role gate; a 404 would mean it matched
|
||||
// nothing, which is how a param route swallowing a static sibling shows up.
|
||||
// Using the right role instead would enter the handler and hit the database,
|
||||
// which is not what this file tests.
|
||||
cases := []struct {
|
||||
path string
|
||||
wrongRole int
|
||||
}{
|
||||
{"/api/v1/miler/bases", 1}, // static, sits beside /consignments/:id
|
||||
{"/api/v1/hub/inbound/expected", 5}, // static, sits beside /inbound/:id/reconcile
|
||||
{"/api/v1/miler/consignments/logs", 1}, // static, sits beside /consignments/:id
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
status, body := do(t, app, http.MethodGet, c.path, token(t, 1, c.wrongRole), "")
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("404 — the route is shadowed or unregistered: %s", body)
|
||||
}
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("got %d, want 403 (matched the route, refused the role): %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
scratch/check_booking_paging.go
Normal file
104
scratch/check_booking_paging.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only: does the console's page-by-page drain actually see every booking?
|
||||
//
|
||||
// GetAdminBookings runs Offset/Limit with NO ORDER BY. In Postgres that makes
|
||||
// the row order across pages unspecified, so a paged drain can legally return
|
||||
// the same row twice and never return another. This replays the exact 6 pages
|
||||
// the console fetches and compares the union against the table.
|
||||
//
|
||||
// go run scratch/check_booking_paging.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const pageSize = 100 // what the server actually returns: min(100, requested)
|
||||
|
||||
type row struct{ Bookingid int }
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings`).Scan(&total)
|
||||
pages := int((total + pageSize - 1) / pageSize)
|
||||
fmt.Printf("bookings: %d, pages of %d: %d\n\n", total, pageSize, pages)
|
||||
|
||||
// The truth: every id that exists.
|
||||
var all []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings`).Scan(&all)
|
||||
truth := map[int]bool{}
|
||||
for _, r := range all {
|
||||
truth[r.Bookingid] = true
|
||||
}
|
||||
|
||||
// The drain, exactly as the console runs it — unordered Offset/Limit.
|
||||
seen := map[int]int{}
|
||||
for p := 0; p < pages; p++ {
|
||||
var page []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT ? OFFSET ?`, pageSize, p*pageSize).Scan(&page)
|
||||
for _, r := range page {
|
||||
seen[r.Bookingid]++
|
||||
}
|
||||
fmt.Printf(" page %d: %d rows\n", p+1, len(page))
|
||||
}
|
||||
|
||||
missed := []int{}
|
||||
for id := range truth {
|
||||
if seen[id] == 0 {
|
||||
missed = append(missed, id)
|
||||
}
|
||||
}
|
||||
dupes := []int{}
|
||||
for id, n := range seen {
|
||||
if n > 1 {
|
||||
dupes = append(dupes, id)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(missed)))
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(dupes)))
|
||||
|
||||
fmt.Printf("\nunordered drain saw %d distinct of %d\n", len(seen), len(truth))
|
||||
fmt.Printf(" MISSED %d: %v\n", len(missed), head(missed, 20))
|
||||
fmt.Printf(" DUPLICATED %d: %v\n", len(dupes), head(dupes, 20))
|
||||
|
||||
// The same drain with a deterministic order — the proposed fix.
|
||||
seenOrdered := map[int]int{}
|
||||
for p := 0; p < pages; p++ {
|
||||
var page []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings ORDER BY bookingid DESC LIMIT ? OFFSET ?`,
|
||||
pageSize, p*pageSize).Scan(&page)
|
||||
for _, r := range page {
|
||||
seenOrdered[r.Bookingid]++
|
||||
}
|
||||
}
|
||||
missedOrdered := 0
|
||||
for id := range truth {
|
||||
if seenOrdered[id] == 0 {
|
||||
missedOrdered++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nwith ORDER BY bookingid DESC: saw %d distinct, missed %d\n",
|
||||
len(seenOrdered), missedOrdered)
|
||||
}
|
||||
|
||||
func head(xs []int, n int) []int {
|
||||
if len(xs) > n {
|
||||
return xs[:n]
|
||||
}
|
||||
return xs
|
||||
}
|
||||
71
scratch/check_bookingno.go
Normal file
71
scratch/check_bookingno.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
|
||||
fmt.Println("=== unique constraints on bookingno / trackingno ===")
|
||||
type c struct{ Conname, Def string }
|
||||
var cs []c
|
||||
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid
|
||||
WHERE t.relname IN ('pickupbookings','consignments') AND c.contype='u'`).Scan(&cs)
|
||||
if len(cs) == 0 {
|
||||
fmt.Println(" NONE — nothing stops a duplicate at the database level")
|
||||
}
|
||||
for _, x := range cs {
|
||||
fmt.Printf(" %s: %s\n", x.Conname, x.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== duplicate booking numbers ===")
|
||||
type d struct {
|
||||
Bookingno string
|
||||
N int64
|
||||
}
|
||||
var ds []d
|
||||
db.DB.Raw(`SELECT bookingno, count(*) n FROM pickupbookings
|
||||
GROUP BY 1 HAVING count(*)>1 ORDER BY n DESC LIMIT 10`).Scan(&ds)
|
||||
fmt.Printf(" %d duplicated\n", len(ds))
|
||||
for _, x := range ds {
|
||||
fmt.Printf(" %s x%d\n", x.Bookingno, x.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== all-zero random part (would mean rand.Read failed) ===")
|
||||
var zeros int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingno LIKE 'DM-BK-00000000-%'`).Scan(&zeros)
|
||||
fmt.Printf(" %d\n", zeros)
|
||||
|
||||
fmt.Println("\n=== how many bookings share a timestamp suffix ===")
|
||||
type s struct {
|
||||
Suffix string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT split_part(bookingno,'-',4) suffix, count(*) n
|
||||
FROM pickupbookings GROUP BY 1 ORDER BY n DESC LIMIT 5`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" suffix %-8s %d bookings\n", x.Suffix, x.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== duplicate tracking numbers ===")
|
||||
var td []d
|
||||
db.DB.Raw(`SELECT trackingno bookingno, count(*) n FROM consignments
|
||||
GROUP BY 1 HAVING count(*)>1 LIMIT 5`).Scan(&td)
|
||||
fmt.Printf(" %d duplicated\n", len(td))
|
||||
}
|
||||
115
scratch/check_customer_bookings.go
Normal file
115
scratch/check_customer_bookings.go
Normal file
@@ -0,0 +1,115 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only: why a customer-app booking may not reach the console.
|
||||
//
|
||||
// go run scratch/check_customer_bookings.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
fmt.Println("=== 1. How many bookings exist, by source ===")
|
||||
type srcRow struct {
|
||||
Bookingsource string
|
||||
N int64
|
||||
}
|
||||
var srcs []srcRow
|
||||
db.DB.Raw(`SELECT COALESCE(NULLIF(bookingsource,''),'(blank)') AS bookingsource, count(*) AS n
|
||||
FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&srcs)
|
||||
var total int64
|
||||
for _, s := range srcs {
|
||||
fmt.Printf(" %-16s %d\n", s.Bookingsource, s.N)
|
||||
total += s.N
|
||||
}
|
||||
fmt.Printf(" %-16s %d\n", "TOTAL", total)
|
||||
|
||||
fmt.Println("\n=== 2. The 10 newest bookings ===")
|
||||
type bRow struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Bookingsource string
|
||||
Status string
|
||||
Tenantid *int
|
||||
Pickuplatitude float64
|
||||
Pickuppincode string
|
||||
Createdat string
|
||||
}
|
||||
var newest []bRow
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
|
||||
pickuplatitude, pickuppincode, createdat::text
|
||||
FROM pickupbookings ORDER BY bookingid DESC LIMIT 10`).Scan(&newest)
|
||||
for _, b := range newest {
|
||||
tenant := "NULL"
|
||||
if b.Tenantid != nil {
|
||||
tenant = fmt.Sprintf("%d", *b.Tenantid)
|
||||
}
|
||||
fmt.Printf(" #%-6d %-14s src=%-14s status=%-24s tenant=%-5s lat=%-10.4f pin=%-7s %s\n",
|
||||
b.Bookingid, b.Bookingno, b.Bookingsource, b.Status, tenant,
|
||||
b.Pickuplatitude, b.Pickuppincode, b.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 3. Customer-app bookings specifically ===")
|
||||
var appBookings []bRow
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
|
||||
pickuplatitude, pickuppincode, createdat::text
|
||||
FROM pickupbookings WHERE bookingsource = 'Customer_App'
|
||||
ORDER BY bookingid DESC LIMIT 10`).Scan(&appBookings)
|
||||
if len(appBookings) == 0 {
|
||||
fmt.Println(" none at all")
|
||||
}
|
||||
for _, b := range appBookings {
|
||||
fmt.Printf(" #%-6d %-14s status=%-24s lat=%-10.4f pin=%-7s %s\n",
|
||||
b.Bookingid, b.Bookingno, b.Status, b.Pickuplatitude, b.Pickuppincode, b.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 4. Customer-app bookings with no pickup coordinates ===")
|
||||
var noCoords int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings
|
||||
WHERE bookingsource = 'Customer_App'
|
||||
AND (pickuplatitude = 0 OR pickuplongitude = 0
|
||||
OR pickuplatitude IS NULL OR pickuplongitude IS NULL)`).Scan(&noCoords)
|
||||
fmt.Printf(" %d (these cannot be assigned a rider and fail the console's zone filter)\n", noCoords)
|
||||
|
||||
fmt.Println("\n=== 5. What one page of the console's own query returns ===")
|
||||
// The console drains GET /admin/bookings, which runs no ORDER BY. This is
|
||||
// the same shape: LIMIT/OFFSET with no ordering.
|
||||
var page1, page1again []idRow
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1)
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1again)
|
||||
fmt.Printf(" unordered page 1, call A: %v\n", ids(page1))
|
||||
fmt.Printf(" unordered page 1, call B: %v\n", ids(page1again))
|
||||
|
||||
var lastPage []idRow
|
||||
offset := total - 5
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
db.DB.Raw(fmt.Sprintf(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET %d`, offset)).Scan(&lastPage)
|
||||
fmt.Printf(" unordered LAST page: %v\n", ids(lastPage))
|
||||
fmt.Println(" (if the newest ids appear only on the last page, a truncated drain never sees them)")
|
||||
}
|
||||
|
||||
type idRow struct{ Bookingid int }
|
||||
|
||||
func ids(rows []idRow) []int {
|
||||
out := make([]int, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.Bookingid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
136
scratch/check_handover_schema.go
Normal file
136
scratch/check_handover_schema.go
Normal file
@@ -0,0 +1,136 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only verification for the base-handover work. Reads information_schema
|
||||
// and pg_constraint only — no writes, no DDL, no migrations.
|
||||
//
|
||||
// go run scratch/check_handover_schema.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
fmt.Println("=== 1. New columns (expected ABSENT until AutoMigrate runs) ===")
|
||||
type col struct {
|
||||
Table string
|
||||
Name string
|
||||
}
|
||||
for _, want := range []col{
|
||||
{"pickupbookings", "pickupsourcetype"},
|
||||
{"pickupbookings", "pickuphubid"},
|
||||
{"consignments", "inwardedat"},
|
||||
} {
|
||||
var n int64
|
||||
db.DB.Raw(`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name = ? AND column_name = ?`, want.Table, want.Name).Scan(&n)
|
||||
fmt.Printf(" %-16s %-18s present=%v\n", want.Table, want.Name, n > 0)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 2. CHECK constraints on the tables we write ===")
|
||||
type chk struct {
|
||||
Conname string
|
||||
Def string
|
||||
}
|
||||
var checks []chk
|
||||
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) AS def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE c.contype = 'c'
|
||||
AND t.relname IN ('consignments','consignmentexceptions','consignmenthistory',
|
||||
'pickupbookings','bookingassignments','milerprofiles')
|
||||
ORDER BY t.relname, c.conname`).Scan(&checks)
|
||||
for _, c := range checks {
|
||||
fmt.Printf(" %s\n %s\n", c.Conname, c.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 3. Foreign keys on the user/id columns we write ===")
|
||||
type fk struct {
|
||||
Table string
|
||||
Conname string
|
||||
Def string
|
||||
}
|
||||
var fks []fk
|
||||
db.DB.Raw(`SELECT t.relname AS table, c.conname, pg_get_constraintdef(c.oid) AS def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE c.contype = 'f'
|
||||
AND t.relname IN ('consignmentexceptions','consignmenthistory','consignments',
|
||||
'tripsheets','bookingassignments','pickupbookings')
|
||||
ORDER BY t.relname, c.conname`).Scan(&fks)
|
||||
if len(fks) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
for _, f := range fks {
|
||||
fmt.Printf(" %-24s %s\n", f.Table, f.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 4. NOT NULL columns on the tables we insert into ===")
|
||||
type nn struct {
|
||||
Table string
|
||||
Column string
|
||||
Def *string
|
||||
}
|
||||
var nns []nn
|
||||
db.DB.Raw(`SELECT table_name AS table, column_name AS column, column_default AS def
|
||||
FROM information_schema.columns
|
||||
WHERE table_name IN ('consignmentexceptions','consignmenthistory')
|
||||
AND is_nullable = 'NO'
|
||||
ORDER BY table_name, ordinal_position`).Scan(&nns)
|
||||
for _, c := range nns {
|
||||
d := "(no default)"
|
||||
if c.Def != nil {
|
||||
d = *c.Def
|
||||
}
|
||||
fmt.Printf(" %-24s %-20s %s\n", c.Table, c.Column, d)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 5. Hub master data completeness (request 29) ===")
|
||||
type hubRow struct {
|
||||
Total int64
|
||||
NoAddress int64
|
||||
NoPincode int64
|
||||
NoCoords int64
|
||||
ActiveTotal int64
|
||||
}
|
||||
var h hubRow
|
||||
db.DB.Raw(`SELECT count(*) AS total,
|
||||
count(*) FILTER (WHERE address IS NULL OR address = '') AS no_address,
|
||||
count(*) FILTER (WHERE pincode IS NULL OR pincode = '') AS no_pincode,
|
||||
count(*) FILTER (WHERE latitude IS NULL OR latitude = 0 OR longitude IS NULL OR longitude = 0) AS no_coords,
|
||||
count(*) FILTER (WHERE status = 'Active') AS active_total
|
||||
FROM hubs WHERE deletedat IS NULL`).Scan(&h)
|
||||
fmt.Printf(" hubs=%d active=%d missing_address=%d missing_pincode=%d missing_coords=%d\n",
|
||||
h.Total, h.ActiveTotal, h.NoAddress, h.NoPincode, h.NoCoords)
|
||||
|
||||
fmt.Println("\n=== 6. Consignment status distribution (what is live now) ===")
|
||||
type sc struct {
|
||||
Status string
|
||||
N int64
|
||||
}
|
||||
var scs []sc
|
||||
db.DB.Raw(`SELECT status, count(*) AS n FROM consignments
|
||||
WHERE deletedat IS NULL GROUP BY status ORDER BY n DESC`).Scan(&scs)
|
||||
for _, s := range scs {
|
||||
fmt.Printf(" %-22s %d\n", s.Status, s.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 7. Open assignments on already-converted bookings (the off-duty bug) ===")
|
||||
var stuck int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments a
|
||||
JOIN pickupbookings b ON b.bookingid = a.bookingid
|
||||
WHERE a.assignmentstatus IN ('Assigned','Accepted')
|
||||
AND b.status = 'Converted_To_Consignment'`).Scan(&stuck)
|
||||
fmt.Printf(" assignments still open on a converted booking: %d\n", stuck)
|
||||
}
|
||||
62
scratch/check_latest.go
Normal file
62
scratch/check_latest.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
type b struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Bookingsource string
|
||||
Status string
|
||||
Tenantid *int
|
||||
Appcustomerid int
|
||||
Pickuppincode string
|
||||
Pickuplatitude float64
|
||||
Createdat string
|
||||
}
|
||||
var rows []b
|
||||
db.DB.Raw(`SELECT bookingid,bookingno,bookingsource,status,tenantid,appcustomerid,
|
||||
pickuppincode,pickuplatitude,createdat::text
|
||||
FROM pickupbookings ORDER BY bookingid DESC LIMIT 12`).Scan(&rows)
|
||||
fmt.Println("=== 12 newest bookings (any source) ===")
|
||||
for _, r := range rows {
|
||||
tn := "NULL"
|
||||
if r.Tenantid != nil {
|
||||
tn = fmt.Sprint(*r.Tenantid)
|
||||
}
|
||||
fmt.Printf(" #%-5d src=%-13q status=%-24s tenant=%-5s cust=%-4d pin=%-7s lat=%.4f %s\n",
|
||||
r.Bookingid, r.Bookingsource, r.Status, tn, r.Appcustomerid,
|
||||
r.Pickuppincode, r.Pickuplatitude, r.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== exact distinct bookingsource values (byte-for-byte) ===")
|
||||
type s struct {
|
||||
V string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT '['||bookingsource||']' v, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" %-20s %d\n", x.V, x.N)
|
||||
}
|
||||
|
||||
var appTotal int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingsource='Customer_App'`).Scan(&appTotal)
|
||||
fmt.Printf("\nCustomer_App bookings the console page should list: %d\n", appTotal)
|
||||
}
|
||||
37
scratch/check_miler23.go
Normal file
37
scratch/check_miler23.go
Normal file
@@ -0,0 +1,37 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
var avail string
|
||||
db.DB.Raw(`SELECT availabilitystatus FROM milerprofiles WHERE userid = 23`).Scan(&avail)
|
||||
fmt.Printf("miler 23 availability: %s\n", avail)
|
||||
|
||||
var open int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
|
||||
JOIN pickupbookings b ON b.bookingid = ba.bookingid
|
||||
WHERE ba.mileruserid = 23 AND ba.assignmentstatus IN ('Assigned','Accepted')`).Scan(&open)
|
||||
fmt.Printf("miler 23 open assignments: %d\n", open)
|
||||
|
||||
var onCancelled int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
|
||||
JOIN pickupbookings b ON b.bookingid = ba.bookingid
|
||||
WHERE ba.assignmentstatus IN ('Assigned','Accepted') AND b.status = 'Cancelled'`).Scan(&onCancelled)
|
||||
fmt.Printf("\nACROSS ALL RIDERS — open assignments on a CANCELLED booking: %d\n", onCancelled)
|
||||
}
|
||||
38
scratch/check_phone.go
Normal file
38
scratch/check_phone.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
type c struct {
|
||||
Appcustomerid int
|
||||
Firstname string
|
||||
Phone string
|
||||
Configid int
|
||||
Status string
|
||||
}
|
||||
var rows []c
|
||||
db.DB.Raw(`SELECT appcustomerid,firstname,phone,configid,status FROM appcustomers
|
||||
WHERE phone IN ('9876543210') OR appcustomerid = 5`).Scan(&rows)
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("no appcustomer with phone 9876543210, and no id 5")
|
||||
}
|
||||
for _, r := range rows {
|
||||
fmt.Printf(" id=%d name=%q phone=%s configid=%d status=%s\n",
|
||||
r.Appcustomerid, r.Firstname, r.Phone, r.Configid, r.Status)
|
||||
}
|
||||
}
|
||||
80
scratch/check_two_bookings.go
Normal file
80
scratch/check_two_bookings.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
|
||||
type b struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Status string
|
||||
Createdat string
|
||||
Updatedat string
|
||||
Assignedmileruserid *int
|
||||
Pickuplatitude float64
|
||||
Pickuplongitude float64
|
||||
Pickuppincode string
|
||||
Deliverypincode string
|
||||
Deliveryaddress string
|
||||
Appcustomerid int
|
||||
}
|
||||
var rows []b
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, status, createdat::text, updatedat::text,
|
||||
assignedmileruserid, pickuplatitude, pickuplongitude, pickuppincode,
|
||||
deliverypincode, deliveryaddress, appcustomerid
|
||||
FROM pickupbookings WHERE bookingid IN (546,547,179) ORDER BY bookingid DESC`).Scan(&rows)
|
||||
for _, r := range rows {
|
||||
miler := "none"
|
||||
if r.Assignedmileruserid != nil {
|
||||
miler = fmt.Sprintf("%d", *r.Assignedmileruserid)
|
||||
}
|
||||
fmt.Printf("#%d %s\n status=%s created=%s\n updated=%s miler=%s customer=%d\n pickup=(%.4f,%.4f) %s -> %s drop=%q\n\n",
|
||||
r.Bookingid, r.Bookingno, r.Status, r.Createdat, r.Updatedat, miler,
|
||||
r.Appcustomerid, r.Pickuplatitude, r.Pickuplongitude, r.Pickuppincode,
|
||||
r.Deliverypincode, r.Deliveryaddress)
|
||||
}
|
||||
|
||||
fmt.Println("=== assignments on those bookings ===")
|
||||
type a struct {
|
||||
Bookingid int
|
||||
Assignmentstatus string
|
||||
Assignedat string
|
||||
Remarks string
|
||||
}
|
||||
var as []a
|
||||
db.DB.Raw(`SELECT bookingid, assignmentstatus, assignedat::text, remarks
|
||||
FROM bookingassignments WHERE bookingid IN (546,547,179)`).Scan(&as)
|
||||
if len(as) == 0 {
|
||||
fmt.Println(" none — no rider was ever assigned")
|
||||
}
|
||||
for _, x := range as {
|
||||
fmt.Printf(" booking %d: %s at %s %q\n", x.Bookingid, x.Assignmentstatus, x.Assignedat, x.Remarks)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== how many bookings sit in each status ===")
|
||||
type s struct {
|
||||
Status string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT status, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" %-26s %d\n", x.Status, x.N)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user