# Feature 10 — Wrapped Orders (collect, wrap, then deliver)

## What this feature does

Some stores have their goods wrapped before delivery — sometimes at our own premises,
sometimes at a third party's shop. Such an order is three stages, not one:

1. a captain collects from **one or more suppliers**;
2. they leave everything at the **wrapping location**, and **that captain is then free**;
3. later, a **different captain** collects the finished parcel and delivers it.

A store asks for this with one boolean on the order it pushes. Where the wrapping happens
comes from the store's own configuration, never from the order.

**An order that does not need wrapping is completely untouched by any of this** — one row,
`leg` null, the same flow as before.

## The shape: two linked orders

| | Collection leg | Delivery leg |
| --- | --- | --- |
| `leg` | `collection` | `delivery` |
| `parent_order_id` | the delivery leg | null |
| Pickups | every supplier | the wrapping location |
| Drop-off | the wrapping location | the customer |
| `store_client_id` | **null** | the store |
| `external_order_id` | **null** | the store's reference |
| Customer | the wrapping shop's name | the real customer |
| Money | `fee` 0, no `amount_to_collect`, prepaid | the real payment |
| `order_number` | `ORD-000042-C` | `ORD-000042` |
| Webhooks | **none** | the usual ones |

The store is answered with, and only ever hears about, the **delivery leg**.

### Why two rows rather than one order with a third stop

Because the first captain is **released** halfway through. Freeing a captain mid-order
means handing a capacity slot back on a non-terminal status, and every "is this captain
busy?" answer in the system is derived from the in-progress statuses —
`countInProgressFor()`, `currentOrderFor()`, `capacityDrift()` and the dashboard's active
tile would all become wrong at once. The lifecycle would also stop being linear, and
`driver_id`, `picked_up_at`, `accepted_at` and `amount_paid` are single columns that would
each need to hold two captains' values.

Two legs keep every invariant literally true: one captain, one slot, one pickup
confirmation and one linear timeline per row. Each leg is also exactly the shape dispatch
already handles — stops in, one drop-off out — so **nothing in the dispatch engine
changed**.

## No status of its own — the block is derived

The delivery leg is written as an ordinary `pending` order and left there. What stops a
captain being sent is **a fact about the other row**: assignment is refused until every
collection leg has reached `delivered`.

```
POST /api/dashboard/orders/{uuid}/assign
    → 409  "The goods for this order have not arrived at the wrapping
            location yet — collection ORD-000042-C is Pending.
            A captain cannot be sent until they do."
```

Checked in `AssignmentService::assign()`, **before** the capacity lock and before any slot is
taken — the same place the freelance check sits, and for the same reason: this is a
dispatcher's mistake, not a race, and it should not briefly make a captain look full.

### Why derived rather than stored

Whether the parcel is ready is already recorded, completely, by the collection leg's status.
A second copy on the delivery row — a status, a flag, a timestamp — would be free to drift
from it, and would need somebody to press a button to restate what the system already knew.
Asking the question at the one moment the answer changes anything means the two can never
disagree.

A **failed** collection leg blocks too, and permanently: a failure is the clearest statement
there is that the goods never arrived. The back office is told separately (below) so somebody
can send another captain for them or call the order off.

### What this buys elsewhere

Because the delivery leg is just `pending`, nothing else in the system had to learn a new
state. `StoreOrderEditPolicy` needed no new entry in its progression, `StoreWebhookEvent`
needed no new arm, the captain's stepper needed no new case, and the status ENUMs are back to
the eight values they had before.

## Configuring a store

Four columns on `store_clients`, set together or not at all:

| Column | |
| --- | --- |
| `wrapping_name` | the shop's name, optional |
| `wrapping_address` | required with the coordinates |
| `wrapping_lat` / `wrapping_lng` | required with the address |

Edited through `PUT /api/dashboard/store-clients/{uuid}` and readable as a nested
`wrapping` object with a `configured` flag. A lone coordinate is refused with 422 — a
wrapping shop we cannot put on a map is not a place we can send anyone.

The location is **snapshotted** onto the two legs when the order is created, so a store
that later moves its wrapping shop does not rewrite where past orders went.

## Sending a wrapped order

```
POST /api/integration/v1/orders
{ ..., "needs_wrapping": true }
```

A flag, never an address. Sending `true` from a store with no wrapping location configured
is **422** naming the store's configuration — refused at the door rather than accepted
into a journey with nowhere to go.

## What the rest of the system sees

- **Commercial figures exclude collection legs.** `countByStatus()`, `countByPaymentMethod()`,
  `dailyCreated()`, `dailyDelivered()` and `dailyRevenue()` all filter
  `whereNull('parent_order_id')`: a wrapped delivery is two journeys but **one sale**.
- **The back office's order list does not.** A dispatcher has to see the collection leg,
  because a captain is out driving it. `OrderResource` exposes `leg` and `leg_label` so a
  screen can tell the two rows apart instead of showing what looks like a duplicate.
- **The store's own list does not contain it either** — the collection leg has no
  `store_client_id`, so `paginateForStoreClient()` cannot return it and
  `StoreWebhookService::queue()` answers null for it.
- **Cancelling either leg cancels the other**, in `OrderService::cancel()` — the one door
  both the back office and the store come through. The cascade terminates because the
  repository only offers legs that are still live.
- **A store may still edit a blocked order**, because as far as the edit policy is
  concerned it is an ordinary pending order — the quiet benefit of not giving it a status.

## Migrations

| Migration | |
| --- | --- |
| `2026_09_27_110000_add_wrapping_to_store_clients_table` | the four location columns |
| `2026_09_27_120100_add_wrapping_legs_to_orders_table` | `parent_order_id`, `leg`, indexes |
| `2026_09_27_130000_remove_awaiting_wrap_status_from_orders` | withdraws the status again |

The first attempt added an `awaiting_wrap` status (`..._120000_...`). It worked, but it
stored a fact the collection leg already held, so it was withdrawn in favour of the derived
rule. Rows holding it become `pending`, which under the new rule is exactly what they were:
pending, and blocked until their goods arrive.

## Nothing is allowed to get stuck

A blocked delivery sits in plain `pending` and looks, from its status alone, exactly like
an order a dispatcher could hand out right now. Two things make sure the difference is
visible rather than discovered by clicking.

### The blocked list

```
GET /api/dashboard/orders/awaiting-collection
```

Every delivery that cannot be assigned yet, **oldest first** — the opposite of every other
list here, and the point of the screen: the order blocked longest is the thing that needs
looking at. Needs `orders.view`.

Each row carries `can_be_assigned`, `assignment_blocked_reason` and `collection_legs` with
their status and any failure reason. That is what separates *waiting* from *waiting for
goods that will never arrive*.

The same three fields appear on the order detail. They are derived from the loaded
collection legs, so they are **absent** rather than wrongly `true` on reads that do not
carry them.

### A collection that fails raises its parent

If the first captain cannot collect, the collection leg ends in `delivery_failed`. Its
parent stays `pending` and blocked — the delivery itself has not failed, and the customer
may still get their order once somebody fetches the goods — but nothing will ever unblock
it on its own.

`RaiseStrandedWrappingOrder` notifies the back office, naming **the customer's order
number** and the reason, and pointing at the blocked list. It does **not** cancel:
whether to send another captain or call the order off needs somebody who can ring the
suppliers, and per the product decision that is never automatic.

The generic `NotifyAdminsDeliveryFailed` skips collection legs, so one failure raises one
notification — the useful one. Without that guard the more prominent of the two would read
*"Order ORD-000042-C failed during delivery"*: a number the store has never seen, for a
delivery to a wrapping shop that was never the customer's.

## Still to come

- **Wrapper self-service.** A wrapping shop currently has no way to tell us a parcel is
  ready; the back office does it on their behalf. A signed one-off link per order
  (`URL::temporarySignedRoute`) is the shape to use — the store portal is tenanted to a
  `StoreClient`, and a third-party wrapper must not see another tenant's orders.
- **`order.wrapping_started` / `order.wrapping_finished`** as a versioned contract change,
  if stores ask to be told.
- **Publishing `additional_pickups` and `items.*.pickup_index`** in the integration
  document. Both work; the published contract is still single-pickup, so they are
  undocumented on purpose.

## Tests

`tests/Feature/Order/WrappedOrderTest.php` — both legs and their link, the customer and
money on the right one, the suppliers and the wrapper on the right stops, a forced
mid-way failure leaving nothing, the untouched single-row path, the 422 for an
unconfigured store, the single commercial count, the webhook silence, the release
assignment guard from both sides — refused while the goods are still coming, allowed the
moment the collection leg is delivered, refused for good when it failed, and untouched for
an ordinary order — the cancellation cascade in both directions, the blocked list's
ordering and contents, and a failed collection raising its parent exactly once without
cancelling it.
