diff --git a/ecommerce_integrations/controllers/customer.py b/ecommerce_integrations/controllers/customer.py index 4c543b7ac..b00d68c18 100644 --- a/ecommerce_integrations/controllers/customer.py +++ b/ecommerce_integrations/controllers/customer.py @@ -21,7 +21,7 @@ def get_customer_doc(self): else: raise frappe.DoesNotExistError() - def sync_customer(self, customer_name: str, customer_group: str) -> None: + def sync_customer(self, customer_name: str, customer_group: str, **kwargs) -> None: """Create customer in ERPNext if one does not exist already.""" customer = frappe.get_doc( { @@ -35,6 +35,9 @@ def sync_customer(self, customer_name: str, customer_group: str) -> None: } ) + if kwargs.get("company"): + customer.custom_company = kwargs["company"] + customer.flags.ignore_mandatory = True customer.insert(ignore_permissions=True) diff --git a/ecommerce_integrations/controllers/scheduling.py b/ecommerce_integrations/controllers/scheduling.py index 9b59e30ae..bd0871497 100644 --- a/ecommerce_integrations/controllers/scheduling.py +++ b/ecommerce_integrations/controllers/scheduling.py @@ -2,7 +2,8 @@ from frappe.utils import add_to_date, cint, get_datetime, now -def need_to_run(setting, interval_field, timestamp_field) -> bool: +# TODO: handle unicommerce +def need_to_run(setting, doc_name, interval_field, timestamp_field) -> bool: """A utility function to make "configurable" scheduled events. If timestamp_field is older than current_time - inveterval_field then this function updates the timestamp_field to `now()` and returns True, @@ -13,11 +14,11 @@ def need_to_run(setting, interval_field, timestamp_field) -> bool: - timestamp field is datetime field. - This function is called from scheuled job with less frequency than lowest interval_field. Ideally, every minute. """ - interval = frappe.db.get_single_value(setting, interval_field, cache=True) - last_run = frappe.db.get_single_value(setting, timestamp_field) + interval = frappe.db.get_value(setting, doc_name, interval_field, cache=True) + last_run = frappe.db.get_value(setting, doc_name, timestamp_field) if last_run and get_datetime() < get_datetime(add_to_date(last_run, minutes=cint(interval, default=10))): return False - frappe.db.set_value(setting, None, timestamp_field, now(), update_modified=False) + frappe.db.set_value(setting, doc_name, timestamp_field, now(), update_modified=False) return True diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js index 5e55650ce..8e0788a9f 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js @@ -3,7 +3,8 @@ frappe.ui.form.on("Ecommerce Integration Log", { refresh: function (frm) { - if (frm.doc.request_data && frm.doc.status == "Error") { + const retryStatusList = ["Error", "Invalid"] + if (frm.doc.request_data && retryStatusList.includes(frm.doc.status)) { frm.add_custom_button(__("Retry"), function () { frappe.call({ method: "ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log.resync", @@ -11,6 +12,7 @@ frappe.ui.form.on("Ecommerce Integration Log", { method: frm.doc.method, name: frm.doc.name, request_data: frm.doc.request_data, + shopify_account: frm.doc.shopify_account, }, callback: function (r) { frappe.msgprint(__("Reattempting to sync")); diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json index 01722a1c2..704c5b99c 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json @@ -7,6 +7,7 @@ "field_order": [ "title", "integration", + "shopify_account", "status", "method", "message", @@ -66,12 +67,21 @@ "fieldtype": "Code", "label": "Response Data", "read_only": 1 + }, + { + "depends_on": "eval: doc.integration == \"shopify\"", + "fieldname": "shopify_account", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Shopify Account", + "options": "Shopify Account" } ], "in_create": 1, "links": [], - "modified": "2021-05-28 16:06:49.008875", - "modified_by": "Administrator", + "modified": "2025-10-28 16:06:18.084320", + "modified_by": "user@nexusdemo.com", "module": "Ecommerce Integrations", "name": "Ecommerce Integration Log", "owner": "Administrator", @@ -101,7 +111,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "title" } \ No newline at end of file diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py index c5ed9f380..25c2117ea 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py @@ -13,6 +13,24 @@ class EcommerceIntegrationLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + integration: DF.Link | None + message: DF.Code | None + method: DF.SmallText | None + request_data: DF.Code | None + response_data: DF.Code | None + shopify_account: DF.Link | None + status: DF.Data | None + title: DF.Data | None + traceback: DF.Code | None + # end: auto-generated types def validate(self): self._set_title() @@ -47,6 +65,7 @@ def create_log( method=None, message=None, make_new=False, + shopify_account=None, ): make_new = make_new or not bool(frappe.flags.request_id) @@ -71,6 +90,7 @@ def create_log( log.request_data = request_data or log.request_data log.traceback = log.traceback or frappe.get_traceback() log.status = status + log.shopify_account = shopify_account log.save(ignore_permissions=True) frappe.db.commit() @@ -96,12 +116,14 @@ def _retry_job(job: str): frappe.only_for("System Manager") doc = frappe.get_doc("Ecommerce Integration Log", job) - if not doc.method.startswith("ecommerce_integrations.") or doc.status != "Error": + retry_status_list = ["Error", "Invalid"] + if not doc.method.startswith("ecommerce_integrations.") or doc.status not in retry_status_list: return doc.db_set("status", "Queued", update_modified=False) doc.db_set("traceback", "", update_modified=False) - + shopify_account = frappe.get_doc("Shopify Account", doc.shopify_account) if doc.shopify_account else None + frappe.enqueue( method=doc.method, queue="short", @@ -109,6 +131,7 @@ def _retry_job(job: str): is_async=True, payload=json.loads(doc.request_data), request_id=doc.name, + shopify_account=shopify_account, enqueue_after_commit=True, ) diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json index 08ad74011..0d120f3aa 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json @@ -9,6 +9,7 @@ "erpnext_item_code", "integration_item_code", "sku", + "company", "column_break_5", "has_variants", "variant_id", @@ -91,12 +92,20 @@ "fieldtype": "Datetime", "label": "Item Data Synced On", "read_only": 1 + }, + { + "fetch_from": "erpnext_item_code.custom_company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-15 11:36:04.733227", - "modified_by": "Administrator", + "modified": "2025-10-26 11:38:45.690155", + "modified_by": "user@nexus.com", "module": "Ecommerce Integrations", "name": "Ecommerce Item", "owner": "Administrator", @@ -114,6 +123,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py index 81985d80c..541ec5203 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py @@ -147,7 +147,12 @@ def create_ecommerce_item( "doctype": "Item", "is_stock_item": 1, "is_sales_item": 1, - "item_defaults": [{"company": get_default_company()}], + "item_defaults": [ + { + "company": item_dict.get("custom_company"), + "default_warehouse": item_dict.get("default_warehouse"), + } + ], } item.update(item_dict) diff --git a/ecommerce_integrations/hooks.py b/ecommerce_integrations/hooks.py index 2f99e5e57..dafe1e09b 100644 --- a/ecommerce_integrations/hooks.py +++ b/ecommerce_integrations/hooks.py @@ -136,10 +136,14 @@ # --------------- scheduler_events = { - "all": ["ecommerce_integrations.shopify.inventory.update_inventory_on_shopify"], + "all": [ + # Updated to use multi-tenant inventory sync + "ecommerce_integrations.shopify.inventory.update_inventory_on_shopify" + ], "daily": [], "daily_long": ["ecommerce_integrations.zenoti.doctype.zenoti_settings.zenoti_settings.sync_stocks"], "hourly": [ + # Updated to use multi-tenant old orders sync "ecommerce_integrations.shopify.order.sync_old_orders", "ecommerce_integrations.amazon.doctype.amazon_sp_api_settings.amazon_sp_api_settings.schedule_get_order_details", ], @@ -198,21 +202,26 @@ # "filter_by": "{filter_by}", # "redact_fields": ["{field_1}", "{field_2}"], # "partial": 1, -# }, -# { -# "doctype": "{doctype_2}", -# "filter_by": "{filter_by}", -# "partial": 1, -# }, -# { -# "doctype": "{doctype_3}", -# "strict": False, -# }, -# { -# "doctype": "{doctype_4}" # } # ] +# Authentication and authorization +# -------------------------------- + +# auth_hooks = [ +# "ecommerce_integrations.auth.validate" +# ] + +# Translation +# -------------------------------- + +# Make property setters available in the translation file +# translate_linked_doctypes = ["DocType", "Role"] + +# Automatically update python controller files with type annotations for DocTypes +# Automatically add __init__.py files to the DocTypes +# be careful turning this on if you have customizations. +export_python_type_annotations = True default_log_clearing_doctypes = { "Ecommerce Integration Log": 120, diff --git a/ecommerce_integrations/shopify/Readme.md b/ecommerce_integrations/shopify/Readme.md new file mode 100644 index 000000000..03a597481 --- /dev/null +++ b/ecommerce_integrations/shopify/Readme.md @@ -0,0 +1,288 @@ +# Shopify Integration for ERPNext + +A **comprehensive, production-ready Shopify integration** for ERPNext, now with **multi-tenant** architecture โ€” connect multiple Shopify stores to one ERPNext instance while ensuring complete **data isolation**, **company-specific configuration**, and **secure synchronization**. + +--- + +## ๐Ÿš€ Key Features + +* **Multi-Tenant Architecture** โ€“ Connect multiple Shopify stores, each isolated to its own ERPNext company. +* **Legacy Compatibility** โ€“ Backward-compatible with existing single-store setups. +* **Company-Bound Data** โ€“ Warehouses, customers, and transactions are strictly company-specific. +* **Secure Webhook Handling** โ€“ Domain-based routing and HMAC validation for all inbound events. +* **Real-Time Synchronization** โ€“ Bidirectional sync for products, orders, inventory, and customers. +* **Granular Control** โ€“ Per-store toggles for product creation, invoice/fulfillment sync, and more. + +--- + +## ๐Ÿ— Architecture + +### Multi-Tenant Design + +* **Shopify Account** *(Recommended)* โ€“ Modern, non-single DocType for per-store settings. +* **Shopify Setting** *(Deprecated)* โ€“ Legacy singleton for single-store setups. +* **Automatic Migration** โ€“ Legacy settings auto-migrate to new accounts on upgrade. + +### Data Isolation + +* Each **Shopify Account** is tied to one **ERPNext Company**. +* Warehouses, customers, and transactions are scoped to the company. +* No data leakage between stores โ€” strict per-account mappings. + +--- + +## โš™๏ธ Configuration + +### 1. Multi-Tenant Setup *(Recommended)* + +#### Create a Shopify Account + +1. Go to **Shopify Account** in ERPNext. +2. Create a record with the following: + +**Basic Info** + +| Field | Example | Notes | +| ------------- | ----------------------- | ------------------------------- | +| Enabled | โœ“ | Activate this store integration | +| Account Title | Main Store KSA | Friendly label | +| Shop Domain | `mystore.myshopify.com` | Exact Shopify domain | +| API Version | `2023-10` | Auto-managed | + +**Credentials** + +| Field | Example | Notes | +| -------------- | ---------------- | --------------------------- | +| Access Token | `shpat_xxxxx` | From Shopify Admin API | +| Shared Secret | `webhook-secret` | Used for HMAC validation | +| Public App Key | optional | Only for specific app flows | + +**Company & Defaults** + +| Field | Example | Notes | +| ------------------ | -------------------- | ---------------------------- | +| Company | Your ERPNext Company | Required | +| Selling Price List | Standard Selling | Optional | +| Cost Center | Main โ€“ Company | Required if SI/DN sync is on | +| Default Customer | Walk-in Customer | Fallback | + +**Document Series** + +| SO | SI | DN | +| ---------- | ------------ | ---------- | +| `SO-SHOP-` | `SINV-SHOP-` | `DN-SHOP-` | + +**Feature Toggles** + +* Create Customers +* Create Missing Items +* Sync Sales Invoice +* Sync Delivery Note +* Allow Backdated Sync +* Close Orders on Fulfillment + +**Product Upload Settings** + +* Upload new ERPNext Items to Shopify +* Update Shopify Items on ERPNext changes +* Sync New Items as Active +* Upload Variants as Shopify Items + +**Inventory Sync** + +* Update ERPNext stock levels to Shopify +* Sync frequency (15min / 30min / Hourly / 6hrs / Daily) + +**Old Orders Sync** + +* One-time historical order sync with date range. + +--- + +#### Warehouse Mappings + +Map Shopify locations to ERPNext warehouses: + +1. Fetch locations from Shopify. +2. Map each location to an ERPNext warehouse (must belong to the same company). + +#### Tax Mappings + +Map Shopify tax/shipping titles to ERPNext accounts: + +* Shopify Tax/Shipping Title: e.g., `VAT` +* ERPNext Account: e.g., `VAT Payable โ€“ Company` + +--- + +### 2. Legacy Setup *(Deprecated)* + +* Only for existing single-store installations. +* Automatically migrated to **Shopify Account** during upgrade. + +--- + +## ๐Ÿ“ฆ Functional Areas + +### 1. Product Management + +* Bulk import via **Shopify Import Products** page. +* Account-aware upload and variant handling. +* SKU synchronization and price list integration. + +### 2. Order Processing + +* Webhook-driven real-time order creation. +* Auto-create customers with company isolation. +* Location-based inventory allocation. +* Tax mapping and shipping address handling. + +**Supported Events:** +`orders/create`, `orders/updated`, `orders/paid`, `orders/cancelled`, `orders/fulfilled`, `orders/partially_fulfilled` + +### 3. Fulfillment Management + +* Auto-create Delivery Notes. +* Sync tracking numbers to Shopify. +* Multi-location fulfillment support. + +### 4. Invoice Management + +* Auto-create Sales Invoices for paid orders. +* Company-specific tax mapping and payment terms. + +### 5. Inventory Synchronization + +* Configurable sync frequency. +* Multi-location tracking. +* Warehouse-specific stock updates. + +### 6. Customer Management + +* Per-account customer creation and address sync. +* Company-specific customer groups. + +--- + +## ๐Ÿ”” Webhook Handling + +### Automatic Setup + +* Enabled accounts auto-register required webhooks in Shopify. +* Events routed by `X-Shopify-Shop-Domain`. +* HMAC validation per account. + +**Monitored Events:** +`orders/create`, `orders/updated`, `orders/paid`, `orders/cancelled`, `orders/fulfilled`, `orders/partially_fulfilled`, `app/uninstalled` + +--- + +## ๐Ÿ“‘ Custom Fields + +**Item:** `shopify_selling_rate` +**Customer:** `shopify_customer_id` +**Supplier:** `shopify_supplier_id` +**Address:** `shopify_address_id` +**Sales Order:** `shopify_order_id`, `shopify_order_number`, `shopify_order_status` +**SO Item:** `shopify_discount_per_unit` +**Delivery Note:** `shopify_fulfillment_id` + +--- + +## ๐Ÿ›ก Data Isolation Rules + +1. **Warehouse must match account company.** +2. **Tax account must match account company.** +3. **Customers & transactions** created in the accountโ€™s company only. +4. **Series & numbering** respect the accountโ€™s settings. + +--- + +## ๐Ÿ”„ Migration from Legacy + +**Automatic Process:** + +1. Detect existing **Shopify Setting**. +2. Create equivalent **Shopify Account**. +3. Validate data integrity. +4. Keep legacy mode as fallback until fully retired. + +--- + +## ๐Ÿ›  API Reference (Python) + +```python +# Product +get_shopify_products(from_=None, account=None) +sync_product(product_id, account=None) +import_all_products(account=None) + +# Orders +sync_sales_order(order_data, account=None) +create_order(order_data, account=None) + +# Accounts +get_shopify_accounts() +validate_account(account_name) + +# Inventory +update_inventory_levels(account=None) +sync_stock_to_shopify(account=None) +``` + +--- + +## ๐Ÿงช Testing Guidelines + +* **Unit:** Function-level with mocks. +* **Integration:** End-to-end flows per account. +* **Multi-Tenant:** Two accounts โ†’ verify isolation. +* **Webhook:** Valid/invalid HMAC & domain routing. +* **Performance:** Large product/order datasets. + +--- + +## ๐Ÿ’ก Best Practices + +**Account Setup** + +* Use descriptive names. +* Map all locations and tax accounts. +* Test webhook connectivity before live. + +**Warehouse** + +* Keep consistent naming. +* Align warehouses to the accountโ€™s company. + +**Security** + +* Rotate tokens regularly. +* Monitor webhook logs. +* Use strong, unique secrets. + +**Performance** + +* Bulk operations for large catalogs. +* Tune inventory sync frequency. +* Monitor queue lengths. + +--- + +## ๐Ÿ“… Maintenance + +* Review integration logs weekly. +* Update Shopify API version periodically. +* Apply security patches promptly. +* Monitor API usage and sync times. + +--- + +## ๐Ÿ“š Support + +* **Docs:** This README + inline code comments. +* **Logs:** Integration Log, Error Log, Webhook Log. +* **Community:** ERPNext forums, GitHub Issues. +* **Professional:** ERPNext support or implementation partners. + +--- diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4a4c7c86d..1fa87c28a 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -12,29 +12,51 @@ from ecommerce_integrations.shopify.constants import ( API_VERSION, EVENT_MAPPER, - SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, WEBHOOK_EVENTS, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def temp_shopify_session(func): - """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns.""" +def temp_shopify_session(shopify_account=None): + """Decorator for functions that need a temporary Shopify session.""" + print("temp_shopify_session called with ", shopify_account) - @functools.wraps(func) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: + return func(*args, **kwargs) - setting = frappe.get_doc(SETTING_DOCTYPE) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + # If a callable is passed, call it with self to get the account + if shopify_account is None: + # TODO: handle if get_user_shopify_account returns None + account = get_user_shopify_account().name + else: + account = shopify_account(args[0]) if callable(shopify_account) else shopify_account - with Session.temp(*auth_details): - return func(*args, **kwargs) + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if setting.is_enabled(): + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + with Session.temp(*auth_details): + return func(*args, **kwargs) - return wrapper + return wrapper + + return decorator + + +def get_auth_details(setting) -> tuple[str, str, str]: + """Get authentication details for Shopify API.""" + # setting = frappe.get_doc(ACCOUNT_DOCTYPE, setting) + return setting.shopify_url, API_VERSION, setting.get_password("password") + + +def get_temp_session_context(setting): + """Get a temporary Shopify session context manager.""" + auth_details = get_auth_details(setting) + return Session.temp(*auth_details) def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: @@ -92,36 +114,37 @@ def get_callback_url() -> str: @frappe.whitelist(allow_guest=True) -def store_request_data() -> None: +def store_request_data(**kwargs) -> None: if frappe.request: hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + # Get shopify account + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + settings = frappe.get_doc(ACCOUNT_DOCTYPE, shop_domain) - _validate_request(frappe.request, hmac_header) + _validate_request(frappe.request, hmac_header, secret_key=settings.shared_secret) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") - process_request(data, event) + process_request(data, event, shopify_account=settings) -def process_request(data, event): +def process_request(data, event, shopify_account=None): + print("Processing webhook event: ", event, "\n", shopify_account) # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - - # enqueue backround job + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account) + print("log created") + # enqueue background job frappe.enqueue( method=EVENT_MAPPER[event], queue="short", timeout=300, is_async=True, - **{"payload": data, "request_id": log.name}, + **{"payload": data, "request_id": log.name, "shopify_account": shopify_account}, ) -def _validate_request(req, hmac_header): - settings = frappe.get_doc(SETTING_DOCTYPE) - secret_key = settings.shared_secret - +def _validate_request(req, hmac_header, secret_key): sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) if sig != bytes(hmac_header.encode()): diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index 47720e032..50fca8813 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -3,7 +3,8 @@ MODULE_NAME = "shopify" -SETTING_DOCTYPE = "Shopify Setting" +SETTING_DOCTYPE = "Shopify Setting" # Legacy singleton +ACCOUNT_DOCTYPE = "Shopify Account" # New multi-tenant account OLD_SETTINGS_DOCTYPE = "Shopify Settings" API_VERSION = "2024-01" diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 3a0ee952f..01a911e5b 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -9,24 +9,22 @@ ADDRESS_ID_FIELD, CUSTOMER_ID_FIELD, MODULE_NAME, - SETTING_DOCTYPE, ) +from ecommerce_integrations.shopify.utils import get_company_shopify_account class ShopifyCustomer(EcommerceCustomer): def __init__(self, customer_id: str): - self.setting = frappe.get_doc(SETTING_DOCTYPE) super().__init__(customer_id, CUSTOMER_ID_FIELD, MODULE_NAME) - def sync_customer(self, customer: dict[str, Any]) -> None: + def sync_customer(self, customer: dict[str, Any], customer_group: str) -> None: """Create Customer in ERPNext using shopify's Customer dict.""" customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name")) if len(customer_name.strip()) == 0: customer_name = customer.get("email") - customer_group = self.setting.customer_group - super().sync_customer(customer_name, customer_group) + super().sync_customer(customer_name, customer_group, company=customer.get("company")) billing_address = customer.get("billing_address", {}) or customer.get("default_address") shipping_address = customer.get("shipping_address", {}) diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_account/__init__.py similarity index 100% rename from ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py rename to ecommerce_integrations/shopify/doctype/shopify_account/__init__.py diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js similarity index 95% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index 45be645ac..cab7119cb 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -1,9 +1,9 @@ // Copyright (c) 2021, Frappe and contributors // For license information, please see LICENSE -frappe.provide("ecommerce_integrations.shopify.shopify_setting"); +frappe.provide("ecommerce_integrations.shopify.shopify_account"); -frappe.ui.form.on("Shopify Setting", { +frappe.ui.form.on("Shopify Account", { onload: function (frm) { frappe.call({ method: "ecommerce_integrations.utils.naming_series.get_series", diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json similarity index 97% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 01722169b..1103db19a 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -1,6 +1,7 @@ { "actions": [], - "creation": "2021-04-13 13:30:54.909583", + "autoname": "field:shopify_url", + "creation": "2024-01-01 00:00:00.000000", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -82,7 +83,8 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Shop URL", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify", + "unique": 1 }, { "fieldname": "column_break_3", @@ -153,9 +155,10 @@ { "fieldname": "company", "fieldtype": "Link", + "in_list_view": 1, "label": "Company", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Company" + "options": "Company", + "reqd": 1 }, { "fieldname": "cash_bank_account", @@ -390,12 +393,13 @@ } ], "index_web_pages_for_search": 1, - "issingle": 1, + "issingle": 0, "links": [], - "modified": "2023-10-24 10:38:49.247431", + "modified": "2024-01-01 00:00:00.000000", "modified_by": "Administrator", "module": "shopify", - "name": "Shopify Setting", + "name": "Shopify Account", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -413,4 +417,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py similarity index 91% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index dc974e70e..6b957c73e 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021, Frappe and contributors +# Copyright (c) 2024, Frappe and contributors # For license information, please see LICENSE import frappe @@ -31,12 +31,13 @@ ) -class ShopifySetting(SettingController): +class ShopifyAccount(SettingController): def is_enabled(self) -> bool: return bool(self.enable_shopify) def validate(self): - ensure_old_connector_is_disabled() + # TODO: uncomment + # ensure_old_connector_is_disabled() if self.shopify_url: self.shopify_url = self.shopify_url.replace("https://", "") @@ -79,7 +80,7 @@ def _initalize_default_values(self): self.last_inventory_sync = get_datetime("1970-01-01") @frappe.whitelist() - @connection.temp_shopify_session + @connection.temp_shopify_session(lambda self: self.shopify_url) def update_location_table(self): """Fetch locations from shopify and add it to child table so user can map it with correct ERPNext warehouse.""" @@ -92,6 +93,16 @@ def update_location_table(self): {"shopify_location_id": location.id, "shopify_location_name": location.name}, ) + def get_shopify_locations(self): + """Fetch locations from shopify and add it to child table so user can + map it with correct ERPNext warehouse.""" + result = [] + with connection.get_temp_session_context(self): + for locations in PaginatedIterator(Location.find()): + for location in locations: + result.append(location) + return result + def get_erpnext_warehouses(self) -> list[ERPNextWarehouse]: return [wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping] diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py new file mode 100644 index 000000000..8ccfc70da --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py @@ -0,0 +1,203 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see LICENSE + +import frappe +import unittest +from frappe.test_runner import make_test_records + +from ecommerce_integrations.shopify.doctype.shopify_account.shopify_account import ShopifyAccount + + +class TestShopifyAccount(unittest.TestCase): + def setUp(self): + """Set up test data.""" + make_test_records("Company") + + # Create a test company if it doesn't exist + if not frappe.db.exists("Company", "Test Company"): + company = frappe.get_doc({ + "doctype": "Company", + "company_name": "Test Company", + "default_currency": "USD", + "country": "United States" + }) + company.insert() + + def tearDown(self): + """Clean up test data.""" + # Delete test Shopify accounts + frappe.db.delete("Shopify Account", {"shop_domain": ["like", "%test%"]}) + + def test_shop_domain_validation(self): + """Test shop domain format validation.""" + # Test valid domain + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account.validate() # Should not raise any exception + + # Test invalid domain + account.shop_domain = "invalid-domain.com" + with self.assertRaises(frappe.ValidationError): + account.validate() + + # Test domain with https prefix (should be auto-corrected) + account.shop_domain = "https://test-store.myshopify.com" + account.validate() + self.assertEqual(account.shop_domain, "test-store.myshopify.com") + + def test_required_fields_when_enabled(self): + """Test required field validation when account is enabled.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "enabled": 1, + "account_title": "Test Store" + }) + + # Should fail validation due to missing required fields + with self.assertRaises(frappe.ValidationError): + account.validate() + + # Add required fields + account.shop_domain = "test-store.myshopify.com" + account.access_token = "test_token" + account.shared_secret = "test_secret" + account.company = "Test Company" + + # Should pass validation now + account.validate() + + def test_company_consistency_validation(self): + """Test that all company-related fields belong to the same company.""" + # Create test cost center + if not frappe.db.exists("Cost Center", "Test Cost Center - TC"): + cost_center = frappe.get_doc({ + "doctype": "Cost Center", + "cost_center_name": "Test Cost Center", + "company": "Test Company", + "parent_cost_center": "Test Company - TC" + }) + cost_center.insert() + + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "cost_center": "Test Cost Center - TC" + }) + + # Should pass validation + account.validate() + + def test_warehouse_mapping_validation(self): + """Test warehouse mapping validation.""" + # Create test warehouse + if not frappe.db.exists("Warehouse", "Test Warehouse - TC"): + warehouse = frappe.get_doc({ + "doctype": "Warehouse", + "warehouse_name": "Test Warehouse", + "company": "Test Company" + }) + warehouse.insert() + + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + + # Add warehouse mapping + account.append("warehouse_mappings", { + "shopify_location_id": "12345", + "shopify_location_name": "Test Location", + "erpnext_warehouse": "Test Warehouse - TC" + }) + + # Should pass validation + account.validate() + + def test_duplicate_shop_domain(self): + """Test that shop domains must be unique.""" + # Create first account + account1 = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store 1", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account1.insert() + + # Try to create second account with same domain + account2 = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store 2", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + + # Should fail due to unique constraint + with self.assertRaises(frappe.DuplicateEntryError): + account2.insert() + + def test_static_methods(self): + """Test static utility methods.""" + # Create a test account + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "enabled": 1 + }) + account.insert() + + # Test get_account_by_domain + found_account = ShopifyAccount.get_account_by_domain("test-store.myshopify.com") + self.assertEqual(found_account.name, account.name) + + # Test get_enabled_accounts + enabled_accounts = ShopifyAccount.get_enabled_accounts() + self.assertTrue(any(acc.name == account.name for acc in enabled_accounts)) + + def test_sync_status_update(self): + """Test sync status update functionality.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account.insert() + + # Test status update + account.update_sync_status("Success") + self.assertEqual(account.last_sync_status, "Success") + self.assertIsNotNone(account.last_sync_at) + + # Test invalid status + with self.assertRaises(frappe.ValidationError): + account.update_sync_status("InvalidStatus") + + def test_helper_methods(self): + """Test helper methods.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "enabled": 1 + }) + + # Test is_enabled + self.assertTrue(account.is_enabled()) + + account.enabled = 0 + self.assertFalse(account.is_enabled()) + + # Test get_shop_url + self.assertEqual(account.get_shop_url(), "https://test-store.myshopify.com") diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py deleted file mode 100644 index ab05370b9..000000000 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) 2021, Frappe and Contributors -# See LICENSE - -import unittest - -import frappe - -from ecommerce_integrations.shopify.constants import ( - ADDRESS_ID_FIELD, - CUSTOMER_ID_FIELD, - FULLFILLMENT_ID_FIELD, - ITEM_SELLING_RATE_FIELD, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SUPPLIER_ID_FIELD, -) - -from .shopify_setting import setup_custom_fields - - -class TestShopifySetting(unittest.TestCase): - @classmethod - def setUpClass(cls): - frappe.db.sql( - """delete from `tabCustom Field` - where name like '%shopify%'""" - ) - - def test_custom_field_creation(self): - setup_custom_fields() - - created_fields = frappe.get_all( - "Custom Field", - filters={"fieldname": ["LIKE", "%shopify%"]}, - fields="fieldName", - as_list=True, - order_by=None, - ) - - required_fields = set( - [ - ADDRESS_ID_FIELD, - CUSTOMER_ID_FIELD, - FULLFILLMENT_ID_FIELD, - ITEM_SELLING_RATE_FIELD, - ORDER_ID_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SUPPLIER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ] - ) - - self.assertGreaterEqual(len(created_fields), 13) - created_fields_set = {d[0] for d in created_fields} - - self.assertEqual(created_fields_set, required_fields) diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 5ffc0ebae..0d336e61d 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -8,28 +8,28 @@ FULLFILLMENT_ID_FIELD, ORDER_ID_FIELD, ORDER_NUMBER_FIELD, - SETTING_DOCTYPE, + # SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.order import get_sales_order from ecommerce_integrations.shopify.utils import create_shopify_log -def prepare_delivery_note(payload, request_id=None): +def prepare_delivery_note(payload, request_id=None, shopify_account=None): frappe.set_user("Administrator") - setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id order = payload try: sales_order = get_sales_order(cstr(order["id"])) + shopify_account_name = shopify_account.name if shopify_account else None if sales_order: - create_delivery_note(order, setting, sales_order) - create_shopify_log(status="Success") + create_delivery_note(order, shopify_account, sales_order) + create_shopify_log(status="Success", shopify_account=shopify_account_name) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.") + create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.", shopify_account=shopify_account_name) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) def create_delivery_note(shopify_order, setting, so): @@ -49,7 +49,7 @@ def create_delivery_note(shopify_order, setting, so): dn.posting_date = getdate(fulfillment.get("created_at")) dn.naming_series = setting.delivery_note_series or "DN-Shopify-" dn.items = get_fulfillment_items( - dn.items, fulfillment.get("line_items"), fulfillment.get("location_id") + dn.items, fulfillment.get("line_items"), setting, fulfillment.get("location_id") ) dn.flags.ignore_mandatory = True dn.save() @@ -59,13 +59,12 @@ def create_delivery_note(shopify_order, setting, so): dn.add_comment(text=f"Order Note: {shopify_order.get('note')}") -def get_fulfillment_items(dn_items, fulfillment_items, location_id=None): +def get_fulfillment_items(dn_items, fulfillment_items, setting, location_id=None): # local import to avoid circular imports from ecommerce_integrations.shopify.product import get_item_code fulfillment_items = deepcopy(fulfillment_items) - setting = frappe.get_cached_doc(SETTING_DOCTYPE) wh_map = setting.get_integration_to_erpnext_wh_mapping() warehouse = wh_map.get(str(location_id)) or setting.warehouse diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 526107dd3..f793a9e2f 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -10,8 +10,8 @@ update_inventory_sync_status, ) from ecommerce_integrations.controllers.scheduling import need_to_run -from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE +from ecommerce_integrations.shopify.connection import get_temp_session_context +from ecommerce_integrations.shopify.constants import MODULE_NAME, ACCOUNT_DOCTYPE from ecommerce_integrations.shopify.utils import create_shopify_log @@ -20,23 +20,29 @@ def update_inventory_on_shopify() -> None: Called by scheduler on configured interval. """ - setting = frappe.get_doc(SETTING_DOCTYPE) + all_accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enable_shopify": 1, "update_erpnext_stock_levels_to_shopify": 1}, + pluck="name", + ) + for account in all_accounts: + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: + continue - if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: - return + if not need_to_run(ACCOUNT_DOCTYPE, setting.name, "inventory_sync_frequency", "last_inventory_sync"): + continue - if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): - return - warehous_map = setting.get_erpnext_to_integration_wh_mapping() - inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) + warehous_map = setting.get_erpnext_to_integration_wh_mapping() + inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) - if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map) + if inventory_levels: + with get_temp_session_context(setting): + upload_inventory_data_to_shopify(inventory_levels, warehous_map, setting) -@temp_shopify_session -def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: +def upload_inventory_data_to_shopify(inventory_levels, warehous_map, setting) -> None: synced_on = now() for inventory_sync_batch in create_batch(inventory_levels, 50): @@ -65,10 +71,10 @@ def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: frappe.db.commit() - _log_inventory_update_status(inventory_sync_batch) + _log_inventory_update_status(inventory_sync_batch, setting) -def _log_inventory_update_status(inventory_levels) -> None: +def _log_inventory_update_status(inventory_levels, setting) -> None: """Create log of inventory update.""" log_message = "variant_id,location_id,status,failure_reason\n" @@ -90,4 +96,4 @@ def _log_inventory_update_status(inventory_levels) -> None: 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) + create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message, shopify_account=setting.name) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index f841cb416..14189e7b2 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -5,29 +5,29 @@ from ecommerce_integrations.shopify.constants import ( ORDER_ID_FIELD, ORDER_NUMBER_FIELD, - SETTING_DOCTYPE, + # SETTING_DOCTYPE, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def prepare_sales_invoice(payload, request_id=None): +def prepare_sales_invoice(payload, request_id=None, shopify_account=None): from ecommerce_integrations.shopify.order import get_sales_order order = payload frappe.set_user("Administrator") - setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id try: sales_order = get_sales_order(cstr(order["id"])) + shopify_account_name = shopify_account.name if shopify_account else None if sales_order: - create_sales_invoice(order, setting, sales_order) - create_shopify_log(status="Success") + create_sales_invoice(order, shopify_account, sales_order) + create_shopify_log(status="Success", shopify_account=shopify_account_name) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.") + create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.", shopify_account=shopify_account_name) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) def create_sales_invoice(shopify_order, setting, so): diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 0570d035b..cf413ca7e 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -7,7 +7,7 @@ from shopify.collection import PaginatedIterator from shopify.resources import Order -from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.connection import get_temp_session_context from ecommerce_integrations.shopify.constants import ( CUSTOMER_ID_FIELD, EVENT_MAPPER, @@ -15,11 +15,12 @@ ORDER_ITEM_DISCOUNT_FIELD, ORDER_NUMBER_FIELD, ORDER_STATUS_FIELD, - SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, + # SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.customer import ShopifyCustomer from ecommerce_integrations.shopify.product import create_items_if_not_exist, get_item_code -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account from ecommerce_integrations.utils.price_list import get_dummy_price_list from ecommerce_integrations.utils.taxation import get_dummy_tax_category @@ -29,13 +30,18 @@ } -def sync_sales_order(payload, request_id=None): +def sync_sales_order(payload, request_id=None, shopify_account=None): order = payload + frappe.set_user("Administrator") frappe.flags.request_id = request_id + + if isinstance(shopify_account, str): + shopify_account = frappe.get_doc("Shopify Account", shopify_account) + shopify_account_name = shopify_account.name if shopify_account else None if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): - create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + create_shopify_log(status="Invalid", message="Sales order already exists, not synced", shopify_account=shopify_account_name) return try: shopify_customer = order.get("customer") if order.get("customer") is not None else {} @@ -44,19 +50,20 @@ def sync_sales_order(payload, request_id=None): customer_id = shopify_customer.get("id") if customer_id: customer = ShopifyCustomer(customer_id=customer_id) + # Add company to shopify_customer for multi-company setups + shopify_customer['company'] = shopify_account.company if not customer.is_synced(): - customer.sync_customer(customer=shopify_customer) + customer.sync_customer(customer=shopify_customer, customer_group=shopify_account.customer_group) else: customer.update_existing_addresses(shopify_customer) - create_items_if_not_exist(order) + create_items_if_not_exist(order, company=shopify_account.company) - setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) + create_order(order, shopify_account) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) else: - create_shopify_log(status="Success") + create_shopify_log(status="Success", shopify_account=shopify_account_name) def create_order(order, setting, company=None): @@ -97,7 +104,7 @@ def create_sales_order(shopify_order, setting, company=None): product_not_exists = [] # TODO: fix missing items message += "\n" + ", ".join(product_not_exists) - create_shopify_log(status="Error", exception=message, rollback=True) + create_shopify_log(status="Error", exception=message, rollback=True, shopify_account=setting.name) return "" @@ -203,9 +210,9 @@ def get_order_taxes(shopify_order, setting, items): taxes.append( { "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "account_head": get_tax_account_head(tax, setting, charge_type="sales_tax"), "description": ( - get_tax_account_description(tax) + get_tax_account_description(tax, setting) or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" ), "tax_amount": tax.get("price"), @@ -259,17 +266,17 @@ def consolidate_order_taxes(taxes): return tax_account_wise_data.values() -def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | None = None): +def get_tax_account_head(tax, setting, charge_type: Literal["shipping", "sales_tax"] | None = None): tax_title = str(tax.get("title")) tax_account = frappe.db.get_value( "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + {"parent": setting.name, "shopify_tax": tax_title}, "tax_account", ) if not tax_account and charge_type: - tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) + tax_account = frappe.db.get_value(ACCOUNT_DOCTYPE, setting.name, DEFAULT_TAX_FIELDS[charge_type]) if not tax_account: frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) @@ -277,12 +284,12 @@ def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | No return tax_account -def get_tax_account_description(tax): +def get_tax_account_description(tax, setting): tax_title = tax.get("title") tax_description = frappe.db.get_value( "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + {"parent": setting.name, "shopify_tax": tax_title}, "tax_description", ) @@ -321,7 +328,7 @@ def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxe { "charge_type": "Actual", "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) + "description": get_tax_account_description(shipping_charge, setting) or shipping_charge["title"], "tax_amount": shipping_charge_amount, "cost_center": setting.cost_center, @@ -334,7 +341,7 @@ def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxe "charge_type": "Actual", "account_head": get_tax_account_head(tax, charge_type="sales_tax"), "description": ( - get_tax_account_description(tax) + get_tax_account_description(tax, setting) or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" ), "tax_amount": tax["price"], @@ -356,7 +363,7 @@ def get_sales_order(order_id): return frappe.get_doc("Sales Order", sales_order) -def cancel_order(payload, request_id=None): +def cancel_order(payload, request_id=None, shopify_account=None): """Called by order/cancelled event. When shopify order is cancelled there could be many different someone handles it. @@ -369,6 +376,7 @@ def cancel_order(payload, request_id=None): frappe.flags.request_id = request_id order = payload + shopify_account_name = shopify_account.name if shopify_account else None try: order_id = order["id"] @@ -377,7 +385,7 @@ def cancel_order(payload, request_id=None): sales_order = get_sales_order(order_id) if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") + create_shopify_log(status="Invalid", message="Sales Order does not exist", shopify_account=shopify_account_name) return sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) @@ -395,28 +403,32 @@ def cancel_order(payload, request_id=None): frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) except Exception as e: - create_shopify_log(status="Error", exception=e) + create_shopify_log(status="Error", exception=e, shopify_account=shopify_account_name) else: - create_shopify_log(status="Success") + create_shopify_log(status="Success", shopify_account=shopify_account_name) -@temp_shopify_session def sync_old_orders(): - shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) - if not cint(shopify_setting.sync_old_orders): - return - - orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) + all_accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enable_shopify": 1, "sync_old_orders": 1}, + pluck="name", + ) + for account in all_accounts: + shopify_setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if not cint(shopify_setting.sync_old_orders): + continue - for order in orders: - log = create_shopify_log( - method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True - ) - sync_sales_order(order, request_id=log.name) + with get_temp_session_context(shopify_setting): + orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) + for order in orders: + log = create_shopify_log( + method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True, shopify_account=shopify_setting.name + ) + sync_sales_order(order, request_id=log.name, setting=shopify_setting) - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) - shopify_setting.sync_old_orders = 0 - shopify_setting.save() + shopify_setting.sync_old_orders = 0 + shopify_setting.save() def _fetch_old_orders(from_time, to_time): diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 62db3c216..664e564f2 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -30,8 +30,14 @@ shopify.ProductImporter = class { } async checkSyncStatus() { + // TODO: RQ Job is a virtual doctype and not persisted in the mariadb, should we get the result from redis?? + // frappe.call({ + // method: "ecommerce_integrations.shopify.utils.get_jobs" + // }) + // .then(r => console.log("result", r)) + // .catch(e => console.log("error", e)) const jobs = await frappe.db.get_list("RQ Job", { - filters: { status: ("in", ("queued", "started")) }, + filters: { status: ["in", ["queued", "started"]] }, }); this.syncRunning = jobs.find( @@ -111,7 +117,7 @@ shopify.ProductImporter = class { this.wrapper.find("#count-products-erpnext").text(erpnextCount); this.wrapper.find("#count-products-synced").text(syncedCount); } catch (error) { - frappe.throw(__("Error fetching product count.")); + frappe.throw(__(`Error fetching product count.\n ${error}`)); } } 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..275a53567 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 @@ -8,6 +8,7 @@ from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import MODULE_NAME from ecommerce_integrations.shopify.product import ShopifyProduct +from ecommerce_integrations.shopify.utils import get_user_company # constants SYNC_JOB_NAME = "shopify.job.sync.all.products" @@ -46,7 +47,7 @@ def fetch_all_products(from_=None): } -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def _fetch_products_from_shopify(from_=None, limit=20): if from_: collection = Product.find(from_=from_) @@ -73,7 +74,7 @@ def get_product_count(): } -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def get_shopify_product_count(): return Product.count() @@ -81,7 +82,7 @@ def get_shopify_product_count(): @frappe.whitelist() def sync_product(product): try: - shopify_product = ShopifyProduct(product) + shopify_product = ShopifyProduct(product, company=get_user_company(frappe.session.user)) shopify_product.sync_product() return True @@ -95,7 +96,7 @@ def resync_product(product): return _resync_product(product) -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def _resync_product(product): savepoint = "shopify_resync_product" try: @@ -103,7 +104,7 @@ def _resync_product(product): frappe.db.savepoint(savepoint) for variant in item.variants: - shopify_product = ShopifyProduct(product, variant_id=variant.id) + shopify_product = ShopifyProduct(product, variant_id=variant.id, company=get_user_company(frappe.session.user)) shopify_product.sync_product() return True @@ -147,7 +148,7 @@ def queue_sync_all_products(*args, **kwargs): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id) + shopify_product = ShopifyProduct(product.id, company=get_user_company(kwargs.get("user"))) shopify_product.sync_product() publish(f"โœ… Synced Product {product.id}", synced=True) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..8e351382f 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -7,16 +7,15 @@ 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.connection import get_temp_session_context 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, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_company_shopify_account, get_user_shopify_account class ShopifyProduct: @@ -26,13 +25,14 @@ def __init__( variant_id: str | None = None, sku: str | None = None, has_variants: int | None = 0, + company: str | 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.company = company + self.setting = get_company_shopify_account(company) if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -53,12 +53,12 @@ def get_erpnext_item(self): 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) + with get_temp_session_context(self.setting): + 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) @@ -73,6 +73,7 @@ def _make_item(self, product_dict): else: product_dict["variant_id"] = product_dict["variants"][0]["id"] + price = product_dict.get("variants", [{'price': None}])[0].get("price") self._create_item(product_dict, warehouse) def _create_attribute(self, product_dict): @@ -121,6 +122,8 @@ def _set_new_attribute_values(self, item_attr, 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): + price = product_dict.get("price") if variant_of else product_dict.get("variants", [{'price': None}])[0].get("price") + item_dict = { "variant_of": variant_of, "is_stock_item": 1, @@ -137,8 +140,12 @@ 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), + "shopify_selling_rate": price, } + if self.company: + item_dict["custom_company"] = self.company + 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"] @@ -174,6 +181,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"), + "price": variant.get("price"), } for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): @@ -211,7 +219,10 @@ def _get_item_group(self, product_type=None): "parent_item_group": parent_item_group, "is_group": "No", } - ).insert() + ) + if self.company: + item_group["custom_company"] = self.company + item_group = item_group.insert() return item_group.name def _get_supplier(self, product_dict): @@ -301,13 +312,13 @@ def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, return False -def create_items_if_not_exist(order): +def create_items_if_not_exist(order, company): """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) + product = ShopifyProduct(product_id, company=company, variant_id=variant_id, sku=sku) if not product.is_synced(): product.sync_product() @@ -328,7 +339,6 @@ def get_item_code(shopify_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`. @@ -340,7 +350,15 @@ def upload_erpnext_item(doc, method=None): if item.flags.from_integration: return - setting = frappe.get_doc(SETTING_DOCTYPE) + # TODO: Handle if doc.custom_company is None + if doc.hasattr("custom_company"): + setting = get_company_shopify_account(company=doc.custom_company) + else: + setting = get_user_shopify_account() + + if not setting: + # msgprint(_("Could not find Shopify Account for uploading item.")) + return if not setting.is_enabled() or not setting.upload_erpnext_items: return @@ -369,104 +387,105 @@ def upload_erpnext_item(doc, method=None): ) 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() + with get_temp_session_context(setting): + if is_new_product: + product = Product() + product.published = False + product.status = "active" if setting.sync_new_item_as_active else "draft" - 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: + 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, - 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( + 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( { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), + "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, } ) - 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) + ecom_item.insert() + + write_upload_log(status=is_successful, product=product, item=item, shopify_account=setting.name) + 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" + ), + } ) - product.variants.append(Variant(variant_attributes)) + 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) + 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") + write_upload_log(status=is_successful, product=product, item=item, action="Updated", shopify_account=setting.name) def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): @@ -549,7 +568,7 @@ def update_default_variant_properties( default_variant.sku = sku -def write_upload_log(status: bool, product: Product, item, action="Created") -> None: +def write_upload_log(status: bool, product: Product, item, action="Created", shopify_account=None) -> None: if not status: msg = _("Failed to upload item to Shopify") + "
" msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) @@ -560,6 +579,7 @@ def write_upload_log(status: bool, product: Product, item, action="Created") -> request_data=product.to_dict(), message=msg, method="upload_erpnext_item", + shopify_account=shopify_account, ) else: create_shopify_log( @@ -567,4 +587,5 @@ def write_upload_log(status: bool, product: Product, item, action="Created") -> request_data=product.to_dict(), message=f"{action} Item: {item.name}, shopify product: {product.id}", method="upload_erpnext_item", + shopify_account=shopify_account, ) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 06bf1f582..815fc4fa7 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -9,11 +9,46 @@ ) from ecommerce_integrations.shopify.constants import ( MODULE_NAME, - OLD_SETTINGS_DOCTYPE, - SETTING_DOCTYPE, + # OLD_SETTINGS_DOCTYPE, + # SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, ) +@frappe.whitelist() +def get_jobs(): + return frappe.utils.background_jobs.get_jobs(site=frappe.local.site) + + +def get_user_company(user): + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + return company_id + return None + +def get_user_shopify_account(): + user = frappe.session.user + print("get_user_shopify_account called for user ", user) + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + return get_company_shopify_account(company_id) + return None + + +def get_company_shopify_account(company): + try: + sa_exists = frappe.db.exists("Shopify Account", {"company": company}) + if sa_exists: + account = frappe.get_doc("Shopify Account", sa_exists) + return account + return None + except Exception as e: + return None + def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) @@ -38,16 +73,17 @@ def migrate_from_old_connector(payload=None, request_id=None): def ensure_old_connector_is_disabled(): - try: - old_setting = frappe.get_doc(OLD_SETTINGS_DOCTYPE) - except Exception: - frappe.clear_last_message() - return + # try: + # old_setting = frappe.get_doc(OLD_SETTINGS_DOCTYPE) + # except Exception: + # frappe.clear_last_message() + # return - if old_setting.enable_shopify: - link = frappe.utils.get_link_to_form(OLD_SETTINGS_DOCTYPE, OLD_SETTINGS_DOCTYPE) - msg = _("Please disable old Shopify integration from {0} to proceed.").format(link) - frappe.throw(msg) + # if old_setting.enable_shopify: + # link = frappe.utils.get_link_to_form(OLD_SETTINGS_DOCTYPE, OLD_SETTINGS_DOCTYPE) + # msg = _("Please disable old Shopify integration from {0} to proceed.").format(link) + # frappe.throw(msg) + pass def _migrate_items_to_ecommerce_item(log): @@ -66,8 +102,8 @@ def _migrate_items_to_ecommerce_item(log): log.traceback = frappe.get_traceback() log.save() return - - frappe.db.set_value(SETTING_DOCTYPE, SETTING_DOCTYPE, "is_old_data_migrated", 1) + account_name = get_user_shopify_account().name + frappe.db.set_value(ACCOUNT_DOCTYPE, account_name, "is_old_data_migrated", 1) log.status = "Success" log.save() diff --git a/ecommerce_integrations/unicommerce/inventory.py b/ecommerce_integrations/unicommerce/inventory.py index d8f6961de..426a0917a 100644 --- a/ecommerce_integrations/unicommerce/inventory.py +++ b/ecommerce_integrations/unicommerce/inventory.py @@ -10,13 +10,25 @@ ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.unicommerce.api_client import UnicommerceAPIClient -from ecommerce_integrations.unicommerce.constants import MODULE_NAME, SETTINGS_DOCTYPE +from ecommerce_integrations.unicommerce.constants import MODULE_NAME, ACCOUNT_DOCTYPE # Note: Undocumented but currently handles ~1000 inventory changes in one request. # Remaining to be done in next interval. MAX_INVENTORY_UPDATE_IN_REQUEST = 1000 +def get_user_shopify_account(): + user = frappe.session.user + print("get_user_shopify_account called for user ", user) + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + account = frappe.get_doc("Shopify Account", {"company": company_id}) + return account + return None + + def update_inventory_on_unicommerce(client=None, force=False): """Update ERPnext warehouse wise inventory to Unicommerce. @@ -25,13 +37,13 @@ def update_inventory_on_unicommerce(client=None, force=False): force=True ignores the set frequency. """ - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + settings = get_user_shopify_account() if not settings.is_enabled() or not settings.enable_inventory_sync: return # check if need to run based on configured sync frequency - if not force and not need_to_run(SETTINGS_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): + if not force and not need_to_run(ACCOUNT_DOCTYPE, settings, "inventory_sync_frequency", "last_inventory_sync"): return # get configured warehouses