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

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