updates on the api endpoints on the customer page and more

This commit is contained in:
2026-09-02 16:32:53 +05:30
parent 8e2c484bcb
commit d12629a1e4
31 changed files with 3541 additions and 121 deletions

View File

@@ -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]**

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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")

View 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,
})
}

View 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,
})
}

View 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`)
}
}

View 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)
}

View File

@@ -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,
})
}

View File

@@ -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
View 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.

View 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.

View File

@@ -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
View File

@@ -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
View 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
View 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)
}
}

View 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)
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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"`

View File

@@ -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)

View 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)
}
})
}
}

View 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
}

View 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))
}

View 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
}

View 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
View 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
View 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
View 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)
}
}

View 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)
}
}