Diagnostic
Combination resolved from the wrong shop context in multistore
A product in your multistore install has a combination that looks broken in one shop only. Price shows 0, or the minimal quantity is not what you set, but the same product looks fine in a sibling shop. Nothing in your data changed. This is a known gap in how PrestaShop's assembler resolves the id_product_attribute for a product: it can pick a combination row that has a product_attribute_shop association for a different shop, not the one being served. Here is why that happens and a small script that finds every product where this has likely occurred.
Combinations live in one shared product_attribute row, but their price, impact, and default-attribute fields live in the per-shop product_attribute_shop association table. Historically the assembler code that resolves a product's combination, ProductAssemblerCore::addMissingProductFields and the cache_default_attribute lookups such as getIdProductAttributeByIdAttributes, queried product_attribute and product_attribute_shop without consistently filtering by the current id_shop, so it could resolve an id_product_attribute that only has an association row for a sibling shop. This was tracked upstream as GitHub issue 17573, where the accepted fix joins product_attribute_shop filtered on pas.id_shop = idShop and threads id_shop through the resolver methods. Run a Python or Node.js script that walks every shop, every product with combinations, and cross-checks the resolved combination against stock_availables for that shop. When the resolved id_product_attribute has no matching row for the shop context, flag it. Repair is a separate, explicitly confirmed step. Full code, tests, and citations are below.
The problem in plain words
In PrestaShop multistore, a combination is not duplicated per shop. There is one product_attribute row for a given size or color variant, shared across every shop the product belongs to. What changes per shop is the price, the impact on the base price, the minimal quantity, and whether that combination is the default one, and all of those live in product_attribute_shop, a separate table keyed by both id_product_attribute and id_shop.
When a page or an API call asks for a product in a given shop, something has to decide which id_product_attribute is the default or the requested one for that shop, and then pull its price and quantity fields from the matching product_attribute_shop row. That resolver is the assembler. If it queries product_attribute and product_attribute_shop without filtering consistently by id_shop, it can hand back a combination id that genuinely exists, and genuinely has a product_attribute_shop row, just not one for the shop you are actually serving. The price then reads as 0, or the minimal quantity reads as whatever a sibling shop configured, because the row the code actually joined against belongs to that other shop.
Why it happens
This is a documented gap in the core assembler, not a mistake in your catalog. A few things make it easy to hit:
- Combinations are shared in one
product_attributetable across every shop a product belongs to, while the shop-specific fields, price, impact, minimal quantity, and default status, live only inproduct_attribute_shop. - The resolver code,
ProductAssemblerCore::addMissingProductFieldsand thecache_default_attributelookups such asgetIdProductAttributeByIdAttributes, did not consistently threadid_shopthrough every query path, so some code paths pull the first matching row instead of the one scoped to the current shop. - This was tracked and fixed upstream in PrestaShop/PrestaShop issue 17573, where the accepted patch joins
product_attribute_shopfiltered onpas.id_shop = idShopand passesid_shopthrough the resolver methods. - Related reports, issue 31378 and issue 28773, describe the same family of symptom: a product with different combinations across shops behaves inconsistently or errors depending on which shop context is active.
Because the combination row itself is not broken and the sibling shop shows correct data, this is easy to dismiss as a one-off glitch until it hits a second and third product. See the citations at the end for the exact threads and docs.
The webservice cannot tell you on its own that a resolved combination belongs to the wrong shop, because the resolved view for a shop just returns whatever id_product_attribute the assembler picked. So the safe pattern is to cross-check that resolved id against an independent, shop-scoped signal, the stock_availables row for that exact product, combination, and shop. When that row is missing or belongs to a different shop, the combination the assembler handed you was never actually valid for this shop context, and that is worth flagging for a human before anything is changed.
The fix, as a flow
We do not touch the storefront or rewrite combinations automatically. We add a job that enumerates shops, lists each shop's products, reads the resolved combination for that shop, and pulls the full combination list plus the matching stock_availables rows. A pure decision function flags every combination whose resolved shop does not appear among its actual associated shops. A corrective write is only sent when a genuine missing-association gap is confirmed, and only when explicitly authorized.
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 shops, products, combinations, 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 allow the guarded PUT
// 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 allow the guarded PUT
Enumerate the shops in this install
Call GET /api/shops?display=full&output_format=JSON to get every shop id. Each one is a separate context we need to check the resolved combination against.
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 all_shop_ids():
data = api_get("shops", params={"display": "full"})
rows = data.get("shops") or []
return [int(row["id"]) for row in rows]
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 allShopIds() {
const data = await apiGet("shops", { display: "full" });
const rows = data.shops || [];
return rows.map((row) => Number(row.id));
}
List each shop's products, then read the resolved combination
For each id_shop, call GET /api/products?filter[id_shop]={id_shop}&display=full to find its products. For each product with combinations, call GET /api/products/{id_product}?id_shop={id_shop}&display=full to read the resolved id_default_combination, price, and minimal_quantity for that exact shop context. This is the value your storefront would actually show in that shop.
def products_for_shop(id_shop):
data = api_get("products", params={"display": "full", "filter[id_shop]": id_shop})
return data.get("products") or []
def resolved_product_for_shop(id_product, id_shop):
data = api_get(f"products/{id_product}", params={"id_shop": id_shop, "display": "full"})
return data.get("product") or {}
async function productsForShop(idShop) {
const data = await apiGet("products", { display: "full", "filter[id_shop]": idShop });
return data.products || [];
}
async function resolvedProductForShop(idProduct, idShop) {
const data = await apiGet(`products/${idProduct}`, { id_shop: idShop, display: "full" });
return data.product || {};
}
Pull the full combination list and cross-check with stock_availables
Call GET /api/combinations?filter[id_product]={id_product}&display=full to get every id_product_attribute for the product, and build a map of which shops each one is actually associated with. Then, for the resolved combination from step 3, call GET /api/stock_availables?filter[id_product]={id_product}&filter[id_product_attribute]={id_product_attribute}&filter[id_shop]={id_shop}&display=full. A mismatch is a resolved combination with no stock_availables row for that exact shop, meaning it has no product_attribute_shop association there either.
def combinations_for_product(id_product):
data = api_get("combinations", params={"display": "full", "filter[id_product]": id_product})
return data.get("combinations") or []
def stock_available_shops(id_product, id_product_attribute):
data = api_get("stock_availables", params={
"display": "full",
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
})
rows = data.get("stock_availables") or []
return {int(row["id_shop"]) for row in rows if int(row.get("id_shop", 0)) > 0}
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
return data.combinations || [];
}
async function stockAvailableShops(idProduct, idProductAttribute) {
const data = await apiGet("stock_availables", {
display: "full",
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
});
const rows = data.stock_availables || [];
return new Set(rows.map((row) => Number(row.id_shop)).filter((id) => id > 0));
}
Decide, with one pure function
Keep the decision in its own function that takes only plain dicts and sets, no I/O at all. It compares the shop a combination was resolved in against the shops it is actually associated with, derived from the stock_availables cross-check. A combination is flagged whenever the shop it was resolved for is not among its actual shops.
def find_shop_mismatched_combinations(shop_id, product_combinations, shop_associations_by_combination):
flagged = []
for combo in product_combinations:
id_product_attribute = combo["id_product_attribute"]
actual_shops = shop_associations_by_combination.get(id_product_attribute, set())
if shop_id not in actual_shops:
flagged.append({
"id_product_attribute": id_product_attribute,
"id_product": combo["id_product"],
"resolved_in_shop": shop_id,
"actual_shops": sorted(actual_shops),
"reason": "resolved id_product_attribute has no product_attribute_shop association for this shop",
})
return flagged
export function findShopMismatchedCombinations(shopId, productCombinations, shopAssociationsByCombination) {
const flagged = [];
for (const combo of productCombinations) {
const idProductAttribute = combo.id_product_attribute;
const actualShops = shopAssociationsByCombination.get(idProductAttribute) || new Set();
if (!actualShops.has(shopId)) {
flagged.push({
id_product_attribute: idProductAttribute,
id_product: combo.id_product,
resolved_in_shop: shopId,
actual_shops: [...actualShops].sort((a, b) => a - b),
reason: "resolved id_product_attribute has no product_attribute_shop association for this shop",
});
}
}
return flagged;
}
Report by default, repair only when explicitly confirmed
Because this is a core assembler defect and not something safely correctable by rewriting arbitrary store data, the default action is to log every flagged combination and stop there. Only when a genuine missing-association gap is confirmed, meaning the combination exists but simply lacks a product_attribute_shop row for the target shop, is the guarded write sent: a PUT to /api/combinations/{id_product_attribute} with the combination's existing body plus ?id_shop={id_shop}, resending the same content to create the missing association, per the Manage Multishop pattern. It never deletes or reassigns the core product_attribute row, and it only runs when DRY_RUN=false.
Always start with DRY_RUN=true. Flagging is safe and reversible. The guarded PUT is only for a confirmed missing-association gap, one id_product_attribute and id_shop pair at a time, never a bulk rewrite, and never a delete or reassignment of the shared product_attribute row.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, reports by default, and only logs the intended PUT payload for a confirmed missing-association gap unless the dry run flag is explicitly turned off.
"""Flag PrestaShop combinations resolved from the wrong shop context in multistore.
Combinations are shared in one product_attribute row, but price, impact, and
default-attribute fields live in the per-shop product_attribute_shop association
table. Historically the assembler code that resolves a product's combination,
ProductAssemblerCore::addMissingProductFields and cache_default_attribute lookups
such as getIdProductAttributeByIdAttributes, queried product_attribute and
product_attribute_shop without consistently filtering by id_shop, so it could
resolve an id_product_attribute that only has an association row for a sibling
shop (PrestaShop/PrestaShop issue 17573). The symptom is a combination showing
price 0 or the wrong minimal_quantity in one shop only.
This script enumerates shops, lists each shop's products, reads the resolved
combination per shop, and cross-checks it against stock_availables to learn
which shops a combination is actually associated with. A pure decision function
flags every combination whose resolved shop is not among its actual shops. It
reports by default. A guarded PUT to /api/combinations/{id} with ?id_shop= is
only logged, and only sent when DRY_RUN=false, for a confirmed missing-association
gap. It never deletes or reassigns the core product_attribute row.
Run on a schedule, or right after a multistore catalog sync. Safe to run again
and again, since it never writes unless DRY_RUN is explicitly turned off.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_shop_mismatch")
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 find_shop_mismatched_combinations(shop_id, product_combinations, shop_associations_by_combination):
"""Pure decision function, no I/O.
shop_id: the id_shop context the product/combination was resolved under.
product_combinations: list of dicts like {"id_product_attribute": int,
"id_product": int, "price": float, "minimal_quantity": int} as resolved
for this shop context.
shop_associations_by_combination: map of id_product_attribute -> set of
id_shop values that combination is actually associated with (derived
from product_attribute_shop / combinations API).
Returns a list of flagged dicts for every combination whose resolved shop_id
is not among its actual associated shops.
"""
flagged = []
for combo in product_combinations:
id_product_attribute = combo["id_product_attribute"]
actual_shops = shop_associations_by_combination.get(id_product_attribute, set())
if shop_id not in actual_shops:
flagged.append({
"id_product_attribute": id_product_attribute,
"id_product": combo["id_product"],
"resolved_in_shop": shop_id,
"actual_shops": sorted(actual_shops),
"reason": "resolved id_product_attribute has no product_attribute_shop association for this shop",
})
return flagged
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):
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 all_shop_ids():
data = api_get("shops", params={"display": "full"})
rows = data.get("shops") or []
return [int(row["id"]) for row in rows]
def products_for_shop(id_shop):
data = api_get("products", params={"display": "full", "filter[id_shop]": id_shop})
return data.get("products") or []
def resolved_product_for_shop(id_product, id_shop):
data = api_get(f"products/{id_product}", params={"id_shop": id_shop, "display": "full"})
return data.get("product") or {}
def combinations_for_product(id_product):
data = api_get("combinations", params={"display": "full", "filter[id_product]": id_product})
return data.get("combinations") or []
def stock_available_shops(id_product, id_product_attribute):
data = api_get("stock_availables", params={
"display": "full",
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
})
rows = data.get("stock_availables") or []
return {int(row["id_shop"]) for row in rows if int(row.get("id_shop", 0)) > 0}
def rescope_combination_to_shop(combination, id_shop):
# Resend the identical combination body, scoping the query string to id_shop,
# to create the missing product_attribute_shop association. Per the Manage
# Multishop pattern. Never deletes or reassigns the core product_attribute row.
body = dict(combination)
return api_put(
f"combinations/{combination['id']}", "combination", body,
params={"output_format": "JSON", "id_shop": id_shop},
)
def run(confirm=False):
flagged_total = 0
repaired = 0
for id_shop in all_shop_ids():
for product in products_for_shop(id_shop):
id_product = int(product["id"])
resolved = resolved_product_for_shop(id_product, id_shop)
if not resolved.get("id_default_combination"):
continue
combinations = combinations_for_product(id_product)
if not combinations:
continue
shop_map = {}
for combo in combinations:
id_product_attribute = int(combo["id"])
shop_map[id_product_attribute] = stock_available_shops(id_product, id_product_attribute)
resolved_combo = {
"id_product_attribute": int(resolved["id_default_combination"]),
"id_product": id_product,
"price": resolved.get("price"),
"minimal_quantity": resolved.get("minimal_quantity"),
}
flagged = find_shop_mismatched_combinations(id_shop, [resolved_combo], shop_map)
for item in flagged:
flagged_total += 1
log.warning(
"Product %s id_product_attribute=%s resolved_in_shop=%s actual_shops=%s",
item["id_product"], item["id_product_attribute"], item["resolved_in_shop"], item["actual_shops"],
)
if not DRY_RUN and confirm:
combo_body = next((c for c in combinations if int(c["id"]) == item["id_product_attribute"]), None)
if combo_body is not None:
rescope_combination_to_shop(combo_body, id_shop)
repaired += 1
log.info(
"Repaired id_product_attribute=%s for id_shop=%s.",
item["id_product_attribute"], id_shop,
)
log.info("Done. %d combination(s) flagged, %d repaired.", flagged_total, repaired)
if __name__ == "__main__":
run()
/**
* Flag PrestaShop combinations resolved from the wrong shop context in multistore.
*
* Combinations are shared in one product_attribute row, but price, impact, and
* default-attribute fields live in the per-shop product_attribute_shop association
* table. Historically the assembler code that resolves a product's combination,
* ProductAssemblerCore::addMissingProductFields and cache_default_attribute lookups
* such as getIdProductAttributeByIdAttributes, queried product_attribute and
* product_attribute_shop without consistently filtering by id_shop, so it could
* resolve an id_product_attribute that only has an association row for a sibling
* shop (PrestaShop/PrestaShop issue 17573). The symptom is a combination showing
* price 0 or the wrong minimal_quantity in one shop only.
*
* This script enumerates shops, lists each shop's products, reads the resolved
* combination per shop, and cross-checks it against stock_availables to learn
* which shops a combination is actually associated with. A pure decision function
* flags every combination whose resolved shop is not among its actual shops. It
* reports by default. A guarded PUT to /api/combinations/{id} with ?id_shop= is
* only logged, and only sent when DRY_RUN=false, for a confirmed missing-association
* gap. It never deletes or reassigns the core product_attribute row.
*
* Guide: https://www.allanninal.dev/prestashop/wrong-shop-combination-resolved/
*/
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.
*
* shopId: the id_shop context the product/combination was resolved under.
* productCombinations: array of objects like { id_product_attribute, id_product,
* price, minimal_quantity } as resolved for this shop context.
* shopAssociationsByCombination: Map of id_product_attribute -> Set of id_shop
* values that combination is actually associated with (derived from
* product_attribute_shop / combinations API).
*
* Returns an array of flagged objects for every combination whose resolved
* shopId is not among its actual associated shops.
*/
export function findShopMismatchedCombinations(shopId, productCombinations, shopAssociationsByCombination) {
const flagged = [];
for (const combo of productCombinations) {
const idProductAttribute = combo.id_product_attribute;
const actualShops = shopAssociationsByCombination.get(idProductAttribute) || new Set();
if (!actualShops.has(shopId)) {
flagged.push({
id_product_attribute: idProductAttribute,
id_product: combo.id_product,
resolved_in_shop: shopId,
actual_shops: [...actualShops].sort((a, b) => a - b),
reason: "resolved id_product_attribute has no product_attribute_shop association for this shop",
});
}
}
return flagged;
}
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}`);
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 allShopIds() {
const data = await apiGet("shops", { display: "full" });
const rows = data.shops || [];
return rows.map((row) => Number(row.id));
}
async function productsForShop(idShop) {
const data = await apiGet("products", { display: "full", "filter[id_shop]": idShop });
return data.products || [];
}
async function resolvedProductForShop(idProduct, idShop) {
const data = await apiGet(`products/${idProduct}`, { id_shop: idShop, display: "full" });
return data.product || {};
}
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
return data.combinations || [];
}
async function stockAvailableShops(idProduct, idProductAttribute) {
const data = await apiGet("stock_availables", {
display: "full",
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
});
const rows = data.stock_availables || [];
return new Set(rows.map((row) => Number(row.id_shop)).filter((id) => id > 0));
}
async function rescopeCombinationToShop(combination, idShop) {
// Resend the identical combination body, scoping the query string to id_shop,
// to create the missing product_attribute_shop association. Per the Manage
// Multishop pattern. Never deletes or reassigns the core product_attribute row.
const body = { ...combination };
return apiPut(`combinations/${combination.id}`, "combination", body, {
output_format: "JSON",
id_shop: idShop,
});
}
export async function run(confirm = false) {
let flaggedTotal = 0;
let repaired = 0;
for (const idShop of await allShopIds()) {
for (const product of await productsForShop(idShop)) {
const idProduct = Number(product.id);
const resolved = await resolvedProductForShop(idProduct, idShop);
if (!resolved.id_default_combination) continue;
const combinations = await combinationsForProduct(idProduct);
if (!combinations.length) continue;
const shopMap = new Map();
for (const combo of combinations) {
const idProductAttribute = Number(combo.id);
shopMap.set(idProductAttribute, await stockAvailableShops(idProduct, idProductAttribute));
}
const resolvedCombo = {
id_product_attribute: Number(resolved.id_default_combination),
id_product: idProduct,
price: resolved.price,
minimal_quantity: resolved.minimal_quantity,
};
const flagged = findShopMismatchedCombinations(idShop, [resolvedCombo], shopMap);
for (const item of flagged) {
flaggedTotal++;
console.warn(
`Product ${item.id_product} id_product_attribute=${item.id_product_attribute} resolved_in_shop=${item.resolved_in_shop} actual_shops=${JSON.stringify(item.actual_shops)}`
);
if (!DRY_RUN && confirm) {
const comboBody = combinations.find((c) => Number(c.id) === item.id_product_attribute);
if (comboBody) {
await rescopeCombinationToShop(comboBody, idShop);
repaired++;
console.log(`Repaired id_product_attribute=${item.id_product_attribute} for id_shop=${idShop}.`);
}
}
}
}
}
console.log(`Done. ${flaggedTotal} combination(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const confirm = process.argv.includes("--confirm");
run(confirm).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which combinations get reported, and it gates the only write path in the script. Because we kept find_shop_mismatched_combinations pure, no I/O and no network, the tests just feed in plain dicts and sets and check the answer.
from find_shop_mismatch import find_shop_mismatched_combinations
def combo(**over):
base = {"id_product_attribute": 100, "id_product": 10, "price": 19.99, "minimal_quantity": 1}
base.update(over)
return base
def test_flags_when_resolved_shop_not_in_actual_shops():
result = find_shop_mismatched_combinations(1, [combo()], {100: {2, 3}})
assert len(result) == 1
assert result[0]["id_product_attribute"] == 100
assert result[0]["resolved_in_shop"] == 1
assert result[0]["actual_shops"] == [2, 3]
def test_no_flag_when_resolved_shop_is_among_actual_shops():
result = find_shop_mismatched_combinations(1, [combo()], {100: {1, 2}})
assert result == []
def test_no_flag_when_only_one_shop_and_it_matches():
result = find_shop_mismatched_combinations(1, [combo()], {100: {1}})
assert result == []
def test_flags_when_combination_has_no_association_at_all():
result = find_shop_mismatched_combinations(1, [combo()], {})
assert len(result) == 1
assert result[0]["actual_shops"] == []
def test_multiple_combinations_only_mismatched_ones_flagged():
combos = [combo(id_product_attribute=100), combo(id_product_attribute=200)]
shop_map = {100: {1}, 200: {2}}
result = find_shop_mismatched_combinations(1, combos, shop_map)
assert len(result) == 1
assert result[0]["id_product_attribute"] == 200
def test_reason_explains_missing_association():
result = find_shop_mismatched_combinations(1, [combo()], {100: {2}})
assert "product_attribute_shop" in result[0]["reason"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findShopMismatchedCombinations } from "./find-shop-mismatch.js";
const combo = (over = {}) => ({ id_product_attribute: 100, id_product: 10, price: 19.99, minimal_quantity: 1, ...over });
test("flags when resolved shop not in actual shops", () => {
const result = findShopMismatchedCombinations(1, [combo()], new Map([[100, new Set([2, 3])]]));
assert.equal(result.length, 1);
assert.equal(result[0].id_product_attribute, 100);
assert.equal(result[0].resolved_in_shop, 1);
assert.deepEqual(result[0].actual_shops, [2, 3]);
});
test("no flag when resolved shop is among actual shops", () => {
const result = findShopMismatchedCombinations(1, [combo()], new Map([[100, new Set([1, 2])]]));
assert.deepEqual(result, []);
});
test("no flag when only one shop and it matches", () => {
const result = findShopMismatchedCombinations(1, [combo()], new Map([[100, new Set([1])]]));
assert.deepEqual(result, []);
});
test("flags when combination has no association at all", () => {
const result = findShopMismatchedCombinations(1, [combo()], new Map());
assert.equal(result.length, 1);
assert.deepEqual(result[0].actual_shops, []);
});
test("multiple combinations, only mismatched ones flagged", () => {
const combos = [combo({ id_product_attribute: 100 }), combo({ id_product_attribute: 200 })];
const shopMap = new Map([[100, new Set([1])], [200, new Set([2])]]);
const result = findShopMismatchedCombinations(1, combos, shopMap);
assert.equal(result.length, 1);
assert.equal(result[0].id_product_attribute, 200);
});
test("reason explains missing association", () => {
const result = findShopMismatchedCombinations(1, [combo()], new Map([[100, new Set([2])]]));
assert.match(result[0].reason, /product_attribute_shop/);
});
Case studies
The size variant that priced at zero in the new region
A footwear brand ran one PrestaShop install with a shop per region, sharing the same combinations across all of them but pricing each size differently per region. After a catalog sync added a new region, a handful of products started showing a size variant at price 0 in that region only, while the same product looked correct in the original region.
Running the detection script against every shop turned up the pattern fast: the resolved id_default_combination for the new region had a product_attribute_shop row for the original region, not the new one. The team confirmed it was a genuine missing association from an incomplete sync step, then ran the guarded repair one id_product_attribute and id_shop pair at a time to backfill the missing rows.
Wholesale minimal quantities leaking into retail
A merchant ran a public storefront and a wholesale shop in one install, sharing combinations but setting a much higher minimal_quantity on the wholesale side. A retail customer reported being unable to buy a single unit of one product, and the resolved combination for the retail shop turned out to carry the wholesale shop's minimal_quantity.
The report script flagged the exact id_product_attribute and confirmed its actual_shops list only contained the wholesale shop id, not retail. Because this traced back to the core assembler gap in issue 17573 rather than a data gap, the team left it as a flagged, monitored case and applied the upstream-recommended core fix instead of guessing at a data-only repair.
After this runs on a schedule, every combination your storefronts resolve gets cross-checked against the shop it was actually served in, and nothing silently shows a sibling shop's price or minimal quantity without someone knowing about it. The report tells you exactly which id_product_attribute and id_shop pair is involved and why, so a human can tell a genuine missing-association gap from the underlying core assembler defect before anything changes.
FAQ
Why does a PrestaShop combination show price 0 or the wrong minimal_quantity in one shop?
Combinations are shared in the product_attribute table but scoped per shop through product_attribute_shop, which is where the price, impact, and default-attribute fields actually live. When the assembler resolves the default or requested id_product_attribute for a product without consistently filtering by the current id_shop, it can return a combination that only has a product_attribute_shop row for a sibling shop. That row's price and minimal_quantity do not apply to the shop being served, so the values come back as 0 or wrong.
Is this a bug in my store data or in PrestaShop core?
It is a core assembler defect, tracked upstream as PrestaShop/PrestaShop issue 17573. The accepted fix joins product_attribute_shop filtered on pas.id_shop = idShop and threads id_shop through the resolver methods such as ProductAssemblerCore::addMissingProductFields and the cache_default_attribute lookups. Your store data is not corrupted just because this happens, so the default response is to detect and flag it, not to rewrite arbitrary rows.
When is it safe to write a fix instead of just flagging the mismatch?
Only when you confirm the combination itself is fine but is genuinely missing a product_attribute_shop association row for the target shop, a true association gap rather than a core resolver bug. In that narrow case the guarded fix is to PUT the combination's existing body to /api/combinations/{id_product_attribute} with ?id_shop={id_shop} to create the missing association, wrapped in a DRY_RUN guard, and never by deleting or reassigning the core product_attribute row.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: ProductAssembler class assigns wrong product attributes in multistore website (from another shop), have a solution, issue #17573. github.com/PrestaShop/PrestaShop/issues/17573
- PrestaShop GitHub: Multi-store, Prestashop 1.7.x, Product combinations issue, issue #31378. github.com/PrestaShop/PrestaShop/issues/31378
- PrestaShop GitHub: In multistore mode, a product present in the two stores that have different combinations generates errors, issue #28773. github.com/PrestaShop/PrestaShop/issues/28773
On the solution:
- PrestaShop Developer Documentation: Combinations webservice resource. devdocs.prestashop-project.org/9/webservice/resources/combinations
- PrestaShop Developer Documentation: Manage Multishop. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop
- PrestaShop Developer Documentation: The PrestaShop Webservice API. devdocs.prestashop-project.org/9/webservice
Stuck on a tricky one?
If you have a problem in PrestaShop multistore, categories, stock, orders, 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 multistore combinations?
If this saved you a product that priced at zero in the wrong shop, 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