Diagnostic Stock & Inventory
Combination stock quantities do not sum to the product level total
A product with combinations shows a total quantity that does not match what you get when you add up every size and color. Nobody typed the wrong number in. PrestaShop keeps the product-level row and the combination rows in sync with application code, not a live sum, and that sync can quietly fall apart. Here is why the two figures drift and a small script that finds every mismatch, plus the orphaned rows hiding behind it, without ever writing a number on its own.
PrestaShop stores stock in a single stock_available table with one row per product, combination, and shop. The row where id_product_attribute is 0 holds the product-level quantity, and it is supposed to be kept equal to the sum of every combination's row, but only by application logic such as StockAvailable::synchronizeOne, never by a live SUM() or a database constraint. Pull the combinations and the stock rows for a product from the Webservice API, sum the combination quantities, and compare that to the product-level row. Also flag any stock row whose id_product_attribute is not in the current combinations list, since those orphaned rows can inflate the total without ever showing up as a mismatch. Report only. Full code, tests, and a dry run guarded corrective write are below.
The problem in plain words
A product with combinations does not have one stock number in PrestaShop. It has many. Every combination, a size, a color, a bundle, gets its own row in stock_available, keyed by id_product_attribute. The product itself also gets a row, the one where id_product_attribute is 0, and that row is meant to represent the total: the same number a customer sees on the plain product page before picking an option.
Keeping that product-level row equal to the sum of its combinations is not something the database enforces. There is no constraint and no query that recalculates it on read. Instead, PrestaShop's application code updates the parent row as a side effect whenever a combination's quantity changes, through functions like StockAvailable::synchronizeOne and the hooks tied to product quantity updates. That is an imperative step, not a guarantee, and it only runs when the code path that is supposed to call it actually gets called.
Why it happens
PrestaShop's stock model favors fast reads at the product page over recomputing a total every time. That design choice shows up in a handful of recurring ways stores end up with a mismatched product-level row:
- A combination is deleted and a new one recreated in its place, so the old combination's stock row becomes orphaned, and stock returned from a cancelled order can be credited back to an
id_product_attributethat no longer exists. - Quantities are written through direct SQL, a bulk ERP import, or a webservice call that updates combination rows without going through the code path that recalculates the parent row.
- Advanced stock management or a multi-warehouse setup is enabled, where reservations and physical versus available quantity are tracked separately from the simple sum a merchant expects.
- A combination's stock is edited in the backoffice while an order is mid-transaction, so the parent row reflects a moment that no longer matches the combinations by the time you check it.
This is a recurring, well documented behavior rather than a one-off bug. PrestaShop's own issue tracker and community forums have repeated reports of wrong backoffice quantities for products with combinations and of leftover stock_available rows after a combination is removed. See the citations at the end for the exact reports.
The product-level row is not always wrong just because it disagrees with a naive sum of combinations. Advanced stock management, multi-warehouse ledgers, reserved_quantity, and orders in flight can all make a real, valid divergence look identical to a broken one from the outside. So the right move is to detect and report, not to silently overwrite. A corrective write should only ever touch the product-level row, guarded by a dry run and a merchant's explicit choice, and combination rows are never edited, since they are the operator's ground truth.
The fix, as a flow
The script pulls a product's combinations and its stock rows from the Webservice API, separates the product-level row from the combination rows, sums the valid combination quantities, and compares that sum to the product-level row. Anything that does not match is reported. Anything left behind by a deleted combination is reported separately, even when the sum happens to be correct.
Build it step by step
Get a webservice key with the right permissions
In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to combinations, stock_availables, and products, plus write access to stock_availables only if you plan to enable the optional corrective write later. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, change to false to write
Pull the live combinations for a product
Every combination row carries an id, which is the id_product_attribute value used everywhere else. This list is the ground truth for which combinations actually exist right now, and it is what the orphan check compares stock rows against.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def combinations_for_product(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
rows = data.get("combinations") or []
return [{"id": int(c["id"]), "id_product": int(c["id_product"])} for c in rows]
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
const rows = data.combinations || [];
return rows.map((c) => ({ id: Number(c.id), id_product: Number(c.id_product) }));
}
Pull every stock_available row for the same product
One call returns both the product-level row, where id_product_attribute is 0, and one row per combination. Read the id_shop on each row too, since a store running multiple shops needs to scope the comparison to the shop it actually cares about.
def stock_rows_for_product(id_product):
data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
rows = data.get("stock_availables") or []
return [
{
"id": int(r["id"]),
"id_product": int(r["id_product"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"id_shop": int(r.get("id_shop") or 0),
"quantity": int(r.get("quantity") or 0),
}
for r in rows
]
async function stockRowsForProduct(idProduct) {
const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
const rows = data.stock_availables || [];
return rows.map((r) => ({
id: Number(r.id),
id_product: Number(r.id_product),
id_product_attribute: Number(r.id_product_attribute || 0),
id_shop: Number(r.id_shop || 0),
quantity: Number(r.quantity || 0),
}));
}
Decide, with one pure function
Keep the comparison itself in a function that takes plain arrays in and returns a plain report out. It scopes the stock rows to the shop you care about, finds the product-level row, sums the quantities of combination rows whose id_product_attribute is still in the live combinations set, flags orphaned rows separately, and computes the signed delta. Nothing here touches the network, so it is simple to test with fixtures covering matching sums, positive and negative deltas, orphaned rows with a sum that happens to be correct, and products with zero combinations.
def find_stock_mismatches(product_id, combinations, stock_available_rows, shop_id):
rows = [
r for r in stock_available_rows
if r["id_shop"] == shop_id and r["id_product"] == product_id
]
valid_attribute_ids = {c["id"] for c in combinations}
product_row = next((r for r in rows if r["id_product_attribute"] == 0), None)
product_level_quantity = product_row["quantity"] if product_row else None
combination_rows = [r for r in rows if r["id_product_attribute"] != 0]
orphaned_row_ids = [
r["id"] for r in combination_rows
if r["id_product_attribute"] not in valid_attribute_ids
]
valid_combination_rows = [
r for r in combination_rows
if r["id_product_attribute"] in valid_attribute_ids
]
combination_quantity_sum = sum(r["quantity"] for r in valid_combination_rows)
delta = (product_level_quantity or 0) - combination_quantity_sum
is_mismatched = len(combinations) > 0 and delta != 0
return {
"productId": product_id,
"productLevelQuantity": product_level_quantity,
"combinationQuantitySum": combination_quantity_sum,
"delta": delta,
"isMismatched": is_mismatched,
"orphanedRowIds": orphaned_row_ids,
}
export function findStockMismatches(productId, combinations, stockAvailableRows, shopId) {
const rows = stockAvailableRows.filter(
(r) => r.id_shop === shopId && r.id_product === productId
);
const validAttributeIds = new Set(combinations.map((c) => c.id));
const productRow = rows.find((r) => r.id_product_attribute === 0);
const productLevelQuantity = productRow ? productRow.quantity : null;
const combinationRows = rows.filter((r) => r.id_product_attribute !== 0);
const orphanedRowIds = combinationRows
.filter((r) => !validAttributeIds.has(r.id_product_attribute))
.map((r) => r.id);
const validCombinationRows = combinationRows.filter((r) =>
validAttributeIds.has(r.id_product_attribute)
);
const combinationQuantitySum = validCombinationRows.reduce((sum, r) => sum + r.quantity, 0);
const delta = (productLevelQuantity ?? 0) - combinationQuantitySum;
const isMismatched = combinations.length > 0 && delta !== 0;
return {
productId,
productLevelQuantity,
combinationQuantitySum,
delta,
isMismatched,
orphanedRowIds,
};
}
Report only, and gate any write behind an explicit opt in
Because a real, valid divergence can look identical to a broken one from the outside, the default behavior is to log the mismatch and the orphaned row ids and stop there. If a merchant explicitly opts into a corrective write, the only safe action is a PUT on the product-level stock_available row so its quantity equals the verified combination sum. Combination rows are never written, and orphaned rows are only ever reported with their id and quantity for manual review or a manual DELETE, never auto-deleted.
def correct_product_level_quantity(product_row_id, combination_quantity_sum):
body = {"stock_available": {"id": product_row_id, "quantity": combination_quantity_sum}}
r = requests.put(
f"{BASE_URL}/api/stock_availables/{product_row_id}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
async function correctProductLevelQuantity(productRowId, combinationQuantitySum) {
const url = new URL(`${BASE_URL}/api/stock_availables/${productRowId}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ stock_available: { id: productRowId, quantity: combinationQuantitySum } }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The run loop pulls combinations and stock rows for each product you pass in, computes the report, and logs a warning for a mismatch or any orphaned rows. Leave DRY_RUN on for every run until you have reviewed the output by hand. Even with DRY_RUN=false, the script only ever writes the product-level row, and only when isMismatched is true.
Always start with DRY_RUN=true. This script never edits a combination's stock row and never deletes an orphaned row on its own. It reports, and the only opt in write is a single PUT on the product-level row.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never writes anything except the product-level quantity, and only when a mismatch is confirmed and dry run is off.
"""Detect PrestaShop combination stock quantities that do not sum to the product total.
stock_available keeps one row per (id_product, id_product_attribute, id_shop). The row
where id_product_attribute is 0 is the product-level quantity, and it is only kept equal
to the sum of the combination rows by application code such as StockAvailable::synchronizeOne,
never by a live SUM() or a database constraint. Deleting and recreating combinations, direct
SQL or ERP writes, and advanced stock management setups can all leave the two figures
disagreeing. This reports the mismatch and any orphaned stock rows left behind by deleted
combinations. It never writes a combination row, and it only writes the product-level row
when a mismatch is confirmed and DRY_RUN is off. 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("combination_quantity_sum_mismatch")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
SHOP_ID = int(os.environ.get("PRESTASHOP_SHOP_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def combinations_for_product(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
rows = data.get("combinations") or []
return [{"id": int(c["id"]), "id_product": int(c["id_product"])} for c in rows]
def stock_rows_for_product(id_product):
data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
rows = data.get("stock_availables") or []
return [
{
"id": int(r["id"]),
"id_product": int(r["id_product"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"id_shop": int(r.get("id_shop") or 0),
"quantity": int(r.get("quantity") or 0),
}
for r in rows
]
def find_stock_mismatches(product_id, combinations, stock_available_rows, shop_id):
rows = [
r for r in stock_available_rows
if r["id_shop"] == shop_id and r["id_product"] == product_id
]
valid_attribute_ids = {c["id"] for c in combinations}
product_row = next((r for r in rows if r["id_product_attribute"] == 0), None)
product_level_quantity = product_row["quantity"] if product_row else None
combination_rows = [r for r in rows if r["id_product_attribute"] != 0]
orphaned_row_ids = [
r["id"] for r in combination_rows
if r["id_product_attribute"] not in valid_attribute_ids
]
valid_combination_rows = [
r for r in combination_rows
if r["id_product_attribute"] in valid_attribute_ids
]
combination_quantity_sum = sum(r["quantity"] for r in valid_combination_rows)
delta = (product_level_quantity or 0) - combination_quantity_sum
is_mismatched = len(combinations) > 0 and delta != 0
return {
"productId": product_id,
"productLevelQuantity": product_level_quantity,
"combinationQuantitySum": combination_quantity_sum,
"delta": delta,
"isMismatched": is_mismatched,
"orphanedRowIds": orphaned_row_ids,
}
def correct_product_level_quantity(product_row_id, combination_quantity_sum):
body = {"stock_available": {"id": product_row_id, "quantity": combination_quantity_sum}}
r = requests.put(
f"{BASE_URL}/api/stock_availables/{product_row_id}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def check_product(product_id, shop_id=SHOP_ID):
combinations = combinations_for_product(product_id)
rows = stock_rows_for_product(product_id)
return find_stock_mismatches(product_id, combinations, rows, shop_id), rows
def run(product_ids):
mismatched_count = 0
orphan_count = 0
for product_id in product_ids:
report, rows = check_product(product_id)
if report["orphanedRowIds"]:
orphan_count += len(report["orphanedRowIds"])
log.warning(
"Product %s has %d orphaned stock_available row(s): %s (manual review only)",
product_id, len(report["orphanedRowIds"]), report["orphanedRowIds"],
)
if not report["isMismatched"]:
continue
mismatched_count += 1
log.warning(
"Product %s mismatch: product_level=%s combination_sum=%s delta=%s (%s)",
product_id, report["productLevelQuantity"], report["combinationQuantitySum"],
report["delta"], "would correct" if DRY_RUN else "correcting",
)
if not DRY_RUN:
product_row = next(r for r in rows if r["id_product_attribute"] == 0)
correct_product_level_quantity(product_row["id"], report["combinationQuantitySum"])
log.info(
"Done. %d product(s) mismatched, %d orphaned row(s) found.",
mismatched_count, orphan_count,
)
if __name__ == "__main__":
ids = [int(x) for x in os.environ.get("PRODUCT_IDS", "").split(",") if x.strip()]
run(ids)
/**
* Detect PrestaShop combination stock quantities that do not sum to the product total.
*
* stock_available keeps one row per (id_product, id_product_attribute, id_shop). The row
* where id_product_attribute is 0 is the product-level quantity, and it is only kept equal
* to the sum of the combination rows by application code such as StockAvailable::synchronizeOne,
* never by a live SUM() or a database constraint. Deleting and recreating combinations, direct
* SQL or ERP writes, and advanced stock management setups can all leave the two figures
* disagreeing. This reports the mismatch and any orphaned stock rows left behind by deleted
* combinations. It never writes a combination row, and it only writes the product-level row
* when a mismatch is confirmed and DRY_RUN is off. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/combination-quantity-sum-mismatch/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const SHOP_ID = Number(process.env.PRESTASHOP_SHOP_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
const rows = data.combinations || [];
return rows.map((c) => ({ id: Number(c.id), id_product: Number(c.id_product) }));
}
async function stockRowsForProduct(idProduct) {
const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
const rows = data.stock_availables || [];
return rows.map((r) => ({
id: Number(r.id),
id_product: Number(r.id_product),
id_product_attribute: Number(r.id_product_attribute || 0),
id_shop: Number(r.id_shop || 0),
quantity: Number(r.quantity || 0),
}));
}
export function findStockMismatches(productId, combinations, stockAvailableRows, shopId) {
const rows = stockAvailableRows.filter(
(r) => r.id_shop === shopId && r.id_product === productId
);
const validAttributeIds = new Set(combinations.map((c) => c.id));
const productRow = rows.find((r) => r.id_product_attribute === 0);
const productLevelQuantity = productRow ? productRow.quantity : null;
const combinationRows = rows.filter((r) => r.id_product_attribute !== 0);
const orphanedRowIds = combinationRows
.filter((r) => !validAttributeIds.has(r.id_product_attribute))
.map((r) => r.id);
const validCombinationRows = combinationRows.filter((r) =>
validAttributeIds.has(r.id_product_attribute)
);
const combinationQuantitySum = validCombinationRows.reduce((sum, r) => sum + r.quantity, 0);
const delta = (productLevelQuantity ?? 0) - combinationQuantitySum;
const isMismatched = combinations.length > 0 && delta !== 0;
return {
productId,
productLevelQuantity,
combinationQuantitySum,
delta,
isMismatched,
orphanedRowIds,
};
}
async function correctProductLevelQuantity(productRowId, combinationQuantitySum) {
const url = new URL(`${BASE_URL}/api/stock_availables/${productRowId}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ stock_available: { id: productRowId, quantity: combinationQuantitySum } }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function checkProduct(productId, shopId = SHOP_ID) {
const combinations = await combinationsForProduct(productId);
const rows = await stockRowsForProduct(productId);
return [findStockMismatches(productId, combinations, rows, shopId), rows];
}
export async function run(productIds) {
let mismatchedCount = 0;
let orphanCount = 0;
for (const productId of productIds) {
const [report, rows] = await checkProduct(productId);
if (report.orphanedRowIds.length) {
orphanCount += report.orphanedRowIds.length;
console.warn(
`Product ${productId} has ${report.orphanedRowIds.length} orphaned stock_available row(s): ${report.orphanedRowIds} (manual review only)`
);
}
if (!report.isMismatched) continue;
mismatchedCount++;
console.warn(
`Product ${productId} mismatch: product_level=${report.productLevelQuantity} combination_sum=${report.combinationQuantitySum} delta=${report.delta} (${DRY_RUN ? "would correct" : "correcting"})`
);
if (!DRY_RUN) {
const productRow = rows.find((r) => r.id_product_attribute === 0);
await correctProductLevelQuantity(productRow.id, report.combinationQuantitySum);
}
}
console.log(`Done. ${mismatchedCount} product(s) mismatched, ${orphanCount} orphaned row(s) found.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const ids = (process.env.PRODUCT_IDS || "").split(",").map((x) => x.trim()).filter(Boolean).map(Number);
run(ids).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The comparison function is the part most worth testing, because it decides which products get reported as mismatched and which stock rows get flagged as orphaned. Because find_stock_mismatches is pure, the test needs no PrestaShop instance and no network. It just feeds in plain lists and checks the report.
from combination_quantity_sum_mismatch import find_stock_mismatches
SHOP_ID = 1
def combo(id_):
return {"id": id_, "id_product": 10}
def row(**over):
base = {"id": 900, "id_product": 10, "id_product_attribute": 0, "id_shop": SHOP_ID, "quantity": 0}
base.update(over)
return base
def test_no_mismatch_when_sum_matches_product_row():
combinations = [combo(1), combo(2)]
rows = [
row(id=900, id_product_attribute=0, quantity=7),
row(id=901, id_product_attribute=1, quantity=3),
row(id=902, id_product_attribute=2, quantity=4),
]
result = find_stock_mismatches(10, combinations, rows, SHOP_ID)
assert result["isMismatched"] is False
assert result["combinationQuantitySum"] == 7
assert result["delta"] == 0
assert result["orphanedRowIds"] == []
def test_positive_delta_when_product_row_higher_than_sum():
combinations = [combo(1)]
rows = [
row(id=900, id_product_attribute=0, quantity=10),
row(id=901, id_product_attribute=1, quantity=4),
]
result = find_stock_mismatches(10, combinations, rows, SHOP_ID)
assert result["isMismatched"] is True
assert result["delta"] == 6
def test_negative_delta_when_product_row_lower_than_sum():
combinations = [combo(1)]
rows = [
row(id=900, id_product_attribute=0, quantity=2),
row(id=901, id_product_attribute=1, quantity=9),
]
result = find_stock_mismatches(10, combinations, rows, SHOP_ID)
assert result["isMismatched"] is True
assert result["delta"] == -7
def test_orphaned_row_reported_even_when_sum_matches():
# id_product_attribute 5 no longer exists in the live combinations list,
# but it happens to make the naive sum equal the product row anyway.
combinations = [combo(1)]
rows = [
row(id=900, id_product_attribute=0, quantity=4),
row(id=901, id_product_attribute=1, quantity=4),
row(id=902, id_product_attribute=5, quantity=99),
]
result = find_stock_mismatches(10, combinations, rows, SHOP_ID)
assert result["orphanedRowIds"] == [902]
assert result["combinationQuantitySum"] == 4
assert result["isMismatched"] is False
def test_zero_combinations_is_never_flagged():
rows = [row(id=900, id_product_attribute=0, quantity=123)]
result = find_stock_mismatches(10, [], rows, SHOP_ID)
assert result["isMismatched"] is False
assert result["productLevelQuantity"] == 123
def test_rows_scoped_to_requested_shop_only():
combinations = [combo(1)]
rows = [
row(id=900, id_product_attribute=0, id_shop=2, quantity=999),
row(id=901, id_product_attribute=1, id_shop=1, quantity=5),
]
result = find_stock_mismatches(10, combinations, rows, SHOP_ID)
assert result["productLevelQuantity"] is None
assert result["combinationQuantitySum"] == 5
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStockMismatches } from "./combination-quantity-sum-mismatch.js";
const SHOP_ID = 1;
const combo = (id) => ({ id, id_product: 10 });
const row = (over = {}) => ({
id: 900,
id_product: 10,
id_product_attribute: 0,
id_shop: SHOP_ID,
quantity: 0,
...over,
});
test("no mismatch when sum matches product row", () => {
const combinations = [combo(1), combo(2)];
const rows = [
row({ id: 900, id_product_attribute: 0, quantity: 7 }),
row({ id: 901, id_product_attribute: 1, quantity: 3 }),
row({ id: 902, id_product_attribute: 2, quantity: 4 }),
];
const result = findStockMismatches(10, combinations, rows, SHOP_ID);
assert.equal(result.isMismatched, false);
assert.equal(result.combinationQuantitySum, 7);
assert.equal(result.delta, 0);
assert.deepEqual(result.orphanedRowIds, []);
});
test("positive delta when product row higher than sum", () => {
const combinations = [combo(1)];
const rows = [
row({ id: 900, id_product_attribute: 0, quantity: 10 }),
row({ id: 901, id_product_attribute: 1, quantity: 4 }),
];
const result = findStockMismatches(10, combinations, rows, SHOP_ID);
assert.equal(result.isMismatched, true);
assert.equal(result.delta, 6);
});
test("negative delta when product row lower than sum", () => {
const combinations = [combo(1)];
const rows = [
row({ id: 900, id_product_attribute: 0, quantity: 2 }),
row({ id: 901, id_product_attribute: 1, quantity: 9 }),
];
const result = findStockMismatches(10, combinations, rows, SHOP_ID);
assert.equal(result.isMismatched, true);
assert.equal(result.delta, -7);
});
test("orphaned row reported even when sum matches", () => {
const combinations = [combo(1)];
const rows = [
row({ id: 900, id_product_attribute: 0, quantity: 4 }),
row({ id: 901, id_product_attribute: 1, quantity: 4 }),
row({ id: 902, id_product_attribute: 5, quantity: 99 }),
];
const result = findStockMismatches(10, combinations, rows, SHOP_ID);
assert.deepEqual(result.orphanedRowIds, [902]);
assert.equal(result.combinationQuantitySum, 4);
assert.equal(result.isMismatched, false);
});
test("zero combinations is never flagged", () => {
const rows = [row({ id: 900, id_product_attribute: 0, quantity: 123 })];
const result = findStockMismatches(10, [], rows, SHOP_ID);
assert.equal(result.isMismatched, false);
assert.equal(result.productLevelQuantity, 123);
});
test("rows scoped to requested shop only", () => {
const combinations = [combo(1)];
const rows = [
row({ id: 900, id_product_attribute: 0, id_shop: 2, quantity: 999 }),
row({ id: 901, id_product_attribute: 1, id_shop: 1, quantity: 5 }),
];
const result = findStockMismatches(10, combinations, rows, SHOP_ID);
assert.equal(result.productLevelQuantity, null);
assert.equal(result.combinationQuantitySum, 5);
});
Case studies
The color swap that left a ghost behind
An apparel store discontinued a color, deleted the combination, and added a new color a week later. Nothing in the storefront looked wrong, but a monthly audit flagged the product's total quantity as seven units higher than the sum of the colors customers could actually buy.
The script traced it to an orphaned stock_available row still holding the old color's id_product_attribute, left over from the delete. The team reviewed it in the backoffice and removed it by hand. The product-level number matched the visible combinations again, and the audit now runs weekly instead of finding this by accident.
The nightly import that only touched combinations
A hardware store synced stock from its ERP every night, but the import only wrote combination-level rows and never called the code path that recalculates the product-level total. Over a few weeks, the storefront's plain product page quietly understated what was actually available.
Running the checker after each import surfaced the drift the same night it happened instead of weeks later. With a merchant sign off, the guarded corrective write brought the product-level row back in line with the true combination sum, without ever touching the combination rows the ERP owned.
After this runs on a schedule, a mismatch between the product-level total and its combinations gets surfaced the same day it appears, along with any orphaned rows a deleted combination left behind. Nothing gets silently overwritten. A merchant decides when a correction is safe, the script only ever writes the product-level row, and combination rows stay untouched as the source of truth they are meant to be.
FAQ
Why do my PrestaShop combination stock quantities not add up to the product total?
PrestaShop stores one stock_available row per id_product_attribute, where 0 holds the product-level quantity and each combination has its own row. The product-level row is kept in sync by application code such as StockAvailable::synchronizeOne, not a live SUM() or database constraint, so deleting and recreating combinations, direct SQL or ERP writes, and advanced stock management setups can all leave the two figures disagreeing.
Is it safe to auto-correct the product-level quantity to match the combination sum?
Not automatically. The product-level row can legitimately differ from a naive sum under advanced stock management, multi-warehouse configurations, reserved_quantity semantics, or an order mid-transaction. The safe pattern is to report the mismatch, and only write a correction when a merchant explicitly opts in through a dry run guarded script, and even then only the product-level row is touched, never a combination row.
What is an orphaned stock_available row and why does it matter?
It is a stock_available row whose id_product_attribute no longer matches any live combination, usually left behind when a combination is deleted and recreated. PrestaShop does not always clean these up, and they can silently inflate a sum comparison even when the totals happen to match, so they need to be reported and reviewed separately from the quantity mismatch itself.
Related field notes
Citations
On the problem:
- Wrong quantities showing in BO for products with combinations. github.com/PrestaShop/PrestaShop/issues/12814
- Product quantity not a sum of individual product variations' quantities. github.com/PrestaShop/PrestaShop/issues/21718
- Rows not removed from ps_stock_available upon combination removal. Wrong stock quantity. prestashop.com/forums/topic/1079762
On the solution:
- PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/8/webservice/resources/stock_availables/
- PrestaShop Developer Documentation: Combinations webservice resource. devdocs.prestashop-project.org/9/webservice/resources/combinations/
- PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock/
Stuck on a tricky one?
If you have a problem in PrestaShop stock, orders, combinations, or the Webservice API 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 settle your combination stock?
If this saved you a pile of confused stock counts or a wrong "in stock" message, 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