diff --git a/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.json b/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.json new file mode 100644 index 000000000..f99725152 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.json @@ -0,0 +1,39 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-12-06 14:05:51.822550", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_group", + "hsnsac_code" + ], + "fields": [ + { + "fieldname": "item_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": " Item Group", + "options": "Item Group" + }, + { + "fieldname": "hsnsac_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "HSN/SAC Code" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-12-06 16:01:12.105446", + "modified_by": "Administrator", + "module": "shopify", + "name": "Shopify Item Group HSN Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.py b/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.py new file mode 100644 index 000000000..777ee6a66 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_item_group_hsn_mapping/shopify_item_group_hsn_mapping.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ShopifyItemGroupHSNMapping(Document): + pass diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js index 45be645ac..6ba67be41 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js @@ -26,6 +26,9 @@ frappe.ui.form.on("Shopify Setting", { }, refresh: function (frm) { + frm.add_custom_button(__("Import Item Group"), function () { + frm.trigger("shopify_import_item_group"); + }); frm.add_custom_button(__("Import Products"), function () { frappe.set_route("shopify-import-products"); }); @@ -96,4 +99,9 @@ frappe.ui.form.on("Shopify Setting", { frm.set_query("default_sales_tax_account", tax_query); frm.set_query("default_shipping_charges_account", tax_query); }, + shopify_import_item_group: function (frm) { + frappe.call({ + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_product_groups", + }); + }, }); diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index 01722169b..29a974909 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -52,6 +52,7 @@ "inventory_sync_frequency", "fetch_shopify_locations", "shopify_warehouse_mapping", + "shopify_item_group_hsn_mapping", "sync_old_orders_section", "sync_old_orders", "column_break_45", @@ -387,12 +388,18 @@ "fieldtype": "Link", "label": "Default Shipping Charges Account", "options": "Account" + }, + { + "fieldname": "shopify_item_group_hsn_mapping", + "fieldtype": "Table", + "label": "Shopify Item Group HSN Mapping", + "options": "Shopify Item Group HSN Mapping" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-24 10:38:49.247431", + "modified": "2024-12-10 16:31:44.145714", "modified_by": "Administrator", "module": "shopify", "name": "Shopify Setting", diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index e30a102e9..1614ae511 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -6,12 +6,14 @@ 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 MODULE_NAME +from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE from ecommerce_integrations.shopify.product import ShopifyProduct # constants SYNC_JOB_NAME = "shopify.job.sync.all.products" REALTIME_KEY = "shopify.key.sync.all.products" +SYNC_PRODUCT_GROUP_JOB_NAME = "shopify.job.sync.all.product.groups" +SYNC_PRODUCT_GROUP_REALTIME_KEY = "shopify.key.sync.all.product.groups" @frappe.whitelist() @@ -126,6 +128,41 @@ def import_all_products(): ) +@frappe.whitelist() +def import_all_product_groups(): + frappe.enqueue( + queue_sync_all_product_groups, + queue="long", + job_name=SYNC_PRODUCT_GROUP_JOB_NAME, + key=SYNC_PRODUCT_GROUP_REALTIME_KEY, + ) + + +def queue_sync_all_product_groups(*args, **kwargs): + _sync = True + collection = _fetch_products_from_shopify(limit=100) + savepoint = "shopify_product_groups_sync" + setting = frappe.get_doc(SETTING_DOCTYPE) + while _sync: + for product in collection: + try: + frappe.db.savepoint(savepoint) + shopify_product = ShopifyProduct(product.id, setting=setting) + shopify_product.sync_product(sync_product_group=True) + except Exception: + frappe.log_error(title="queue_sync_all_product_groups", message=frappe.get_traceback()) + frappe.db.rollback(save_point=savepoint) + continue + + if collection.has_next_page(): + frappe.db.commit() # prevents too many write request error + collection = _fetch_products_from_shopify(from_=collection.next_page_url) + else: + _sync = False + setting.save() + return True + + def queue_sync_all_products(*args, **kwargs): start_time = process_time() diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..1bfbb8131 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -1,7 +1,9 @@ from typing import Optional import frappe +import shopify from frappe import _, msgprint +from frappe.query_builder import DocType from frappe.utils import cint, cstr from frappe.utils.nestedset import get_root_of from shopify.resources import Product, Variant @@ -26,12 +28,13 @@ def __init__( variant_id: str | None = None, sku: str | None = None, has_variants: int | None = 0, + setting: object | None = None, ): 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) + self.setting = setting if setting else frappe.get_doc(SETTING_DOCTYPE) if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -54,11 +57,22 @@ def get_erpnext_item(self): ) @temp_shopify_session - def sync_product(self): - if not self.is_synced(): + def sync_product(self, sync_product_group=False): + if not self.is_synced() or sync_product_group: shopify_product = Product.find(self.product_id) product_dict = shopify_product.to_dict() - self._make_item(product_dict) + product_dict = self._update_hsn_sac_code(product_dict) + if sync_product_group: + self._sync_product_group(product_dict) + else: + self._make_item(product_dict) + + def _sync_product_group(self, product_dict): + self._get_item_group( + product_type=product_dict.get("product_type"), + sync_product_group=True, + hsnsac_code=product_dict.get("gst_hsn_code"), + ) def _make_item(self, product_dict): _add_weight_details(product_dict) @@ -127,7 +141,9 @@ def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, "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")), + "item_group": self._get_item_group( + product_dict.get("product_type"), hsnsac_code=product_dict.get("gst_hsn_code") + ), "has_variants": has_variant, "attributes": attributes or [], "stock_uom": product_dict.get("uom") or _("Nos"), @@ -137,6 +153,7 @@ def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, "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), + "gst_hsn_code": product_dict.get("gst_hsn_code"), } integration_item_code = product_dict["id"] # shopify product_id @@ -148,9 +165,9 @@ def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, ): ecommerce_item.create_ecommerce_item( MODULE_NAME, - integration_item_code, + str(integration_item_code), item_dict, - variant_id=variant_id, + variant_id=str(variant_id), sku=sku, variant_of=variant_of, has_variants=has_variant, @@ -174,6 +191,7 @@ def _create_item_variants(self, product_dict, warehouse, attributes): "item_price": variant.get("price"), "weight_unit": variant.get("weight_unit"), "weight": variant.get("weight"), + "gst_hsn_code": product_dict.get("gst_hsn_code"), } for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): @@ -187,22 +205,49 @@ def _create_item_variants(self, product_dict, warehouse, attributes): ) self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) + def _update_hsn_sac_code(self, product_dict): + try: + for variant in product_dict.get("variants"): + if variant.get("inventory_item_id"): + inventory_item = shopify.InventoryItem.find(variant.get("inventory_item_id")) + if hasattr(inventory_item, "harmonized_system_code"): + product_dict["gst_hsn_code"] = inventory_item.harmonized_system_code + break + except Exception: + frappe.log_error(title="_update_hsn_sac_code", message=frappe.get_traceback()) + if not (product_dict.get("gst_hsn_code")): + hsnsac_code = frappe.db.get_value( + "Shopify Item Group HSN Mapping", + { + "parent": SETTING_DOCTYPE, + "item_group": product_dict.get("product_type"), + "hsnsac_code": ["is", "set"], + }, + "hsnsac_code", + ) + if hsnsac_code: + product_dict["gst_hsn_code"] = hsnsac_code + + return product_dict + 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, + attribute_value = frappe.db.get_all( + "Item Attribute Value", + filters={"parent": attribute["attribute"]}, + or_filters={"abbr": variant_attr_val, "attribute_value": variant_attr_val}, + fields=["attribute_value"], + as_list=True, ) return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) - def _get_item_group(self, product_type=None): + def _get_item_group(self, product_type=None, hsnsac_code=None, sync_product_group=False): 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"): + self._sync_item_group_hsn_sac_code(sync_product_group, product_type, hsnsac_code, False) return product_type item_group = frappe.get_doc( { @@ -210,19 +255,53 @@ def _get_item_group(self, product_type=None): "item_group_name": product_type, "parent_item_group": parent_item_group, "is_group": "No", + "gst_hsn_code": hsnsac_code, } ).insert() + self._sync_item_group_hsn_sac_code(sync_product_group, product_type, hsnsac_code) return item_group.name + def _update_hsn_code_in_item_group(self, product_type, hsnsac_code): + try: + if not (frappe.db.get_value("Item Group", product_type, "gst_hsn_code")) and hsnsac_code: + frappe.db.set_value("Item Group", product_type, "gst_hsn_code", hsnsac_code) + except Exception: + pass + + def _update_in_item_group_mapping(self, sync_product_group, product_type, hsnsac_code): + has_value_flag = False + for row in self.setting.shopify_item_group_hsn_mapping: + if row.get("item_group") == product_type: + if hsnsac_code and row.get("hsnsac_code") != hsnsac_code: + row.hsnsac_code = hsnsac_code + has_value_flag = True + break + if not has_value_flag: + self._sync_item_group_hsn_sac_code(sync_product_group, product_type, hsnsac_code) + + def _sync_item_group_hsn_sac_code(self, sync_product_group, item_group, hsnsac_code=None, is_new=True): + if sync_product_group: + if is_new: + self.setting.append( + "shopify_item_group_hsn_mapping", + {"item_group": item_group, "hsnsac_code": hsnsac_code}, + ) + else: + self._update_hsn_code_in_item_group(item_group, hsnsac_code) + self._update_in_item_group_mapping(sync_product_group, item_group, hsnsac_code) + 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, - ) - + Supplier = DocType("Supplier") + + supplier = ( + frappe.qb.from_(Supplier) + .select(Supplier.name) + .where( + (Supplier.name == product_dict.get("vendor")) + | (Supplier[SUPPLIER_ID_FIELD] == product_dict.get("vendor").lower()) + ) + ).run(as_list=True) if supplier: return product_dict.get("vendor") supplier = frappe.get_doc( diff --git a/ecommerce_integrations/shopify/tests/test_product.py b/ecommerce_integrations/shopify/tests/test_product.py index 78d04d240..435663fdf 100644 --- a/ecommerce_integrations/shopify/tests/test_product.py +++ b/ecommerce_integrations/shopify/tests/test_product.py @@ -3,6 +3,7 @@ import frappe +from ecommerce_integrations.shopify.constants import SETTING_DOCTYPE from ecommerce_integrations.shopify.product import ShopifyProduct from .utils import TestCase @@ -25,6 +26,13 @@ def test_sync_single_product(self): ecommerce_item_exists = frappe.db.exists("Ecommerce Item", {"erpnext_item_code": item.name}) self.assertTrue(bool(ecommerce_item_exists)) + def test_sync_item_group(self): + self.fake("products/6704435495065", body=self.load_fixture("variant_product")) + product = ShopifyProduct(product_id="6704435495065") + product.sync_product(sync_product_group=1) + self.assertEqual(frappe.get_last_doc("Item Group").name, "shirt") + self.assertEqual(product.setting.shopify_item_group_hsn_mapping[0].item_group, "shirt") + def test_sync_product_with_variants(self): self.fake("products/6704435495065", body=self.load_fixture("variant_product")) diff --git a/ecommerce_integrations/shopify/tests/utils.py b/ecommerce_integrations/shopify/tests/utils.py index 8cc246c7d..cbaad18ae 100644 --- a/ecommerce_integrations/shopify/tests/utils.py +++ b/ecommerce_integrations/shopify/tests/utils.py @@ -77,6 +77,7 @@ def setUpClass(cls): "erpnext_warehouse": "_Test Warehouse 2 - _TC", }, ], + "shopify_item_group_hsn_mapping": [], } ).save(ignore_permissions=True)