361 lines
15 KiB
Markdown
361 lines
15 KiB
Markdown
# 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.
|