Repair Fulfillment

Routed to an out-of-stock location

The order looks fine everywhere except the one place it matters: the location it got assigned to has nothing to pick. Staff open it in the warehouse and there is no stock, so it just sits there, OPEN or IN_PROGRESS, going nowhere. Here is why Shopify routes orders this way and a small script that finds the ones stuck on a dead location and moves them to one that can actually ship.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A close up of a book with writing on it
Photo by Brett Jordan on Unsplash
The short answer

Shopify assigns a fulfillment order to a location at order time using routing rules and whatever inventory data it has then. If the stock at that location was never really there, or a count changed right after, the order is stuck on a location with nothing to pick. Run a small Python or Node.js script that lists the fulfillment orders assigned to the problem location, asks Shopify with locationsForMove which other locations could cover every remaining line item, and calls fulfillmentOrderMove to reassign only the ones with a full match. Full code, tests, and a dry run guard are below.

The problem in plain words

When an order comes in, Shopify decides which location should fulfill each line item. It looks at your location priority, shipping zones, and the inventory it believes each location has, then assigns a fulfillment order to whichever location wins that decision.

That decision is only as good as the inventory data behind it. A location can be marked as stocking an item when the shelf is actually empty, a manual count can lag what really happened in the warehouse, or a routing rule can send every order from a region to one location regardless of what is on hand there. The fulfillment order gets created anyway, sitting at OPEN or IN_PROGRESS, and no one notices until a picker opens it and finds nothing to grab.

Order placed routing rules apply Location assigned based on stale stock nothing on the shelf No stock to pick at the assigned location Order stuck OPEN
The order and the routing rule both look correct. The problem is that the location they agreed on never actually had the stock to fill it.

Why it happens

Shopify's fulfillment order routing trusts the inventory numbers it has at the moment the order is placed. A few common ways that trust breaks down:

This is a common source of confusion. Store owners see the order sitting as OPEN and assume it is a shipping delay, not a routing problem, so it can sit for days before anyone checks the location itself. Shopify does let staff move a fulfillment order by hand in the admin, but doing that one order at a time does not scale once several orders land on the same dead location, and it is easy to move an order to a place that only has half the items in stock. See the citations at the end for the exact docs.

The key insight

Moving a fulfillment order is not just "send it somewhere else." A partial move can split one order into two fulfillment orders shipping from two places, which usually is not what a merchant wants for a small order. So the safe pattern is to only move an order to a location that Shopify itself confirms, through locationsForMove, can cover every remaining line item in one go. If nothing qualifies, we leave the order alone for a human to check.

The fix, as a flow

We do not touch inventory counts or routing rules. We add a job that lists the fulfillment orders assigned to a known problem location, asks Shopify which other locations could take the work, keeps only the locations that can cover every line item still owed, and calls the same move Shopify's admin uses. Anything without a full match is skipped for a human to look at.

Scheduled job runs on a timer List stuck orders at the problem location Read locationsForMove candidates and coverage Full match found? yes no, skip fulfillmentOrderMove order can now ship
The script only moves an order when a candidate location can cover every remaining line item. Everything else is skipped for a human to check.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_assigned_fulfillment_orders, read_merchant_managed_fulfillment_orders, and write_merchant_managed_fulfillment_orders scopes, then install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export OUT_OF_STOCK_LOCATION_ID="gid://shopify/Location/123456789"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export OUT_OF_STOCK_LOCATION_ID="gid://shopify/Location/123456789"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read fulfillment orders and to run the move.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List the fulfillment orders stuck at the problem location

Ask the location for its fulfillment orders, and for each one read its status, its remaining line items, and the candidate locations from locationsForMove, including which line items each candidate can actually cover. We page through with a cursor so the job handles a large backlog.

step3.py
FULFILLMENT_ORDERS_QUERY = """
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    fulfillmentOrders(first: 25, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        status
        lineItems(first: 50) { nodes { id remainingQuantity } }
        locationsForMove(first: 10) {
          nodes {
            location { id name }
            message
            availableLineItems(first: 50) { nodes { id remainingQuantity } }
          }
        }
      }
    }
  }
}"""

def stuck_fulfillment_orders():
    cursor = None
    while True:
        data = gql(FULFILLMENT_ORDERS_QUERY, {"cursor": cursor, "locationId": PROBLEM_LOCATION_ID})
        page = data["location"]["fulfillmentOrders"]
        for node in page["nodes"]:
            yield node
        if not page["pageInfo"]["hasNextPage"]:
            return
        cursor = page["pageInfo"]["endCursor"]
step3.js
const FULFILLMENT_ORDERS_QUERY = `
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    fulfillmentOrders(first: 25, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        status
        lineItems(first: 50) { nodes { id remainingQuantity } }
        locationsForMove(first: 10) {
          nodes {
            location { id name }
            message
            availableLineItems(first: 50) { nodes { id remainingQuantity } }
          }
        }
      }
    }
  }
}`;

async function* stuckFulfillmentOrders() {
  let cursor = null;
  while (true) {
    const data = await gql(FULFILLMENT_ORDERS_QUERY, { cursor, locationId: PROBLEM_LOCATION_ID });
    const page = data.location.fulfillmentOrders;
    for (const node of page.nodes) yield node;
    if (!page.pageInfo.hasNextPage) return;
    cursor = page.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a fulfillment order and returns a location id to move to, or nothing. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The order must still be movable, it must have quantity left to fulfill, and a candidate location must be able to cover every remaining unit, not just some of them. Among locations that qualify, we pick the one with the most coverage. If nothing qualifies, we leave the order alone.

decide.py
MOVABLE_STATUSES = {"OPEN", "IN_PROGRESS"}

def total_remaining(line_items):
    return sum(item.get("remainingQuantity", 0) for item in line_items or [])

def pick_reroute_location(fulfillment_order):
    if fulfillment_order.get("status") not in MOVABLE_STATUSES:
        return None

    needed = total_remaining(fulfillment_order.get("lineItems", {}).get("nodes"))
    if needed <= 0:
        return None

    best_id = None
    best_covered = -1
    for candidate in fulfillment_order.get("locationsForMove", {}).get("nodes", []):
        covered = total_remaining(candidate.get("availableLineItems", {}).get("nodes"))
        if covered < needed:
            continue
        if covered > best_covered:
            best_covered = covered
            best_id = candidate["location"]["id"]

    return best_id
decide.js
const MOVABLE_STATUSES = new Set(["OPEN", "IN_PROGRESS"]);

export function totalRemaining(lineItems) {
  return (lineItems || []).reduce((sum, item) => sum + (item.remainingQuantity || 0), 0);
}

export function pickRerouteLocation(fulfillmentOrder) {
  if (!MOVABLE_STATUSES.has(fulfillmentOrder.status)) return null;

  const needed = totalRemaining(fulfillmentOrder.lineItems?.nodes);
  if (needed <= 0) return null;

  let bestId = null;
  let bestCovered = -1;
  for (const candidate of fulfillmentOrder.locationsForMove?.nodes || []) {
    const covered = totalRemaining(candidate.availableLineItems?.nodes);
    if (covered < needed) continue;
    if (covered > bestCovered) {
      bestCovered = covered;
      bestId = candidate.location.id;
    }
  }
  return bestId;
}
5

Move the fulfillment order the way the Admin UI would

When a candidate qualifies, call the fulfillmentOrderMove mutation with the fulfillment order id and the new location id. Shopify reassigns the eligible line items to the new location. Always read back userErrors. If Shopify refuses the move, for example because progress was already reported by hand, the error tells you why, and the script should stop on it rather than pretend it worked.

apply.py
MOVE_MUTATION = """
mutation($id: ID!, $newLocationId: ID!) {
  fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
    movedFulfillmentOrder { id status }
    userErrors { field message }
  }
}"""

def move_fulfillment_order(fulfillment_order_id, new_location_id):
    result = gql(MOVE_MUTATION, {"id": fulfillment_order_id, "newLocationId": new_location_id})["fulfillmentOrderMove"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["movedFulfillmentOrder"]["status"]
apply.js
const MOVE_MUTATION = `
mutation($id: ID!, $newLocationId: ID!) {
  fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
    movedFulfillmentOrder { id status }
    userErrors { field message }
  }
}`;

async function moveFulfillmentOrder(fulfillmentOrderId, newLocationId) {
  const result = (await gql(MOVE_MUTATION, { id: fulfillmentOrderId, newLocationId })).fulfillmentOrderMove;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.movedFulfillmentOrder.status;
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which fulfillment orders it would move and where. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how fast new orders land on the problem location, for example every fifteen minutes.

Run it safe

Always start with DRY_RUN=true, and only move an order when a candidate location can cover every remaining line item. A partial move can split one order into two shipments from two places, so the strict full-coverage rule is what keeps this safe to run unattended.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only moves fulfillment orders when Shopify itself confirms a location can cover the entire remaining order.

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

reroute_out_of_stock_fulfillment.py
"""Move Shopify fulfillment orders off a location that cannot stock them.

An order can land on a location that shows OPEN or IN_PROGRESS but has no
usable inventory for one or more line items there, often because a location
rule or the customer's address routed it there before a stock count caught
up. Shopify will not fulfill from a location with nothing to pick, so the
order stalls. This lists fulfillment orders assigned to a "problem" location,
asks Shopify which other locations could take the line items with
locationsForMove, picks the best candidate in pure code, and calls
fulfillmentOrderMove to reassign it. Read only apart from the move.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
PROBLEM_LOCATION_ID = os.environ["OUT_OF_STOCK_LOCATION_ID"]
MOVABLE_STATUSES = {"OPEN", "IN_PROGRESS"}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

FULFILLMENT_ORDERS_QUERY = """
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    fulfillmentOrders(first: 25, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        status
        lineItems(first: 50) { nodes { id remainingQuantity } }
        locationsForMove(first: 10) {
          nodes {
            location { id name }
            message
            availableLineItems(first: 50) { nodes { id remainingQuantity } }
          }
        }
      }
    }
  }
}"""

MOVE_MUTATION = """
mutation($id: ID!, $newLocationId: ID!) {
  fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
    movedFulfillmentOrder { id status }
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def total_remaining(line_items):
    return sum(item.get("remainingQuantity", 0) for item in line_items or [])


def pick_reroute_location(fulfillment_order):
    """Pure decision: which location (if any) should this fulfillment order move to.

    Returns a candidate location id, or None if the order should be left alone.
    The order is only a candidate when it is still movable (OPEN or IN_PROGRESS)
    and it has unfulfilled line items. Among the locations Shopify reports with
    locationsForMove, we keep only the ones that can cover every remaining line
    item (no partial moves that would split the order in two), and we pick the
    one that covers the most remaining quantity so the busiest order clears
    first when several locations qualify.
    """
    if fulfillment_order.get("status") not in MOVABLE_STATUSES:
        return None

    needed = total_remaining(fulfillment_order.get("lineItems", {}).get("nodes"))
    if needed <= 0:
        return None

    best_id = None
    best_covered = -1
    for candidate in fulfillment_order.get("locationsForMove", {}).get("nodes", []):
        covered = total_remaining(candidate.get("availableLineItems", {}).get("nodes"))
        if covered < needed:
            continue
        if covered > best_covered:
            best_covered = covered
            best_id = candidate["location"]["id"]

    return best_id


def stuck_fulfillment_orders():
    cursor = None
    while True:
        data = gql(FULFILLMENT_ORDERS_QUERY, {"cursor": cursor, "locationId": PROBLEM_LOCATION_ID})
        location = data.get("location")
        if location is None:
            return
        page = location["fulfillmentOrders"]
        for node in page["nodes"]:
            yield node
        if not page["pageInfo"]["hasNextPage"]:
            return
        cursor = page["pageInfo"]["endCursor"]


def move_fulfillment_order(fulfillment_order_id, new_location_id):
    result = gql(MOVE_MUTATION, {"id": fulfillment_order_id, "newLocationId": new_location_id})["fulfillmentOrderMove"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["movedFulfillmentOrder"]["status"]


def run():
    moved = 0
    for fulfillment_order in stuck_fulfillment_orders():
        target = pick_reroute_location(fulfillment_order)
        if target is None:
            continue
        log.info(
            "Fulfillment order %s can move to %s. %s",
            fulfillment_order["id"], target, "would move" if DRY_RUN else "moving",
        )
        if not DRY_RUN:
            move_fulfillment_order(fulfillment_order["id"], target)
        moved += 1
    log.info("Done. %d fulfillment order(s) %s.", moved, "to move" if DRY_RUN else "moved")


if __name__ == "__main__":
    run()
reroute-out-of-stock-fulfillment.js
/**
 * Move Shopify fulfillment orders off a location that cannot stock them.
 *
 * An order can land on a location that shows OPEN or IN_PROGRESS but has no
 * usable inventory for one or more line items there, often because a location
 * rule or the customer's address routed it there before a stock count caught
 * up. Shopify will not fulfill from a location with nothing to pick, so the
 * order stalls. This lists fulfillment orders assigned to a "problem"
 * location, asks Shopify which other locations could take the line items
 * with locationsForMove, picks the best candidate in pure code, and calls
 * fulfillmentOrderMove to reassign it. Run on a schedule.
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const PROBLEM_LOCATION_ID = process.env.OUT_OF_STOCK_LOCATION_ID || "gid://shopify/Location/1";
const MOVABLE_STATUSES = new Set(["OPEN", "IN_PROGRESS"]);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function totalRemaining(lineItems) {
  return (lineItems || []).reduce((sum, item) => sum + (item.remainingQuantity || 0), 0);
}

export function pickRerouteLocation(fulfillmentOrder) {
  if (!MOVABLE_STATUSES.has(fulfillmentOrder.status)) return null;

  const needed = totalRemaining(fulfillmentOrder.lineItems?.nodes);
  if (needed <= 0) return null;

  let bestId = null;
  let bestCovered = -1;
  for (const candidate of fulfillmentOrder.locationsForMove?.nodes || []) {
    const covered = totalRemaining(candidate.availableLineItems?.nodes);
    if (covered < needed) continue;
    if (covered > bestCovered) {
      bestCovered = covered;
      bestId = candidate.location.id;
    }
  }
  return bestId;
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const FULFILLMENT_ORDERS_QUERY = `
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    fulfillmentOrders(first: 25, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        status
        lineItems(first: 50) { nodes { id remainingQuantity } }
        locationsForMove(first: 10) {
          nodes {
            location { id name }
            message
            availableLineItems(first: 50) { nodes { id remainingQuantity } }
          }
        }
      }
    }
  }
}`;

const MOVE_MUTATION = `
mutation($id: ID!, $newLocationId: ID!) {
  fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
    movedFulfillmentOrder { id status }
    userErrors { field message }
  }
}`;

async function* stuckFulfillmentOrders() {
  let cursor = null;
  while (true) {
    const data = await gql(FULFILLMENT_ORDERS_QUERY, { cursor, locationId: PROBLEM_LOCATION_ID });
    const location = data.location;
    if (!location) return;
    const page = location.fulfillmentOrders;
    for (const node of page.nodes) yield node;
    if (!page.pageInfo.hasNextPage) return;
    cursor = page.pageInfo.endCursor;
  }
}

async function moveFulfillmentOrder(fulfillmentOrderId, newLocationId) {
  const result = (await gql(MOVE_MUTATION, { id: fulfillmentOrderId, newLocationId })).fulfillmentOrderMove;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.movedFulfillmentOrder.status;
}

export async function run() {
  let moved = 0;
  for await (const fulfillmentOrder of stuckFulfillmentOrders()) {
    const target = pickRerouteLocation(fulfillmentOrder);
    if (!target) continue;
    console.log(`Fulfillment order ${fulfillmentOrder.id} can move to ${target}. ${DRY_RUN ? "would move" : "moving"}`);
    if (!DRY_RUN) await moveFulfillmentOrder(fulfillmentOrder.id, target);
    moved++;
  }
  console.log(`Done. ${moved} fulfillment order(s) ${DRY_RUN ? "to move" : "moved"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether a real order gets reassigned to a real location. Because we kept pick_reroute_location pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.

test_routed_reroute_decision.py
from reroute_out_of_stock_fulfillment import pick_reroute_location, total_remaining


def line_item(remaining=1, item_id="gid://shopify/FulfillmentOrderLineItem/1"):
    return {"id": item_id, "remainingQuantity": remaining}


def candidate(location_id, covered_items, name="Warehouse B"):
    return {
        "location": {"id": location_id, "name": name},
        "message": None,
        "availableLineItems": {"nodes": covered_items},
    }


def fulfillment_order(status="OPEN", line_items=None, candidates=None):
    return {
        "id": "gid://shopify/FulfillmentOrder/1",
        "status": status,
        "lineItems": {"nodes": line_items if line_items is not None else [line_item(2)]},
        "locationsForMove": {"nodes": candidates or []},
    }


def test_skips_orders_that_are_not_movable():
    order = fulfillment_order(status="CLOSED", candidates=[candidate("gid://shopify/Location/2", [line_item(2)])])
    assert pick_reroute_location(order) is None


def test_skips_orders_with_nothing_left_to_fulfill():
    order = fulfillment_order(line_items=[line_item(0)], candidates=[candidate("gid://shopify/Location/2", [line_item(0)])])
    assert pick_reroute_location(order) is None


def test_skips_when_no_candidate_covers_all_remaining_quantity():
    order = fulfillment_order(line_items=[line_item(5)], candidates=[candidate("gid://shopify/Location/2", [line_item(3)])])
    assert pick_reroute_location(order) is None


def test_picks_the_candidate_with_the_most_coverage_when_several_qualify():
    order = fulfillment_order(
        line_items=[line_item(2)],
        candidates=[
            candidate("gid://shopify/Location/2", [line_item(2)]),
            candidate("gid://shopify/Location/3", [line_item(9)]),
        ],
    )
    assert pick_reroute_location(order) == "gid://shopify/Location/3"
reroute.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { totalRemaining, pickRerouteLocation } from "./reroute-out-of-stock-fulfillment.js";

const lineItem = (remaining = 1, id = "gid://shopify/FulfillmentOrderLineItem/1") => ({ id, remainingQuantity: remaining });

const candidate = (locationId, coveredItems, name = "Warehouse B") => ({
  location: { id: locationId, name },
  message: null,
  availableLineItems: { nodes: coveredItems },
});

const fulfillmentOrder = ({ status = "OPEN", lineItems = [lineItem(2)], candidates = [] } = {}) => ({
  id: "gid://shopify/FulfillmentOrder/1",
  status,
  lineItems: { nodes: lineItems },
  locationsForMove: { nodes: candidates },
});

test("skips orders that are not movable", () => {
  const order = fulfillmentOrder({ status: "CLOSED", candidates: [candidate("gid://shopify/Location/2", [lineItem(2)])] });
  assert.equal(pickRerouteLocation(order), null);
});

test("skips when no candidate covers all remaining quantity", () => {
  const order = fulfillmentOrder({ lineItems: [lineItem(5)], candidates: [candidate("gid://shopify/Location/2", [lineItem(3)])] });
  assert.equal(pickRerouteLocation(order), null);
});

test("picks the candidate with the most coverage when several qualify", () => {
  const order = fulfillmentOrder({
    lineItems: [lineItem(2)],
    candidates: [
      candidate("gid://shopify/Location/2", [lineItem(2)]),
      candidate("gid://shopify/Location/3", [lineItem(9)]),
    ],
  });
  assert.equal(pickRerouteLocation(order), "gid://shopify/Location/3");
});

Case studies

Regional routing rule

The rule sent every order to a warehouse with no stock

A furniture brand set a routing rule that sent all orders from one state to its closest warehouse, to save on shipping. That warehouse ran out of a popular chair for two weeks, but the rule kept assigning orders there anyway, because it never checked live availability.

The job caught the backlog on its first run in dry run, showed exactly which orders could move to the main distribution center that still had stock, and after a quick review the team let it run for real. Every stuck order shipped the same day, and they added an alert for the next time a location runs dry.

Stale inventory count

A manual count never matched the shelf

A small retailer with two stores fulfilled online orders from whichever store had stock on paper. One store's count had drifted high for months, so it kept getting assigned orders that its shelves could not fill, and staff resorted to fulfilling them by hand from the other store every time.

Once the script started running hourly, it moved the misassigned orders to the store that could actually cover them, using the same full-coverage rule described above. No orders split into partial shipments, and the manual fixing stopped.

What good looks like

After this runs on a schedule, an order routed to a dead location is a quick reassignment away from actually shipping, without anyone manually clicking through the admin. The move only happens when a location can cover the whole order, so nothing splits into surprise partial shipments. Keep an eye on the ones the script skips, since those usually point at a location that needs a real stock count.

FAQ

Why did my Shopify order get assigned to a location with no stock?

Shopify assigns a fulfillment order to a location using routing rules and inventory data at the moment the order is placed. If a stock count changes right after, or a routing rule ignores real availability, the order can end up assigned to a location that has nothing to pick, and it will not fulfill from there.

Is it safe to move a fulfillment order to another location automatically?

Yes, when the script only moves an order to a location that Shopify itself reports through locationsForMove as able to cover every remaining line item. That keeps the move from splitting the order into two partial shipments, and it runs in dry run first so you see the plan before anything changes.

What does locationsForMove tell you about a fulfillment order?

locationsForMove lists the locations a fulfillment order could move to and, for each one, which of its line items that location can actually cover. Comparing that coverage against what the order still needs is how the script decides whether a move is safe.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: how fulfillment order routing assigns a location to each order. help.shopify.com/en/manual/fulfillment/managing-orders/assigning-fulfillment-locations
  2. Shopify Help Center: managing inventory across multiple locations. help.shopify.com/en/manual/products/inventory/managing-inventory/multiple-locations
  3. Shopify Community: fulfillment orders stuck at a location with no available inventory. community.shopify.com shopify apis and sdks

On the solution:

  1. Shopify Admin GraphQL: the fulfillmentOrderMove mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderMove
  2. Shopify Admin GraphQL: the FulfillmentOrder object, including locationsForMove and line items. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder
  3. Shopify Admin GraphQL: the FulfillmentOrderLocationForMove object. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrderLocationForMove

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment 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 get your orders moving again?

If this saved you a pile of manual reassignments or a warehouse full of confused pickers, 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 Shopify field notes