Diagnostic Number Ranges and Documents

Duplicate number range configs for a sales channel desync counting

A sales channel is only ever supposed to have one number range per type, one for orders, one for customers, one for order transactions. But that rule lives only in the admin screen. Underneath it, nothing stops a sales channel from being bound to two different number ranges for the same type at once. Once that happens, the "next number" preview you see in Settings quietly stops matching the number that is actually being handed out, and it never fixes itself. Here is why the two disagree and a script that finds every sales channel this has already happened to.

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

Shopware enforces "one number range per type per sales channel" only in the Administration UI. The number_range_sales_channel mapping table has no unique constraint on (sales_channel_id, number_range_type_id), so the database and the Admin API will happily accept a second row binding the same sales channel to a different numberRangeId for the same type. When that duplicate exists, NumberRangeValueGenerator's preview logic resolves by numberRangeTypeId alone, so the preview always shows the global, type-level range's state, while real number generation increments whichever range the sales-channel-specific binding resolves to at write time. The preview and the real counter permanently disagree. This is a confirmed, reported issue (shopware/shopware#10333). Detection code, tests, and sources are below.

The problem in plain words

Every sales channel is supposed to have exactly one number range configured for each document type it issues numbers for: one for orders, one for customers, one for order transactions, and so on. Shopware's Settings screen for number ranges is built around that assumption, and it only ever lets you pick one range per type per sales channel through the UI.

The trouble is that assumption is not backed by a database constraint. The table that actually stores the binding, number_range_sales_channel, links a sales channel id to a number range id and a number range type id, but nothing in the schema or the Admin API stops two separate rows from existing for the same sales channel and the same type, each pointing at a different number range. That can happen from a rushed migration, an import script, a plugin that creates its own binding, or manual edits through the API. Once it exists, Shopware has no idea which of the two ranges is the "real" one for that sales channel, and it does not need to, because nothing forces the duplicate to be resolved.

Sales channel bound to type "order" twice Number range A (global) read by the preview Number range B (channel) actually incremented on write Admin preview shows stale value Real order numbers keep counting up
Two number ranges are bound to one sales channel for the same type. The preview reads one, the write path increments the other, and the gap never closes on its own.

Why it happens

The root cause sits in two places at once, a missing constraint and a preview that reads the wrong key. A few concrete ways stores end up here:

This is a confirmed, reported problem. See shopware/shopware#10333, "Two identical number ranges for a sales channel stop the preview and current number," and the related report in #8239 about two kinds of number range showing up for quotes. See the citations at the end for the exact threads and docs.

The key insight

This is not a counting bug you can patch by nudging a number up or down. Two different number_range records are both legitimately bound to the same sales channel and type, each with its own pattern, start value, and increment history. Picking which one is authoritative is a business decision, not something a script should guess at. Shopware has no supported "merge number ranges" endpoint, so the only safe automated step is detection: find every sales channel and type with more than one distinct numberRangeId bound to it, and hand the two competing configurations to a human to resolve.

The fix, as a flow

We never delete a binding or a number range automatically. The script pages through every number_range_sales_channel row, groups them by sales channel and type, flags any group with more than one distinct number range, pulls each conflicting range's pattern, start, and global flag for context, and prints the report plus the exact remediation command a human would run after choosing which range to keep.

Fetch all bindings number-range-sales-channel Group by channel + type salesChannelId :: typeId More than 1 range id? no, healthy yes, conflict Print both ranges: pattern, start, global for a human to compare Print suggested PATCH command, DRY_RUN guarded human runs it after choosing which range to keep
Detection only. The script never deletes a binding or a number range by itself, it only reports the conflict and the exact command a human would run.

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"   # start safe, change to false to print the write command
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"   // start safe, change to false to print the write command
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 the number-range-sales-channel search and the follow-up number-range lookups.

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_search(entity, token, body):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/{entity}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]
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 apiSearch(entity, token, body) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
    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 ${entity} search`);
  const data = await res.json();
  return data.data;
}
3

Pull every number range sales channel binding

Search number-range-sales-channel with the numberRange, salesChannel, and numberRangeType associations loaded, sorted by salesChannelId then numberRangeTypeId so rows for the same group land next to each other. Page through with page and limit since a store can have many sales channels and types.

step3.py
def fetch_bindings(token, limit=500):
    page = 1
    out = []
    while True:
        body = {
            "page": page,
            "limit": limit,
            "associations": {"numberRange": {}, "salesChannel": {}, "numberRangeType": {}},
            "sort": [
                {"field": "salesChannelId", "order": "ASC"},
                {"field": "numberRangeTypeId", "order": "ASC"},
            ],
            "total-count-mode": 1,
        }
        rows = api_search("number-range-sales-channel", token, body)
        out.extend(rows)
        if len(rows) < limit:
            return out
        page += 1
step3.js
async function fetchBindings(token, limit = 500) {
  let page = 1;
  const out = [];
  while (true) {
    const body = {
      page,
      limit,
      associations: { numberRange: {}, salesChannel: {}, numberRangeType: {} },
      sort: [
        { field: "salesChannelId", order: "ASC" },
        { field: "numberRangeTypeId", order: "ASC" },
      ],
      "total-count-mode": 1,
    };
    const rows = await apiSearch("number-range-sales-channel", token, body);
    out.push(...rows);
    if (rows.length < limit) return out;
    page++;
  }
}
4

Decide, with one pure function

Keep the decision entirely separate from the network calls. Given the full list of bindings already fetched, group them by sales channel and number range type, and flag any group whose rows point at more than one distinct numberRangeId. A group with exactly one distinct range id, even bound through several redundant rows, is not a counting conflict, only a tidiness issue. This takes no I/O, so it is trivial to test with synthetic arrays.

decide.py
def find_duplicate_number_range_bindings(bindings):
    groups = {}
    for b in bindings:
        key = (b["salesChannelId"], b["numberRangeTypeId"])
        groups.setdefault(key, []).append(b)

    conflicts = []
    for (sales_channel_id, number_range_type_id), rows in groups.items():
        distinct_range_ids = sorted({r["numberRangeId"] for r in rows})
        if len(distinct_range_ids) > 1:
            conflicts.append({
                "salesChannelId": sales_channel_id,
                "numberRangeTypeId": number_range_type_id,
                "numberRangeIds": distinct_range_ids,
                "bindingIds": [r["id"] for r in rows],
            })

    conflicts.sort(key=lambda c: (c["salesChannelId"], c["numberRangeTypeId"]))
    return conflicts
decide.js
export function findDuplicateNumberRangeBindings(bindings) {
  const groups = new Map();
  for (const b of bindings) {
    const key = `${b.salesChannelId}::${b.numberRangeTypeId}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(b);
  }

  const conflicts = [];
  for (const rows of groups.values()) {
    const distinctRangeIds = [...new Set(rows.map((r) => r.numberRangeId))].sort();
    if (distinctRangeIds.length > 1) {
      conflicts.push({
        salesChannelId: rows[0].salesChannelId,
        numberRangeTypeId: rows[0].numberRangeTypeId,
        numberRangeIds: distinctRangeIds,
        bindingIds: rows.map((r) => r.id),
      });
    }
  }

  conflicts.sort((a, b) =>
    a.salesChannelId === b.salesChannelId
      ? a.numberRangeTypeId.localeCompare(b.numberRangeTypeId)
      : a.salesChannelId.localeCompare(b.salesChannelId)
  );
  return conflicts;
}
5

Pull each conflicting range's details for the report

For every flagged numberRangeId, fetch GET /api/number-range/{id} to read the pattern, start, global flag, and name, and its state via number-range-state to see the lastValue it is really incrementing. That is the context a human needs to decide which range is authoritative before anything gets removed.

apply.py
def get_number_range(token, number_range_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/number-range/{number_range_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

def get_number_range_state(token, number_range_id):
    body = {
        "filter": [{"type": "equals", "field": "numberRangeId", "value": number_range_id}],
        "total-count-mode": 1,
    }
    rows = api_search("number-range-state", token, body)
    return rows[0] if rows else None
apply.js
async function getNumberRange(token, numberRangeId) {
  const res = await fetch(`${SHOPWARE_URL}/api/number-range/${numberRangeId}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range ${numberRangeId}`);
  const data = await res.json();
  return data.data;
}

async function getNumberRangeState(token, numberRangeId) {
  const body = {
    filter: [{ type: "equals", field: "numberRangeId", value: numberRangeId }],
    "total-count-mode": 1,
  };
  const rows = await apiSearch("number-range-state", token, body);
  return rows[0] || null;
}
6

Report the conflict and the suggested command, never delete automatically

For every conflict, print the sales channel name and id, the number range type's technicalName, and each competing range's name, id, pattern, and start value. Then print, guarded by DRY_RUN, the exact DELETE /api/number-range-sales-channel/{bindingId} command an admin would run for the extra binding once they have picked which range to keep. The script never runs that command itself.

Run it safe

Never delete a number-range-sales-channel row or a number-range record automatically. Merging two number ranges is a business decision about which pattern, start value, and increment history the store owner intends to keep, and Shopware has no supported merge API. This script only reports the conflict and prints the remediation command; a human decides and runs it.

The full code

Here is the complete script in one file for each language. It authenticates, pages through every number-range-sales-channel binding, groups and flags conflicts with the pure function, pulls each conflicting range's details, and prints a report plus the DRY_RUN guarded remediation command, never deleting anything on its own.

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

find_duplicate_number_range_bindings.py
"""Detect Shopware 6 sales channels bound to more than one number range for the
same type, without ever merging or deleting a binding automatically.

Shopware only enforces "one number range per type per sales channel" in the
Administration UI. The number_range_sales_channel mapping table has no unique
constraint on (sales_channel_id, number_range_type_id), so the database and the
Admin API accept a second row binding the same sales channel to a different
numberRangeId for the same type. NumberRangeValueGenerator's preview logic then
resolves by numberRangeTypeId alone, so the preview always shows the type's
global range while real number generation increments whichever range the sales
channel binding resolves to at write time (shopware/shopware#10333, #8239).

This only ever reports conflicts. Merging or removing a binding is a business
decision about which range's pattern, start value, and increment history is
authoritative, and there is no supported merge API, so the script prints the
exact DELETE command an admin would run and never executes it. Run on demand.
Safe to run again and again, it never writes anything.
"""
import os
import logging
import requests

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

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"

PAGE_LIMIT = 500


def find_duplicate_number_range_bindings(bindings):
    """Pure decision. No I/O. Groups number_range_sales_channel rows by
    (salesChannelId, numberRangeTypeId) and returns the groups whose rows point
    at more than one distinct numberRangeId, sorted by salesChannelId then
    numberRangeTypeId.
    """
    groups = {}
    for b in bindings:
        key = (b["salesChannelId"], b["numberRangeTypeId"])
        groups.setdefault(key, []).append(b)

    conflicts = []
    for (sales_channel_id, number_range_type_id), rows in groups.items():
        distinct_range_ids = sorted({r["numberRangeId"] for r in rows})
        if len(distinct_range_ids) > 1:
            conflicts.append({
                "salesChannelId": sales_channel_id,
                "numberRangeTypeId": number_range_type_id,
                "numberRangeIds": distinct_range_ids,
                "bindingIds": [r["id"] for r in rows],
            })

    conflicts.sort(key=lambda c: (c["salesChannelId"], c["numberRangeTypeId"]))
    return conflicts


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_search(entity, token, body):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/{entity}",
        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 fetch_bindings(token, limit=PAGE_LIMIT):
    page = 1
    out = []
    while True:
        body = {
            "page": page,
            "limit": limit,
            "associations": {"numberRange": {}, "salesChannel": {}, "numberRangeType": {}},
            "sort": [
                {"field": "salesChannelId", "order": "ASC"},
                {"field": "numberRangeTypeId", "order": "ASC"},
            ],
            "total-count-mode": 1,
        }
        rows = api_search("number-range-sales-channel", token, body)
        out.extend(rows)
        if len(rows) < limit:
            return out
        page += 1


def get_number_range(token, number_range_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/number-range/{number_range_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def run():
    token = get_token()
    bindings = fetch_bindings(token)
    conflicts = find_duplicate_number_range_bindings(bindings)

    if not conflicts:
        log.info("Done. 0 sales channel/type conflict(s) found across %d binding(s).", len(bindings))
        return

    for conflict in conflicts:
        log.warning(
            "Sales channel %s, type %s: %d competing number range(s): %s",
            conflict["salesChannelId"], conflict["numberRangeTypeId"],
            len(conflict["numberRangeIds"]), ", ".join(conflict["numberRangeIds"]),
        )
        for range_id in conflict["numberRangeIds"]:
            try:
                nr = get_number_range(token, range_id)
                log.info(
                    "  range %s: name=%s pattern=%s start=%s global=%s",
                    range_id, nr.get("name"), nr.get("pattern"), nr.get("start"), nr.get("global"),
                )
            except requests.HTTPError as exc:
                log.warning("  range %s: could not fetch details (%s)", range_id, exc)

        extra_binding_id = conflict["bindingIds"][-1]
        log.warning(
            "  %s: DELETE %s/api/number-range-sales-channel/%s  (after a human confirms which numberRangeId to keep)",
            "Suggested remediation" if DRY_RUN else "Remediation (not executed, review required)",
            SHOPWARE_URL, extra_binding_id,
        )

    log.info("Done. %d sales channel/type conflict(s) found and reported. Nothing was deleted.", len(conflicts))


if __name__ == "__main__":
    run()
find-duplicate-number-range-bindings.js
/**
 * Detect Shopware 6 sales channels bound to more than one number range for the
 * same type, without ever merging or deleting a binding automatically.
 *
 * Shopware only enforces "one number range per type per sales channel" in the
 * Administration UI. The number_range_sales_channel mapping table has no unique
 * constraint on (sales_channel_id, number_range_type_id), so the database and the
 * Admin API accept a second row binding the same sales channel to a different
 * numberRangeId for the same type. NumberRangeValueGenerator's preview logic then
 * resolves by numberRangeTypeId alone, so the preview always shows the type's
 * global range while real number generation increments whichever range the sales
 * channel binding resolves to at write time (shopware/shopware#10333, #8239).
 *
 * This only ever reports conflicts. Merging or removing a binding is a business
 * decision about which range's pattern, start value, and increment history is
 * authoritative, and there is no supported merge API, so the script prints the
 * exact DELETE command an admin would run and never executes it. Run on demand.
 * Safe to run again and again, it never writes anything.
 *
 * Guide: https://www.allanninal.dev/shopware/duplicate-number-range-sales-channel/
 */
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";

const PAGE_LIMIT = 500;

export function findDuplicateNumberRangeBindings(bindings) {
  const groups = new Map();
  for (const b of bindings) {
    const key = `${b.salesChannelId}::${b.numberRangeTypeId}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(b);
  }

  const conflicts = [];
  for (const rows of groups.values()) {
    const distinctRangeIds = [...new Set(rows.map((r) => r.numberRangeId))].sort();
    if (distinctRangeIds.length > 1) {
      conflicts.push({
        salesChannelId: rows[0].salesChannelId,
        numberRangeTypeId: rows[0].numberRangeTypeId,
        numberRangeIds: distinctRangeIds,
        bindingIds: rows.map((r) => r.id),
      });
    }
  }

  conflicts.sort((a, b) =>
    a.salesChannelId === b.salesChannelId
      ? a.numberRangeTypeId.localeCompare(b.numberRangeTypeId)
      : a.salesChannelId.localeCompare(b.salesChannelId)
  );
  return conflicts;
}

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 apiSearch(entity, token, body) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
    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 ${entity} search`);
  const data = await res.json();
  return data.data;
}

async function fetchBindings(token, limit = PAGE_LIMIT) {
  let page = 1;
  const out = [];
  while (true) {
    const body = {
      page,
      limit,
      associations: { numberRange: {}, salesChannel: {}, numberRangeType: {} },
      sort: [
        { field: "salesChannelId", order: "ASC" },
        { field: "numberRangeTypeId", order: "ASC" },
      ],
      "total-count-mode": 1,
    };
    const rows = await apiSearch("number-range-sales-channel", token, body);
    out.push(...rows);
    if (rows.length < limit) return out;
    page++;
  }
}

async function getNumberRange(token, numberRangeId) {
  const res = await fetch(`${SHOPWARE_URL}/api/number-range/${numberRangeId}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range ${numberRangeId}`);
  const data = await res.json();
  return data.data;
}

export async function run() {
  const token = await getToken();
  const bindings = await fetchBindings(token);
  const conflicts = findDuplicateNumberRangeBindings(bindings);

  if (!conflicts.length) {
    console.log(`Done. 0 sales channel/type conflict(s) found across ${bindings.length} binding(s).`);
    return;
  }

  for (const conflict of conflicts) {
    console.warn(
      `Sales channel ${conflict.salesChannelId}, type ${conflict.numberRangeTypeId}: ${conflict.numberRangeIds.length} competing number range(s): ${conflict.numberRangeIds.join(", ")}`
    );
    for (const rangeId of conflict.numberRangeIds) {
      try {
        const nr = await getNumberRange(token, rangeId);
        console.log(`  range ${rangeId}: name=${nr.name} pattern=${nr.pattern} start=${nr.start} global=${nr.global}`);
      } catch (err) {
        console.warn(`  range ${rangeId}: could not fetch details (${err.message})`);
      }
    }

    const extraBindingId = conflict.bindingIds[conflict.bindingIds.length - 1];
    console.warn(
      `  ${DRY_RUN ? "Suggested remediation" : "Remediation (not executed, review required)"}: DELETE ${SHOPWARE_URL}/api/number-range-sales-channel/${extraBindingId}  (after a human confirms which numberRangeId to keep)`
    );
  }

  console.log(`Done. ${conflicts.length} sales channel/type conflict(s) found and reported. Nothing was deleted.`);
}

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

Add a test

The grouping function is the part most worth testing, because it decides which sales channels get reported as a genuine counting conflict versus a harmless redundant row. Because find_duplicate_number_range_bindings is pure, the test needs no network and no live Shopware store.

test_duplicate_bindings.py
from find_duplicate_number_range_bindings import find_duplicate_number_range_bindings


def binding(id, sales_channel_id, number_range_type_id, number_range_id):
    return {
        "id": id,
        "salesChannelId": sales_channel_id,
        "numberRangeTypeId": number_range_type_id,
        "numberRangeId": number_range_id,
    }


def test_no_duplicates_returns_empty():
    bindings = [binding("b1", "sc1", "t-order", "nr1")]
    assert find_duplicate_number_range_bindings(bindings) == []


def test_exact_duplicate_rows_same_range_not_flagged():
    bindings = [
        binding("b1", "sc1", "t-order", "nr1"),
        binding("b2", "sc1", "t-order", "nr1"),
    ]
    assert find_duplicate_number_range_bindings(bindings) == []


def test_true_conflict_two_different_ranges_same_channel_and_type():
    bindings = [
        binding("b1", "sc1", "t-order", "nr1"),
        binding("b2", "sc1", "t-order", "nr2"),
    ]
    result = find_duplicate_number_range_bindings(bindings)
    assert len(result) == 1
    assert result[0]["salesChannelId"] == "sc1"
    assert result[0]["numberRangeTypeId"] == "t-order"
    assert result[0]["numberRangeIds"] == ["nr1", "nr2"]
    assert set(result[0]["bindingIds"]) == {"b1", "b2"}


def test_conflicts_across_multiple_channels_and_types_are_all_reported():
    bindings = [
        binding("b1", "sc1", "t-order", "nr1"),
        binding("b2", "sc1", "t-order", "nr2"),
        binding("b3", "sc1", "t-customer", "nr3"),
        binding("b4", "sc2", "t-order", "nr4"),
        binding("b5", "sc2", "t-order", "nr5"),
    ]
    result = find_duplicate_number_range_bindings(bindings)
    assert len(result) == 2
    assert result[0]["salesChannelId"] == "sc1"
    assert result[1]["salesChannelId"] == "sc2"


def test_result_sorted_by_sales_channel_then_type():
    bindings = [
        binding("b1", "sc2", "t-order", "nr1"),
        binding("b2", "sc2", "t-order", "nr2"),
        binding("b3", "sc1", "t-order", "nr3"),
        binding("b4", "sc1", "t-order", "nr4"),
    ]
    result = find_duplicate_number_range_bindings(bindings)
    assert [c["salesChannelId"] for c in result] == ["sc1", "sc2"]
duplicate-bindings.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateNumberRangeBindings } from "./find-duplicate-number-range-bindings.js";

const binding = (id, salesChannelId, numberRangeTypeId, numberRangeId) => ({
  id,
  salesChannelId,
  numberRangeTypeId,
  numberRangeId,
});

test("no duplicates returns empty", () => {
  const bindings = [binding("b1", "sc1", "t-order", "nr1")];
  assert.deepEqual(findDuplicateNumberRangeBindings(bindings), []);
});

test("exact duplicate rows for the same range are not flagged", () => {
  const bindings = [
    binding("b1", "sc1", "t-order", "nr1"),
    binding("b2", "sc1", "t-order", "nr1"),
  ];
  assert.deepEqual(findDuplicateNumberRangeBindings(bindings), []);
});

test("true conflict, two different ranges for the same channel and type", () => {
  const bindings = [
    binding("b1", "sc1", "t-order", "nr1"),
    binding("b2", "sc1", "t-order", "nr2"),
  ];
  const result = findDuplicateNumberRangeBindings(bindings);
  assert.equal(result.length, 1);
  assert.equal(result[0].salesChannelId, "sc1");
  assert.equal(result[0].numberRangeTypeId, "t-order");
  assert.deepEqual(result[0].numberRangeIds, ["nr1", "nr2"]);
  assert.deepEqual(new Set(result[0].bindingIds), new Set(["b1", "b2"]));
});

test("conflicts across multiple channels and types are all reported", () => {
  const bindings = [
    binding("b1", "sc1", "t-order", "nr1"),
    binding("b2", "sc1", "t-order", "nr2"),
    binding("b3", "sc1", "t-customer", "nr3"),
    binding("b4", "sc2", "t-order", "nr4"),
    binding("b5", "sc2", "t-order", "nr5"),
  ];
  const result = findDuplicateNumberRangeBindings(bindings);
  assert.equal(result.length, 2);
  assert.equal(result[0].salesChannelId, "sc1");
  assert.equal(result[1].salesChannelId, "sc2");
});

test("result is sorted by sales channel then type", () => {
  const bindings = [
    binding("b1", "sc2", "t-order", "nr1"),
    binding("b2", "sc2", "t-order", "nr2"),
    binding("b3", "sc1", "t-order", "nr3"),
    binding("b4", "sc1", "t-order", "nr4"),
  ];
  const result = findDuplicateNumberRangeBindings(bindings);
  assert.deepEqual(result.map((c) => c.salesChannelId), ["sc1", "sc2"]);
});

Case studies

Migration leftover

The preview that lied for two months

A store migrated its order number range configuration during a platform upgrade. The old sales-channel-specific range for orders was meant to be replaced by a new global range, but the migration script added the new binding without removing the old one. Settings kept showing a "next number" preview that never matched the number that actually landed on the next order, and staff assumed it was just a display refresh bug.

Running the detection script found the sales channel bound to two number ranges for the order type, with the old range's binding still sitting in number_range_sales_channel. Once the report showed both ranges' patterns and start values side by side, the team could see which one matched their real order numbering and had someone remove the stale binding by hand.

Plugin conflict

Two plugins configuring the same type

Two installed plugins each shipped their own setup step that created a number range and bound it to the storefront sales channel for customers, unaware the other had already done the same thing. Neither plugin checked for an existing binding first. Customer numbers kept incrementing fine, but the admin's number range preview for that sales channel stopped making sense to the support team.

The report flagged the sales channel and customer type conflict immediately, with both plugins' ranges named clearly enough to tell them apart. The store owner picked the one actually driving customer numbers, and an admin removed the other plugin's now-redundant binding after confirming with that plugin's vendor.

What good looks like

After running this, every sales channel with a genuine number range conflict shows up in one report: the sales channel, the type, both competing ranges with their pattern, start, and global flag, and the exact command to remove the extra binding once a human has decided which range to keep. The admin preview is never trusted blindly again, and nothing is merged or deleted without someone confirming which counting history is the real one.

FAQ

Why does the number range preview in Shopware admin not match the real next number?

This happens when a sales channel has been bound to two different number ranges for the same type, such as order. Shopware only enforces one intended range per sales channel per type in the admin UI, not in the database, so the number_range_sales_channel table can hold two rows for the same sales channel and type pointing at different number ranges. The preview always resolves by type, so it shows one range's state, while the actual number generation increments whichever range the sales channel binding resolves to at write time.

How do I find sales channels with duplicate number range bindings?

Search number-range-sales-channel with the numberRange, salesChannel, and numberRangeType associations loaded, then group the rows by salesChannelId and numberRangeTypeId together. Any group whose rows point at more than one distinct numberRangeId is a genuine conflict worth reviewing, not just a harmless duplicate row bound to the same range twice.

Can I just delete one of the duplicate number range bindings automatically?

No. Shopware has no supported merge operation for number ranges, and deleting the wrong number_range_sales_channel row or the wrong number_range record can break already-issued document or order numbers, or remove an in-use global fallback. The safe approach is to report the conflict with both ranges' pattern, start, and current state, and let a human decide which one to keep before anything is deleted.

Related field notes

Citations

On the problem:

  1. Two identical number ranges for a sales channel stop the preview and current number (shopware/shopware #10333). github.com/shopware/shopware/issues/10333
  2. Shopware 6 Settings: Number ranges. docs.shopware.com/en/shopware-6-en/settings/Numberranges
  3. There are 2 kinds of number range for Quote(s) (shopware/shopware #8239). github.com/shopware/shopware/issues/8239

On the solution:

  1. NumberRangeSalesChannel entity reference, Admin API. shopware.stoplight.io number-range-sales-channel
  2. Admin API, Shopware Developer Documentation. developer.shopware.com/docs/concepts/api/admin-api.html
  3. Entity Reference, Admin API. shopware.stoplight.io entity-reference

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 a counter that never matched?

If this saved you from a number range headache, 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