Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
789
MILER_API_REQUIREMENTS.md
Normal file
789
MILER_API_REQUIREMENTS.md
Normal file
@@ -0,0 +1,789 @@
|
||||
# Miler App — API requirements for the backend team
|
||||
|
||||
**From:** Miler rider-app engineering
|
||||
**Date:** 21 Aug 2026
|
||||
**Against:** *Doormile Miler App — API reference* (38 `/miler/*` routes)
|
||||
**Base URL:** `https://api.doormile.com/api/v1`
|
||||
|
||||
Everything below was traced in the shipped app and, where it says *verified*,
|
||||
confirmed with live calls against production using rider **Rajan A**
|
||||
(`userid 38`, `tenantid 13`, configid 1001) on 20–21 Aug 2026.
|
||||
|
||||
Requests are ordered by **what is blocking riders today**. Each one states the
|
||||
symptom, the evidence, and the smallest change that fixes it.
|
||||
|
||||
---
|
||||
|
||||
## 0. Status — updated 21 Aug 2026
|
||||
|
||||
The backend team shipped requests **1, 2, 3, 5, 6** and cross-cutting items
|
||||
**10.1–10.4** the same day. The app has been updated against the new contract
|
||||
and the whole suite is green; each section below carries its own status line.
|
||||
|
||||
**What the app now does with it**
|
||||
|
||||
| Shipped | What the app changed |
|
||||
|---|---|
|
||||
| `Collected_By_Miler` | A real rung in `ConsignmentState`, excluded from the hub guard — a rider is never told the hub is holding a parcel that is in his own box. |
|
||||
| `POST /consignments/:id/start-delivery` | **Start round** is a server write again. The local released-set is written **only** for what the server released; a stop it refused keeps its PICKED word rather than showing a rung the hub does not agree with. |
|
||||
| `GET /consignments/:consignmentid` | The delivery gate reads state in one call instead of pulling and sorting the whole history. The logs walk stays as a fallback so a rider on this build meeting an older deployment is not stranded. |
|
||||
| `consignmentid` + `consignmentstatus` on lists | The 17/29 blocker is closed, and the row itself now says where the delivery half has got to — so a delivered stop drops off the Deliveries tab without a per-stop round trip. |
|
||||
| `Arrived_At_Pickup` | Parsed and mapped to the *arrived* rung; **I've arrived** now shows in the console. The undocumented `At_Customer` spelling still parses. |
|
||||
| Skip from `Collected_By_Miler` | A failed attempt is reportable the moment the parcel is collected, not only once the round has started. |
|
||||
| Error codes on 4xx | Every branch reads `ApiResult.code` — `INVALID_STATE`, `IDEMPOTENCY_IN_PROGRESS`. No delivery decision is made from message prose any more. |
|
||||
| `Idempotency-Key` | Sent on `pickup-complete`, `deliver`, `skip`, `payment` and `start-delivery` as a stable `verb:resource:day` key. `IDEMPOTENCY_IN_PROGRESS` is waited out and re-asked twice rather than surfaced — a rider is no longer told his delivery failed while it is in the act of succeeding. |
|
||||
|
||||
**The flag split — confirmed and handled.** `Collected_By_Miler` +
|
||||
`start-delivery` ship behind `MILER_COLLECTED_STATE_ENABLED`, **default off**,
|
||||
so hyperlocal pickup still goes straight to `Out_for_Delivery` today. This
|
||||
build is correct in both worlds and does not need the flag flipped:
|
||||
|
||||
- **Flag off** — the row's `consignmentstatus` already reads `Out_for_Delivery`
|
||||
when the load reaches the Deliveries tab, so **Start round** spends no
|
||||
request at all and behaves exactly as it does now. The app never fires a
|
||||
`start-delivery` that can only be refused.
|
||||
- **Flag on** — the row reads `Collected_By_Miler`, the press calls
|
||||
`start-delivery`, and the local released-set is written only for what the
|
||||
server released.
|
||||
|
||||
Nothing here needs coordinating: flip the flag whenever this build is out.
|
||||
|
||||
**Also shipped, unannounced and now consumed:** `step`, `stoptype`,
|
||||
`etaminutes`, `cumulativekms` and `cumulativeeta` on the booking row. The
|
||||
adapter builds a fixed map, so it had been dropping all five; the app now reads
|
||||
them. `step` in particular is what lets the delivery leg follow the hub's route
|
||||
instead of re-sorting nearest-first — **but see request 13: it is `0` on every
|
||||
row.**
|
||||
|
||||
**Still open:** request 4 (upload route for proof of delivery — the photo
|
||||
cannot leave the phone until this exists), **13** (populate the sequence),
|
||||
**14** (an outcome for a failed attempt), 7, 8, 9.
|
||||
|
||||
---
|
||||
|
||||
## 0b. Summary table
|
||||
|
||||
| # | Request | Priority | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | A consignment state that means *collected, not yet riding* | **P0** | ✅ Shipped 21 Aug — `Collected_By_Miler` + `start-delivery` |
|
||||
| 2 | `GET /miler/consignments/:id` (current state, one call) | **P0** | ✅ Shipped 21 Aug |
|
||||
| 3 | Return `consignmentid` on `GET /miler/bookings` | **P0** | ✅ Shipped 21 Aug — with `consignmentstatus` |
|
||||
| 4 | A file-upload route for proof of delivery | **P1** | ⏳ **Open** — POD cannot leave the phone |
|
||||
| 5 | A booking state for *arrived at pickup* | **P1** | ✅ Shipped 21 Aug — `Arrived_At_Pickup` |
|
||||
| 6 | Allow cancel/skip after pickup, or document the refusal | **P1** | ✅ Shipped 21 Aug — skip from collected |
|
||||
| 7 | Real route distance + duration on the assignment | **P2** | ⏳ Open — figures are estimates |
|
||||
| 8 | Persist `bonuspoints`, and a per-stop payout figure | **P2** | ⏳ Open — Earnings shows placeholders |
|
||||
| 9 | Notification read-state table | **P3** | ⏳ Open — known stub |
|
||||
| 13 | Route sequence — field shipped, **never populated** | **P0** | ⚠️ **Half** — `step` is on both endpoints but is `0` everywhere, `sequencedat` null |
|
||||
| 14 | An outcome for a failed delivery attempt | **P1** | ⏳ **Open** — skip leaves the consignment open with no way to say why |
|
||||
| 15 | `reached` must actually persist `Arrived_At_Pickup` | **P0** | 🔴 **BLOCKER** — reproduced: returns 200, writes nothing |
|
||||
| 16 | Admin mapping for `Arrived_At_Pickup` + `Collected_By_Miler` | **P0** | 🔴 **Console change** — both absent; flag must not be flipped first |
|
||||
| 17 | What `/admin/bookings` returns in `status` | **P1** | ❓ Unproven — one console network call answers it |
|
||||
|
||||
---
|
||||
|
||||
## 1. P0 — There is no consignment state meaning "collected, not yet riding"
|
||||
|
||||
> **✅ Shipped 21 Aug 2026 — Option A.** `pickup-complete` now stops at
|
||||
> `Collected_By_Miler`; `POST /miler/consignments/:id/start-delivery` makes the
|
||||
> release. The app's **Start round** is a server write again.
|
||||
|
||||
### Symptom (reported)
|
||||
> *"If I try to update as Picked it is directly updating as **active** in the
|
||||
> console."*
|
||||
|
||||
### Evidence — verified
|
||||
`GET /miler/consignments/logs/34` returns the consignment's real history:
|
||||
|
||||
```
|
||||
Out_for_Delivery "Package collected by miler and converted to consignment"
|
||||
Delivered "Delivered to SEQTEST Ukkadam at (11.00506, 76.95087)"
|
||||
```
|
||||
|
||||
The first row is written by **`pickup-complete`** — it carries that handler's
|
||||
own remark. So on the hyperlocal path, `pickup-complete` moves the consignment
|
||||
straight to `Out_for_Delivery`.
|
||||
|
||||
The console maps (`src/utils/bookingStatus.js`):
|
||||
|
||||
```js
|
||||
picked_up: 'picked',
|
||||
converted_to_consignment: 'picked',
|
||||
out_for_delivery: 'active', // ← what the operator sees
|
||||
```
|
||||
|
||||
So the moment the rider taps **Picked** at the kitchen counter, the consignment
|
||||
is `Out_for_Delivery` and the operator's board says **active** — while the food
|
||||
is still on the counter and the rider has not moved.
|
||||
|
||||
### Why this is a backend request, not a UI one
|
||||
The app already models the distinction locally (it knows the rider has not set
|
||||
off), but local state cannot reach the hub, and the operator's board is fed
|
||||
from server state. There is no server state that expresses *collected but not
|
||||
yet out for delivery*, so the information does not exist to display.
|
||||
|
||||
### Requested change — pick **one**
|
||||
|
||||
**Option A (preferred).** Add a consignment status between conversion and the
|
||||
road:
|
||||
|
||||
```
|
||||
Created → Inwarded_at_Hub → … → Out_for_Delivery → Delivered
|
||||
▲
|
||||
Collected_By_Miler ← NEW
|
||||
```
|
||||
|
||||
`pickup-complete` writes `Collected_By_Miler` for hyperlocal work.
|
||||
A new call moves it on:
|
||||
|
||||
```http
|
||||
POST /miler/consignments/:id/start-delivery
|
||||
→ 200 { success, data: { consignmentid, status: "Out_for_Delivery" } }
|
||||
400 if the consignment is not Collected_By_Miler
|
||||
```
|
||||
|
||||
`deliver` then accepts `Out_for_Delivery` exactly as it does now.
|
||||
|
||||
**Option B (smaller).** Leave the consignment alone and keep the **booking** at
|
||||
`Picked_Up` until the rider starts the round, moving it to
|
||||
`Converted_To_Consignment` at that point. Cheaper, but it leaves the two
|
||||
vocabularies coupled, which is the root of several problems in this document.
|
||||
|
||||
> **Note on `POST /miler/deliveries/start`:** the app used to call this. It is
|
||||
> **not** in the 38-route contract and returns `404 Cannot POST` — verified. We
|
||||
> have removed the call. Request 1 Option A is the properly-specced replacement.
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 — No way to read a consignment's current state in one call
|
||||
|
||||
> **✅ Shipped 21 Aug 2026.** `GET /miler/consignments/:consignmentid` returns
|
||||
> the status plus `collected` / `out_for_delivery` / `delivered` /
|
||||
> `can_start_delivery` / `can_deliver` / `can_skip`. The gate reads it; the
|
||||
> logs walk remains only as a fallback for older deployments.
|
||||
|
||||
### Symptom (reported)
|
||||
> *"I can't update it as delivered/skipped/cancelled — it's showing a lot of
|
||||
> errors."*
|
||||
|
||||
### Evidence — verified
|
||||
`POST /miler/consignments/34/deliver` →
|
||||
|
||||
```json
|
||||
400 { "success": false, "message": "consignment is not out for delivery" }
|
||||
```
|
||||
|
||||
That message is returned for **two opposite situations**:
|
||||
|
||||
| Real state | Meaning | What the rider should be told |
|
||||
|---|---|---|
|
||||
| `Inwarded_at_Hub` | not released yet | "the hub still has it" |
|
||||
| `Delivered` | **already delivered** | "already done — nothing to do" |
|
||||
|
||||
In the reported case it was the second: consignment 34 had been delivered at
|
||||
10:59 the previous day. The rider was shown an error for work he had completed.
|
||||
|
||||
### Why the app cannot fix this alone
|
||||
`GET /miler/bookings` reports only the **booking** status, which is terminal at
|
||||
`Converted_To_Consignment` and never learns about the delivery half. The only
|
||||
route that exposes consignment state is
|
||||
`GET /miler/consignments/logs/:consignmentid`, which returns the whole event
|
||||
history — the app now sorts it by `historyid` and takes the last row. That
|
||||
works, but it is a log-walk standing in for a state read, on the rider's mobile
|
||||
data, before every delivery.
|
||||
|
||||
### Requested change
|
||||
|
||||
```http
|
||||
GET /miler/consignments/:consignmentid
|
||||
→ 200 {
|
||||
success: true,
|
||||
data: {
|
||||
consignmentid: 34,
|
||||
trackingno: "DM-TRK-…",
|
||||
status: "Out_for_Delivery",
|
||||
bookingid: 60,
|
||||
attemptcount: 0,
|
||||
updatedat: "2026-08-20T10:59:35Z"
|
||||
}
|
||||
}
|
||||
404 if it is not this rider's consignment
|
||||
```
|
||||
|
||||
**And** make the two refusals distinguishable — either distinct messages or,
|
||||
better, a stable code:
|
||||
|
||||
```json
|
||||
400 { "success": false, "code": "CONSIGNMENT_ALREADY_DELIVERED",
|
||||
"message": "consignment already delivered" }
|
||||
400 { "success": false, "code": "CONSIGNMENT_NOT_RELEASED",
|
||||
"message": "consignment is not out for delivery" }
|
||||
```
|
||||
|
||||
A machine-readable `code` on every 4xx across `/miler/*` would let the app stop
|
||||
string-matching messages, which is fragile in exactly the way this bug proves.
|
||||
|
||||
---
|
||||
|
||||
## 3. P0 — `GET /miler/bookings` does not return `consignmentid`
|
||||
|
||||
> **✅ Shipped 21 Aug 2026.** Both `consignmentid` and `consignmentstatus` are
|
||||
> on the list rows. The 17-of-29 gap is closed, and the consignment status now
|
||||
> outranks the booking status the app draws the card from.
|
||||
|
||||
### Evidence — verified
|
||||
24 booking rows returned for rider 38; **zero** contain any `consignment*` key.
|
||||
`GET /miler/assignments` (list) does not carry it either. It appears **only** in
|
||||
`GET /miler/assignments/:id`, nested inside `booking`:
|
||||
|
||||
```jsonc
|
||||
// GET /miler/assignments/71
|
||||
{ "data": { "assignment": {…}, "booking": { "bookingid": 75, …,
|
||||
"consignmentid": 46 } } }
|
||||
```
|
||||
|
||||
### This is not a performance concern — it blocks deliveries outright
|
||||
|
||||
**Verified 21 Aug 2026, rider 38:**
|
||||
|
||||
| Endpoint | Rows | Booking ids |
|
||||
|---|---|---|
|
||||
| `GET /miler/bookings` | **29** | 26, 28, 41, 51, **54–78**, 82 |
|
||||
| `GET /miler/assignments` | **12** | 26, 28, 41, 51, 72–78, 82 |
|
||||
|
||||
**17 of 29 bookings have no assignment row at all.** `?status=Completed`,
|
||||
`?status=all`, `?pagesize=200` and `?bookingid=59` all return the same 12 rows —
|
||||
the endpoint ignores query parameters.
|
||||
|
||||
So for booking **59** (`DM-BK-501CB551-45130`, status
|
||||
`Converted_To_Consignment`, sitting on the rider's Deliveries tab) every route
|
||||
to its consignment id is a dead end:
|
||||
|
||||
1. the booking row — carries no consignment key *(none of the 29 do)*
|
||||
2. the app's local record from `pickup-complete` — absent after a reinstall,
|
||||
and absent entirely if that response did not carry the id
|
||||
3. `GET /miler/assignments/:id` — **booking 59 is not in the assignments list**,
|
||||
so there is no assignment id to fetch
|
||||
4. `GET /miler/consignments/userlogs/:userid` — telemetry only (1 row, for an
|
||||
unrelated consignment); carries no booking linkage
|
||||
|
||||
**There is no fifth route.** The rider is holding the parcel, the consignment
|
||||
demonstrably exists (the booking is converted), and the app cannot name it — so
|
||||
`deliver` can never be called and the stop can never be completed from the app.
|
||||
|
||||
This is the single highest-impact item in this document.
|
||||
|
||||
### Requested change
|
||||
Add `consignmentid` (nullable) and `consignmentstatus` to every row of:
|
||||
|
||||
- `GET /miler/bookings`
|
||||
- `GET /miler/assignments`
|
||||
|
||||
…and separately, **`GET /miler/assignments` should return every assignment the
|
||||
rider still has work for**, not a 12-row subset. If that list is intentionally
|
||||
scoped to active assignments, say so and we will stop treating it as a lookup
|
||||
table — but then requirement (a) below becomes mandatory rather than merely
|
||||
strongly preferred.
|
||||
|
||||
```jsonc
|
||||
{ "bookingid": 75, "status": "Converted_To_Consignment",
|
||||
"consignmentid": 46, "consignmentstatus": "Out_for_Delivery" }
|
||||
```
|
||||
|
||||
This single change removes an N+1 call pattern **and** makes Request 2's read
|
||||
unnecessary for the list case.
|
||||
|
||||
---
|
||||
|
||||
## 4. P1 — No upload route: proof of delivery cannot leave the phone
|
||||
|
||||
> **⏳ STILL OPEN.** The photo is captured, previewed and stored on the device,
|
||||
> and it is shown on the Activity record. It cannot reach the hub until this
|
||||
> route exists. This is now the highest-priority outstanding item.
|
||||
|
||||
### What the app now does
|
||||
The rider taps **Delivered** → a proof screen opens the camera → he sees the
|
||||
photo → **Mark as delivered** on the same screen. The photo is stored on the
|
||||
handset and shown on the delivery record in Activity.
|
||||
|
||||
### The blocker
|
||||
`POST /miler/consignments/:id/deliver` takes `photourl` — a **string**. Nothing
|
||||
in the 38-route contract accepts a file: no multipart route, no signed-URL
|
||||
endpoint, no attachment on any other call.
|
||||
|
||||
We deliberately **do not** send the device file path in `photourl`. A path from
|
||||
somebody's phone is not a URL; writing one into the hub's record would store a
|
||||
string that looks like evidence and resolves to nothing.
|
||||
|
||||
**So proof of delivery currently exists only on the rider's phone**, and the
|
||||
hub cannot see it. For a delivery business this is the difference between
|
||||
having evidence and believing you have it.
|
||||
|
||||
### Requested change — either shape works
|
||||
|
||||
**Option A — direct upload (simplest for us).**
|
||||
```http
|
||||
POST /miler/uploads
|
||||
Content-Type: multipart/form-data
|
||||
file: <binary> (jpeg, ≤ 5 MB)
|
||||
purpose: "delivery_proof" ("pickup_proof" | "signature" | …)
|
||||
consignmentid: 46 (optional, for association)
|
||||
→ 201 { success, data: { url: "https://cdn.doormile.com/proof/46-abc.jpg" } }
|
||||
```
|
||||
|
||||
**Option B — signed URL (cheaper server-side).**
|
||||
```http
|
||||
POST /miler/uploads/sign
|
||||
{ "purpose": "delivery_proof", "contentType": "image/jpeg" }
|
||||
→ 200 { success, data: { uploadUrl: "https://…?X-Amz-Signature=…",
|
||||
url: "https://cdn.doormile.com/proof/…" } }
|
||||
```
|
||||
The app PUTs the bytes to `uploadUrl`, then sends `url` as `photourl`.
|
||||
|
||||
**Constraints from our side:** photos are JPEG, quality 70, max width 1280 —
|
||||
typically 80–250 KB. Riders are on mobile data and often on 3G, so the upload
|
||||
must be retryable and must **not** block the delivery: we will complete the
|
||||
stop and upload in the background, then attach.
|
||||
|
||||
Please also confirm whether `receiversignatureurl` is expected to be fed from
|
||||
the same mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 5. P1 — "Arrived" does not persist: there is no booking state for it
|
||||
|
||||
> **✅ Shipped 21 Aug 2026.** `reached` persists `Arrived_At_Pickup`. The app
|
||||
> parses it (and the older `At_Customer` spelling) to the *arrived* rung.
|
||||
|
||||
### Symptom (reported)
|
||||
> *"If I click Arrived on the home page it is not updating as arrived."*
|
||||
|
||||
### Evidence
|
||||
The app calls `POST /miler/bookings/:bookingid/reached` with the booking id —
|
||||
correct per contract. But the booking enum is:
|
||||
|
||||
```
|
||||
Pending_Pickup, Created, Miler_Assigned, Pickup_Scheduled,
|
||||
Picked_Up, Converted_To_Consignment, Cancelled
|
||||
```
|
||||
|
||||
**There is no `Arrived`.** Whatever `reached` writes, the operator's board maps
|
||||
`pickup_scheduled → 'accepted'`, so an arrived rider is indistinguishable from
|
||||
one who merely accepted the job and is still 20 km away.
|
||||
|
||||
### Requested change
|
||||
Add `Arrived_At_Pickup` to the booking enum, written by `reached`, and return
|
||||
the new status in the response so the app can confirm rather than assume:
|
||||
|
||||
```http
|
||||
POST /miler/bookings/:bookingid/reached
|
||||
→ 200 { success, data: { bookingid, status: "Arrived_At_Pickup",
|
||||
reachedat: "2026-08-21T09:14:02Z" } }
|
||||
```
|
||||
|
||||
**Every state-changing route should return the resulting entity state.** Today
|
||||
most return `{success: true}` with no body, so the app cannot verify the
|
||||
transition landed and must re-poll the list.
|
||||
|
||||
---
|
||||
|
||||
## 6. P1 — A rider cannot report a failed delivery
|
||||
|
||||
> **✅ Shipped 21 Aug 2026.** `skip` is accepted from `Collected_By_Miler` as
|
||||
> well as `Out_for_Delivery`.
|
||||
|
||||
### Symptom (reported)
|
||||
> *"I can't update it as … skipped/cancelled."*
|
||||
|
||||
### Evidence
|
||||
- `POST /miler/consignments/:id/skip` requires `Out_for_Delivery` — so a
|
||||
consignment in any other state cannot be skipped, including one the rider is
|
||||
genuinely holding.
|
||||
- `POST /miler/bookings/:id/cancel` is **"refused once picked up"** per the
|
||||
contract. After `pickup-complete` there is therefore *no* route by which a
|
||||
rider can say "this cannot be completed" — he is holding a parcel with no way
|
||||
to report the failure.
|
||||
|
||||
### Requested change
|
||||
1. Allow `skip` from `Collected_By_Miler` **and** `Out_for_Delivery` (see
|
||||
Request 1).
|
||||
2. Add a consignment-level failure route:
|
||||
|
||||
```http
|
||||
POST /miler/consignments/:id/fail
|
||||
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
|
||||
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
|
||||
```
|
||||
|
||||
3. Document the maximum `attemptcount` and what the hub does when it is hit —
|
||||
the app should stop offering "skip" at that point rather than letting the
|
||||
rider discover the ceiling by being refused.
|
||||
|
||||
---
|
||||
|
||||
## 7. P2 — The Home figures are estimates, not real data
|
||||
|
||||
**Direct answer to the question asked.** The four figures on Home
|
||||
(`DURATION · DISTANCE · PARCELS · PAYMENT`) are:
|
||||
|
||||
| Figure | Source | Real? |
|
||||
|---|---|---|
|
||||
| **PARCELS** | sum of parcel counts on the booking rows | ✅ **Real** (backend data) |
|
||||
| **PAYMENT** | sum of `collectionamt` on the booking rows | ✅ **Real** (backend data) |
|
||||
| **DISTANCE** | *client-side* haversine: hub → each stop in order → hub, straight lines. Falls back to summing each row's `kms` when coordinates are missing | ⚠️ **Estimate** |
|
||||
| **DURATION** | *client-side*: straight-line distance ÷ an assumed 20 km/h, **plus** a hardcoded service time per stop — 2m30 delivery / 4m00 pickup / 5m30 combined, +90s if cash, +25s per extra parcel | ⚠️ **Estimate** |
|
||||
|
||||
So two of the four are real and two are the app's own arithmetic. Straight-line
|
||||
distance under-reads real road distance by roughly 20–40% in Coimbatore, and
|
||||
the service times are guesses that have never been measured against actual
|
||||
stop durations.
|
||||
|
||||
### Requested change
|
||||
Return the routed figures on the assignment/trip, computed once server-side
|
||||
(you already have the coordinates, and a routing engine gives real road
|
||||
distance):
|
||||
|
||||
```jsonc
|
||||
// GET /miler/assignments — per row, or a trip-level summary
|
||||
{
|
||||
"routedistancemeters": 21800, // real road distance for the leg
|
||||
"routedurationseconds": 1620, // real drive time
|
||||
"cumulativedistancemeters": 48200,
|
||||
"cumulativedurationseconds": 5400
|
||||
}
|
||||
```
|
||||
|
||||
The assignment model already has `riderkms`, `previouskms`, `cumulativekms`,
|
||||
`etaminutes` and `cumulativeeta` — **all of them return 0** today (verified on
|
||||
all 24 rows). Populating those existing fields would be enough; no new schema
|
||||
needed.
|
||||
|
||||
**Until then the app will keep showing estimates, because inventing precision
|
||||
we do not have is worse than an honest approximation.**
|
||||
|
||||
---
|
||||
|
||||
## 8. P2 — Earnings has no real per-stop money
|
||||
|
||||
- `bonuspoints` is never written (acknowledged in your own doc).
|
||||
- There is no per-stop payout anywhere in the API. `deliver` computes
|
||||
`riderkms` and `ridercharges` server-side and returns **neither**;
|
||||
`GET /miler/earnings` answers for a *period* only.
|
||||
|
||||
The app therefore shows `Stop payout · See Account` on the delivery record
|
||||
rather than a number, because a figure derived from a rate the app does not
|
||||
hold is the one invention a rider would act on and be wrong about.
|
||||
|
||||
### Requested change
|
||||
Return the earnings the server already computed, on the response to `deliver`
|
||||
and on the booking/consignment rows:
|
||||
|
||||
```jsonc
|
||||
{ "riderkms": 4.6, "ridercharges": 38.50, "bonuspoints": 5 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Battery percentage — direct answer
|
||||
|
||||
**Yes, it can reach the console today, and the plumbing already exists on both
|
||||
sides.**
|
||||
|
||||
- The app reads the real battery level (`battery_plus`) in
|
||||
`live_tracking_service.dart` and `foreground_service.dart`.
|
||||
- It is sent on `POST /miler/logs` and `POST /miler/consignments/logs` as the
|
||||
documented **string** field `battery`.
|
||||
- The console already renders it — `src/pages/nearle/riders/riders.js`:
|
||||
`riderLogsdata?.battery ? \`${battery}%\` : 'N/A'`.
|
||||
|
||||
**What to check on your side:** the console reads it from *rider logs*, so it
|
||||
only appears while the app is posting telemetry (on duty, service running). If
|
||||
operators see `N/A`, the likely causes in order are: (a) the rider is off duty
|
||||
so nothing is being posted; (b) `GET /miler/logs` is returning the latest row
|
||||
without `battery` populated; (c) the value is being stored but the console's
|
||||
rider-detail query is not selecting it.
|
||||
|
||||
**One request:** expose the most recent telemetry row per rider in one call, so
|
||||
the console does not have to page logs to find a current battery/GPS reading:
|
||||
|
||||
```http
|
||||
GET /miler/riders/:userid/last-seen (or /admin equivalent)
|
||||
→ { success, data: { latitude, longitude, battery, speed, status,
|
||||
logdate, connection } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Cross-cutting requests
|
||||
|
||||
> **✅ 10.1–10.4 shipped 21 Aug 2026.** Mutations return the resulting state,
|
||||
> `pickup-complete` returns `consignment_id` and `next_action`, 4xx bodies
|
||||
> carry a machine-readable `code`, and `Idempotency-Key` is honoured. 10.5
|
||||
> (`requiredeliveryotp` on the tenant payload) is still open.
|
||||
|
||||
These are small and would remove whole classes of defect:
|
||||
|
||||
1. **Return the resulting state from every mutation.** `accept`, `reject`,
|
||||
`reached`, `parcel`, `payment`, `pickup-complete`, `deliver`, `skip`,
|
||||
`cancel` mostly return `{success: true}`. The app cannot confirm a
|
||||
transition and must re-poll.
|
||||
|
||||
2. **`pickup-complete` must return the `consignmentid` it minted.** It is the
|
||||
one moment the id is guaranteed to exist. The app records it locally from
|
||||
the response today; when the response omits it, the delivery leg has to go
|
||||
hunting (Request 3).
|
||||
|
||||
3. **Machine-readable error codes on every 4xx** (see Request 2). String
|
||||
matching on `"consignment is not out for delivery"` is how the app ended up
|
||||
telling riders the wrong thing.
|
||||
|
||||
4. **Idempotency.** `deliver`, `pickup-complete` and `accept` are not
|
||||
idempotent. A dropped response on a bad connection means the rider retries
|
||||
and gets a 400 for work that succeeded. Accepting an
|
||||
`Idempotency-Key` header and replaying the original result would remove
|
||||
this entire failure mode.
|
||||
|
||||
5. **Confirm `requiredeliveryotp` per tenant** is exposed to the app. We
|
||||
currently send `otp` only when we have one and never fabricate a
|
||||
placeholder; if the flag were on the profile/tenant payload we could show
|
||||
or hide the OTP field correctly instead of inferring.
|
||||
|
||||
---
|
||||
|
||||
## 11. What the app already does correctly (no change needed)
|
||||
|
||||
Recorded so the backend team does not chase these:
|
||||
|
||||
- `deliver`/`skip` key on the **consignment id**, never the booking, assignment
|
||||
or pickup id.
|
||||
- `accept`/`reject` key on `bookingassignmentid`.
|
||||
- `reached`/`parcel`/`payment`/`pickup-complete`/`cancel` key on `bookingid`.
|
||||
- Telemetry sends lat/long/speed/heading/battery as **strings**, and never
|
||||
sends `userid` in the body.
|
||||
- Booking statuses and consignment statuses are handled as two separate
|
||||
vocabularies; the app no longer infers a delivery state from a booking
|
||||
status.
|
||||
- Duplicate taps are collapsed client-side (one in-flight mutation per
|
||||
resource+verb), but see Request 10.4 — that is not a substitute for
|
||||
server-side idempotency.
|
||||
|
||||
---
|
||||
|
||||
## 11b. Request 13 — P0: the route sequence is exposed but never populated
|
||||
|
||||
**Re-verified live 21 Aug 2026** against rider `userid 23 / tenantid 1` —
|
||||
`GET /miler/bookings` (29 rows) and `GET /miler/assignments` (11 rows).
|
||||
|
||||
### The transport is there. Thank you.
|
||||
|
||||
`step` is on **both** endpoints, alongside `sequencedat`, `etaminutes`,
|
||||
`cumulativekms`, `cumulativeeta` and `stoptype`. That closes the client half of
|
||||
this entirely: the app now reads `step` off the booking row, orders both legs
|
||||
by it, and never re-sorts an assigned route.
|
||||
|
||||
### The data is empty
|
||||
|
||||
```
|
||||
/miler/bookings step = 0 on all 29 rows
|
||||
/miler/assignments step = 0 on all 11 rows
|
||||
sequencedat = null on all 11 rows
|
||||
etaminutes = 0, riderkms = 0, cumulativekms = 0
|
||||
```
|
||||
|
||||
`sequencedat: null` on every assignment means **the route optimizer has never
|
||||
run for this rider**. So there is no admin route to follow — not one the app is
|
||||
dropping, one that does not exist.
|
||||
|
||||
### What the app does about it
|
||||
|
||||
It does not pretend. With no sequence anywhere in the set it falls back —
|
||||
booked time first, then nearest-first — and **labels the queue on screen** with
|
||||
which rule produced the order (`Hub route` vs `Nearest first`), so a rider is
|
||||
never told the app's guess is his route. It is also logged per fetch:
|
||||
|
||||
```
|
||||
[ROUTE][deliveries] 7 stop(s) with no assigned sequence — ordering is the
|
||||
app's own fallback, not the hub's route
|
||||
```
|
||||
|
||||
### Requested change
|
||||
|
||||
Populate it. Whatever writes `sequencedat` — batch assign, the console's
|
||||
sequence action, a scheduled optimizer pass — is either not running or not
|
||||
running for this tenant. Once `step` comes down non-zero, the app follows it on
|
||||
both legs with no further change and no release needed.
|
||||
|
||||
**Please confirm:** is sequencing expected to be automatic on assignment, or is
|
||||
it a deliberate action a hub manager has to take in the console? The answer
|
||||
changes whether "no route assigned" is a normal state the rider should see or a
|
||||
fault worth alerting on.
|
||||
|
||||
---
|
||||
|
||||
## 11c. Request 14 — P1: a failed delivery attempt has no outcome
|
||||
|
||||
`POST /miler/consignments/:id/skip` returns 200 and, as far as the app can
|
||||
tell, leaves the consignment `Out_for_Delivery`. There is no state, field or
|
||||
route that says *this stop was attempted and failed*.
|
||||
|
||||
That leaves the two systems unable to agree on whether the stop is still
|
||||
actionable:
|
||||
|
||||
- The **rider** has been to the door. It is done with, for him, today.
|
||||
- The **hub** still has an open `Out_for_Delivery` consignment.
|
||||
|
||||
The app refuses to resolve that by inventing a terminal state locally — it
|
||||
reads the consignment after every skip and only files the stop as finished if
|
||||
the server agrees it is closed. When the server keeps it open, the stop is
|
||||
**parked**: still visible under SKIPPED with the rider's reason, still holding
|
||||
its consignment mapping, resumable by him. Honest, but it is a workaround for a
|
||||
missing contract.
|
||||
|
||||
### Requested change — any one of these
|
||||
|
||||
1. A consignment state for it — `Delivery_Failed` / `Attempt_Failed` — that
|
||||
`skip` moves it to, with whatever reassignment or RTO the hub decides
|
||||
happening from there.
|
||||
2. An `attemptcount` + `lastattemptat` on the consignment, so the app can at
|
||||
least show *"attempt 2 of 3"* and the hub can act on the count.
|
||||
3. Documented confirmation that a skip is expected to leave the consignment
|
||||
open and that the rider is meant to retry it in the same shift — in which
|
||||
case the app will surface it as retryable rather than as a closed record.
|
||||
|
||||
Whichever you pick, the app needs to know **who owns the stop after a skip**.
|
||||
|
||||
---
|
||||
|
||||
## 11d. Request 15 — P0 BLOCKER: `reached` is a deployed no-op
|
||||
|
||||
**Reproduced against production 21 Aug 2026.** Rider `userid 23 / tenantid 1`,
|
||||
booking 78, three calls in sequence:
|
||||
|
||||
```
|
||||
GET /miler/bookings → booking 78 status = Miler_Assigned
|
||||
|
||||
POST /miler/bookings/78/reached
|
||||
{"lat":11.0168,"lon":76.9558}
|
||||
→ 200 {"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}
|
||||
|
||||
GET /miler/bookings → booking 78 status = Miler_Assigned
|
||||
```
|
||||
|
||||
The endpoint returns `success: true`, echoes the booking's **unchanged**
|
||||
status, and writes nothing. `Arrived_At_Pickup` is not written and does not
|
||||
appear on any of the 31 bookings in this tenant.
|
||||
|
||||
**This is the whole reason Arrived never reaches Admin.** It is not a client
|
||||
bug: the app calls the documented endpoint with the documented body and gets a
|
||||
200. It had been treating that 200 as proof of a transition, which it no longer
|
||||
does — the app now reads the returned status and, when it is not
|
||||
`Arrived_At_Pickup`, tells the rider his hub has not recorded the arrival
|
||||
rather than drawing a tick.
|
||||
|
||||
### Requested change
|
||||
|
||||
`POST /miler/bookings/:bookingid/reached` must persist `Arrived_At_Pickup` on
|
||||
the booking and return it:
|
||||
|
||||
```
|
||||
POST /miler/bookings/:bookingid/reached
|
||||
{ "lat": 11.0168, "lon": 76.9558 }
|
||||
|
||||
200 { "success": true,
|
||||
"data": { "bookingid": 78, "status": "Arrived_At_Pickup" } }
|
||||
```
|
||||
|
||||
**Please also confirm which is true**, because they need different fixes:
|
||||
1. the handler was never wired to write, or
|
||||
2. it writes only from `Pickup_Scheduled` and silently no-ops from
|
||||
`Miler_Assigned` (booking 78 was assigned but not yet accepted).
|
||||
|
||||
If (2), say so and the app will stop offering **I've arrived** before the
|
||||
accept lands. If it is meant to be callable from `Miler_Assigned`, it must
|
||||
write from there too.
|
||||
|
||||
---
|
||||
|
||||
## 11e. Request 16 — P0: Admin has no mapping for the two new statuses
|
||||
|
||||
**Not a backend change — a console change.** Traced in
|
||||
`Doormilexpress_console/src/utils/bookingStatus.js`:
|
||||
|
||||
```js
|
||||
export const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
created: 'pending', pending_pickup: 'pending',
|
||||
miler_assigned: 'pending', pickup_scheduled: 'accepted',
|
||||
picked_up: 'picked', converted_to_consignment: 'picked',
|
||||
out_for_delivery: 'active',
|
||||
delivered: 'delivered', cancelled: 'cancelled'
|
||||
};
|
||||
```
|
||||
|
||||
Neither `arrived_at_pickup` nor `collected_by_miler` is in it. Unmapped
|
||||
statuses "pass through lowercased", which the Deliveries page renders as an
|
||||
unknown badge — and the file's own comment says the Arrived tab "will show a 0
|
||||
count until the real enum is confirmed".
|
||||
|
||||
Two consequences the console team must act on:
|
||||
|
||||
1. **Even after request 15 ships, Admin still will not show Arrived.** Add
|
||||
`arrived_at_pickup: 'arrived'`.
|
||||
2. **Enabling `MILER_COLLECTED_STATE_ENABLED` today would make Admin worse,
|
||||
not better.** With no `collected_by_miler` key, every freshly collected
|
||||
parcel would render as an unknown badge instead of Active. Add
|
||||
`collected_by_miler: 'picked'` **before** the flag is flipped.
|
||||
|
||||
### Ordering — this matters
|
||||
|
||||
```
|
||||
1. console adds arrived_at_pickup + collected_by_miler mappings
|
||||
2. backend fixes `reached` to persist Arrived_At_Pickup (request 15)
|
||||
3. this app build ships — it already supports both lifecycles
|
||||
4. only then flip MILER_COLLECTED_STATE_ENABLED = true
|
||||
```
|
||||
|
||||
Flipping the flag before step 1 or step 3 breaks the rider's Picked rung.
|
||||
|
||||
---
|
||||
|
||||
## 11f. Request 17 — the one thing still unproven: what `/admin/bookings` puts in `status`
|
||||
|
||||
`GET /miler/bookings` reports the **booking** status: booking 77 is
|
||||
`Converted_To_Consignment` with `consignmentstatus: Out_for_Delivery`.
|
||||
|
||||
The console reads `b.status` from `/admin/bookings` and maps it
|
||||
(`api.js:611` and `api.js:664`). If that field held `Converted_To_Consignment`
|
||||
too, the console would render **Picked** — which is not what we see; it shows
|
||||
**Active**.
|
||||
|
||||
So exactly one of these is true, and we cannot tell which from the rider token:
|
||||
|
||||
| # | Possibility | How to confirm | Owner |
|
||||
|---|---|---|---|
|
||||
| A | `/admin/bookings` projects the consignment status onto `status` | one network call in the console's dev tools | backend |
|
||||
| B | the backend also writes `Out_for_Delivery` onto the booking row for hyperlocal, and `/miler/bookings` reports something different | compare both endpoints for the same bookingid | backend |
|
||||
| C | the deployed console is older than this source (the repo comment says `converted_to_consignment` "used to map to `accepted`") | check the deployed bundle | console |
|
||||
|
||||
**Please open the console's Deliveries page, look at the `/admin/bookings`
|
||||
response for one collected booking, and tell us the value of `status`.** That
|
||||
single value decides which of the three it is, and none of them is fixed in the
|
||||
rider app.
|
||||
|
||||
Whichever it is, note that in compatibility mode **Active is not wrong** — the
|
||||
consignment genuinely is `Out_for_Delivery`. The app now agrees with the
|
||||
console instead of showing `Picked` over it. Picked/Collected becomes a real,
|
||||
separate rung only once the flag is on.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions for the backend team
|
||||
|
||||
~~1. Is `Collected_By_Miler` (Request 1 Option A) acceptable, or do you prefer
|
||||
holding the booking at `Picked_Up` (Option B)?~~ **Answered: Option A, shipped.**
|
||||
|
||||
2. Which upload shape do you want to support — direct multipart or signed URL?
|
||||
3. Is there an existing object store / CDN we should target for proof photos,
|
||||
and what retention applies to them?
|
||||
4. Can `riderkms` / `etaminutes` / `cumulativekms` be populated from the
|
||||
routing engine, or is there no routing engine in the stack today?
|
||||
5. What is the intended `attemptcount` ceiling, and what happens at it?
|
||||
Reference in New Issue
Block a user