# Bug: orders imported already-fulfilled can get a blank name/order_number

Found and fixed 2026-09-30, surfaced by the Profit & Loss beta page showing blank "Order" cells
for real orders with real totals — see [profit-loss-plugin.md](profit-loss-plugin.md).

## The symptom

An order exists with a real `total_amount`, `status`, `created_at` etc. — but `name`/
`order_number` are `NULL`/empty, so it shows nowhere sensible in any order list ("C1234" never
appears, just blank). Confirmed on production via:

```php
use Webkul\ChannelOrders\Models\Order;
Order::where(fn ($q) => $q->whereNull('name')->orWhere('name', ''))->count();
```

Production had exactly **395** — matching, to the order, the count `channels:import-historical-shopify-orders`
reported importing. All 395 were on that command's Shopify channel.

## The actual cause

`Order.name`/`order_number` are normally assigned by a `creating()` model event
(`Order::boot()`, now `Order::assignNextOrderNumber()` — see below): `next = max(existing
order_number) + 1`, `name = 'C'.next`.

`Model::withoutEvents($callback)` unsets the model's event dispatcher *entirely* for the
callback's duration — Eloquent's `fireModelEvent()` silently no-ops with no dispatcher, so
`creating()` never fires and `order_number`/`name` are left null. Every importer
(`EbayOrderImporter`/`AmazonOrderImporter`/`ShopifyOrderImporter`) wraps `Order::create()` in
`Order::withoutEvents()` whenever the order is already fulfilled at import time — needed so
`OrderObserver::created()` doesn't fire before order lines exist (deduction/sync then run
manually afterward instead).

In ordinary live sync this almost never bites: an order is nearly always first seen **open**
(`Order::create()` runs normally, `creating()` fires, name gets assigned) and only *later*
transitions to processed via a status update — which never re-runs `create()`, so the name it
already has is untouched. The `withoutEvents()`-wrapped path is the rare exception: an order
that's already fulfilled the very first moment Cydekick ever sees it.

A historical bulk import is exactly that exception, for **every single order** — hence 395 blank
names, one per imported order, and zero from ordinary live sync.

## The fix

1. **`Order::assignNextOrderNumber(Order $order): void`**
   (`plugins/webkul/channel-orders/src/Models/Order.php`) — the same "next = max+1" logic,
   extracted out of `creating()` into a reusable public static method.
2. `creating()` now just calls it (only if `order_number` isn't already set — so a caller can
   pre-assign it and `creating()` won't clobber that).
3. All three importers now call `Order::assignNextOrderNumber()` **explicitly, before** the
   `Order::create()` call, whenever they're about to wrap it in `withoutEvents()` (the
   `$isFulfilled` branch in all three; also the `$historicalImport` branch in
   `ShopifyOrderImporter`) — injecting `order_number`/`name` into `$orderData` so they're set
   correctly on the row from the moment it's actually created, `creating()` or not.

Verified via a rolled-back tinker test: both a historical-import order and a normal
already-fulfilled import now get correct, sequential numbers (`C1027`, `C1028`).

## Backfilling orders that already have a blank name

```
php artisan channels:backfill-order-numbers --dry-run
php artisan channels:backfill-order-numbers
```

(`plugins/webkul/channels/src/Console/Commands/BackfillOrderNumbersCommand.php`) — processes
oldest-first (by `created_at`), assigns each the next available number via the same
`assignNextOrderNumber()`, raw `DB::table()` update (never fires any `Order` observer — no
stock/reservation/fulfilment-push side effects for orders that are already processed/cancelled).
Idempotent — a second run reports "No orders with a blank name found."

**Accepted, unavoidable quirk**: these get numbers *after* the current max (e.g. `C1040+`), even
though many are chronologically *older* than already-numbered orders — `C####` has always been a
pure insertion-order reference, never tied to the order's own real date, so there's no way to
"insert" a historical order retroactively into the middle of the existing sequence without
renumbering everything after it. Not worth doing — the number is an internal Cydekick label only,
never shown to or used by eBay/Shopify/Amazon.

Verified locally against the one pre-existing blank order in the local DB (`id=148` → `C1027`),
confirmed idempotent on a second dry-run.
