Reconciler
Product quantity field via webservice always reads or writes as zero
You call the products resource, read the quantity field, and it is always 0, even for a product that is very much in stock. You try writing a new value back to it and nothing complains, but nothing changes either. This is not a bug in your script. PrestaShop moved real stock out of the product table years ago, and the webservice never caught up. Here is why products.quantity always lies and a small script that reads and repairs the real number instead.
Since PrestaShop 1.5, physical stock lives in a dedicated stock_availables row keyed by id_product (and id_product_attribute for combinations), not in the products table. The webservice products resource still exposes a legacy quantity field for backward compatibility, but it was never wired to stock_availables.quantity, so every GET returns 0 and every PUT or POST to it silently no-ops. Run a Python or Node.js script that pulls each product from GET /api/products, ignores its bogus quantity, fetches the real number from GET /api/stock_availables?filter[id_product]=<id>, and flags or repairs only that row with a PATCH to stock_availables/{id}. Full code, tests, and citations are below.
The problem in plain words
Older versions of PrestaShop kept a product's stock right on the product row. Since 1.5, that changed. Physical stock became its own concept, StockAvailable, stored in its own stock_availables table, so that a product's sellable count could depend on the shop, the combination, and how stock sharing is configured across a multistore setup.
The webservice never fully caught up with that split. The products resource still shows a quantity node in its XML or JSON, left over from before the split, but nothing in the core connects it to the real stock_availables row. So a GET on a product always reports quantity as 0, no matter how much stock is actually on hand, and a PUT or POST that tries to change it does not error, it just does nothing. This is a documented core issue, not a misconfiguration on your store (see PrestaShop/PrestaShop issue #18953 in the citations). Scripts that trust products.quantity end up believing every product is permanently out of stock.
Why it happens
The split between the product and its stock is a core architecture decision, not an oversight in a single endpoint. A few things make it bite specifically in the webservice:
- Since PrestaShop 1.5, physical stock is owned by
StockAvailable, keyed byid_productplusid_product_attributefor combinations, and further scoped byid_shoporid_shop_groupdepending on how stock sharing is configured. - The webservice
productsresource kept aquantityfield in its schema for backward compatibility with older integrations, but the mapping that would read it fromstock_availableswas never implemented (documented as PrestaShop/PrestaShop GitHub issue #18953). - Because the field still exists and accepts a value on PUT or POST, nothing tells the caller the write was ignored. There is no error, no warning, just a value that never actually reaches real stock (related reports track the same confusion when trying to update stock through the API, see issue #17857).
- Scripts and integrations that read or write
products.quantitydirectly, instead of thestock_availablesresource, therefore always see a stale zero, and any automation gating on that number treats every product as unsellable.
This has tripped up enough integrators that it shows up repeatedly on the PrestaShop forums and issue tracker, usually phrased as "why is my quantity always zero" or "my update did not stick." See the citations at the end for the exact threads and docs.
products.quantity is a legacy, unwired field. It is not a source of truth and it is not a safe place to write. The safe pattern is to never trust it and never write to it: always read the real number from stock_availables, filtered by id_product (and id_product_attribute for combinations), and if a correction is needed, write it there with a PATCH to the specific stock_availables/{id} resource, not to the product.
The fix, as a flow
We do not touch the product's stock during checkout or storefront browsing. We add a job that pulls the product list, ignores the bogus quantity field entirely, fetches the authoritative row from stock_availables for each product, and only acts when that real row looks wrong for an active, visible product. Anything ambiguous gets flagged for a human instead of written automatically.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with access to products and stock_availables. 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
List products, but never trust their quantity field
Call GET /api/products?output_format=JSON&display=full&limit=100 to page through the catalog. Read id, active, and visibility off each product. Note the quantity node too, only so you can show, later, that it is always 0. Never use it as the comparison source.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def list_products(limit=100):
data = api_get("products", params={"display": "full", "limit": limit})
return data.get("products") or []
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function listProducts(limit = 100) {
const data = await apiGet("products", { display: "full", limit });
return data.products || [];
}
Fetch the real stock from stock_availables
For each product id, call GET /api/stock_availables?filter[id_product]=<id>&filter[id_product_attribute]=0&display=full. This returns the authoritative row: its own id, the real quantity, depends_on_stock, and out_of_stock. This is the number your automation should actually gate on, never the one on the product.
def stock_available_row(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
async function stockAvailableRow(idProduct, idProductAttribute = 0) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
display: "full",
});
const rows = data.stock_availables || [];
return rows.length ? rows[0] : null;
}
Decide, with one pure function
Keep the decision in its own function that takes only primitive inputs, no I/O at all. It always ignores products.quantity as untrustworthy. It flags a product as needing repair when the real stock_availables quantity is missing, or when the product is active and visible but the real quantity is zero or negative while the caller expects it to be sellable. It only recommends a write when a target quantity is known and dry run is off; otherwise it hands the discrepancy to a human.
def decide_quantity_sync(product_quantity_field, stock_available_quantity,
is_active, visibility, dry_run,
expected_positive=False, target_quantity=None):
# products.quantity is always 0 and must never be trusted as a source of truth.
if stock_available_quantity is None:
return {
"status": "ignore_legacy_field",
"action": "flag",
"reason": "no stock_availables row found for this product",
"target_quantity": None,
}
needs_repair = (
is_active and visibility != "none"
and stock_available_quantity <= 0
and expected_positive
)
if not needs_repair:
return {
"status": "ignore_legacy_field",
"action": "none",
"reason": "real stock_availables quantity looks fine",
"target_quantity": None,
}
dry_run_safe_to_write = (not dry_run) and target_quantity is not None
if dry_run_safe_to_write:
return {
"status": "ignore_legacy_field",
"action": "patch_stock_available",
"reason": "active, visible product has non-positive real stock",
"target_quantity": target_quantity,
}
return {
"status": "ignore_legacy_field",
"action": "flag",
"reason": "discrepancy needs human reconciliation before any write",
"target_quantity": target_quantity,
}
export function decideQuantitySync(
productQuantityField, stockAvailableQuantity, isActive, visibility, dryRun,
expectedPositive = false, targetQuantity = null
) {
// products.quantity is always 0 and must never be trusted as a source of truth.
if (stockAvailableQuantity == null) {
return { status: "ignore_legacy_field", action: "flag",
reason: "no stock_availables row found for this product", targetQuantity: null };
}
const needsRepair = isActive && visibility !== "none"
&& stockAvailableQuantity <= 0 && expectedPositive;
if (!needsRepair) {
return { status: "ignore_legacy_field", action: "none",
reason: "real stock_availables quantity looks fine", targetQuantity: null };
}
const dryRunSafeToWrite = !dryRun && targetQuantity !== null;
if (dryRunSafeToWrite) {
return { status: "ignore_legacy_field", action: "patch_stock_available",
reason: "active, visible product has non-positive real stock", targetQuantity };
}
return { status: "ignore_legacy_field", action: "flag",
reason: "discrepancy needs human reconciliation before any write", targetQuantity };
}
Repair only the stock_availables row, never the product
When the decision says to patch, send PATCH /api/stock_availables/{id}?output_format=JSON with the resource body carrying id, id_product, id_product_attribute, and the corrected quantity. Never PUT a quantity back to products, it will not error, but it will also not change real stock.
def api_patch(path, resource_key, body):
r = requests.patch(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"}, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def repair_stock_available(id_stock_available, id_product, id_product_attribute, target_quantity):
body = {
"id": id_stock_available,
"id_product": id_product,
"id_product_attribute": id_product_attribute,
"quantity": target_quantity,
}
return api_patch(f"stock_availables/{id_stock_available}", "stock_available", body)
async function apiPatch(path, resourceKey, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PATCH",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PATCH ${path}`);
return res.json();
}
async function repairStockAvailable(idStockAvailable, idProduct, idProductAttribute, targetQuantity) {
const body = {
id: idStockAvailable,
id_product: idProduct,
id_product_attribute: idProductAttribute,
quantity: targetQuantity,
};
return apiPatch(`stock_availables/${idStockAvailable}`, "stock_available", body);
}
Wire it together with a dry run guard
The loop ties every piece together: list products, ignore products.quantity, fetch the real stock_availables row, run it through decide_quantity_sync, log the id_product, id_stock_available, and both quantities for anything flagged, and only PATCH when the decision explicitly says to. Leave DRY_RUN on for the first runs and review the flagged rows before you let it write. Run it on a schedule that matches how often your real inventory changes.
Always start with DRY_RUN=true. If the discrepancy source is ambiguous, for example a real inventory system disagreeing with PrestaShop, do not let the script auto-write. Report the id_product, the id_stock_available, and both quantities for a human to reconcile instead.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, never trusts products.quantity, respects the dry run flag, and only ever writes to the specific stock_availables row it has already proven needs a correction.
"""Detect and repair PrestaShop products whose webservice quantity is stuck at zero.
Since PrestaShop 1.5, physical stock lives in stock_availables, keyed by id_product
(and id_product_attribute for combinations), not in the products table. The webservice
products resource still exposes a legacy quantity field for backward compatibility, but
it was never wired to stock_availables.quantity, so GET always returns 0 and PUT/POST
silently no-op on it (PrestaShop/PrestaShop GitHub issue #18953).
This script lists products, ignores their bogus quantity field entirely, fetches the
real stock_availables row for each one, and flags active, visible products whose real
quantity is unexpectedly zero or negative. The only sanctioned write (when DRY_RUN=false
and a target quantity is known) is a PATCH to the specific stock_availables/{id}
resource. products.quantity is never written; it is a no-op field. Ambiguous cases are
reported for human reconciliation rather than auto-corrected.
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("sync_stock_quantity")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def decide_quantity_sync(product_quantity_field, stock_available_quantity,
is_active, visibility, dry_run,
expected_positive=False, target_quantity=None):
"""Pure decision function, no I/O.
product_quantity_field: the legacy products.quantity value (always 0, never trusted).
stock_available_quantity: the real quantity from stock_availables, or None if no row.
is_active, visibility: the product's active flag and visibility ("both"/"catalog"/
"search"/"none").
dry_run: whether writes are currently disabled.
expected_positive: caller's signal that this product should currently have stock
(e.g. a known restock, or a real inventory feed reporting units on hand).
target_quantity: the corrected quantity to write, if known.
Returns a decision dict describing what to do. All HTTP calls happen in the caller.
"""
# products.quantity is a legacy, unwired field and is never the comparison source.
del product_quantity_field
if stock_available_quantity is None:
return {
"status": "ignore_legacy_field",
"action": "flag",
"reason": "no stock_availables row found for this product",
"target_quantity": None,
}
needs_repair = (
is_active and visibility != "none"
and stock_available_quantity <= 0
and expected_positive
)
if not needs_repair:
return {
"status": "ignore_legacy_field",
"action": "none",
"reason": "real stock_availables quantity looks fine",
"target_quantity": None,
}
dry_run_safe_to_write = (not dry_run) and target_quantity is not None
if dry_run_safe_to_write:
return {
"status": "ignore_legacy_field",
"action": "patch_stock_available",
"reason": "active, visible product has non-positive real stock",
"target_quantity": target_quantity,
}
return {
"status": "ignore_legacy_field",
"action": "flag",
"reason": "discrepancy needs human reconciliation before any write",
"target_quantity": target_quantity,
}
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def api_patch(path, resource_key, body):
r = requests.patch(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
auth=AUTH,
json={resource_key: body},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_products(limit=100):
data = api_get("products", params={"display": "full", "limit": limit})
return data.get("products") or []
def stock_available_row(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
def repair_stock_available(id_stock_available, id_product, id_product_attribute, target_quantity):
body = {
"id": id_stock_available,
"id_product": id_product,
"id_product_attribute": id_product_attribute,
"quantity": target_quantity,
}
return api_patch(f"stock_availables/{id_stock_available}", "stock_available", body)
def run():
flagged = 0
repaired = 0
for product in list_products():
id_product = product.get("id")
is_active = str(product.get("active", "0")) == "1"
visibility = product.get("visibility", "both")
legacy_quantity = product.get("quantity")
row = stock_available_row(id_product)
real_quantity = int(row["quantity"]) if row else None
decision = decide_quantity_sync(
product_quantity_field=legacy_quantity,
stock_available_quantity=real_quantity,
is_active=is_active,
visibility=visibility,
dry_run=DRY_RUN,
)
if decision["action"] == "none":
continue
flagged += 1
log.warning(
"Product %s id_stock_available=%s legacy products.quantity=%s (ignored) "
"real stock_availables.quantity=%s action=%s reason=%s",
id_product, row["id"] if row else None, legacy_quantity, real_quantity,
decision["action"], decision["reason"],
)
if decision["action"] == "patch_stock_available" and row and not DRY_RUN:
repair_stock_available(row["id"], id_product, row.get("id_product_attribute", 0),
decision["target_quantity"])
repaired += 1
log.info("Patched stock_availables/%s quantity=%s.", row["id"], decision["target_quantity"])
log.info("Done. %d row(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run()
/**
* Detect and repair PrestaShop products whose webservice quantity is stuck at zero.
*
* Since PrestaShop 1.5, physical stock lives in stock_availables, keyed by id_product
* (and id_product_attribute for combinations), not in the products table. The webservice
* products resource still exposes a legacy quantity field for backward compatibility, but
* it was never wired to stock_availables.quantity, so GET always returns 0 and PUT/POST
* silently no-op on it (PrestaShop/PrestaShop GitHub issue #18953).
*
* This script lists products, ignores their bogus quantity field entirely, fetches the
* real stock_availables row for each one, and flags active, visible products whose real
* quantity is unexpectedly zero or negative. The only sanctioned write (when DRY_RUN=false
* and a target quantity is known) is a PATCH to the specific stock_availables/{id}
* resource. products.quantity is never written; it is a no-op field. Ambiguous cases are
* reported for human reconciliation rather than auto-corrected.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-product-quantity-always-zero/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* productQuantityField: the legacy products.quantity value (always 0, never trusted).
* stockAvailableQuantity: the real quantity from stock_availables, or null if no row.
* isActive, visibility: the product's active flag and visibility ("both"/"catalog"/
* "search"/"none").
* dryRun: whether writes are currently disabled.
* expectedPositive: caller's signal that this product should currently have stock.
* targetQuantity: the corrected quantity to write, if known.
*
* Returns a decision object describing what to do. All HTTP calls happen in the caller.
*/
export function decideQuantitySync(
productQuantityField, stockAvailableQuantity, isActive, visibility, dryRun,
expectedPositive = false, targetQuantity = null
) {
// products.quantity is a legacy, unwired field and is never the comparison source.
void productQuantityField;
if (stockAvailableQuantity == null) {
return {
status: "ignore_legacy_field",
action: "flag",
reason: "no stock_availables row found for this product",
targetQuantity: null,
};
}
const needsRepair = isActive && visibility !== "none"
&& stockAvailableQuantity <= 0 && expectedPositive;
if (!needsRepair) {
return {
status: "ignore_legacy_field",
action: "none",
reason: "real stock_availables quantity looks fine",
targetQuantity: null,
};
}
const dryRunSafeToWrite = !dryRun && targetQuantity !== null;
if (dryRunSafeToWrite) {
return {
status: "ignore_legacy_field",
action: "patch_stock_available",
reason: "active, visible product has non-positive real stock",
targetQuantity,
};
}
return {
status: "ignore_legacy_field",
action: "flag",
reason: "discrepancy needs human reconciliation before any write",
targetQuantity,
};
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function apiPatch(path, resourceKey, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PATCH",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PATCH ${path}`);
return res.json();
}
async function listProducts(limit = 100) {
const data = await apiGet("products", { display: "full", limit });
return data.products || [];
}
async function stockAvailableRow(idProduct, idProductAttribute = 0) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
display: "full",
});
const rows = data.stock_availables || [];
return rows.length ? rows[0] : null;
}
async function repairStockAvailable(idStockAvailable, idProduct, idProductAttribute, targetQuantity) {
const body = {
id: idStockAvailable,
id_product: idProduct,
id_product_attribute: idProductAttribute,
quantity: targetQuantity,
};
return apiPatch(`stock_availables/${idStockAvailable}`, "stock_available", body);
}
export async function run() {
let flagged = 0;
let repaired = 0;
for (const product of await listProducts()) {
const idProduct = product.id;
const isActive = String(product.active) === "1";
const visibility = product.visibility || "both";
const legacyQuantity = product.quantity;
const row = await stockAvailableRow(idProduct);
const realQuantity = row ? Number(row.quantity) : null;
const decision = decideQuantitySync(legacyQuantity, realQuantity, isActive, visibility, DRY_RUN);
if (decision.action === "none") continue;
flagged++;
console.warn(
`Product ${idProduct} id_stock_available=${row ? row.id : null} legacy products.quantity=${legacyQuantity} (ignored) ` +
`real stock_availables.quantity=${realQuantity} action=${decision.action} reason=${decision.reason}`
);
if (decision.action === "patch_stock_available" && row && !DRY_RUN) {
await repairStockAvailable(row.id, idProduct, row.id_product_attribute || 0, decision.targetQuantity);
repaired++;
console.log(`Patched stock_availables/${row.id} quantity=${decision.targetQuantity}.`);
}
}
console.log(`Done. ${flagged} row(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether the script ever touches real stock, and whether it does so safely. Because we kept decide_quantity_sync pure, the test needs no network and no PrestaShop store. It just feeds in plain values and checks the answer.
from sync_stock_quantity import decide_quantity_sync
def test_legacy_field_never_trusted_even_when_nonzero():
# Even if products.quantity somehow carried a nonzero value, it must be ignored.
result = decide_quantity_sync(99, 5, True, "both", dry_run=True)
assert result["status"] == "ignore_legacy_field"
assert result["action"] == "none"
def test_flags_when_no_stock_available_row_found():
result = decide_quantity_sync(0, None, True, "both", dry_run=True)
assert result["action"] == "flag"
assert "no stock_availables row" in result["reason"]
def test_no_action_when_real_quantity_is_healthy():
result = decide_quantity_sync(0, 12, True, "both", dry_run=True)
assert result["action"] == "none"
def test_flags_zero_stock_on_active_visible_product_when_expected_positive():
result = decide_quantity_sync(0, 0, True, "both", dry_run=True, expected_positive=True)
assert result["action"] == "flag"
def test_no_repair_when_product_is_inactive():
result = decide_quantity_sync(0, 0, False, "both", dry_run=True, expected_positive=True)
assert result["action"] == "none"
def test_no_repair_when_visibility_is_none():
result = decide_quantity_sync(0, 0, True, "none", dry_run=True, expected_positive=True)
assert result["action"] == "none"
def test_patches_when_dry_run_off_and_target_known():
result = decide_quantity_sync(0, 0, True, "both", dry_run=False,
expected_positive=True, target_quantity=10)
assert result["action"] == "patch_stock_available"
assert result["target_quantity"] == 10
def test_flags_instead_of_patching_when_dry_run_on():
result = decide_quantity_sync(0, 0, True, "both", dry_run=True,
expected_positive=True, target_quantity=10)
assert result["action"] == "flag"
def test_flags_instead_of_patching_when_target_quantity_unknown():
result = decide_quantity_sync(0, 0, True, "both", dry_run=False,
expected_positive=True, target_quantity=None)
assert result["action"] == "flag"
def test_negative_real_quantity_on_active_visible_product_is_flagged():
result = decide_quantity_sync(0, -3, True, "catalog", dry_run=True, expected_positive=True)
assert result["action"] == "flag"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideQuantitySync } from "./sync-stock-quantity.js";
test("legacy field never trusted even when nonzero", () => {
const result = decideQuantitySync(99, 5, true, "both", true);
assert.equal(result.status, "ignore_legacy_field");
assert.equal(result.action, "none");
});
test("flags when no stock_availables row found", () => {
const result = decideQuantitySync(0, null, true, "both", true);
assert.equal(result.action, "flag");
assert.match(result.reason, /no stock_availables row/);
});
test("no action when real quantity is healthy", () => {
const result = decideQuantitySync(0, 12, true, "both", true);
assert.equal(result.action, "none");
});
test("flags zero stock on active visible product when expected positive", () => {
const result = decideQuantitySync(0, 0, true, "both", true, true);
assert.equal(result.action, "flag");
});
test("no repair when product is inactive", () => {
const result = decideQuantitySync(0, 0, false, "both", true, true);
assert.equal(result.action, "none");
});
test("no repair when visibility is none", () => {
const result = decideQuantitySync(0, 0, true, "none", true, true);
assert.equal(result.action, "none");
});
test("patches when dry run off and target known", () => {
const result = decideQuantitySync(0, 0, true, "both", false, true, 10);
assert.equal(result.action, "patch_stock_available");
assert.equal(result.targetQuantity, 10);
});
test("flags instead of patching when dry run on", () => {
const result = decideQuantitySync(0, 0, true, "both", true, true, 10);
assert.equal(result.action, "flag");
});
test("flags instead of patching when target quantity unknown", () => {
const result = decideQuantitySync(0, 0, true, "both", false, true, null);
assert.equal(result.action, "flag");
});
test("negative real quantity on active visible product is flagged", () => {
const result = decideQuantitySync(0, -3, true, "catalog", true, true);
assert.equal(result.action, "flag");
});
Case studies
The feed that thought everything was out of stock
A store synced its PrestaShop catalog to a marketplace by reading the products resource directly, including its quantity field. Every single listing showed zero stock on the marketplace, even best sellers with hundreds of units on the shelf, because the sync had never touched stock_availables at all.
Switching the feed to pull real quantity from stock_availables, filtered per product, fixed the listings on the next sync. The team also added a check that flags any active, visible product whose real stock still reads zero, so a genuine stockout is never confused with the old bug again.
The restock script that never restocked
An internal tool PUT a fresh count to products.quantity after every warehouse delivery. Nothing errored, so the team assumed it worked, until customers kept reporting an item as sold out that had just been restocked in the back room.
Reading the webservice docs closely showed the field was never wired to the real stock model. The team rewrote the tool to PATCH stock_availables/{id} instead, with the decision function gating the write so it only ever corrects a row it can prove needs correcting.
After this runs, nothing in your stack reads or writes products.quantity anymore. Every real number comes from and goes to stock_availables, gated by a pure decision function that only acts on active, visible products with a provable non-positive count, and it hands anything ambiguous to a human instead of guessing. The legacy field stops being a trap.
FAQ
Why does the PrestaShop webservice always show product quantity as 0?
Since PrestaShop 1.5, real stock lives in a separate stock_availables table keyed by the product, not in the products table itself. The products resource in the webservice still exposes a legacy quantity field for backward compatibility, but it was never wired up to read from stock_availables, so it always reads as 0 regardless of the real stock on hand.
Can I fix stock by writing to the products.quantity field over the API?
No. Writing to products.quantity through the webservice is a silent no-op, it does not raise an error but it also does not change real stock. The only field that actually controls sellable stock is stock_availables.quantity, reached through the stock_availables resource, not the products resource.
How do I read or update the real stock quantity through the PrestaShop webservice?
Call GET /api/stock_availables with filter[id_product] set to the product id to read the real quantity, depends_on_stock, and out_of_stock fields. To correct it, PATCH or PUT the specific stock_availables/{id} resource with the corrected quantity. Never write quantity back to the products resource.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: WS - Products - you can't get or set the product quantity (always zero), issue #18953. github.com/PrestaShop/PrestaShop/issues/18953
- PrestaShop GitHub: Trying to update stock via webservice, issue #17857. github.com/PrestaShop/PrestaShop/issues/17857
- PrestaShop Forums: Checking product quantity with WebService. forum.prestashop.com/topic/987958-checking-product-quantity-with-webservice
On the solution:
- PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
- PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock/
- PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az/
Stuck on a tricky one?
If you have a problem in PrestaShop stock, orders, order states, 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 untangle your stock numbers?
If this saved you a wrong stock feed or a silent write that never landed, 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