Diagnostic Orders & Fulfillment
Partial fulfillment breaks on a zero quantity line
You want to ship four of the five lines on an order right now and leave the fifth for later, on purpose. That should be a normal partial fulfillment. Instead the orderFulfill call comes back with an error about a quantity, the whole mutation aborts, and the order is stuck in PARTIALLY_FULFILLED with the remaining line never touched. Here is why a zero quantity line trips this up and a script that finds the stuck orders and reports the exact payload that would actually pass.
Old Saleor rejected a fulfillment line quantity of 0 outright because FulfillmentLine.quantity carried a Django MinValueValidator(1) (see saleor/saleor#3758). Modern Saleor fixed that at the mutation layer: OrderFulfillStockInput.quantity now accepts 0, and clean_input() in order_fulfill.py silently drops any stock entry with quantity <= 0. But two other guards still bite naive multi-line scripts. check_total_quantity_of_items raises ZERO_QUANTITY if every submitted line sums to zero, and clean_order_line_quantities raises FULFILL_ORDER_LINE ("Only N item(s) remaining to fulfill") if a resent quantity exceeds that line's current quantityUnfulfilled. Either failure aborts the whole call atomically, leaving the order stuck. Run a small Python or Node.js script that finds these stuck orders and reports, per order, the corrected lines array that would actually pass validation, for staff to confirm before it runs for real.
The problem in plain words
Saleor's orderFulfill mutation is atomic. You hand it a list of lines, each with the stock and quantity to ship, and it either creates the whole fulfillment or it creates none of it. That is good behavior in general, but it means one bad line in a five-line payload can sink the other four.
Historically the problem was blunt: FulfillmentLine.quantity had a Django MinValueValidator(1), so a script that intentionally sent quantity 0 for a line it wanted to skip got back "Ensure this value is greater than or equal to 1" and the whole call died (saleor/saleor#3758, filed in 2019). Modern Saleor 3.x fixed that specific complaint at the mutation input layer. OrderFulfillStockInput.quantity accepts 0 today, and clean_input() in saleor/graphql/order/mutations/order_fulfill.py filters out any stock entry where quantity <= 0 before it ever reaches the database.
The trap now is two steps removed from that original bug, and both live in validation that runs before any fulfillment is created. check_total_quantity_of_items sums every line's requested quantity across the whole payload and raises OrderErrorCode.ZERO_QUANTITY if that sum is zero. That happens when a script always includes the intentionally-skipped line at quantity 0 instead of omitting it, and a separate bug or a stale variable then zeroes every other line as well, so the mutation sees nothing to fulfill at all. Separately, clean_order_line_quantities in saleor/order/utils.py checks each requested quantity against that line's live quantityUnfulfilled and raises OrderErrorCode.FULFILL_ORDER_LINE, with a message like "Only 2 item(s) remaining to fulfill," if the payload asks for more than what remains. That is exactly what happens when a retried or rebuilt payload resends an old quantity for a line a prior partial fulfillment already partly consumed.
Why it happens
- Historically,
FulfillmentLine.quantitycarried a DjangoMinValueValidator(1), so submitting quantity 0 to intentionally skip a line threw "Ensure this value is greater than or equal to 1" (saleor/saleor#3758). - Modern Saleor 3.x fixed that at the mutation layer.
OrderFulfillStockInput.quantityaccepts 0, andclean_input()inorder_fulfill.pysilently skips any stock entry withquantity <= 0, so a single zeroed line no longer crashes the call by itself. check_total_quantity_of_itemsstill raisesOrderErrorCode.ZERO_QUANTITYif every submitted line across the whole payload sums to zero, which happens when a script always resends the intentionally-skipped line at quantity 0 and a bug zeroes the rest too.clean_order_line_quantitiesinsaleor/order/utils.pyraisesOrderErrorCode.FULFILL_ORDER_LINE("Only N item(s) remaining to fulfill") if a retried or rebuilt payload resends a quantity greater than that line's currentquantityUnfulfilled, for example after an earlier partial fulfillment already consumed part of it.- Because
orderFulfillvalidates and creates fulfillments for the entire mutation atomically, either failure mode aborts the whole call, leaving the order inPARTIALLY_FULFILLEDwith the remaining line never fulfilled.
None of this corrupts data. The order is simply left exactly where it was before the failed call, correctly PARTIALLY_FULFILLED, with no fulfillment created for the retry. The confusing part is that the error message names a symptom, a quantity problem, when the real cause is upstream: the payload was built without checking each line's live quantityToFulfill first. See the citations at the end for the exact GitHub issues and docs.
Sending quantity: 0 for a line you want to leave unfulfilled is not wrong exactly, Saleor tolerates it, but it is fragile: if that becomes the only nonzero-looking line in a payload, or if it happens to be the last line standing after a bug zeroes the others, the whole call sums to zero and dies. The safer pattern is to never send a zero-quantity stock entry at all. Omit that line's entry from the array entirely, exactly the way clean_input's own if stock["quantity"] > 0 filter treats it internally. And always clamp each requested quantity to that line's current quantityToFulfill before building the payload, since Saleor does not clamp for you, it just rejects the whole call if you get it wrong.
The fix, as a flow
We do not touch the live order automatically. A scheduled job finds orders that are PARTIALLY_FULFILLED with a line still needing fulfillment past an SLA, recomputes each line's real quantityToFulfill, and runs a pure planner that builds the corrected lines array: any line clamped to zero is dropped instead of sent as quantity 0, and if the whole array would come out empty, we short-circuit before ever calling orderFulfill. The result is a dry run report an operator reviews and confirms before anything writes.
Build it step by step
Get an app token with order read and manage access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders. It also needs permission to manage orders since the confirmed repair path calls orderFulfill. Use the app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export FULFILLMENT_WAREHOUSE_ID="V2FyZWhvdXNlOjE="
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export FULFILLMENT_WAREHOUSE_ID="V2FyZWhvdXNlOjE="
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {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"]
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
List partially fulfilled orders and read each line's real remainder
Ask for orders(filter: { status: [PARTIALLY_FULFILLED] }) and expand lines { id quantity quantityFulfilled } and fulfillments { id status lines { orderLine { id } quantity } }. Compute quantityToFulfill = quantity - quantityFulfilled per line, since that is the true remainder regardless of what any stale script variable thinks it is.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 25, after: $cursor, filter: { status: [PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
lines { id quantity quantityFulfilled }
fulfillments { id status lines { orderLine { id } quantity } }
}
}
}
}"""
def partially_fulfilled_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 25, after: $cursor, filter: { status: [PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
lines { id quantity quantityFulfilled }
fulfillments { id status lines { orderLine { id } quantity } }
}
}
}
}`;
async function* partiallyFulfilledOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the payload-building logic in its own function that takes the order's lines, the requested quantities, and a warehouse id, and returns the corrected lines array with no I/O at all. For each line, clamp the requested quantity to [0, quantityToFulfill]. If the clamped quantity is 0, drop that line entirely rather than send it as quantity: 0, mirroring Saleor's own if stock["quantity"] > 0 filter. If the result is empty, return an empty array so the caller can short-circuit before ever calling orderFulfill, avoiding the ZERO_QUANTITY total-sum rejection. If any requested quantity exceeds that line's remainder, return a validation marker instead of silently clamping upward, since clamping up would just move the FULFILL_ORDER_LINE error from Saleor to us with less context.
class FulfillOrderLineError(Exception):
"""Raised when a requested quantity exceeds a line's quantityToFulfill.
Mirrors Saleor's OrderErrorCode.FULFILL_ORDER_LINE instead of silently clamping up."""
def plan_fulfillment_lines(order_lines, requested_quantities, warehouse_id):
result = []
for line in order_lines:
line_id = line["orderLineId"]
to_fulfill = line["quantityToFulfill"]
requested = requested_quantities.get(line_id, 0)
if requested > to_fulfill:
raise FulfillOrderLineError(
f"Only {to_fulfill} item(s) remaining to fulfill for line {line_id}"
)
clamped = max(0, min(requested, to_fulfill))
if clamped == 0:
continue # omit the line entirely, never send quantity: 0
result.append({
"orderLineId": line_id,
"stocks": [{"warehouseId": warehouse_id, "quantity": clamped}],
})
return result
export class FulfillOrderLineError extends Error {}
export function planFulfillmentLines(orderLines, requestedQuantities, warehouseId) {
const result = [];
for (const line of orderLines) {
const lineId = line.orderLineId;
const toFulfill = line.quantityToFulfill;
const requested = requestedQuantities[lineId] ?? 0;
if (requested > toFulfill) {
throw new FulfillOrderLineError(
`Only ${toFulfill} item(s) remaining to fulfill for line ${lineId}`
);
}
const clamped = Math.max(0, Math.min(requested, toFulfill));
if (clamped === 0) continue; // omit the line entirely, never send quantity: 0
result.push({
orderLineId: lineId,
stocks: [{ warehouseId, quantity: clamped }],
});
}
return result;
}
Report the corrected payload, do not auto-fulfill by default
When a stuck order is found, run plan_fulfillment_lines and write a report entry with orderId, per-line quantityToFulfill, and the corrected orderFulfill lines array. If the planner returns an empty array, the order has nothing genuinely left to fulfill right now, so skip it rather than call the mutation. Only when DRY_RUN=false and an operator has confirmed the plan should the script call orderFulfill, and it must check errors { field code message } for ZERO_QUANTITY or FULFILL_ORDER_LINE before treating the call as done.
ORDER_FULFILL = """
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}"""
def fulfill_order(order_id, lines):
result = gql(ORDER_FULFILL, {"order": order_id, "input": {"lines": lines, "notifyCustomer": False}})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
const ORDER_FULFILL = `
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}`;
async function fulfillOrder(orderId, lines) {
const result = (await gql(ORDER_FULFILL, { order: orderId, input: { lines, notifyCustomer: false } })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs the report entry for each stuck order: {orderId, number, lines}. It never calls orderFulfill unless an operator has reviewed the exact plan and switched the flag off. Run it on a schedule that matches how quickly you want a stuck partial fulfillment surfaced, for example every hour.
Always start with DRY_RUN=true and read the corrected lines array the report prints before switching it off. The correct remaining quantity per line is business data an operator must confirm, since only a human knows whether that fifth line was really meant to stay unfulfilled or whether it should ship too.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through PARTIALLY_FULFILLED orders, plans the corrected lines with the pure function, reports every stuck order, and only writes when DRY_RUN is off.
"""Report Saleor orders stuck in PARTIALLY_FULFILLED because a prior
orderFulfill retry hit ZERO_QUANTITY or FULFILL_ORDER_LINE and aborted the
whole call (see saleor/saleor#3758, saleor/saleor#6136, and the orderFulfill
docs). This script never calls orderFulfill by default. Under DRY_RUN=true
(the default) it only logs a report entry per stuck order with the corrected
lines array that would actually pass validation. Only with DRY_RUN=false,
after an operator confirms the plan, does it call orderFulfill for real.
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("repair_partial_fulfillment")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
WAREHOUSE_ID = os.environ.get("FULFILLMENT_WAREHOUSE_ID", "V2FyZWhvdXNlOjE=")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 25, after: $cursor, filter: { status: [PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
lines { id quantity quantityFulfilled }
fulfillments { id status lines { orderLine { id } quantity } }
}
}
}
}"""
ORDER_FULFILL = """
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}"""
class FulfillOrderLineError(Exception):
"""Raised when a requested quantity exceeds a line's quantityToFulfill.
Mirrors Saleor's OrderErrorCode.FULFILL_ORDER_LINE instead of silently clamping up."""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {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 plan_fulfillment_lines(order_lines, requested_quantities, warehouse_id):
result = []
for line in order_lines:
line_id = line["orderLineId"]
to_fulfill = line["quantityToFulfill"]
requested = requested_quantities.get(line_id, 0)
if requested > to_fulfill:
raise FulfillOrderLineError(
f"Only {to_fulfill} item(s) remaining to fulfill for line {line_id}"
)
clamped = max(0, min(requested, to_fulfill))
if clamped == 0:
continue # omit the line entirely, never send quantity: 0
result.append({
"orderLineId": line_id,
"stocks": [{"warehouseId": warehouse_id, "quantity": clamped}],
})
return result
def to_plain_lines(node):
return [
{
"orderLineId": line["id"],
"quantityToFulfill": line["quantity"] - line["quantityFulfilled"],
}
for line in node["lines"]
]
def partially_fulfilled_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def fulfill_order(order_id, lines):
result = gql(ORDER_FULFILL, {"order": order_id, "input": {"lines": lines, "notifyCustomer": False}})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
def run():
reported = 0
for node in partially_fulfilled_orders():
plain_lines = to_plain_lines(node)
remaining = {ln["orderLineId"]: ln["quantityToFulfill"] for ln in plain_lines}
# Request the full remainder on every line still owed; already-settled
# lines have quantityToFulfill 0 and will be dropped by the planner.
try:
planned = plan_fulfillment_lines(plain_lines, remaining, WAREHOUSE_ID)
except FulfillOrderLineError as exc:
log.error("Order %s could not be planned: %s", node["number"], exc)
continue
if not planned:
continue # nothing genuinely left to fulfill, skip rather than call orderFulfill
report_entry = {
"orderId": node["id"],
"number": node["number"],
"lines": planned,
}
log.warning("Stuck order found. %s %s", report_entry,
"(dry run, reporting only)" if DRY_RUN else "(reporting only, confirm before writing)")
reported += 1
if not DRY_RUN:
fulfill_order(node["id"], planned)
log.info("Order %s fulfilled with corrected lines.", node["number"])
log.info("Done. %d stuck order(s) reported.", reported)
if __name__ == "__main__":
run()
/**
* Report Saleor orders stuck in PARTIALLY_FULFILLED because a prior
* orderFulfill retry hit ZERO_QUANTITY or FULFILL_ORDER_LINE and aborted the
* whole call (see saleor/saleor#3758, saleor/saleor#6136, and the orderFulfill
* docs). This script never calls orderFulfill by default. Under DRY_RUN=true
* (the default) it only logs a report entry per stuck order with the
* corrected lines array that would actually pass validation. Only with
* DRY_RUN=false, after an operator confirms the plan, does it call
* orderFulfill for real. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/partial-fulfillment-breaks-zero-quantity-line/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const WAREHOUSE_ID = process.env.FULFILLMENT_WAREHOUSE_ID || "V2FyZWhvdXNlOjE=";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export class FulfillOrderLineError extends Error {}
export function planFulfillmentLines(orderLines, requestedQuantities, warehouseId) {
const result = [];
for (const line of orderLines) {
const lineId = line.orderLineId;
const toFulfill = line.quantityToFulfill;
const requested = requestedQuantities[lineId] ?? 0;
if (requested > toFulfill) {
throw new FulfillOrderLineError(
`Only ${toFulfill} item(s) remaining to fulfill for line ${lineId}`
);
}
const clamped = Math.max(0, Math.min(requested, toFulfill));
if (clamped === 0) continue; // omit the line entirely, never send quantity: 0
result.push({
orderLineId: lineId,
stocks: [{ warehouseId, quantity: clamped }],
});
}
return result;
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 25, after: $cursor, filter: { status: [PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
lines { id quantity quantityFulfilled }
fulfillments { id status lines { orderLine { id } quantity } }
}
}
}
}`;
const ORDER_FULFILL = `
mutation($order: ID!, $input: OrderFulfillInput!) {
orderFulfill(order: $order, input: $input) {
fulfillments { id status }
errors { field code message }
}
}`;
async function* partiallyFulfilledOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function fulfillOrder(orderId, lines) {
const result = (await gql(ORDER_FULFILL, { order: orderId, input: { lines, notifyCustomer: false } })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
function toPlainLines(node) {
return node.lines.map((line) => ({
orderLineId: line.id,
quantityToFulfill: line.quantity - line.quantityFulfilled,
}));
}
export async function run() {
let reported = 0;
for await (const node of partiallyFulfilledOrders()) {
const plainLines = toPlainLines(node);
const remaining = Object.fromEntries(plainLines.map((ln) => [ln.orderLineId, ln.quantityToFulfill]));
let planned;
try {
planned = planFulfillmentLines(plainLines, remaining, WAREHOUSE_ID);
} catch (err) {
console.error(`Order ${node.number} could not be planned:`, err.message);
continue;
}
if (planned.length === 0) continue; // nothing genuinely left, skip rather than call orderFulfill
const reportEntry = { orderId: node.id, number: node.number, lines: planned };
console.warn("Stuck order found.", reportEntry, DRY_RUN ? "(dry run, reporting only)" : "(reporting only, confirm before writing)");
reported++;
if (!DRY_RUN) {
await fulfillOrder(node.id, planned);
console.log(`Order ${node.number} fulfilled with corrected lines.`);
}
}
console.log(`Done. ${reported} stuck order(s) reported.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The planner is the part most worth testing, because it decides which lines actually get submitted to orderFulfill. Because plan_fulfillment_lines is pure, taking plain arrays and objects with no network calls, the test needs no Saleor account. It just feeds in numbers and strings and checks the answer.
import pytest
from repair_partial_fulfillment import plan_fulfillment_lines, FulfillOrderLineError
WAREHOUSE = "V2FyZWhvdXNlOjE="
def test_plans_only_lines_with_remaining_quantity():
lines = [
{"orderLineId": "line-1", "quantityToFulfill": 3},
{"orderLineId": "line-2", "quantityToFulfill": 2},
]
requested = {"line-1": 3, "line-2": 2}
result = plan_fulfillment_lines(lines, requested, WAREHOUSE)
assert result == [
{"orderLineId": "line-1", "stocks": [{"warehouseId": WAREHOUSE, "quantity": 3}]},
{"orderLineId": "line-2", "stocks": [{"warehouseId": WAREHOUSE, "quantity": 2}]},
]
def test_drops_line_clamped_to_zero_instead_of_sending_quantity_zero():
lines = [
{"orderLineId": "line-1", "quantityToFulfill": 3},
{"orderLineId": "line-2", "quantityToFulfill": 2},
]
# line-2 is the intentionally-skipped line: not present in requested at all
requested = {"line-1": 3}
result = plan_fulfillment_lines(lines, requested, WAREHOUSE)
assert len(result) == 1
assert result[0]["orderLineId"] == "line-1"
def test_returns_empty_list_when_every_line_is_already_settled():
lines = [
{"orderLineId": "line-1", "quantityToFulfill": 0},
{"orderLineId": "line-2", "quantityToFulfill": 0},
]
result = plan_fulfillment_lines(lines, {}, WAREHOUSE)
assert result == []
def test_negative_requested_quantity_is_clamped_to_zero_and_dropped():
lines = [{"orderLineId": "line-1", "quantityToFulfill": 3}]
result = plan_fulfillment_lines(lines, {"line-1": -5}, WAREHOUSE)
assert result == []
def test_raises_fulfill_order_line_error_when_requested_exceeds_remaining():
lines = [{"orderLineId": "line-1", "quantityToFulfill": 2}]
with pytest.raises(FulfillOrderLineError):
plan_fulfillment_lines(lines, {"line-1": 5}, WAREHOUSE)
def test_exact_remaining_quantity_is_allowed():
lines = [{"orderLineId": "line-1", "quantityToFulfill": 4}]
result = plan_fulfillment_lines(lines, {"line-1": 4}, WAREHOUSE)
assert result[0]["stocks"][0]["quantity"] == 4
import { test } from "node:test";
import assert from "node:assert/strict";
import { planFulfillmentLines, FulfillOrderLineError } from "./repair-partial-fulfillment.js";
const WAREHOUSE = "V2FyZWhvdXNlOjE=";
test("plans only lines with remaining quantity", () => {
const lines = [
{ orderLineId: "line-1", quantityToFulfill: 3 },
{ orderLineId: "line-2", quantityToFulfill: 2 },
];
const requested = { "line-1": 3, "line-2": 2 };
const result = planFulfillmentLines(lines, requested, WAREHOUSE);
assert.deepEqual(result, [
{ orderLineId: "line-1", stocks: [{ warehouseId: WAREHOUSE, quantity: 3 }] },
{ orderLineId: "line-2", stocks: [{ warehouseId: WAREHOUSE, quantity: 2 }] },
]);
});
test("drops a line clamped to zero instead of sending quantity 0", () => {
const lines = [
{ orderLineId: "line-1", quantityToFulfill: 3 },
{ orderLineId: "line-2", quantityToFulfill: 2 },
];
// line-2 is the intentionally-skipped line: not present in requested at all
const requested = { "line-1": 3 };
const result = planFulfillmentLines(lines, requested, WAREHOUSE);
assert.equal(result.length, 1);
assert.equal(result[0].orderLineId, "line-1");
});
test("returns an empty array when every line is already settled", () => {
const lines = [
{ orderLineId: "line-1", quantityToFulfill: 0 },
{ orderLineId: "line-2", quantityToFulfill: 0 },
];
const result = planFulfillmentLines(lines, {}, WAREHOUSE);
assert.deepEqual(result, []);
});
test("a negative requested quantity is clamped to zero and dropped", () => {
const lines = [{ orderLineId: "line-1", quantityToFulfill: 3 }];
const result = planFulfillmentLines(lines, { "line-1": -5 }, WAREHOUSE);
assert.deepEqual(result, []);
});
test("throws FulfillOrderLineError when requested exceeds remaining", () => {
const lines = [{ orderLineId: "line-1", quantityToFulfill: 2 }];
assert.throws(() => planFulfillmentLines(lines, { "line-1": 5 }, WAREHOUSE), FulfillOrderLineError);
});
test("the exact remaining quantity is allowed", () => {
const lines = [{ orderLineId: "line-1", quantityToFulfill: 4 }];
const result = planFulfillmentLines(lines, { "line-1": 4 }, WAREHOUSE);
assert.equal(result[0].stocks[0].quantity, 4);
});
Case studies
The five-line order with one backordered item
A furniture retailer's warehouse app fulfilled four in-stock lines on a five-line order right away and always resent the fifth, backordered line at quantity 0 so the payload shape stayed consistent across retries. A restock delay meant a second automated retry ran before the backorder cleared, and this time a stale cached quantity zeroed a fifth line that should have carried 2 units, leaving all five lines summed to zero. The whole call died with ZERO_QUANTITY, and even the four in-stock lines that should have shipped stayed unfulfilled.
Switching to the planner fixed both problems at once. Lines with nothing left to fulfill are simply omitted instead of resent at quantity 0, and the report caught the stale-quantity bug in the retry logic before it ever reached Saleor again. Staff reviewed the corrected payload for the stuck order, confirmed it, and the four ready lines shipped immediately.
A timeout retry resent a quantity that was already partly fulfilled
A fulfillment integration timed out waiting for a response from orderFulfill on a large order, and its retry logic resent the exact same payload assuming nothing had happened. In reality the first call had succeeded and had already fulfilled 3 of 5 units on one line before the timeout occurred client-side. The retry asked for all 5 again, quantityUnfulfilled was only 2, and Saleor rejected the whole retry with FULFILL_ORDER_LINE, blocking two other lines that were still genuinely pending.
Running the report script against the account surfaced the order immediately, with the real quantityToFulfill of 2 for that line printed right next to the stale request of 5. The team fixed the retry logic to always re-read quantityToFulfill before rebuilding a payload, and confirmed the one-off correction through the report for the order already stuck.
After this runs on a schedule, a partial fulfillment that stalls on a zero-sum or stale-quantity payload stops being a silent dead end. It shows up in a report with the exact corrected lines array an operator can read, confirm, and apply in one pass, and the planner's own clamping keeps a fresh retry from making the same mistake twice. Nothing gets auto-corrected behind anyone's back, since the true remaining quantity per line is business data only a person can confirm.
FAQ
Why does orderFulfill fail with ZERO_QUANTITY when I try to leave one line unfulfilled?
Saleor's check_total_quantity_of_items rejects a fulfillment call the moment every submitted line sums to a quantity of zero. This usually happens when a script always includes the intentionally-skipped line in its payload with quantity 0 instead of omitting that line entirely, and a bug then zeroes every other line too, so the whole call has nothing left to fulfill.
What does the FULFILL_ORDER_LINE error mean on orderFulfill?
It means clean_order_line_quantities rejected a line because the requested quantity is greater than that line's current quantityUnfulfilled, with a message like Only N item(s) remaining to fulfill. It commonly happens on a retried or rebuilt payload that resends a quantity from before a prior partial fulfillment already consumed part of that line.
Can I submit a fulfillment line with quantity 0 to skip it in Saleor?
Modern Saleor accepts a quantity of 0 in OrderFulfillStockInput and clean_input silently drops that stock entry, so it will not crash the mutation by itself. The safer and clearer pattern is to omit the line entirely from the lines array rather than sending quantity 0, since that also avoids tripping the ZERO_QUANTITY check when every line in a payload happens to be zero.
Related field notes
Citations
On the problem:
- Partial Fulfillment Not Working when 1 item not fulfilled at all. github.com/saleor/saleor/issues/3758
- Cannot fulfill order when product quantity is 1. github.com/saleor/saleor/issues/6136
- Saleor Commerce Documentation: Order Fulfillment. docs.saleor.io/developer/order/order-fulfillment
On the solution:
- Saleor Commerce Documentation: the orderFulfill mutation. docs.saleor.io/api-reference/orders/mutations/order-fulfill
- Saleor Commerce Documentation: the Fulfillment object. docs.saleor.io/api-reference/orders/objects/fulfillment
- Saleor Commerce Documentation: the OrderLine object. docs.saleor.io/api-reference/orders/objects/order-line
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock, channels, 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.
Did this unstick a partial fulfillment for you?
If this saved you from chasing a vague quantity error through the whole mutation, 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