# API — dispatch contracts

The endpoints the captain dispatch algorithm adds or changes, with the behaviour behind them.
Every request needs `Accept: application/json` and `Accept-Language: en|ar`; every answer uses
the envelope `{ Model, Status, Message, MessageDebug, Total, Page, Records }`. The OpenAPI
description (`/api/documentation`) carries the same contracts field by field.

Sections are added as each task lands: suggestions (T5.5), assignment (T6.1), Suggestions (T5.5), assignment (T6.1),
route plans (T7.3–T7.5) and KPIs (T8.1) are documented below.

---

## 1. Captain app — GPS ping (adaptive)

`POST /api/driver/location` · bearer token of the `driver` guard

### Request

| Field | Type | Rules | Meaning |
|---|---|---|---|
| `lat` | number | required, −90…90 | latitude |
| `lng` | number | required, −180…180 | longitude |
| `accuracy` | number | optional, 0…10000 | GPS accuracy radius, metres |
| `speed_mps` | number | optional, 0…100 | speed the phone's GPS reports, metres per second |
| `heading` | integer | optional, 0…359 | degrees clockwise from north |

### Response `200`

```json
{
  "Model": {
    "lat": 24.7136,
    "lng": 46.6753,
    "accuracy": 6.5,
    "captured_at": "2026-09-14 10:19:59",
    "speed_mps": 11.2,
    "heading": 270,
    "accepted": true,
    "next_ping_seconds": 8
  },
  "Status": true
}
```

| Field | Meaning |
|---|---|
| `lat` … `captured_at` | the captain's **stored** position after this ping |
| `speed_mps` | the speed the ping was judged by (reported, or worked out — see below); null when unknown |
| `accepted` | `false` when the ping was captured before the stored point and was not written |
| `next_ping_seconds` | how long the app waits before its next ping |

### Behaviour

- **Adaptive interval.** `next_ping_seconds` is `ping_moving_s` (8) when the speed is at or above
  `moving_speed_mps` (2 m/s), and `ping_stationary_s` (45) below it or when the speed is unknown.
  The v2.1 ranges are 5–10 s moving and 30–60 s stationary; all three values are in
  `config/dispatch.php`. The app should follow the value it is given rather than a fixed timer.
- **Time.** The server stamps every position with the moment the request arrives. There is no
  `captured_at` field; one sent by the app is ignored. Trusting the phone's clock let a time
  without a timezone, or a stale example date, make pings look hours or days old, and each was
  refused with a 200 while the stored position never moved.
- **Speed.** A reported `speed_mps` is used as it is. Without one, the speed is the straight-line
  distance from the stored point divided by the seconds between the two arrivals.
- **Buffered pings.** A ping the phone held while offline is stamped when it arrives. Send them
  oldest first so the last one sent — the newest — is what stays stored.
- **`accepted: false`.** The ping was not written because the stored point is newer. With server
  stamping this only happens if the server's clock steps backwards (an NTP correction).
- **Where it is stored, within the request:**

  | Store | Key | Content |
  |---|---|---|
  | SQL | `driver_locations` (one row per captain) | lat, lng, accuracy, captured_at |
  | Redis GEO | `{prefix}captains_live`, member = captain id | the point, for radius searches |
  | Redis hash | `{prefix}captain:{id}:gps` | lat, lng, accuracy, speed_mps, heading, captured_at (unix seconds) |
  | Redis list | `{prefix}captains:gps_history` | every ping's point (late ones too), waiting for `dispatch:flush-gps` to write it to `driver_location_history` |
  | Redis GEO | `{prefix}captains_effective`, member = captain id | the point the captain is judged from: the GPS point while idle, the current order's drop-off while busy (re-pointed on every accepted ping, on going online / offline, and on every order status change) |

  `{prefix}` is the application's Redis prefix (`REDIS_PREFIX`).
- **Redis unavailable.** The SQL row is still written and the answer is the same; the failure is
  reported to the log. Until a ping reaches Redis, dispatch sees the captain's live point as
  missing or stale — never a wrong position.

### Errors

`401` token or headers missing · `422` validation.

---

## 2. Captain app — availability and break

`POST /api/driver/availability` · bearer token of the `driver` guard

| Field | Type | Rules | Meaning |
|---|---|---|---|
| `is_online` | boolean | required | open for orders |
| `on_break` | boolean | optional | online, but not offered new orders |

Answer `Model`: `{ is_online, on_break, last_seen_at, updated_at }`.

- A request without `on_break` leaves the break as it was.
- Going offline ends the break (whatever `on_break` says), takes the captain off
  `captains_live` and `captains_effective`, and deletes their GPS hash. Going online puts them
  back on `captains_effective` at once when they carry an order (its drop-off), or at their
  next ping when they are idle.
- `403` when the captain is not approved; `401`; `422`.

---

## 3. Dashboard — dispatch settings

`GET /api/dashboard/dispatch/settings` (`dispatch.view`) · `PUT /api/dashboard/dispatch/settings`
(`dispatch.settings`) · bearer token of the `admin` guard

- `GET` answers `Model.editable` — the values the operations team may change
  (`handoff_buffer_prepaid_min`, `handoff_buffer_cod_min`, `max_batch_detour_min`), each with
  `value`, `default`, `min`, `max`, `overridden`, `updated_at`, `updated_by` — and
  `Model.effective`, every value the algorithm runs on now.
- `PUT` takes any of the editable keys: a number within its range overrides the default, `null`
  resets it. At least one editable key is required (`422` otherwise). The answer is the `GET`
  body with the new values. Overrides are cached for 60 s; a `PUT` applies at once.

---

## 4. Route plans — the repaint contract (T7.3, T7.4, T7.5)

A **route plan** is everything a captain still has to do: the stores not yet collected and the
customers not yet delivered, in the order the system wants them driven, with a line to draw and an
arrival time per stop. It is recomputed whenever an order joins the captain's route or a stop is
completed, when the captain leaves the route they were given, and when a dispatcher reorders it by
hand.

The `trigger` on a frame says which of those it was. `deviation` (T7.4) is the one worth knowing
about on the client: it means the captain has been more than 250 m off the line for half a minute
and the route has been rebuilt around where they actually are. It arrives at most once per plan
version, so a captain driving their own way does not produce a stream of them.

### The one rule every client must follow

**Render only the highest `version` you have seen. Ignore anything lower or equal.**

Recomputes are frequent and the same change arrives twice on purpose — once over the websocket
(fast) and once over FCM (reliable, and the one that survives a tunnel). Frames can also arrive out
of order. A client that repainted on arrival order would show a route that has already been
replaced, and a captain would be sent to a store they had just left. Keep the highest version,
compare, discard the rest. Receiving the same version twice must repaint once.

### Reading the route

`GET /api/driver/route-plan` · bearer token of the `driver` guard

Called when the app opens, when it returns from the background, and whenever a push says the
version moved. There is no captain in the path: a captain may read their own route and nothing
else.

- `Model` is `null` when the captain is carrying nothing. That is an answer, not an error.
- `Model.stops[]` carries `key`, `type` (`pickup` / `dropoff`), `order_id`, `pickup_id`, `lat`,
  `lng`, `leg_seconds`, `leg_meters`, `eta_at`. The leg is the hop that leads **to** that stop, so
  the times add up along the route.
- `Model.degraded` is `true` when the times in the plan cannot be trusted. Usually that means the
  map service could not answer: **the stops and their order are still correct** — precedence is
  arithmetic, not cartography — but the times are estimated from straight-line distance and
  `polyline` is `null`. Draw the stop list; do not invent a line.
- **`degraded` can turn true without the version changing** (T7.5). When the map service is down
  and the captain's remaining stops have not changed, the route they are already driving is kept
  as it is — right stops, right order, real polyline — and only the flag is set, because replacing
  a measured route with a straight-line one mid-delivery would take the line off their map and
  gain nothing. Nothing is broadcast for it, since there is no new version to render. A screen
  that wants to show "these times are ageing" must read it on its next fetch rather than wait for
  a push. The system retries behind the scenes and publishes a real new version when the engine
  answers again.

### The websocket

Private channels, authorised at `POST /api/broadcasting/auth` with the same bearer token:

| Channel | Who | Carries |
|---|---|---|
| `captain.{driver_id}` | that captain only | their own route |
| `dispatch.dashboard` | an admin with `dispatch.view` | every route being driven |

The event name is **`route-plan.updated`** — match on that string, not on a class path. Its payload
is `uuid`, `driver_id`, `version`, `trigger`, `stops`, `polyline`, `total_seconds`,
`total_meters`, `degraded`, `computed_at`: the same shape the endpoint returns.

A captain subscribing to another captain's channel is refused. A route names the customers someone
is carrying and where they live, so that refusal is the point of the channel being private.

The auth endpoint answers **401** without a token and **403** for a channel the token may not
have. It serves both audiences, so the checks are by type as well as by id: an admin token is
refused on `captain.{id}` exactly as another captain's would be, and a captain is refused
`dispatch.dashboard`, which carries the whole fleet's work. `dispatch.dashboard` additionally
requires the `dispatch.view` permission — being an admin is not enough.

### The push

An FCM **data** message — silent, deliberately. A route is recomputed every time a stop is
completed, and a captain buzzed a dozen times an hour about work they are already doing would learn
to ignore the notification that matters. The one push that *should* interrupt is a new order.

It carries a pointer, not the plan: `type` (`route_plan.updated`), `route_plan_uuid`, `version`,
`trigger`, `stops` (a count), `degraded` — every value a string, as FCM requires. The phone then
calls `GET /api/driver/route-plan`, which is the same call it makes when it opens, so there is one
way to read a route rather than two that can disagree. A route with a polyline can exceed FCM's
4 KB limit, and an oversized message is rejected outright — the captain would get nothing.

### The customer's arrival time

`orders.customer_eta_at` and the `CustomerEtaUpdated` event are the whole contract. The customer
app is not part of this project: whoever speaks to the customer subscribes to that event or reads
that column. The event carries the previous time as well as the new one, because "your delivery is
eight minutes later" and "your delivery is at 14:05" are different messages and only the sender
knows which their customer should get. An arrival that did not move is not announced.

---

## 5. Dashboard — who should take this order (T5.5)

`GET /api/dashboard/orders/{uuid}/captains` · bearer token of the `admin` guard · permission
`orders.assign`

The ranked list a dispatcher chooses from. It is a **suggestion**, never a decision: every
candidate can be assigned, and the dispatcher may pick any of them.

### The one thing to understand before reading the list

**It is not ordered by distance.** A captain standing at the store may still be twenty minutes
from being able to start, because they have a delivery to finish and a parcel to hand over. The
list is ordered by **Adjusted ETA** — when this captain could realistically be at the pickup:

```
adjusted_eta_min = road_eta_min + remaining_delivery_eta_min + handoff_buffer_min
```

For an idle captain the last two are `null` and the adjusted ETA is just the drive. Those three
numbers are sent so a dispatcher can check the fourth by eye; a list that cannot be checked is one
that gets ignored.

### `Model`

| Field | Meaning |
|---|---|
| `suggestion_uuid` | The list itself. Send it back with the assignment so the choice can be attributed. |
| `candidates[]` | Ranked, best first. |
| `radius_km` / `search_expanded` | How far the geo filter had to look. Expansion means the area is thin. |
| `excluded_stale[]` | Captains set aside because their position was too old to trust. |
| `routing_elements` / `cache_hit` | What this list cost the map service. |
| `ranking_degraded` / `degraded_reason` | **The engine could not answer.** See below. |
| `timings` / `total_ms` | How long each phase took, against its budget. |
| `no_candidates_reason` | Populated when the list is empty. An empty list is an answer, not a failure. |

### A candidate

| Field | Meaning |
|---|---|
| `rank` | 1..n, matching the array order. |
| `captain` | `{ uuid, name }`. Assign by `uuid`. |
| `state` / `state_label` | `idle`, `batchable` or `full` — and the same in words. |
| `at_store_batch` | They are already collecting from *this* store for another order. |
| `gps_freshness` / `_label` | `fresh`, `aging`, `stale`, `missing`. |
| `distance_km` | Straight line from their **effective** position — their phone if idle, the customer they are driving to if busy. |
| `road_eta_min` | The drive to the pickup. |
| `remaining_delivery_eta_min` | Finishing what they are carrying. `null` when idle. |
| `handoff_buffer_min` | Handing that parcel over. `null` when idle. |
| `adjusted_eta_min` | The three above, added. **This is the ranking.** |
| `detour_delta_min` | What batching would cost the customer already on board. |
| `batch_verdict` / `_label` | `not_applicable`, `accepted`, `rejected_detour`, `rejected_promise`. |
| `eta_estimated` | **These minutes are a guess.** Do not quote them to a customer. |
| `reasons[]` | Human-readable, translated, already in the caller's language. |

### When the map service is down

The list still arrives. `ranking_degraded` is `true`, `degraded_reason` names the failure
(`routing_timeout`, `routing_error`, `routing_partial`, `routing_not_configured`), and every
candidate carries `eta_estimated: true`. The order is then by straight-line estimate.

**Show the banner.** A degraded list that looks normal is how somebody assigns the wrong captain
and never finds out why — half a list helps nobody, and an error page helps less.

### A refused batch

When adding this order to a captain's route would delay the customer they are already carrying
past `max_batch_detour_min`, or miss the new order's own `promised_at`, the verdict says so. What
happens next is configuration: `rank_lower` keeps them on the list below the captains who can take
it cleanly, `exclude` removes them. Either way `reasons[]` explains it in words.

---

## 6. Dashboard — handing the order over (T6.1)

`PATCH /api/dashboard/orders/{uuid}/assign` · permission `orders.assign`

```json
{ "driver_uuid": "01m2...", "suggestion_uuid": "9f1c...", "rank": 1 }
```

`suggestion_uuid` and `rank` are optional and worth sending. They are what lets the system later
answer "do dispatchers take our first suggestion?" — and a dispatcher who knew better is a signal
about the ranking, not a failure.

| Status | Meaning |
|---|---|
| `200` | Assigned. `Model` is the order, `Model.driver` the captain. |
| `409` | **The captain cannot take it.** At capacity, no longer eligible, or another dispatcher is confirming them this second. `Message` says which, in words. |
| `422` | The order cannot move to assigned from where it is. |

**A 409 is a normal answer, not an error.** Two dispatchers looking at the same list is the
ordinary case; capacity is reserved in one conditional update, so exactly one of them wins and the
other is told immediately. Refresh the list and pick again.

### Reordering a captain's route by hand

`PATCH /api/dashboard/dispatch/drivers/{driver}/route-plan/reorder` · permission `orders.assign`

```json
{ "version": 4, "stops": ["pickup:12", "pickup:15", "dropoff:7", "dropoff:9"] }
```

Stops are named, never numbered by position: the route can be rebuilt between the screen being
drawn and the button being pressed. `version` is the plan you were looking at — a `409` means it
moved on and you are reordering a route that no longer exists. A `422` means the order asked for
is not a legal route, which in practice means delivering something before collecting it.

---

## 7. Dashboard — how dispatch is performing (T8.1)

`GET /api/dashboard/dispatch/kpis?from=YYYY-MM-DD&to=YYYY-MM-DD` · permission `dispatch.view`

Both dates are optional and default to the last seven days. A window reaching further back than
the counters' retention is refused rather than answered with the part that still exists.

| Field | Meaning |
|---|---|
| `suggestion_to_display` | `{ p50_ms, p95_ms }` — how long the system took to build a list. |
| `display_to_assign` | The same, for how long the dispatcher then took. Measures the human. |
| `first_suggestion_acceptance_rate` | Of assignments made from a list, the share that took rank 1. |
| `batch_rate` | Of all assignments, the share that joined a route rather than starting one. |
| `assignment_failure_rate` | Of all *attempts*, the share refused. |
| `routing_fallback_rate` | Of all routing calls, the share the engine did not answer. |
| `routing_elements_per_assignment` | Billable map units per order assigned. |
| `maps_cost_per_delivery` | What the map service cost, per parcel actually delivered. |
| `recomputes_per_delivery` | How often a captain's route was rebuilt, per parcel. |
| `totals` | The raw counters, keyed by metric name. |

**Every rate is `null` when its denominator is zero, and that is deliberate.** A quiet Sunday has
no batch rate. Render `—`, never `0 %` — a screen that cannot tell "nothing happened" from "it
went badly" is worse than no screen.

Rates are fractions between 0 and 1, not percentages. Percentiles rather than averages, because an
average hides the tail and the tail is the complaint.
