# Feature 04 — Manual Order Assignment & Nearest-First Captain Suggestion (MVP)

## What this feature does

The back office creates delivery orders and, per order, gets a ranked list of the idle
captains closest to the pickup point. The **dispatcher picks** who carries the order and
confirms it — the MVP never auto-assigns. Orders are created through the dashboard API as
*fake* orders until the live third-party platform integration starts feeding the system;
the create endpoint writes the same `orders` table the feed will write, so the handover is
a matter of pointing the feed at the same writer.

## Endpoints (all under `/api/dashboard`, `auth:admin`)

| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| GET | `/api/dashboard/orders` | `orders.view` | List, paginated, filterable by `status` and `search` (order number / customer / phone). |
| POST | `/api/dashboard/orders` | `orders.create` | Create a fake order (pending). Optional `items[]`. |
| GET | `/api/dashboard/orders/{uuid}` | `orders.view` | Detail: items, carrying captain, timeline. |
| GET | `/api/dashboard/orders/{uuid}/captains` | `orders.assign` | Idle captains, nearest-first. |
| PATCH | `/api/dashboard/orders/{uuid}/assign` | `orders.assign` | Assign the named captain. |

Route identifiers are UUIDs (`whereUuid`), matching the `Order` model's `HasUuids`.

## The captain-eligibility rule (one rule, one writer)

A captain may be offered or assigned an order only if **all** of these hold:

1. `status = approved` — an approved account is the only one that may sign in.
2. `is_active = true` — the activation switch is on.
3. `driver_availabilities.is_online = true` — on duty right now.
4. has a `driver_locations` row — a reported position exists to rank by.
5. holds **no in-progress order** — i.e. no `orders` row in
   `assigned` / `picked_up` / `on_the_way` (`OrderStatus::inProgress()`).

Everything else is excluded from suggestions **and** refused by the assignment itself,
because the check lives once inside `OrderService::assign()`. This keeps a captain from
being assigned through a different door (future auto-dispatch included).

### Behavior details

- Order with no pickup coordinates: suggestions answer **422** (`pickup_location_required`)
  — there is nothing to measure from.
- Assigning a busy / offline / inactive / unapproved captain: **422**
  (`captain_unavailable`).
- Assigning an order that is not `pending`: **422** via the order state machine
  (`invalid_transition`). The lifecycle is unchanged: Pending → Assigned → PickedUp →
  OnTheWay → Delivered/DeliveryFailed.
- A captain's token on these routes: **401** (`auth:admin` refuses it before any
  permission is consulted).
- An admin without `orders.assign` (or whose role lacks the permission): **403** on
  `/captains` and `/assign`.
- `driver_uuid` that does not exist: **404**.

## Distance & ranking

Distance is the straight-line ("as the crow flies") haversine distance computed in PHP
in `App\Services\Order\DistanceService`, rounded to two decimals and emitted as
`distance_km` per suggestion. Doing it in PHP keeps results identical across MySQL and the
SQLite test database; the ranking sorts ascending by distance, ties by captain name so the
list is deterministic. For a fleet large enough to outgrow a PHP sort (thousands of online
captains), this service is the single place to swap in a spatial index.

## Assignment flow

1. Dispatcher calls `/captains`, gets `[{ distance_km, captain: DriverResource }]`.
2. Dispatcher calls `/assign` with `{ driver_uuid }`.
3. `OrderService::assign()` checks eligibility, then the state machine moves
   Pending → Assigned inside a transaction; `driver_id` is set and a `order_status_history`
   row is appended naming the back-office admin as the actor.
4. `OrderAssigned` fires; the queued `NotifyCaptainOfAssignment` listener pushes
   `NewOrderAssignedNotification` (FCM `type=order_assigned`, `order_uuid`) to the
   captain's devices. Like the review notification, it delivers only when the captain has
   registered an FCM device token (Firebase credentials stay unconfigured for now).

## Permissions

New `AdminPermission` cases (auto-seeded by `PermissionSeeder`):
`orders.view`, `orders.create`, `orders.assign`.

Default role grants: **operations-manager** → all three; **support** → `orders.view`
only. `super-admin` bypasses permission checks by design (gate in `AppServiceProvider`).

## Data & seeds (demo-ready)

- `Order` now carries pickup/dropoff lat/lng in the factory and in `OrderSeeder`
  (ORD-100001..100004, Riyadh coords).
- `DriverSeeder` puts every approved, active sample captain online with a reported
  position spread across Riyadh, so the assignment screen has a list to offer.
- A fourth approved+active sample captain (`+966500000008`, Abdulaziz, own GMC Terrain)
  is added so there is someone free to suggest right after seeding — Saeed and Nasser
  hold the seeded active orders, which is exactly the busy rule working.

## Where the code lives

- Service/rules: `app/Services/Order/{OrderService,DistanceService,OrderStateMachine}.php`
- Repositories: `app/Repositories/Order/OrderRepository.php`,
  `app/Repositories/Driver/DriverRepository.php` (`suggestableCaptains()`)
- Controller/resources/requests: `app/Http/Controllers/Dashboard/Order/`,
  `app/Http/Resources/Order/`, `app/Http/Requests/Dashboard/Order/`
- Routes: `routes/order-management.php` (required from `routes/api.php`)
- Events/notifications: `app/Events/Order/OrderAssigned.php`,
  `app/Listeners/Order/NotifyCaptainOfAssignment.php`,
  `app/Notifications/NewOrderAssignedNotification.php`
- Enum helper: `OrderStatus::inProgress()` (busy definition)

## Tests

- `tests/Unit/Services/Order/DistanceServiceTest.php` — known distances (Riyadh→Jeddah
  ≈ 845 km, 1° of latitude ≈ 111 km, symmetry, rounding, zero).
- `tests/Feature/Order/OrderAssignmentApiTest.php` — nearest-first ranking + distances,
  missing pickup coords 422, offline / unlocated / inactive / not-approved / busy
  exclusions, assignment + timeline + notification, busy + non-pending refusal, 404
  unknown captain, 401 captain token, 403 missing permission, create-with-items + pair
  validation, list filters, detail.

## Turned off on purpose (out of scope for the MVP)

- No automatic/round-robin dispatch — the dispatcher always confirms.
- No distance-based *radius* filter — the whole idle roster is suggested, ranked.
- No live third-party order feed — fake orders until the integration lands.
- No geo fence / ETA / route optimization; nearest is straight-line distance only.