# QA test plan — captain suggestion algorithm

Test data to create, and the exact result the algorithm must produce for it.

Every number below was computed with the application's own `DistanceService` and
the seeder's own destination-point helper, against the defaults in
`config/dispatch.php`. They are predictions you can hold the system to, not
illustrations.

Endpoint under test: `GET /api/dashboard/orders/{uuid}/captains`.

---

## 1. What the algorithm actually is

Read this first, because half of what people expect it to do, it does not do.

**There is no weighted score.** No `0.4 × distance + 0.3 × load`. Ratings,
acceptance rate, vehicle type, order value, seniority and zone are not inputs
anywhere. Do not raise a bug because a five-star captain lost to a two-star one;
the system has never looked at ratings.

The list is a **lexicographic sort on one number plus three rules**:

```
FINAL = [at-store captains]  ++  [batch-accepted]  ++  [batch-rejected]

within each group:
   1. adjusted_seconds ascending          (lower wins)
   2. captains within 120 s of the FASTEST member of their group are a tie
   3. inside a tie, drivers.idle_since ascending  (longest wait first;
                                                   NULL — i.e. busy — sorts last)
   4. still equal → the filter's own order: distance_km ascending, then name
```

`adjusted_seconds` is the only number that matters:

```
idle captain:  adjusted = road(captain GPS → pickup)
busy captain:  adjusted = remaining(GPS → own drop-off)
                        + handoff buffer
                        + road(own drop-off → pickup)
```

A **busy captain is measured from where their current delivery ends**, not from
where their phone is. That single fact explains most "wrong" orderings.

The handoff buffer is keyed on the **current** order's payment method, not the
new one: COD **5 min**, prepaid **3 min**.

### The arithmetic, with the fake engine

`DISPATCH_ROUTING_ENGINE=fake` is set in `.env`, so distances are simulated and
fully predictable:

```
road_seconds = round( round(haversine_km, 2) × 1.3 ÷ 30 × 3600 )
             = round( haversine_km × 156 )
```

`1.3` is `detour_index`, `30` is `fake_routing_speed_kmh`. The haversine is
rounded to 2 decimals first — that rounding is why you should place captains on
clean distances.

**Switching to the real Google engine invalidates every expected value in this
document.** Keep the engine on `fake` for these cases.

---

## 2. Before you start

| Requirement | Why |
|---|---|
| **Redis must be running** | The candidate search reads Redis directly and does **not** fall back to SQL. With Redis down the endpoint returns **HTTP 500**, not a degraded list. |
| `DISPATCH_ROUTING_ENGINE=fake` | Makes ETAs predictable. |
| A queue worker is *not* needed | Suggestion is fully synchronous. |
| Admin token with `orders.assign` | The endpoint is behind `permission:orders.assign`. |
| Both headers on every call | `Accept: application/json` **and** `Accept-Language: en`. Missing either is a 401 *before* auth. |

Confirm the settings the expectations assume:

```bash
php artisan tinker --execute='echo json_encode(app(App\Services\Dispatch\DispatchSettings::class)->effective(), 128);'
```

| Setting | Expected | Effect if different |
|---|---|---|
| `max_active_orders` | 2 | changes who is `full` |
| `radius_steps_km` | 5, 8, 12 | changes who is found |
| `top_n` | 7 | changes the cut |
| `detour_index` | 1.3 | changes every ETA |
| `eta_estimate_speed_kmh` | 30 | changes every ETA |
| `gps_aging_min` / `gps_stale_min` | 1.5 / 3 | changes the GPS bands |
| `handoff_buffer_cod_min` / `_prepaid_min` | 5 / 3 | changes busy ETAs |
| `tie_break_band_min` | 2 | changes which rows tie |
| `max_batch_detour_min` | 6 | changes batch verdicts |
| `batch_rejected_policy` | `rank_lower` | `exclude` hides rejected captains |

### The one thing you cannot do through the API

**You cannot create an aged GPS point over HTTP.** `POST /api/driver/location`
ignores any `captured_at` you send and stamps the server's own arrival time, so
every ping is fresh. Aging and stale captains must be written straight to the
GPS store:

```php
app(App\Repositories\Dispatch\CaptainGpsStore::class)->record($driver->id,
    new App\DTOs\Dispatch\CaptainGpsData(
        lat: 33.5195231, lng: 36.2833649,
        accuracy: 8.0, speed_mps: null, heading: null,
        captured_at: Carbon\CarbonImmutable::now()->subSeconds(240),
    ));
app(App\Services\Dispatch\EffectiveLocationResolver::class)->refresh($driver);
```

`refresh()` is what puts the captain on the map the search actually reads. A
captain who never pinged and was never refreshed is invisible — they will not
even appear in `excluded_stale`.

---

## 3. The fixture

All coordinates are measured from **Store A — Umayyad Square, Damascus:
`33.5138, 36.2765`**, the same anchor the demo seeder uses.

### 3.1 The order under test — `QA-ORDER-1`

```json
{
  "customer_name": "QA Customer One",
  "customer_phone": "+963900000001",
  "pickup_address": "Store A, Umayyad Square, Damascus",
  "pickup_lat": 33.5138,
  "pickup_lng": 36.2765,
  "dropoff_address": "QA drop-off, 3 km north-east",
  "dropoff_lat": 33.5371640,
  "dropoff_lng": 36.2926840,
  "payment_method": "cash_on_delivery",
  "amount_to_collect": 120.00,
  "fee": 25.00,
  "currency": "SYP",
  "items": [{ "name": "QA parcel", "quantity": 1, "unit_price": 100.00 }]
}
```

`POST /api/dashboard/orders`. The drop-off is 3.0 km on a bearing of 30°.

> **`pickup_lat` / `pickup_lng` are optional on this endpoint but mandatory for
> dispatch.** An order created without them returns **422
> `pickup_location_required`** from the suggestion endpoint — it can never be
> assigned. That is Scenario H.

### 3.2 The fleet

Create all eleven. Names matter: the final tie-break is alphabetical, so keep
them distinct.

| Captain | Role | Lat | Lng | Online | Break | Carrying | GPS age | `idle_since` |
|---|---|---|---|---|---|---|---|---|
| **C-NEAR** | idle, closest | 33.5245919 | 36.2765000 | ✓ | — | 0 | 10 s | −25 min |
| **C-MID** | idle, middle | 33.5371824 | 36.2765000 | ✓ | — | 0 | 10 s | −20 min |
| **C-AGING** | aging GPS | 33.4889957 | 36.3062374 | ✓ | — | 0 | **120 s** | −15 min |
| **C-PREPAID** | busy, prepaid | *see 3.3* | | ✓ | — | 1 | 10 s | NULL |
| **C-COD** | busy, COD | *see 3.3* | | ✓ | — | 1 | 10 s | NULL |
| **C-FULL** | at capacity | 33.5137999 | 36.2711068 | ✓ | — | **2** | 10 s | NULL |
| **C-BREAK** | on break | 33.5176154 | 36.2719235 | ✓ | **✓** | 0 | 10 s | −5 min |
| **C-OFFLINE** | offline | 33.5093485 | 36.2711613 | **✗** | — | 0 | 10 s | −5 min |
| **C-STALE** | stale GPS | 33.5195231 | 36.2833649 | ✓ | — | 0 | **240 s** | −30 min |
| **C-DECLINED** | declined this order | 33.5205607 | 36.2794516 | ✓ | — | 0 | 10 s | −30 min |
| **C-OUTSIDE** | beyond every radius | 33.6307118 | 36.2765000 | ✓ | — | 0 | 10 s | −30 min |

Every captain must be `status = approved`, `is_active = true`, and have a
`driver_availabilities` row. **A captain with no availability row at all is
excluded** — the query uses `whereHas`, so a missing row is not "online = false",
it is invisible.

No vehicle is required. Dispatch never looks at vehicles.

### 3.3 The two busy captains

Each needs an in-progress order with drop-off coordinates. The drop-off is their
effective position.

| | C-PREPAID | C-COD |
|---|---|---|
| current order payment | `prepaid` | `cash_on_delivery` |
| current order status | `on_the_way` | `on_the_way` |
| its drop-off | **33.5137943, 36.3142525** (3.5 km @ 90°) | **33.4733305, 36.2765000** (4.5 km @ 180°) |
| captain's GPS | **33.5137942, 36.3196457** | **33.4697332, 36.2765000** |
| pickups | all collected (`picked_up_at` set) | all collected |

The phone sits *beyond* the drop-off on purpose: it proves the captain is being
measured from the drop-off, not the handset.

**Mark their pickups collected.** An uncollected pickup makes them a candidate
for at-store stacking, which pins them to rank 1 and wrecks Scenario A.

### 3.4 Setup script

Creating eleven captains through the registration API means 44 file uploads.
Everything in this section is already written as a seeder —
`database/seeders/QaSuggestionFleetSeeder.php`:

```bash
php artisan db:seed --class=QaSuggestionFleetSeeder
```

It builds the order `QA-SG-0001`, all eleven captains, the two carried orders,
the decline row, and `C-ATSTORE` with its uncollected pickup. It is idempotent —
keyed on phone and order number — so re-running resets rather than duplicates,
and it prints the expected ranking when it finishes.

> **The fixture decays, and fast. Re-stamp the positions immediately before you
> ask for a ranking:**
>
> ```bash
> php artisan tinker --execute='Database\Seeders\QaSuggestionFleetSeeder::refreshPositions();'
> ```
>
> GPS ages are relative to the moment of seeding. A captain seeded at 10 s is
> `aging` 90 seconds later, and C-AGING — placed at 120 s to sit in the middle
> band — goes `stale` after one more minute. Seed, get distracted for five
> minutes, and the list is missing captains for reasons that have nothing to do
> with the rule under test. This was observed, not theorised: a first run put
> C-AGING in `excluded_stale` and badged every other captain `aging`.
>
> `refreshPositions()` touches positions only, so orders, capacity, declines and
> availability survive a scenario already part-way through.

**It writes Redis, unlike `DispatchScenarioSeeder`.** That matters: the candidate
search reads the Redis effective map and has no SQL fallback, so a captain who
exists only in `driver_locations` is invisible to dispatch. Redis must be running.

To remove everything, including the Redis points (deleting rows alone leaves
positions answering geo searches for drivers that no longer exist):

```bash
php artisan tinker --execute='Database\Seeders\QaSuggestionFleetSeeder::purge();'
```

The equivalent by hand, if you would rather build a subset:

```php
use App\Models\{Driver, Order}; use App\Enums\Driver\DriverStatus;
use App\DTOs\Dispatch\CaptainGpsData; use Carbon\CarbonImmutable;
$gps = app(App\Repositories\Dispatch\CaptainGpsStore::class);
$eff = app(App\Services\Dispatch\EffectiveLocationResolver::class);

$make = function (string $name, float $lat, float $lng, bool $online = true,
                  bool $break = false, int $age = 10, ?int $idleMin = 20) use ($gps, $eff) {
    $d = Driver::factory()->create([
        'name' => $name, 'status' => DriverStatus::Approved, 'is_active' => true,
        'idle_since' => $idleMin === null ? null : now()->subMinutes($idleMin),
    ]);
    $d->availability()->updateOrCreate([], [
        'is_online' => $online, 'on_break' => $break, 'last_seen_at' => now(),
    ]);
    $gps->record($d->id, new CaptainGpsData(
        lat: $lat, lng: $lng, accuracy: 8.0, speed_mps: null, heading: null,
        captured_at: CarbonImmutable::now()->subSeconds($age),
    ));
    $eff->refresh($d->fresh());
    return $d;
};

$make('C-NEAR',     33.5245919, 36.2765000, idleMin: 25);
$make('C-MID',      33.5371824, 36.2765000, idleMin: 20);
$make('C-AGING',    33.4889957, 36.3062374, age: 120, idleMin: 15);
$make('C-BREAK',    33.5176154, 36.2719235, break: true, idleMin: 5);
$make('C-OFFLINE',  33.5093485, 36.2711613, online: false, idleMin: 5);
$make('C-STALE',    33.5195231, 36.2833649, age: 240, idleMin: 30);
$make('C-DECLINED', 33.5205607, 36.2794516, idleMin: 30);
$make('C-OUTSIDE',  33.6307118, 36.2765000, idleMin: 30);
```

Busy captains additionally need an order and `active_orders` set. `C-DECLINED`
needs an `order_declines` row for `QA-ORDER-1`.

After any change to a captain's position or availability, call
`$eff->refresh($driver)` again or the map keeps the old point.

---

## 4. Scenarios

### Scenario A — the baseline ranking

**Given** the full fleet and `QA-ORDER-1`.
**When** you call `GET /api/dashboard/orders/{uuid}/captains`.
**Then** exactly five captains come back, in this order:

| Rank | Captain | `adjusted_eta_min` | Arithmetic | `state` |
|---|---|---|---|---|
| 1 | C-NEAR | **3.1** | 1.20 km × 156 = 187 s | `idle` |
| 2 | C-MID | **6.8** | 2.60 km × 156 = 406 s | `idle` |
| 3 | C-AGING | **10.1** | 3.90 km × 156 = 608 s | `idle` |
| 4 | C-PREPAID | **13.4** | 78 + 180 + 546 = 804 s | `batchable` |
| 5 | C-COD | **17.7** | 62 + 300 + 702 = 1064 s | `batchable` |

Every gap exceeds the 120 s band, so `idle_since` never applies here and the
order is decided purely by ETA.

**And** the response envelope must read:

| Field | Expected | Why |
|---|---|---|
| `radius_km` | **12.0** | Only 5 candidates exist — fewer than `top_n` = 7 — so the search widens through 5 → 8 → 12 km and stops having exhausted the steps. |
| `search_expanded` | **true** | Same reason. |
| `excluded_stale` | `["<C-STALE uuid>"]` | One entry, UUID only. |
| `ranking_degraded` | `false` | |
| `no_candidates_reason` | `null` | |
| `cache_hit` | `false` first call | |

> **`radius_km: 12.0` is correct, not a bug.** The radius only stays at 5 km when
> seven or more candidates are found there. Most test fleets are smaller than
> that, so expect 12.0 almost always.

**And** these six must be absent:

| Captain | Why | Reported anywhere? |
|---|---|---|
| C-FULL | 2 in-progress = `max_active_orders` | **no** |
| C-BREAK | `on_break = true` | **no** |
| C-OFFLINE | `is_online = false` | **no** |
| C-STALE | GPS 240 s > 180 s | **yes** — `excluded_stale` |
| C-DECLINED | declined this order | **no** |
| C-OUTSIDE | 13 km > 12 km | **no** |

**This is a real finding to record:** only the stale case is reported. Five of
the six vanish with no explanation whatsoever. A dispatcher looking at a short
list cannot tell whether the others are busy, offline, or simply not there.

### Scenario B — closest is not fastest

Confirm C-COD, whose drop-off is 4.5 km out, ranks **below** C-MID at 2.6 km, and
that C-PREPAID at 3.5 km also ranks below it. Then check that each busy row's
components add up:

```
road_eta_min + remaining_delivery_eta_min + handoff_buffer_min ≈ adjusted_eta_min
```

For C-COD: `11.7 + 1.0 + 5.0 = 17.7`. Allow ±0.2 for rounding to one decimal.

`handoff_buffer_min` must be **5.0** for C-COD and **3.0** for C-PREPAID — from
the order each is *currently carrying*, not `QA-ORDER-1`. Swap the current
order's payment method and the buffer must follow; change `QA-ORDER-1`'s and
nothing must move.

### Scenario C — the fairness band

Move C-MID to **33.5281891, 36.2765000** (1.6 km). Its ETA becomes 250 s, which
is 63 s from C-NEAR's 187 s — inside the 120 s band, so they tie.

| `idle_since` | Expected rank 1 |
|---|---|
| C-NEAR −25 min, C-MID −20 min | **C-NEAR** (waited longer) |
| C-NEAR −5 min, C-MID −40 min | **C-MID**, despite the worse ETA |

Then push C-MID to **2.0 km** (312 s, a 125 s gap) and the tie must break: C-NEAR
first on ETA regardless of `idle_since`.

The band is measured from the **fastest member of the group, not the neighbour**,
so three captains at 187 / 300 / 400 s form the group {187, 300} and then {400} —
400 is 100 s from 300 but 213 s from the anchor.

A busy captain always has `idle_since = NULL` and therefore **loses every tie**
to any idle captain.

### Scenario D — GPS bands

One idle captain at 1.2 km; vary only the GPS age.

| Age | `gps_freshness` | In the list? | Extra reason |
|---|---|---|---|
| 10 s | `fresh` | yes | — |
| **89 s** | `fresh` | yes | — |
| **90 s** | `aging` | yes | "Position is a little old…" |
| **180 s** | `aging` | yes | same |
| **181 s** | `stale` | **no** — `excluded_stale` | — |
| never pinged | `missing` | **no** — and *not* in `excluded_stale` | — |

90 s and 180 s are both *inclusive* of `aging`. These are the boundaries worth
testing exactly.

**Then repeat with a busy captain** (give them an order with drop-off
coordinates) at 240 s. They must still be **ranked**, because a captain measured
from their drop-off is never excluded for stale GPS — the position being used is
not the handset's. Remove the drop-off coordinates from their current order and
the same captain flips to excluded.

### Scenario E — at-store stacking

Give a new captain **C-ATSTORE** an `assigned` order whose **pickup is
uncollected** at **33.5142428, 36.2765937** (50 m from Store A) and whose
drop-off is **33.5275776, 36.2903690** (1.09 km from `QA-ORDER-1`'s drop-off).

**Expected:** C-ATSTORE is **rank 1**, ahead of C-NEAR, with:

- `at_store_batch: true`
- `adjusted_eta_min`, `road_eta_min`, `remaining_delivery_eta_min`,
  `handoff_buffer_min` all **`null`** — no ETA is computed for a pinned captain
- `distance_km` = **2.0** — measured from their current drop-off, because an
  at-store captain is by definition busy and the geo filter found them there.
  It is only the pickup distance (0.05) when the radius search never saw them,
  which is the far-away case in note 1 below. Verified against a live run.
- reason "Already collecting another order from this store."

Boundaries — both are *inclusive*:

| Pickup distance | Drop-off separation | Pinned? |
|---|---|---|
| 50 m | 1.09 km | yes |
| **100 m** (33.5146857, 36.2766873) | 1.09 km | **yes** |
| **120 m** (33.5148628, 36.2767248) | 1.09 km | **no** |
| 50 m | **3.00 km** | yes |
| 50 m | **3.01 km** | no |

**Two behaviours here are worth challenging rather than accepting:**

1. **An at-store captain who declined this order is still pinned rank 1** if the
   radius search did not independently find them. The decline filter lives in the
   candidate finder; the at-store detector runs beside it and never consults it.
   Reproduce: put C-ATSTORE 13 km from the store — far outside every radius —
   give them a decline row for `QA-ORDER-1`, and keep the uncollected pickup at
   the store. They should still appear at rank 1.
2. **At-store pinning ignores `batch_rejected_policy = exclude`.** A captain
   whose batch verdict is rejected is dropped from the list under that policy —
   unless they are pinned, in which case they stay.

### Scenario F — batching verdicts

Against a busy captain, with `max_batch_detour_min = 6`:

| Condition | `batch_verdict` | Placement under `rank_lower` (default) |
|---|---|---|
| detour ≤ 360 s | `accepted` | normal ETA position |
| detour **= 360 s exactly** | `accepted` | normal — the limit is inclusive |
| detour > 360 s | `rejected_detour` | **below every accepted captain**, whatever its ETA |
| arrival after `promised_at` | `rejected_promise` | same |
| captain is idle | `not_applicable` | normal |

Then set `DISPATCH_BATCH_REJECTED_POLICY=exclude` and confirm rejected captains
disappear entirely.

> **`promised_at` cannot be set from the dashboard.** `POST /api/dashboard/orders`
> has no such field, so a dashboard-created order can *never* produce
> `rejected_promise` — the rule silently always passes. To test it you must ingest
> the order through `POST /api/integration/v1/orders`, which requires a store
> client, a bearer token and an HMAC signature. Plan for that setup or record the
> rule as untestable from the dashboard.

### Scenario G — radius expansion and the Top-N cut

| Fleet | Expected `radius_km` | `search_expanded` |
|---|---|---|
| 7+ eligible within 5 km | **5.0** | false |
| 6 within 5 km, 1 more at 6.5 km | **8.0** | true |
| 5 as in Scenario A | **12.0** | true |
| a captain at 12.5 km | never found | — |

Useful points on a due-north bearing: 4.9 km = `33.5578668, 36.2765000`;
5.1 km = `33.5596654, 36.2765000`; 12.5 km = `33.6262152, 36.2765000`.

**The Top-N cut is silent.** With eight eligible captains inside 5 km, the
eighth — sorted by distance, then name — is dropped and reported nowhere. Create
eight and confirm only seven return.

### Scenario H — failure paths

| Setup | Expected |
|---|---|
| Order created with no `pickup_lat`/`pickup_lng` and no pickup rows | **422**, `order.messages.pickup_location_required` |
| No eligible captain at all | **200**, `candidates: []`, `no_candidates_reason` = "No captain can take this order right now: nobody eligible was found near the pickup." |
| Every captain declined | **200**, same sentence — indistinguishable from the above |
| Every captain stale | **200**, same sentence, but `excluded_stale` is populated |
| **Redis stopped** | **HTTP 500.** Not a degraded list. |

Routing outage — bind a failing engine or point `DISPATCH_GOOGLE_MAPS_KEY` at
nothing with `DISPATCH_ROUTING_ENGINE=google`:

| Failure | `ranking_degraded` | `degraded_reason` |
|---|---|---|
| timeout | true | `routing_timeout` |
| HTTP error / bad key | true | `routing_error` |
| some cells missing | true | `routing_partial` |
| engine not configured | true | **`routing_error`** |

The list still returns, every candidate carries `eta_estimated: true`, and the
ranking degenerates to nearest-first. Note the last row: `routing_not_configured`
exists in the enum but **is never produced** — a missing key reports
`routing_error`. Worth a defect note for diagnosability.

### Scenario I — capacity drift

The suggestion list counts in-progress orders; assignment reads the
`drivers.active_orders` **column**. They can disagree.

```sql
UPDATE drivers SET active_orders = 2 WHERE name = 'C-NEAR';
-- C-NEAR still has zero in-progress orders
```

**Expected:** C-NEAR still appears at **rank 1** (counted = 0 → `idle`), and
assigning to them fails with **409** and reason `at_capacity`. This is the drift
`CapacityReconciler` exists to repair; `php artisan` reconciliation should
correct the column back to 0.

This is a genuine inconsistency, not a test artefact — worth filing.

### Scenario J — assignment round trip

Take `suggestion_uuid` and a candidate's `rank` and
`PATCH /api/dashboard/orders/{uuid}/assign`.

| Case | Expected |
|---|---|
| rank 1, eligible | 200, order `assigned`, captain's `active_orders` +1 |
| a captain at capacity | **409**, reason `at_capacity` |
| two dispatchers at once | one wins, the other **409** `locked` (30 s lock) |
| assign, then captain declines | order back to `pending`, capacity released, and that captain is **permanently excluded from this order** |

**Decline exclusion has no time window.** It is per-order and forever — there is
no cooldown to wait out. A captain who declined order A is fully eligible for
order B immediately.

---

## 5. Boundary values

The single-value cases most likely to be wrong:

| Quantity | Pass | Fail |
|---|---|---|
| GPS age | 89 s fresh, 90 s aging, 180 s aging | 181 s stale |
| At-store pickup | 100 m | 101 m |
| At-store drop-off gap | 3.00 km | 3.01 km |
| Batch detour | 360 s accepted | 361 s rejected |
| Active orders | 1 → `batchable` | 2 → `full` |
| Tie band | 120 s = tie | 121 s = not a tie |
| Radius | 12.0 km found | 12.1 km not found |
| Candidates found at 5 km | 7 → no expansion | 6 → expands |

---

## 6. Known behaviour — do not file these as bugs

- No weighted score; no ratings, vehicle type or acceptance rate anywhere.
- `radius_km: 12.0` on a small fleet is correct.
- At-store captains have `null` ETAs by design.
- A busy captain with stale GPS is ranked, not excluded.
- Rejected-batch captains appear *below* accepted ones by default rather than
  being hidden.

## 7. Real gaps worth filing

Found while deriving this plan. Each is reproducible with the scenarios above.

1. **Silent exclusions (Scenario A).** Only GPS-stale is reported. Offline, on
   break, full, declined, out-of-radius and Top-N-truncated captains disappear
   with no signal. The dispatcher cannot distinguish "nobody is near" from
   "everyone declined".
2. **One generic `no_candidates_reason` (H).** The same sentence covers every
   cause.
3. **At-store pinning bypasses the decline filter (E).** A captain who refused
   the order can be suggested first.
4. **At-store pinning bypasses `batch_rejected_policy = exclude` (E).**
5. **Capacity drift is visible to the dispatcher (I).** A captain can be offered
   and then refuse assignment with 409.
6. **Redis down = HTTP 500 (H).** The only unguarded dependency; everything else
   degrades.
7. **`routing_not_configured` is unreachable (H).** A missing key is reported as
   `routing_error`.
8. **`promised_at` is unreachable from the dashboard (F).** `rejected_promise`
   cannot fire for dashboard-created orders.
9. **The routing-matrix cache is not invalidated by movement.** It is keyed on a
   ~150 m geohash of the pickup and the captain set, with a 75 s TTL, and is only
   cleared on an order status change. Within that window a captain who has driven
   2 km still ranks on their old road time. Reproduce: take a list, move a
   captain, re-request inside 75 s, and watch `cache_hit: true` with an unchanged
   ETA.

---

## 8. Reference

| Concern | File |
|---|---|
| Ordering rules | `app/Services/Dispatch/Ranker.php` |
| Pipeline and response | `app/Services/Dispatch/SuggestionService.php` |
| Filtering, radius, stale split | `app/Services/Dispatch/CandidateFinder.php` |
| Adjusted-ETA formula | `app/Services/Dispatch/AdjustedEtaCalculator.php` |
| Handoff buffer | `app/Services/Dispatch/HandoffBuffer.php` |
| Batch verdicts | `app/Services/Dispatch/BatchCompatibilityService.php` |
| At-store pin | `app/Services/Dispatch/AtStoreStackingDetector.php` |
| Eligibility SQL | `app/Repositories/Driver/DriverRepository.php` |
| Defaults | `config/dispatch.php` |

A ready-made eight-captain fixture already exists —
`php artisan dispatch:demo --fresh` seeds `DispatchScenarioSeeder` (captains
C1–C8, orders `ORD-990001/2`) and narrates the ranking. It is a faster smoke
test than building the fleet by hand; this plan is the exhaustive version.
