From 5a3aa13b271b7e5279162b2eb5710b2ba0cf69d3 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Mon, 11 Aug 2025 20:43:25 +0200 Subject: [PATCH 01/11] Added escaping to item group and item name. Also fixed product variation id mapping and added correction algorithm --- woocommerce_fusion/tasks/sync_items.py | 91 ++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index be69e442..bc17014a 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -202,15 +202,17 @@ def get_corresponding_item_or_product(self): def get_erpnext_item(self): """ Get erpnext item for a WooCommerce Product + Primary match by (server, woocommerce_id). + If not found, fall back to SKU-based matching. + Also auto-corrects a wrong link in case a variation is linked to its parent product ID. """ - if not all( - [self.woocommerce_product.woocommerce_server, self.woocommerce_product.woocommerce_id] - ): + if not all([self.woocommerce_product.woocommerce_server, self.woocommerce_product.woocommerce_id]): raise ValueError("Both woocommerce_server and woocommerce_id required") iws = frappe.qb.DocType("Item WooCommerce Server") itm = frappe.qb.DocType("Item") - + + # 1) Primary match: exact match by (server, woocommerce_id) and_conditions = [ iws.woocommerce_server == self.woocommerce_product.woocommerce_server, iws.woocommerce_id == self.woocommerce_product.woocommerce_id, @@ -233,6 +235,54 @@ def get_erpnext_item(self): server.idx for server in found_item.woocommerce_servers if server.name == item_codes[0].name ), ) + # 1a) If this is a VARIATION but the linked WooCommerce ID in ERPNext + # is wrong (e.g., storing the parent product ID instead of the variation ID) → correct it + if self.woocommerce_product.get("type") == "variation": + iws_row = next((row for row in found_item.woocommerce_servers if row.name == item_codes[0].name), None) + if iws_row and iws_row.woocommerce_id != self.woocommerce_product.woocommerce_id: + # common case: iws_row.woocommerce_id == self.woocommerce_product.parent_id + iws_row.db_set("woocommerce_id", self.woocommerce_product.woocommerce_id, update_modified=False) + return + + # 2) Fallback: try to match by SKU (in case Item Code = SKU) + sku = (self.woocommerce_product.sku or "").strip() + + if not sku: + return # no SKU available, nothing else to try + + item_name = frappe.db.get_value("Item", {"item_code": sku}, "name") + + if not item_name: + return # no match by SKU either + + found_item = frappe.get_doc("Item", item_name) + + # 2a) Ensure there is an Item WooCommerce Server row for this server + iws_row = next( + (row for row in found_item.woocommerce_servers + if row.woocommerce_server == self.woocommerce_product.woocommerce_server), + None + ) + + if not iws_row: + iws_row = found_item.append("woocommerce_servers", {}) + iws_row.woocommerce_server = self.woocommerce_product.woocommerce_server + + # 2b) If the linked WooCommerce ID differs from the actual product's ID → correct it + if iws_row.woocommerce_id != self.woocommerce_product.woocommerce_id: + iws_row.woocommerce_id = self.woocommerce_product.woocommerce_id + found_item.flags.ignore_mandatory = True + found_item.flags.created_by_sync = True + found_item.save() + + # 2c) Wrap the found item into ERPNextItemToSync for further processing + self.item = ERPNextItemToSync( + item=found_item, + item_woocommerce_server_idx=next( + server.idx for server in found_item.woocommerce_servers + if server.woocommerce_server == self.woocommerce_product.woocommerce_server + ), + ) def sync_wc_product_with_erpnext_item(self): """ @@ -507,9 +557,36 @@ def set_item_fields(self, item: Item) -> Tuple[bool, Item]: # We expect woocommerce_field_name to be valid JSONPath jsonpath_expr = parse(map.woocommerce_field_name) - woocommerce_product_field_matches = jsonpath_expr.find(woocommerce_product_dict) - - setattr(item, erpnext_item_field_name[0], woocommerce_product_field_matches[0].value) + matches = jsonpath_expr.find(woocommerce_product_dict) + + # Skip silently if the path yields nothing (prevents IndexError) + if not matches: + frappe.logger().info( + f"[WooFusion] JSONPath '{map.woocommerce_field_name}' empty on " + f"{self.woocommerce_product.get('name')}; skip '{erpnext_item_field_name[0]}'" + ) + continue + + # Do NOT set categories on WooCommerce variations (they belong to the parent) + if (self.woocommerce_product.get("type") == "variation" + and "categories" in map.woocommerce_field_name.lower()): + frappe.logger().info( + f"[WooFusion] Skip categories mapping for variation " + f"{self.woocommerce_product.get('name')}" + ) + continue + + value = matches[0].value + + # Skip empty strings/lists to avoid writing junk + if value in (None, "", []): + continue + + # Unescape HTML entities coming from WC (e.g. '&') + if isinstance(value, str): + value = unescape(value) + + setattr(item, erpnext_item_field_name[0], value) item_dirty = True return item_dirty, item From 29762e024d146e4923ab79d44650e94d73734309 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 09:35:34 +0200 Subject: [PATCH 02/11] Added category skipping for variations --- woocommerce_fusion/tasks/sync_items.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index bc17014a..0717e4db 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -22,6 +22,7 @@ generate_woocommerce_record_name_from_domain_and_id, ) +from html import unescape def run_item_sync_from_hook(doc, method): """ @@ -467,8 +468,8 @@ def create_item(self, wc_product: WooCommerceProduct) -> None: else str(wc_product.woocommerce_id) ) item.stock_uom = wc_server.uom or _("Nos") - item.item_group = wc_server.item_group - item.item_name = wc_product.woocommerce_name + item.item_group = unescape(wc_server.item_group) + item.item_name = unescape(wc_product.woocommerce_name) row = item.append("woocommerce_servers") row.woocommerce_id = wc_product.woocommerce_id row.woocommerce_server = wc_server.name @@ -618,6 +619,17 @@ def set_product_fields( jsonpath_expr = parse(map.woocommerce_field_name) woocommerce_product_field_matches = jsonpath_expr.find(wc_product_with_deserialised_fields) + # Varianten haben keine eigenen Kategorien → Kategorie-Mapping überspringen + if ( + woocommerce_product.get("type") == "variation" + and "categories" in map.woocommerce_field_name.lower() + ): + frappe.logger().info( + f"[WooFusion] Skip categories mapping for variation " + f"{woocommerce_product.get('name')} (JSONPath: {map.woocommerce_field_name})" + ) + continue + if len(woocommerce_product_field_matches) == 0: if woocommerce_product.name: # We're strict about existing WooCommerce Products, the field should exist From 650e9fa1c40e6c15f76d9b566cd3a678c45102f6 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 10:21:43 +0200 Subject: [PATCH 03/11] Added skipping if regular price is not set for variation parents --- woocommerce_fusion/tasks/sync_items.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index 0717e4db..fc06ca5e 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -352,6 +352,15 @@ def update_woocommerce_product( if product_fields_changed: wc_product_dirty = True + # Skip saving variable parent products without regular_price + if ( + wc_product.regular_price is None and + wc_product.type == "variable" and + not wc_product.get("__islocal") + ): + frappe.logger().info(f"[WooCommerce Fusion] Skipped saving variable parent product '{wc_product.name}' due to missing regular_price (expected for variable products).") + return + if wc_product_dirty: wc_product.save() From 934313c8e375873c43305b19c888c496f5e24fb9 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 10:55:34 +0200 Subject: [PATCH 04/11] Added skipping if regular price is not set for variation parents --- woocommerce_fusion/tasks/sync_items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index fc06ca5e..fcab5d4b 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -721,7 +721,7 @@ def get_list_of_wc_products( new_results = woocommerce_product.get_list( args={ "filters": filters, - "page_lenth": page_length, + "page_length": page_length, "start": start, "servers": servers, "as_doc": True, From 6f91af6dc2f0a8ee8f7033d74f1368287511f4db Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 11:37:13 +0200 Subject: [PATCH 05/11] Trying to fix pagination issue for WooCommerce Products --- .../woocommerce_order/woocommerce_order.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py index 382369e1..9aec94ac 100644 --- a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py +++ b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py @@ -84,10 +84,41 @@ def _init_api() -> List[WooCommerceAPI]: # nosemgrep @staticmethod def get_list(args): - return WooCommerceOrder.get_list_of_records(args) - - def after_load_from_db(self, order: Dict): - return self.get_additional_order_attributes(order) + all_products = [] + page = 1 + + # Hole alle Seiten aus WooCommerce + while True: + args["params"] = args.get("params", {}) + args["params"].update({"per_page": 100, "page": page}) + products = WooCommerceProduct.get_list_of_records(args) + + if not products: + break + + all_products.extend(products) + page += 1 + + # Extend the list with product variants + products_with_variants = [ + (product.get("id"), product.get("woocommerce_name")) + for product in all_products + if product.get("type") == "variable" + ] + for id, woocommerce_name in products_with_variants: + args["endpoint"] = f"products/{id}/variations" + args["metadata"] = {"parent_woocommerce_name": woocommerce_name} + + page = 1 + while True: + args["params"] = {"per_page": 100, "page": page} + variants = WooCommerceProduct.get_list_of_records(args) + if not variants: + break + all_products.extend(variants) + page += 1 + + return all_products # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext # nosemgrep From 7a9c5b1ee0a21be17a24cea6ba9558a220a8ffe1 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 12:58:27 +0200 Subject: [PATCH 06/11] Trying to fix pagination issue for WooCommerce Products --- .../woocommerce_order/woocommerce_order.py | 131 +++++++++++++----- 1 file changed, 100 insertions(+), 31 deletions(-) diff --git a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py index 9aec94ac..e9a81127 100644 --- a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py +++ b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Dict, List +from frappe.utils import cint import frappe @@ -80,45 +81,113 @@ def _init_api() -> List[WooCommerceAPI]: return wc_api_list - # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext - # nosemgrep + def _fetch_wc_page(args, endpoint, page, per_page=100, metadata=None): + a = {**args} + a["endpoint"] = endpoint + a["params"] = {**a.get("params", {}), "per_page": per_page, "page": page} + if metadata: + a["metadata"] = metadata + return WooCommerceProduct.get_list_of_records(a) + + def _iter_flat_products(args, per_page=100): + """ + Generator: liefert eine flache Sequenz aus Produkten UND ihren Variationen + in der Reihenfolge: Produkt, dann dessen Variationen (falls 'variable'). + """ + page = 1 + while True: + products = _fetch_wc_page(args, "products", page, per_page=per_page) + if not products: + break + + for p in products: + yield p + if p.get("type") == "variable": + # Variationen seitenweise nachladen + vpage = 1 + while True: + variants = _fetch_wc_page( + args, + f"products/{p.get('id')}/variations", + vpage, + per_page=per_page, + metadata={"parent_woocommerce_name": p.get("woocommerce_name")}, + ) + if not variants: + break + for v in variants: + yield v + if len(variants) < per_page: + break + vpage += 1 + + if len(products) < per_page: + break + page += 1 + @staticmethod def get_list(args): - all_products = [] - page = 1 + # gewünschtes Fenster aus der flachen Sequenz schneiden + start = cint(args.get("limit_start") or args.get("start") or 0) + page_len = cint(args.get("limit_page_length") or args.get("page_length") or 20) + want_until = start + page_len + + results = [] + seen = set() # zur Deduplizierung (server/id/type) + for row in _iter_flat_products(args, per_page=100): + # Key für Duplikatserkennung + key = (row.get("woocommerce_server"), row.get("id"), row.get("type") or row.get("resource_type")) + if key in seen: + continue + seen.add(key) + results.append(row) + if len(results) >= want_until: + break - # Hole alle Seiten aus WooCommerce - while True: - args["params"] = args.get("params", {}) - args["params"].update({"per_page": 100, "page": page}) - products = WooCommerceProduct.get_list_of_records(args) + # endgültiges Slice fürs UI + return results[start:want_until] + @staticmethod + def get_count(args) -> int: + """ + Zählt Produkte + Variationen. Achtung: macht mehrere Requests. + Für große Kataloge evtl. cachen (frappe.cache) und z. B. 5–15 min gültig lassen. + """ + cache_key = "woofusion:count:products+variants" + cached = frappe.cache.hget(cache_key, "v1") + if cached is not None: + try: + return int(cached) + except Exception: + pass + + # 1) Gesamtzahl der Hauptprodukte (per Header von /products) + # get_count_of_records(args) sollte 'per_page'/'page' selbst handhaben; + # falls nicht, ein Mini-Wrapper wie oben verwenden und Header X-WP-Total auslesen. + total_products = WooCommerceProduct.get_count_of_records({**args, "endpoint": "products"}) + + # 2) Variationen zählen: alle variable Produkte iterieren, je Produkt Variations-Count addieren + # => nutzt get_count_of_records auf dem Variations-Endpoint + variants_total = 0 + page = 1 + per_page = 100 + while True: + products = _fetch_wc_page(args, "products", page, per_page=per_page) if not products: break - - all_products.extend(products) + for p in products: + if p.get("type") == "variable": + vcount = WooCommerceProduct.get_count_of_records( + {**args, "endpoint": f"products/{p.get('id')}/variations"} + ) + variants_total += int(vcount or 0) + if len(products) < per_page: + break page += 1 - # Extend the list with product variants - products_with_variants = [ - (product.get("id"), product.get("woocommerce_name")) - for product in all_products - if product.get("type") == "variable" - ] - for id, woocommerce_name in products_with_variants: - args["endpoint"] = f"products/{id}/variations" - args["metadata"] = {"parent_woocommerce_name": woocommerce_name} - - page = 1 - while True: - args["params"] = {"per_page": 100, "page": page} - variants = WooCommerceProduct.get_list_of_records(args) - if not variants: - break - all_products.extend(variants) - page += 1 - - return all_products + total = int(total_products or 0) + variants_total + frappe.cache.hset(cache_key, "v1", total, expires_in_sec=600) # 10 min Cache + return total # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext # nosemgrep From 67529f06c9424e50d47baf3c2341e36ef126ce5e Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 13:48:47 +0200 Subject: [PATCH 07/11] Added handling of catalog visibility for variations --- woocommerce_fusion/tasks/sync_items.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index fcab5d4b..4374493b 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -361,6 +361,37 @@ def update_woocommerce_product( frappe.logger().info(f"[WooCommerce Fusion] Skipped saving variable parent product '{wc_product.name}' due to missing regular_price (expected for variable products).") return + # Sicherstellen, dass catalog_visibility einen gültigen Wert hat + valid_visibilities = {"visible", "catalog", "search", "hidden"} + + def safe_visibility(current, parent_vis=None, ptype=None): + if current in valid_visibilities: + return current + # Für Variations: vom Parent erben oder Standard auf "visible" + if ptype == "variation": + return parent_vis or "visible" + return "visible" + + parent_visibility = None + try: + if getattr(wc_product, "parent_id", None): + parent = frappe.get_all( + "WooCommerce Product", + filters={"woocommerce_id": wc_product.parent_id}, + fields=["catalog_visibility"], + limit=1 + ) + if parent: + parent_visibility = parent[0].get("catalog_visibility") + except Exception: + pass + + wc_product.catalog_visibility = safe_visibility( + getattr(wc_product, "catalog_visibility", None), + parent_vis=parent_visibility, + ptype=getattr(wc_product, "type", None) + ) + if wc_product_dirty: wc_product.save() From 06a995be2ff3f6d83924454fca9a46d81d10f094 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Tue, 12 Aug 2025 18:42:04 +0200
Subject: [PATCH 08/11] Different fixes
---
woocommerce_fusion/tasks/sync_items.py | 80 ++++++++++++++++----------
1 file changed, 50 insertions(+), 30 deletions(-)
diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py
index 4374493b..f7ca013c 100644
--- a/woocommerce_fusion/tasks/sync_items.py
+++ b/woocommerce_fusion/tasks/sync_items.py
@@ -212,7 +212,7 @@ def get_erpnext_item(self):
iws = frappe.qb.DocType("Item WooCommerce Server")
itm = frappe.qb.DocType("Item")
-
+
# 1) Primary match: exact match by (server, woocommerce_id)
and_conditions = [
iws.woocommerce_server == self.woocommerce_product.woocommerce_server,
@@ -237,7 +237,7 @@ def get_erpnext_item(self):
),
)
# 1a) If this is a VARIATION but the linked WooCommerce ID in ERPNext
- # is wrong (e.g., storing the parent product ID instead of the variation ID) → correct it
+ # is wrong (e.g., storing the parent product ID instead of the variation ID) → correct it
if self.woocommerce_product.get("type") == "variation":
iws_row = next((row for row in found_item.woocommerce_servers if row.name == item_codes[0].name), None)
if iws_row and iws_row.woocommerce_id != self.woocommerce_product.woocommerce_id:
@@ -252,7 +252,7 @@ def get_erpnext_item(self):
return # no SKU available, nothing else to try
item_name = frappe.db.get_value("Item", {"item_code": sku}, "name")
-
+
if not item_name:
return # no match by SKU either
@@ -335,6 +335,15 @@ def update_item(self, woocommerce_product: WooCommerceProduct, item: ERPNextItem
self.set_sync_hash()
+ @staticmethod
+ def _missing_regular_price(doc) -> bool:
+ val = doc.get("regular_price")
+ try:
+ # consider None, "", "0", 0, 0.0 as missing
+ return val is None or float(val) == 0.0
+ except Exception:
+ return not bool(val)
+
def update_woocommerce_product(
self, wc_product: WooCommerceProduct, item: ERPNextItemToSync
) -> None:
@@ -352,11 +361,11 @@ def update_woocommerce_product(
if product_fields_changed:
wc_product_dirty = True
- # Skip saving variable parent products without regular_price
+ # Skip saving variable parent products when regular_price is missing/zero (expected)
if (
- wc_product.regular_price is None and
- wc_product.type == "variable" and
- not wc_product.get("__islocal")
+ wc_product.get("type") == "variable"
+ and not wc_product.get("__islocal")
+ and self._missing_regular_price(wc_product)
):
frappe.logger().info(f"[WooCommerce Fusion] Skipped saving variable parent product '{wc_product.name}' due to missing regular_price (expected for variable products).")
return
@@ -608,11 +617,13 @@ def set_item_fields(self, item: Item) -> Tuple[bool, Item]:
)
continue
- # Do NOT set categories on WooCommerce variations (they belong to the parent)
- if (self.woocommerce_product.get("type") == "variation"
- and "categories" in map.woocommerce_field_name.lower()):
+ # Do NOT set categories/tags on WooCommerce variations (they belong to the parent)
+ if (
+ self.woocommerce_product.get("type") == "variation"
+ and any(k in map.woocommerce_field_name.lower() for k in ("categories", "tags"))
+ ):
frappe.logger().info(
- f"[WooFusion] Skip categories mapping for variation "
+ f"[WooFusion] Skip categories/tags mapping for variation "
f"{self.woocommerce_product.get('name')}"
)
continue
@@ -655,32 +666,40 @@ def set_product_fields(
erpnext_item_field_name = map.erpnext_field_name.split(" | ")
erpnext_item_field_value = getattr(item.item, erpnext_item_field_name[0])
- # We expect woocommerce_field_name to be valid JSONPath
- jsonpath_expr = parse(map.woocommerce_field_name)
- woocommerce_product_field_matches = jsonpath_expr.find(wc_product_with_deserialised_fields)
+ # Determine mapping traits once
+ is_variation = woocommerce_product.get("type") == "variation"
+ path_lower = (map.woocommerce_field_name or "").lower()
+ is_parent_only = ("categories" in path_lower) or ("tags" in path_lower)
- # Varianten haben keine eigenen Kategorien → Kategorie-Mapping überspringen
- if (
- woocommerce_product.get("type") == "variation"
- and "categories" in map.woocommerce_field_name.lower()
- ):
+ # Variations never carry categories/tags in WooCommerce → skip early
+ if is_variation and is_parent_only:
frappe.logger().info(
- f"[WooFusion] Skip categories mapping for variation "
- f"{woocommerce_product.get('name')} (JSONPath: {map.woocommerce_field_name})"
+ f"[WooFusion] Skipping mapping '{map.woocommerce_field_name}' for variation "
+ f"{woocommerce_product.get('name')} (parent-only in WooCommerce)"
)
continue
+ # Parse JSONPath and find matches
+ jsonpath_expr = parse(map.woocommerce_field_name)
+ woocommerce_product_field_matches = jsonpath_expr.find(wc_product_with_deserialised_fields)
+
+ # Missing fields: allow absence for new docs and for categories/tags on existing docs
if len(woocommerce_product_field_matches) == 0:
- if woocommerce_product.name:
- # We're strict about existing WooCommerce Products, the field should exist
- raise ValueError(
- _("Field {0} not found in WooCommerce Product {1}").format(
- map.woocommerce_field_name, woocommerce_product.name
- )
+ if not woocommerce_product.name:
+ # New product; nested path may not exist yet
+ continue
+ if is_parent_only:
+ frappe.logger().info(
+ f"[WooFusion] JSONPath '{map.woocommerce_field_name}' not present on "
+ f"{woocommerce_product.get('name')} — skipping (parent-only/optional)."
)
- else:
- # For new WooCommerce Products, the nested field may not exist yet, so don't stop the sync
continue
+ # For all other fields stay strict on existing products
+ raise ValueError(
+ _("Field {0} not found in WooCommerce Product {1}").format(
+ map.woocommerce_field_name, woocommerce_product.name
+ )
+ )
# JSONPath parsing typically returns a list, we'll only take the first value
woocommerce_product_field_value = woocommerce_product_field_matches[0].value
@@ -744,7 +763,8 @@ def get_list_of_wc_products(
if date_time_from:
filters.append(["WooCommerce Product", "date_modified", ">", date_time_from])
if item:
- filters.append(["WooCommerce Product", "id", "=", item.item_woocommerce_server.woocommerce_id])
+ # Filter must use 'woocommerce_id' (the external WC id), not the DocType's database 'name/id'
+ filters.append(["WooCommerce Product", "woocommerce_id", "=", item.item_woocommerce_server.woocommerce_id])
servers = [item.item_woocommerce_server.woocommerce_server]
while new_results:
From b67adc921d10808d0d3e296fd7fd2a2b2f40a619 Mon Sep 17 00:00:00 2001
From: gitpat
Date: Wed, 13 Aug 2025 18:04:06 +0200 Subject: [PATCH 09/11] Different fixes --- woocommerce_fusion/tasks/sync_items.py | 53 +++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index f7ca013c..34c7eff8 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -339,11 +339,35 @@ def update_item(self, woocommerce_product: WooCommerceProduct, item: ERPNextItem def _missing_regular_price(doc) -> bool: val = doc.get("regular_price") try: - # consider None, "", "0", 0, 0.0 as missing + # treat None, "", "0", 0, 0.0 as missing return val is None or float(val) == 0.0 except Exception: return not bool(val) + def _ensure_regular_price(self, wc_product: WooCommerceProduct, item: ERPNextItemToSync) -> bool: + """ + Ensure regular_price is non-zero for simple/variation before saving. + Returns True if it's safe to save, False if we should skip saving. + """ + ptype = wc_product.get("type") + if ptype == "variable": + return True # parent variable products are allowed to have no price + if not self._missing_regular_price(wc_product): + return True + + # Try derive from ERPNext price list + price = get_item_price_rate(item) + if price: + wc_product.regular_price = str(price) + return True + + # No price available -> skip save to avoid MandatoryError + frappe.logger().info( + f"[WooFusion] Skipping save for {wc_product.get('name')} (type={ptype}) because regular_price is missing " + f"and no Item Price could be derived." + ) + return False + def update_woocommerce_product( self, wc_product: WooCommerceProduct, item: ERPNextItemToSync ) -> None: @@ -401,6 +425,21 @@ def safe_visibility(current, parent_vis=None, ptype=None): ptype=getattr(wc_product, "type", None) ) + # variable parent ohne Preis -> speichern überspringen (erwartet) + if ( + wc_product.get("type") == "variable" + and not wc_product.get("__islocal") + and self._missing_regular_price(wc_product) + ): + frappe.logger().info( + f"[WooCommerce Fusion] Skipped save for variable parent '{wc_product.name}' (no regular_price, expected)." + ) + return + + # simple/variation: Preis MUSS vorhanden sein -> sonst versuchen zu setzen, ggf. Save skippen + if not self._ensure_regular_price(wc_product, item): + return + if wc_product_dirty: wc_product.save() @@ -700,6 +739,18 @@ def set_product_fields( map.woocommerce_field_name, woocommerce_product.name ) ) + + # Do not overwrite regular_price with empty/zero from ERPNext + if "regular_price" in (map.woocommerce_field_name or "").lower(): + try: + v = float(erpnext_item_field_value) if erpnext_item_field_value not in (None, "", []) else 0.0 + except Exception: + v = 0.0 + if v <= 0.0: + frappe.logger().info( + f"[WooFusion] Skip regular_price update (empty/zero) for {woocommerce_product.get('name')}" + ) + continue # JSONPath parsing typically returns a list, we'll only take the first value woocommerce_product_field_value = woocommerce_product_field_matches[0].value From 73931d79c2d5cc5dab0959c0493649b54e7b268f Mon Sep 17 00:00:00 2001 From: gitpat
Date: Wed, 13 Aug 2025 20:01:37 +0200 Subject: [PATCH 10/11] Different fixes --- woocommerce_fusion/tasks/sync_items.py | 55 +++++++++++++++++++------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index 34c7eff8..c567c61b 100644 --- a/woocommerce_fusion/tasks/sync_items.py +++ b/woocommerce_fusion/tasks/sync_items.py @@ -344,29 +344,37 @@ def _missing_regular_price(doc) -> bool: except Exception: return not bool(val) - def _ensure_regular_price(self, wc_product: WooCommerceProduct, item: ERPNextItemToSync) -> bool: + def _ensure_regular_price(self, wc_product: WooCommerceProduct, item: ERPNextItemToSync) -> tuple[bool, bool]: """ Ensure regular_price is non-zero for simple/variation before saving. Returns True if it's safe to save, False if we should skip saving. """ + ptype = wc_product.get("type") + if ptype == "variable": - return True # parent variable products are allowed to have no price + return True, False # parent variable products are allowed to have no price if not self._missing_regular_price(wc_product): - return True + return True, False # Try derive from ERPNext price list price = get_item_price_rate(item) - if price: - wc_product.regular_price = str(price) - return True - - # No price available -> skip save to avoid MandatoryError + # akzeptiere nur > 0.0 + if isinstance(price, (int, float)) and float(price) > 0.0: + wc_product.regular_price = f"{float(price):.2f}" + # optional auch 'price' angleichen, wenn euer Doctype das erwartet: + try: + wc_product.price = wc_product.regular_price + except Exception: + pass + return True, True + # No price available -> skip save to avoid MandatoryError frappe.logger().info( f"[WooFusion] Skipping save for {wc_product.get('name')} (type={ptype}) because regular_price is missing " f"and no Item Price could be derived." ) - return False + + return False, False def update_woocommerce_product( self, wc_product: WooCommerceProduct, item: ERPNextItemToSync @@ -437,8 +445,11 @@ def safe_visibility(current, parent_vis=None, ptype=None): return # simple/variation: Preis MUSS vorhanden sein -> sonst versuchen zu setzen, ggf. Save skippen - if not self._ensure_regular_price(wc_product, item): + ok_to_save, price_set = self._ensure_regular_price(wc_product, item) + if not ok_to_save: return + if price_set: + wc_product_dirty = True if wc_product_dirty: wc_product.save() @@ -846,20 +857,34 @@ def get_item_price_rate(item: ERPNextItemToSync): wc_server = frappe.get_cached_doc( "WooCommerce Server", item.item_woocommerce_server.woocommerce_server ) + if wc_server.enable_price_list_sync: + # primary: by item_code (korrekt) item_prices = frappe.get_all( "Item Price", - filters={"item_code": item.item.item_name, "price_list": wc_server.price_list}, + filters={"item_code": item.item.item_code, "price_list": wc_server.price_list}, fields=["price_list_rate", "valid_upto"], ) - return next( + # fallback: falls manche Preise (historisch) auf item_name liegen + if not item_prices: + item_prices = frappe.get_all( + "Item Price", + filters={"item_code": item.item.item_name, "price_list": wc_server.price_list}, + fields=["price_list_rate", "valid_upto"], + ) + rate = next( ( - price.price_list_rate - for price in item_prices - if not price.valid_upto or price.valid_upto > now() + p.price_list_rate + for p in item_prices + if not p.valid_upto or p.valid_upto > now() ), None, ) + try: + return float(rate) if rate is not None else None + except Exception: + return None + def clear_sync_hash_and_run_item_sync(item_code: str): From 82dd2304e0809a26fbd342126ab6e51113bc4545 Mon Sep 17 00:00:00 2001 From: gitpat
Date: Thu, 11 Sep 2025 08:37:01 +0200 Subject: [PATCH 11/11] More fixes --- woocommerce_fusion/tasks/sync_sales_orders.py | 2 +- .../woocommerce_order/woocommerce_order.py | 106 +----------------- 2 files changed, 2 insertions(+), 106 deletions(-) diff --git a/woocommerce_fusion/tasks/sync_sales_orders.py b/woocommerce_fusion/tasks/sync_sales_orders.py index bb3e3cca..113ccdca 100644 --- a/woocommerce_fusion/tasks/sync_sales_orders.py +++ b/woocommerce_fusion/tasks/sync_sales_orders.py @@ -914,7 +914,7 @@ def get_list_of_wc_orders( while new_results: woocommerce_order = frappe.get_doc({"doctype": "WooCommerce Order"}) new_results = woocommerce_order.get_list( - args={"filters": filters, "page_lenth": page_length, "start": start, "as_doc": True} + args={"filters": filters, "page_length": page_length, "start": start, "as_doc": True} ) for wc_order in new_results: wc_orders.append(wc_order) diff --git a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py index e9a81127..6fae47a3 100644 --- a/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py +++ b/woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py @@ -81,113 +81,9 @@ def _init_api() -> List[WooCommerceAPI]: return wc_api_list - def _fetch_wc_page(args, endpoint, page, per_page=100, metadata=None): - a = {**args} - a["endpoint"] = endpoint - a["params"] = {**a.get("params", {}), "per_page": per_page, "page": page} - if metadata: - a["metadata"] = metadata - return WooCommerceProduct.get_list_of_records(a) - - def _iter_flat_products(args, per_page=100): - """ - Generator: liefert eine flache Sequenz aus Produkten UND ihren Variationen - in der Reihenfolge: Produkt, dann dessen Variationen (falls 'variable'). - """ - page = 1 - while True: - products = _fetch_wc_page(args, "products", page, per_page=per_page) - if not products: - break - - for p in products: - yield p - if p.get("type") == "variable": - # Variationen seitenweise nachladen - vpage = 1 - while True: - variants = _fetch_wc_page( - args, - f"products/{p.get('id')}/variations", - vpage, - per_page=per_page, - metadata={"parent_woocommerce_name": p.get("woocommerce_name")}, - ) - if not variants: - break - for v in variants: - yield v - if len(variants) < per_page: - break - vpage += 1 - - if len(products) < per_page: - break - page += 1 - @staticmethod def get_list(args): - # gewünschtes Fenster aus der flachen Sequenz schneiden - start = cint(args.get("limit_start") or args.get("start") or 0) - page_len = cint(args.get("limit_page_length") or args.get("page_length") or 20) - want_until = start + page_len - - results = [] - seen = set() # zur Deduplizierung (server/id/type) - for row in _iter_flat_products(args, per_page=100): - # Key für Duplikatserkennung - key = (row.get("woocommerce_server"), row.get("id"), row.get("type") or row.get("resource_type")) - if key in seen: - continue - seen.add(key) - results.append(row) - if len(results) >= want_until: - break - - # endgültiges Slice fürs UI - return results[start:want_until] - - @staticmethod - def get_count(args) -> int: - """ - Zählt Produkte + Variationen. Achtung: macht mehrere Requests. - Für große Kataloge evtl. cachen (frappe.cache) und z. B. 5–15 min gültig lassen. - """ - cache_key = "woofusion:count:products+variants" - cached = frappe.cache.hget(cache_key, "v1") - if cached is not None: - try: - return int(cached) - except Exception: - pass - - # 1) Gesamtzahl der Hauptprodukte (per Header von /products) - # get_count_of_records(args) sollte 'per_page'/'page' selbst handhaben; - # falls nicht, ein Mini-Wrapper wie oben verwenden und Header X-WP-Total auslesen. - total_products = WooCommerceProduct.get_count_of_records({**args, "endpoint": "products"}) - - # 2) Variationen zählen: alle variable Produkte iterieren, je Produkt Variations-Count addieren - # => nutzt get_count_of_records auf dem Variations-Endpoint - variants_total = 0 - page = 1 - per_page = 100 - while True: - products = _fetch_wc_page(args, "products", page, per_page=per_page) - if not products: - break - for p in products: - if p.get("type") == "variable": - vcount = WooCommerceProduct.get_count_of_records( - {**args, "endpoint": f"products/{p.get('id')}/variations"} - ) - variants_total += int(vcount or 0) - if len(products) < per_page: - break - page += 1 - - total = int(total_products or 0) + variants_total - frappe.cache.hset(cache_key, "v1", total, expires_in_sec=600) # 10 min Cache - return total + return WooCommerceOrder.get_list_of_records(args) # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext # nosemgrep