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