diff --git a/shopify_integration/orders.py b/shopify_integration/orders.py index f37121d..6acf707 100644 --- a/shopify_integration/orders.py +++ b/shopify_integration/orders.py @@ -3,16 +3,22 @@ import frappe from frappe.utils import flt, getdate, nowdate -from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log +from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import ( + make_shopify_log, +) from shopify_integration.utils import get_shopify_document, get_tax_account_head if TYPE_CHECKING: from erpnext.selling.doctype.sales_order.sales_order import SalesOrder from shopify import LineItem, Order - from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ( + ShopifySettings, + ) -def create_shopify_documents(shop_name: str, order_id: str, log_id: str = str()): +def create_shopify_documents( + shop_name: str, order_id: str, log_id: str = str(), amended_from: str = str() +): """ Create the following from a Shopify order: @@ -26,6 +32,7 @@ def create_shopify_documents(shop_name: str, order_id: str, log_id: str = str()) order_id (str): The Shopify order ID. log_id (str, optional): The ID of an existing Shopify Log. Defaults to an empty string. + amended_from (str, optional): The name of the original cancelled Sales Order. """ from shopify_integration.fulfilments import create_shopify_delivery @@ -38,7 +45,7 @@ def create_shopify_documents(shop_name: str, order_id: str, log_id: str = str()) if not order: return - sales_order = create_shopify_order(shop_name, order, log_id) + sales_order = create_shopify_order(shop_name, order, log_id, amended_from) if sales_order: create_shopify_invoice(shop_name, order, sales_order, log_id) create_shopify_delivery(shop_name, order, sales_order, log_id) @@ -62,7 +69,12 @@ def get_shopify_order(shop_name: str, order_id: str, log_id: str = str()): return order -def create_shopify_order(shop_name: str, shopify_order: "Order", log_id: str = str()): +def create_shopify_order( + shop_name: str, + shopify_order: "Order", + log_id: str = str(), + amended_from: str = str(), +): """ Create a Sales Order document for a Shopify order. @@ -71,6 +83,7 @@ def create_shopify_order(shop_name: str, shopify_order: "Order", log_id: str = s shopify_order (Order): The Shopify order data. log_id (str, optional): The ID of an existing Shopify Log. Defaults to an empty string. + amended_from (str, optional): The name of the original cancelled Sales Order. Returns: SalesOrder: The created Sales Order document, if any, otherwise None. @@ -81,30 +94,68 @@ def create_shopify_order(shop_name: str, shopify_order: "Order", log_id: str = s frappe.flags.log_id = log_id - existing_so = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=shopify_order) - if existing_so: + if existing_so := get_shopify_document( + shop_name=shop_name, doctype="Sales Order", order=shopify_order + ): existing_so: "SalesOrder" - make_shopify_log(shop_name, status="Skipped", response_data=shopify_order.to_dict()) + make_shopify_log( + shop_name, status="Skipped", response_data=shopify_order.to_dict() + ) return existing_so try: validate_customer(shop_name, shopify_order) validate_items(shop_name, shopify_order) - sales_order = create_sales_order(shop_name, shopify_order) + sales_order = create_sales_order( + shop_name, shopify_order, amended_from=amended_from + ) except Exception as e: - make_shopify_log(shop_name, status="Error", response_data=shopify_order.to_dict(), exception=e) + make_shopify_log( + shop_name, + status="Error", + response_data=shopify_order.to_dict(), + exception=e, + ) else: - make_shopify_log(shop_name, status="Success", response_data=shopify_order.to_dict()) + make_shopify_log( + shop_name, status="Success", response_data=shopify_order.to_dict() + ) return sales_order -def create_sales_order(shop_name: str, shopify_order: "Order"): +def update_shopify_order(shop_name: str, order_id: str, log_id: str = str()): + """ + Webhook endpoint to process changes in a Shopify order. + + Instead of updating the existing documents, cancel them and create a new series of + sales documents for the Shopify order. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + order_id (str): The Shopify order ID. + log_id (str, optional): The ID of an existing Shopify Log. Defaults + to an empty string. + """ + + if existing_so := get_shopify_document( + shop_name=shop_name, doctype="Sales Order", order_id=order_id + ): + cancel_shopify_order(shop_name, order_id, log_id) + create_shopify_documents( + shop_name, order_id, log_id, amended_from=existing_so.name + ) + + +def create_sales_order( + shop_name: str, shopify_order: "Order", *, amended_from: str = str() +): """ Helper function to create a Sales Order document for a Shopify order. Args: shop_name (str): The name of the Shopify configuration for the store. shopify_order (Order): The Shopify order data. + amended_from (str, optional): The name of the original cancelled Sales Order. Returns: SalesOrder: The created Sales Order document, if any, otherwise None. @@ -112,25 +163,34 @@ def create_sales_order(shop_name: str, shopify_order: "Order"): shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) shopify_customer = shopify_order.attributes.get("customer", frappe._dict()) - customer = frappe.db.get_value("Customer", {"shopify_customer_id": shopify_customer.id}, "name") - - sales_order: "SalesOrder" = frappe.get_doc({ - "doctype": "Sales Order", - "naming_series": shopify_settings.sales_order_series or "SO-Shopify-", - "shopify_settings": shopify_settings.name, - "shopify_order_id": shopify_order.id, - "shopify_order_number": shopify_order.attributes.get("order_number"), - "customer": customer or shopify_settings.default_customer, - "transaction_date": getdate(shopify_order.attributes.get("created_at")), - "delivery_date": getdate(shopify_order.attributes.get("created_at")), - "company": shopify_settings.company, - "selling_price_list": shopify_settings.price_list, - "ignore_pricing_rule": 1, - "items": get_order_items(shopify_order.attributes.get("line_items", []), shopify_settings), - "taxes": get_order_taxes(shopify_order, shopify_settings), - "apply_discount_on": "Grand Total", - "discount_amount": flt(shopify_order.attributes.get("total_discounts")), - }) + customer = frappe.db.get_value( + "Customer", {"shopify_customer_id": shopify_customer.id}, "name" + ) + + sales_order: "SalesOrder" = frappe.get_doc( + { + "doctype": "Sales Order", + "naming_series": shopify_settings.sales_order_series or "SO-Shopify-", + "shopify_settings": shopify_settings.name, + "shopify_order_id": shopify_order.id, + "shopify_order_number": shopify_order.attributes.get("order_number"), + "customer": customer or shopify_settings.default_customer, + "transaction_date": getdate(shopify_order.attributes.get("created_at")), + "delivery_date": getdate(shopify_order.attributes.get("created_at")), + "company": shopify_settings.company, + "selling_price_list": shopify_settings.price_list, + "ignore_pricing_rule": 1, + "items": get_order_items( + shopify_order.attributes.get("line_items", []), shopify_settings + ), + "taxes": get_order_taxes(shopify_order, shopify_settings), + "apply_discount_on": "Grand Total", + "discount_amount": flt( + shopify_order.attributes.get("current_total_discounts") + ), + "amended_from": amended_from, + } + ) sales_order.flags.ignore_mandatory = True sales_order.save(ignore_permissions=True) @@ -142,59 +202,97 @@ def create_sales_order(shop_name: str, shopify_order: "Order"): def get_order_items( shopify_order_items: List["LineItem"], shopify_settings: "ShopifySettings" ): - from shopify_integration.products import get_item_code - items = [] for shopify_item in shopify_order_items: - item_code = get_item_code(shopify_item) - item_group = ( - frappe.db.get_value("Item", item_code, "item_group") - or shopify_settings.item_group - ) - - items.append({ - "item_code": item_code, - "item_name": shopify_item.attributes.get("name"), - "item_group": item_group, - "rate": shopify_item.attributes.get("price"), - "delivery_date": nowdate(), - "qty": shopify_item.attributes.get("quantity"), - "stock_uom": shopify_item.attributes.get("uom") - or frappe.db.get_single_value("Stock Settings", "stock_uom"), - "conversion_factor": 1, - "warehouse": shopify_settings.warehouse, - }) + items.append(get_order_item(shopify_item, shopify_settings)) return items +def get_order_item(shopify_item: "LineItem", shopify_settings: "ShopifySettings"): + from shopify_integration.products import get_item_code + + item_code = get_item_code(shopify_item) + item_group = ( + frappe.db.get_value("Item", item_code, "item_group") + or shopify_settings.item_group + ) + + stock_uom = shopify_item.attributes.get("uom") or frappe.db.get_single_value( + "Stock Settings", "stock_uom" + ) + + return { + "shopify_order_item_id": str(shopify_item.id), + "item_code": item_code, + "item_name": shopify_item.attributes.get("name"), + "item_group": item_group, + "rate": flt(shopify_item.attributes.get("price")), + # TODO: if items with discounts are edited, Shopify's API doesn't have an easy + # way to get the discount amount + # "discount_amount": flt(shopify_item.attributes.get("total_discount")), + "delivery_date": nowdate(), + "qty": flt(shopify_item.attributes.get("fulfillable_quantity")), + "stock_uom": stock_uom, + "conversion_factor": 1, + "warehouse": shopify_settings.warehouse, + } + + def get_order_taxes(shopify_order: "Order", shopify_settings: "ShopifySettings"): taxes = [] # add shipping charges for shipping in shopify_order.attributes.get("shipping_lines"): if shipping.attributes.get("price"): - taxes.append({ - "charge_type": "Actual", - "account_head": get_tax_account_head(shopify_settings.name, "shipping"), - "description": shipping.attributes.get("title"), - "tax_amount": shipping.attributes.get("price"), - "cost_center": shopify_settings.cost_center - }) + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head( + shopify_settings.name, "shipping" + ), + "description": shipping.attributes.get("title"), + "tax_amount": shipping.attributes.get("price"), + "cost_center": shopify_settings.cost_center, + } + ) # add additional taxes and fees for tax in shopify_order.attributes.get("tax_lines"): - tax_description = ( - f'{tax.attributes.get("title")} - {tax.attributes.get("rate") * 100.0}%' + title = tax.attributes.get("title") + if rate := tax.attributes.get("rate"): + tax_description = f"{title} - {rate * 100.0}%" + else: + tax_description = title + + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shopify_settings.name, "tax"), + "description": tax_description, + "tax_amount": flt(tax.attributes.get("price")), + "cost_center": shopify_settings.cost_center, + "included_in_print_rate": shopify_order.attributes.get( + "taxes_included" + ), + } ) - taxes.append({ - "charge_type": "Actual", - "account_head": get_tax_account_head(shopify_settings.name, "tax"), - "description": tax_description, - "tax_amount": tax.attributes.get("price"), - "cost_center": shopify_settings.cost_center, - "included_in_print_rate": shopify_order.attributes.get("taxes_included") - }) + # TODO: Shopify's API doesn't have a clear way to identify changes in taxes + # from orders being edited. Instead of calculating the difference, we'll + # just add a tax line for the difference + total_taxes = sum(tax.get("tax_amount") for tax in taxes) + order_taxes = flt(shopify_order.attributes.get("current_total_tax")) + difference = order_taxes - total_taxes + if difference: + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shopify_settings.name, "tax"), + "description": "Tax Difference from Order Edits", + "tax_amount": difference, + "cost_center": shopify_settings.cost_center, + } + ) return taxes @@ -230,16 +328,30 @@ def cancel_shopify_order(shop_name: str, order_id: str, log_id: str = str()): doc.flags.ignore_links = True doc.cancel() except Exception as e: - make_shopify_log(shop_name, status="Error", response_data=order.to_dict(), - exception=e, rollback=True) + make_shopify_log( + shop_name, + status="Error", + response_data=order.to_dict(), + exception=e, + rollback=True, + ) # update the financial status in all linked Shopify Payouts - payout_transactions = frappe.get_all("Shopify Payout Transaction", + payout_transactions = frappe.get_all( + "Shopify Payout Transaction", filters={ frappe.scrub(doctype): doc.name, - "source_order_financial_status": ["!=", order.attributes.get("financial_status")] - }) + "source_order_financial_status": [ + "!=", + order.attributes.get("financial_status"), + ], + }, + ) for transaction in payout_transactions: - frappe.db.set_value("Shopify Payout Transaction", transaction.name, - "source_order_financial_status", frappe.unscrub(order.attributes.get("financial_status"))) + frappe.db.set_value( + "Shopify Payout Transaction", + transaction.name, + "source_order_financial_status", + frappe.unscrub(order.attributes.get("financial_status")), + ) diff --git a/shopify_integration/setup.py b/shopify_integration/setup.py index 838a1b2..a825c36 100644 --- a/shopify_integration/setup.py +++ b/shopify_integration/setup.py @@ -80,6 +80,17 @@ def setup_custom_fields(args=None): fieldtype="Data", insert_after="cb_shopify", read_only=1, print_hide=1, translatable=0) ], + "Sales Order Item": [ + dict( + fieldname="shopify_order_item_id", + label="Shopify Order Item ID", + fieldtype="Data", + insert_after="warehouse", + read_only=True, + print_hide=True, + translatable=False, + ), + ], "Sales Invoice": [ dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", insert_after="cost_center", collapsible=0), @@ -95,6 +106,17 @@ def setup_custom_fields(args=None): fieldtype="Data", insert_after="cb_shopify", read_only=1, print_hide=1, translatable=0) ], + "Sales Invoice Item": [ + dict( + fieldname="shopify_order_item_id", + label="Shopify Order Item ID", + fieldtype="Data", + insert_after="sales_order", + read_only=True, + print_hide=True, + translatable=False, + ), + ], "Delivery Note": [ dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", insert_after="return_against", collapsible=0), @@ -112,7 +134,18 @@ def setup_custom_fields(args=None): dict(fieldname="shopify_fulfillment_id", label="Shopify Fulfillment ID", fieldtype="Data", insert_after="shopify_order_id", read_only=1, print_hide=1, translatable=0) - ] + ], + "Delivery Note Item": [ + dict( + fieldname="shopify_order_item_id", + label="Shopify Order Item ID", + fieldtype="Data", + insert_after="against_sales_order", + read_only=True, + print_hide=True, + translatable=False, + ), + ], } # ref: https://github.com/ParsimonyGit/shipstation_integration/ diff --git a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.js b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.js index 2b57fc1..f54f244 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.js +++ b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.js @@ -6,21 +6,16 @@ frappe.ui.form.on('Shopify Log', { refresh: (frm) => { if (frm.doc.request_data && frm.doc.status === 'Error') { - frm.add_custom_button('Resync', () => { - frappe.call({ - method: "shopify_integration.shopify_integration.doctype.shopify_log.shopify_log.resync", - args: { - shop_name: frm.doc.shop, - method: frm.doc.method, - name: frm.doc.name, - request_data: frm.doc.request_data - }, - callback: (r) => { - if (!r.exc) { - frappe.msgprint(__("Order rescheduled for sync")); - } - } + frm.add_custom_button('Resync', async () => { + const response = await frm.call({ + doc: frm.doc, + method: "resync", + freeze: true, }) + + if (!response.exc) { + frappe.msgprint(__("Order rescheduled for sync")); + } }).addClass('btn-primary'); } } diff --git a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py index 9d30c53..3930a6e 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py +++ b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py @@ -3,26 +3,38 @@ # For license information, please see license.txt import json -from typing import TYPE_CHECKING, Dict, List, Union +from typing import Dict, List, Optional, Union import frappe from frappe.model.document import Document -if TYPE_CHECKING: - from shopify import Order - from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings +class ShopifyLog(Document): + @frappe.whitelist() + def resync(self): + self.db_set("status", "Queued", update_modified=False) + request_data = json.loads(self.request_data) + if request_data.get("order_edit"): + order_id = request_data.get("order_edit", {}).get("order_id") + else: + order_id = request_data.get("id") -class ShopifyLog(Document): - pass + frappe.enqueue( + method=self.method, + queue="short", + timeout=300, + is_async=True, + **{"shop_name": self.shop, "order_id": order_id, "log_id": self.name} + ) def make_shopify_log( shop_name: str, status: str = "Queued", - response_data: Union[str, Dict] = None, - exception: Union[Exception, List] = None, + message: Optional[str] = None, + response_data: Optional[Union[str, Dict]] = None, + exception: Optional[Union[Exception, List]] = None, rollback: bool = False, ): # if name not provided by log calling method then fetch existing queued state log @@ -42,36 +54,26 @@ def make_shopify_log( if not isinstance(response_data, str): response_data = json.dumps(response_data, sort_keys=True, indent=4) - log.shop = shop_name - log.message = get_message(exception) - log.response_data = response_data - log.traceback = frappe.get_traceback() - log.status = status + error_message = ( + message or get_message(exception) or "Something went wrong while syncing" + ) + + log.update( + { + "shop": shop_name, + "message": error_message, + "response_data": response_data, + "traceback": frappe.get_traceback(), + "status": status, + } + ) + log.save(ignore_permissions=True) frappe.db.commit() def get_message(exception: Exception): - message = "Something went wrong while syncing" - if hasattr(exception, "message"): - message = exception.message + return exception.message elif hasattr(exception, "__str__"): - message = exception.__str__() - - return message - - -@frappe.whitelist() -def resync(shop_name: str, method: str, name: str, request_data: str): - frappe.db.set_value("Shopify Log", name, "status", "Queued", update_modified=False) - - request_data = json.loads(request_data) - shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) - - order_id = request_data.get("id") - orders = shopify_settings.get_orders(order_id) - order: "Order" = orders[0] - - frappe.enqueue(method=method, queue='short', timeout=300, is_async=True, - **{"shop_name": shop_name, "order": order, "log_id": name}) + return exception.__str__() diff --git a/shopify_integration/webhooks.py b/shopify_integration/webhooks.py index 21fc18b..0f19406 100644 --- a/shopify_integration/webhooks.py +++ b/shopify_integration/webhooks.py @@ -19,6 +19,7 @@ SHOPIFY_WEBHOOK_TOPIC_MAPPER = { "orders/create": "shopify_integration.orders.create_shopify_documents", + "orders/edited": "shopify_integration.orders.update_shopify_order", "orders/paid": "shopify_integration.invoices.prepare_sales_invoice", "orders/fulfilled": "shopify_integration.fulfilments.prepare_delivery_note", "orders/cancelled": "shopify_integration.orders.cancel_shopify_order", @@ -79,15 +80,26 @@ def validate_webhooks_request(shop: "ShopifySettings", hmac_key: str): def enqueue_webhook_event(shop_name: str, data: Dict, event: str = "orders/create"): frappe.set_user("Administrator") log = create_shopify_log(shop_name, data, event) - order_id = data.get("id") - if order_id: - frappe.enqueue( - method=SHOPIFY_WEBHOOK_TOPIC_MAPPER.get(event), - queue="short", - timeout=300, - is_async=True, - **{"shop_name": shop_name, "order_id": order_id, "log_id": log.name}, - ) + + # since webhooks are registered for orders only, get order from Shopify webhook data + if event == "orders/edited": + order_id = data.get("order_edit", {}).get("order_id") + else: + order_id = data.get("id") + + if not order_id: + log.status = "Error" + log.message = "Order ID not found in webhook data" + log.save(ignore_permissions=True) + return + + frappe.enqueue( + method=SHOPIFY_WEBHOOK_TOPIC_MAPPER.get(event), + queue="short", + timeout=300, + is_async=True, + **{"shop_name": shop_name, "order_id": order_id, "log_id": log.name}, + ) def create_shopify_log(shop_name: str, data: Dict, event: str = "orders/create"):