Skip to content
275 changes: 250 additions & 25 deletions woocommerce_fusion/tasks/sync_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
generate_woocommerce_record_name_from_domain_and_id,
)

from html import unescape

def run_item_sync_from_hook(doc, method):
"""
Expand Down Expand Up @@ -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,
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <code>{0}</code> 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 <code>{0}</code> 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
Expand Down Expand Up @@ -615,15 +825,16 @@ 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:
woocommerce_product = frappe.get_doc({"doctype": "WooCommerce Product"})
new_results = woocommerce_product.get_list(
args={
"filters": filters,
"page_lenth": page_length,
"page_length": page_length,
"start": start,
"servers": servers,
"as_doc": True,
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion woocommerce_fusion/tasks/sync_sales_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading