Files
doormile_backend/docs/logistics-base-handover.md

15 KiB
Raw Permalink Blame History

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:

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:

{
  "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.

{
  "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.

{
  "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:

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