diff --git a/woocommerce_fusion/tasks/sync_items.py b/woocommerce_fusion/tasks/sync_items.py index be69e442..c567c61b 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): """ @@ -202,15 +203,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 +236,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): """ @@ -284,6 +335,47 @@ 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: + # 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) -> 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, False # parent variable products are allowed to have no price + if not self._missing_regular_price(wc_product): + return True, False + + # Try derive from ERPNext price list + price = get_item_price_rate(item) + # 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, False + def update_woocommerce_product( self, wc_product: WooCommerceProduct, item: ERPNextItemToSync ) -> None: @@ -301,6 +393,64 @@ def update_woocommerce_product( if product_fields_changed: wc_product_dirty = True + # Skip saving variable parent products when regular_price is missing/zero (expected) + 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 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) + ) + + # 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 + 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() @@ -417,8 +567,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 @@ -507,9 +657,38 @@ 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/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/tags 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 @@ -537,20 +716,51 @@ 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 + # 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) + + # Variations never carry categories/tags in WooCommerce → skip early + if is_variation and is_parent_only: + frappe.logger().info( + 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)." + ) + 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 + ) + ) + + # 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')}" ) - else: - # For new WooCommerce Products, the nested field may not exist yet, so don't stop the sync continue # JSONPath parsing typically returns a list, we'll only take the first value @@ -615,7 +825,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: @@ -623,7 +834,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, @@ -646,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): 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 382369e1..6fae47a3 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,15 +81,10 @@ def _init_api() -> List[WooCommerceAPI]: return wc_api_list - # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext - # 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) - # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext # nosemgrep @staticmethod