# Shopify Stock Level Sync — How It Works

This covers every firing point that pushes stock levels (and the `in_stock` boolean metafield)
from Cydekick to Shopify, and the known gaps that existed before 2026-06-23.

---

## The Two Things Being Pushed

### 1. Inventory level (numeric quantity)
Pushed per Shopify location using the Inventory API:
```
ShopifyClient::setInventoryLevel(locationId, inventoryItemId, qty)
```

### 2. `in_stock` boolean metafield
A Shopify product-level metafield (`custom.in_stock`, type `boolean`) that the
Somerset4x4 theme reads to show/hide the "In Stock for Instant Dispatch" badge.
Pushed via GraphQL `metafieldsSet` mutation:
```
ShopifyClient::setProductInStockMetafields([['product_id' => ..., 'in_stock' => true|false]])
```
The value is `true` if the **in_stock_source** warehouse has qty > 0, `false` otherwise.

---

## The Core Push Job

**File:** `plugins/webkul/channels/src/Jobs/SyncProductToChannels.php`

All stock pushes ultimately go through this job. It is `ShouldBeUnique` (coalesces for
60 s per product+channel pair) so rapid bulk changes don't flood the API.

**What it does per product:**
1. Calls `setInventoryLevel()` for every configured location mapping
2. Updates variant price and cost
3. Queries stock at the `is_instock_source` warehouse and calls `setProductInStockMetafields()`
4. Pulls confirmed levels back from Shopify and updates `SkuSyncStatus`

**Prerequisite:** The product must have an active row in `channels_sku_mappings` with a
`shopify_inventory_item_id` and the channel must have at least one `channels_location_mappings`
row. Without these, the job is a no-op.

---

## Firing Points

### 1. ProductQuantity changed — automatic (Observer)

**File:** `plugins/webkul/channels/src/Observers/ProductQuantityObserver.php`

Fires on `created`, `updated` (when `quantity` or `reserved_quantity` changes), `deleted`.

**How it triggers:**
```
ProductQuantity saved
  → ProductQuantityObserver::dispatch()
    → finds channel location mappings for the product's warehouse
    → marks SkuSyncStatus as 'pending'
    → dispatches SyncProductToChannels per channel
```

**Registered in:** `plugins/webkul/channels/src/ChannelServiceProvider.php`

**Covers:** Internal stock adjustments, manual stock edits, stock moves created in Cydekick.

**Does NOT cover:** Products with no `products_quantities` row (e.g. scrape/PSP-only items
where stock is managed externally). For those, the observer finds no rows and does nothing.

---

### 2. Order processed (open → processed transition)

**File:** `plugins/webkul/channel-orders/src/Models/Observers/OrderObserver.php`
**Method:** `updating()`

When an order's status changes from `open` or `draft` to `processed`:
1. `processOrderDeductions()` — decrements `ProductQuantity.quantity` and clears reservations
2. `dispatchChannelSync()` — directly dispatches `SyncProductToChannels` for all products
   in the order lines that have active SKU mappings (belt-and-suspenders for PSP items)

The `ProductQuantityObserver` also fires as a result of step 1 (for tracked items), so
the sync is dispatched twice but `ShouldBeUnique` coalesces them.

---

### 3. Order created already-processed (Shopify fulfilled before import)

**File:** `plugins/webkul/channel-orders/src/Models/Observers/OrderObserver.php`
**Method:** `created()` — **added 2026-06-23**

**The gap this fixes:** When `SyncShopifyOrders` imports a Shopify order that already has
`fulfillment_status = fulfilled`, `ShopifyOrderImporter` creates the Cydekick order directly
with `status = 'processed'`. The status never *changes*, so `updating()` never fires. Before
this fix, no stock deductions and no Shopify sync happened at all for these orders.

**Now:** `created()` checks the initial status. If `'processed'`, it immediately runs
`processOrderDeductions()` and `dispatchChannelSync()`.

**This was the root cause of AEU2147L remaining shown as "In Stock" after its last unit
was sold via a Shopify-side fulfillment on 2026-06-22.**

---

### 4. Manual full sync (Sync Now button)

**File:** `plugins/webkul/channels/src/Jobs/PushAndSyncSkuInventory.php`

Triggered from the Channels → SKU Sync page. Runs a 4-phase bulk job:
1. Push all inventory levels for all mapped SKUs × locations
2. Push all prices and costs
3. Push all `in_stock` metafields (built from `is_instock_source` warehouse qty)
4. Pull confirmed levels back from Shopify

Use this to recover from any stuck state, or to do a full reconciliation after a
migration or bulk stock change.

---

### 5. Product price/listing changed (Observer)

**File:** `plugins/webkul/channels/src/Observers/ProductObserver.php`

Fires when `price`, `cost`, or other listable fields change on the Product model.
Dispatches `SyncProductToChannels`. This triggers the full job including the
`in_stock` metafield update as a side effect.

---

## Location Mapping and in_stock_source

**Table:** `channels_location_mappings`

| Column | Purpose |
|--------|---------|
| `channel_id` | Which Shopify channel |
| `warehouse_id` | Cydekick warehouse to push from |
| `shopify_location_id` | Shopify location ID to push to |
| `is_instock_source` | ONE row per channel should be `true`. This warehouse's qty > 0 determines the `in_stock` metafield value |
| `is_split_sell_source` | MULTIPLE rows may be `true`. If any marked warehouse has qty ≥ 1, `ShopifyBatchSyncJob` Phase 3.6b pushes `custom.step_quantity = 1` and `custom.min_quantity = 1` (we'll split and sell individually). If all are empty, pushes the default supplier's `min_qty` instead. See gotcha 12 in `shopify-listings-bulk.md`. |

If no row has `is_instock_source = true` for a channel, the `in_stock` metafield is
never updated by `SyncProductToChannels`. Set this in the Channels → Locations UI.

---

## Recovering a Stuck `in_stock` Metafield (one-off fix)

If a product is stuck showing "In Stock" on the site but has 0 stock, run this in
`php artisan tinker` on the server:

```php
$product = \Webkul\Product\Models\Product::where('sku', 'THE_SKU')->firstOrFail();

\Webkul\Channel\Models\SkuMapping::where('product_id', $product->id)
    ->where('is_active', true)
    ->pluck('channel_id')
    ->unique()
    ->each(fn($cid) => \Webkul\Channel\Jobs\SyncProductToChannels::dispatch($product->id, (int)$cid));

echo "Sync dispatched for {$product->sku}\n";
```

Then run the queue worker if not running, or wait for the next poll.

---

## Known Gaps / Watch Points

**PSP/scrape-only products with no ProductQuantity rows**
If a product has no `products_quantities` record (stock managed externally via Allmakes PSP),
the `ProductQuantityObserver` chain produces nothing. The `dispatchChannelSync()` call added
to `OrderObserver` in 2026-06-23 now covers orders, but other stock changes (e.g. a PSP stock
check coming back as 0) still won't auto-push. For those, use the manual Sync Now button or
a scheduled `PushAndSyncSkuInventory` job.

**`ShouldBeUnique` 60-second window**
`SyncProductToChannels` coalesces duplicate dispatches within 60 s. If you dispatch and
immediately check Shopify, the job may not have run yet. Check the queue depth in the console.

**Shopify rate limits**
The `setProductInStockMetafields` method batches 25 metafields per GraphQL call. A full
sync of thousands of SKUs is chunked but can still take several minutes.
