# Order Dispatch / Fulfillment Push

## What this is

When an order is marked as **processed** in Cydekick, tracking and carrier details are automatically pushed back to the originating sales channel (Shopify or eBay). This closes the dispatch loop — the customer gets a fulfillment notification from the channel, and the channel records the shipment.

## Architecture

### Core interface

`plugins/webkul/channels/src/Contracts/FulfillableChannelDriver.php`

```php
interface FulfillableChannelDriver
{
    public function fulfillOrder(
        string  $externalOrderId,
        ?string $trackingNumber  = null,
        ?string $carrier         = null,  // Cydekick slug, e.g. 'royal_mail'
        bool    $notifyCustomer  = true,
    ): bool;
}
```

Any channel driver that supports fulfillment push implements this interface. Currently: `ShopifyClient`, `EbayClient`, `AmazonClient` (added 2026-09-28). Future courier integrations should also implement it.

`AmazonClient::fulfillOrder()` calls the Orders API v0 `shipmentConfirmation` endpoint. It fetches the order's item IDs live (`getOrderItems()`), sends `carrierCode: 'Other'` plus a `carrierName` (mapped from the Cydekick slug, e.g. `royal_mail` → "Royal Mail" — Amazon doesn't recognise most UK couriers as a proper `carrierCode`), and is idempotent: an order Amazon already shows as `Shipped` is treated as success without calling the API again.

### Service

`plugins/webkul/channel-orders/src/Services/OrderFulfillmentService.php`

Single entry point:

```php
app(OrderFulfillmentService::class)->dispatch(Order $order, bool $force = false): bool
```

- Returns `false` (silently) when: no `external_id`, no channel, channel not connected, driver doesn't implement `FulfillableChannelDriver`, or already dispatched (unless `$force = true`)
- Throws on API error — callers should catch
- Stamps `dispatched_at = now()` on success (using `withoutEvents`)

### Trigger — automatic

`OrderObserver::updating()` — fires when `status` changes `open → processed`:

```
processOrderDeductions()   ← stock deducted
dispatchChannelSync()      ← stock pushed to channel listings
dispatchFulfillment()      ← NEW — calls OrderFulfillmentService::dispatch()
```

Also fires in `OrderObserver::created()` for orders imported already in `processed` state.

**API errors are caught and logged — they do NOT block the order save.** The order is still marked processed even if the channel API call fails.

### Trigger — manual (Resend)

`ViewOrder` header action: **"Resend Dispatch"**

- Visible when: `status === processed` AND `external_id` is set
- Calls `OrderFulfillmentService::dispatch($order, force: true)`
- Use when: initial dispatch failed, tracking was corrected after processing, or testing

## Carrier code mapping

Cydekick stores carrier as a slug. Each driver maps it to the platform's expected format:

| Cydekick slug | Shopify display name | eBay carrier code | Amazon |
|---|---|---|---|
| `royal_mail` | Royal Mail | `ROYAL_MAIL` | `Other` + carrierName "Royal Mail" |
| `dpd` | DPD | `DPD` | `Other` + carrierName "DPD" |
| `dx` | DX Freight | `DX_FREIGHT` | `Other` + carrierName "DX" |
| `evri` | Evri | `EVRI` | `Other` + carrierName "Evri" |
| `yodel` | Yodel | `YODEL` | `Other` + carrierName "Yodel" |
| `customer_collection` | *(skipped — no API call)* | *(skipped)* | *(skipped)* |
| `local_delivery` | *(skipped)* | *(skipped)* | *(skipped)* |

Amazon carrier names live in `AmazonClient::CARRIER_NAMES`. All three drivers now first normalise the carrier string (lower-case, spaces/dashes → underscore) before matching, so "Royal Mail" typed on an order and `royal_mail` picked from a dropdown both match the same table entry.

## eBay specifics

`EbayClient::fulfillOrder()` makes **two API calls**:

1. `GET /sell/fulfillment/v1/order/{orderId}` — fetches live line item IDs (not stored locally)
2. `POST /sell/fulfillment/v1/order/{orderId}/shipping_fulfillment` — pushes dispatch with line items + tracking

The `lineItemId` values are fetched fresh each time because they're not stored in `channels_ebay_sku_mappings`. This adds one extra API call but avoids a migration + backfill.

## Database

`channel_orders_orders.dispatched_at` — nullable timestamp.

- `null` = not yet dispatched (or channel doesn't support fulfillment push)
- Populated = dispatch was successfully sent to the channel
- Does NOT mean the order was ever marked processed (`processed_at` is the separate stamp)

## Two dispatch paths

### Path 1 — Dispatched in Shopify/eBay (current workflow)

Order fulfilled externally in Shopify (via Shopify's own shipping integration):

1. Shopify fires `orders/fulfilled` webhook → `ShopifyFulfillmentsController` sets `status = processed`, saves tracking, **stamps `dispatched_at = now()`**
2. Observer fires → stock deducted → `OrderFulfillmentService::dispatch()` is called
3. Idempotency guard: `dispatched_at` is already set → **skipped, no push-back** ✓
4. OR: `SyncShopifyOrders` reconciliation notices order is fulfilled → same: stamps `dispatched_at` → no push-back

Pre-fulfilled orders at import time: `ShopifyOrderImporter` and `EbayOrderImporter` stamp `dispatched_at` when `status = processed` at creation (via `withoutEvents`, so no observer fires anyway). Note: the `orders/create` webhook always delivers an `open` order — the `$isFulfilled` branch in `ShopifyOrderImporter` only fires when `SyncShopifyOrders` imports a new order that was already fulfilled before the sync ran (a webhook-miss scenario).

### Path 2 — Dispatched from Cydekick (future workflow with own courier integration)

1. Order imports as `open`
2. Pick, pack, create label in Cydekick
3. Edit order → set **Shipping Carrier** + **Tracking Number** → **Mark as Processed**
4. Observer fires → stock deducted → `OrderFulfillmentService::dispatch()` called
5. `dispatched_at` is null → proceeds → pushes tracking to channel → stamps `dispatched_at`
6. If push fails: error logged, order still processed, use **Resend Dispatch** button to retry

### The `dispatched_at` guard

The key that separates the two paths. Set by:
- **Inbound (Path 1)**: `ShopifyFulfillmentsController`, `SyncShopifyOrders`, importers
- **Outbound (Path 2)**: `OrderFulfillmentService` on successful push

`force: true` on `dispatch()` bypasses this guard — used only by the Resend Dispatch button.

## Marketplace orders hosted in Shopify (2026-09-28)

Amazon and eBay orders can be pushed into Shopify instead of imported directly (`amazonOrderDestination()` /
`ebayOrderDestination() === 'shopify'`, see `amazon-order-sync.md`). Dispatching that order only told Shopify —
the marketplace itself never heard about it, so an Amazon order fulfilled via Shopify stayed "Unshipped" in
Seller Central forever. Same gap existed for eBay: `EbayClient::fulfillOrder()` worked, but `OrderFulfillmentService`
called `$channel->driver()`, which throws for an eBay channel (eBay has no `ChannelDriverInterface` implementation —
see `ebayDriver()`), so it silently never fired.

**Fix:** `OrderFulfillmentService::dispatch()` now does two independent pushes:

1. **To the order's own channel** (unchanged, as above) — `dispatchToOwnChannel()`.
2. **To the originating marketplace**, if the order's `source_name` is `ebay`/`amazon` — `dispatchToMarketplace()` (also public, so it can be called directly by a button; see below).

```
channel_orders_orders.marketplace_order_id      — the eBay/Amazon order id (e.g. Amazon "204-8832654-4643545")
channel_orders_orders.marketplace_dispatched_at — idempotency guard for the marketplace push, separate from dispatched_at
```

`marketplace_order_id` is captured by `ShopifyOrderImporter::resolveMarketplaceOrderId()` from the `ebay_order_id`/
`amazon_order_id` note attribute both pushers already write (`EbayToShopifyPusher`, `AmazonToShopifyPusher`). **Orders
imported before this was added have no value** — `OrderFulfillmentService::ensureMarketplaceOrderId()` recovers it
on demand by re-reading the Shopify order's notes (`ShopifyClient::getOrderNotes()`) the first time a dispatch is
attempted, then saves it so it's never re-fetched.

`fulfillmentDriver()` resolves the right client per platform (`ebayDriver()` for eBay, `new AmazonClient($channel)`
for Amazon — neither implements `ChannelDriverInterface`, so `$channel->driver()` can't be used for them) and picks
the connected eBay/Amazon channel configured to push into that Shopify store, falling back to the first connected one.

### Manual button

`ViewOrder` header action **"Send tracking to Amazon"** / **"Send tracking to eBay"** — visible only on a
processed order whose channel is Shopify and whose `source_name` is `ebay`/`amazon`. Calls
`OrderFulfillmentService::dispatchToMarketplace($order, force: true)` directly (skips the Shopify push — use
**Resend Dispatch** instead if Shopify itself needs re-pushing).

### Run log

Every attempt is logged to the console (not just Laravel's log file): `JobLogger::ok/error/warn` with lines like
`Marketplace dispatch · C1034 → Amazon 204-8832654-4643545 · tracking MZ… (Royal Mail)`.

### Known gap — Amazon buyer PII

Amazon withholds buyer name and full street address by default (SP-API restricted data). `getOrders()` only
returns city/postcode/country; `AmazonClient::getOrderWithPii()` exists to fetch the rest via a Restricted Data
Token, but the app's SP-API registration doesn't yet have the PII role granted (confirmed live: Amazon returns
`"Application does not have access to one or more requested data elements: [shippingAddress]"`). Until Amazon
grants it (Seller Central → Develop Apps → roles → Direct-to-Consumer Shipping), addresses come through
incomplete. Workarounds shipped in the meantime:

- **`AmazonOrderImporter`/`AmazonToShopifyPusher`** send a visible placeholder street line ("Address not released
  by Amazon") instead of an empty one, and a payment `transaction` so Shopify shows the order as actually paid
  (previously showed "Paid" with £0.00 received).
- **`ShopifyClient::updateOrderAddress()`** + **`EditOrder` header action "Push address to Shopify"** — an order
  created by Cydekick's app can't be edited in the Shopify admin UI at all (Shopify restriction: only the
  creating app, or GraphQL-created orders, can edit an app-created order). This action lets staff correct the
  name/address on the Cydekick side and push it to the existing Shopify order via the API instead. A single-word
  name is sent with a dashed last name (`-`) — Shopify's API rejects an address with a blank last name.
- **`EditOrder::saveContactDetails()`** (channel-orders) — phone/email typed on the order screen previously only
  saved via a separate "Update customer record?" confirmation button and were lost on a normal Save. Save now
  always persists them to the customer record and copies the phone onto the order's own delivery address (what
  the address-push button and courier label use) if that address had none.

### Linking marketplace order lines by SKU

`MarketplaceSkuResolver` (`plugins/webkul/channels/src/Services/MarketplaceSkuResolver.php`) — used by both
`AmazonOrderImporter` and `EbayOrderImporter`. Looks up the channel's own SKU-mapping table first
(`channels_amazon_sku_mappings` / `channels_ebay_sku_mappings`), then falls back to an exact match on the
Cydekick product's own SKU. Covers listings created by hand on the marketplace that were never linked in Manage
Listings — previously those order lines always came in as `[UNLINKED]`. The **"Re-check links"** button on the
Order Sync page's Unlinked Lines stat re-runs this against already-imported lines.

## Adding a new channel / courier

1. Create your driver class
2. Implement `FulfillableChannelDriver` alongside any other interfaces
3. Add the carrier slug → platform code mapping inside `fulfillOrder()`
4. No changes needed to `OrderObserver` or `OrderFulfillmentService`

## Files

| File | Role |
|---|---|
| `plugins/webkul/channels/src/Contracts/FulfillableChannelDriver.php` | Interface |
| `plugins/webkul/channels/src/Services/ShopifyClient.php` | Implements `fulfillOrder()` — wraps `createFulfillment()` |
| `plugins/webkul/channels/src/Services/EbayClient.php` | Implements `fulfillOrder()` — fetches line IDs, calls `createShippingFulfillment()` |
| `plugins/webkul/channel-orders/src/Services/OrderFulfillmentService.php` | Dispatch service |
| `plugins/webkul/channel-orders/src/Models/Observers/OrderObserver.php` | `dispatchFulfillment()` wired into `updating()` + `created()` |
| `plugins/webkul/channel-orders/src/Filament/Resources/OrderResource/Pages/ViewOrder.php` | Resend Dispatch + Send tracking to Amazon/eBay actions |
| `plugins/webkul/channel-orders/database/migrations/2026_08_26_000001_add_dispatched_at...` | `dispatched_at` column |
| `plugins/webkul/channels/src/Services/AmazonClient.php` | Implements `fulfillOrder()` — `shipmentConfirmation`; also `getOrderWithPii()`, `getOrderNotes()` |
| `plugins/webkul/channels/src/Services/MarketplaceSkuResolver.php` | SKU → product fallback for Amazon/eBay order lines |
| `plugins/webkul/channel-orders/database/migrations/2026_09_25_000001_add_marketplace_dispatch...` | `marketplace_order_id`, `marketplace_dispatched_at` columns |
| `plugins/webkul/channels/src/Services/ShopifyClient.php` | Also: `updateOrderAddress()`, `getOrderNotes()` |

## What was removed

`EditOrder::pushShopifyFulfillment()` — hardcoded Shopify-only fulfillment push that ran on every save of a processed order. Replaced by the observer-driven `OrderFulfillmentService` which is channel-agnostic and idempotent.
