Reconciler Inventory and Stock

availableStock much lower than stock after a SW5 to SW6 migration

The warehouse count looks right. Physical stock in Shopware 6 says two hundred units are sitting on the shelf. But the storefront thinks the product is sold out, or checkout refuses the quantity the customer wants, because availableStock is far below stock, sometimes deep into negative territory. Nothing changed in the warehouse. What changed is the migration, and thousands of old SW5 orders that Shopware 6 still thinks are open.

Python and Node.js Admin API Safe by default (dry run)
The short answer

In Shopware 6, product.stock is the physical warehouse count, but product.availableStock is a derived number: stock minus the summed line item quantities of every order that is not yet completed or cancelled, computed by StockUpdater::updateAvailableStockAndSales(). A migration from Shopware 5 typically imports historical orders as rows in order and order_line_item, but leaves their SW5 status mapped to a SW6 order or delivery state that never reaches a terminal one. Shopware 6 then reads thousands of already-shipped orders as still open, subtracts all of their quantities from stock, and availableStock collapses far below reality. The fix is not to PATCH availableStock, which the indexer will just overwrite. It is to find the stale legacy orders and transition them forward through the real order state machine so Shopware's own recompute produces the right number. Full code, tests, and sources are below.

The problem in plain words

Shopware 6 keeps two numbers on a product. stock is simple: it is what is physically on the shelf, and it only changes when someone counts it or a stock movement says so. availableStock is not simple. It is stock minus whatever is currently reserved by orders Shopware considers still open, and it is recomputed by StockUpdater every time an order is placed, cancelled, or has a line item change, via lineItemWritten() and the CheckoutOrderPlaced listener.

That subtraction only makes sense if "still open" actually means still open. A migration from Shopware 5 breaks that assumption. The migration tool moves years of historical orders into the SW6 order and order_line_item tables so reporting and customer history keep working, but the SW5 order status rarely maps cleanly onto a SW6 stateMachineState that Shopware treats as terminal. An order shipped and paid in full three years ago under SW5 can land in SW6 as open or in_progress, because nothing in the migration walked it through the real transitions to completed. Multiply that by every order in the store's history, and the available-stock recompute subtracts all of it, even though none of that stock is actually held for anyone.

Thousands of SW5 orders, already shipped Migrated into order and order_line_item status mapping incomplete Left "open" or "in_progress" never reached completed StockUpdater::updateAvailableStockAndSales() sums line items on every non-terminal order availableStock deeply negative product appears sold out or blocked
Old, already-fulfilled SW5 orders keep a non-terminal SW6 order state after migration, so the available-stock recompute keeps treating their quantities as reserved forever.

Why it happens

Shopware only excludes an order from the available-stock subtraction once it, or its delivery, reaches a terminal state such as completed or cancelled for the order, or shipped, cancelled, or returned for the delivery. A migration can leave a large share of historical orders short of that line for a few common reasons:

This is a well documented migration side effect, not a one-off bug in a single store. The Shopware community forum has reports of availableStock reading far below stock right after a SW5 migration, and Shopware's own stock refactoring work acknowledges the earlier available-stock model as fragile enough to warrant a rewrite in 6.5.5. See the citations at the end for the exact threads and docs.

The key insight

availableStock is not wrong data to overwrite. It is the correct answer to the wrong question, because the question it is answering includes thousands of orders that should never have been counted as open in the first place. Fixing the number by writing to it directly only lasts until the next indexer pass recomputes it from the same stale inputs. The only fix that sticks is closing out the legacy orders that are genuinely fulfilled, so the recompute has the right inputs to work from.

The fix, as a flow

We never touch availableStock or stateId directly. The script finds products where the gap between stock and availableStock is large, pulls every order line item still consuming that product's reservation, and classifies each order's reservation as terminal, a legacy stale reservation from before the SW6 go-live date, or a genuinely open current order. Only the legacy stale ones, and only when a human has not flagged them as ambiguous, get transitioned forward through the real state machine so Shopware's own indexer can recompute a correct availableStock.

Scheduled job runs on a timer Find suspect products stock minus availableStock is large List order-line-items holding that product's reservation Legacy stale, pre-cutover? yes terminal or genuinely open, leave it state/complete indexer recomputes
Only orders that are both non-terminal and older than the migration cutover date are candidates. Everything genuinely open, or already terminal, is left exactly as it is.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL, the credentials, and your migration cutover date in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export MIGRATION_CUTOVER_DATE="2025-01-01T00:00:00+00:00"  # your SW6 go-live date
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export MIGRATION_CUTOVER_DATE="2025-01-01T00:00:00+00:00"  // your SW6 go-live date
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for every search and for the transition action calls.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Find products where the gap is large

There is no direct DAL filter that subtracts two fields, so pull products with stock and availableStock and compute the gap client side, keeping the ones where stock - availableStock is large, sorted descending.

step3.py
def search_products(token, limit=500):
    body = {
        "page": 1,
        "limit": limit,
        "includes": {"product": ["id", "productNumber", "stock", "availableStock"]},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/product",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

def find_suspect_products(products, min_gap=1):
    suspects = [p for p in products if (p["stock"] - p["availableStock"]) > min_gap]
    return sorted(suspects, key=lambda p: p["stock"] - p["availableStock"], reverse=True)
step3.js
async function searchProducts(token, limit = 500) {
  const body = {
    page: 1,
    limit,
    includes: { product: ["id", "productNumber", "stock", "availableStock"] },
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/product`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on product search`);
  const data = await res.json();
  return data.data;
}

function findSuspectProducts(products, minGap = 1) {
  const suspects = products.filter((p) => p.stock - p.availableStock > minGap);
  return suspects.sort((a, b) => (b.stock - b.availableStock) - (a.stock - a.availableStock));
}
4

Decide, with one pure function

Keep the classification separate from any network call. Given an order's state machine names and its creation date, plus the migration cutover date, decide whether the order's reservation is terminal, a legacy_stale_reservation, or genuinely_open. Nothing here talks to the network, so it is trivial to test with fixture rows.

decide.py
from datetime import datetime

TERMINAL_ORDER_STATES = {"cancelled", "completed"}
TERMINAL_DELIVERY_STATES = {"shipped", "cancelled", "returned"}

def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))

def classify_order_reservation(order, migration_cutover_date,
                                terminal_order_states=None, terminal_delivery_states=None):
    terminal_order_states = terminal_order_states or TERMINAL_ORDER_STATES
    terminal_delivery_states = terminal_delivery_states or TERMINAL_DELIVERY_STATES

    if (order["stateMachineTechnicalName"] in terminal_order_states
            or order["deliveryStateMachineTechnicalName"] in terminal_delivery_states):
        return "terminal"

    if _parse(order["createdAt"]) < _parse(migration_cutover_date):
        return "legacy_stale_reservation"

    return "genuinely_open"
decide.js
const TERMINAL_ORDER_STATES = ["cancelled", "completed"];
const TERMINAL_DELIVERY_STATES = ["shipped", "cancelled", "returned"];

export function classifyOrderReservation(order, migrationCutoverDate,
    terminalOrderStates = TERMINAL_ORDER_STATES, terminalDeliveryStates = TERMINAL_DELIVERY_STATES) {
  if (
    terminalOrderStates.includes(order.stateMachineTechnicalName) ||
    terminalDeliveryStates.includes(order.deliveryStateMachineTechnicalName)
  ) {
    return "terminal";
  }

  if (Date.parse(order.createdAt) < Date.parse(migrationCutoverDate)) {
    return "legacy_stale_reservation";
  }

  return "genuinely_open";
}
5

List the order line items holding a suspect product's reservation

For each suspect product id, search order-line-item for lines referencing it, with the order, its stateMachineState, and its deliveries and their stateMachineState included, so the pure function has everything it needs.

step5.py
def search_order_line_items(token, product_id, limit=500):
    body = {
        "page": 1,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "referencedId", "value": product_id},
            {"type": "equals", "field": "type", "value": "product"},
        ],
        "associations": {
            "order": {"associations": {
                "stateMachineState": {},
                "deliveries": {"associations": {"stateMachineState": {}}},
            }}
        },
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-line-item",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]
step5.js
async function searchOrderLineItems(token, productId, limit = 500) {
  const body = {
    page: 1,
    limit,
    filter: [
      { type: "equals", field: "referencedId", value: productId },
      { type: "equals", field: "type", value: "product" },
    ],
    associations: {
      order: { associations: {
        stateMachineState: {},
        deliveries: { associations: { stateMachineState: {} } },
      } },
    },
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
  const data = await res.json();
  return data.data;
}
6

Close out flagged orders through the real state machine, never a PATCH

For every line item classified legacy_stale_reservation that you can confirm was genuinely fulfilled, call POST /api/_action/order/{id}/state/complete, and the matching delivery and transaction transitions if the order was truly shipped and paid. Never write stateId directly. Afterward, let the product.indexer message queue run so StockUpdater recomputes availableStock on its own.

apply.py
def complete_order(token, order_id):
    return api_post(f"/api/_action/order/{order_id}/state/complete", token, body={})

def ship_delivery(token, delivery_id):
    return api_post(f"/api/_action/order_delivery/{delivery_id}/state/ship", token, body={})

def mark_transaction_paid(token, transaction_id):
    return api_post(f"/api/_action/order_transaction/{transaction_id}/state/paid", token, body={})
apply.js
async function completeOrder(token, orderId) {
  return apiPost(`/api/_action/order/${orderId}/state/complete`, token, {});
}

async function shipDelivery(token, deliveryId) {
  return apiPost(`/api/_action/order_delivery/${deliveryId}/state/ship`, token, {});
}

async function markTransactionPaid(token, transactionId) {
  return apiPost(`/api/_action/order_transaction/${transactionId}/state/paid`, token, {});
}
7

Wire it together with a dry run guard

The loop ties every piece together. For each suspect product, list its order line items, classify each order's reservation, and only when the classification is legacy_stale_reservation log or apply the completion. Anything genuinely_open is left alone, and anything ambiguous should be reported to a human rather than transitioned.

Run it safe

Never patch availableStock or stateId, and never complete an order you cannot confirm was genuinely fulfilled. Start with DRY_RUN=true, review the exact list of orders the script would close out, and only let it write once you trust the list and your MIGRATION_CUTOVER_DATE is correct.

The full code

Here is the complete script in one file for each language. It authenticates, finds suspect products, lists the order line items holding their reservations, classifies each order with the pure function, and closes out only the confirmed legacy stale ones, respecting DRY_RUN.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

reconcile_available_stock.py
"""Reconcile Shopware 6 availableStock that reads far below physical stock
after a Shopware 5 to Shopware 6 migration, without ever patching
availableStock or stateId directly.

availableStock is derived: stock minus the summed line item quantities of
every order that is not yet completed or cancelled. A migration often leaves
historical orders on a non-terminal SW6 state, so the recompute subtracts
quantities from orders that were actually shipped years ago.

Finds products where stock minus availableStock is large, lists the order
line items still holding each product's reservation, classifies every order
as terminal, a legacy stale reservation from before the migration cutover, or
genuinely open, and only completes the confirmed legacy stale ones through
the real order state machine. Run on a schedule, gated by DRY_RUN. Safe to
run again and again.
"""
import os
import logging
import requests
from datetime import datetime

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_available_stock")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
MIGRATION_CUTOVER_DATE = os.environ["MIGRATION_CUTOVER_DATE"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

TERMINAL_ORDER_STATES = {"cancelled", "completed"}
TERMINAL_DELIVERY_STATES = {"shipped", "cancelled", "returned"}


def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))


def classify_order_reservation(order, migration_cutover_date,
                                terminal_order_states=None, terminal_delivery_states=None):
    """Pure decision. No I/O. Takes plain string/date fields and returns a label."""
    terminal_order_states = terminal_order_states or TERMINAL_ORDER_STATES
    terminal_delivery_states = terminal_delivery_states or TERMINAL_DELIVERY_STATES

    if (order["stateMachineTechnicalName"] in terminal_order_states
            or order["deliveryStateMachineTechnicalName"] in terminal_delivery_states):
        return "terminal"

    if _parse(order["createdAt"]) < _parse(migration_cutover_date):
        return "legacy_stale_reservation"

    return "genuinely_open"


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def search_products(token, limit=500):
    body = {
        "page": 1,
        "limit": limit,
        "includes": {"product": ["id", "productNumber", "stock", "availableStock"]},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/product",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def find_suspect_products(products, min_gap=1):
    suspects = [p for p in products if (p["stock"] - p["availableStock"]) > min_gap]
    return sorted(suspects, key=lambda p: p["stock"] - p["availableStock"], reverse=True)


def search_order_line_items(token, product_id, limit=500):
    body = {
        "page": 1,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "referencedId", "value": product_id},
            {"type": "equals", "field": "type", "value": "product"},
        ],
        "associations": {
            "order": {"associations": {
                "stateMachineState": {},
                "deliveries": {"associations": {"stateMachineState": {}}},
            }}
        },
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-line-item",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def complete_order(token, order_id):
    return api_post(f"/api/_action/order/{order_id}/state/complete", token, body={})


def to_classifier_input(order):
    deliveries = order.get("deliveries") or []
    delivery_state = deliveries[0]["stateMachineState"]["technicalName"] if deliveries else ""
    return {
        "stateMachineTechnicalName": (order.get("stateMachineState") or {}).get("technicalName", "open"),
        "deliveryStateMachineTechnicalName": delivery_state,
        "createdAt": order["createdAt"],
    }


def run():
    token = get_token()
    products = search_products(token)
    suspects = find_suspect_products(products)
    log.info("Found %d suspect product(s) with a large stock/availableStock gap.", len(suspects))

    fixed = 0
    flagged = 0
    for product in suspects:
        line_items = search_order_line_items(token, product["id"])
        seen_orders = set()
        for line_item in line_items:
            order = line_item.get("order")
            if not order or order["id"] in seen_orders:
                continue
            seen_orders.add(order["id"])

            classification = classify_order_reservation(
                to_classifier_input(order), MIGRATION_CUTOVER_DATE
            )
            if classification != "legacy_stale_reservation":
                continue

            log.info(
                "Order %s (product %s) classified legacy_stale_reservation. %s",
                order.get("orderNumber", order["id"]), product["productNumber"],
                "would complete" if DRY_RUN else "completing",
            )
            if not DRY_RUN:
                complete_order(token, order["id"])
            fixed += 1

    log.info("Done. %d order(s) %s. Re-run detection after the indexer catches up to confirm the gap shrank.",
              fixed, "to complete" if DRY_RUN else "completed")


if __name__ == "__main__":
    run()
reconcile-available-stock.js
/**
 * Reconcile Shopware 6 availableStock that reads far below physical stock
 * after a Shopware 5 to Shopware 6 migration, without ever patching
 * availableStock or stateId directly.
 *
 * availableStock is derived: stock minus the summed line item quantities of
 * every order that is not yet completed or cancelled. A migration often
 * leaves historical orders on a non-terminal SW6 state, so the recompute
 * subtracts quantities from orders that were actually shipped years ago.
 *
 * Finds products where stock minus availableStock is large, lists the order
 * line items still holding each product's reservation, classifies every
 * order as terminal, a legacy stale reservation from before the migration
 * cutover, or genuinely open, and only completes the confirmed legacy stale
 * ones through the real order state machine. Run on a schedule, gated by
 * DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/available-stock-low-after-migration/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const MIGRATION_CUTOVER_DATE = process.env.MIGRATION_CUTOVER_DATE || "2025-01-01T00:00:00+00:00";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const TERMINAL_ORDER_STATES = ["cancelled", "completed"];
const TERMINAL_DELIVERY_STATES = ["shipped", "cancelled", "returned"];

export function classifyOrderReservation(order, migrationCutoverDate,
    terminalOrderStates = TERMINAL_ORDER_STATES, terminalDeliveryStates = TERMINAL_DELIVERY_STATES) {
  if (
    terminalOrderStates.includes(order.stateMachineTechnicalName) ||
    terminalDeliveryStates.includes(order.deliveryStateMachineTechnicalName)
  ) {
    return "terminal";
  }

  if (Date.parse(order.createdAt) < Date.parse(migrationCutoverDate)) {
    return "legacy_stale_reservation";
  }

  return "genuinely_open";
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchProducts(token, limit = 500) {
  const body = {
    page: 1,
    limit,
    includes: { product: ["id", "productNumber", "stock", "availableStock"] },
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/product`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on product search`);
  const data = await res.json();
  return data.data;
}

function findSuspectProducts(products, minGap = 1) {
  const suspects = products.filter((p) => p.stock - p.availableStock > minGap);
  return suspects.sort((a, b) => (b.stock - b.availableStock) - (a.stock - a.availableStock));
}

async function searchOrderLineItems(token, productId, limit = 500) {
  const body = {
    page: 1,
    limit,
    filter: [
      { type: "equals", field: "referencedId", value: productId },
      { type: "equals", field: "type", value: "product" },
    ],
    associations: {
      order: { associations: {
        stateMachineState: {},
        deliveries: { associations: { stateMachineState: {} } },
      } },
    },
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
  const data = await res.json();
  return data.data;
}

async function completeOrder(token, orderId) {
  return apiPost(`/api/_action/order/${orderId}/state/complete`, token, {});
}

function toClassifierInput(order) {
  const deliveries = order.deliveries || [];
  const deliveryState = deliveries.length ? deliveries[0].stateMachineState.technicalName : "";
  return {
    stateMachineTechnicalName: order.stateMachineState?.technicalName || "open",
    deliveryStateMachineTechnicalName: deliveryState,
    createdAt: order.createdAt,
  };
}

export async function run() {
  const token = await getToken();
  const products = await searchProducts(token);
  const suspects = findSuspectProducts(products);
  console.log(`Found ${suspects.length} suspect product(s) with a large stock/availableStock gap.`);

  let fixed = 0;
  for (const product of suspects) {
    const lineItems = await searchOrderLineItems(token, product.id);
    const seenOrders = new Set();
    for (const lineItem of lineItems) {
      const order = lineItem.order;
      if (!order || seenOrders.has(order.id)) continue;
      seenOrders.add(order.id);

      const classification = classifyOrderReservation(toClassifierInput(order), MIGRATION_CUTOVER_DATE);
      if (classification !== "legacy_stale_reservation") continue;

      console.log(
        `Order ${order.orderNumber || order.id} (product ${product.productNumber}) classified legacy_stale_reservation. ${DRY_RUN ? "would complete" : "completing"}`
      );
      if (!DRY_RUN) await completeOrder(token, order.id);
      fixed++;
    }
  }

  console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to complete" : "completed"}. Re-run detection after the indexer catches up to confirm the gap shrank.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides which orders get closed out and stop holding phantom stock. Because classify_order_reservation is pure and takes only plain string and date fields, the test needs no network, no Shopware store, and no message queue.

test_available_stock_classification.py
from reconcile_available_stock import classify_order_reservation

CUTOVER = "2025-01-01T00:00:00+00:00"


def order(**over):
    base = {
        "stateMachineTechnicalName": "open",
        "deliveryStateMachineTechnicalName": "open",
        "createdAt": "2024-06-01T00:00:00+00:00",  # before cutover
    }
    base.update(over)
    return base


def test_cancelled_pre_cutover_is_terminal():
    result = classify_order_reservation(order(stateMachineTechnicalName="cancelled"), CUTOVER)
    assert result == "terminal"


def test_completed_pre_cutover_is_terminal():
    result = classify_order_reservation(order(stateMachineTechnicalName="completed"), CUTOVER)
    assert result == "terminal"


def test_open_pre_cutover_is_legacy_stale_reservation():
    result = classify_order_reservation(order(), CUTOVER)
    assert result == "legacy_stale_reservation"


def test_open_post_cutover_is_genuinely_open():
    result = classify_order_reservation(order(createdAt="2025-06-01T00:00:00+00:00"), CUTOVER)
    assert result == "genuinely_open"


def test_shipped_delivery_pre_cutover_with_open_order_is_terminal():
    # Edge case: delivery state is terminal even though the order state lags behind.
    result = classify_order_reservation(
        order(stateMachineTechnicalName="in_progress", deliveryStateMachineTechnicalName="shipped"),
        CUTOVER,
    )
    assert result == "terminal"


def test_in_progress_pre_cutover_open_delivery_is_legacy_stale_reservation():
    result = classify_order_reservation(order(stateMachineTechnicalName="in_progress"), CUTOVER)
    assert result == "legacy_stale_reservation"
classify-order-reservation.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderReservation } from "./reconcile-available-stock.js";

const CUTOVER = "2025-01-01T00:00:00+00:00";

const order = (over = {}) => ({
  stateMachineTechnicalName: "open",
  deliveryStateMachineTechnicalName: "open",
  createdAt: "2024-06-01T00:00:00+00:00",
  ...over,
});

test("cancelled pre-cutover is terminal", () => {
  assert.equal(classifyOrderReservation(order({ stateMachineTechnicalName: "cancelled" }), CUTOVER), "terminal");
});

test("completed pre-cutover is terminal", () => {
  assert.equal(classifyOrderReservation(order({ stateMachineTechnicalName: "completed" }), CUTOVER), "terminal");
});

test("open pre-cutover is legacy_stale_reservation", () => {
  assert.equal(classifyOrderReservation(order(), CUTOVER), "legacy_stale_reservation");
});

test("open post-cutover is genuinely_open", () => {
  assert.equal(classifyOrderReservation(order({ createdAt: "2025-06-01T00:00:00+00:00" }), CUTOVER), "genuinely_open");
});

test("shipped delivery pre-cutover with open order is terminal", () => {
  const o = order({ stateMachineTechnicalName: "in_progress", deliveryStateMachineTechnicalName: "shipped" });
  assert.equal(classifyOrderReservation(o, CUTOVER), "terminal");
});

test("in_progress pre-cutover open delivery is legacy_stale_reservation", () => {
  assert.equal(classifyOrderReservation(order({ stateMachineTechnicalName: "in_progress" }), CUTOVER), "legacy_stale_reservation");
});

Case studies

Furniture retailer

Best sellers reading as sold out on day one

A furniture store migrated eight years of SW5 order history into a new SW6 storefront. Within a week, its three best selling product lines showed as out of stock in the storefront even though the warehouse had hundreds of units. Support first suspected a theme bug, then a stock import error, before someone compared stock and availableStock directly and found a five-figure gap on the worst offenders.

Running the reconciler in dry run turned up thousands of SW5 orders from before the go-live date, all still sitting in a non-terminal state. Every one of them had shipped years earlier according to the old SW5 database. Completing them through the real order state machine, then letting the indexer catch up, closed the gap without a single manual stock edit.

Wholesale B2B

Checkout rejecting a quantity the warehouse could easily fill

A B2B supplier kept hearing from account managers that customers could not order the quantities they wanted, even on items the warehouse confirmed were well stocked. The store had migrated from SW5 eighteen months earlier and no one had checked whether the migration left any orders behind.

The detection query showed a handful of high-volume SKUs with a gap in the thousands of units. Because the store had not migrated delivery states cleanly, many of the stale orders had a shipped delivery but an order state still stuck on in_progress, exactly the edge case the classifier is built to catch by delivery state alone. After completing the confirmed ones and leaving the ambiguous handful for manual review, checkout stopped rejecting valid orders.

What good looks like

After this runs, availableStock reflects reality again: physical stock minus only the orders that are truly still open. The storefront stops hiding products that are actually in stock, checkout stops rejecting quantities the warehouse can fill, and nothing was achieved by overwriting a derived field that the indexer would have reset anyway. Anything the script could not confirm stays flagged for a human, so a real customer's order is never quietly closed out by mistake.

FAQ

Why is availableStock so much lower than stock after migrating from Shopware 5?

Shopware 6 computes availableStock by subtracting the line item quantities of every order that is not completed or cancelled from the physical stock. A SW5 to SW6 migration usually imports thousands of historical orders whose SW5 status never maps cleanly to a terminal SW6 order or delivery state, so Shopware 6 reads long-since-shipped orders as still open and keeps subtracting their quantities forever.

Can I just PATCH availableStock back to the right number?

No. availableStock is a derived value that the product indexer recomputes from stock and open order line items, and in Shopware 6.5.5 and later it is explicitly a managed mirror of stock. A direct PATCH is overwritten the next time the indexer runs, so the only durable fix is to correct the underlying order states that are feeding the wrong number.

Is it safe to automatically complete old migrated orders?

Only for orders you can confirm were genuinely fulfilled in the real world before the SW6 go-live date. The script should transition an order forward through its real state machine, never PATCH stateId directly, and any order it cannot confirm should be flagged for a human instead of being closed automatically, since wrongly completing a still-open order would free stock a real customer is still owed.

Related field notes

Citations

On the problem:

  1. Shopware Community Forum: Available stock is much less than stock after migration from SW5. forum.shopware.com/t/available-stock-is-much-less-than-stock-after-migration-from-sw5/91049
  2. Stock storage refactoring, shopware/shopware Discussion #3172. github.com/shopware/shopware/discussions/3172
  3. Stock refactoring changelog, shopware/shopware release-6-5-5-0. github.com/shopware/shopware release-6-5-5-0 stock refactoring changelog

On the solution:

  1. Shopware Developer Documentation: Available stock improvements ADR. developer.shopware.com/docs/resources/references/adr/2022-03-25-available-stock.html
  2. Shopware Developer Documentation: Stock Manipulation API ADR. developer.shopware.com/docs/resources/references/adr/2023-05-15-stock-api.html
  3. Shopware Developer Documentation: Stock Configuration. developer.shopware.com/docs/guides/hosting/configurations/shopware/stock.html

Stuck on a tricky one?

If you have a problem in Shopware orders, inventory, migrations, or the state machine that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your stock numbers?

If this saved you a wall of support tickets or a warehouse full of stock the storefront refused to sell, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes