Reconciler
Product visibility setting silently reverts after being changed via API
You hide a product in one shop, or set it to catalog only, and it sticks for a while. Then a scheduled sync runs and the product is back to both, visible everywhere, like your change never happened. Nothing in your logs complains. This is not a fluke. A recurring sync is almost certainly overwriting the field, and in multistore setups a long standing webservice bug can make it worse. Here is why visibility keeps reverting and a small reconciler that finds and safely repairs the drift.
PrestaShop stores visibility (both, catalog, search, or none) per shop in ps_product_shop, keyed by id_shop, not as one global setting on the product. Scheduled sync jobs, ERP feeds, price and stock updaters, and marketplace connectors typically PUT the full product resource on every run from an external source of truth that never tracked your manual override, so each sync silently writes visibility back to both. Multistore installs make this worse because of a long standing webservice bug where a PUT does not reliably honor id_shop scoping, so a change meant for one shop can land on, or be read back from, the default shop instead. Run a Python or Node.js reconciler that keeps an intended-state list, polls the real value scoped by id_shop, reapplies a drifted value once with a scoped PUT, and flags it for a human instead of looping forever if it reverts again. Full code, tests, and citations are below.
The problem in plain words
Visibility looks like a simple flag on a product, so it is easy to assume that setting it once is permanent. It is not permanent, because it is not global. PrestaShop keeps visibility in ps_product_shop, one row per shop the product belongs to. Change it for shop B and shop A never sees that change, and neither does anything reading the product without a shop context.
The trouble starts when something else also writes to that same row. Most stores run at least one scheduled job that keeps the catalog in sync with an ERP, a price feed, a stock updater, or a marketplace connector. Those jobs usually build a full product payload from their own source of truth and PUT it on every run, because that is simpler than diffing field by field. Their source of truth has never heard of your manual "hide this in shop B" decision, so its payload always says visibility: both. The next time the job runs, your change is gone, and nothing logs it as an error because nothing failed. The write succeeded. It just succeeded at overwriting you.
Why it happens
Nothing here is a single bug you can patch and forget. It is a combination of how PrestaShop models visibility and how most integrations are written:
visibilitylives inps_product_shop, one row perid_shop, not as a single attribute on the product. A value you set for shop B says nothing about shop A, and reading or writing the product without shop scoping can quietly target the wrong row (see PrestaShop/PrestaShop issue #14386, which tracks visibility changes not sticking).- Scheduled sync jobs, ERP connectors, and marketplace integrations usually PUT the entire product resource on every run, because rebuilding the full payload from their own source of truth is simpler than diffing individual fields against what is already in PrestaShop.
- That external source of truth has no concept of a merchant's manual visibility override, so its payload always carries whatever it considers the default, typically
both, and it overwrites your change every time it runs, whether that is hourly or nightly. - PrestaShop's webservice has a long standing multistore bug where PUT requests do not reliably honor
id_shoporid_group_shopscoping, so an update intended for one shop context can apply to, or be read back from, the default shop instead (see issue #15317, about a product added invisible through the webservice, and issue #35901, about webservice writes in multistore mode not creating the expected per shop associations).
Put those together and a mismatch that only shows up when you read the product without id_shop scoping is a strong signal you are looking at the default-shop-fallback bug rather than a second sync job stepping on the first one. See the citations at the end for the exact issues and docs.
A single GET or PUT that ignores shop scope cannot tell you whether visibility reverted because of a competing sync or because of the multistore scoping bug. The fix is to keep your own record of what visibility should be per product and per shop, always read and write with id_shop explicit, and treat any mismatch found without that scoping as a hint that the default-shop fallback is involved, not a guess.
The fix, as a flow
We do not fight the sync job or try to guess its schedule. We add a reconciler that compares what you intended against what is actually in PrestaShop, scoped per shop, and reapplies the intended value exactly once per drift. If the same product and shop reverts again right after a reapply, the reconciler stops touching it and reports the conflict instead of looping against a job it cannot see.
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, for multistore installs, shops. 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
Read the real visibility, scoped by shop
Call GET /api/products/{id}?output_format=JSON&display=[id,visibility,active,id_shop_default]&id_shop={id_shop} for each product and shop in your intended-state list. For multistore installs, pull the shop ids first from GET /api/shops?output_format=JSON so you check every shop context a product could be scoped to, not just the default one.
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_shops():
data = api_get("shops", params={"display": "full"})
return data.get("shops") or []
def actual_visibility(id_product, id_shop):
data = api_get(f"products/{id_product}", params={
"display": "[id,visibility,active,id_shop_default]",
"id_shop": id_shop,
})
product = data.get("product") or {}
return product.get("visibility")
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 listShops() {
const data = await apiGet("shops", { display: "full" });
return data.shops || [];
}
async function actualVisibility(idProduct, idShop) {
const data = await apiGet(`products/${idProduct}`, {
display: "[id,visibility,active,id_shop_default]",
id_shop: idShop,
});
return (data.product || {}).visibility;
}
Batch-find products that drifted back to both
You do not need to poll every product one by one for the common drift. Call GET /api/products?filter[visibility]=both&filter[id]=[{comma list}]&display=[id,visibility]&output_format=JSON against the ids you care about, to quickly find the ones that came back to both, then confirm each one with the scoped GET above before you decide anything.
def find_drifted_to_both(product_ids):
id_list = "[" + ",".join(str(i) for i in product_ids) + "]"
data = api_get("products", params={
"filter[visibility]": "both",
"filter[id]": id_list,
"display": "[id,visibility]",
})
return data.get("products") or []
async function findDriftedToBoth(productIds) {
const idList = `[${productIds.join(",")}]`;
const data = await apiGet("products", {
"filter[visibility]": "both",
"filter[id]": idList,
display: "[id,visibility]",
});
return data.products || [];
}
Decide, with one pure function
Keep the decision in its own function that takes only plain dictionaries, no I/O at all. It compares the intended visibility for every (product_id, id_shop) pair against the actual value. If they match, do nothing. If they differ and this pair has never been reapplied, reapply the intended value once. If they differ and this pair was already reapplied once before, that means the reapply itself got reverted again, so stop and flag it for a human instead of writing again.
def decide_visibility_action(intended, actual, already_repaired_once):
"""Pure decision function, no I/O.
intended: dict[(product_id, id_shop) -> visibility] of what the merchant wants.
actual: dict[(product_id, id_shop) -> visibility] read back from PrestaShop.
already_repaired_once: set of (product_id, id_shop) keys already reapplied once
in a previous run, used as the repair-loop cutoff.
Returns a list of decision records, most severe (needs a human) is not ranked,
every key in intended gets exactly one record.
"""
decisions = []
for key, intended_value in intended.items():
product_id, id_shop = key
actual_value = actual.get(key)
if actual_value == intended_value:
action = "none"
elif key not in already_repaired_once:
action = "reapply"
else:
action = "flag"
decisions.append({
"product_id": product_id,
"id_shop": id_shop,
"intended": intended_value,
"actual": actual_value,
"action": action,
})
return decisions
/**
* Pure decision function, no I/O.
*
* intended: Map or plain object keyed by "productId:idShop" -> visibility the
* merchant wants.
* actual: same shape, the value read back from PrestaShop.
* alreadyRepairedOnce: Set of "productId:idShop" keys already reapplied once in a
* previous run, used as the repair-loop cutoff.
*/
export function decideVisibilityAction(intended, actual, alreadyRepairedOnce) {
const decisions = [];
for (const [key, intendedValue] of Object.entries(intended)) {
const [productIdStr, idShopStr] = key.split(":");
const actualValue = actual[key];
let action;
if (actualValue === intendedValue) {
action = "none";
} else if (!alreadyRepairedOnce.has(key)) {
action = "reapply";
} else {
action = "flag";
}
decisions.push({
productId: Number(productIdStr),
idShop: Number(idShopStr),
intended: intendedValue,
actual: actualValue === undefined ? null : actualValue,
action,
});
}
return decisions;
}
Reapply with a scoped PUT, never a global one
When the decision says to reapply, send PUT /api/products/{id}?output_format=JSON&id_shop={id_shop} with a body carrying only id and the intended visibility. Keeping id_shop in the query string is what keeps the write from landing on the default shop instead of the one you meant.
def api_put(path, resource_key, body, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params=params, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def reapply_visibility(id_product, id_shop, visibility):
body = {"id": id_product, "visibility": visibility}
return api_put(f"products/{id_product}", "product", body, params={"id_shop": id_shop})
async function apiPut(path, resourceKey, body, 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, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function reapplyVisibility(idProduct, idShop, visibility) {
const body = { id: idProduct, visibility };
return apiPut(`products/${idProduct}`, "product", body, { id_shop: idShop });
}
Wire it together with a dry run guard and a repair cutoff
The loop reads the intended-state list, checks the real value scoped by id_shop for each pair, runs everything through decide_visibility_action, reapplies once when told to, and remembers which pairs it already reapplied so the next run can flag instead of loop. Leave DRY_RUN on for the first runs and read the flagged pairs before you let it write. Run it on a schedule that fits how often your sync job runs, so you catch the drift before a human notices the product went missing.
Always start with DRY_RUN=true, and never let the reconciler reapply the same pair twice in a row without a human looking at it. A second silent revert means a competing job is fighting your override, and the fix for that is to find and change the job, not to write faster than it does.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, keeps the intended-state comparison pure and testable, respects the dry run flag, and stops auto-repairing a pair the moment it proves a competing job is reverting it.
"""Detect and repair PrestaShop products whose visibility silently reverts.
visibility ("both"/"catalog"/"search"/"none") lives per shop in ps_product_shop,
keyed by id_shop, not as a single attribute on the product. Scheduled sync jobs
(ERP feeds, price/stock updaters, marketplace connectors) typically PUT the full
product resource on every run from a source of truth that never tracked a
merchant's manual visibility override, so each sync silently writes visibility
back to "both" (PrestaShop/PrestaShop GitHub issue #14386). Multistore installs
also carry a long standing webservice bug where PUT does not reliably honor
id_shop scoping, so a change meant for one shop can land on, or be read back
from, the default shop instead (issues #15317 and #35901).
This script keeps an intended-state list of (product_id, id_shop) -> visibility,
reads the real value scoped by id_shop, and reapplies a drifted value exactly
once with a scoped PUT. If the same pair reverts again after that one reapply,
it stops writing and flags the pair for a human instead of looping against a
job it cannot see.
Run on a schedule. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_visibility")
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, "")
# Local record of pairs already reapplied once, so a second revert gets flagged
# instead of repaired again. In production, persist this to a file or a database.
REPAIRED_ONCE_STATE_FILE = os.environ.get("REPAIRED_ONCE_STATE_FILE", "repaired_once.json")
def decide_visibility_action(intended, actual, already_repaired_once):
"""Pure decision function, no I/O.
intended: dict[(product_id, id_shop) -> visibility] of what the merchant wants.
actual: dict[(product_id, id_shop) -> visibility] read back from PrestaShop.
already_repaired_once: set of (product_id, id_shop) keys already reapplied once
in a previous run, used as the repair-loop cutoff.
Returns a list of decision records, one per key in intended.
"""
decisions = []
for key, intended_value in intended.items():
product_id, id_shop = key
actual_value = actual.get(key)
if actual_value == intended_value:
action = "none"
elif key not in already_repaired_once:
action = "reapply"
else:
action = "flag"
decisions.append({
"product_id": product_id,
"id_shop": id_shop,
"intended": intended_value,
"actual": actual_value,
"action": action,
})
return decisions
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_put(path, resource_key, body, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params=params, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def actual_visibility(id_product, id_shop):
data = api_get(f"products/{id_product}", params={
"display": "[id,visibility,active,id_shop_default]",
"id_shop": id_shop,
})
product = data.get("product") or {}
return product.get("visibility")
def reapply_visibility(id_product, id_shop, visibility):
body = {"id": id_product, "visibility": visibility}
return api_put(f"products/{id_product}", "product", body, params={"id_shop": id_shop})
def load_repaired_once():
if not os.path.exists(REPAIRED_ONCE_STATE_FILE):
return set()
with open(REPAIRED_ONCE_STATE_FILE) as f:
pairs = json.load(f)
return {(p[0], p[1]) for p in pairs}
def save_repaired_once(pairs):
with open(REPAIRED_ONCE_STATE_FILE, "w") as f:
json.dump([[p[0], p[1]] for p in sorted(pairs)], f)
def run(intended):
"""intended: dict[(product_id, id_shop) -> visibility]."""
already_repaired_once = load_repaired_once()
actual = {}
for product_id, id_shop in intended:
actual[(product_id, id_shop)] = actual_visibility(product_id, id_shop)
decisions = decide_visibility_action(intended, actual, already_repaired_once)
reapplied = 0
flagged = 0
newly_repaired = set(already_repaired_once)
for d in decisions:
key = (d["product_id"], d["id_shop"])
if d["action"] == "none":
continue
if d["action"] == "reapply":
log.warning(
"Product %s shop %s drifted: intended=%s actual=%s. %s",
d["product_id"], d["id_shop"], d["intended"], d["actual"],
"would reapply" if DRY_RUN else "reapplying",
)
if not DRY_RUN:
reapply_visibility(d["product_id"], d["id_shop"], d["intended"])
newly_repaired.add(key)
reapplied += 1
elif d["action"] == "flag":
log.error(
"Product %s shop %s reverted again after a repair: intended=%s actual=%s. "
"Not auto-repairing again, a competing job is likely overwriting this.",
d["product_id"], d["id_shop"], d["intended"], d["actual"],
)
flagged += 1
if not DRY_RUN:
save_repaired_once(newly_repaired)
log.info("Done. %d pair(s) reapplied, %d pair(s) flagged for a human.", reapplied, flagged)
return decisions
if __name__ == "__main__":
# Example intended-state list. Replace with your real source, e.g. a JSON
# file or a database table of products you deliberately hid per shop.
example_intended = {
(12, 1): "none",
(12, 2): "both",
}
run(example_intended)
/**
* Detect and repair PrestaShop products whose visibility silently reverts.
*
* visibility ("both"/"catalog"/"search"/"none") lives per shop in ps_product_shop,
* keyed by id_shop, not as a single attribute on the product. Scheduled sync jobs
* (ERP feeds, price/stock updaters, marketplace connectors) typically PUT the full
* product resource on every run from a source of truth that never tracked a
* merchant's manual visibility override, so each sync silently writes visibility
* back to "both" (PrestaShop/PrestaShop GitHub issue #14386). Multistore installs
* also carry a long standing webservice bug where PUT does not reliably honor
* id_shop scoping, so a change meant for one shop can land on, or be read back
* from, the default shop instead (issues #15317 and #35901).
*
* This script keeps an intended-state map of "productId:idShop" -> visibility,
* reads the real value scoped by id_shop, and reapplies a drifted value exactly
* once with a scoped PUT. If the same pair reverts again after that one reapply,
* it stops writing and flags the pair for a human instead of looping against a
* job it cannot see.
*
* Guide: https://www.allanninal.dev/prestashop/product-visibility-reverts/
*/
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
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";
const REPAIRED_ONCE_STATE_FILE = process.env.REPAIRED_ONCE_STATE_FILE || "repaired_once.json";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* intended: plain object keyed by "productId:idShop" -> visibility the merchant wants.
* actual: same shape, the value read back from PrestaShop.
* alreadyRepairedOnce: Set of "productId:idShop" keys already reapplied once in a
* previous run, used as the repair-loop cutoff.
*/
export function decideVisibilityAction(intended, actual, alreadyRepairedOnce) {
const decisions = [];
for (const [key, intendedValue] of Object.entries(intended)) {
const [productIdStr, idShopStr] = key.split(":");
const actualValue = actual[key];
let action;
if (actualValue === intendedValue) {
action = "none";
} else if (!alreadyRepairedOnce.has(key)) {
action = "reapply";
} else {
action = "flag";
}
decisions.push({
productId: Number(productIdStr),
idShop: Number(idShopStr),
intended: intendedValue,
actual: actualValue === undefined ? null : actualValue,
action,
});
}
return decisions;
}
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 apiPut(path, resourceKey, body, 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, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function actualVisibility(idProduct, idShop) {
const data = await apiGet(`products/${idProduct}`, {
display: "[id,visibility,active,id_shop_default]",
id_shop: idShop,
});
return (data.product || {}).visibility;
}
async function reapplyVisibility(idProduct, idShop, visibility) {
const body = { id: idProduct, visibility };
return apiPut(`products/${idProduct}`, "product", body, { id_shop: idShop });
}
function loadRepairedOnce() {
if (!existsSync(REPAIRED_ONCE_STATE_FILE)) return new Set();
const pairs = JSON.parse(readFileSync(REPAIRED_ONCE_STATE_FILE, "utf8"));
return new Set(pairs);
}
function saveRepairedOnce(pairs) {
writeFileSync(REPAIRED_ONCE_STATE_FILE, JSON.stringify([...pairs].sort()));
}
export async function run(intended) {
const alreadyRepairedOnce = loadRepairedOnce();
const actual = {};
for (const key of Object.keys(intended)) {
const [productIdStr, idShopStr] = key.split(":");
actual[key] = await actualVisibility(Number(productIdStr), Number(idShopStr));
}
const decisions = decideVisibilityAction(intended, actual, alreadyRepairedOnce);
let reapplied = 0;
let flagged = 0;
const newlyRepaired = new Set(alreadyRepairedOnce);
for (const d of decisions) {
const key = `${d.productId}:${d.idShop}`;
if (d.action === "none") continue;
if (d.action === "reapply") {
console.warn(
`Product ${d.productId} shop ${d.idShop} drifted: intended=${d.intended} actual=${d.actual}. ` +
`${DRY_RUN ? "would reapply" : "reapplying"}`
);
if (!DRY_RUN) {
await reapplyVisibility(d.productId, d.idShop, d.intended);
newlyRepaired.add(key);
}
reapplied++;
} else if (d.action === "flag") {
console.error(
`Product ${d.productId} shop ${d.idShop} reverted again after a repair: intended=${d.intended} actual=${d.actual}. ` +
`Not auto-repairing again, a competing job is likely overwriting this.`
);
flagged++;
}
}
if (!DRY_RUN) saveRepairedOnce(newlyRepaired);
console.log(`Done. ${reapplied} pair(s) reapplied, ${flagged} pair(s) flagged for a human.`);
return decisions;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
// Example intended-state map. Replace with your real source, e.g. a JSON
// file or a database table of products you deliberately hid per shop.
const exampleIntended = {
"12:1": "none",
"12:2": "both",
};
run(exampleIntended).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 reconciler keeps repairing a product forever or knows to stop and flag a human. Because we kept decide_visibility_action pure, the test needs no network and no PrestaShop store. It just feeds in plain dictionaries and checks the answer.
from reconcile_visibility import decide_visibility_action
def test_no_action_when_actual_matches_intended():
intended = {(1, 1): "none"}
actual = {(1, 1): "none"}
result = decide_visibility_action(intended, actual, set())
assert result[0]["action"] == "none"
def test_reapply_when_drifted_and_never_repaired():
intended = {(1, 1): "none"}
actual = {(1, 1): "both"}
result = decide_visibility_action(intended, actual, set())
assert result[0]["action"] == "reapply"
assert result[0]["intended"] == "none"
assert result[0]["actual"] == "both"
def test_flag_when_drifted_again_after_a_repair():
intended = {(1, 1): "none"}
actual = {(1, 1): "both"}
result = decide_visibility_action(intended, actual, {(1, 1)})
assert result[0]["action"] == "flag"
def test_missing_actual_value_is_treated_as_drift():
intended = {(2, 3): "catalog"}
actual = {}
result = decide_visibility_action(intended, actual, set())
assert result[0]["action"] == "reapply"
assert result[0]["actual"] is None
def test_handles_multiple_pairs_independently():
intended = {(1, 1): "none", (2, 1): "search", (3, 1): "both"}
actual = {(1, 1): "both", (2, 1): "search", (3, 1): "both"}
result = decide_visibility_action(intended, actual, {(1, 1)})
by_key = {(d["product_id"], d["id_shop"]): d["action"] for d in result}
assert by_key[(1, 1)] == "flag"
assert by_key[(2, 1)] == "none"
assert by_key[(3, 1)] == "none"
def test_returns_one_decision_per_intended_key():
intended = {(1, 1): "none", (1, 2): "both"}
actual = {(1, 1): "none", (1, 2): "both"}
result = decide_visibility_action(intended, actual, set())
assert len(result) == 2
def test_no_network_or_side_effects():
# decide_visibility_action must be pure: same inputs, same output, no I/O.
intended = {(9, 1): "search"}
actual = {(9, 1): "search"}
first = decide_visibility_action(intended, actual, set())
second = decide_visibility_action(intended, actual, set())
assert first == second
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideVisibilityAction } from "./reconcile-visibility.js";
test("no action when actual matches intended", () => {
const result = decideVisibilityAction({ "1:1": "none" }, { "1:1": "none" }, new Set());
assert.equal(result[0].action, "none");
});
test("reapply when drifted and never repaired", () => {
const result = decideVisibilityAction({ "1:1": "none" }, { "1:1": "both" }, new Set());
assert.equal(result[0].action, "reapply");
assert.equal(result[0].intended, "none");
assert.equal(result[0].actual, "both");
});
test("flag when drifted again after a repair", () => {
const result = decideVisibilityAction({ "1:1": "none" }, { "1:1": "both" }, new Set(["1:1"]));
assert.equal(result[0].action, "flag");
});
test("missing actual value is treated as drift", () => {
const result = decideVisibilityAction({ "2:3": "catalog" }, {}, new Set());
assert.equal(result[0].action, "reapply");
assert.equal(result[0].actual, null);
});
test("handles multiple pairs independently", () => {
const intended = { "1:1": "none", "2:1": "search", "3:1": "both" };
const actual = { "1:1": "both", "2:1": "search", "3:1": "both" };
const result = decideVisibilityAction(intended, actual, new Set(["1:1"]));
const byKey = Object.fromEntries(result.map((d) => [`${d.productId}:${d.idShop}`, d.action]));
assert.equal(byKey["1:1"], "flag");
assert.equal(byKey["2:1"], "none");
assert.equal(byKey["3:1"], "none");
});
test("returns one decision per intended key", () => {
const intended = { "1:1": "none", "1:2": "both" };
const actual = { "1:1": "none", "1:2": "both" };
const result = decideVisibilityAction(intended, actual, new Set());
assert.equal(result.length, 2);
});
test("no network or side effects, pure function", () => {
const intended = { "9:1": "search" };
const actual = { "9:1": "search" };
const first = decideVisibilityAction(intended, actual, new Set());
const second = decideVisibilityAction(intended, actual, new Set());
assert.deepEqual(first, second);
});
Case studies
The seasonal product that kept coming back
A store hid an out of season product in one shop for a multistore chain by setting visibility to none. The nightly ERP sync PUT the full product from the warehouse system every night, which had never heard of the seasonal hide, so by morning the product was back to both and customers could order something not actually available in that location.
The team added the reconciler as a second nightly job that ran right after the ERP sync, comparing intended visibility against the scoped actual value. It reapplied the hide the moment it detected the revert, and when the same product reverted twice in a row, it flagged the ERP job by name so the integration team could fix the payload at the source instead of chasing it forever.
Hidden in shop B, visible everywhere
A merchant running two shops on one PrestaShop install set a product to catalog only for shop B. A support ticket a week later showed the product fully visible in search on shop B, even though no sync job for that catalog had run recently.
Reading the product without id_shop scoping showed both, but reading it with id_shop explicit for shop A showed the value the merchant expected. That pattern, a mismatch only visible without shop scoping, pointed at the default-shop webservice bug rather than a competing job. The team started always reading and writing with id_shop explicit and used the reconciler to catch and reapply the rare case it still slipped through.
After this runs on a schedule, a manual visibility change stops being a coin flip against whatever sync ran last. Every read and write carries an explicit id_shop, drift gets caught and reapplied automatically the first time, and a product that keeps reverting gets reported by name instead of silently flipping back and forth forever. The intended-state list becomes the one place that says what visibility should be, and everything else is checked against it.
FAQ
Why does my PrestaShop product visibility keep reverting to both?
Visibility is stored per shop in ps_product_shop, but most scheduled sync jobs PUT the full product resource from an external source of truth that has no idea a merchant hid the product in one shop. Every time that job runs it sends visibility as both again, so your manual change gets silently overwritten on the next sync.
Is this a multistore bug or a sync job overwriting my change?
It can be either, and sometimes both at once. A competing sync job overwriting visibility on every run is the common cause. But PrestaShop also has a long standing webservice bug where a PUT does not reliably honor id_shop scoping in multistore, so an update meant for one shop can land on or be read back from the default shop instead. Checking whether the drift only shows up without id_shop scoping helps you tell them apart.
How do I safely fix a product whose visibility keeps drifting?
Keep an intended-state list of the visibility you actually want per product and per shop, poll the real value with a scoped GET, and reapply it with a scoped PUT only once. If the very next check shows it reverted again, stop auto-repairing that product and flag it for a human instead of looping forever against a competing job.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Can not change visibility of a product, issue #14386. github.com/PrestaShop/PrestaShop/issues/14386
- PrestaShop GitHub: Invisible Product in front added with WebService, issue #15317. github.com/PrestaShop/PrestaShop/issues/15317
- PrestaShop GitHub: Updating product images via Webservice in multistore mode does not create the proper associations in ps_image_shop, issue #35901. github.com/PrestaShop/PrestaShop/issues/35901
On the solution:
- PrestaShop Developer Documentation: Products webservice resource reference. devdocs.prestashop-project.org/9/webservice/resources/products/
- PrestaShop Developer Documentation: Manage Multishop. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/manage-multishop/
- PrestaShop Developer Documentation: Update a resource. devdocs.prestashop-project.org/9/webservice/tutorials/prestashop-webservice-lib/update-resource/
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 stop your product from disappearing again?
If this saved you a confusing revert or a customer complaint about a hidden product showing up everywhere, 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