updates on the api endpoints on the customer page and more
This commit is contained in:
360
docs/logistics-base-handover.md
Normal file
360
docs/logistics-base-handover.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Logistics pickup-source and base-handover flow
|
||||
|
||||
The backend contract for requests 25–31 on the Miler logistics line. Written as
|
||||
the answer to that register: what shipped, what the wire values are, and the
|
||||
state transitions for each of the three journeys.
|
||||
|
||||
**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`,
|
||||
`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*.
|
||||
Nothing here changes a wire value to match the app's wording, and the app's
|
||||
wording never leaks back into this API. Console and backend keep saying hub.
|
||||
|
||||
---
|
||||
|
||||
## The flag
|
||||
|
||||
`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**).
|
||||
|
||||
| | off (today) | on |
|
||||
|---|---|---|
|
||||
| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands |
|
||||
| `next_action` returned | `handed_to_hub` | `inward_at_hub` |
|
||||
| Rider's assignment | closed at pickup-complete | closed at the handover |
|
||||
| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) |
|
||||
| Base sees it on `/hub/inbound/expected` | no — it is already received | yes |
|
||||
|
||||
Off is not a placeholder: it is what the currently deployed rider app expects. A
|
||||
build that cannot call the handover endpoint would, with the flag on, collect an
|
||||
intercity parcel and have no way to advance it — the parcel would sit on
|
||||
`Created` in the rider's queue and appear in no base's received list. Turn it on
|
||||
when a rider build that calls `inward-at-hub` is live:
|
||||
|
||||
```bash
|
||||
kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true
|
||||
```
|
||||
|
||||
Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same
|
||||
time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3).
|
||||
|
||||
**Everything else below is ungated** and live regardless of the flag: `next_hub`,
|
||||
the handover endpoint, `next_action`/`next_hub` on the queue read,
|
||||
`pickup_source_type`, base master data, inbound visibility, reconciliation and
|
||||
the routing block.
|
||||
|
||||
---
|
||||
|
||||
## State transitions — the three journeys
|
||||
|
||||
`consignmentstatus` is the consignment's own state; `booking.status` moves to
|
||||
`Converted_To_Consignment` at pickup-complete in all three and stops there.
|
||||
|
||||
### Base/Hub H1 → Customer
|
||||
|
||||
Pickup source is a base; the parcel then goes to a person. Routing is decided by
|
||||
pincode, exactly as for any other pickup — a base-origin booking delivering into
|
||||
the same postal area is hyperlocal.
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` |
|
||||
|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` |
|
||||
| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` |
|
||||
| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` |
|
||||
| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` |
|
||||
|
||||
The booking row carries `pickup_source_type: "hub"` and `sourceid` /
|
||||
`pickuplocationid` = the base id, so Home names the base as the pickup source
|
||||
rather than the rider's own office.
|
||||
|
||||
With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to
|
||||
`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is
|
||||
already `true` in production.
|
||||
|
||||
### Customer → Customer (hyperlocal)
|
||||
|
||||
Identical to the table above from pickup-complete onward; the only difference is
|
||||
`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name
|
||||
and address on the row.
|
||||
|
||||
### Customer → Base (intercity / interstate)
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` | `next_hub` |
|
||||
|---|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` | null |
|
||||
| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields |
|
||||
| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
|
||||
After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's —
|
||||
tripsheet, transit, and a final-mile rider at the other end.
|
||||
|
||||
**With the flag off**, the middle row does not exist: pickup-complete returns
|
||||
`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so
|
||||
the app can name the base. `inward-at-hub` called against such a parcel answers
|
||||
200 with the state that stands and `already_inwarded: true`, rather than failing.
|
||||
|
||||
---
|
||||
|
||||
## What changed, request by request
|
||||
|
||||
### 25 — `next_hub` on pickup-complete
|
||||
|
||||
`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever
|
||||
the parcel's next leg is a base, with all six fields:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tracking_no": "DM...",
|
||||
"consignment_id": 4821, // always present
|
||||
"consignmentstatus": "Created",
|
||||
"status": "Created", // alias, same value
|
||||
"booking_no": "BK...",
|
||||
"booking_status": "Converted_To_Consignment",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": {
|
||||
"id": 1,
|
||||
"name": "Coimbatore Hub",
|
||||
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pincode": "641004",
|
||||
"latitude": 11.0272,
|
||||
"longitude": 76.9905
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`next_hub` is absent for a hyperlocal parcel — there is no base leg.
|
||||
|
||||
**Which base.** `resolveHandoverHub`, in order: the base the booking was routed
|
||||
to (`nearesthubid`, nothing populates this column today — it is checked first so
|
||||
that it wins the moment something does), then the collecting rider's own base
|
||||
(the operational default), then the nearest **active** base to the pickup point,
|
||||
then any base at all. The app never chooses; it navigates to what it is given.
|
||||
|
||||
The nearest-active-base step replaced a fallback that took whichever hub row came
|
||||
back first from an unordered query.
|
||||
|
||||
### 26 — the handover mutation
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/inward-at-hub
|
||||
Idempotency-Key: <optional, same middleware as pickup-complete>
|
||||
|
||||
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
|
||||
```
|
||||
|
||||
`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for
|
||||
`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel
|
||||
is handed into the base it was already routed to.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"consignmentid": 4821,
|
||||
"trackingno": "DM...",
|
||||
"consignmentstatus": "Inwarded_at_Hub",
|
||||
"inwardedat": "2026-09-02T14:22:10Z",
|
||||
"hub": { "id": 1, "name": "...", "address": "...", "pincode": "...",
|
||||
"latitude": 11.0272, "longitude": 76.9905 },
|
||||
"next_action": "handed_to_hub",
|
||||
"already_inwarded": false
|
||||
}
|
||||
```
|
||||
|
||||
It names the resulting state, per the rule request 15 exists for. Idempotent
|
||||
twice over: the route carries the shared `Idempotency-Key` middleware, and a
|
||||
parcel already inwarded answers 200 with `already_inwarded: true` rather than a
|
||||
4xx — a retry after a dropped response confirms instead of erroring.
|
||||
|
||||
Side effects, all in one transaction: status and `currenthubid` set, `inwardedat`
|
||||
stamped, a `consignmenthistory` row written with the rider's coordinates, the
|
||||
`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and
|
||||
`ridercharges`, and the rider returned to `Available`. Before this, an intercity
|
||||
rider's every job reported zero distance and zero value on `/miler/earnings`.
|
||||
|
||||
Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403),
|
||||
`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel
|
||||
already out for delivery or past this leg.
|
||||
|
||||
### 27 — `next_action` and `next_hub` on the queue read
|
||||
|
||||
Every row of `GET /miler/bookings` now carries both, derived from server state on
|
||||
each read by the same helper pickup-complete uses — the pivot's answer and the
|
||||
poll's answer cannot drift.
|
||||
|
||||
| consignment state | `next_action` | `next_hub` |
|
||||
|---|---|---|
|
||||
| no consignment yet | `pickup` | null |
|
||||
| `Created` | `inward_at_hub` | the base |
|
||||
| `Collected_By_Miler` | `start_delivery` | null |
|
||||
| `Out_for_Delivery` | `deliver` | null |
|
||||
| `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
| anything terminal | `none` | null |
|
||||
|
||||
`GET /miler/consignments/:consignmentid` carries the same pair, plus
|
||||
`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as
|
||||
authoritative as a full poll.
|
||||
|
||||
### 28 — `pickup_source_type` on the booking row
|
||||
|
||||
On the row, never on a location master — a customer-door pickup has no location
|
||||
id at all, so a type held against locations could never classify one.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"bookingid": 4821,
|
||||
"pickup_source_type": "hub", // hub | customer | merchant | store
|
||||
"sourceid": 1, // null for a customer door
|
||||
"pickuplocationid": 1, // alias, same value
|
||||
"pickup_source_name": "Coimbatore Hub",
|
||||
"pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pickuppincode": "641004",
|
||||
"deliverypincode": "600001"
|
||||
}
|
||||
```
|
||||
|
||||
Sent on every booking, with `"customer"` as a value rather than an omission.
|
||||
`pickuplocationid` on this row is the source id — not the `pickuplocationid`
|
||||
column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a
|
||||
different concept. The booking row never carried either spelling before, so
|
||||
nothing is being redefined out from under a reader.
|
||||
|
||||
Storage is the new `pickupbookings.pickupsourcetype` column, written at creation.
|
||||
Rows created before it existed are classified on read: names a base → `hub`,
|
||||
names a client site → `merchant`, otherwise → `customer`. A stored value always
|
||||
wins. An unrecognised type is dropped at write rather than stored, so the column
|
||||
never holds a word the app has no meaning for.
|
||||
|
||||
### 29 — base master data
|
||||
|
||||
`Hub` already carried all six fields; what was missing was a route a rider token
|
||||
could read. `/admin/tenants/:id/locations` is a different dataset — a client's own
|
||||
sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so
|
||||
that 401 is by design, not an oversight.
|
||||
|
||||
```
|
||||
GET /miler/bases ?status=Active (default) &applocationid=
|
||||
```
|
||||
|
||||
Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
|
||||
`distance_km` and nearest-first ordering when the rider has reported a position.
|
||||
|
||||
`GET /admin/hubs` (console) already returns full hub rows and is unchanged.
|
||||
|
||||
### 30 — inbound visibility and receiving
|
||||
|
||||
```
|
||||
GET /hub/inbound/expected on the way in, not yet handed over
|
||||
POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." }
|
||||
```
|
||||
|
||||
`expected` lists consignments on `Created` whose current base is this one —
|
||||
rider, source and source type, customer, pickup and destination address,
|
||||
destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped:
|
||||
partner-tenant staff see only their own client's parcels
|
||||
(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing
|
||||
booking scoping).
|
||||
|
||||
`reconcile` is the receiving side. `received: true` inwards the parcel and is
|
||||
idempotent — staff working through a pile will hit rows twice. `received: false`
|
||||
is the dispute path: it does **not** quietly move the parcel backwards, it raises
|
||||
an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears
|
||||
was handed over and staff never saw becomes a tracked item rather than an
|
||||
argument nobody owns.
|
||||
|
||||
The exception type is `Lost` — the closest value the `consignmentexceptions`
|
||||
CHECK constraint already permits. A dedicated `Handover_Not_Received` type would
|
||||
need that constraint widened first (see `DEV_ONBOARDING.md` §2.5).
|
||||
|
||||
The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still
|
||||
works and now stamps `inwardedat` too.
|
||||
|
||||
### 31 — the routing decision on booking detail
|
||||
|
||||
`GET /admin/bookings/:id` keeps every field it returned and adds `routing`
|
||||
alongside them:
|
||||
|
||||
```jsonc
|
||||
"routing": {
|
||||
"pickup_source_type": "customer",
|
||||
"pickup_source_id": null,
|
||||
"pickup_source_name": "Anitha R",
|
||||
"from_address": "12 Race Course Road, Coimbatore",
|
||||
"from_pincode": "641018",
|
||||
"to_address": "44 Mount Road, Chennai",
|
||||
"destination_pincode": "600002",
|
||||
"is_hyperlocal": false,
|
||||
"consignment_state": "Created",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": { "id": 1, "name": "Coimbatore Hub", ... },
|
||||
"inwardedat": null,
|
||||
"decided": true
|
||||
}
|
||||
```
|
||||
|
||||
`decided` is false before pickup, when the routing result is a projection from
|
||||
the captured from/to rather than a decision that has been taken. `is_hyperlocal`
|
||||
is computed by the same helper pickup-complete uses, so the shown reason cannot
|
||||
disagree with the actual routing.
|
||||
|
||||
---
|
||||
|
||||
## Route sequencing knows about the base
|
||||
|
||||
`internal/routing` orders a rider's active stops via the Route Optimization API.
|
||||
It read `pickupbookings.deliverylatitude` for every assignment, with no idea
|
||||
whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the
|
||||
optimizer the rider was riding 430 km to the receiver, when the real next stop is
|
||||
a base a few kilometres away. One such destination in a rider's set also drags
|
||||
the ordering of every genuine local stop beside it, because the solver is
|
||||
optimising a journey nobody is going to make.
|
||||
|
||||
`dropForLeg` now decides where THIS rider's leg ends: the receiver for a
|
||||
hyperlocal parcel, the base for a hub-routed one. The base comes from the same
|
||||
order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if
|
||||
anything set it, otherwise the rider's own base — joined in by the stop query. A
|
||||
hub-routed stop with no usable base coordinates is left unsequenced rather than
|
||||
pointed at the receiver: one missing stop is better than a skewed route.
|
||||
|
||||
The final destination is not lost. It is simply not this leg — it belongs to
|
||||
whoever carries the parcel out of the base.
|
||||
|
||||
**`internal/legs`** exists for this. The hyperlocal rule is needed by
|
||||
`controllers` (which state a consignment lands in) and by `internal/routing`
|
||||
(where the leg ends), and `controllers` already imports `internal/routing`, so
|
||||
routing cannot import back. Rather than keep a second copy of the rule — the
|
||||
shape that has bitten this codebase before — it lives in a package both import.
|
||||
`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin
|
||||
delegates, so their existing call sites and tests are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No
|
||||
CHECK constraint needed widening — `Created` was already permitted on
|
||||
`consignments`.
|
||||
|
||||
| Table | Column | Why |
|
||||
|---|---|---|
|
||||
| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 |
|
||||
| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** |
|
||||
| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write |
|
||||
|
||||
---
|
||||
|
||||
## Still open on this line
|
||||
|
||||
- **15** — `reached` persists the arrival fact (`arrivedat`, returned as
|
||||
`reachedat` on the booking row) but the booking status does not move to
|
||||
`Arrived_At_Pickup`. Half done; not touched by this work.
|
||||
- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`.
|
||||
- **14** — a failed-delivery outcome; `skip` still leaves the consignment
|
||||
`Out_for_Delivery`.
|
||||
- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a
|
||||
`milerprofiles.availabilitystatus` value, not a booking or consignment state,
|
||||
so it says where the *rider* is rather than where the *parcel* is, and the only
|
||||
thing that writes it is `POST /miler/bookings/:bookingid/reached`
|
||||
(`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action.
|
||||
Nothing sets it on a delivery leg; a rider heading to a receiver goes
|
||||
`On_Delivery`. The name is misleading and predates the current lifecycle.
|
||||
- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either
|
||||
way**: it parses the JSON body first and falls back to `?reason=`, defaulting to
|
||||
"Rejected by rider" if neither is present. The doc/deployed disagreement was
|
||||
settled by accepting both, so the app can keep sending both.
|
||||
Reference in New Issue
Block a user