Diagnostic Number Ranges and Documents

Quote creation consumes the shared order number range

Your order numbers should run 1001, 1002, 1003, one per order, with nothing missing. Then you notice the sequence has raced far ahead of how many orders you actually have. Numbers are being handed out and never used. The culprit is often the B2B quote flow: every time a quote is created or recalculated, it reserves the next value from the very same order number range that real orders draw from, and that value is never given back. Here is why a quote quietly eats an order number, and a script that measures the drift without touching a single live order.

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

Shopware 6 assigns an orderNumber by reserving the next value from the order number range through the number_range system. A B2B quote is built on the same order data model, so creating or recalculating a quote can reserve a value from that same shared range, incrementing the sequence, without ever committing an order. The reserved value is never released, so every such quote leaves a permanent gap and the order number range state drifts ahead of the real count of orders that exist. Read the range's current lastValue from number-range-state and the actual order total from the Admin API, then a pure function decides whether the sequence is ahead of the real order count by more than a tolerance. This is reporting only. Never renumber existing orders, because those numbers are customer-facing and may already be on an invoice. The real fix is giving quotes their own dedicated number range so they stop eating the order sequence. Full code, tests, and sources are below.

The problem in plain words

Every order needs an orderNumber, and Shopware hands those out through the number_range system: a counter tied to the order range that increments by one every time an order is placed. As long as only real orders draw from that counter, its current value stays right next to the number of orders you have. The sequence and the reality match.

A B2B quote is not a separate concept underneath. It is built on the same order entity and the same order lifecycle, so the code path that prepares a quote can reach for the next value from that shared order range too. The moment a quote is created, or recalculated after an edit, it can reserve a number. That number is now spent. If the quote never becomes a committed order, the number is simply gone, a gap in the sequence that nothing will ever fill.

Real order requests a number B2B quote requests a number Shared order range one counter for both Order 1002 committed, number kept Number 1003 spent quote abandoned, gap sequence ahead of orders
Both the real order and the quote pull from one shared order range. The order keeps its number. The quote spends a number and walks away, so the sequence runs ahead of the count of orders that actually exist.

Why it happens

The counter itself is doing exactly what it is designed to do. The trouble is who is allowed to draw from it:

See the citations at the end for Shopware's own documentation on number ranges and on giving the B2B quote flow its own range.

The key insight

You do not need to trace every individual quote to know this is happening. The number range only ever moves forward, so its current lastValue should sit right next to the real count of orders. When lastValue has pulled well ahead of that count, something is consuming numbers without producing orders, and on a B2B store the shared quote flow is the usual answer. Measuring that single gap, the sequence versus the real order count, tells you whether drift is happening without inspecting a single quote by hand.

The fix, as a flow

We never renumber an existing order, because gaps are harmless and those numbers may already be on invoices. Instead the script reads the order range's current lastValue and the actual order total, hands both to a pure function that decides whether the sequence has drifted ahead by more than a tolerance, and reports the result. The real remedy, moving quotes onto their own dedicated number range, is a configuration change you make once the drift is confirmed.

Read range state lastValue and order total assessOrderNumberDrift compare vs tolerance, pure Drift > tolerance? no, within tolerance Nothing to do yes, drift detected Report drift, no rewrite recommend quote range
The pure function compares the sequence against the real order count. Drift is only ever reported for a human to act on, and the fix is a dedicated quote range. No order number is ever rewritten.

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 and credentials 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 DRY_RUN="true"          # reporting only either way
export DRIFT_TOLERANCE="10"    # gaps allowed before drift is reported
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 DRY_RUN="true"          // reporting only either way
export DRIFT_TOLERANCE="10"    // gaps allowed before drift is reported
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then every request carries Authorization: Bearer <token> and Accept: application/json. We reuse it to count orders and to read the number range state.

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

Count the orders that actually exist

Search order with a limit of 1 and total-count-mode of 1, and read the total field. We do not need the rows themselves, only how many orders there are, so this stays a single cheap request no matter how large the store.

step3.py
def count_orders(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return int(r.json()["total"])
step3.js
async function countOrders(token) {
  const body = { page: 1, limit: 1, filter: [], "total-count-mode": 1 };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
    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 count`);
  const data = await res.json();
  return Number(data.total);
}
4

Read the order number range state

The running sequence lives in number-range-state. Search it filtered to the range whose numberRange.type.technicalName is order, and read its lastValue: the highest number the range has handed out so far. That value, compared against the order count, is the whole story.

step4.py
def get_order_number_range_state(token):
    """Return (state_id, last_value) for the order number range, or (None, None)."""
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "numberRange.type.technicalName", "value": "order"}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/number-range-state",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    if not rows:
        return None, None
    return rows[0]["id"], int(rows[0]["lastValue"])
step4.js
async function getOrderNumberRangeState(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "numberRange.type.technicalName", value: "order" }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/number-range-state`, {
    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 number-range-state search`);
  const data = await res.json();
  if (!data.data.length) return { stateId: null, lastValue: null };
  return { stateId: data.data[0].id, lastValue: Number(data.data[0].lastValue) };
}
5

Decide drift with one pure function

Keep the decision in its own function that takes the lastValue, the real order count, and a tolerance, and returns whether the sequence is ahead by more than that tolerance. A pure function like this has no network and is trivial to test with plain numbers. A few gaps from cancelled or deleted orders are normal, so only a gap strictly greater than the tolerance is reported as drift.

decide.py
def assess_order_number_drift(last_value, order_count, tolerance):
    """Pure decision. No I/O. The number range only moves forward, so last_value
    should sit near the real order_count. Every quote that consumes a value without
    creating an order pushes last_value ahead. A gap beyond the tolerance is drift.
    """
    drift = last_value - order_count
    return {
        "lastValue": last_value,
        "orderCount": order_count,
        "drift": drift,
        "tolerance": tolerance,
        "drifted": drift > tolerance,
    }
decide.js
export function assessOrderNumberDrift(lastValue, orderCount, tolerance) {
  // The number range only moves forward, so lastValue should sit near the real
  // orderCount. Every quote that consumes a value without creating an order pushes
  // lastValue ahead. A gap beyond the tolerance is drift.
  const drift = lastValue - orderCount;
  return {
    lastValue,
    orderCount,
    drift,
    tolerance,
    drifted: drift > tolerance,
  };
}
6

Wire it together and report

The runner ties it together. Read the range state, count the orders, run the pure function, and log the numbers. When the gap exceeds the tolerance, warn that the sequence has drifted and point at the real remedy: a dedicated quote number range. This script never writes, so it is safe to run against production any time.

Run it safe

Do not try to close the gaps by renumbering orders. Order numbers are customer-facing and may already be on an invoice or in an external system, and gaps in a sequence are harmless. This script only measures and reports the drift. The actual fix, giving the B2B quote flow its own number range so it stops drawing from the order sequence, is a deliberate configuration change you make once you have confirmed the drift.

The full code

Here is the complete script in one file for each language. It authenticates, reads the order number range's lastValue and the real order count, runs the pure drift assessment, and reports whether the sequence has drifted ahead beyond the tolerance. It never rewrites a value.

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

detect_order_number_drift.py
"""Detect and report Shopware 6 order number range drift caused by B2B quote
creation consuming the shared order number range, without ever renumbering an order.

Shopware assigns an orderNumber by reserving the next value from the "order" number
range through the number_range system. Creating or recalculating a B2B quote can reserve
a value from that same shared range, incrementing the sequence, without ever producing a
committed order. Every quote that does this leaves a permanent gap in the order numbers,
so the number range's current state drifts ahead of the real count of orders that exist.

This reads the order number range's current sequence state (its lastValue) and the actual
total count of orders through the Admin API, then a pure function decides whether the
sequence has drifted ahead of the real order count by more than a tolerance. It reports
the drift only. Renumbering existing orders is unsafe, because order numbers are
customer-facing and may already be on invoices or referenced in external systems, so this
script never rewrites a value. Run on demand. Safe to run again and again.

Guide: https://www.allanninal.dev/shopware/quote-consumes-order-number-range/
"""
import os
import logging
import requests

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

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

# How many gaps we tolerate before we call it drift. A handful of gaps is normal
# (cancelled checkouts, deleted test orders). Sustained drift points at quotes eating
# the shared order range.
DRIFT_TOLERANCE = int(os.environ.get("DRIFT_TOLERANCE", "10"))


def assess_order_number_drift(last_value, order_count, tolerance):
    """Pure decision. No I/O.

    last_value: the current sequence state of the order number range (an int), i.e. the
        highest value the range has handed out so far.
    order_count: the actual number of orders that exist (an int).
    tolerance: how many gaps to allow before reporting drift (an int).

    The number range only ever moves forward, so last_value should be close to the real
    order count. Every quote that consumes a value from the shared range without creating
    an order pushes last_value ahead of order_count. When that gap exceeds the tolerance,
    the sequence has drifted and we report it. Returns a plain dict describing the state;
    it never decides to rewrite anything.
    """
    drift = last_value - order_count
    return {
        "lastValue": last_value,
        "orderCount": order_count,
        "drift": drift,
        "tolerance": tolerance,
        "drifted": drift > tolerance,
    }


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 count_orders(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return int(r.json()["total"])


def get_order_number_range_state(token):
    """Return (state_id, last_value) for the order number range, or (None, None)."""
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "numberRange.type.technicalName", "value": "order"}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/number-range-state",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    if not rows:
        return None, None
    return rows[0]["id"], int(rows[0]["lastValue"])


def run():
    token = get_token()

    state_id, last_value = get_order_number_range_state(token)
    if state_id is None:
        log.warning("Could not find number-range-state for the order type, nothing to assess.")
        return

    order_count = count_orders(token)
    report = assess_order_number_drift(last_value, order_count, DRIFT_TOLERANCE)

    log.info(
        "Order number range state %s: lastValue=%d, orders=%d, drift=%d (tolerance %d).",
        state_id, report["lastValue"], report["orderCount"], report["drift"], report["tolerance"],
    )

    if report["drifted"]:
        log.warning(
            "DRIFT DETECTED: the order number sequence is %d ahead of the real order count, "
            "beyond the tolerance of %d. This is consistent with B2B quotes consuming the shared "
            "order number range. Reporting only; renumbering existing orders is unsafe and must "
            "be a manual decision. Consider a dedicated quote number range so quotes stop eating "
            "the order sequence.",
            report["drift"], report["tolerance"],
        )
    else:
        log.info(
            "No significant drift. The sequence is within tolerance of the real order count.",
        )

    # Reporting only. Even outside DRY_RUN this script makes no write, because renumbering
    # live orders is unsafe. DRY_RUN is surfaced so operators see the same safe posture as
    # the sibling field notes.
    log.info("Done. Reporting only, no changes made (DRY_RUN=%s).", DRY_RUN)


if __name__ == "__main__":
    run()
detect-order-number-drift.js
/**
 * Detect and report Shopware 6 order number range drift caused by B2B quote creation
 * consuming the shared order number range, without ever renumbering an order.
 *
 * Shopware assigns an orderNumber by reserving the next value from the "order" number
 * range through the number_range system. Creating or recalculating a B2B quote can
 * reserve a value from that same shared range, incrementing the sequence, without ever
 * producing a committed order. Every quote that does this leaves a permanent gap in the
 * order numbers, so the number range's current state drifts ahead of the real count of
 * orders that exist.
 *
 * This reads the order number range's current sequence state (its lastValue) and the
 * actual total count of orders through the Admin API, then a pure function decides
 * whether the sequence has drifted ahead of the real order count by more than a
 * tolerance. It reports the drift only. Renumbering existing orders is unsafe, because
 * order numbers are customer-facing and may already be on invoices or referenced in
 * external systems, so this script never rewrites a value. Run on demand.
 *
 * Guide: https://www.allanninal.dev/shopware/quote-consumes-order-number-range/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// How many gaps we tolerate before we call it drift. A handful of gaps is normal
// (cancelled checkouts, deleted test orders). Sustained drift points at quotes eating
// the shared order range.
const DRIFT_TOLERANCE = Number(process.env.DRIFT_TOLERANCE || "10");

/**
 * Pure decision. No I/O.
 *
 * The number range only ever moves forward, so lastValue should be close to the real
 * order count. Every quote that consumes a value from the shared range without creating
 * an order pushes lastValue ahead of orderCount. When that gap exceeds the tolerance, the
 * sequence has drifted and we report it. Returns a plain object describing the state; it
 * never decides to rewrite anything.
 */
export function assessOrderNumberDrift(lastValue, orderCount, tolerance) {
  const drift = lastValue - orderCount;
  return {
    lastValue,
    orderCount,
    drift,
    tolerance,
    drifted: drift > tolerance,
  };
}

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 countOrders(token) {
  const body = { page: 1, limit: 1, filter: [], "total-count-mode": 1 };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
    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 count`);
  const data = await res.json();
  return Number(data.total);
}

async function getOrderNumberRangeState(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "numberRange.type.technicalName", value: "order" }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/number-range-state`, {
    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 number-range-state search`);
  const data = await res.json();
  if (!data.data.length) return { stateId: null, lastValue: null };
  return { stateId: data.data[0].id, lastValue: Number(data.data[0].lastValue) };
}

export async function run() {
  const token = await getToken();

  const { stateId, lastValue } = await getOrderNumberRangeState(token);
  if (stateId === null) {
    console.warn("Could not find number-range-state for the order type, nothing to assess.");
    return;
  }

  const orderCount = await countOrders(token);
  const report = assessOrderNumberDrift(lastValue, orderCount, DRIFT_TOLERANCE);

  console.log(
    `Order number range state ${stateId}: lastValue=${report.lastValue}, orders=${report.orderCount}, drift=${report.drift} (tolerance ${report.tolerance}).`
  );

  if (report.drifted) {
    console.warn(
      `DRIFT DETECTED: the order number sequence is ${report.drift} ahead of the real order count, ` +
        `beyond the tolerance of ${report.tolerance}. This is consistent with B2B quotes consuming the ` +
        `shared order number range. Reporting only; renumbering existing orders is unsafe and must be a ` +
        `manual decision. Consider a dedicated quote number range so quotes stop eating the order sequence.`
    );
  } else {
    console.log("No significant drift. The sequence is within tolerance of the real order count.");
  }

  // Reporting only. Even outside DRY_RUN this script makes no write, because renumbering
  // live orders is unsafe. DRY_RUN is surfaced so operators see the same safe posture as
  // the sibling field notes.
  console.log(`Done. Reporting only, no changes made (DRY_RUN=${DRY_RUN}).`);
}

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

Add a test

The drift assessment is the part most worth testing, because it decides whether the sequence has strayed far enough to report. Because assess_order_number_drift is pure, the test needs no network and no live Shopware store, just plain numbers.

test_quote_drift.py
from detect_order_number_drift import assess_order_number_drift


def test_no_drift_when_sequence_matches_order_count():
    report = assess_order_number_drift(1000, 1000, 10)
    assert report["drift"] == 0
    assert report["drifted"] is False


def test_small_gap_within_tolerance_is_not_drift():
    # A handful of cancelled or deleted orders is normal, not quote drift.
    report = assess_order_number_drift(1005, 1000, 10)
    assert report["drift"] == 5
    assert report["drifted"] is False


def test_gap_equal_to_tolerance_is_not_drift():
    # Strictly greater than tolerance is required to report.
    report = assess_order_number_drift(1010, 1000, 10)
    assert report["drift"] == 10
    assert report["drifted"] is False


def test_gap_beyond_tolerance_is_drift():
    report = assess_order_number_drift(1050, 1000, 10)
    assert report["drift"] == 50
    assert report["drifted"] is True


def test_report_echoes_inputs():
    report = assess_order_number_drift(1234, 1200, 10)
    assert report == {
        "lastValue": 1234,
        "orderCount": 1200,
        "drift": 34,
        "tolerance": 10,
        "drifted": True,
    }


def test_sequence_behind_order_count_is_never_drift():
    # If somehow the count is higher than the sequence, drift is negative and never
    # reported. This function only ever flags the sequence running ahead.
    report = assess_order_number_drift(990, 1000, 10)
    assert report["drift"] == -10
    assert report["drifted"] is False


def test_zero_tolerance_flags_any_gap():
    assert assess_order_number_drift(1001, 1000, 0)["drifted"] is True
    assert assess_order_number_drift(1000, 1000, 0)["drifted"] is False
detect-order-number-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { assessOrderNumberDrift } from "./detect-order-number-drift.js";

test("no drift when sequence matches order count", () => {
  const report = assessOrderNumberDrift(1000, 1000, 10);
  assert.equal(report.drift, 0);
  assert.equal(report.drifted, false);
});

test("small gap within tolerance is not drift", () => {
  const report = assessOrderNumberDrift(1005, 1000, 10);
  assert.equal(report.drift, 5);
  assert.equal(report.drifted, false);
});

test("gap equal to tolerance is not drift", () => {
  const report = assessOrderNumberDrift(1010, 1000, 10);
  assert.equal(report.drift, 10);
  assert.equal(report.drifted, false);
});

test("gap beyond tolerance is drift", () => {
  const report = assessOrderNumberDrift(1050, 1000, 10);
  assert.equal(report.drift, 50);
  assert.equal(report.drifted, true);
});

test("report echoes inputs", () => {
  const report = assessOrderNumberDrift(1234, 1200, 10);
  assert.deepEqual(report, {
    lastValue: 1234,
    orderCount: 1200,
    drift: 34,
    tolerance: 10,
    drifted: true,
  });
});

test("sequence behind order count is never drift", () => {
  const report = assessOrderNumberDrift(990, 1000, 10);
  assert.equal(report.drift, -10);
  assert.equal(report.drifted, false);
});

test("zero tolerance flags any gap", () => {
  assert.equal(assessOrderNumberDrift(1001, 1000, 0).drifted, true);
  assert.equal(assessOrderNumberDrift(1000, 1000, 0).drifted, false);
});

Case studies

Quote-heavy wholesaler

The sales team that quoted more than it sold

A wholesale shop ran almost all of its business through B2B quotes: reps built a quote for nearly every prospect, revised it two or three times as terms were negotiated, and only a fraction ever converted to an order. Each quote and each recalculation drew from the shared order number range, so the sequence climbed far faster than the orders did.

Running the detection script showed a lastValue of over 40000 against barely 9000 real orders. That gap, thousands wide and far past the tolerance, confirmed the quote flow was consuming the order sequence. The team moved quotes onto their own dedicated number range, the order sequence stopped racing ahead, and the existing gaps were left untouched because they were harmless.

Audit question

The missing invoice numbers the auditor flagged

A finance team was asked during a review why their order and invoice numbering had gaps, with dozens of values simply absent. It looked alarming, as if orders had been created and then deleted, but no deletions had happened. The store had recently enabled the B2B suite and started sending quotes.

The script made the picture clear in one run: the order range's lastValue sat well above the real order count, and the drift lined up with when quoting began. With that evidence the team could explain the gaps to the auditor as reserved-but-unused numbers, not lost orders, and put quotes on a separate range so the order sequence would stay clean from then on.

What good looks like

After this runs, a runaway order sequence stops being a mystery and becomes a single reported number: how far the range has drifted ahead of the orders that actually exist. You keep the harmless historical gaps exactly as they are, and you give the B2B quote flow its own number range so it no longer draws from the order sequence. From that point the order numbers track real orders again, and a quick rerun of the script confirms the drift has stopped growing.

FAQ

Why does creating a B2B quote skip order numbers in Shopware?

Shopware assigns an order number by reserving the next value from the order number range through the number_range system. A B2B quote is built on the same order data model, so creating or recalculating a quote can reserve a value from that same shared order range, incrementing the sequence, without ever producing a committed order. The reserved value is never released, so every such quote leaves a permanent gap. Over time the order number range state drifts ahead of the real count of orders that actually exist.

Is it safe to renumber orders to close the gaps?

No. Order numbers are customer-facing, they appear on invoices and other documents, and they may already be referenced in external systems like accounting or shipping. Renumbering a live order to close a gap can break that trail, and gaps in a sequence are harmless on their own. The safe response is to detect and report the drift, then move quotes onto their own dedicated number range so they stop consuming the order sequence. The existing gaps are simply left as they are.

How does the script tell normal gaps from quote drift?

It compares the order number range's current lastValue against the actual total count of orders. A few gaps are normal from cancelled checkouts or deleted test orders, so the script only reports drift when that gap exceeds a configurable tolerance. A pure function does the comparison so it can be tested without a live store, and sustained drift beyond the tolerance is the tell-tale sign that quotes are eating the shared order number range.

Related field notes

Citations

On the problem:

  1. Shopware Developer Documentation: Number Ranges, how the order number range and its increment state work. developer.shopware.com/docs/guides/hosting/performance/number-ranges.html
  2. Shopware Developer Documentation: Add custom number ranges, showing that separate flows can and should have their own range. developer.shopware.com/docs/guides/plugins/plugins/framework/number-range/add-custom-number-ranges.html
  3. Shopware B2B Suite: Quote management, the quote flow that shares the order data model. developer.shopware.com/docs/products/extensions/b2b-suite/

On the solution:

  1. Shopware Developer Documentation: Number Ranges. developer.shopware.com/docs/guides/hosting/performance/number-ranges.html
  2. Shopware Developer Documentation: Add custom number ranges. developer.shopware.com/docs/guides/plugins/plugins/framework/number-range/add-custom-number-ranges.html
  3. NumberRange, Admin API Reference. shopware.stoplight.io/docs/admin-api/a9621db91f093-number-range

Stuck on a tricky one?

If you have a problem in Shopware orders, payments, number ranges, or documents 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 explain your runaway order numbers?

If this saved you from a confusing audit question or a wild goose chase for deleted orders, 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