diff --git a/ecommerce_integrations/controllers/inventory.py b/ecommerce_integrations/controllers/inventory.py index 5abd0a83..3438086f 100644 --- a/ecommerce_integrations/controllers/inventory.py +++ b/ecommerce_integrations/controllers/inventory.py @@ -24,7 +24,7 @@ def get_inventory_levels(warehouses: Tuple[str], integration: str) -> List[_dict ON ei.erpnext_item_code = bin.item_code WHERE bin.warehouse in ({', '.join('%s' for _ in warehouses)}) AND bin.modified > ei.inventory_synced_on - AND integration = %s + AND ei.integration = %s """, values=warehouses + (integration,), as_dict=1, diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index e13ddd0b..d6534057 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -38,6 +38,9 @@ "erpnext_to_shopify_sync_section", "upload_erpnext_items", "update_shopify_item_on_update", + "column_break_34", + "sync_new_item_as_active", + "upload_variants_as_items", "inventory_sync_section", "warehouse", "update_erpnext_stock_levels_to_shopify", @@ -327,12 +330,29 @@ "hidden": 1, "label": "Last Inventory Sync", "read_only": 1 + }, + { + "fieldname": "column_break_34", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Caution: Only 3 attributes will be accepted by Shopify", + "fieldname": "upload_variants_as_items", + "fieldtype": "Check", + "label": "Upload ERPNext Variants as Shopify Items" + }, + { + "default": "0", + "fieldname": "sync_new_item_as_active", + "fieldtype": "Check", + "label": "Sync New Items as Active" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-02-16 11:34:11.966616", + "modified": "2022-11-01 16:09:42.685577", "modified_by": "Administrator", "module": "shopify", "name": "Shopify Setting", diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 1d16df99..b975912b 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -329,7 +329,7 @@ def upload_erpnext_item(doc, method=None): New items are pushed to shopify and changes to existing items are updated depending on what is configured in "Shopify Setting" doctype. """ - item = doc # alias for readability + template_item = item = doc # alias for readability # a new item recieved from ecommerce_integrations is being inserted if item.flags.from_integration: return @@ -342,54 +342,149 @@ def upload_erpnext_item(doc, method=None): if frappe.flags.in_import: return - if doc.has_variants or doc.variant_of: - msgprint(_("Item with variants or template items can not be uploaded to Shopify.")) + 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": item.name, "integration": MODULE_NAME}, + {"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 = "draft" + product.status = "active" if setting.sync_new_item_as_active else "draft" - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=item) + 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=item.item_code, price=item.standard_rate, is_stock_item=item.is_stock_item, + product, + sku=template_item.item_code, + price=template_item.standard_rate, + is_stock_item=template_item.is_stock_item, ) + if item.variant_of: + product.options = [] + product.variants = [] + variant_attributes = {"title": template_item.item_name} + 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_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": item.name, - "integration": MODULE_NAME, - "integration_item_code": str(product.id), - "variant_id": str(product.variants[0].id), - "sku": str(product.variants[0].sku), - } - ) - ecom_item.insert() + 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=item) - update_default_variant_properties(product, is_stock_item=item.is_stock_item) + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + update_default_variant_properties(product, is_stock_item=template_item.is_stock_item) + + variant_attributes = {} + if item.variant_of: + 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") +def map_erpnext_variant_to_shopify_variant( + 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 + + def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): """Map erpnext fields to shopify, called both when updating and creating new products.""" diff --git a/ecommerce_integrations/shopify/tests/test_product.py b/ecommerce_integrations/shopify/tests/test_product.py index b6466c80..31758ad7 100644 --- a/ecommerce_integrations/shopify/tests/test_product.py +++ b/ecommerce_integrations/shopify/tests/test_product.py @@ -63,3 +63,120 @@ def test_sync_product_with_variants(self): self.assertEqual(len(created_ecom_variants), 9) self.assertEqual(sorted(required_variants), sorted(created_ecom_variants)) + + def test_variant_id_mapping(self): + template_item = make_item() + from erpnext.controllers.item_variant import create_variant + + variant_LR = create_variant( + template_item.item_code, {"Test Sync Size": "L", "Test Sync Colour": "Red"} + ) + variant_MR = create_variant( + template_item.item_code, {"Test Sync Size": "M", "Test Sync Colour": "Red"} + ) + variant_LG = create_variant( + template_item.item_code, {"Test Sync Size": "L", "Test Sync Colour": "Green"} + ) + variant_MG = create_variant( + template_item.item_code, {"Test Sync Size": "M", "Test Sync Colour": "Green"} + ) + + self.fake("products/6704435495065", body=self.load_fixture("variant_product")) + product = ShopifyProduct(product_id="6704435495065", has_variants=1) + product.sync_product() + + self.assertTrue(product.is_synced()) + from shopify.resources import Product + + shopify_product = Product.find(product.product_id) + + from ecommerce_integrations.shopify.product import map_erpnext_variant_to_shopify_variant + + self.assertEqual( + map_erpnext_variant_to_shopify_variant( + shopify_product, variant_LG, {"option1": "L", "option2": "Green"} + ), + "39845261705369", + ) + self.assertEqual( + map_erpnext_variant_to_shopify_variant( + shopify_product, variant_LR, {"option1": "L", "option2": "Red"} + ), + "39845261639833", + ) + self.assertEqual( + map_erpnext_variant_to_shopify_variant( + shopify_product, variant_MG, {"option1": "M", "option2": "Green"} + ), + "39845261607065", + ) + self.assertEqual( + map_erpnext_variant_to_shopify_variant( + shopify_product, variant_MR, {"option1": "M", "option2": "Red"} + ), + "39845261541529", + ) + + +def create_item_attributes(): + if not frappe.db.exists("Item Attribute", "Test Sync Size"): + frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": "Test Sync Size", + "priority": 1, + "item_attribute_values": [ + {"attribute_value": "XSL", "abbr": "XSL"}, + {"attribute_value": "S", "abbr": "S"}, + {"attribute_value": "M", "abbr": "M"}, + {"attribute_value": "L", "abbr": "L"}, + {"attribute_value": "XL", "abbr": "XL"}, + {"attribute_value": "2XL", "abbr": "2XL"}, + ], + } + ).insert() + if not frappe.db.exists("Item Attribute", "Test Sync Colour"): + frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": "Test Sync Colour", + "priority": 2, + "item_attribute_values": [ + {"attribute_value": "Red", "abbr": "R"}, + {"attribute_value": "Green", "abbr": "G"}, + {"attribute_value": "Blue", "abbr": "B"}, + ], + } + ).insert() + + +def make_item(item_code=None, properties=None): + create_item_attributes() + if not item_code: + item_code = frappe.generate_hash(length=16) + + if frappe.db.exists("Item", item_code): + return frappe.get_doc("Item", item_code) + + item = frappe.get_doc( + { + "doctype": "Item", + "item_code": item_code, + "item_name": item_code, + "description": item_code, + "item_group": "Products", + "attributes": [{"attribute": "Test Sync Size"}, {"attribute": "Test Sync Colour"},], + "has_variants": 1, + } + ) + + if properties: + item.update(properties) + + if item.is_stock_item: + for item_default in [doc for doc in item.get("item_defaults") if not doc.default_warehouse]: + item_default.default_warehouse = "_Test Warehouse - _TC" + item_default.company = "_Test Company" + item.insert() + + return item