Diagnostic Inventory (MSI)
Magento 2 negative source item quantity still counted as positive stock
One source has 2 units, another has 3, and a third has been driven to minus 29 by a drop-ship deficit or an oversell tracking rule. Add them the way a spreadsheet would and the stock is clearly wiped out. But Magento's own indexer can compute a combined salable quantity for that SKU that reads as positive and available, because a negative source is only forced to zero when that source is explicitly marked out of stock. Here is why the sign gets lost in the sum and a small script that catches the impossible total before a customer buys stock that does not exist.
MSI legitimately allows a source_item to carry a negative quantity, for example a drop-ship source or an oversell tracking source recording a deficit. Magento's stock indexer, Magento\InventoryIndexer\Indexer\SelectBuilder::execute, uses getCheckSql() to force a source's quantity to 0 in the sum only when that source's is_in_stock flag is 0. When the negative source is left marked in-stock, or the out-of-stock branch never triggers for how sources are combined across a stock's linked source list, its raw negative quantity is added directly into the SUM() that becomes the stock's combined salable quantity. Two healthy sources of 2 and 3 units plus a masked negative 29 can compute as positive and salable instead of correctly zeroing the product out. This is tracked upstream as magento/inventory#3346 and magento/inventory#3165, both still open, so a store has to detect this itself. A script cannot safely rewrite the value, since only a human knows whether the negative row is bad data or an intentional deficit signal, so it flags the SKU and stock pair, and only performs a guarded corrective write when an operator has confirmed it. Full code, tests, and a dry run guard are below.
The problem in plain words
A stock in MSI is not one number. It is the sum of every source linked to it, and each source keeps its own quantity and its own is_in_stock status. Most of the time every linked source carries a quantity of zero or more, so summing them is harmless. But MSI was built to allow a source's quantity to go negative on purpose, for cases like a drop-ship source that tracks a deficit until a supplier restocks, or an oversell source recording exactly how far a location has gone below zero.
The indexer that turns those source rows into the one combined salable quantity a stock reports does try to guard against a source that should not count at all: when a source's is_in_stock flag is 0, getCheckSql() forces that source's contribution to 0 instead of its raw quantity. But that guard only fires for sources explicitly flagged out of stock. A source can carry a negative quantity while still being marked in-stock, or the way sources are joined and combined across a stock's linked list can bypass that branch, and in either case the raw negative number is summed exactly as written. A depleted or oversold source that should have zeroed the product out instead cancels part or all of the positive quantity from healthy sources, and the arithmetic can even flip the sign, so a SKU that should read as out of stock reads as salable.
Why it happens
- A source's
is_in_stockflag is left at 1 even though itsquantityhas been driven negative, sogetCheckSql()never substitutes 0 for that row and the raw negative number reaches the sum. - A drop-ship or oversell tracking source is deliberately allowed to carry a negative quantity as a signal of how far it has gone below zero, and that design intent gets combined with healthy sources in the same stock without anyone checking the combined total makes sense.
- The way sources are joined across a stock's linked source list in the indexer query can route around the out-of-stock zeroing branch depending on how many sources feed that stock and their individual flags.
- Nobody reindexes and manually checks the combined number against a naive sum of the raw source rows, so the masked total is never noticed until a customer manages to buy stock that was never really there.
This is tracked upstream and still open as of Magento 2.4.x. See the citations at the end for the exact issues.
A negative source quantity is not inherently wrong. MSI allows it on purpose. What is wrong is when that negative quantity gets masked, when the combined sum across a stock's sources comes out non-negative even though one of the contributing sources is deeply negative and marked out of stock, or effectively acts that way. That is an impossible total: a source cannot subtract from availability and simultaneously have no visible effect on it. So the right move is not to guess whether every negative row is bad data. It is to detect the exact signature where a negative, deficit-carrying source is being cancelled out by other sources instead of correctly zeroing the SKU, and only take a corrective action once a human confirms which case it is.
The fix, as a flow
We do not touch the indexer or the reservation ledger. We add a job that reads the raw source rows behind a stock, the stock-to-source links, and the authoritative salable quantity Magento reports, runs a pure decision function to spot the impossible-total signature, and reports it. Only when an operator has reviewed and confirmed a row is bad data does the script perform a guarded write to zero it, and even then it tells the operator a CLI reindex is still required.
Build it step by step
Get an admin bearer token
The script authenticates like any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" # start safe, change to false to allow the guarded zero-out write
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" // start safe, change to false to allow the guarded zero-out write
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and PUT and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_put(path, payload):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json() if r.content else None
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.status === 204 ? null : res.json();
}
Enumerate stock-to-source links, then pull the raw source rows
GET /rest/V1/inventory/stocks lists every stock, and GET /rest/V1/inventory/stock-source-links tells you which sources feed each stock_id. For a given SKU, GET /rest/V1/inventory/source-items filtered by SKU returns each source's raw source_code, quantity, and status (0 out of stock, 1 in stock). Group those rows by the stock they belong to using the links, since the impossible-total check has to run per SKU, per stock.
def get_stock_source_links():
data = magento_get("/inventory/stock-source-links", {"searchCriteria[pageSize]": 200})
return data["items"]
def get_source_items_for_sku(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/inventory/source-items", params)["items"]
def group_rows_by_stock(source_items, stock_source_links):
code_to_stocks = {}
for link in stock_source_links:
code_to_stocks.setdefault(link["source_code"], []).append(link["stock_id"])
grouped = {}
for item in source_items:
for stock_id in code_to_stocks.get(item["source_code"], []):
grouped.setdefault(stock_id, []).append({
"sourceCode": item["source_code"],
"quantity": item["quantity"],
"status": item["status"],
})
return grouped
async function getStockSourceLinks() {
const data = await magentoGet("/inventory/stock-source-links", { "searchCriteria[pageSize]": 200 });
return data.items;
}
async function getSourceItemsForSku(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items;
}
function groupRowsByStock(sourceItems, stockSourceLinks) {
const codeToStocks = {};
for (const link of stockSourceLinks) {
(codeToStocks[link.source_code] ||= []).push(link.stock_id);
}
const grouped = {};
for (const item of sourceItems) {
const stockIds = codeToStocks[item.source_code] || [];
for (const stockId of stockIds) {
(grouped[stockId] ||= []).push({
sourceCode: item.source_code,
quantity: item.quantity,
status: item.status,
});
}
}
return grouped;
}
Decide, with one pure function
Keep the decision in its own function that takes only the source rows for one stock and returns whether the total is impossible. A pure function like this is easy to read and easy to test, which we do later. The rule: sum every row's quantity. If at least one row has a negative quantity, and the naive sum is non-negative, or the sum otherwise fails to propagate an out-of-stock negative row's deficit, that is the impossible-total signature, because a genuinely depleted source cannot vanish from the total without a trace.
def is_impossible_stock_total(source_rows):
total = sum(row["quantity"] for row in source_rows)
negative_sources = [row["sourceCode"] for row in source_rows if row["quantity"] < 0]
if not negative_sources:
return {"flagged": False, "sum": total, "negativeSources": [], "reason": None}
masked = total >= 0 or any(
row["quantity"] < 0 and row["status"] == 0 and total > row["quantity"]
for row in source_rows
)
if not masked:
return {"flagged": False, "sum": total, "negativeSources": negative_sources, "reason": None}
culprit = next(row for row in source_rows if row["quantity"] < 0)
status_label = "out_of_stock" if culprit["status"] == 0 else "in_stock"
reason = (
f"source {culprit['sourceCode']} qty={culprit['quantity']} status={status_label} "
f"masked, sum={total} treated as salable"
)
return {"flagged": True, "sum": total, "negativeSources": negative_sources, "reason": reason}
export function isImpossibleStockTotal(sourceRows) {
const sum = sourceRows.reduce((acc, row) => acc + row.quantity, 0);
const negativeSources = sourceRows.filter((row) => row.quantity < 0).map((row) => row.sourceCode);
if (negativeSources.length === 0) {
return { flagged: false, sum, negativeSources: [], reason: null };
}
const masked =
sum >= 0 ||
sourceRows.some((row) => row.quantity < 0 && row.status === 0 && sum > row.quantity);
if (!masked) {
return { flagged: false, sum, negativeSources, reason: null };
}
const culprit = sourceRows.find((row) => row.quantity < 0);
const statusLabel = culprit.status === 0 ? "out_of_stock" : "in_stock";
const reason = `source ${culprit.sourceCode} qty=${culprit.quantity} status=${statusLabel} masked, sum=${sum} treated as salable`;
return { flagged: true, sum, negativeSources, reason };
}
Cross-check against the authoritative salable quantity, then report or guard-fix
Confirm the defect against Magento's own answer with GET /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId}. If that live value diverges from, or masks, the naive negative-inclusive sum, report the SKU, stock id, offending source codes, their quantity and status, and both numbers. There is no supported API to force a recalculation, since reindex is CLI-only. If an operator confirms a row is bad data, the guarded write zeros it with a source-items upsert, and only when DRY_RUN=false. Always re-fetch the salable quantity after a live write and remind the operator a CLI reindex is still required for the storefront to reflect it.
def get_salable_quantity(sku, stock_id):
return float(magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}"))
def zero_out_source_item(sku, source_code):
payload = {"sourceItems": [{"sku": sku, "source_code": source_code, "quantity": 0, "status": 0}]}
if DRY_RUN:
log.info("DRY_RUN: would PUT /inventory/source-items with %s", payload)
return
magento_put("/inventory/source-items", payload)
log.warning("Zeroed %s at source %s. Re-check salable qty, then run a CLI reindex.", sku, source_code)
async function getSalableQuantity(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return Number(data);
}
async function zeroOutSourceItem(sku, sourceCode) {
const payload = { sourceItems: [{ sku, source_code: sourceCode, quantity: 0, status: 0 }] };
if (DRY_RUN) {
console.log(`DRY_RUN: would PUT /inventory/source-items with`, JSON.stringify(payload));
return;
}
await magentoPut("/inventory/source-items", payload);
console.warn(`Zeroed ${sku} at source ${sourceCode}. Re-check salable qty, then run a CLI reindex.`);
}
Always start with DRY_RUN=true. This script never silently rewrites stock, since only a human can judge whether a negative row is a data-entry error, an intentional drop-ship deficit signal, or a legitimate oversell that needs a supplier reorder. It only reports the impossible-total signature by default, and performs the guarded zero-out write only when an operator has confirmed the row is bad and set DRY_RUN=false. A CLI reindex is always still required afterward.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, cross-checks the source rows and the authoritative salable quantity per SKU and stock, runs the pure decision function, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Flag Magento 2 SKUs where a negative source_item quantity is masked as positive stock.
MSI legitimately allows a source_item to carry a negative quantity, for a drop-ship
or oversell tracking source signalling a deficit. Magento's indexer, SelectBuilder::execute,
only forces a source's contribution to 0 in the SUM() when that source's is_in_stock flag
is 0 (via getCheckSql()). When a negative-quantity source is left marked in-stock, or the
zeroing branch never fires for how sources combine into a stock, the raw negative number is
summed as is, and a depleted source can cancel out or invert the sign of healthy sources,
producing an impossible positive salable total. Tracked upstream as magento/inventory#3346
and #3165, both open. This script never rewrites source_items automatically. It reports the
impossible-total signature per SKU and stock, and only performs the guarded zero-out write
after DRY_RUN is explicitly set to false, which an operator should only do once they have
confirmed the negative row is bad data. Safe to run again and again in report mode.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_negative_source_masked")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_put(path, payload):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json() if r.content else None
def get_stock_source_links():
data = magento_get("/inventory/stock-source-links", {"searchCriteria[pageSize]": 200})
return data["items"]
def get_source_items_for_sku(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/inventory/source-items", params)["items"]
def group_rows_by_stock(source_items, stock_source_links):
code_to_stocks = {}
for link in stock_source_links:
code_to_stocks.setdefault(link["source_code"], []).append(link["stock_id"])
grouped = {}
for item in source_items:
for stock_id in code_to_stocks.get(item["source_code"], []):
grouped.setdefault(stock_id, []).append({
"sourceCode": item["source_code"],
"quantity": item["quantity"],
"status": item["status"],
})
return grouped
def is_impossible_stock_total(source_rows):
total = sum(row["quantity"] for row in source_rows)
negative_sources = [row["sourceCode"] for row in source_rows if row["quantity"] < 0]
if not negative_sources:
return {"flagged": False, "sum": total, "negativeSources": [], "reason": None}
masked = total >= 0 or any(
row["quantity"] < 0 and row["status"] == 0 and total > row["quantity"]
for row in source_rows
)
if not masked:
return {"flagged": False, "sum": total, "negativeSources": negative_sources, "reason": None}
culprit = next(row for row in source_rows if row["quantity"] < 0)
status_label = "out_of_stock" if culprit["status"] == 0 else "in_stock"
reason = (
f"source {culprit['sourceCode']} qty={culprit['quantity']} status={status_label} "
f"masked, sum={total} treated as salable"
)
return {"flagged": True, "sum": total, "negativeSources": negative_sources, "reason": reason}
def get_salable_quantity(sku, stock_id):
return float(magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}"))
def zero_out_source_item(sku, source_code):
payload = {"sourceItems": [{"sku": sku, "source_code": source_code, "quantity": 0, "status": 0}]}
if DRY_RUN:
log.info("DRY_RUN: would PUT /inventory/source-items with %s", payload)
return
magento_put("/inventory/source-items", payload)
log.warning("Zeroed %s at source %s. Re-check salable qty, then run a CLI reindex.", sku, source_code)
def run(skus=None, fix_source_codes=None):
skus = skus or []
fix_source_codes = fix_source_codes or {}
stock_source_links = get_stock_source_links()
flagged = 0
for sku in skus:
source_items = get_source_items_for_sku(sku)
grouped = group_rows_by_stock(source_items, stock_source_links)
for stock_id, rows in grouped.items():
result = is_impossible_stock_total(rows)
if not result["flagged"]:
continue
salable_qty = get_salable_quantity(sku, stock_id)
log.warning(
"SKU %s stock %s: %s naive_sum=%s live_salable=%s",
sku, stock_id, result["reason"], result["sum"], salable_qty,
)
flagged += 1
confirmed_bad_source = fix_source_codes.get(sku)
if confirmed_bad_source and confirmed_bad_source in result["negativeSources"]:
zero_out_source_item(sku, confirmed_bad_source)
new_salable_qty = get_salable_quantity(sku, stock_id)
log.info("SKU %s stock %s salable qty after write: %s", sku, stock_id, new_salable_qty)
log.info("Done. %d SKU/stock pair(s) flagged.", flagged)
if __name__ == "__main__":
run(skus=os.environ.get("CHECK_SKUS", "").split(",") if os.environ.get("CHECK_SKUS") else [])
/**
* Flag Magento 2 SKUs where a negative source_item quantity is masked as positive stock.
*
* MSI legitimately allows a source_item to carry a negative quantity, for a drop-ship
* or oversell tracking source signalling a deficit. Magento's indexer, SelectBuilder::execute,
* only forces a source's contribution to 0 in the SUM() when that source's is_in_stock flag
* is 0 (via getCheckSql()). When a negative-quantity source is left marked in-stock, or the
* zeroing branch never fires for how sources combine into a stock, the raw negative number is
* summed as is, and a depleted source can cancel out or invert the sign of healthy sources,
* producing an impossible positive salable total. Tracked upstream as magento/inventory#3346
* and #3165, both open. This script never rewrites source_items automatically. It reports the
* impossible-total signature per SKU and stock, and only performs the guarded zero-out write
* after DRY_RUN is explicitly set to false, which an operator should only do once they have
* confirmed the negative row is bad data. Safe to run again and again in report mode.
*
* Guide: https://www.allanninal.dev/magento/negative-source-item-counted-as-positive/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function isImpossibleStockTotal(sourceRows) {
const sum = sourceRows.reduce((acc, row) => acc + row.quantity, 0);
const negativeSources = sourceRows.filter((row) => row.quantity < 0).map((row) => row.sourceCode);
if (negativeSources.length === 0) {
return { flagged: false, sum, negativeSources: [], reason: null };
}
const masked =
sum >= 0 ||
sourceRows.some((row) => row.quantity < 0 && row.status === 0 && sum > row.quantity);
if (!masked) {
return { flagged: false, sum, negativeSources, reason: null };
}
const culprit = sourceRows.find((row) => row.quantity < 0);
const statusLabel = culprit.status === 0 ? "out_of_stock" : "in_stock";
const reason = `source ${culprit.sourceCode} qty=${culprit.quantity} status=${statusLabel} masked, sum=${sum} treated as salable`;
return { flagged: true, sum, negativeSources, reason };
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.status === 204 ? null : res.json();
}
async function getStockSourceLinks() {
const data = await magentoGet("/inventory/stock-source-links", { "searchCriteria[pageSize]": 200 });
return data.items;
}
async function getSourceItemsForSku(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items;
}
function groupRowsByStock(sourceItems, stockSourceLinks) {
const codeToStocks = {};
for (const link of stockSourceLinks) {
(codeToStocks[link.source_code] ||= []).push(link.stock_id);
}
const grouped = {};
for (const item of sourceItems) {
const stockIds = codeToStocks[item.source_code] || [];
for (const stockId of stockIds) {
(grouped[stockId] ||= []).push({
sourceCode: item.source_code,
quantity: item.quantity,
status: item.status,
});
}
}
return grouped;
}
async function getSalableQuantity(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return Number(data);
}
async function zeroOutSourceItem(sku, sourceCode) {
const payload = { sourceItems: [{ sku, source_code: sourceCode, quantity: 0, status: 0 }] };
if (DRY_RUN) {
console.log(`DRY_RUN: would PUT /inventory/source-items with`, JSON.stringify(payload));
return;
}
await magentoPut("/inventory/source-items", payload);
console.warn(`Zeroed ${sku} at source ${sourceCode}. Re-check salable qty, then run a CLI reindex.`);
}
export async function run(skus = [], fixSourceCodes = {}) {
const stockSourceLinks = await getStockSourceLinks();
let flagged = 0;
for (const sku of skus) {
const sourceItems = await getSourceItemsForSku(sku);
const grouped = groupRowsByStock(sourceItems, stockSourceLinks);
for (const [stockId, rows] of Object.entries(grouped)) {
const result = isImpossibleStockTotal(rows);
if (!result.flagged) continue;
const salableQty = await getSalableQuantity(sku, stockId);
console.warn(
`SKU ${sku} stock ${stockId}: ${result.reason} naive_sum=${result.sum} live_salable=${salableQty}`
);
flagged++;
const confirmedBadSource = fixSourceCodes[sku];
if (confirmedBadSource && result.negativeSources.includes(confirmedBadSource)) {
await zeroOutSourceItem(sku, confirmedBadSource);
const newSalableQty = await getSalableQuantity(sku, stockId);
console.log(`SKU ${sku} stock ${stockId} salable qty after write: ${newSalableQty}`);
}
}
}
console.log(`Done. ${flagged} SKU/stock pair(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const skus = (process.env.CHECK_SKUS || "").split(",").filter(Boolean);
run(skus, {}).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 SKU is reported as broken or left alone. Because we kept is_impossible_stock_total pure, the test needs no network, no database, and no Magento store. It just feeds in an array of source rows and checks the answer.
from flag_negative_source_masked import is_impossible_stock_total
def row(source_code, quantity, status=1):
return {"sourceCode": source_code, "quantity": quantity, "status": status}
def test_flagged_when_negative_masked_by_healthy_sources():
rows = [row("S1", 2), row("S2", 3), row("S3", -29, status=0)]
result = is_impossible_stock_total(rows)
assert result["flagged"] is True
assert result["sum"] == -24
assert "S3" in result["negativeSources"]
def test_not_flagged_when_no_negative_rows():
rows = [row("S1", 2), row("S2", 3)]
result = is_impossible_stock_total(rows)
assert result["flagged"] is False
assert result["sum"] == 5
def test_flagged_when_naive_sum_is_non_negative_with_negative_row():
rows = [row("S1", 5), row("S2", -2, status=0)]
result = is_impossible_stock_total(rows)
assert result["flagged"] is True
assert result["sum"] == 3
def test_flagged_when_healthy_source_partially_offsets_out_of_stock_negative():
# sum (-3) is still negative but greater than the culprit's own -5, so the
# deficit was partially masked by S1 even though the total stayed negative.
rows = [row("S1", 2), row("S2", -5, status=0)]
result = is_impossible_stock_total(rows)
assert result["sum"] == -3
assert result["flagged"] is True
def test_not_flagged_when_single_out_of_stock_negative_source_alone():
# No other source to mask the deficit: sum equals the culprit's own quantity,
# so it is not masked, just a plain negative total from one source.
rows = [row("S1", -5, status=0)]
result = is_impossible_stock_total(rows)
assert result["sum"] == -5
assert result["flagged"] is False
def test_reason_names_the_culprit_source():
rows = [row("S1", 2), row("S2", -2, status=0)]
result = is_impossible_stock_total(rows)
assert result["flagged"] is True
assert "S2" in result["reason"]
assert "out_of_stock" in result["reason"]
def test_negative_in_stock_source_included_in_negative_sources():
rows = [row("S1", 10), row("S2", -1, status=1)]
result = is_impossible_stock_total(rows)
assert "S2" in result["negativeSources"]
assert result["sum"] == 9
import { test } from "node:test";
import assert from "node:assert/strict";
import { isImpossibleStockTotal } from "./flag-negative-source-masked.js";
const row = (sourceCode, quantity, status = 1) => ({ sourceCode, quantity, status });
test("flagged when negative masked by healthy sources", () => {
const rows = [row("S1", 2), row("S2", 3), row("S3", -29, 0)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.flagged, true);
assert.equal(result.sum, -24);
assert.ok(result.negativeSources.includes("S3"));
});
test("not flagged when no negative rows", () => {
const rows = [row("S1", 2), row("S2", 3)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.flagged, false);
assert.equal(result.sum, 5);
});
test("flagged when naive sum is non-negative with negative row", () => {
const rows = [row("S1", 5), row("S2", -2, 0)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.flagged, true);
assert.equal(result.sum, 3);
});
test("flagged when healthy source partially offsets out-of-stock negative", () => {
// sum (-3) is still negative but greater than the culprit's own -5, so the
// deficit was partially masked by S1 even though the total stayed negative.
const rows = [row("S1", 2), row("S2", -5, 0)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.sum, -3);
assert.equal(result.flagged, true);
});
test("not flagged when single out-of-stock negative source alone", () => {
// No other source to mask the deficit: sum equals the culprit's own quantity,
// so it is not masked, just a plain negative total from one source.
const rows = [row("S1", -5, 0)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.sum, -5);
assert.equal(result.flagged, false);
});
test("reason names the culprit source", () => {
const rows = [row("S1", 2), row("S2", -2, 0)];
const result = isImpossibleStockTotal(rows);
assert.equal(result.flagged, true);
assert.match(result.reason, /S2/);
assert.match(result.reason, /out_of_stock/);
});
test("negative in-stock source included in negativeSources", () => {
const rows = [row("S1", 10), row("S2", -1, 1)];
const result = isImpossibleStockTotal(rows);
assert.ok(result.negativeSources.includes("S2"));
assert.equal(result.sum, 9);
});
Case studies
The supplier deficit that quietly cancelled out real stock
A multi-source catalog used a dedicated drop-ship source to track how far a supplier had gone below zero on backordered units, left deliberately marked in-stock so staff could see it in the admin grid. Nobody realized that same source fed the same stock as two warehouses with real physical inventory.
The combined salable quantity looked fine for weeks, right up until the deficit grew large enough to eat through the warehouses' stock in the sum, and checkout kept selling units that were actually gone. The detection job caught the exact SKU and stock id the first time it ran, naming the drop-ship source as the masked culprit before the next order shipped short.
The SKU that read differently depending on which sources answered
A retailer with regional sources noticed one SKU's storefront stock status flipped between visits with no configuration change. One region's source had gone negative from an oversell tracking rule, and depending on which other sources were queried alongside it, the combined total sometimes read positive and sometimes correctly negative.
Running the script against the raw source rows and the live salable quantity side by side made the pattern obvious: whenever the naive sum came out non-negative while a negative, in-stock-flagged source was present, it was the masking bug, not a data problem. The operator confirmed the negative row was legitimate oversell tracking, left it alone, and instead fixed the store's own alerting to expect it.
After this runs on a schedule, a masked negative source is named by SKU, stock id, and source code within one detection cycle, instead of surviving until a customer manages to buy stock that never existed. The report carries both the naive sum and Magento's own live salable quantity, so whoever reviews it can tell in seconds whether the negative row is bad data or an intentional deficit signal. Keep the zero-out write gated behind a confirmed, human decision, since that is what keeps the script from erasing a legitimate drop-ship or oversell record.
FAQ
Why does Magento add a negative source quantity into stock instead of zeroing it?
MSI's indexer only forces a source's quantity to 0 when that source's is_in_stock flag is 0. If a source with a negative quantity is still marked in-stock, or is combined across a stock's linked sources in a way that never triggers that zeroing branch, its raw negative quantity is summed as-is into the SUM() that becomes the stock's combined salable quantity. A genuinely depleted source can then cancel out or invert the sign of otherwise healthy sources, producing an impossible positive total.
Is a negative source_item quantity always a bug?
No. MSI legitimately allows negative source quantities for cases like drop-ship or oversell tracking sources, where the number represents a deficit signal rather than physical stock. It only becomes a defect when that negative quantity is masked by other sources in the same stock so the combined total reads as salable, which is the impossible-total pattern this field note detects.
Can a script safely fix a negative source quantity over the REST API?
Not automatically. Magento has no supported API to force-recalculate the MSI salable-quantity index, and whether a negative quantity is bad data or an intentional deficit signal requires human judgment. A script should detect and report the impossible-total pattern, and only zero out a row via the source-items PUT endpoint after an operator confirms it is bad data, with DRY_RUN on by default and a CLI reindex still required afterward for storefront salable quantity to reflect the fix.
Related field notes
Citations
On the problem:
- GitHub Issue: Negative source item quantity not calculated in stock. github.com/magento/inventory/issues/3346
- GitHub Issue: MSI setting that prevents negative salable qtys (or some kind of correction process). github.com/magento/inventory/issues/3165
- magento/inventory Wiki: MSI FAQs. github.com/magento/inventory/wiki/MSI-FAQs
On the solution:
- Adobe Commerce PHP Extensions: Inventory Management API reference. developer.adobe.com/commerce/php/development/components/web-api/inventory-management
- magento/inventory GitHub repository: MSI source code and issue tracker. github.com/magento/inventory
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce inventory, orders, catalog data, or indexing 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 catch a bad SKU?
If this saved you an oversold order or a confusing negative quantity, 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