# Captain app: what changed in the API (fulfillment work, October 2026)

For the team building the captain mobile app. Everything below is live in the Logistic API on `dev`; the OpenAPI document
(`/api/documentation/captain/docs`) has the full request and response shapes. **Nothing here removes or renames an existing field or
endpoint.** The one change that can break an existing app build is marked **BREAKING** (section 2).

## 1. New things the app can do

| Screen / action | Endpoint | Notes |
|---|---|---|
| Per-item counts at a supplier | `PATCH /api/driver/orders/{uuid}/pickups/{pickup}/collected` with `items: [{uuid, quantity_collected, unit_price_paid?, note?, reason_code?}]` | Optional. `items[].uuid` is from `pickups[].items[]`. A line with `quantity_collected: 0` must carry a reason (section 2a). |
| "I could not collect from this supplier" | `PATCH /api/driver/orders/{uuid}/pickups/{pickup}/failed` `{reason}` | Only while the order is `accepted`. The supplier leaves your route; the office decides what happens. |

## 2. A reason is required when the pickup differs from the order (**BREAKING**)

**This rule is a server setting (`DISPATCH_REQUIRE_COLLECTION_REASON`), off by default until the app asks for a reason.** With it off, a differing pickup is accepted without a reason and a reason that is sent is still kept. With it on:

A count or a payment that is not what was ordered is refused with **422** (nothing recorded) unless `note` says why. This applies to:

- `PATCH .../pickups/{pickup}/collected` and `PATCH .../picked-up`: send `note` (max 255 characters) whenever
  - `items_collected` is not `items_expected` (short, surplus, or a line left out of `items[]`), or
  - `amount_paid` is not `expected_goods_cost` (nothing paid, `0`, never needs a reason), or
  - any line's `unit_price_paid` differs from its `unit_price`.
- **A changed price needs its own reason on that line** (`items[].note`); a stop-level `note` does not excuse it. The 422 message names the item.
- A pickup that matches the order, and a one-tap confirmation that sends nothing, need no reason.

An app build that confirms a short count or a different payment without `note` gets the 422 until it adds a reason field.

## 2a. A product taken none of must say why (**BREAKING**, always on)

The captain may enter `quantity_collected: 0` for an unavailable product. The line is cancelled and left out of the order total and the invoice; lines above zero are priced as before. Unlike section 2, this is **not** behind a setting: a zero line without a reason is refused with **422** naming the product, and nothing in the request is recorded.

Send `items[].reason_code`, `items[].note`, or both:

| `reason_code` | Meaning |
|---|---|
| `out_of_stock` | Not available |
| `poor_quality` | Poor quality |
| `poor_packaging` | Poor packaging from the supplier |
| `partial_unacceptable` | Only some was available, and not good enough in quality or packaging |
| `other` | Another reason; **requires `note`** |

A stop-level `note` does not excuse a line. The reason is stored on the line and returned as `items[].collection_reason` (`{code, label}`, label translated, or `null`) beside `collection_note`.

## 3. New fields on the order you already read

On `CaptainOrder`:
- `collection_note`: the reason given when the order was collected differently from how it was ordered (null when it matched).
- On `pickups[]`: `phone` (the number to call at that counter: the branch the store prepared the order at, when the store sent one; show it as a call button, `null` when absent; the pickup's `store_name`, `address`, `lat` and `lng` are already the branch's), `collection_note`, `failed`, `failed_at`, `failure_reason`, `retry_count`. A `failed` supplier is off your route
  until the office sends you back.
- On every line (`items[]`, also under `pickups[].items[]`): `quantity_collected`, `unit_price_paid`, `collection_note`, `collection_reason`,
  `quantity_ordered` (what was asked for, once the office reduced the line), `missing_quantity`, `shortage_reason`, and
  `cancelled` / `cancelled_at` / `cancel_reason`.

**A cancelled line is still listed, marked `cancelled: true`. Do not ask the supplier for it and do not count it.** A reduced line has
`quantity` = what is coming and `quantity_ordered` = what the customer first asked for.

## 4. Behaviour the app should expect

- A supplier whose lines were all called off is stamped collected by the office (`items_collected: 0`, no captain): skip it.
- If you were at the last supplier you had left and the office empties it, the order moves to `picked_up` without you pressing anything.
- A wrapped order's collection can be split, one supplier per captain: you may be assigned a collection leg with a single supplier
  (`leg: "collection"`), exactly like any other order.
- **Pay:** a freelance captain may earn a flat amount per collection leg delivered when the company sets `COLLECTION_LEG_EARNING`
  (zero until then). It appears as a `delivery_earning` entry in the ledger for that leg.

## 5. Suggested screens, in the order they would be used

1. Supplier stop: list `items[]` (skip `cancelled`), a count stepper per line, an optional "price differs" field, a note box that
   becomes required the moment the count or a price differs.
2. "Could not collect": reason, then the stop greys out.

## 6. Handing goods over at the wrapping shop (new, optional)

`PATCH /api/driver/orders/{uuid}/delivered` on a **collection** (`leg: "collection"`) accepts `items_handed_over`: how many pieces you handed over at the wrapping shop.
If it differs from what you collected, the order is flagged (`handoff_mismatch: true`) and the office is told at once, so say it while you are still there. Omitting it
changes nothing. Both fields come back on the order.
