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