diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152f5bdf4..cfe7b4bf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Node.js v14 uses: actions/setup-node@v2 with: - node-version: 18 + node-version: 20 - name: Setup dependencies run: | npm install @semantic-release/git @semantic-release/exec --no-save diff --git a/ecommerce_integrations/__init__.py b/ecommerce_integrations/__init__.py index 30244104a..67d42d310 100644 --- a/ecommerce_integrations/__init__.py +++ b/ecommerce_integrations/__init__.py @@ -1 +1 @@ -__version__ = "1.17.0" +__version__ = "1.20.2" diff --git a/ecommerce_integrations/hooks.py b/ecommerce_integrations/hooks.py index face21c3f..84bc60a9b 100644 --- a/ecommerce_integrations/hooks.py +++ b/ecommerce_integrations/hooks.py @@ -8,6 +8,7 @@ app_color = "grey" app_email = "developers@frappe.io" app_license = "GNU GPL v3.0" +required_apps = ["frappe/erpnext"] # Includes in
# ------------------ diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index 01722169b..73b9dfbe9 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -392,7 +392,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-24 10:38:49.247431", + "modified": "2025-09-28 19:36:38.323699", "modified_by": "Administrator", "module": "shopify", "name": "Shopify Setting", @@ -407,8 +407,19 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 526107dd3..3063ae77a 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from collections import Counter import frappe @@ -6,8 +7,8 @@ from shopify.resources import InventoryLevel, Variant from ecommerce_integrations.controllers.inventory import ( - get_inventory_levels, - update_inventory_sync_status, + get_inventory_levels, + update_inventory_sync_status, ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.shopify.connection import temp_shopify_session @@ -15,79 +16,180 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def update_inventory_on_shopify() -> None: - """Upload stock levels from ERPNext to Shopify. - - Called by scheduler on configured interval. - """ - setting = frappe.get_doc(SETTING_DOCTYPE) +def _log(status: str, message: str, data=None, method: str = "update_inventory_on_shopify"): + create_shopify_log(status=status, message=message, request_data=data or {}, method=method) - if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: - return - if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): - return +def _filter_to_flagged_items(inventory_levels): + """Keep rows for Items with custom_sync_to_shopify = 1 and not disabled.""" + if not inventory_levels: + return [] - warehous_map = setting.get_erpnext_to_integration_wh_mapping() - inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) + ecom_names = [d.ecom_item for d in inventory_levels if getattr(d, "ecom_item", None)] + if not ecom_names: + return [] - if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map) + ecom_rows = frappe.get_all( + "Ecommerce Item", + filters={"name": ["in", ecom_names], "integration": MODULE_NAME}, + fields=["name", "erpnext_item_code"], + ) + ecom_to_item = {r["name"]: r["erpnext_item_code"] for r in ecom_rows if r.get("erpnext_item_code")} + allowed_items = set( + frappe.get_all( + "Item", + filters={"name": ["in", list(ecom_to_item.values())], "custom_sync_to_shopify": 1, "disabled": 0}, + pluck="name", + ) + ) -@temp_shopify_session -def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: - synced_on = now() + filtered = [] + for d in inventory_levels: + erp_item = ecom_to_item.get(getattr(d, "ecom_item", "")) + if erp_item in allowed_items: + filtered.append(d) - for inventory_sync_batch in create_batch(inventory_levels, 50): - for d in inventory_sync_batch: - d.shopify_location_id = warehous_map[d.warehouse] + return filtered - try: - variant = Variant.find(d.variant_id) - inventory_id = variant.inventory_item_id - InventoryLevel.set( - location_id=d.shopify_location_id, - inventory_item_id=inventory_id, - # shopify doesn't support fractional quantity - available=cint(d.actual_qty) - cint(d.reserved_qty), - ) - update_inventory_sync_status(d.ecom_item, time=synced_on) - d.status = "Success" - except ResourceNotFound: - # Variant or location is deleted, mark as last synced and ignore. - update_inventory_sync_status(d.ecom_item, time=synced_on) - d.status = "Not Found" - except Exception as e: - d.status = "Failed" - d.failure_reason = str(e) +def _commit_row_and_continue(d, synced_on): + try: + if getattr(d, "ecom_item", None): + update_inventory_sync_status(getattr(d, "ecom_item"), time=synced_on) + except Exception: + pass + frappe.db.commit() - frappe.db.commit() - _log_inventory_update_status(inventory_sync_batch) +def _log_batch_status(inventory_levels) -> None: + """Per-row status summary in Shopify Log.""" + log_message = "variant_id,location_id,status,failure_reason\n" + log_message += "\n".join( + f"{getattr(d,'variant_id','')},{getattr(d,'shopify_location_id','')}," + f"{getattr(d,'status','')},{getattr(d,'failure_reason','') or ''}" + for d in inventory_levels + ) + statuses = [getattr(d, "status", "Failed") for d in inventory_levels] + stats = Counter(statuses) + total = max(len(inventory_levels), 1) + pct = stats.get("Success", 0) / total -def _log_inventory_update_status(inventory_levels) -> None: - """Create log of inventory update.""" - log_message = "variant_id,location_id,status,failure_reason\n" + status = "Success" if pct == 1 else ("Partial Success" if pct > 0 else "Failed") + _log(status, f"Updated {pct * 100}% items\n\n{log_message}") - log_message += "\n".join( - f"{d.variant_id},{d.shopify_location_id},{d.status},{d.failure_reason or ''}" - for d in inventory_levels - ) - stats = Counter([d.status for d in inventory_levels]) +@temp_shopify_session +def upload_inventory_data_to_shopify(inventory_levels, warehouse_map) -> None: + """Push inventory to Shopify for each row (Default Warehouse only).""" + synced_on = now() + + for batch in create_batch(inventory_levels, 50): + for d in batch: + # force single mapped location for Default Warehouse + try: + d.shopify_location_id = int(warehouse_map[d.warehouse]) + except Exception: + d.status = "Failed" + d.failure_reason = f"No numeric Shopify Location for ERP Warehouse: {d.warehouse}" + _commit_row_and_continue(d, synced_on) + continue + + try: + if not getattr(d, "variant_id", None): + d.status = "Failed" + d.failure_reason = "Missing variant_id in Ecommerce Item mapping." + _commit_row_and_continue(d, synced_on) + continue + + variant = Variant.find(d.variant_id) + inventory_item_id = getattr(variant, "inventory_item_id", None) + if not inventory_item_id: + d.status = "Failed" + d.failure_reason = f"Shopify variant {d.variant_id} has no inventory_item_id." + _commit_row_and_continue(d, synced_on) + continue + + available = cint(d.actual_qty) - cint(d.reserved_qty) # Shopify wants integers + InventoryLevel.set( + location_id=d.shopify_location_id, + inventory_item_id=inventory_item_id, + available=int(available), + ) + update_inventory_sync_status(d.ecom_item, time=synced_on) + d.status = "Success" + + except ResourceNotFound: + update_inventory_sync_status(d.ecom_item, time=synced_on) + d.status = "Not Found" + d.failure_reason = ( + f"Variant or Location not found. variant_id={getattr(d,'variant_id',None)} " + f"loc={getattr(d,'shopify_location_id',None)}" + ) + except Exception as e: + d.status = "Failed" + d.failure_reason = str(e) + + frappe.db.commit() + + _log_batch_status(batch) + + +def _get_numeric_location_id(setting, default_wh: str) -> int: + erp_to_shop = setting.get_erpnext_to_integration_wh_mapping() or {} + if not default_wh or default_wh not in erp_to_shop: + raise Exception(f"No Shopify Location mapping for Default Warehouse: {default_wh}") + try: + return int(erp_to_shop[default_wh]) + except Exception: + raise Exception( + f"Shopify Location ID must be numeric. Got '{erp_to_shop[default_wh]}' for '{default_wh}'." + ) + + +def _run_push_for_default_warehouse(setting) -> None: + """Core one-way push using ONLY the Default Warehouse on Shopify Setting.""" + if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: + return + + default_wh = getattr(setting, "warehouse", None) + if not default_wh: + _log("Error", "Default Warehouse is not set on Shopify Setting (field: warehouse).") + return + + location_id = None + try: + location_id = _get_numeric_location_id(setting, default_wh) + except Exception as e: + _log("Error", str(e)) + return + + # inventory rows for only the Default Warehouse + inventory_levels = get_inventory_levels((default_wh,), MODULE_NAME) + if not inventory_levels: + _log("Success", f"No inventory rows found for Default Warehouse: {default_wh}") + return + + # keep only flagged items + inventory_levels = _filter_to_flagged_items(inventory_levels) + if not inventory_levels: + _log("Success", "No flagged items to sync (custom_sync_to_shopify=1 & not disabled).") + return + + # push + upload_inventory_data_to_shopify(inventory_levels, {default_wh: location_id}) - percent_successful = stats["Success"] / len(inventory_levels) - if percent_successful == 0: - status = "Failed" - elif percent_successful < 1: - status = "Partial Success" - else: - status = "Success" +def update_inventory_on_shopify() -> None: + """Scheduled job entrypoint – one-way push from ERPNext to Shopify.""" + setting = frappe.get_doc(SETTING_DOCTYPE) + if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): + return + _run_push_for_default_warehouse(setting) - log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message - create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message) +def update_inventory_on_shopify_now() -> None: + """Immediate manual push (can be called from console or other code).""" + setting = frappe.get_doc(SETTING_DOCTYPE) + _run_push_for_default_warehouse(setting) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index ffae32049..1c7c34cba 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -2,557 +2,667 @@ import frappe from frappe import _, msgprint -from frappe.utils import cint, cstr +from frappe.utils import cint, cstr, get_url from frappe.utils.nestedset import get_root_of +from pyactiveresource.connection import ResourceNotFound from shopify.resources import Product, Variant from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import ( - ITEM_SELLING_RATE_FIELD, - MODULE_NAME, - SETTING_DOCTYPE, - SHOPIFY_VARIANTS_ATTR_LIST, - SUPPLIER_ID_FIELD, - WEIGHT_TO_ERPNEXT_UOM_MAP, + ITEM_SELLING_RATE_FIELD, + MODULE_NAME, + SETTING_DOCTYPE, + SHOPIFY_VARIANTS_ATTR_LIST, + SUPPLIER_ID_FIELD, + WEIGHT_TO_ERPNEXT_UOM_MAP, ) from ecommerce_integrations.shopify.utils import create_shopify_log class ShopifyProduct: - def __init__( - self, - product_id: str, - variant_id: Optional[str] = None, - sku: Optional[str] = None, - has_variants: Optional[int] = 0, - ): - self.product_id = str(product_id) - self.variant_id = str(variant_id) if variant_id else None - self.sku = str(sku) if sku else None - self.has_variants = has_variants - self.setting = frappe.get_doc(SETTING_DOCTYPE) - - if not self.setting.is_enabled(): - frappe.throw(_("Can not create Shopify product when integration is disabled.")) - - def is_synced(self) -> bool: - return ecommerce_item.is_synced( - MODULE_NAME, integration_item_code=self.product_id, variant_id=self.variant_id, sku=self.sku, - ) - - def get_erpnext_item(self): - return ecommerce_item.get_erpnext_item( - MODULE_NAME, - integration_item_code=self.product_id, - variant_id=self.variant_id, - sku=self.sku, - has_variants=self.has_variants, - ) - - @temp_shopify_session - def sync_product(self): - if not self.is_synced(): - shopify_product = Product.find(self.product_id) - product_dict = shopify_product.to_dict() - self._make_item(product_dict) - - def _make_item(self, product_dict): - _add_weight_details(product_dict) - - warehouse = self.setting.warehouse - - if _has_variants(product_dict): - self.has_variants = 1 - attributes = self._create_attribute(product_dict) - self._create_item(product_dict, warehouse, 1, attributes) - self._create_item_variants(product_dict, warehouse, attributes) - - else: - product_dict["variant_id"] = product_dict["variants"][0]["id"] - self._create_item(product_dict, warehouse) - - def _create_attribute(self, product_dict): - attribute = [] - for attr in product_dict.get("options"): - if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): - frappe.get_doc( - { - "doctype": "Item Attribute", - "attribute_name": attr.get("name"), - "item_attribute_values": [ - {"attribute_value": attr_value, "abbr": attr_value} for attr_value in attr.get("values") - ], - } - ).insert() - attribute.append({"attribute": attr.get("name")}) - - else: - # check for attribute values - item_attr = frappe.get_doc("Item Attribute", attr.get("name")) - if not item_attr.numeric_values: - self._set_new_attribute_values(item_attr, attr.get("values")) - item_attr.save() - attribute.append({"attribute": attr.get("name")}) - - else: - attribute.append( - { - "attribute": attr.get("name"), - "from_range": item_attr.get("from_range"), - "to_range": item_attr.get("to_range"), - "increment": item_attr.get("increment"), - "numeric_values": item_attr.get("numeric_values"), - } - ) - - return attribute - - def _set_new_attribute_values(self, item_attr, values): - for attr_value in values: - if not any( - (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) - for d in item_attr.item_attribute_values - ): - item_attr.append("item_attribute_values", {"attribute_value": attr_value, "abbr": attr_value}) - - def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, variant_of=None): - item_dict = { - "variant_of": variant_of, - "is_stock_item": 1, - "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), - "item_name": product_dict.get("title", "").strip(), - "description": product_dict.get("body_html") or product_dict.get("title"), - "item_group": self._get_item_group(product_dict.get("product_type")), - "has_variants": has_variant, - "attributes": attributes or [], - "stock_uom": product_dict.get("uom") or _("Nos"), - "sku": product_dict.get("sku") or _get_sku(product_dict), - "default_warehouse": warehouse, - "image": _get_item_image(product_dict), - "weight_uom": WEIGHT_TO_ERPNEXT_UOM_MAP[product_dict.get("weight_unit")], - "weight_per_unit": product_dict.get("weight"), - "default_supplier": self._get_supplier(product_dict), - } - - integration_item_code = product_dict["id"] # shopify product_id - variant_id = product_dict.get("variant_id", "") # shopify variant_id if has variants - sku = item_dict["sku"] - - if not _match_sku_and_link_item( - item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant - ): - ecommerce_item.create_ecommerce_item( - MODULE_NAME, - integration_item_code, - item_dict, - variant_id=variant_id, - sku=sku, - variant_of=variant_of, - has_variants=has_variant, - ) - - def _create_item_variants(self, product_dict, warehouse, attributes): - template_item = ecommerce_item.get_erpnext_item( - MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 - ) - - if template_item: - for variant in product_dict.get("variants"): - shopify_item_variant = { - "id": product_dict.get("id"), - "variant_id": variant.get("id"), - "item_code": variant.get("id"), - "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), - "product_type": product_dict.get("product_type"), - "sku": variant.get("sku"), - "uom": template_item.stock_uom or _("Nos"), - "item_price": variant.get("price"), - "weight_unit": variant.get("weight_unit"), - "weight": variant.get("weight"), - } - - for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): - if variant.get(variant_attr): - attributes[i].update( - {"attribute_value": self._get_attribute_value(variant.get(variant_attr), attributes[i])} - ) - self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) - - def _get_attribute_value(self, variant_attr_val, attribute): - attribute_value = frappe.db.sql( - """select attribute_value from `tabItem Attribute Value` - where parent = %s and (abbr = %s or attribute_value = %s)""", - (attribute["attribute"], variant_attr_val, variant_attr_val), - as_list=1, - ) - return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) - - def _get_item_group(self, product_type=None): - parent_item_group = get_root_of("Item Group") - - if not product_type: - return parent_item_group - - if frappe.db.get_value("Item Group", product_type, "name"): - return product_type - item_group = frappe.get_doc( - { - "doctype": "Item Group", - "item_group_name": product_type, - "parent_item_group": parent_item_group, - "is_group": "No", - } - ).insert() - return item_group.name - - def _get_supplier(self, product_dict): - if product_dict.get("vendor"): - supplier = frappe.db.sql( - f"""select name from tabSupplier - where name = %s or {SUPPLIER_ID_FIELD} = %s """, - (product_dict.get("vendor"), product_dict.get("vendor").lower()), - as_list=1, - ) - - if supplier: - return product_dict.get("vendor") - supplier = frappe.get_doc( - { - "doctype": "Supplier", - "supplier_name": product_dict.get("vendor"), - SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), - "supplier_group": self._get_supplier_group(), - } - ).insert() - return supplier.name - else: - return "" - - def _get_supplier_group(self): - supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) - if not supplier_group: - supplier_group = frappe.get_doc( - {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} - ).insert() - return supplier_group.name - return supplier_group + def __init__( + self, + product_id: str, + variant_id: Optional[str] = None, + sku: Optional[str] = None, + has_variants: Optional[int] = 0, + ): + self.product_id = str(product_id) + self.variant_id = str(variant_id) if variant_id else None + self.sku = str(sku) if sku else None + self.has_variants = has_variants + self.setting = frappe.get_doc(SETTING_DOCTYPE) + + if not self.setting.is_enabled(): + frappe.throw(_("Can not create Shopify product when integration is disabled.")) + + def is_synced(self) -> bool: + return ecommerce_item.is_synced( + MODULE_NAME, integration_item_code=self.product_id, variant_id=self.variant_id, sku=self.sku, + ) + + def get_erpnext_item(self): + return ecommerce_item.get_erpnext_item( + MODULE_NAME, + integration_item_code=self.product_id, + variant_id=self.variant_id, + sku=self.sku, + has_variants=self.has_variants, + ) + + @temp_shopify_session + def sync_product(self): + if not self.is_synced(): + shopify_product = Product.find(self.product_id) + product_dict = shopify_product.to_dict() + self._make_item(product_dict) + + def _make_item(self, product_dict): + _add_weight_details(product_dict) + + warehouse = self.setting.warehouse + + if _has_variants(product_dict): + self.has_variants = 1 + attributes = self._create_attribute(product_dict) + self._create_item(product_dict, warehouse, 1, attributes) + self._create_item_variants(product_dict, warehouse, attributes) + + else: + product_dict["variant_id"] = product_dict["variants"][0]["id"] + self._create_item(product_dict, warehouse) + + def _create_attribute(self, product_dict): + attribute = [] + for attr in product_dict.get("options"): + if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): + frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": attr.get("name"), + "item_attribute_values": [ + {"attribute_value": attr_value, "abbr": attr_value} for attr_value in attr.get("values") + ], + } + ).insert() + attribute.append({"attribute": attr.get("name")}) + + else: + # check for attribute values + item_attr = frappe.get_doc("Item Attribute", attr.get("name")) + if not item_attr.numeric_values: + self._set_new_attribute_values(item_attr, attr.get("values")) + item_attr.save() + attribute.append({"attribute": attr.get("name")}) + + else: + attribute.append( + { + "attribute": attr.get("name"), + "from_range": item_attr.get("from_range"), + "to_range": item_attr.get("to_range"), + "increment": item_attr.get("increment"), + "numeric_values": item_attr.get("numeric_values"), + } + ) + + return attribute + + def _set_new_attribute_values(self, item_attr, values): + for attr_value in values: + if not any( + (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) + for d in item_attr.item_attribute_values + ): + item_attr.append("item_attribute_values", {"attribute_value": attr_value, "abbr": attr_value}) + + def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, variant_of=None): + item_dict = { + "variant_of": variant_of, + "is_stock_item": 1, + "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), + "item_name": product_dict.get("title", "").strip(), + "description": product_dict.get("body_html") or product_dict.get("title"), + "item_group": self._get_item_group(product_dict.get("product_type")), + "has_variants": has_variant, + "attributes": attributes or [], + "stock_uom": product_dict.get("uom") or _("Nos"), + "sku": product_dict.get("sku") or _get_sku(product_dict), + "default_warehouse": warehouse, + "image": _get_item_image(product_dict), + "weight_uom": WEIGHT_TO_ERPNEXT_UOM_MAP[product_dict.get("weight_unit")], + "weight_per_unit": product_dict.get("weight"), + "default_supplier": self._get_supplier(product_dict), + } + + integration_item_code = product_dict["id"] # shopify product_id + variant_id = product_dict.get("variant_id", "") # shopify variant_id if has variants + sku = item_dict["sku"] + + if not _match_sku_and_link_item( + item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant + ): + ecommerce_item.create_ecommerce_item( + MODULE_NAME, + integration_item_code, + item_dict, + variant_id=variant_id, + sku=sku, + variant_of=variant_of, + has_variants=has_variant, + ) + + def _create_item_variants(self, product_dict, warehouse, attributes): + template_item = ecommerce_item.get_erpnext_item( + MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 + ) + + if template_item: + for variant in product_dict.get("variants"): + shopify_item_variant = { + "id": product_dict.get("id"), + "variant_id": variant.get("id"), + "item_code": variant.get("id"), + "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), + "product_type": product_dict.get("product_type"), + "sku": variant.get("sku"), + "uom": template_item.stock_uom or _("Nos"), + "item_price": variant.get("price"), + "weight_unit": variant.get("weight_unit"), + "weight": variant.get("weight"), + } + + for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): + if variant.get(variant_attr): + attributes[i].update( + {"attribute_value": self._get_attribute_value(variant.get(variant_attr), attributes[i])} + ) + self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) + + def _get_attribute_value(self, variant_attr_val, attribute): + attribute_value = frappe.db.sql( + """select attribute_value from `tabItem Attribute Value` + where parent = %s and (abbr = %s or attribute_value = %s)""", + (attribute["attribute"], variant_attr_val, variant_attr_val), + as_list=1, + ) + return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) + + def _get_item_group(self, product_type=None): + parent_item_group = get_root_of("Item Group") + + if not product_type: + return parent_item_group + + if frappe.db.get_value("Item Group", product_type, "name"): + return product_type + item_group = frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": product_type, + "parent_item_group": parent_item_group, + "is_group": "No", + } + ).insert() + return item_group.name + + def _get_supplier(self, product_dict): + if product_dict.get("vendor"): + supplier = frappe.db.sql( + f"""select name from tabSupplier + where name = %s or {SUPPLIER_ID_FIELD} = %s """, + (product_dict.get("vendor"), product_dict.get("vendor").lower()), + as_list=1, + ) + + if supplier: + return product_dict.get("vendor") + supplier = frappe.get_doc( + { + "doctype": "Supplier", + "supplier_name": product_dict.get("vendor"), + SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), + "supplier_group": self._get_supplier_group(), + } + ).insert() + return supplier.name + else: + return "" + + def _get_supplier_group(self): + supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) + if not supplier_group: + supplier_group = frappe.get_doc( + {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} + ).insert() + return supplier_group.name + return supplier_group def _add_weight_details(product_dict): - variants = product_dict.get("variants") - if variants: - product_dict["weight"] = variants[0]["weight"] - product_dict["weight_unit"] = variants[0]["weight_unit"] + variants = product_dict.get("variants") + if variants: + product_dict["weight"] = variants[0]["weight"] + product_dict["weight_unit"] = variants[0]["weight_unit"] def _has_variants(product_dict) -> bool: - options = product_dict.get("options") - return bool(options and "Default Title" not in options[0]["values"]) + options = product_dict.get("options") + return bool(options and "Default Title" not in options[0]["values"]) def _get_sku(product_dict): - if product_dict.get("variants"): - return product_dict.get("variants")[0].get("sku") - return "" + if product_dict.get("variants"): + return product_dict.get("variants")[0].get("sku") + return "" def _get_item_image(product_dict): - if product_dict.get("image"): - return product_dict.get("image").get("src") - return None + if product_dict.get("image"): + return product_dict.get("image").get("src") + return None def _match_sku_and_link_item( - item_dict, product_id, variant_id, variant_of=None, has_variant=False + item_dict, product_id, variant_id, variant_of=None, has_variant=False ) -> bool: - """Tries to match new item with existing item using Shopify SKU == item_code. - - Returns true if matched and linked. - """ - sku = item_dict["sku"] - if not sku or variant_of or has_variant: - return False - - item_name = frappe.db.get_value("Item", {"item_code": sku}) - if item_name: - try: - ecommerce_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "integration": MODULE_NAME, - "erpnext_item_code": item_name, - "integration_item_code": product_id, - "has_variants": 0, - "variant_id": cstr(variant_id), - "sku": sku, - } - ) - - ecommerce_item.insert() - return True - except Exception: - return False - - + """Tries to match new item with existing item using Shopify SKU == item_code. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if not sku or variant_of or has_variant: + return False + + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_name, + "integration_item_code": product_id, + "has_variants": 0, + "variant_id": cstr(variant_id), + "sku": sku, + } + ) + + ecommerce_item.insert() + return True + except Exception: + return False + + +# ---------------- ONE-WAY CREATION ENFORCEMENT ---------------- def create_items_if_not_exist(order): - """Using shopify order, sync all items that are not already synced.""" - for item in order.get("line_items", []): - - product_id = item["product_id"] - variant_id = item.get("variant_id") - sku = item.get("sku") - product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) - - if not product.is_synced(): - product.sync_product() + """Disabled to enforce one-way item creation (ERPNext ➜ Shopify only).""" + return +# -------------------------------------------------------------- def get_item_code(shopify_item): - """Get item code using shopify_item dict. + """Get item code using shopify_item dict. - Item should contain both product_id and variant_id.""" + Item should contain both product_id and variant_id.""" - item = ecommerce_item.get_erpnext_item( - integration=MODULE_NAME, - integration_item_code=shopify_item.get("product_id"), - variant_id=shopify_item.get("variant_id"), - sku=shopify_item.get("sku"), - ) - if item: - return item.item_code + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=shopify_item.get("product_id"), + variant_id=shopify_item.get("variant_id"), + sku=shopify_item.get("sku"), + ) + if item: + return item.item_code @temp_shopify_session def upload_erpnext_item(doc, method=None): - """This hook is called when inserting new or updating existing `Item`. - - New items are pushed to shopify and changes to existing items are - updated depending on what is configured in "Shopify Setting" doctype. - """ - template_item = item = doc # alias for readability - # a new item recieved from ecommerce_integrations is being inserted - if item.flags.from_integration: - return - - setting = frappe.get_doc(SETTING_DOCTYPE) - - if not setting.is_enabled() or not setting.upload_erpnext_items: - return - - if frappe.flags.in_import: - return - - if item.has_variants: - return - - if len(item.attributes) > 3: - msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) - return - - if doc.variant_of and not setting.upload_variants_as_items: - msgprint(_("Enable variant sync in setting to upload item to Shopify.")) - return - - if item.variant_of: - template_item = frappe.get_doc("Item", item.variant_of) - - product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - is_new_product = not bool(product_id) - - if is_new_product: - product = Product() - product.published = False - product.status = "active" if setting.sync_new_item_as_active else "draft" - - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - is_successful = product.save() - - if is_successful: - update_default_variant_properties( - product, - sku=template_item.item_code, - price=template_item.get(ITEM_SELLING_RATE_FIELD), - is_stock_item=template_item.is_stock_item, - ) - if item.variant_of: - product.options = [] - product.variants = [] - variant_attributes = { - "title": template_item.item_name, - "sku": item.item_code, - "price": item.get(ITEM_SELLING_RATE_FIELD), - } - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( - { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw(_("Shopify Error: Missing value for attribute {}").format(attr.attribute)) - product.variants.append(Variant(variant_attributes)) - - product.save() # push variant - - ecom_items = list(set([item, template_item])) - for d in ecom_items: - ecom_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": d.name, - "integration": MODULE_NAME, - "integration_item_code": str(product.id), - "variant_id": "" if d.has_variants else str(product.variants[0].id), - "sku": "" if d.has_variants else str(product.variants[0].sku), - "has_variants": d.has_variants, - "variant_of": d.variant_of, - } - ) - ecom_item.insert() - - write_upload_log(status=is_successful, product=product, item=item) - elif setting.update_shopify_item_on_update: - product = Product.find(product_id) - if product: - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - if not item.variant_of: - update_default_variant_properties( - product, is_stock_item=template_item.is_stock_item, price=item.get(ITEM_SELLING_RATE_FIELD) - ) - else: - variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} - product.options = [] - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( - { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw(_("Shopify Error: Missing value for attribute {}").format(attr.attribute)) - product.variants.append(Variant(variant_attributes)) - - is_successful = product.save() - if is_successful and item.variant_of: - map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - - write_upload_log(status=is_successful, product=product, item=item, action="Updated") + """This hook is called when inserting new or updating existing `Item`. + + New items are pushed to shopify and changes to existing items are + updated depending on what is configured in "Shopify Setting" doctype. + """ + template_item = item = doc # alias for readability + # a new item recieved from ecommerce_integrations is being inserted + if item.flags.from_integration: + return + + setting = frappe.get_doc(SETTING_DOCTYPE) + + if not setting.is_enabled() or not setting.upload_erpnext_items: + return + + # Only push if explicitly selected + if not cint(getattr(item, "custom_sync_to_shopify", 0)): + return + + if frappe.flags.in_import: + return + + if item.has_variants: + return + + if len(item.attributes) > 3: + msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) + return + + if doc.variant_of and not setting.upload_variants_as_items: + msgprint(_("Enable variant sync in setting to upload item to Shopify.")) + return + + if item.variant_of: + template_item = frappe.get_doc("Item", item.variant_of) + + product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + is_new_product = not bool(product_id) + + if is_new_product: + product = Product() + product.published = False + product.status = "active" if setting.sync_new_item_as_active else "draft" + + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + # ensure image on create + _apply_erp_image_to_shopify_product(product, template_item) + + is_successful = product.save() + + if is_successful: + update_default_variant_properties( + product, + sku=template_item.item_code, + price=template_item.get(ITEM_SELLING_RATE_FIELD), + is_stock_item=template_item.is_stock_item, + ) + if item.variant_of: + product.options = [] + product.variants = [] + variant_attributes = { + "title": template_item.item_name, + "sku": item.item_code, + "price": item.get(ITEM_SELLING_RATE_FIELD), + } + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw(_("Shopify Error: Missing value for attribute {}").format(attr.attribute)) + product.variants.append(Variant(variant_attributes)) + + product.save() # push variant + + ecom_items = list(set([item, template_item])) + for d in ecom_items: + ecom_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": d.name, + "integration": MODULE_NAME, + "integration_item_code": str(product.id), + "variant_id": "" if d.has_variants else str(product.variants[0].id), + "sku": "" if d.has_variants else str(product.variants[0].sku), + "has_variants": d.has_variants, + "variant_of": d.variant_of, + } + ) + ecom_item.insert() + + write_upload_log(status=is_successful, product=product, item=item) + + elif setting.update_shopify_item_on_update: + try: + product = Product.find(product_id) + if product: + # normal update path + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + _apply_erp_image_to_shopify_product(product, template_item) + + if not item.variant_of: + update_default_variant_properties( + product, is_stock_item=template_item.is_stock_item, price=item.get(ITEM_SELLING_RATE_FIELD) + ) + else: + variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} + product.options = [] + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw(_("Shopify Error: Missing value for attribute {}").format(attr.attribute)) + product.variants.append(Variant(variant_attributes)) + + is_successful = product.save() + if is_successful and item.variant_of: + map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) + + write_upload_log(status=is_successful, product=product, item=item, action="Updated") + + except ResourceNotFound: + # Mapping exists but Shopify product is gone. Clean mapping & create a fresh product. + frappe.db.delete("Ecommerce Item", { + "erpnext_item_code": template_item.name, + "integration": MODULE_NAME + }) + frappe.db.commit() + + # Re-enter the create branch + product = Product() + product.published = False + product.status = "active" if setting.sync_new_item_as_active else "draft" + + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + _apply_erp_image_to_shopify_product(product, template_item) + + is_successful = product.save() + + if is_successful: + update_default_variant_properties( + product, + sku=template_item.item_code, + price=template_item.get(ITEM_SELLING_RATE_FIELD), + is_stock_item=template_item.is_stock_item, + ) + if item.variant_of: + product.options = [] + product.variants = [] + variant_attributes = { + "title": template_item.item_name, + "sku": item.item_code, + "price": item.get(ITEM_SELLING_RATE_FIELD), + } + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw(_("Shopify Error: Missing value for attribute {}").format(attr.attribute)) + product.variants.append(Variant(variant_attributes)) + + product.save() + + # Recreate mapping rows for template and item as needed + ecom_items = list(set([item, template_item])) + for d in ecom_items: + ecom_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": d.name, + "integration": MODULE_NAME, + "integration_item_code": str(product.id), + "variant_id": "" if d.has_variants else str(product.variants[0].id), + "sku": "" if d.has_variants else str(product.variants[0].sku), + "has_variants": d.has_variants, + "variant_of": d.variant_of, + } + ) + ecom_item.insert() + + write_upload_log(status=True, product=product, item=item, action="Recreated") + + +def _apply_erp_image_to_shopify_product(shopify_product: Product, erpnext_item) -> None: + """Ensure Shopify product has the same primary image as ERPNext Item.image.""" + try: + img_src = _get_erp_item_image_url(erpnext_item) + if not img_src: + return + if getattr(shopify_product, "images", None): + try: + shopify_product.images[0]["src"] = img_src + except Exception: + try: + shopify_product.images[0].src = img_src + except Exception: + shopify_product.images = [{"src": img_src}] + else: + shopify_product.images = [{"src": img_src}] + except Exception: + # Never block the sync on image issues. + pass + + +def _get_erp_item_image_url(erpnext_item) -> Optional[str]: + """Return absolute URL for ERPNext Item.image if present.""" + path = getattr(erpnext_item, "image", None) + if not path: + return None + try: + return get_url(path) + except Exception: + return None def map_erpnext_variant_to_shopify_variant( - shopify_product: Product, erpnext_item, variant_attributes + shopify_product: Product, erpnext_item, variant_attributes ): - variant_product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - if not variant_product_id: - for variant in shopify_product.variants: - if ( - variant.option1 == variant_attributes.get("option1") - and variant.option2 == variant_attributes.get("option2") - and variant.option3 == variant_attributes.get("option3") - ): - variant_product_id = str(variant.id) - if not frappe.flags.in_test: - frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": erpnext_item.name, - "integration": MODULE_NAME, - "integration_item_code": str(shopify_product.id), - "variant_id": variant_product_id, - "sku": str(variant.sku), - "variant_of": erpnext_item.variant_of, - } - ).insert() - break - if not variant_product_id: - msgprint(_("Shopify: Couldn't sync item variant.")) - return variant_product_id + variant_product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + if not variant_product_id: + for variant in shopify_product.variants: + if ( + variant.option1 == variant_attributes.get("option1") + and variant.option2 == variant_attributes.get("option2") + and variant.option3 == variant_attributes.get("option3") + ): + variant_product_id = str(variant.id) + if not frappe.flags.in_test: + frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": erpnext_item.name, + "integration": MODULE_NAME, + "integration_item_code": str(shopify_product.id), + "variant_id": variant_product_id, + "sku": str(variant.sku), + "variant_of": erpnext_item.variant_of, + } + ).insert() + break + if not variant_product_id: + msgprint(_("Shopify: Couldn't sync item variant.")) + return variant_product_id def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): - """Map erpnext fields to shopify, called both when updating and creating new products.""" + """Map erpnext fields to shopify, called both when updating and creating new products.""" - shopify_product.title = erpnext_item.item_name - shopify_product.body_html = erpnext_item.description - shopify_product.product_type = erpnext_item.item_group + shopify_product.title = erpnext_item.item_name + shopify_product.body_html = erpnext_item.description + shopify_product.product_type = erpnext_item.item_group - if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): - # reverse lookup for key - uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) - shopify_product.weight = erpnext_item.weight_per_unit - shopify_product.weight_unit = uom + if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): + # reverse lookup for key + uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) + shopify_product.weight = erpnext_item.weight_per_unit + shopify_product.weight_unit = uom - if erpnext_item.disabled: - shopify_product.status = "draft" - shopify_product.published = False - msgprint(_("Status of linked Shopify product is changed to Draft.")) + if erpnext_item.disabled: + shopify_product.status = "draft" + shopify_product.published = False + msgprint(_("Status of linked Shopify product is changed to Draft.")) def get_shopify_weight_uom(erpnext_weight_uom: str) -> str: - for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): - if erpnext_uom == erpnext_weight_uom: - return shopify_uom + for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): + if erpnext_uom == erpnext_weight_uom: + return shopify_uom def update_default_variant_properties( - shopify_product: Product, - is_stock_item: bool, - sku: Optional[str] = None, - price: Optional[float] = None, + shopify_product: Product, + is_stock_item: bool, + sku: Optional[str] = None, + price: Optional[float] = None, ): - """Shopify creates default variant upon saving the product. + """Shopify creates default variant upon saving the product. - Some item properties are supposed to be updated on the default variant. - Input: saved shopify_product, sku and price - """ - default_variant: Variant = shopify_product.variants[0] + Some item properties are supposed to be updated on the default variant. + Input: saved shopify_product, sku and price + """ + default_variant: Variant = shopify_product.variants[0] - # this will create Inventory item and qty will be updated by scheduled job. - if is_stock_item: - default_variant.inventory_management = "shopify" + # this will create Inventory item and qty will be updated by scheduled job. + if is_stock_item: + default_variant.inventory_management = "shopify" - if price is not None: - default_variant.price = price - if sku is not None: - default_variant.sku = sku + if price is not None: + default_variant.price = price + if sku is not None: + default_variant.sku = sku def write_upload_log(status: bool, product: Product, item, action="Created") -> None: - if not status: - msg = _("Failed to upload item to Shopify") + "