Skip to content

Commit

Permalink
feat: item variant sync (frappe#212)
Browse files Browse the repository at this point in the history
* feat: ability to upload variants as items

* feat: variant sync

* chore: clean-up code

* fix: add variant to ecomm item

* feat: option to sync item as active

* fix: incorrect variant ID issue

* chore: change field logic

* test: variant ID check

* fix: dont warn for template item all the time
  • Loading branch information
rtdany10 committed Nov 11, 2022
1 parent ccfa51b commit d1c6b22
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 23 deletions.
2 changes: 1 addition & 1 deletion ecommerce_integrations/controllers/inventory.py
Expand Up @@ -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,
Expand Down
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
137 changes: 116 additions & 21 deletions ecommerce_integrations/shopify/product.py
Expand Up @@ -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
Expand All @@ -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."""

Expand Down
117 changes: 117 additions & 0 deletions ecommerce_integrations/shopify/tests/test_product.py
Expand Up @@ -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

0 comments on commit d1c6b22

Please sign in to comment.