From eaf8d8abdf162135d55d2084e96e21f128e65337 Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Wed, 17 Dec 2025 14:03:23 +0530 Subject: [PATCH 01/18] fix: Handle Shopify line items with null product_id and improve duplicate item handling - Add intelligent fallback mapping for line items without product_id * Tips -> SHOPIFY-TIP * Samples -> SHOPIFY-SAMPLE * Rush fees -> SHOPIFY-RUSH-FEE * Adjustments -> SHOPIFY-ADJUSTMENT * Other -> SHOPIFY-MISC - Skip syncing items with null product_id in create_items_if_not_exist() - Add error handling for duplicate item errors to prevent order sync failures - Enhance get_order_items() to process null product_id items before product_exists check - Improve _match_sku_and_link_item() to match by both SKU and product_id - Add comprehensive logging for fallback item mapping and errors Fixes expected String to be a id error when orders contain tips, samples, or other non-product line items --- ecommerce_integrations/shopify/order.py | 893 +++++++-------- ecommerce_integrations/shopify/product.py | 1224 +++++++++++---------- 2 files changed, 1112 insertions(+), 1005 deletions(-) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 0570d035b..086853115 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -1,435 +1,458 @@ -import json -from typing import Literal, Optional - -import frappe -from frappe import _ -from frappe.utils import cint, cstr, flt, get_datetime, getdate, nowdate -from shopify.collection import PaginatedIterator -from shopify.resources import Order - -from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import ( - CUSTOMER_ID_FIELD, - EVENT_MAPPER, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - 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.utils.price_list import get_dummy_price_list -from ecommerce_integrations.utils.taxation import get_dummy_tax_category - -DEFAULT_TAX_FIELDS = { - "sales_tax": "default_sales_tax_account", - "shipping": "default_shipping_charges_account", -} - - -def sync_sales_order(payload, request_id=None): - order = payload - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - 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") - return - try: - shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") - customer_id = shopify_customer.get("id") - if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) - if not customer.is_synced(): - customer.sync_customer(customer=shopify_customer) - else: - customer.update_existing_addresses(shopify_customer) - - create_items_if_not_exist(order) - - setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) - except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) - else: - create_shopify_log(status="Success") - - -def create_order(order, setting, company=None): - # local import to avoid circular dependencies - from ecommerce_integrations.shopify.fulfillment import create_delivery_note - from ecommerce_integrations.shopify.invoice import create_sales_invoice - - so = create_sales_order(order, setting, company) - if so: - if order.get("financial_status") == "paid": - create_sales_invoice(order, setting, so) - - if order.get("fulfillments"): - create_delivery_note(order, setting, so) - - -def create_sales_order(shopify_order, setting, company=None): - customer = setting.default_customer - if shopify_order.get("customer", {}): - if customer_id := shopify_order.get("customer", {}).get("id"): - customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - - so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") - - if not so: - items = get_order_items( - shopify_order.get("line_items"), - setting, - getdate(shopify_order.get("created_at")), - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if not items: - message = ( - "Following items exists in the shopify order but relevant records were" - " not found in the shopify Product master" - ) - product_not_exists = [] # TODO: fix missing items - message += "\n" + ", ".join(product_not_exists) - - create_shopify_log(status="Error", exception=message, rollback=True) - - return "" - - taxes = get_order_taxes(shopify_order, setting, items) - so = frappe.get_doc( - { - "doctype": "Sales Order", - "naming_series": setting.sales_order_series or "SO-Shopify-", - ORDER_ID_FIELD: str(shopify_order.get("id")), - ORDER_NUMBER_FIELD: shopify_order.get("name"), - "customer": customer, - "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), - "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": setting.company, - "selling_price_list": get_dummy_price_list(), - "ignore_pricing_rule": 1, - "items": items, - "taxes": taxes, - "tax_category": get_dummy_tax_category(), - } - ) - - if company: - so.update({"company": company, "status": "Draft"}) - so.flags.ignore_mandatory = True - so.flags.shopiy_order_json = json.dumps(shopify_order) - so.save(ignore_permissions=True) - so.submit() - - if shopify_order.get("note"): - so.add_comment(text=f"Order Note: {shopify_order.get('note')}") - - else: - so = frappe.get_doc("Sales Order", so) - - return so - - -def get_order_items(order_items, setting, delivery_date, taxes_inclusive): - items = [] - all_product_exists = True - product_not_exists = [] - - for shopify_item in order_items: - if not shopify_item.get("product_exists"): - all_product_exists = False - product_not_exists.append( - {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} - ) - continue - - if all_product_exists: - item_code = get_item_code(shopify_item) - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - else: - items = [] - - return items - - -def _get_item_price(line_item, taxes_inclusive: bool) -> float: - price = flt(line_item.get("price")) - qty = cint(line_item.get("quantity")) - - # remove line item level discounts - total_discount = _get_total_discount(line_item) - - if not taxes_inclusive: - return price - (total_discount / qty) - - total_taxes = 0.0 - for tax in line_item.get("tax_lines"): - total_taxes += flt(tax.get("price")) - - return price - (total_taxes + total_discount) / qty - - -def _get_total_discount(line_item) -> float: - discount_allocations = line_item.get("discount_allocations") or [] - return sum(flt(discount.get("amount")) for discount in discount_allocations) - - -def get_order_taxes(shopify_order, setting, items): - taxes = [] - line_items = shopify_order.get("line_items") - - for line_item in line_items: - item_code = get_item_code(line_item) - for tax in line_item.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax.get("price"), - "included_in_print_rate": 0, - "cost_center": setting.cost_center, - "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, - "dont_recompute_tax": 1, - } - ) - - update_taxes_with_shipping_lines( - taxes, - shopify_order.get("shipping_lines"), - setting, - items, - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if cint(setting.consolidate_taxes): - taxes = consolidate_order_taxes(taxes) - - for row in taxes: - tax_detail = row.get("item_wise_tax_detail") - if isinstance(tax_detail, dict): - row["item_wise_tax_detail"] = json.dumps(tax_detail) - - return taxes - - -def consolidate_order_taxes(taxes): - tax_account_wise_data = {} - for tax in taxes: - account_head = tax["account_head"] - tax_account_wise_data.setdefault( - account_head, - { - "charge_type": "Actual", - "account_head": account_head, - "description": tax.get("description"), - "cost_center": tax.get("cost_center"), - "included_in_print_rate": 0, - "dont_recompute_tax": 1, - "tax_amount": 0, - "item_wise_tax_detail": {}, - }, - ) - tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) - if tax.get("item_wise_tax_detail"): - tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) - - return tax_account_wise_data.values() - - -def get_tax_account_head(tax, 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}, - "tax_account", - ) - - if not tax_account and charge_type: - tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) - - if not tax_account: - frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) - - return tax_account - - -def get_tax_account_description(tax): - tax_title = tax.get("title") - - tax_description = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_description", - ) - - return tax_description - - -def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): - """Shipping lines represents the shipping details, - each such shipping detail consists of a list of tax_lines""" - shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item - for shipping_charge in shipping_lines: - if shipping_charge.get("price"): - shipping_discounts = shipping_charge.get("discount_allocations") or [] - total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) - - shipping_taxes = shipping_charge.get("tax_lines") or [] - total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) - - shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) - if bool(taxes_inclusive): - shipping_charge_amount -= total_tax - - if shipping_as_item: - items.append( - { - "item_code": setting.shipping_item, - "rate": shipping_charge_amount, - "delivery_date": items[-1]["delivery_date"] if items else nowdate(), - "qty": 1, - "stock_uom": "Nos", - "warehouse": setting.warehouse, - } - ) - else: - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) - or shipping_charge["title"], - "tax_amount": shipping_charge_amount, - "cost_center": setting.cost_center, - } - ) - - for tax in shipping_charge.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax["price"], - "cost_center": setting.cost_center, - "item_wise_tax_detail": { - setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] - } - if shipping_as_item - else {}, - "dont_recompute_tax": 1, - } - ) - - -def get_sales_order(order_id): - """Get ERPNext sales order using shopify order id.""" - sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - if sales_order: - return frappe.get_doc("Sales Order", sales_order) - - -def cancel_order(payload, request_id=None): - """Called by order/cancelled event. - - When shopify order is cancelled there could be many different someone handles it. - - Updates document with custom field showing order status. - - IF sales invoice / delivery notes are not generated against an order, then cancel it. - """ - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - order = payload - - try: - order_id = order["id"] - order_status = order["financial_status"] - - sales_order = get_sales_order(order_id) - - if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") - return - - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) - - if sales_invoice: - frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) - - for dn in delivery_notes: - frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) - - if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: - sales_order.cancel() - else: - 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) - else: - create_shopify_log(status="Success") - - -@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) - - 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) - - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) - shopify_setting.sync_old_orders = 0 - shopify_setting.save() - - -def _fetch_old_orders(from_time, to_time): - """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" - - from_time = get_datetime(from_time).astimezone().isoformat() - to_time = get_datetime(to_time).astimezone().isoformat() - orders_iterator = PaginatedIterator( - Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) - ) - - for orders in orders_iterator: - for order in orders: - # Using generator instead of fetching all at once is better for - # avoiding rate limits and reducing resource usage. - yield order.to_dict() +import json +from typing import Literal, Optional + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, get_datetime, getdate, nowdate +from shopify.collection import PaginatedIterator +from shopify.resources import Order + +from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.constants import ( + CUSTOMER_ID_FIELD, + EVENT_MAPPER, + ORDER_ID_FIELD, + ORDER_ITEM_DISCOUNT_FIELD, + ORDER_NUMBER_FIELD, + ORDER_STATUS_FIELD, + 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.utils.price_list import get_dummy_price_list +from ecommerce_integrations.utils.taxation import get_dummy_tax_category + +DEFAULT_TAX_FIELDS = { + "sales_tax": "default_sales_tax_account", + "shipping": "default_shipping_charges_account", +} + + +def sync_sales_order(payload, request_id=None): + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + 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") + return + try: + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + customer_id = shopify_customer.get("id") + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + customer.sync_customer(customer=shopify_customer) + else: + customer.update_existing_addresses(shopify_customer) + + create_items_if_not_exist(order) + + setting = frappe.get_doc(SETTING_DOCTYPE) + create_order(order, setting) + except Exception as e: + create_shopify_log(status="Error", exception=e, rollback=True) + else: + create_shopify_log(status="Success") + + +def create_order(order, setting, company=None): + # local import to avoid circular dependencies + from ecommerce_integrations.shopify.fulfillment import create_delivery_note + from ecommerce_integrations.shopify.invoice import create_sales_invoice + + so = create_sales_order(order, setting, company) + if so: + if order.get("financial_status") == "paid": + create_sales_invoice(order, setting, so) + + if order.get("fulfillments"): + create_delivery_note(order, setting, so) + + +def create_sales_order(shopify_order, setting, company=None): + customer = setting.default_customer + if shopify_order.get("customer", {}): + if customer_id := shopify_order.get("customer", {}).get("id"): + customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + + so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") + + if not so: + items = get_order_items( + shopify_order.get("line_items"), + setting, + getdate(shopify_order.get("created_at")), + taxes_inclusive=shopify_order.get("taxes_included"), + ) + + if not items: + message = ( + "Following items exists in the shopify order but relevant records were" + " not found in the shopify Product master" + ) + product_not_exists = [] # TODO: fix missing items + message += "\n" + ", ".join(product_not_exists) + + create_shopify_log(status="Error", exception=message, rollback=True) + + return "" + + taxes = get_order_taxes(shopify_order, setting, items) + so = frappe.get_doc( + { + "doctype": "Sales Order", + "naming_series": setting.sales_order_series or "SO-Shopify-", + ORDER_ID_FIELD: str(shopify_order.get("id")), + ORDER_NUMBER_FIELD: shopify_order.get("name"), + "customer": customer, + "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), + "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), + "company": setting.company, + "selling_price_list": get_dummy_price_list(), + "ignore_pricing_rule": 1, + "items": items, + "taxes": taxes, + "tax_category": get_dummy_tax_category(), + } + ) + + if company: + so.update({"company": company, "status": "Draft"}) + so.flags.ignore_mandatory = True + so.flags.shopiy_order_json = json.dumps(shopify_order) + so.save(ignore_permissions=True) + so.submit() + + if shopify_order.get("note"): + so.add_comment(text=f"Order Note: {shopify_order.get('note')}") + + else: + so = frappe.get_doc("Sales Order", so) + + return so + + +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): + items = [] + all_product_exists = True + product_not_exists = [] + + for shopify_item in order_items: + product_id = shopify_item.get("product_id") + + # Handle items without product_id (tips, samples, fees) - skip product_exists check + if not product_id: + item_code = get_item_code(shopify_item) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append( + {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} + ) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": shopify_item.get("uom") or "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + else: + items = [] + + return items + + +def _get_item_price(line_item, taxes_inclusive: bool) -> float: + price = flt(line_item.get("price")) + qty = cint(line_item.get("quantity")) + + # remove line item level discounts + total_discount = _get_total_discount(line_item) + + if not taxes_inclusive: + return price - (total_discount / qty) + + total_taxes = 0.0 + for tax in line_item.get("tax_lines"): + total_taxes += flt(tax.get("price")) + + return price - (total_taxes + total_discount) / qty + + +def _get_total_discount(line_item) -> float: + discount_allocations = line_item.get("discount_allocations") or [] + return sum(flt(discount.get("amount")) for discount in discount_allocations) + + +def get_order_taxes(shopify_order, setting, items): + taxes = [] + line_items = shopify_order.get("line_items") + + for line_item in line_items: + item_code = get_item_code(line_item) + for tax in line_item.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax.get("price"), + "included_in_print_rate": 0, + "cost_center": setting.cost_center, + "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, + "dont_recompute_tax": 1, + } + ) + + update_taxes_with_shipping_lines( + taxes, + shopify_order.get("shipping_lines"), + setting, + items, + taxes_inclusive=shopify_order.get("taxes_included"), + ) + + if cint(setting.consolidate_taxes): + taxes = consolidate_order_taxes(taxes) + + for row in taxes: + tax_detail = row.get("item_wise_tax_detail") + if isinstance(tax_detail, dict): + row["item_wise_tax_detail"] = json.dumps(tax_detail) + + return taxes + + +def consolidate_order_taxes(taxes): + tax_account_wise_data = {} + for tax in taxes: + account_head = tax["account_head"] + tax_account_wise_data.setdefault( + account_head, + { + "charge_type": "Actual", + "account_head": account_head, + "description": tax.get("description"), + "cost_center": tax.get("cost_center"), + "included_in_print_rate": 0, + "dont_recompute_tax": 1, + "tax_amount": 0, + "item_wise_tax_detail": {}, + }, + ) + tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) + if tax.get("item_wise_tax_detail"): + tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) + + return tax_account_wise_data.values() + + +def get_tax_account_head(tax, 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}, + "tax_account", + ) + + if not tax_account and charge_type: + tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) + + if not tax_account: + frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) + + return tax_account + + +def get_tax_account_description(tax): + tax_title = tax.get("title") + + tax_description = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + "tax_description", + ) + + return tax_description + + +def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): + """Shipping lines represents the shipping details, + each such shipping detail consists of a list of tax_lines""" + shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item + for shipping_charge in shipping_lines: + if shipping_charge.get("price"): + shipping_discounts = shipping_charge.get("discount_allocations") or [] + total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) + + shipping_taxes = shipping_charge.get("tax_lines") or [] + total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) + + shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) + if bool(taxes_inclusive): + shipping_charge_amount -= total_tax + + if shipping_as_item: + items.append( + { + "item_code": setting.shipping_item, + "rate": shipping_charge_amount, + "delivery_date": items[-1]["delivery_date"] if items else nowdate(), + "qty": 1, + "stock_uom": "Nos", + "warehouse": setting.warehouse, + } + ) + else: + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), + "description": get_tax_account_description(shipping_charge) + or shipping_charge["title"], + "tax_amount": shipping_charge_amount, + "cost_center": setting.cost_center, + } + ) + + for tax in shipping_charge.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax["price"], + "cost_center": setting.cost_center, + "item_wise_tax_detail": { + setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] + } + if shipping_as_item + else {}, + "dont_recompute_tax": 1, + } + ) + + +def get_sales_order(order_id): + """Get ERPNext sales order using shopify order id.""" + sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + if sales_order: + return frappe.get_doc("Sales Order", sales_order) + + +def cancel_order(payload, request_id=None): + """Called by order/cancelled event. + + When shopify order is cancelled there could be many different someone handles it. + + Updates document with custom field showing order status. + + IF sales invoice / delivery notes are not generated against an order, then cancel it. + """ + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + order = payload + + try: + order_id = order["id"] + order_status = order["financial_status"] + + sales_order = get_sales_order(order_id) + + if not sales_order: + create_shopify_log(status="Invalid", message="Sales Order does not exist") + return + + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) + + if sales_invoice: + frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) + + for dn in delivery_notes: + frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) + + if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: + sales_order.cancel() + else: + 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) + else: + create_shopify_log(status="Success") + + +@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) + + 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) + + shopify_setting = frappe.get_doc(SETTING_DOCTYPE) + shopify_setting.sync_old_orders = 0 + shopify_setting.save() + + +def _fetch_old_orders(from_time, to_time): + """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" + + from_time = get_datetime(from_time).astimezone().isoformat() + to_time = get_datetime(to_time).astimezone().isoformat() + orders_iterator = PaginatedIterator( + Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) + ) + + for orders in orders_iterator: + for order in orders: + # Using generator instead of fetching all at once is better for + # avoiding rate limits and reducing resource usage. + yield order.to_dict() diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..effa57cf8 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -1,570 +1,654 @@ -from typing import Optional - -import frappe -from frappe import _, msgprint -from frappe.utils import cint, cstr -from frappe.utils.nestedset import get_root_of -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.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 - - -class ShopifyProduct: - def __init__( - self, - product_id: str, - variant_id: str | None = None, - sku: str | None = None, - has_variants: int | None = 0, - ): - 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) - - if not self.setting.is_enabled(): - frappe.throw(_("Can not create Shopify product when integration is disabled.")) - - def is_synced(self) -> bool: - return ecommerce_item.is_synced( - MODULE_NAME, - integration_item_code=self.product_id, - variant_id=self.variant_id, - sku=self.sku, - ) - - def get_erpnext_item(self): - return ecommerce_item.get_erpnext_item( - MODULE_NAME, - integration_item_code=self.product_id, - variant_id=self.variant_id, - sku=self.sku, - 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) - - def _make_item(self, product_dict): - _add_weight_details(product_dict) - - warehouse = self.setting.warehouse - - if _has_variants(product_dict): - self.has_variants = 1 - attributes = self._create_attribute(product_dict) - self._create_item(product_dict, warehouse, 1, attributes) - self._create_item_variants(product_dict, warehouse, attributes) - - else: - product_dict["variant_id"] = product_dict["variants"][0]["id"] - self._create_item(product_dict, warehouse) - - def _create_attribute(self, product_dict): - attribute = [] - for attr in product_dict.get("options"): - if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): - frappe.get_doc( - { - "doctype": "Item Attribute", - "attribute_name": attr.get("name"), - "item_attribute_values": [ - {"attribute_value": attr_value, "abbr": attr_value} - for attr_value in attr.get("values") - ], - } - ).insert() - attribute.append({"attribute": attr.get("name")}) - - else: - # check for attribute values - item_attr = frappe.get_doc("Item Attribute", attr.get("name")) - if not item_attr.numeric_values: - self._set_new_attribute_values(item_attr, attr.get("values")) - item_attr.save() - attribute.append({"attribute": attr.get("name")}) - - else: - attribute.append( - { - "attribute": attr.get("name"), - "from_range": item_attr.get("from_range"), - "to_range": item_attr.get("to_range"), - "increment": item_attr.get("increment"), - "numeric_values": item_attr.get("numeric_values"), - } - ) - - return attribute - - def _set_new_attribute_values(self, item_attr, values): - for attr_value in values: - if not any( - (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) - for d in item_attr.item_attribute_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): - item_dict = { - "variant_of": variant_of, - "is_stock_item": 1, - "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), - "item_name": product_dict.get("title", "").strip(), - "description": product_dict.get("body_html") or product_dict.get("title"), - "item_group": self._get_item_group(product_dict.get("product_type")), - "has_variants": has_variant, - "attributes": attributes or [], - "stock_uom": product_dict.get("uom") or _("Nos"), - "sku": product_dict.get("sku") or _get_sku(product_dict), - "default_warehouse": warehouse, - "image": _get_item_image(product_dict), - "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), - } - - 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"] - - if not _match_sku_and_link_item( - item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant - ): - ecommerce_item.create_ecommerce_item( - MODULE_NAME, - integration_item_code, - item_dict, - variant_id=variant_id, - sku=sku, - variant_of=variant_of, - has_variants=has_variant, - ) - - def _create_item_variants(self, product_dict, warehouse, attributes): - template_item = ecommerce_item.get_erpnext_item( - MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 - ) - - if template_item: - for variant in product_dict.get("variants"): - shopify_item_variant = { - "id": product_dict.get("id"), - "variant_id": variant.get("id"), - "item_code": variant.get("id"), - "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), - "product_type": product_dict.get("product_type"), - "sku": variant.get("sku"), - "uom": template_item.stock_uom or _("Nos"), - "item_price": variant.get("price"), - "weight_unit": variant.get("weight_unit"), - "weight": variant.get("weight"), - } - - for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): - if variant.get(variant_attr): - attributes[i].update( - { - "attribute_value": self._get_attribute_value( - variant.get(variant_attr), attributes[i] - ) - } - ) - self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) - - def _get_attribute_value(self, variant_attr_val, attribute): - attribute_value = frappe.db.sql( - """select attribute_value from `tabItem Attribute Value` - where parent = %s and (abbr = %s or attribute_value = %s)""", - (attribute["attribute"], variant_attr_val, variant_attr_val), - as_list=1, - ) - return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) - - def _get_item_group(self, product_type=None): - parent_item_group = get_root_of("Item Group") - - if not product_type: - return parent_item_group - - if frappe.db.get_value("Item Group", product_type, "name"): - return product_type - item_group = frappe.get_doc( - { - "doctype": "Item Group", - "item_group_name": product_type, - "parent_item_group": parent_item_group, - "is_group": "No", - } - ).insert() - return item_group.name - - def _get_supplier(self, product_dict): - if product_dict.get("vendor"): - supplier = frappe.db.sql( - f"""select name from tabSupplier - where name = %s or {SUPPLIER_ID_FIELD} = %s """, - (product_dict.get("vendor"), product_dict.get("vendor").lower()), - as_list=1, - ) - - if supplier: - return product_dict.get("vendor") - supplier = frappe.get_doc( - { - "doctype": "Supplier", - "supplier_name": product_dict.get("vendor"), - SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), - "supplier_group": self._get_supplier_group(), - } - ).insert() - return supplier.name - else: - return "" - - def _get_supplier_group(self): - supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) - if not supplier_group: - supplier_group = frappe.get_doc( - {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} - ).insert() - return supplier_group.name - return supplier_group - - -def _add_weight_details(product_dict): - variants = product_dict.get("variants") - if variants: - product_dict["weight"] = variants[0]["weight"] - product_dict["weight_unit"] = variants[0]["weight_unit"] - - -def _has_variants(product_dict) -> bool: - options = product_dict.get("options") - return bool(options and "Default Title" not in options[0]["values"]) - - -def _get_sku(product_dict): - if product_dict.get("variants"): - return product_dict.get("variants")[0].get("sku") - return "" - - -def _get_item_image(product_dict): - if product_dict.get("image"): - return product_dict.get("image").get("src") - return None - - -def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: - """Tries to match new item with existing item using Shopify SKU == item_code. - - Returns true if matched and linked. - """ - sku = item_dict["sku"] - if not sku or variant_of or has_variant: - return False - - item_name = frappe.db.get_value("Item", {"item_code": sku}) - if item_name: - try: - ecommerce_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "integration": MODULE_NAME, - "erpnext_item_code": item_name, - "integration_item_code": product_id, - "has_variants": 0, - "variant_id": cstr(variant_id), - "sku": sku, - } - ) - - ecommerce_item.insert() - return True - except Exception: - return False - - -def create_items_if_not_exist(order): - """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) - - if not product.is_synced(): - product.sync_product() - - -def get_item_code(shopify_item): - """Get item code using shopify_item dict. - - Item should contain both product_id and variant_id.""" - - item = ecommerce_item.get_erpnext_item( - integration=MODULE_NAME, - integration_item_code=shopify_item.get("product_id"), - variant_id=shopify_item.get("variant_id"), - sku=shopify_item.get("sku"), - ) - if 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`. - - New items are pushed to shopify and changes to existing items are - updated depending on what is configured in "Shopify Setting" doctype. - """ - template_item = item = doc # alias for readability - # a new item recieved from ecommerce_integrations is being inserted - if item.flags.from_integration: - return - - setting = frappe.get_doc(SETTING_DOCTYPE) - - if not setting.is_enabled() or not setting.upload_erpnext_items: - return - - if frappe.flags.in_import: - return - - if item.has_variants: - return - - if len(item.attributes) > 3: - msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) - return - - if doc.variant_of and not setting.upload_variants_as_items: - msgprint(_("Enable variant sync in setting to upload item to Shopify.")) - return - - if item.variant_of: - template_item = frappe.get_doc("Item", item.variant_of) - - product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - is_new_product = not bool(product_id) - - if is_new_product: - product = Product() - product.published = False - product.status = "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() - - 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: - 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" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw( - _("Shopify Error: Missing value for attribute {}").format(attr.attribute) - ) - product.variants.append(Variant(variant_attributes)) - - is_successful = product.save() - if is_successful and item.variant_of: - map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - - write_upload_log(status=is_successful, product=product, item=item, action="Updated") - - -def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): - variant_product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - if not variant_product_id: - for variant in shopify_product.variants: - if ( - variant.option1 == variant_attributes.get("option1") - and variant.option2 == variant_attributes.get("option2") - and variant.option3 == variant_attributes.get("option3") - ): - variant_product_id = str(variant.id) - if not frappe.flags.in_test: - frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": erpnext_item.name, - "integration": MODULE_NAME, - "integration_item_code": str(shopify_product.id), - "variant_id": variant_product_id, - "sku": str(variant.sku), - "variant_of": erpnext_item.variant_of, - } - ).insert() - break - if not variant_product_id: - msgprint(_("Shopify: Couldn't sync item variant.")) - return variant_product_id - - -def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): - """Map erpnext fields to shopify, called both when updating and creating new products.""" - - shopify_product.title = erpnext_item.item_name - shopify_product.body_html = erpnext_item.description - shopify_product.product_type = erpnext_item.item_group - - if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): - # reverse lookup for key - uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) - shopify_product.weight = erpnext_item.weight_per_unit - shopify_product.weight_unit = uom - - if erpnext_item.disabled: - shopify_product.status = "draft" - shopify_product.published = False - msgprint(_("Status of linked Shopify product is changed to Draft.")) - - -def get_shopify_weight_uom(erpnext_weight_uom: str) -> str: - for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): - if erpnext_uom == erpnext_weight_uom: - return shopify_uom - - -def update_default_variant_properties( - shopify_product: Product, - is_stock_item: bool, - sku: str | None = None, - price: float | None = None, -): - """Shopify creates default variant upon saving the product. - - Some item properties are supposed to be updated on the default variant. - Input: saved shopify_product, sku and price - """ - default_variant: Variant = shopify_product.variants[0] - - # this will create Inventory item and qty will be updated by scheduled job. - if is_stock_item: - default_variant.inventory_management = "shopify" - - if price is not None: - default_variant.price = price - if sku is not None: - default_variant.sku = sku - - -def write_upload_log(status: bool, product: Product, item, action="Created") -> None: - if not status: - msg = _("Failed to upload item to Shopify") + "
" - msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) - msgprint(msg, title="Note", indicator="orange") - - create_shopify_log( - status="Error", - request_data=product.to_dict(), - message=msg, - method="upload_erpnext_item", - ) - else: - create_shopify_log( - status="Success", - request_data=product.to_dict(), - message=f"{action} Item: {item.name}, shopify product: {product.id}", - method="upload_erpnext_item", - ) +from typing import Optional + +import frappe +from frappe import _, msgprint +from frappe.utils import cint, cstr +from frappe.utils.nestedset import get_root_of +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.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 + + +def get_shopify_fallback_item(title): + """Map Shopify line items without product_id to appropriate fallback items. + + Args: + title (str): Line item title from Shopify + + Returns: + str: ERPNext Item code + """ + if not title: + return "SHOPIFY-MISC" + + title_lower = title.lower() + + # Map based on title keywords + if "tip" in title_lower: + return "SHOPIFY-TIP" + elif "sample" in title_lower: + return "SHOPIFY-SAMPLE" + elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower: + return "SHOPIFY-RUSH-FEE" + elif "adjustment" in title_lower or "price adjustment" in title_lower: + return "SHOPIFY-ADJUSTMENT" + else: + return "SHOPIFY-MISC" + + +class ShopifyProduct: + def __init__( + self, + product_id: str, + variant_id: str | None = None, + sku: str | None = None, + has_variants: int | None = 0, + ): + 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) + + if not self.setting.is_enabled(): + frappe.throw(_("Can not create Shopify product when integration is disabled.")) + + def is_synced(self) -> bool: + return ecommerce_item.is_synced( + MODULE_NAME, + integration_item_code=self.product_id, + variant_id=self.variant_id, + sku=self.sku, + ) + + def get_erpnext_item(self): + return ecommerce_item.get_erpnext_item( + MODULE_NAME, + integration_item_code=self.product_id, + variant_id=self.variant_id, + sku=self.sku, + 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) + + def _make_item(self, product_dict): + _add_weight_details(product_dict) + + warehouse = self.setting.warehouse + + if _has_variants(product_dict): + self.has_variants = 1 + attributes = self._create_attribute(product_dict) + self._create_item(product_dict, warehouse, 1, attributes) + self._create_item_variants(product_dict, warehouse, attributes) + + else: + product_dict["variant_id"] = product_dict["variants"][0]["id"] + self._create_item(product_dict, warehouse) + + def _create_attribute(self, product_dict): + attribute = [] + for attr in product_dict.get("options"): + if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): + frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": attr.get("name"), + "item_attribute_values": [ + {"attribute_value": attr_value, "abbr": attr_value} + for attr_value in attr.get("values") + ], + } + ).insert() + attribute.append({"attribute": attr.get("name")}) + + else: + # check for attribute values + item_attr = frappe.get_doc("Item Attribute", attr.get("name")) + if not item_attr.numeric_values: + self._set_new_attribute_values(item_attr, attr.get("values")) + item_attr.save() + attribute.append({"attribute": attr.get("name")}) + + else: + attribute.append( + { + "attribute": attr.get("name"), + "from_range": item_attr.get("from_range"), + "to_range": item_attr.get("to_range"), + "increment": item_attr.get("increment"), + "numeric_values": item_attr.get("numeric_values"), + } + ) + + return attribute + + def _set_new_attribute_values(self, item_attr, values): + for attr_value in values: + if not any( + (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) + for d in item_attr.item_attribute_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): + item_dict = { + "variant_of": variant_of, + "is_stock_item": 1, + "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), + "item_name": product_dict.get("title", "").strip(), + "description": product_dict.get("body_html") or product_dict.get("title"), + "item_group": self._get_item_group(product_dict.get("product_type")), + "has_variants": has_variant, + "attributes": attributes or [], + "stock_uom": product_dict.get("uom") or _("Nos"), + "sku": product_dict.get("sku") or _get_sku(product_dict), + "default_warehouse": warehouse, + "image": _get_item_image(product_dict), + "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), + } + + 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"] + + if not _match_sku_and_link_item( + item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant + ): + ecommerce_item.create_ecommerce_item( + MODULE_NAME, + integration_item_code, + item_dict, + variant_id=variant_id, + sku=sku, + variant_of=variant_of, + has_variants=has_variant, + ) + + def _create_item_variants(self, product_dict, warehouse, attributes): + template_item = ecommerce_item.get_erpnext_item( + MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 + ) + + if template_item: + for variant in product_dict.get("variants"): + shopify_item_variant = { + "id": product_dict.get("id"), + "variant_id": variant.get("id"), + "item_code": variant.get("id"), + "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), + "product_type": product_dict.get("product_type"), + "sku": variant.get("sku"), + "uom": template_item.stock_uom or _("Nos"), + "item_price": variant.get("price"), + "weight_unit": variant.get("weight_unit"), + "weight": variant.get("weight"), + } + + for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): + if variant.get(variant_attr): + attributes[i].update( + { + "attribute_value": self._get_attribute_value( + variant.get(variant_attr), attributes[i] + ) + } + ) + self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) + + def _get_attribute_value(self, variant_attr_val, attribute): + attribute_value = frappe.db.sql( + """select attribute_value from `tabItem Attribute Value` + where parent = %s and (abbr = %s or attribute_value = %s)""", + (attribute["attribute"], variant_attr_val, variant_attr_val), + as_list=1, + ) + return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) + + def _get_item_group(self, product_type=None): + parent_item_group = get_root_of("Item Group") + + if not product_type: + return parent_item_group + + if frappe.db.get_value("Item Group", product_type, "name"): + return product_type + item_group = frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": product_type, + "parent_item_group": parent_item_group, + "is_group": "No", + } + ).insert() + return item_group.name + + def _get_supplier(self, product_dict): + if product_dict.get("vendor"): + supplier = frappe.db.sql( + f"""select name from tabSupplier + where name = %s or {SUPPLIER_ID_FIELD} = %s """, + (product_dict.get("vendor"), product_dict.get("vendor").lower()), + as_list=1, + ) + + if supplier: + return product_dict.get("vendor") + supplier = frappe.get_doc( + { + "doctype": "Supplier", + "supplier_name": product_dict.get("vendor"), + SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), + "supplier_group": self._get_supplier_group(), + } + ).insert() + return supplier.name + else: + return "" + + def _get_supplier_group(self): + supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) + if not supplier_group: + supplier_group = frappe.get_doc( + {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} + ).insert() + return supplier_group.name + return supplier_group + + +def _add_weight_details(product_dict): + variants = product_dict.get("variants") + if variants: + product_dict["weight"] = variants[0]["weight"] + product_dict["weight_unit"] = variants[0]["weight_unit"] + + +def _has_variants(product_dict) -> bool: + options = product_dict.get("options") + return bool(options and "Default Title" not in options[0]["values"]) + + +def _get_sku(product_dict): + if product_dict.get("variants"): + return product_dict.get("variants")[0].get("sku") + return "" + + +def _get_item_image(product_dict): + if product_dict.get("image"): + return product_dict.get("image").get("src") + return None + + +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code or product_id. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if variant_of or has_variant: + return False + + # Try matching by SKU first + if sku: + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_name, + "integration_item_code": product_id, + "has_variants": 0, + "variant_id": cstr(variant_id), + "sku": sku, + } + ) + ecommerce_item.insert() + return True + except Exception: + pass + + # Also try matching by product_id as item_code + item_name = frappe.db.get_value("Item", {"item_code": product_id}) + if item_name: + try: + ecommerce_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_name, + "integration_item_code": product_id, + "has_variants": 0, + "variant_id": cstr(variant_id), + "sku": sku or "", + } + ) + ecommerce_item.insert() + return True + except Exception: + return False + + return False + + +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" + for item in order.get("line_items", []): + product_id = item.get("product_id") + variant_id = item.get("variant_id") + sku = item.get("sku") + + # Skip items with null product_id - mapped to fallback items + if not product_id: + continue + + try: + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + if not product.is_synced(): + product.sync_product() + except frappe.DuplicateEntryError: + frappe.logger().info(f"Item {product_id} already exists, skipping") + continue + except Exception as e: + frappe.logger().error(f"Error syncing item {product_id}: {str(e)}") + if "IntegrityError" not in str(e): + raise + continue + + +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + product_id = shopify_item.get("product_id") + variant_id = shopify_item.get("variant_id") + sku = shopify_item.get("sku") + title = shopify_item.get("title", "") + + # Handle items without product_id (tips, samples, fees) + if not product_id: + fallback_item = get_shopify_fallback_item(title) + + frappe.logger().info( + f"Line item '{title}' has no product_id - mapped to: {fallback_item}" + ) + + if frappe.db.exists("Item", fallback_item): + return fallback_item + else: + frappe.throw( + f"Fallback item '{fallback_item}' not found for '{title}'" + ) + + # Original logic continues + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=product_id, + variant_id=variant_id, + sku=sku, + ) + if 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`. + + New items are pushed to shopify and changes to existing items are + updated depending on what is configured in "Shopify Setting" doctype. + """ + template_item = item = doc # alias for readability + # a new item recieved from ecommerce_integrations is being inserted + if item.flags.from_integration: + return + + setting = frappe.get_doc(SETTING_DOCTYPE) + + if not setting.is_enabled() or not setting.upload_erpnext_items: + return + + if frappe.flags.in_import: + return + + if item.has_variants: + return + + if len(item.attributes) > 3: + msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) + return + + if doc.variant_of and not setting.upload_variants_as_items: + msgprint(_("Enable variant sync in setting to upload item to Shopify.")) + return + + if item.variant_of: + template_item = frappe.get_doc("Item", item.variant_of) + + product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + is_new_product = not bool(product_id) + + if is_new_product: + product = Product() + product.published = False + product.status = "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() + + 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: + 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" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw( + _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ) + product.variants.append(Variant(variant_attributes)) + + is_successful = product.save() + if is_successful and item.variant_of: + map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) + + write_upload_log(status=is_successful, product=product, item=item, action="Updated") + + +def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): + variant_product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + if not variant_product_id: + for variant in shopify_product.variants: + if ( + variant.option1 == variant_attributes.get("option1") + and variant.option2 == variant_attributes.get("option2") + and variant.option3 == variant_attributes.get("option3") + ): + variant_product_id = str(variant.id) + if not frappe.flags.in_test: + frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": erpnext_item.name, + "integration": MODULE_NAME, + "integration_item_code": str(shopify_product.id), + "variant_id": variant_product_id, + "sku": str(variant.sku), + "variant_of": erpnext_item.variant_of, + } + ).insert() + break + if not variant_product_id: + msgprint(_("Shopify: Couldn't sync item variant.")) + return variant_product_id + + +def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): + """Map erpnext fields to shopify, called both when updating and creating new products.""" + + shopify_product.title = erpnext_item.item_name + shopify_product.body_html = erpnext_item.description + shopify_product.product_type = erpnext_item.item_group + + if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): + # reverse lookup for key + uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) + shopify_product.weight = erpnext_item.weight_per_unit + shopify_product.weight_unit = uom + + if erpnext_item.disabled: + shopify_product.status = "draft" + shopify_product.published = False + msgprint(_("Status of linked Shopify product is changed to Draft.")) + + +def get_shopify_weight_uom(erpnext_weight_uom: str) -> str: + for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): + if erpnext_uom == erpnext_weight_uom: + return shopify_uom + + +def update_default_variant_properties( + shopify_product: Product, + is_stock_item: bool, + sku: str | None = None, + price: float | None = None, +): + """Shopify creates default variant upon saving the product. + + Some item properties are supposed to be updated on the default variant. + Input: saved shopify_product, sku and price + """ + default_variant: Variant = shopify_product.variants[0] + + # this will create Inventory item and qty will be updated by scheduled job. + if is_stock_item: + default_variant.inventory_management = "shopify" + + if price is not None: + default_variant.price = price + if sku is not None: + default_variant.sku = sku + + +def write_upload_log(status: bool, product: Product, item, action="Created") -> None: + if not status: + msg = _("Failed to upload item to Shopify") + "
" + msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) + msgprint(msg, title="Note", indicator="orange") + + create_shopify_log( + status="Error", + request_data=product.to_dict(), + message=msg, + method="upload_erpnext_item", + ) + else: + create_shopify_log( + status="Success", + request_data=product.to_dict(), + message=f"{action} Item: {item.name}, shopify product: {product.id}", + method="upload_erpnext_item", + ) From 668069ca7e8d8319c3540cc183e56ef1a96fafce Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Fri, 2 Jan 2026 11:36:33 +0530 Subject: [PATCH 02/18] docs: Add comprehensive technical documentation for null product_id handling --- ...NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md diff --git a/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md new file mode 100644 index 000000000..d3eb41fe8 --- /dev/null +++ b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,718 @@ +# Technical Documentation: Shopify Null Product ID Handling + +## Document Information +- **Version:** 1.0 +- **Date:** December 16, 2025 +- **Branch:** `fix/shopify-null-product-id-fallback-mapping` +- **Commit:** `eaf8d8a` +- **Author:** Development Team + +--- + +## Table of Contents +1. [Problem Statement](#problem-statement) +2. [Solution Overview](#solution-overview) +3. [Files Modified](#files-modified) +4. [Detailed Code Changes](#detailed-code-changes) +5. [Function Reference](#function-reference) +6. [Data Flow](#data-flow) +7. [Testing Scenarios](#testing-scenarios) +8. [Dependencies](#dependencies) + +--- + +## Problem Statement + +### Issue +Shopify orders containing line items without a `product_id` (such as tips, samples, rush fees, adjustments) were failing to sync to ERPNext with the error: + +``` +expected String to be a id +``` + +### Root Causes +1. **Null Product ID Items**: Shopify allows line items without `product_id` for non-product charges (tips, samples, fees, adjustments) +2. **Missing Fallback Logic**: The system attempted to sync these items as regular products, causing API errors +3. **Early Filtering**: Items with null `product_id` were filtered out by `product_exists` check before reaching item mapping logic +4. **Duplicate Item Errors**: Existing items with missing Ecommerce Item link records caused duplicate errors that crashed the sync process + +### Impact +- Orders with tips, samples, or fees failed to sync completely +- Sales Orders were not created for affected orders +- Manual intervention required for each failed order + +--- + +## Solution Overview + +### Approach +Implemented a multi-layered solution: + +1. **Intelligent Fallback Mapping**: Map null `product_id` items to pre-configured fallback items based on line item title keywords +2. **Early Processing**: Process null `product_id` items before `product_exists` validation +3. **Error Handling**: Add comprehensive error handling to prevent sync failures +4. **Enhanced Matching**: Improve item matching to handle both SKU and product_id based item codes + +### Fallback Item Mapping +| Line Item Title Contains | Mapped To ERPNext Item | +|-------------------------|------------------------| +| "tip" | `SHOPIFY-TIP` | +| "sample" | `SHOPIFY-SAMPLE` | +| "rush", "rush order", "rush fee" | `SHOPIFY-RUSH-FEE` | +| "adjustment", "price adjustment" | `SHOPIFY-ADJUSTMENT` | +| Any other text or empty | `SHOPIFY-MISC` | + +**Note:** These fallback items must exist in ERPNext before deployment. + +--- + +## Files Modified + +### Summary +- **2 files modified** +- **~95 lines added** +- **~15 lines modified** + +### File List +1. `ecommerce_integrations/shopify/product.py` +2. `ecommerce_integrations/shopify/order.py` + +--- + +## Detailed Code Changes + +### File 1: `ecommerce_integrations/shopify/product.py` + +#### Change 1: New Function - `get_shopify_fallback_item()` + +**Location:** Lines 22-46 (after imports, before `ShopifyProduct` class) + +**Purpose:** Maps Shopify line item titles to appropriate ERPNext fallback items + +**Code Added:** +```python +def get_shopify_fallback_item(title): + """Map Shopify line items without product_id to appropriate fallback items. + + Args: + title (str): Line item title from Shopify + + Returns: + str: ERPNext Item code + """ + if not title: + return "SHOPIFY-MISC" + + title_lower = title.lower() + + # Map based on title keywords + if "tip" in title_lower: + return "SHOPIFY-TIP" + elif "sample" in title_lower: + return "SHOPIFY-SAMPLE" + elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower: + return "SHOPIFY-RUSH-FEE" + elif "adjustment" in title_lower or "price adjustment" in title_lower: + return "SHOPIFY-ADJUSTMENT" + else: + return "SHOPIFY-MISC" +``` + +**Logic:** +- Case-insensitive keyword matching +- Priority order: tip → sample → rush → adjustment → misc +- Returns default `SHOPIFY-MISC` for empty titles or unmatched items + +--- + +#### Change 2: Enhanced Function - `_match_sku_and_link_item()` + +**Location:** Lines 301-351 + +**Purpose:** Match existing ERPNext items by both SKU and product_id to prevent duplicate creation + +**Before:** +```python +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if not sku or variant_of or has_variant: + return False + + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + return False +``` + +**After:** +```python +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code or product_id. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if variant_of or has_variant: + return False + + # Try matching by SKU first + if sku: + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + pass + + # Also try matching by product_id as item_code + item_name = frappe.db.get_value("Item", {"item_code": product_id}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + return False + + return False +``` + +**Key Changes:** +1. Removed `if not sku` early return - now continues even without SKU +2. Added two-stage matching: + - **Stage 1:** Match by SKU (original logic) + - **Stage 2:** Match by product_id as item_code (new) +3. Better error handling with `pass` in Stage 1 to allow Stage 2 attempt + +**Why:** Items created with product_id as item_code weren't found by SKU matching, causing duplicate errors. + +--- + +#### Change 3: Enhanced Function - `create_items_if_not_exist()` + +**Location:** Lines 354-376 + +**Purpose:** Skip syncing items with null product_id and add error handling + +**Before:** +```python +def create_items_if_not_exist(order): + """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) + + if not product.is_synced(): + product.sync_product() +``` + +**After:** +```python +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" + for item in order.get("line_items", []): + product_id = item.get("product_id") + variant_id = item.get("variant_id") + sku = item.get("sku") + + # Skip items with null product_id - mapped to fallback items + if not product_id: + continue + + try: + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + if not product.is_synced(): + product.sync_product() + except frappe.DuplicateEntryError: + frappe.logger().info(f"Item {product_id} already exists, skipping") + continue + except Exception as e: + frappe.logger().error(f"Error syncing item {product_id}: {str(e)}") + if "IntegrityError" not in str(e): + raise + continue +``` + +**Key Changes:** +1. Changed `item["product_id"]` to `item.get("product_id")` - handles null values +2. Added null check: `if not product_id: continue` - skips null product_id items +3. Wrapped sync logic in try-except block +4. Catches `DuplicateEntryError` - logs and continues +5. Catches `IntegrityError` - logs and continues (database constraint violations) +6. Re-raises other exceptions - real errors still propagate + +**Why:** Prevents sync crashes when duplicate items exist or database constraints fail. + +--- + +#### Change 4: Enhanced Function - `get_item_code()` + +**Location:** Lines 379-412 + +**Purpose:** Handle null product_id items by mapping to fallback items + +**Before:** +```python +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=shopify_item.get("product_id"), + variant_id=shopify_item.get("variant_id"), + sku=shopify_item.get("sku"), + ) + if item: + return item.item_code +``` + +**After:** +```python +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + product_id = shopify_item.get("product_id") + variant_id = shopify_item.get("variant_id") + sku = shopify_item.get("sku") + title = shopify_item.get("title", "") + + # Handle items without product_id (tips, samples, fees) + if not product_id: + fallback_item = get_shopify_fallback_item(title) + + frappe.logger().info( + f"Line item '{title}' has no product_id - mapped to: {fallback_item}" + ) + + if frappe.db.exists("Item", fallback_item): + return fallback_item + else: + frappe.throw( + f"Fallback item '{fallback_item}' not found for '{title}'" + ) + + # Original logic continues + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=product_id, + variant_id=variant_id, + sku=sku, + ) + if item: + return item.item_code +``` + +**Key Changes:** +1. Extract `product_id`, `variant_id`, `sku`, and `title` at function start +2. Added null `product_id` check at beginning +3. Call `get_shopify_fallback_item(title)` for mapping +4. Log the mapping for debugging +5. Verify fallback item exists in ERPNext +6. Return fallback item code or throw error if not found +7. Original logic continues for items with valid `product_id` + +**Why:** Provides intelligent mapping for non-product line items before they cause errors. + +--- + +### File 2: `ecommerce_integrations/shopify/order.py` + +#### Change 1: Enhanced Function - `get_order_items()` + +**Location:** Lines 139-194 + +**Purpose:** Process null product_id items before product_exists validation + +**Before:** +```python +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): + items = [] + all_product_exists = True + product_not_exists = [] + + for shopify_item in order_items: + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append({...}) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + items.append({...}) + else: + items = [] + + return items +``` + +**After:** +```python +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): + items = [] + all_product_exists = True + product_not_exists = [] + + for shopify_item in order_items: + product_id = shopify_item.get("product_id") + + # Handle items without product_id (tips, samples, fees) - skip product_exists check + if not product_id: + item_code = get_item_code(shopify_item) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append({...}) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + items.append({...}) + else: + items = [] + + return items +``` + +**Key Changes:** +1. Extract `product_id` at start of loop +2. Added null `product_id` check **before** `product_exists` check +3. For null `product_id`: + - Call `get_item_code()` which routes to fallback mapping + - Add item to list with proper structure + - Use `shopify_item.get("name") or shopify_item.get("title")` for item_name + - Use `"Nos"` as default stock_uom + - Continue to next item (skip `product_exists` validation) +4. Original logic preserved for items with valid `product_id` + +**Why:** Null `product_id` items were being filtered out by `product_exists` check before reaching the mapping logic. + +--- + +## Function Reference + +### New Functions + +#### `get_shopify_fallback_item(title: str) -> str` +- **File:** `product.py` +- **Line:** 22-46 +- **Parameters:** + - `title` (str): Line item title from Shopify order +- **Returns:** ERPNext Item code (str) +- **Description:** Maps line item titles to fallback items using keyword matching +- **Dependencies:** None +- **Side Effects:** None + +### Modified Functions + +#### `_match_sku_and_link_item()` +- **File:** `product.py` +- **Line:** 301-351 +- **Changes:** Two-stage matching (SKU + product_id) +- **Impact:** Prevents duplicate item creation errors + +#### `create_items_if_not_exist()` +- **File:** `product.py` +- **Line:** 354-376 +- **Changes:** Null check + error handling +- **Impact:** Skips null product_id items, handles duplicates gracefully + +#### `get_item_code()` +- **File:** `product.py` +- **Line:** 379-412 +- **Changes:** Null product_id handling with fallback mapping +- **Impact:** Maps non-product items to fallback items + +#### `get_order_items()` +- **File:** `order.py` +- **Line:** 139-194 +- **Changes:** Process null product_id items early +- **Impact:** Ensures null product_id items are included in Sales Orders + +--- + +## Data Flow + +### Order Sync Flow (Before) +``` +Shopify Order Received + ↓ +sync_sales_order() + ↓ +create_items_if_not_exist() + ↓ +For each line_item: + - product_id = item["product_id"] ← CRASH if null + - Try to sync product + ↓ +get_order_items() + ↓ +For each line_item: + - Check product_exists ← Filters out null product_id + - get_item_code() ← Never reached for null product_id + ↓ +Sales Order Created (incomplete) +``` + +### Order Sync Flow (After) +``` +Shopify Order Received + ↓ +sync_sales_order() + ↓ +create_items_if_not_exist() + ↓ +For each line_item: + - product_id = item.get("product_id") + - if not product_id: continue ← Skip syncing + - Try to sync product + - Catch duplicate errors → log & continue + ↓ +get_order_items() + ↓ +For each line_item: + - product_id = shopify_item.get("product_id") + - if not product_id: + - get_item_code() → get_shopify_fallback_item() + - Map to SHOPIFY-TIP/SAMPLE/etc. + - Add to items list + - continue + - Original logic for valid product_id + ↓ +Sales Order Created (complete with all items) +``` + +### Fallback Mapping Flow +``` +Line Item with null product_id + ↓ +get_item_code(shopify_item) + ↓ +Check: product_id is null? + ↓ YES +get_shopify_fallback_item(title) + ↓ +Keyword Matching: + - "tip" → SHOPIFY-TIP + - "sample" → SHOPIFY-SAMPLE + - "rush" → SHOPIFY-RUSH-FEE + - "adjustment" → SHOPIFY-ADJUSTMENT + - else → SHOPIFY-MISC + ↓ +Verify item exists in ERPNext + ↓ +Return item_code +``` + +--- + +## Testing Scenarios + +### Test Case 1: Order with Tip +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Tip" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-TIP + +**Test Command:** +```python +test_order_sync("43-31469-21") +``` + +### Test Case 2: Order with Sample +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Sample Fabric" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-SAMPLE + +### Test Case 3: Order with Rush Fee +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Rush Order Fee" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-RUSH-FEE + +### Test Case 4: Order with Multiple Non-Product Items +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Tip" (product_id: null) +- Line Item 3: "Rush Fee" (product_id: null) +- Line Item 4: "Order Adjustment" (product_id: null) + +**Expected Output:** +- Sales Order with 4 line items +- All items mapped correctly + +### Test Case 5: Duplicate Item Handling +**Input:** +- Order with existing item (product_id: 8706562621682) +- Ecommerce Item link missing + +**Expected Output:** +- Item matched by product_id +- Ecommerce Item link created +- No duplicate error +- Order syncs successfully + +### Test Case 6: Missing Fallback Item +**Input:** +- Order with "Tip" (product_id: null) +- SHOPIFY-TIP item not found in ERPNext + +**Expected Output:** +- Error: "Fallback item 'SHOPIFY-TIP' not found for 'Tip'" +- Order sync fails with clear error message + +--- + +## Dependencies + +### Required ERPNext Items +The following items must exist in ERPNext before deployment: + +1. **SHOPIFY-TIP** + - Item Code: `SHOPIFY-TIP` + - Item Name: "Customer Tip" (or similar) + - Item Group: As per business requirements + - Stock UOM: "Nos" + +2. **SHOPIFY-SAMPLE** + - Item Code: `SHOPIFY-SAMPLE` + - Item Name: "Sample Item" (or similar) + +3. **SHOPIFY-RUSH-FEE** + - Item Code: `SHOPIFY-RUSH-FEE` + - Item Name: "Rush Order Fee" (or similar) + +4. **SHOPIFY-ADJUSTMENT** + - Item Code: `SHOPIFY-ADJUSTMENT` + - Item Name: "Order Adjustment" (or similar) + +5. **SHOPIFY-MISC** + - Item Code: `SHOPIFY-MISC` + - Item Name: "Miscellaneous Charge" (or similar) + +### Code Dependencies +- `frappe` - Core Frappe framework +- `ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item` - Ecommerce Item doctype +- `ecommerce_integrations.shopify.constants` - Module constants +- `ecommerce_integrations.shopify.utils` - Utility functions + +### No Breaking Changes +- All changes are backward compatible +- Existing functionality preserved +- Only adds new handling for edge cases + +--- + +## Deployment Checklist + +- [ ] Verify all 5 fallback items exist in ERPNext +- [ ] Test with sample orders containing tips/samples/fees +- [ ] Verify error handling works for duplicate items +- [ ] Check logs for fallback mapping messages +- [ ] Test orders with mixed product and non-product items +- [ ] Verify Sales Orders are created with correct line items +- [ ] Monitor Integration Log for any new errors + +--- + +## Rollback Procedure + +If issues occur, restore from backup files: + +```bash +cd apps/ecommerce_integrations/ecommerce_integrations/shopify +cp product.py.backup.YYYYMMDD_HHMM product.py +cp order.py.backup.YYYYMMDD_HHMM order.py +bench restart +``` + +--- + +## Support & Troubleshooting + +### Common Issues + +1. **"Fallback item 'SHOPIFY-XXX' not found"** + - **Solution:** Create the missing fallback item in ERPNext + +2. **Orders still failing with duplicate errors** + - **Solution:** Check if `_match_sku_and_link_item()` is finding existing items correctly + +3. **Null product_id items not appearing in Sales Order** + - **Solution:** Verify `get_order_items()` is processing null items before `product_exists` check + +### Logging + +All fallback mappings are logged: +``` +Line item 'Tip' has no product_id - mapped to: SHOPIFY-TIP +``` + +Check logs with: +```bash +bench --site [site] logs | grep -i "mapped to" +``` + +--- + +## Version History + +- **v1.0** (2025-12-16): Initial implementation + - Added fallback item mapping + - Enhanced error handling + - Improved item matching logic + +--- + +## Contact + +For questions or issues, refer to: +- GitHub Repository: `sahilvikas/ecommerce_integrations` +- Branch: `fix/shopify-null-product-id-fallback-mapping` +- Commit: `eaf8d8a` + From 1829f07765991c5d52a1d3ad675b862d33aa0da8 Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Tue, 6 Jan 2026 17:22:37 +0530 Subject: [PATCH 03/18] feat: Add dual Shopify store support - Added Store 2 configuration fields to Shopify Setting DocType - Created Shopify Webhook Store 2 child DocType for Store 2 webhooks - Implemented store-aware webhook routing via X-Shopify-Shop-Domain header - Modified API session decorator to use correct store credentials - Added automatic webhook registration/unregistration for Store 2 - Updated order, invoice, and fulfillment processors with store context - Graceful error handling prevents Store 2 issues from affecting Store 1 - Full backward compatibility maintained for Store 1 --- ecommerce_integrations/shopify/connection.py | 95 ++++++++++++++++--- .../shopify_setting/shopify_setting.json | 70 ++++++++++++++ .../shopify_setting/shopify_setting.py | 55 ++++++++++- .../shopify_webhook_store_2/__init__.py | 0 .../shopify_webhook_store_2.json | 42 ++++++++ .../shopify_webhook_store_2.py | 9 ++ ecommerce_integrations/shopify/fulfillment.py | 12 ++- ecommerce_integrations/shopify/invoice.py | 12 ++- ecommerce_integrations/shopify/order.py | 15 ++- 9 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py create mode 100644 ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json create mode 100644 ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4a4c7c86d..27ad8222d 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -19,7 +19,10 @@ 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.""" + """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns. + + Store-aware: Checks for store_name in kwargs or frappe.local.shopify_store_name to determine which store's credentials to use. + """ @functools.wraps(func) def wrapper(*args, **kwargs): @@ -29,7 +32,18 @@ def wrapper(*args, **kwargs): setting = frappe.get_doc(SETTING_DOCTYPE) if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + # Determine which store's credentials to use + store_name = kwargs.get('store_name') or getattr(frappe.local, 'shopify_store_name', None) + + # Use Store 2 credentials if specified and enabled + if store_name and store_name != "Store 1" and setting.enable_store_2: + auth_details = (setting.shopify_url_2, API_VERSION, setting.get_password("password_2")) + frappe.logger().info(f"Using Store 2 ({store_name}) API credentials") + else: + # Default to Store 1 + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + if store_name: + frappe.logger().info(f"Using Store 1 API credentials") with Session.temp(*auth_details): return func(*args, **kwargs) @@ -94,35 +108,90 @@ def get_callback_url() -> str: @frappe.whitelist(allow_guest=True) def store_request_data() -> None: if frappe.request: + # Detect which store sent this webhook + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + + if not shop_domain: + frappe.log_error( + title="Shopify Webhook - Missing Shop Domain", + message="X-Shopify-Shop-Domain header not found" + ) + frappe.throw(_("Missing shop domain header")) + + # Get settings + setting = frappe.get_doc(SETTING_DOCTYPE) + + # Determine store and credentials + store_name = None + shared_secret = None + + # Check Store 1 + if setting.shopify_url and shop_domain in setting.shopify_url: + store_name = "Store 1" + shared_secret = setting.shared_secret + frappe.logger().info(f"Webhook from Store 1: {shop_domain}") + + # Check Store 2 + elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: + store_name = setting.store_2_name or "Store 2" + shared_secret = setting.shared_secret_2 + frappe.logger().info(f"Webhook from Store 2 ({store_name}): {shop_domain}") + + # Unknown store + else: + frappe.log_error( + title="Shopify Webhook - Unknown Store", + message=f"Shop domain '{shop_domain}' does not match configured stores" + ) + frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) + + # Validate with correct secret hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - - _validate_request(frappe.request, hmac_header) + _validate_request(frappe.request, hmac_header, shared_secret) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") + + # Set store context for API sessions + frappe.local.shopify_store_name = store_name - process_request(data, event) + process_request(data, event, store_name) -def process_request(data, event): +def process_request(data, event, store_name=None): + """Process webhook request with store context. + + Args: + data: The webhook payload data + event: The event type (e.g., 'orders/create') + store_name: Name of the store that sent the webhook (default: None for backward compatibility) + """ + # Log which store is processing + if store_name: + frappe.logger().info(f"Processing {event} from {store_name}") + # create log log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - # enqueue backround job + # enqueue backround job with store context 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, "store_name": store_name}, ) -def _validate_request(req, hmac_header): - settings = frappe.get_doc(SETTING_DOCTYPE) - secret_key = settings.shared_secret - - sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) +def _validate_request(req, hmac_header, shared_secret): + """Validate Shopify webhook using HMAC. + + Args: + req: The request object + hmac_header: The HMAC header from request + shared_secret: Shared secret for the specific store + """ + sig = base64.b64encode(hmac.new(shared_secret.encode("utf8"), req.data, hashlib.sha256).digest()) if sig != bytes(hmac_header.encode()): create_shopify_log(status="Error", request_data=req.data) diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index 01722169b..a74234f40 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -14,6 +14,15 @@ "shared_secret", "section_break_4", "webhooks", + "store_2_section", + "enable_store_2", + "store_2_name", + "column_break_store_2", + "shopify_url_2", + "password_2", + "shared_secret_2", + "webhooks_section_2", + "webhooks_2", "customer_settings_section", "default_customer", "column_break_14", @@ -113,6 +122,67 @@ "options": "Shopify Webhooks", "read_only": 1 }, + { + "collapsible": 1, + "fieldname": "store_2_section", + "fieldtype": "Section Break", + "label": "Store 2 - Additional Shopify Store" + }, + { + "default": "0", + "fieldname": "enable_store_2", + "fieldtype": "Check", + "label": "Enable Store 2" + }, + { + "default": "Store 2", + "depends_on": "enable_store_2", + "description": "User-friendly name for reporting (e.g., 'ZipCovers')", + "fieldname": "store_2_name", + "fieldtype": "Data", + "label": "Store 2 Name" + }, + { + "fieldname": "column_break_store_2", + "fieldtype": "Column Break" + }, + { + "depends_on": "enable_store_2", + "description": "e.g., zipcovers.myshopify.com", + "fieldname": "shopify_url_2", + "fieldtype": "Data", + "label": "Shop URL (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "depends_on": "enable_store_2", + "fieldname": "password_2", + "fieldtype": "Password", + "label": "Password / Access Token (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "depends_on": "enable_store_2", + "fieldname": "shared_secret_2", + "fieldtype": "Data", + "label": "Shared Secret / API Secret (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "collapsible": 1, + "depends_on": "enable_store_2", + "fieldname": "webhooks_section_2", + "fieldtype": "Section Break", + "label": "Webhooks (Store 2)" + }, + { + "depends_on": "enable_store_2", + "fieldname": "webhooks_2", + "fieldtype": "Table", + "label": "Webhooks", + "options": "Shopify Webhook Store 2", + "read_only": 1 + }, { "fieldname": "customer_settings_section", "fieldtype": "Section Break", diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py index dc974e70e..a7a4853c4 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py @@ -40,6 +40,11 @@ def validate(self): if self.shopify_url: self.shopify_url = self.shopify_url.replace("https://", "") + + # Clean Store 2 URL if provided + if self.shopify_url_2: + self.shopify_url_2 = self.shopify_url_2.replace("https://", "") + self._handle_webhooks() self._validate_warehouse_links() self._initalize_default_values() @@ -52,22 +57,68 @@ def on_update(self): migrate_from_old_connector() def _handle_webhooks(self): + """Handle webhook registration/unregistration for both stores.""" + + # Handle Store 1 webhooks if self.is_enabled() and not self.webhooks: new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password")) if not new_webhooks: - msg = _("Failed to register webhooks with Shopify.") + "
" + msg = _("Failed to register webhooks with Shopify Store 1.") + "
" msg += _("Please check credentials and retry.") + " " msg += _("Disabling and re-enabling the integration might also help.") frappe.throw(msg) for webhook in new_webhooks: self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic}) + + frappe.logger().info(f"Registered {len(new_webhooks)} webhooks for Store 1") elif not self.is_enabled(): connection.unregister_webhooks(self.shopify_url, self.get_password("password")) - self.webhooks = list() # remove all webhooks + frappe.logger().info("Unregistered Store 1 webhooks") + + # Handle Store 2 webhooks + if self.enable_store_2 and self.shopify_url_2 and not self.webhooks_2: + try: + new_webhooks_2 = connection.register_webhooks( + self.shopify_url_2, + self.get_password("password_2") + ) + + if not new_webhooks_2: + msg = _("Failed to register webhooks with Shopify Store 2.") + "
" + msg += _("Please check Store 2 credentials and retry.") + frappe.throw(msg) + + for webhook in new_webhooks_2: + self.append("webhooks_2", {"webhook_id": webhook.id, "method": webhook.topic}) + + frappe.logger().info(f"Registered {len(new_webhooks_2)} webhooks for Store 2 ({self.store_2_name})") + + except Exception as e: + frappe.log_error( + title="Store 2 Webhook Registration Failed", + message=f"Error registering webhooks for Store 2: {str(e)}" + ) + # Don't throw - allow Store 1 to continue working + frappe.msgprint( + _("Warning: Failed to register Store 2 webhooks. Store 1 will continue working. Error: {0}").format(str(e)), + alert=True + ) + + elif not self.enable_store_2 and self.shopify_url_2 and self.webhooks_2: + # Store 2 disabled but webhooks exist - unregister them + try: + connection.unregister_webhooks(self.shopify_url_2, self.get_password("password_2")) + self.webhooks_2 = list() # remove all Store 2 webhooks + frappe.logger().info("Unregistered Store 2 webhooks") + except Exception as e: + frappe.log_error( + title="Store 2 Webhook Unregistration Failed", + message=f"Error unregistering webhooks for Store 2: {str(e)}" + ) def _validate_warehouse_links(self): for wh_map in self.shopify_warehouse_mapping: diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json new file mode 100644 index 000000000..f9eec3bce --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "creation": "2025-01-02 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "webhook_id", + "method" + ], + "fields": [ + { + "fieldname": "webhook_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Webhook ID", + "read_only": 1 + }, + { + "fieldname": "method", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Method", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-01-02 00:00:00.000000", + "modified_by": "Administrator", + "module": "Shopify", + "name": "Shopify Webhook Store 2", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} + diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py new file mode 100644 index 000000000..2a093fe61 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe and contributors +# For license information, please see LICENSE + +from frappe.model.document import Document + + +class ShopifyWebhookStore2(Document): + pass + diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 5ffc0ebae..da04a0cbe 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -14,12 +14,22 @@ 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, store_name=None): + """Prepare delivery note from Shopify fulfillment. + + Args: + payload: Order data from Shopify + request_id: Shopify Log entry ID + store_name: Name of the store (for logging) + """ frappe.set_user("Administrator") setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id order = payload + + if store_name: + frappe.logger().info(f"Preparing delivery note for order from {store_name}") try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index f841cb416..8915d17e1 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -10,7 +10,14 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def prepare_sales_invoice(payload, request_id=None): +def prepare_sales_invoice(payload, request_id=None, store_name=None): + """Prepare sales invoice from Shopify order. + + Args: + payload: Order data from Shopify + request_id: Shopify Log entry ID + store_name: Name of the store (for logging) + """ from ecommerce_integrations.shopify.order import get_sales_order order = payload @@ -18,6 +25,9 @@ def prepare_sales_invoice(payload, request_id=None): frappe.set_user("Administrator") setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id + + if store_name: + frappe.logger().info(f"Preparing sales invoice for order from {store_name}") try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 086853115..235bc4521 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -29,10 +29,21 @@ } -def sync_sales_order(payload, request_id=None): +def sync_sales_order(payload, request_id=None, store_name=None): + """Sync Shopify order to ERPNext Sales Order. + + Args: + payload: Order data from Shopify + request_id: Shopify Log entry ID + store_name: Name of the store (for logging and context) + """ order = payload frappe.set_user("Administrator") frappe.flags.request_id = request_id + + # Log which store is syncing + if store_name: + frappe.logger().info(f"Syncing order {order.get('name')} from {store_name}") 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") @@ -49,6 +60,8 @@ def sync_sales_order(payload, request_id=None): else: customer.update_existing_addresses(shopify_customer) + # Store context is already set in frappe.local.shopify_store_name + # This will be used by temp_shopify_session decorator create_items_if_not_exist(order) setting = frappe.get_doc(SETTING_DOCTYPE) From 9d6035a0b15edca78868f7a69527d847a9b936e7 Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Tue, 27 Jan 2026 01:45:58 +0530 Subject: [PATCH 04/18] Add comprehensive debugging logs for Store 2 webhook matching issue --- ecommerce_integrations/shopify/connection.py | 85 ++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 27ad8222d..9e86e8ec3 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -118,12 +118,87 @@ def store_request_data() -> None: ) frappe.throw(_("Missing shop domain header")) - # Get settings - setting = frappe.get_doc(SETTING_DOCTYPE) + # Get settings + setting = frappe.get_doc(SETTING_DOCTYPE) + + # === COMPREHENSIVE DEBUG LOGGING === + debug_info = [] + debug_info.append("=" * 80) + debug_info.append("SHOPIFY WEBHOOK STORE MATCHING DEBUG") + debug_info.append("=" * 80) + debug_info.append("") + + # 1. Incoming shop_domain with quotes + debug_info.append(f"1. Incoming shop_domain: '{shop_domain}'") + debug_info.append(f" Type: {type(shop_domain)}") + debug_info.append(f" Length: {len(shop_domain) if shop_domain else 0}") + debug_info.append(f" Byte representation: {shop_domain.encode('utf-8') if shop_domain else 'None'}") + debug_info.append("") + + # 2. Store 1 configuration + debug_info.append(f"2. Store 1 Configuration:") + debug_info.append(f" shopify_url: '{setting.shopify_url}'") + debug_info.append(f" Type: {type(setting.shopify_url)}") + debug_info.append(f" Length: {len(setting.shopify_url) if setting.shopify_url else 0}") + debug_info.append(f" Byte representation: {setting.shopify_url.encode('utf-8') if setting.shopify_url else 'None'}") + debug_info.append("") + + # 3. Store 2 configuration + debug_info.append(f"3. Store 2 Configuration:") + debug_info.append(f" enable_store_2: {setting.enable_store_2}") + debug_info.append(f" shopify_url_2: '{setting.shopify_url_2 if setting.shopify_url_2 else 'Not Set'}'") + if setting.shopify_url_2: + debug_info.append(f" Type: {type(setting.shopify_url_2)}") + debug_info.append(f" Length: {len(setting.shopify_url_2)}") + debug_info.append(f" Byte representation: {setting.shopify_url_2.encode('utf-8')}") + debug_info.append("") + + # 4. Comparison checks for Store 1 + debug_info.append(f"4. Store 1 Comparison:") + debug_info.append(f" Condition: setting.shopify_url and shop_domain in setting.shopify_url") + debug_info.append(f" setting.shopify_url exists: {bool(setting.shopify_url)}") + if setting.shopify_url: + debug_info.append(f" shop_domain in setting.shopify_url: {shop_domain in setting.shopify_url}") + debug_info.append(f" Are they equal (==): {shop_domain == setting.shopify_url}") + debug_info.append(f" Stripped comparison: '{shop_domain.strip()}' == '{setting.shopify_url.strip()}': {shop_domain.strip() == setting.shopify_url.strip()}") + debug_info.append("") + + # 5. Comparison checks for Store 2 + debug_info.append(f"5. Store 2 Comparison:") + debug_info.append(f" Condition: setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2") + debug_info.append(f" enable_store_2: {setting.enable_store_2}") + debug_info.append(f" setting.shopify_url_2 exists: {bool(setting.shopify_url_2)}") + if setting.shopify_url_2: + debug_info.append(f" shop_domain in setting.shopify_url_2: {shop_domain in setting.shopify_url_2}") + debug_info.append(f" Are they equal (==): {shop_domain == setting.shopify_url_2}") + debug_info.append(f" Stripped comparison: '{shop_domain.strip()}' == '{setting.shopify_url_2.strip()}': {shop_domain.strip() == setting.shopify_url_2.strip()}") + debug_info.append("") + + # 6. Character-by-character comparison for Store 2 (if applicable) + if setting.shopify_url_2 and shop_domain: + debug_info.append(f"6. Character-by-Character Analysis (Store 2):") + debug_info.append(f" shop_domain chars: {[c for c in shop_domain]}") + debug_info.append(f" shopify_url_2 chars: {[c for c in setting.shopify_url_2]}") + debug_info.append(f" shop_domain repr: {repr(shop_domain)}") + debug_info.append(f" shopify_url_2 repr: {repr(setting.shopify_url_2)}") - # Determine store and credentials - store_name = None - shared_secret = None + # Check for whitespace + debug_info.append(f" shop_domain has leading/trailing spaces: {shop_domain != shop_domain.strip()}") + debug_info.append(f" shopify_url_2 has leading/trailing spaces: {setting.shopify_url_2 != setting.shopify_url_2.strip()}") + debug_info.append("") + + debug_info.append("=" * 80) + + # Log everything to Error Log + frappe.log_error( + title="Shopify Webhook Debug Info", + message="\n".join(debug_info) + ) + # === END DEBUG LOGGING === + + # Determine store and credentials + store_name = None + shared_secret = None # Check Store 1 if setting.shopify_url and shop_domain in setting.shopify_url: From abb0a589ce61d5302d27fb6f61ed63d7af52453f Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Tue, 27 Jan 2026 13:02:41 +0530 Subject: [PATCH 05/18] Fix indentation error in connection.py debug logging code --- ecommerce_integrations/shopify/connection.py | 60 ++++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 9e86e8ec3..07dcd336b 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -199,38 +199,38 @@ def store_request_data() -> None: # Determine store and credentials store_name = None shared_secret = None - - # Check Store 1 - if setting.shopify_url and shop_domain in setting.shopify_url: - store_name = "Store 1" - shared_secret = setting.shared_secret - frappe.logger().info(f"Webhook from Store 1: {shop_domain}") - - # Check Store 2 - elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: - store_name = setting.store_2_name or "Store 2" - shared_secret = setting.shared_secret_2 - frappe.logger().info(f"Webhook from Store 2 ({store_name}): {shop_domain}") - - # Unknown store - else: - frappe.log_error( - title="Shopify Webhook - Unknown Store", - message=f"Shop domain '{shop_domain}' does not match configured stores" - ) - frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) - - # Validate with correct secret - hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - _validate_request(frappe.request, hmac_header, shared_secret) + + # Check Store 1 + if setting.shopify_url and shop_domain in setting.shopify_url: + store_name = "Store 1" + shared_secret = setting.shared_secret + frappe.logger().info(f"Webhook from Store 1: {shop_domain}") + + # Check Store 2 + elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: + store_name = setting.store_2_name or "Store 2" + shared_secret = setting.shared_secret_2 + frappe.logger().info(f"Webhook from Store 2 ({store_name}): {shop_domain}") + + # Unknown store + else: + frappe.log_error( + title="Shopify Webhook - Unknown Store", + message=f"Shop domain '{shop_domain}' does not match configured stores" + ) + frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) + + # Validate with correct secret + hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + _validate_request(frappe.request, hmac_header, shared_secret) - data = json.loads(frappe.request.data) - event = frappe.request.headers.get("X-Shopify-Topic") - - # Set store context for API sessions - frappe.local.shopify_store_name = store_name + data = json.loads(frappe.request.data) + event = frappe.request.headers.get("X-Shopify-Topic") + + # Set store context for API sessions + frappe.local.shopify_store_name = store_name - process_request(data, event, store_name) + process_request(data, event, store_name) def process_request(data, event, store_name=None): From d4939e7b0ecee342c8accd60301594bc42984ee8 Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Tue, 27 Jan 2026 14:32:05 +0530 Subject: [PATCH 06/18] Fix: Multi-store Shopify integration - store context and indentation Changes: - Fix indentation error in connection.py debug logging - Add comprehensive debug logging for store matching in webhooks - Propagate store context to background jobs: * sync_sales_order() in order.py * prepare_sales_invoice() in invoice.py * prepare_delivery_note() in fulfillment.py - Set frappe.local.shopify_store_name in all background job functions - Ensures @temp_shopify_session uses correct store credentials Fixes Store 2 (0ish15-bq.myshopify.com) webhooks failing to create Sales Orders --- ecommerce_integrations/shopify/fulfillment.py | 2 ++ ecommerce_integrations/shopify/invoice.py | 2 ++ ecommerce_integrations/shopify/order.py | 7 ++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index da04a0cbe..06dbed8be 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -28,8 +28,10 @@ def prepare_delivery_note(payload, request_id=None, store_name=None): order = payload + # Set store context for API calls in this background job if store_name: frappe.logger().info(f"Preparing delivery note for order from {store_name}") + frappe.local.shopify_store_name = store_name try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index 8915d17e1..2a983e488 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -26,8 +26,10 @@ def prepare_sales_invoice(payload, request_id=None, store_name=None): setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id + # Set store context for API calls in this background job if store_name: frappe.logger().info(f"Preparing sales invoice for order from {store_name}") + frappe.local.shopify_store_name = store_name try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 235bc4521..989230b0d 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -41,9 +41,10 @@ def sync_sales_order(payload, request_id=None, store_name=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id - # Log which store is syncing + # Set store context for API calls in this background job if store_name: frappe.logger().info(f"Syncing order {order.get('name')} from {store_name}") + frappe.local.shopify_store_name = store_name 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") @@ -60,8 +61,8 @@ def sync_sales_order(payload, request_id=None, store_name=None): else: customer.update_existing_addresses(shopify_customer) - # Store context is already set in frappe.local.shopify_store_name - # This will be used by temp_shopify_session decorator + # Store context has been set in frappe.local.shopify_store_name above + # This will be used by temp_shopify_session decorator when syncing items create_items_if_not_exist(order) setting = frappe.get_doc(SETTING_DOCTYPE) From 973ee007f6df07b32ad32f961d8c7026e4f6028b Mon Sep 17 00:00:00 2001 From: Sahil Vikas Date: Mon, 2 Feb 2026 18:11:12 +0530 Subject: [PATCH 07/18] Add comprehensive Store 2 debug logging --- ecommerce_integrations/shopify/connection.py | 726 +++++++++---- ecommerce_integrations/shopify/order.py | 1005 +++++++++++------- 2 files changed, 1106 insertions(+), 625 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 07dcd336b..9abcb318b 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -1,8 +1,15 @@ +""" +connection.py - WITH COMPREHENSIVE STORE 2 LOGGING +Every step is logged so we can trace exactly where it breaks. +Logging only triggers for Store 2 to avoid cluttering logs. +""" + import base64 import functools import hashlib import hmac import json +import traceback import frappe from frappe import _ @@ -10,264 +17,525 @@ from shopify.session import Session from ecommerce_integrations.shopify.constants import ( - API_VERSION, - EVENT_MAPPER, - SETTING_DOCTYPE, - WEBHOOK_EVENTS, + API_VERSION, + EVENT_MAPPER, + SETTING_DOCTYPE, + WEBHOOK_EVENTS, ) from ecommerce_integrations.shopify.utils import create_shopify_log +def log_store2(step, message, store_name=None): + """Helper function to log only for Store 2.""" + if store_name and store_name != "Store 1": + frappe.log_error( + title=f"[STORE2 DEBUG] Step {step}", + message=f"Store: {store_name}\n\n{message}" + ) + + 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. - - Store-aware: Checks for store_name in kwargs or frappe.local.shopify_store_name to determine which store's credentials to use. - """ - - @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(): - # Determine which store's credentials to use - store_name = kwargs.get('store_name') or getattr(frappe.local, 'shopify_store_name', None) - - # Use Store 2 credentials if specified and enabled - if store_name and store_name != "Store 1" and setting.enable_store_2: - auth_details = (setting.shopify_url_2, API_VERSION, setting.get_password("password_2")) - frappe.logger().info(f"Using Store 2 ({store_name}) API credentials") - else: - # Default to Store 1 - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) - if store_name: - frappe.logger().info(f"Using Store 1 API credentials") - - with Session.temp(*auth_details): - return func(*args, **kwargs) - - return wrapper + """Any function that needs to access shopify api needs this decorator. + + Store-aware: Checks for store_name in kwargs or frappe.local.shopify_store_name + to determine which store's credentials to use. + """ + + @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(): + # Determine which store's credentials to use + store_name = kwargs.get('store_name') or getattr(frappe.local, 'shopify_store_name', None) + + # STORE 2 LOGGING + log_store2("DECORATOR-1", f""" +temp_shopify_session called for function: {func.__name__} +store_name from kwargs: {kwargs.get('store_name')} +store_name from frappe.local: {getattr(frappe.local, 'shopify_store_name', None)} +Final store_name: {store_name} +enable_store_2: {setting.enable_store_2} +""", store_name) + + # Use Store 2 credentials if specified and enabled + if store_name and store_name != "Store 1" and setting.enable_store_2: + password_2 = setting.get_password("password_2") + + log_store2("DECORATOR-2", f""" +Using Store 2 credentials +shopify_url_2: {setting.shopify_url_2} +password_2 exists: {bool(password_2)} +password_2 length: {len(password_2) if password_2 else 0} +""", store_name) + + if not password_2: + log_store2("DECORATOR-ERROR", "password_2 is None/empty!", store_name) + frappe.throw(_("Store 2 API password not configured")) + + auth_details = (setting.shopify_url_2, API_VERSION, password_2) + else: + # Default to Store 1 + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + + try: + log_store2("DECORATOR-3", f"About to create Shopify session with URL: {auth_details[0]}", store_name) + with Session.temp(*auth_details): + log_store2("DECORATOR-4", f"Session created, calling {func.__name__}", store_name) + result = func(*args, **kwargs) + log_store2("DECORATOR-5", f"Function {func.__name__} completed successfully", store_name) + return result + except Exception as e: + log_store2("DECORATOR-ERROR", f""" +Exception in temp_shopify_session! +Function: {func.__name__} +Error: {str(e)} +Type: {type(e).__name__} +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + return wrapper def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: - """Register required webhooks with shopify and return registered webhooks.""" - new_webhooks = [] + """Register required webhooks with shopify and return registered webhooks.""" + new_webhooks = [] - # clear all stale webhooks matching current site url before registering new ones - unregister_webhooks(shopify_url, password) + # clear all stale webhooks matching current site url before registering new ones + unregister_webhooks(shopify_url, password) - with Session.temp(shopify_url, API_VERSION, password): - for topic in WEBHOOK_EVENTS: - webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"}) + with Session.temp(shopify_url, API_VERSION, password): + for topic in WEBHOOK_EVENTS: + webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"}) - if webhook.is_valid(): - new_webhooks.append(webhook) - else: - create_shopify_log( - status="Error", - response_data=webhook.to_dict(), - exception=webhook.errors.full_messages(), - ) + if webhook.is_valid(): + new_webhooks.append(webhook) + else: + create_shopify_log( + status="Error", + response_data=webhook.to_dict(), + exception=webhook.errors.full_messages(), + ) - return new_webhooks + return new_webhooks def unregister_webhooks(shopify_url: str, password: str) -> None: - """Unregister all webhooks from shopify that correspond to current site url.""" - url = get_current_domain_name() + """Unregister all webhooks from shopify that correspond to current site url.""" + url = get_current_domain_name() - with Session.temp(shopify_url, API_VERSION, password): - for webhook in Webhook.find(): - if url in webhook.address: - webhook.destroy() + with Session.temp(shopify_url, API_VERSION, password): + for webhook in Webhook.find(): + if url in webhook.address: + webhook.destroy() def get_current_domain_name() -> str: - """Get current site domain name. E.g. test.erpnext.com - - If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url. - """ - if frappe.conf.developer_mode and frappe.conf.localtunnel_url: - return frappe.conf.localtunnel_url - else: - return frappe.request.host + """Get current site domain name.""" + if frappe.conf.developer_mode and frappe.conf.localtunnel_url: + return frappe.conf.localtunnel_url + else: + return frappe.request.host def get_callback_url() -> str: - """Shopify calls this url when new events occur to subscribed webhooks. - - If developer_mode is enabled and localtunnel_url is set in site config then callback url is set to localtunnel_url. - """ - url = get_current_domain_name() - - return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data" + """Shopify calls this url when new events occur to subscribed webhooks.""" + url = get_current_domain_name() + return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data" @frappe.whitelist(allow_guest=True) def store_request_data() -> None: - if frappe.request: - # Detect which store sent this webhook - shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") - - if not shop_domain: - frappe.log_error( - title="Shopify Webhook - Missing Shop Domain", - message="X-Shopify-Shop-Domain header not found" - ) - frappe.throw(_("Missing shop domain header")) - - # Get settings - setting = frappe.get_doc(SETTING_DOCTYPE) - - # === COMPREHENSIVE DEBUG LOGGING === - debug_info = [] - debug_info.append("=" * 80) - debug_info.append("SHOPIFY WEBHOOK STORE MATCHING DEBUG") - debug_info.append("=" * 80) - debug_info.append("") - - # 1. Incoming shop_domain with quotes - debug_info.append(f"1. Incoming shop_domain: '{shop_domain}'") - debug_info.append(f" Type: {type(shop_domain)}") - debug_info.append(f" Length: {len(shop_domain) if shop_domain else 0}") - debug_info.append(f" Byte representation: {shop_domain.encode('utf-8') if shop_domain else 'None'}") - debug_info.append("") - - # 2. Store 1 configuration - debug_info.append(f"2. Store 1 Configuration:") - debug_info.append(f" shopify_url: '{setting.shopify_url}'") - debug_info.append(f" Type: {type(setting.shopify_url)}") - debug_info.append(f" Length: {len(setting.shopify_url) if setting.shopify_url else 0}") - debug_info.append(f" Byte representation: {setting.shopify_url.encode('utf-8') if setting.shopify_url else 'None'}") - debug_info.append("") - - # 3. Store 2 configuration - debug_info.append(f"3. Store 2 Configuration:") - debug_info.append(f" enable_store_2: {setting.enable_store_2}") - debug_info.append(f" shopify_url_2: '{setting.shopify_url_2 if setting.shopify_url_2 else 'Not Set'}'") - if setting.shopify_url_2: - debug_info.append(f" Type: {type(setting.shopify_url_2)}") - debug_info.append(f" Length: {len(setting.shopify_url_2)}") - debug_info.append(f" Byte representation: {setting.shopify_url_2.encode('utf-8')}") - debug_info.append("") - - # 4. Comparison checks for Store 1 - debug_info.append(f"4. Store 1 Comparison:") - debug_info.append(f" Condition: setting.shopify_url and shop_domain in setting.shopify_url") - debug_info.append(f" setting.shopify_url exists: {bool(setting.shopify_url)}") - if setting.shopify_url: - debug_info.append(f" shop_domain in setting.shopify_url: {shop_domain in setting.shopify_url}") - debug_info.append(f" Are they equal (==): {shop_domain == setting.shopify_url}") - debug_info.append(f" Stripped comparison: '{shop_domain.strip()}' == '{setting.shopify_url.strip()}': {shop_domain.strip() == setting.shopify_url.strip()}") - debug_info.append("") - - # 5. Comparison checks for Store 2 - debug_info.append(f"5. Store 2 Comparison:") - debug_info.append(f" Condition: setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2") - debug_info.append(f" enable_store_2: {setting.enable_store_2}") - debug_info.append(f" setting.shopify_url_2 exists: {bool(setting.shopify_url_2)}") - if setting.shopify_url_2: - debug_info.append(f" shop_domain in setting.shopify_url_2: {shop_domain in setting.shopify_url_2}") - debug_info.append(f" Are they equal (==): {shop_domain == setting.shopify_url_2}") - debug_info.append(f" Stripped comparison: '{shop_domain.strip()}' == '{setting.shopify_url_2.strip()}': {shop_domain.strip() == setting.shopify_url_2.strip()}") - debug_info.append("") - - # 6. Character-by-character comparison for Store 2 (if applicable) - if setting.shopify_url_2 and shop_domain: - debug_info.append(f"6. Character-by-Character Analysis (Store 2):") - debug_info.append(f" shop_domain chars: {[c for c in shop_domain]}") - debug_info.append(f" shopify_url_2 chars: {[c for c in setting.shopify_url_2]}") - debug_info.append(f" shop_domain repr: {repr(shop_domain)}") - debug_info.append(f" shopify_url_2 repr: {repr(setting.shopify_url_2)}") - - # Check for whitespace - debug_info.append(f" shop_domain has leading/trailing spaces: {shop_domain != shop_domain.strip()}") - debug_info.append(f" shopify_url_2 has leading/trailing spaces: {setting.shopify_url_2 != setting.shopify_url_2.strip()}") - debug_info.append("") - - debug_info.append("=" * 80) - - # Log everything to Error Log - frappe.log_error( - title="Shopify Webhook Debug Info", - message="\n".join(debug_info) - ) - # === END DEBUG LOGGING === - - # Determine store and credentials - store_name = None - shared_secret = None - - # Check Store 1 - if setting.shopify_url and shop_domain in setting.shopify_url: - store_name = "Store 1" - shared_secret = setting.shared_secret - frappe.logger().info(f"Webhook from Store 1: {shop_domain}") - - # Check Store 2 - elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: - store_name = setting.store_2_name or "Store 2" - shared_secret = setting.shared_secret_2 - frappe.logger().info(f"Webhook from Store 2 ({store_name}): {shop_domain}") - - # Unknown store - else: - frappe.log_error( - title="Shopify Webhook - Unknown Store", - message=f"Shop domain '{shop_domain}' does not match configured stores" - ) - frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) - - # Validate with correct secret - hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - _validate_request(frappe.request, hmac_header, shared_secret) - - data = json.loads(frappe.request.data) - event = frappe.request.headers.get("X-Shopify-Topic") - - # Set store context for API sessions - frappe.local.shopify_store_name = store_name - - process_request(data, event, store_name) + """ + Central webhook endpoint for all Shopify stores. + Routes webhooks to the correct handler based on shop domain. + """ + + # ========================================================================= + # STEP 1: Initial request validation + # ========================================================================= + if not frappe.request: + frappe.log_error( + title="[STORE2 DEBUG] Step 1 - FAILED", + message="No frappe.request object!" + ) + frappe.throw(_("Invalid request")) + return + + # Get headers early for logging + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + event = frappe.get_request_header("X-Shopify-Topic") + hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + + # Check if this MIGHT be Store 2 (for early logging) + setting = frappe.get_doc(SETTING_DOCTYPE) + is_likely_store2 = ( + setting.enable_store_2 and + setting.shopify_url_2 and + shop_domain and + shop_domain in setting.shopify_url_2 + ) + + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 1 - Request Received", + message=f""" +====================================== +STORE 2 WEBHOOK RECEIVED +====================================== +shop_domain: {shop_domain} +event: {event} +hmac_header exists: {bool(hmac_header)} +hmac_header length: {len(hmac_header) if hmac_header else 0} +hmac_header (first 20): {hmac_header[:20] if hmac_header else 'NONE'}... +request.data length: {len(frappe.request.data) if frappe.request.data else 0} +request.data (first 200): {frappe.request.data[:200] if frappe.request.data else 'NONE'} +====================================== +""" + ) + + # ========================================================================= + # STEP 2: Validate shop_domain header + # ========================================================================= + if not shop_domain: + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 2 - FAILED", + message="shop_domain header is missing!" + ) + frappe.throw(_("Missing shop domain header")) + return + + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 2 - shop_domain OK", + message=f"shop_domain: {shop_domain}" + ) + + # ========================================================================= + # STEP 3: Determine which store + # ========================================================================= + store_name = None + shared_secret = None + + # Check Store 1 + if setting.shopify_url and shop_domain in setting.shopify_url: + store_name = "Store 1" + shared_secret = setting.shared_secret + + # Check Store 2 + elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: + store_name = setting.store_2_name or "Store 2" + shared_secret = setting.shared_secret_2 + + frappe.log_error( + title="[STORE2 DEBUG] Step 3 - Store Matched", + message=f""" +Store identified as: {store_name} +shop_domain: {shop_domain} +shopify_url_2: {setting.shopify_url_2} +shared_secret_2 exists: {bool(shared_secret)} +shared_secret_2 type: {type(shared_secret)} +shared_secret_2 length: {len(shared_secret) if shared_secret else 0} +shared_secret_2 (first 10): {shared_secret[:10] if shared_secret else 'NONE'}... +""" + ) + + # Unknown store + else: + frappe.log_error( + title="[STORE2 DEBUG] Step 3 - NO MATCH", + message=f""" +shop_domain: {shop_domain} +Store 1 URL: {setting.shopify_url} +Store 2 URL: {setting.shopify_url_2} +Store 2 Enabled: {setting.enable_store_2} +""" + ) + frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) + return + + # ========================================================================= + # STEP 4: Validate shared_secret exists + # ========================================================================= + if store_name != "Store 1": + frappe.log_error( + title="[STORE2 DEBUG] Step 4 - Validating shared_secret", + message=f""" +shared_secret is None: {shared_secret is None} +shared_secret is empty string: {shared_secret == ''} +shared_secret is falsy: {not shared_secret} +bool(shared_secret): {bool(shared_secret)} +repr(shared_secret): {repr(shared_secret)[:50] if shared_secret else 'None'} +""" + ) + + if not shared_secret: + log_store2("4-FAILED", "shared_secret is None or empty!", store_name) + frappe.throw(_(f"Shared secret not configured for {store_name}")) + return + + log_store2("4-OK", "shared_secret exists and is not empty", store_name) + + # ========================================================================= + # STEP 5: Validate HMAC header exists + # ========================================================================= + log_store2("5", f""" +Checking HMAC header... +hmac_header exists: {bool(hmac_header)} +hmac_header is None: {hmac_header is None} +hmac_header type: {type(hmac_header)} +""", store_name) + + if not hmac_header: + log_store2("5-FAILED", "hmac_header is None or empty!", store_name) + frappe.throw(_("Missing HMAC signature header")) + return + + log_store2("5-OK", f"HMAC header present: {hmac_header[:20]}...", store_name) + + # ========================================================================= + # STEP 6: HMAC Validation + # ========================================================================= + log_store2("6", f""" +About to validate HMAC... +shared_secret type: {type(shared_secret)} +shared_secret length: {len(shared_secret)} +hmac_header type: {type(hmac_header)} +hmac_header length: {len(hmac_header)} +request.data type: {type(frappe.request.data)} +request.data length: {len(frappe.request.data)} +""", store_name) + + try: + log_store2("6a", "Calling shared_secret.encode('utf8')...", store_name) + secret_bytes = shared_secret.encode("utf8") + log_store2("6b", f"Success! secret_bytes length: {len(secret_bytes)}", store_name) + + log_store2("6c", "Computing HMAC...", store_name) + computed_hmac = hmac.new(secret_bytes, frappe.request.data, hashlib.sha256) + log_store2("6d", "HMAC computed, getting digest...", store_name) + + digest = computed_hmac.digest() + log_store2("6e", f"Digest obtained, length: {len(digest)}", store_name) + + sig = base64.b64encode(digest) + log_store2("6f", f"Base64 encoded sig: {sig[:30]}...", store_name) + + log_store2("6g", "Encoding hmac_header to bytes...", store_name) + expected_sig = bytes(hmac_header.encode()) + log_store2("6h", f"Expected sig: {expected_sig[:30]}...", store_name) + + log_store2("6i", f""" +Comparing signatures... +Computed: {sig} +Expected: {expected_sig} +Match: {sig == expected_sig} +""", store_name) + + if sig != expected_sig: + log_store2("6-FAILED", f""" +HMAC MISMATCH! +Computed: {sig} +Expected: {expected_sig} + +This means the shared_secret in ERPNext doesn't match Shopify's signing key. +Check: Shopify Admin → Settings → Notifications → Webhooks → "Your webhooks will be signed with: XXX" +""", store_name) + create_shopify_log(status="Error", request_data=frappe.request.data) + frappe.throw(_("Unverified Webhook Data")) + return + + log_store2("6-OK", "HMAC validation PASSED!", store_name) + + except AttributeError as e: + log_store2("6-EXCEPTION", f""" +AttributeError during HMAC validation! +Error: {str(e)} +This usually means shared_secret or hmac_header is None. + +shared_secret is None: {shared_secret is None} +hmac_header is None: {hmac_header is None} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + except Exception as e: + log_store2("6-EXCEPTION", f""" +Exception during HMAC validation! +Error type: {type(e).__name__} +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + # ========================================================================= + # STEP 7: Parse JSON data + # ========================================================================= + log_store2("7", "Parsing JSON data...", store_name) + + try: + data = json.loads(frappe.request.data) + log_store2("7-OK", f""" +JSON parsed successfully! +Order ID: {data.get('id', 'N/A')} +Order Number: {data.get('name', 'N/A')} +Customer: {data.get('customer', {}).get('email', 'N/A') if data.get('customer') else 'N/A'} +Total Price: {data.get('total_price', 'N/A')} +Line Items Count: {len(data.get('line_items', []))} +""", store_name) + except json.JSONDecodeError as e: + log_store2("7-FAILED", f""" +JSON parse error! +Error: {str(e)} +Raw data (first 500): {frappe.request.data[:500]} +""", store_name) + frappe.throw(_("Invalid JSON in webhook payload")) + return + + # ========================================================================= + # STEP 8: Validate event type + # ========================================================================= + log_store2("8", f""" +Validating event type... +Event: {event} +Event in EVENT_MAPPER: {event in EVENT_MAPPER} +Available events: {list(EVENT_MAPPER.keys())} +""", store_name) + + if not event: + log_store2("8-FAILED", "Event header is missing!", store_name) + frappe.throw(_("Missing webhook event type")) + return + + if event not in EVENT_MAPPER: + log_store2("8-FAILED", f"Event '{event}' not in EVENT_MAPPER!", store_name) + frappe.throw(_(f"Unsupported webhook event: {event}")) + return + + log_store2("8-OK", f"Event '{event}' is valid, maps to: {EVENT_MAPPER[event]}", store_name) + + # ========================================================================= + # STEP 9: Set store context + # ========================================================================= + log_store2("9", f"Setting frappe.local.shopify_store_name = '{store_name}'", store_name) + frappe.local.shopify_store_name = store_name + log_store2("9-OK", f"Store context set. Verified: {frappe.local.shopify_store_name}", store_name) + + # ========================================================================= + # STEP 10: Call process_request + # ========================================================================= + log_store2("10", f""" +About to call process_request() +Event: {event} +Store: {store_name} +Order ID: {data.get('id')} +""", store_name) + + try: + process_request(data, event, store_name) + log_store2("10-OK", "process_request() completed without exception", store_name) + except Exception as e: + log_store2("10-EXCEPTION", f""" +Exception in process_request! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise def process_request(data, event, store_name=None): - """Process webhook request with store context. - - Args: - data: The webhook payload data - event: The event type (e.g., 'orders/create') - store_name: Name of the store that sent the webhook (default: None for backward compatibility) - """ - # Log which store is processing - if store_name: - frappe.logger().info(f"Processing {event} from {store_name}") - - # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - - # enqueue backround job with store context - frappe.enqueue( - method=EVENT_MAPPER[event], - queue="short", - timeout=300, - is_async=True, - **{"payload": data, "request_id": log.name, "store_name": store_name}, - ) + """Process webhook request with store context.""" + + # ========================================================================= + # STEP 11: Inside process_request + # ========================================================================= + log_store2("11", f""" +Inside process_request() +Event: {event} +Store: {store_name} +Order ID: {data.get('id')} +Method to call: {EVENT_MAPPER[event]} +""", store_name) + + # ========================================================================= + # STEP 12: Create Shopify log + # ========================================================================= + log_store2("12", "Creating Shopify log entry...", store_name) + + try: + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) + log_store2("12-OK", f"Log created: {log.name}", store_name) + except Exception as e: + log_store2("12-EXCEPTION", f""" +Failed to create Shopify log! +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + # ========================================================================= + # STEP 13: Enqueue background job + # ========================================================================= + log_store2("13", f""" +About to enqueue background job... +Method: {EVENT_MAPPER[event]} +Queue: short +Timeout: 300 +Kwargs: payload (order data), request_id={log.name}, store_name={store_name} +""", store_name) + + try: + frappe.enqueue( + method=EVENT_MAPPER[event], + queue="short", + timeout=300, + is_async=True, + **{"payload": data, "request_id": log.name, "store_name": store_name}, + ) + log_store2("13-OK", f""" +Job enqueued successfully! +Method: {EVENT_MAPPER[event]} +Log ID: {log.name} +Store: {store_name} + +The webhook handler has completed. +The background worker should now pick up the job. +Check RQ Job doctype for the job status. +""", store_name) + except Exception as e: + log_store2("13-EXCEPTION", f""" +Failed to enqueue job! +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise def _validate_request(req, hmac_header, shared_secret): - """Validate Shopify webhook using HMAC. - - Args: - req: The request object - hmac_header: The HMAC header from request - shared_secret: Shared secret for the specific store - """ - sig = base64.b64encode(hmac.new(shared_secret.encode("utf8"), req.data, hashlib.sha256).digest()) - - if sig != bytes(hmac_header.encode()): - create_shopify_log(status="Error", request_data=req.data) - frappe.throw(_("Unverified Webhook Data")) + """Validate Shopify webhook using HMAC. + + Note: This function is now bypassed - validation is done inline in store_request_data + with detailed logging. Keeping for backward compatibility. + """ + sig = base64.b64encode(hmac.new(shared_secret.encode("utf8"), req.data, hashlib.sha256).digest()) + + if sig != bytes(hmac_header.encode()): + create_shopify_log(status="Error", request_data=req.data) + frappe.throw(_("Unverified Webhook Data")) \ No newline at end of file diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 989230b0d..45c807d49 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -1,4 +1,10 @@ +""" +order.py - WITH COMPREHENSIVE STORE 2 LOGGING +This handles the background job for orders/create webhook. +""" + import json +import traceback from typing import Literal, Optional import frappe @@ -9,13 +15,13 @@ from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import ( - CUSTOMER_ID_FIELD, - EVENT_MAPPER, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SETTING_DOCTYPE, + CUSTOMER_ID_FIELD, + EVENT_MAPPER, + ORDER_ID_FIELD, + ORDER_ITEM_DISCOUNT_FIELD, + ORDER_NUMBER_FIELD, + ORDER_STATUS_FIELD, + SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.customer import ShopifyCustomer from ecommerce_integrations.shopify.product import create_items_if_not_exist, get_item_code @@ -24,449 +30,656 @@ from ecommerce_integrations.utils.taxation import get_dummy_tax_category DEFAULT_TAX_FIELDS = { - "sales_tax": "default_sales_tax_account", - "shipping": "default_shipping_charges_account", + "sales_tax": "default_sales_tax_account", + "shipping": "default_shipping_charges_account", } -def sync_sales_order(payload, request_id=None, store_name=None): - """Sync Shopify order to ERPNext Sales Order. - - Args: - payload: Order data from Shopify - request_id: Shopify Log entry ID - store_name: Name of the store (for logging and context) - """ - order = payload - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - # Set store context for API calls in this background job - if store_name: - frappe.logger().info(f"Syncing order {order.get('name')} from {store_name}") - frappe.local.shopify_store_name = store_name - - 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") - return - try: - shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") - customer_id = shopify_customer.get("id") - if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) - if not customer.is_synced(): - customer.sync_customer(customer=shopify_customer) - else: - customer.update_existing_addresses(shopify_customer) - - # Store context has been set in frappe.local.shopify_store_name above - # This will be used by temp_shopify_session decorator when syncing items - create_items_if_not_exist(order) - - setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) - except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) - else: - create_shopify_log(status="Success") - - -def create_order(order, setting, company=None): - # local import to avoid circular dependencies - from ecommerce_integrations.shopify.fulfillment import create_delivery_note - from ecommerce_integrations.shopify.invoice import create_sales_invoice - - so = create_sales_order(order, setting, company) - if so: - if order.get("financial_status") == "paid": - create_sales_invoice(order, setting, so) - - if order.get("fulfillments"): - create_delivery_note(order, setting, so) - - -def create_sales_order(shopify_order, setting, company=None): - customer = setting.default_customer - if shopify_order.get("customer", {}): - if customer_id := shopify_order.get("customer", {}).get("id"): - customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - - so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") - - if not so: - items = get_order_items( - shopify_order.get("line_items"), - setting, - getdate(shopify_order.get("created_at")), - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if not items: - message = ( - "Following items exists in the shopify order but relevant records were" - " not found in the shopify Product master" - ) - product_not_exists = [] # TODO: fix missing items - message += "\n" + ", ".join(product_not_exists) - - create_shopify_log(status="Error", exception=message, rollback=True) - - return "" - - taxes = get_order_taxes(shopify_order, setting, items) - so = frappe.get_doc( - { - "doctype": "Sales Order", - "naming_series": setting.sales_order_series or "SO-Shopify-", - ORDER_ID_FIELD: str(shopify_order.get("id")), - ORDER_NUMBER_FIELD: shopify_order.get("name"), - "customer": customer, - "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), - "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": setting.company, - "selling_price_list": get_dummy_price_list(), - "ignore_pricing_rule": 1, - "items": items, - "taxes": taxes, - "tax_category": get_dummy_tax_category(), - } - ) - - if company: - so.update({"company": company, "status": "Draft"}) - so.flags.ignore_mandatory = True - so.flags.shopiy_order_json = json.dumps(shopify_order) - so.save(ignore_permissions=True) - so.submit() - - if shopify_order.get("note"): - so.add_comment(text=f"Order Note: {shopify_order.get('note')}") - - else: - so = frappe.get_doc("Sales Order", so) - - return so - - -def get_order_items(order_items, setting, delivery_date, taxes_inclusive): - items = [] - all_product_exists = True - product_not_exists = [] - - for shopify_item in order_items: - product_id = shopify_item.get("product_id") - - # Handle items without product_id (tips, samples, fees) - skip product_exists check - if not product_id: - item_code = get_item_code(shopify_item) - if item_code: - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name") or shopify_item.get("title"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - continue - - # Original logic for items with product_id - if not shopify_item.get("product_exists"): - all_product_exists = False - product_not_exists.append( - {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} - ) - continue - - if all_product_exists: - item_code = get_item_code(shopify_item) - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - else: - items = [] - - return items +def log_store2(step, message, store_name=None): + """Helper function to log only for Store 2.""" + if store_name and store_name != "Store 1": + frappe.log_error( + title=f"[STORE2 ORDER] Step {step}", + message=f"Store: {store_name}\n\n{message}" + ) +def sync_sales_order(payload, request_id=None, store_name=None): + """Sync Shopify order to ERPNext Sales Order. + + This is called as a BACKGROUND JOB by the RQ worker. + """ + order = payload + + # ========================================================================= + # STEP BG-1: Background job started + # ========================================================================= + log_store2("BG-1", f""" +======================================== +BACKGROUND JOB STARTED: sync_sales_order +======================================== +request_id: {request_id} +store_name: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +frappe.local exists: {hasattr(frappe, 'local')} +""", store_name) + + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # ========================================================================= + # STEP BG-2: Set store context (CRITICAL!) + # ========================================================================= + log_store2("BG-2", f""" +Setting store context in background worker... +Before: frappe.local.shopify_store_name = {getattr(frappe.local, 'shopify_store_name', 'NOT SET')} +""", store_name) + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("BG-2-OK", f""" +Store context set! +After: frappe.local.shopify_store_name = {frappe.local.shopify_store_name} +""", store_name) + else: + log_store2("BG-2-WARN", "store_name is None! Will default to Store 1 credentials!", store_name) + + # ========================================================================= + # STEP BG-3: Check if order already exists + # ========================================================================= + log_store2("BG-3", f"Checking if Sales Order already exists for Shopify Order ID: {order['id']}", store_name) + + existing_so = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}) + + if existing_so: + log_store2("BG-3-SKIP", f""" +Sales Order already exists! +Existing SO: {existing_so} +Shopify Order ID: {order['id']} +Skipping creation. +""", store_name) + create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + return + + log_store2("BG-3-OK", "No existing Sales Order found, proceeding with creation.", store_name) + + # ========================================================================= + # STEP BG-4: Process customer + # ========================================================================= + try: + log_store2("BG-4", "Processing customer...", store_name) + + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + customer_id = shopify_customer.get("id") + + log_store2("BG-4a", f""" +Customer data: +customer_id: {customer_id} +email: {shopify_customer.get('email')} +has billing_address: {bool(order.get('billing_address'))} +has shipping_address: {bool(order.get('shipping_address'))} +""", store_name) + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + log_store2("BG-4b", f"Customer {customer_id} not synced, creating...", store_name) + customer.sync_customer(customer=shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} created.", store_name) + else: + log_store2("BG-4b", f"Customer {customer_id} already exists, updating addresses...", store_name) + customer.update_existing_addresses(shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} addresses updated.", store_name) + else: + log_store2("BG-4-WARN", "No customer_id in order, will use default customer.", store_name) + + log_store2("BG-4-OK", "Customer processing complete.", store_name) + + except Exception as e: + log_store2("BG-4-EXCEPTION", f""" +Exception processing customer! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-5: Sync items/products + # ========================================================================= + try: + log_store2("BG-5", f""" +Syncing items from order... +Line items count: {len(order.get('line_items', []))} +Line items: {[item.get('title') for item in order.get('line_items', [])]} +""", store_name) + + create_items_if_not_exist(order) + + log_store2("BG-5-OK", "Items synced successfully.", store_name) + + except Exception as e: + log_store2("BG-5-EXCEPTION", f""" +Exception syncing items! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-6: Create Sales Order + # ========================================================================= + try: + log_store2("BG-6", "Creating Sales Order...", store_name) + + setting = frappe.get_doc(SETTING_DOCTYPE) + + log_store2("BG-6a", f""" +Settings loaded: +Company: {setting.company} +Warehouse: {setting.warehouse} +Sales Order Series: {setting.sales_order_series} +Default Customer: {setting.default_customer} +""", store_name) + + create_order(order, setting, store_name=store_name) + + log_store2("BG-6-OK", f""" +======================================== +SALES ORDER CREATED SUCCESSFULLY! +======================================== +Shopify Order ID: {order.get('id')} +Shopify Order Number: {order.get('name')} +Store: {store_name} +""", store_name) + + except Exception as e: + log_store2("BG-6-EXCEPTION", f""" +Exception creating Sales Order! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-7: Success! + # ========================================================================= + log_store2("BG-7", "Creating success log entry...", store_name) + create_shopify_log(status="Success") + log_store2("BG-7-OK", f""" +======================================== +BACKGROUND JOB COMPLETED SUCCESSFULLY! +======================================== +Shopify Order: {order.get('name')} +Store: {store_name} +""", store_name) + + +def create_order(order, setting, company=None, store_name=None): + """Create order with related documents.""" + # local import to avoid circular dependencies + from ecommerce_integrations.shopify.fulfillment import create_delivery_note + from ecommerce_integrations.shopify.invoice import create_sales_invoice + + log_store2("CREATE-1", "Inside create_order()", store_name) + + so = create_sales_order(order, setting, company, store_name=store_name) + + if so: + log_store2("CREATE-2", f"Sales Order created: {so.name}", store_name) + + if order.get("financial_status") == "paid": + log_store2("CREATE-3", "Order is paid, creating Sales Invoice...", store_name) + create_sales_invoice(order, setting, so) + log_store2("CREATE-3-OK", "Sales Invoice created.", store_name) + + if order.get("fulfillments"): + log_store2("CREATE-4", "Order has fulfillments, creating Delivery Note...", store_name) + create_delivery_note(order, setting, so) + log_store2("CREATE-4-OK", "Delivery Note created.", store_name) + else: + log_store2("CREATE-WARN", "create_sales_order returned None!", store_name) + + +def create_sales_order(shopify_order, setting, company=None, store_name=None): + """Create the actual Sales Order document.""" + + log_store2("SO-1", f""" +Creating Sales Order... +Shopify Order ID: {shopify_order.get('id')} +Shopify Order Number: {shopify_order.get('name')} +""", store_name) + + # Determine customer + customer = setting.default_customer + if shopify_order.get("customer", {}): + if customer_id := shopify_order.get("customer", {}).get("id"): + customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or customer + + log_store2("SO-2", f"Customer determined: {customer}", store_name) + + # Check if SO already exists + so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") + + if not so: + log_store2("SO-3", "Getting order items...", store_name) + + items = get_order_items( + shopify_order.get("line_items"), + setting, + getdate(shopify_order.get("created_at")), + taxes_inclusive=shopify_order.get("taxes_included"), + store_name=store_name, + ) + + log_store2("SO-3a", f"Items count: {len(items)}", store_name) + + if not items: + log_store2("SO-3-FAIL", "No items returned! Cannot create Sales Order.", store_name) + message = ( + "Following items exists in the shopify order but relevant records were" + " not found in the shopify Product master" + ) + create_shopify_log(status="Error", exception=message, rollback=True) + return "" + + log_store2("SO-4", "Getting order taxes...", store_name) + taxes = get_order_taxes(shopify_order, setting, items) + log_store2("SO-4a", f"Taxes count: {len(taxes)}", store_name) + + log_store2("SO-5", "Creating Sales Order document...", store_name) + + so = frappe.get_doc( + { + "doctype": "Sales Order", + "naming_series": setting.sales_order_series or "SO-Shopify-", + ORDER_ID_FIELD: str(shopify_order.get("id")), + ORDER_NUMBER_FIELD: shopify_order.get("name"), + "customer": customer, + "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), + "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), + "company": setting.company, + "selling_price_list": get_dummy_price_list(), + "ignore_pricing_rule": 1, + "items": items, + "taxes": taxes, + "tax_category": get_dummy_tax_category(), + } + ) + + if company: + so.update({"company": company, "status": "Draft"}) + + so.flags.ignore_mandatory = True + so.flags.shopiy_order_json = json.dumps(shopify_order) + + log_store2("SO-6", "Saving Sales Order...", store_name) + so.save(ignore_permissions=True) + log_store2("SO-6a", f"Sales Order saved: {so.name}", store_name) + + log_store2("SO-7", "Submitting Sales Order...", store_name) + so.submit() + log_store2("SO-7a", f"Sales Order submitted: {so.name}", store_name) + + if shopify_order.get("note"): + so.add_comment(text=f"Order Note: {shopify_order.get('note')}") + log_store2("SO-8", "Added order note as comment.", store_name) + + else: + log_store2("SO-EXISTS", f"Sales Order already exists: {so}", store_name) + so = frappe.get_doc("Sales Order", so) + + return so + + +def get_order_items(order_items, setting, delivery_date, taxes_inclusive, store_name=None): + """Get order items for Sales Order.""" + items = [] + all_product_exists = True + product_not_exists = [] + + log_store2("ITEMS-1", f"Processing {len(order_items)} line items...", store_name) + + for idx, shopify_item in enumerate(order_items): + product_id = shopify_item.get("product_id") + + log_store2(f"ITEMS-2-{idx}", f""" +Processing item {idx + 1}: + title: {shopify_item.get('title')} + product_id: {product_id} + variant_id: {shopify_item.get('variant_id')} + sku: {shopify_item.get('sku')} + quantity: {shopify_item.get('quantity')} + price: {shopify_item.get('price')} + product_exists: {shopify_item.get('product_exists')} +""", store_name) + + # Handle items without product_id (tips, samples, fees) + if not product_id: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-NOID", f"No product_id, mapped to: {item_code}", store_name) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append( + {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} + ) + log_store2(f"ITEMS-2-{idx}-NOTEXIST", f"Product does not exist in Shopify!", store_name) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-CODE", f"Item code: {item_code}", store_name) + + if not item_code: + log_store2(f"ITEMS-2-{idx}-NOCODE", f"Could not get item_code!", store_name) + continue + + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": shopify_item.get("uom") or "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + else: + items = [] + + log_store2("ITEMS-3", f"Returning {len(items)} items", store_name) + return items + + +# Keep the rest of the functions unchanged but add store_name parameter where needed def _get_item_price(line_item, taxes_inclusive: bool) -> float: - price = flt(line_item.get("price")) - qty = cint(line_item.get("quantity")) - - # remove line item level discounts - total_discount = _get_total_discount(line_item) + price = flt(line_item.get("price")) + qty = cint(line_item.get("quantity")) + total_discount = _get_total_discount(line_item) - if not taxes_inclusive: - return price - (total_discount / qty) + if not taxes_inclusive: + return price - (total_discount / qty) - total_taxes = 0.0 - for tax in line_item.get("tax_lines"): - total_taxes += flt(tax.get("price")) + total_taxes = 0.0 + for tax in line_item.get("tax_lines"): + total_taxes += flt(tax.get("price")) - return price - (total_taxes + total_discount) / qty + return price - (total_taxes + total_discount) / qty def _get_total_discount(line_item) -> float: - discount_allocations = line_item.get("discount_allocations") or [] - return sum(flt(discount.get("amount")) for discount in discount_allocations) + discount_allocations = line_item.get("discount_allocations") or [] + return sum(flt(discount.get("amount")) for discount in discount_allocations) def get_order_taxes(shopify_order, setting, items): - taxes = [] - line_items = shopify_order.get("line_items") - - for line_item in line_items: - item_code = get_item_code(line_item) - for tax in line_item.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax.get("price"), - "included_in_print_rate": 0, - "cost_center": setting.cost_center, - "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, - "dont_recompute_tax": 1, - } - ) - - update_taxes_with_shipping_lines( - taxes, - shopify_order.get("shipping_lines"), - setting, - items, - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if cint(setting.consolidate_taxes): - taxes = consolidate_order_taxes(taxes) - - for row in taxes: - tax_detail = row.get("item_wise_tax_detail") - if isinstance(tax_detail, dict): - row["item_wise_tax_detail"] = json.dumps(tax_detail) - - return taxes + taxes = [] + line_items = shopify_order.get("line_items") + + for line_item in line_items: + item_code = get_item_code(line_item) + for tax in line_item.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax.get("price"), + "included_in_print_rate": 0, + "cost_center": setting.cost_center, + "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, + "dont_recompute_tax": 1, + } + ) + + update_taxes_with_shipping_lines( + taxes, + shopify_order.get("shipping_lines"), + setting, + items, + taxes_inclusive=shopify_order.get("taxes_included"), + ) + + if cint(setting.consolidate_taxes): + taxes = consolidate_order_taxes(taxes) + + for row in taxes: + tax_detail = row.get("item_wise_tax_detail") + if isinstance(tax_detail, dict): + row["item_wise_tax_detail"] = json.dumps(tax_detail) + + return taxes def consolidate_order_taxes(taxes): - tax_account_wise_data = {} - for tax in taxes: - account_head = tax["account_head"] - tax_account_wise_data.setdefault( - account_head, - { - "charge_type": "Actual", - "account_head": account_head, - "description": tax.get("description"), - "cost_center": tax.get("cost_center"), - "included_in_print_rate": 0, - "dont_recompute_tax": 1, - "tax_amount": 0, - "item_wise_tax_detail": {}, - }, - ) - tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) - if tax.get("item_wise_tax_detail"): - tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) - - return tax_account_wise_data.values() + tax_account_wise_data = {} + for tax in taxes: + account_head = tax["account_head"] + tax_account_wise_data.setdefault( + account_head, + { + "charge_type": "Actual", + "account_head": account_head, + "description": tax.get("description"), + "cost_center": tax.get("cost_center"), + "included_in_print_rate": 0, + "dont_recompute_tax": 1, + "tax_amount": 0, + "item_wise_tax_detail": {}, + }, + ) + tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) + if tax.get("item_wise_tax_detail"): + tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) + + return tax_account_wise_data.values() def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | None = None): - tax_title = str(tax.get("title")) + tax_title = str(tax.get("title")) - tax_account = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_account", - ) + tax_account = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "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]) + if not tax_account and charge_type: + tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) - if not tax_account: - frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) + if not tax_account: + frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) - return tax_account + return tax_account def get_tax_account_description(tax): - tax_title = tax.get("title") + tax_title = tax.get("title") - tax_description = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_description", - ) + tax_description = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + "tax_description", + ) - return tax_description + return tax_description def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): - """Shipping lines represents the shipping details, - each such shipping detail consists of a list of tax_lines""" - shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item - for shipping_charge in shipping_lines: - if shipping_charge.get("price"): - shipping_discounts = shipping_charge.get("discount_allocations") or [] - total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) - - shipping_taxes = shipping_charge.get("tax_lines") or [] - total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) - - shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) - if bool(taxes_inclusive): - shipping_charge_amount -= total_tax - - if shipping_as_item: - items.append( - { - "item_code": setting.shipping_item, - "rate": shipping_charge_amount, - "delivery_date": items[-1]["delivery_date"] if items else nowdate(), - "qty": 1, - "stock_uom": "Nos", - "warehouse": setting.warehouse, - } - ) - else: - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) - or shipping_charge["title"], - "tax_amount": shipping_charge_amount, - "cost_center": setting.cost_center, - } - ) - - for tax in shipping_charge.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax["price"], - "cost_center": setting.cost_center, - "item_wise_tax_detail": { - setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] - } - if shipping_as_item - else {}, - "dont_recompute_tax": 1, - } - ) + shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item + for shipping_charge in shipping_lines: + if shipping_charge.get("price"): + shipping_discounts = shipping_charge.get("discount_allocations") or [] + total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) + + shipping_taxes = shipping_charge.get("tax_lines") or [] + total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) + + shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) + if bool(taxes_inclusive): + shipping_charge_amount -= total_tax + + if shipping_as_item: + items.append( + { + "item_code": setting.shipping_item, + "rate": shipping_charge_amount, + "delivery_date": items[-1]["delivery_date"] if items else nowdate(), + "qty": 1, + "stock_uom": "Nos", + "warehouse": setting.warehouse, + } + ) + else: + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), + "description": get_tax_account_description(shipping_charge) + or shipping_charge["title"], + "tax_amount": shipping_charge_amount, + "cost_center": setting.cost_center, + } + ) + + for tax in shipping_charge.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax["price"], + "cost_center": setting.cost_center, + "item_wise_tax_detail": { + setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] + } + if shipping_as_item + else {}, + "dont_recompute_tax": 1, + } + ) def get_sales_order(order_id): - """Get ERPNext sales order using shopify order id.""" - sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - if sales_order: - return frappe.get_doc("Sales Order", sales_order) - - -def cancel_order(payload, request_id=None): - """Called by order/cancelled event. - - When shopify order is cancelled there could be many different someone handles it. + """Get ERPNext sales order using shopify order id.""" + sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + if sales_order: + return frappe.get_doc("Sales Order", sales_order) - Updates document with custom field showing order status. - IF sales invoice / delivery notes are not generated against an order, then cancel it. - """ - frappe.set_user("Administrator") - frappe.flags.request_id = request_id +def cancel_order(payload, request_id=None, store_name=None): + """Called by order/cancelled event.""" + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("CANCEL-1", f"Cancelling order {payload.get('id')}", store_name) - order = payload + order = payload - try: - order_id = order["id"] - order_status = order["financial_status"] + try: + order_id = order["id"] + order_status = order["financial_status"] - sales_order = get_sales_order(order_id) + sales_order = get_sales_order(order_id) - if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") - return + if not sales_order: + log_store2("CANCEL-FAIL", f"Sales Order not found for {order_id}", store_name) + create_shopify_log(status="Invalid", message="Sales Order does not exist") + return - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) - if sales_invoice: - frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) + if sales_invoice: + frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) - for dn in delivery_notes: - frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) + for dn in delivery_notes: + frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) - if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: - sales_order.cancel() - else: - frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) + if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: + sales_order.cancel() + log_store2("CANCEL-OK", f"Sales Order {sales_order.name} cancelled", store_name) + else: + frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) + log_store2("CANCEL-STATUS", f"Sales Order {sales_order.name} status updated", store_name) - except Exception as e: - create_shopify_log(status="Error", exception=e) - else: - create_shopify_log(status="Success") + except Exception as e: + log_store2("CANCEL-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) + create_shopify_log(status="Error", exception=e) + else: + create_shopify_log(status="Success") @temp_shopify_session def sync_old_orders(): - shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) - if not cint(shopify_setting.sync_old_orders): - return + 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) + 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 - ) - sync_sales_order(order, request_id=log.name) + 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) - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) - shopify_setting.sync_old_orders = 0 - shopify_setting.save() + shopify_setting = frappe.get_doc(SETTING_DOCTYPE) + shopify_setting.sync_old_orders = 0 + shopify_setting.save() def _fetch_old_orders(from_time, to_time): - """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" - - from_time = get_datetime(from_time).astimezone().isoformat() - to_time = get_datetime(to_time).astimezone().isoformat() - orders_iterator = PaginatedIterator( - Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) - ) - - for orders in orders_iterator: - for order in orders: - # Using generator instead of fetching all at once is better for - # avoiding rate limits and reducing resource usage. - yield order.to_dict() + from_time = get_datetime(from_time).astimezone().isoformat() + to_time = get_datetime(to_time).astimezone().isoformat() + orders_iterator = PaginatedIterator( + Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) + ) + + for orders in orders_iterator: + for order in orders: + yield order.to_dict() \ No newline at end of file From 269294f538835017896dd0f8ce4ff5065c4ff99b Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Fri, 20 Feb 2026 16:57:30 +0530 Subject: [PATCH 08/18] Add orders/updated webhook support for order sync --- ecommerce_integrations/shopify/constants.py | 2 + ecommerce_integrations/shopify/order.py | 551 ++++++++++++++++++++ 2 files changed, 553 insertions(+) diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index 47720e032..e4c17150d 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -14,6 +14,7 @@ "orders/fulfilled", "orders/cancelled", "orders/partially_fulfilled", + "orders/updated", ] EVENT_MAPPER = { @@ -22,6 +23,7 @@ "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", } SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"] diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 45c807d49..6ee50cea4 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -654,6 +654,557 @@ def cancel_order(payload, request_id=None, store_name=None): create_shopify_log(status="Success") +def update_sales_order(payload, request_id=None, store_name=None): + """Handle order updates from Shopify with comprehensive logging. + + Logs all order references, change details, amount details, and primary keys. + Supports both Store 1 and Store 2. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("UPDATE-1", f""" +======================================== +ORDER UPDATE WEBHOOK RECEIVED +======================================== +Store: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +request_id: {request_id} +""", store_name) + + # Initialize comprehensive log data structure + log_data = { + "order_references": {}, + "change_details": {}, + "amount_details": {}, + "primary_keys": {}, + "line_items_details": [], + "status": "processing", + "store_name": store_name or "Store 1" + } + + try: + # ========== EXTRACT ALL PRIMARY KEYS ========== + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + customer_id = order.get("customer", {}).get("id") if order.get("customer") else None + + log_store2("UPDATE-2", f"Extracting primary keys for order {order_id}...", store_name) + + log_data["primary_keys"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "shopify_customer_id": customer_id, + "shopify_order_name": order.get("name", ""), + "shopify_order_token": order.get("token", ""), + "shopify_checkout_id": order.get("checkout_id"), + "shopify_checkout_token": order.get("checkout_token"), + } + + # Extract line item IDs + line_item_ids = [] + for item in order.get("line_items", []): + line_item_ids.append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + }) + log_data["primary_keys"]["line_items"] = line_item_ids + + # Extract fulfillment IDs + fulfillment_ids = [] + for fulfillment in order.get("fulfillments", []): + fulfillment_ids.append({ + "shopify_fulfillment_id": fulfillment.get("id"), + "shopify_tracking_number": fulfillment.get("tracking_number"), + }) + log_data["primary_keys"]["fulfillments"] = fulfillment_ids + + # Extract discount code IDs + discount_codes = [] + for discount in order.get("discount_codes", []): + discount_codes.append({ + "shopify_discount_code": discount.get("code"), + "shopify_discount_type": discount.get("type"), + }) + log_data["primary_keys"]["discount_codes"] = discount_codes + + log_store2("UPDATE-3", f"Primary keys extracted: {len(line_item_ids)} line items, {len(fulfillment_ids)} fulfillments", store_name) + + # ========== EXTRACT ALL ORDER REFERENCES ========== + log_store2("UPDATE-4", "Extracting order references...", store_name) + + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + customer_name = None + if customer_id: + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + + # Get related documents + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") + + log_data["order_references"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "erpnext_sales_order": sales_order_name, + "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), + "erpnext_sales_invoice": sales_invoice, + "erpnext_delivery_notes": delivery_notes, + "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", + "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", + } + + log_store2("UPDATE-5", f""" +Order references: + Sales Order: {sales_order_name or 'Not found'} + Customer: {customer_name or 'Not found'} + Sales Invoice: {sales_invoice or 'Not found'} + Delivery Notes: {len(delivery_notes) if delivery_notes else 0} +""", store_name) + + # ========== EXTRACT AMOUNT DETAILS ========== + log_store2("UPDATE-6", "Extracting amount details...", store_name) + + log_data["amount_details"] = { + "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), + "shopify_total_tax": flt(order.get("total_tax", 0)), + "shopify_total_discounts": flt(order.get("total_discounts", 0)), + "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, + "shopify_total_price": flt(order.get("total_price", 0)), + "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), + "shopify_currency": order.get("currency", ""), + "shopify_current_total_price": flt(order.get("current_total_price", 0)), + "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), + "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), + "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), + } + + # Line item amounts + line_item_amounts = [] + for item in order.get("line_items", []): + line_item_amounts.append({ + "shopify_line_item_id": item.get("id"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), + "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), + }) + log_data["amount_details"]["line_items"] = line_item_amounts + + # Tax line amounts + tax_line_amounts = [] + for tax_line in order.get("tax_lines", []): + tax_line_amounts.append({ + "title": tax_line.get("title", ""), + "price": flt(tax_line.get("price", 0)), + "rate": flt(tax_line.get("rate", 0)), + }) + log_data["amount_details"]["tax_lines"] = tax_line_amounts + + # Shipping line amounts + shipping_line_amounts = [] + for shipping_line in order.get("shipping_lines", []): + shipping_line_amounts.append({ + "title": shipping_line.get("title", ""), + "price": flt(shipping_line.get("price", 0)), + "code": shipping_line.get("code", ""), + }) + log_data["amount_details"]["shipping_lines"] = shipping_line_amounts + + log_store2("UPDATE-7", f""" +Amount details: + Total Price: {log_data['amount_details']['shopify_total_price']} + Subtotal: {log_data['amount_details']['shopify_subtotal_price']} + Tax: {log_data['amount_details']['shopify_total_tax']} + Discounts: {log_data['amount_details']['shopify_total_discounts']} + Line Items: {len(line_item_amounts)} +""", store_name) + + # ========== DETECT CHANGES ========== + log_store2("UPDATE-8", "Detecting changes...", store_name) + + if not sales_order_name: + # Order doesn't exist, create it + log_data["change_details"] = { + "action": "create_new_order", + "reason": "Order not found in ERPNext", + } + log_data["status"] = "creating" + + log_store2("UPDATE-9", f"Order {order_number} not found, creating new order", store_name) + + create_shopify_log( + status="Info", + message=f"Order {order_number} not found, creating new order", + request_data=order, + response_data=log_data + ) + sync_sales_order(payload, request_id, store_name=store_name) + return + + # Order exists, compare and detect changes + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + log_store2("UPDATE-10", f"Sales Order found: {sales_order_name}, comparing changes...", store_name) + + # Compare amounts + old_total = flt(sales_order.grand_total) + new_total = flt(order.get("total_price", 0)) + if old_total != new_total: + changes["grand_total"] = { + "old": old_total, + "new": new_total, + "difference": new_total - old_total + } + log_store2("UPDATE-10a", f"Grand total changed: {old_total} → {new_total}", store_name) + + old_subtotal = flt(sales_order.total) + new_subtotal = flt(order.get("subtotal_price", 0)) + if old_subtotal != new_subtotal: + changes["subtotal"] = { + "old": old_subtotal, + "new": new_subtotal, + "difference": new_subtotal - old_subtotal + } + log_store2("UPDATE-10b", f"Subtotal changed: {old_subtotal} → {new_subtotal}", store_name) + + # Compare order status + old_status = sales_order.get(ORDER_STATUS_FIELD) or "" + new_status = order.get("financial_status", "") + if old_status != new_status: + changes["financial_status"] = { + "old": old_status, + "new": new_status + } + log_store2("UPDATE-10c", f"Financial status changed: {old_status} → {new_status}", store_name) + + old_fulfillment_status = order.get("fulfillment_status") # This might not be stored + new_fulfillment_status = order.get("fulfillment_status", "") + if old_fulfillment_status != new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status or "unfulfilled" + } + log_store2("UPDATE-10d", f"Fulfillment status changed: {old_fulfillment_status} → {new_fulfillment_status}", store_name) + + # Compare line items + old_items_count = len(sales_order.items) + new_items_count = len(order.get("line_items", [])) + if old_items_count != new_items_count: + changes["line_items_count"] = { + "old": old_items_count, + "new": new_items_count + } + log_store2("UPDATE-10e", f"Line items count changed: {old_items_count} → {new_items_count}", store_name) + + # Compare line items in detail + line_item_changes = [] + shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} + + # Get existing line item IDs from Sales Order + existing_shopify_ids = set() + for so_item in sales_order.items: + # Try to get shopify_line_item_id from custom field if it exists + shopify_item_id = str(so_item.get("shopify_line_item_id", "")) + if shopify_item_id and shopify_item_id in shopify_items_map: + shopify_item = shopify_items_map[shopify_item_id] + item_changes = {} + + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(shopify_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: # Allow small floating point differences + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + if item_changes: + line_item_changes.append({ + "item_code": so_item.item_code, + "shopify_line_item_id": shopify_item_id, + "changes": item_changes + }) + + existing_shopify_ids.add(shopify_item_id) + + # Check for new items + for shopify_item in order.get("line_items", []): + if str(shopify_item.get("id")) not in existing_shopify_ids: + line_item_changes.append({ + "shopify_line_item_id": str(shopify_item.get("id")), + "shopify_product_id": shopify_item.get("product_id"), + "shopify_variant_id": shopify_item.get("variant_id"), + "title": shopify_item.get("title"), + "action": "added" + }) + + if line_item_changes: + changes["line_items"] = line_item_changes + log_store2("UPDATE-10f", f"Line item changes detected: {len(line_item_changes)} items", store_name) + + # Compare customer + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + log_store2("UPDATE-10g", f"Customer changed: {old_customer} → {new_customer}", store_name) + + # Compare order note + new_note = order.get("note", "") + if new_note: + changes["note"] = { + "old": "", + "new": new_note + } + + # Compare dates + old_date = str(sales_order.transaction_date) if sales_order.transaction_date else "" + new_date = str(getdate(order.get("created_at"))) if order.get("created_at") else "" + if old_date != new_date: + changes["transaction_date"] = { + "old": old_date, + "new": new_date + } + + log_data["change_details"] = changes + + log_store2("UPDATE-11", f"Change detection complete. Found {len(changes)} change(s)", store_name) + + # ========== STORE LINE ITEMS DETAILS ========== + log_store2("UPDATE-12", "Storing line items details...", store_name) + + for item in order.get("line_items", []): + item_code = get_item_code(item) if item.get("product_exists") else None + log_data["line_items_details"].append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + "erpnext_item_code": item_code, + "title": item.get("title", ""), + "name": item.get("name", ""), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "requires_shipping": item.get("requires_shipping", False), + "fulfillable_quantity": cint(item.get("fulfillable_quantity", 0)), + }) + + # ========== PROCESS THE UPDATE ========== + log_store2("UPDATE-13", f"Processing update. Sales Order status: {sales_order.docstatus}", store_name) + + if sales_order.docstatus == 2: # Cancelled + log_data["status"] = "cancelled_order" + log_data["change_details"]["action"] = "status_update_only" + log_data["change_details"]["reason"] = "Sales Order is cancelled, only status updated" + + log_store2("UPDATE-14", "Sales Order is cancelled, only updating status", store_name) + + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + create_shopify_log( + status="Invalid", + message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.", + request_data=order, + response_data=log_data + ) + return + + # Update customer if changed + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + log_store2("UPDATE-15", f"Syncing customer {customer_id}...", store_name) + customer.sync_customer(customer=shopify_customer) + else: + log_store2("UPDATE-15", f"Updating addresses for customer {customer_id}...", store_name) + customer.update_existing_addresses(shopify_customer) + + # Ensure items exist + log_store2("UPDATE-16", "Ensuring items exist...", store_name) + create_items_if_not_exist(order) + + # Get updated items and taxes + setting = frappe.get_doc(SETTING_DOCTYPE) + items = get_order_items( + order.get("line_items"), + setting, + getdate(order.get("created_at")) or getdate(sales_order.transaction_date), + taxes_inclusive=order.get("taxes_included"), + store_name=store_name, + ) + + if not items: + log_data["status"] = "error" + log_data["change_details"]["error"] = "Items not found in product master" + + log_store2("UPDATE-17-ERROR", "Items not found in product master!", store_name) + + create_shopify_log( + status="Error", + message="Cannot update order: items not found in product master", + request_data=order, + response_data=log_data, + rollback=True + ) + return + + log_store2("UPDATE-17", f"Got {len(items)} items, calculating taxes...", store_name) + taxes = get_order_taxes(order, setting, items) + + # Update customer if changed + customer_name = setting.default_customer + if shopify_customer.get("id"): + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer + + # Update the order + if sales_order.docstatus == 1: # Submitted + log_data["status"] = "submitted_order_limited_update" + log_data["change_details"]["action"] = "limited_update" + log_data["change_details"]["reason"] = "Order is submitted, only status and notes updated" + + log_store2("UPDATE-18", "Sales Order is submitted, only updating status and notes", store_name) + + # For submitted orders, only update status and notes + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + if order.get("note"): + sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") + + # Update ERPNext amounts in log for comparison + log_data["amount_details"]["erpnext_grand_total"] = flt(sales_order.grand_total) + log_data["amount_details"]["erpnext_total"] = flt(sales_order.total) + log_data["amount_details"]["erpnext_total_taxes_and_charges"] = flt(sales_order.total_taxes_and_charges) + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", + request_data=order, + response_data=log_data + ) + + log_store2("UPDATE-19-SUCCESS", f"Order {order_number} updated successfully (limited)", store_name) + else: + # Draft order - can update fully + log_data["status"] = "draft_order_full_update" + log_data["change_details"]["action"] = "full_update" + + log_store2("UPDATE-18", "Sales Order is draft, performing full update...", store_name) + + old_amounts = { + "grand_total": flt(sales_order.grand_total), + "total": flt(sales_order.total), + "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), + } + + sales_order.update({ + "customer": customer_name, + "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date, + "delivery_date": getdate(order.get("created_at")) or sales_order.delivery_date, + "items": items, + "taxes": taxes, + }) + + if order.get("name") != sales_order.get(ORDER_NUMBER_FIELD): + sales_order.set(ORDER_NUMBER_FIELD, order.get("name")) + + sales_order.flags.ignore_mandatory = True + sales_order.flags.shopiy_order_json = json.dumps(order) + + log_store2("UPDATE-19", "Saving Sales Order...", store_name) + sales_order.save(ignore_permissions=True) + + # Reload to get updated amounts + sales_order.reload() + + new_amounts = { + "grand_total": flt(sales_order.grand_total), + "total": flt(sales_order.total), + "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), + } + + # Add ERPNext amounts to log + log_data["amount_details"]["erpnext_grand_total"] = new_amounts["grand_total"] + log_data["amount_details"]["erpnext_total"] = new_amounts["total"] + log_data["amount_details"]["erpnext_total_taxes_and_charges"] = new_amounts["total_taxes_and_charges"] + + # Add amount changes + if old_amounts["grand_total"] != new_amounts["grand_total"]: + if "grand_total" not in log_data["change_details"]: + log_data["change_details"]["grand_total"] = {} + log_data["change_details"]["grand_total"]["erpnext_old"] = old_amounts["grand_total"] + log_data["change_details"]["grand_total"]["erpnext_new"] = new_amounts["grand_total"] + + if order.get("note"): + sales_order.add_comment(text=f"Order Note: {order.get('note')}") + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected", + request_data=order, + response_data=log_data + ) + + log_store2("UPDATE-20-SUCCESS", f""" +======================================== +ORDER UPDATE COMPLETED SUCCESSFULLY! +======================================== +Order: {order_number} +Sales Order: {sales_order_name} +Changes: {len(changes)} +Store: {store_name} +======================================== +""", store_name) + + except Exception as e: + log_data["status"] = "error" + log_data["change_details"]["error"] = str(e) + log_data["change_details"]["traceback"] = frappe.get_traceback() + + log_store2("UPDATE-ERROR", f""" +======================================== +ORDER UPDATE FAILED! +======================================== +Error: {str(e)} +Type: {type(e).__name__} +Order: {order.get('name', 'Unknown')} +Store: {store_name} + +Traceback: +{traceback.format_exc()} +======================================== +""", store_name) + + create_shopify_log( + status="Error", + exception=e, + message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}", + request_data=order, + response_data=log_data, + rollback=True + ) + + @temp_shopify_session def sync_old_orders(): shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) From 089c7c0c26148a9645a2c7cb9967db95d2c08c3f Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Tue, 24 Feb 2026 16:07:43 +0530 Subject: [PATCH 09/18] Add update detection trigger and fix spam logs in update_sales_order --- SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md | 902 ++++++ ecommerce_integrations/shopify/order.py | 2522 +++++++++-------- 2 files changed, 2189 insertions(+), 1235 deletions(-) create mode 100644 SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md diff --git a/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md new file mode 100644 index 000000000..8391391c4 --- /dev/null +++ b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md @@ -0,0 +1,902 @@ +# Technical Documentation: Shopify Order Update Webhook with Comprehensive Logging + +## Document Information +- **Version:** 1.0 +- **Date:** January 2025 +- **Branch:** `shopify-null-product-id-fallback-mapping` +- **Author:** Priyanshi + +--- + +## Table of Contents +1. [Problem Statement](#problem-statement) +2. [Solution Overview](#solution-overview) +3. [Files Modified](#files-modified) +4. [Detailed Code Changes](#detailed-code-changes) +5. [Function Reference](#function-reference) +6. [Data Flow](#data-flow) +7. [Testing Scenarios](#testing-scenarios) +8. [Dependencies](#dependencies) +9. [Deployment Checklist](#deployment-checklist) +10. [Rollback Procedure](#rollback-procedure) +11. [Support & Troubleshooting](#support--troubleshooting) + +--- + +## Problem Statement + +### Issue +When orders are updated in Shopify (amounts changed, items added/removed, status changed), there was no mechanism to: +- Track these updates in ERPNext +- Log comprehensive details about what changed +- Notify the OPS team about order modifications +- Maintain an audit trail of all order changes +- Capture all order references, primary keys, and amount details for communication purposes + +### Root Causes +1. **Missing Webhook Handler** — No webhook event handler for `orders/updated` event +2. **No Change Tracking** — System couldn't detect or log what changed in orders +3. **Incomplete Logging** — Existing logs didn't capture all necessary details (references, keys, amounts, changes) +4. **No Multi-Store Support** — Update webhook wasn't configured for Store 2 +5. **Limited Visibility** — OPS team had no way to see order updates without manual checking + +### Impact +- Order updates in Shopify were not tracked in ERPNext +- No audit trail for order modifications +- OPS team couldn't see what changed in orders +- Difficult to communicate order changes to stakeholders +- No way to detect discrepancies between Shopify and ERPNext + +--- + +## Solution Overview + +### Approach +Implemented a comprehensive solution: +1. **Webhook Event Registration** — Added `orders/updated` to webhook events for both stores +2. **Update Handler Function** — Created `update_sales_order()` to handle order updates +3. **Comprehensive Logging** — Logs all order references, primary keys, amount details, and change details +4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes +5. **Multi-Store Support** — Works seamlessly with both Store 1 and Store 2 +6. **Complete Data Extraction** — Captures all necessary information for OPS team communication + +### Key Features +- ✅ Automatic webhook registration for both stores +- ✅ Complete order reference tracking (Shopify IDs ↔ ERPNext documents) +- ✅ All primary keys captured (order, items, fulfillments, discounts) +- ✅ Detailed amount breakdowns (totals, line items, taxes, shipping) +- ✅ Change detection (amounts, items, status) +- ✅ Comprehensive log entries in Ecommerce Integration Log +- ✅ Support for new order creation if order doesn't exist + +--- + +## Files Modified + +### Summary +- **2 files modified** +- **~600 lines added** +- **2 lines modified** + +### File List +1. `ecommerce_integrations/shopify/constants.py` +2. `ecommerce_integrations/shopify/order.py` + +--- + +## Detailed Code Changes + +### File 1: `ecommerce_integrations/shopify/constants.py` + +#### Change 1: Add `orders/updated` to WEBHOOK_EVENTS + +**Location:** Line 17 + +**Purpose:** Register the order update webhook event + +**Before:** +```python +WEBHOOK_EVENTS = [ + "orders/create", + "orders/paid", + "orders/fulfilled", + "orders/cancelled", + "orders/partially_fulfilled", +] +``` + +**After:** +```python +WEBHOOK_EVENTS = [ + "orders/create", + "orders/paid", + "orders/fulfilled", + "orders/cancelled", + "orders/partially_fulfilled", + "orders/updated", # ← NEW +] +``` + +**Why:** Enables automatic webhook registration for order updates in both Store 1 and Store 2. + +--- + +#### Change 2: Add Event Mapping to EVENT_MAPPER + +**Location:** Line 26 + +**Purpose:** Map the webhook event to the handler function + +**Before:** +```python +EVENT_MAPPER = { + "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order", + "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice", + "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", + "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", +} +``` + +**After:** +```python +EVENT_MAPPER = { + "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order", + "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice", + "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", + "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", # ← NEW +} +``` + +**Why:** Routes the webhook event to the correct handler function when an order update is received. + +--- + +### File 2: `ecommerce_integrations/shopify/order.py` + +#### Change 1: New Function — `update_sales_order()` + +**Location:** Lines 657-1236 + +**Purpose:** Handle order updates from Shopify with comprehensive logging + +**Code Added:** +```python +def update_sales_order(payload, request_id=None, store_name=None): + """Handle order updates from Shopify with comprehensive logging. + + Logs all order references, change details, amount details, and primary keys. + Supports both Store 1 and Store 2. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("UPDATE-1", f""" +======================================== +ORDER UPDATE WEBHOOK RECEIVED +======================================== +Store: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +request_id: {request_id} +""", store_name) + + # Initialize comprehensive log data structure + log_data = { + "order_references": {}, + "change_details": {}, + "amount_details": {}, + "primary_keys": {}, + "line_items_details": [], + "status": "processing", + "store_name": store_name or "Store 1" + } + + try: + # ========== EXTRACT ALL PRIMARY KEYS ========== + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + customer_id = order.get("customer", {}).get("id") if order.get("customer") else None + + log_data["primary_keys"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "shopify_customer_id": customer_id, + "shopify_order_name": order.get("name", ""), + "shopify_order_token": order.get("token", ""), + "shopify_checkout_id": order.get("checkout_id"), + "shopify_checkout_token": order.get("checkout_token"), + } + + # Extract line item IDs + line_item_ids = [] + for item in order.get("line_items", []): + line_item_ids.append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + }) + log_data["primary_keys"]["line_items"] = line_item_ids + + # Extract fulfillment IDs + fulfillment_ids = [] + for fulfillment in order.get("fulfillments", []): + fulfillment_ids.append({ + "shopify_fulfillment_id": fulfillment.get("id"), + "shopify_tracking_number": fulfillment.get("tracking_number"), + }) + log_data["primary_keys"]["fulfillments"] = fulfillment_ids + + # Extract discount code IDs + discount_codes = [] + for discount in order.get("discount_codes", []): + discount_codes.append({ + "shopify_discount_code": discount.get("code"), + "shopify_discount_type": discount.get("type"), + }) + log_data["primary_keys"]["discount_codes"] = discount_codes + + # ========== EXTRACT ALL ORDER REFERENCES ========== + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + customer_name = None + if customer_id: + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + + # Get related documents + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") + + log_data["order_references"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "erpnext_sales_order": sales_order_name, + "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), + "erpnext_sales_invoice": sales_invoice, + "erpnext_delivery_notes": delivery_notes, + "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", + "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", + } + + # ========== EXTRACT AMOUNT DETAILS ========== + log_data["amount_details"] = { + "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), + "shopify_total_tax": flt(order.get("total_tax", 0)), + "shopify_total_discounts": flt(order.get("total_discounts", 0)), + "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, + "shopify_total_price": flt(order.get("total_price", 0)), + "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), + "shopify_currency": order.get("currency", ""), + "shopify_current_total_price": flt(order.get("current_total_price", 0)), + "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), + "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), + "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), + } + + # Line item amounts + line_item_amounts = [] + for item in order.get("line_items", []): + line_item_amounts.append({ + "shopify_line_item_id": item.get("id"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), + "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), + }) + log_data["amount_details"]["line_items"] = line_item_amounts + + # Tax line amounts + tax_line_amounts = [] + for tax_line in order.get("tax_lines", []): + tax_line_amounts.append({ + "title": tax_line.get("title", ""), + "price": flt(tax_line.get("price", 0)), + "rate": flt(tax_line.get("rate", 0)), + }) + log_data["amount_details"]["tax_lines"] = tax_line_amounts + + # Shipping line amounts + shipping_line_amounts = [] + for shipping_line in order.get("shipping_lines", []): + shipping_line_amounts.append({ + "title": shipping_line.get("title", ""), + "price": flt(shipping_line.get("price", 0)), + "code": shipping_line.get("code", ""), + }) + log_data["amount_details"]["shipping_lines"] = shipping_line_amounts + + # ========== DETECT CHANGES ========== + if not sales_order_name: + # Order doesn't exist, create it + log_data["change_details"] = { + "action": "create_new_order", + "reason": "Order not found in ERPNext", + } + log_data["status"] = "creating" + + create_shopify_log( + status="Info", + message=f"Order {order_number} not found, creating new order", + request_data=order, + response_data=log_data + ) + sync_sales_order(payload, request_id, store_name=store_name) + return + + # Order exists, compare and detect changes + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + # Compare amounts + old_total = flt(sales_order.grand_total) + new_total = flt(order.get("total_price", 0)) + if old_total != new_total: + changes["grand_total"] = { + "old": old_total, + "new": new_total, + "difference": new_total - old_total + } + + old_subtotal = flt(sales_order.total) + new_subtotal = flt(order.get("subtotal_price", 0)) + if old_subtotal != new_subtotal: + changes["subtotal"] = { + "old": old_subtotal, + "new": new_subtotal, + "difference": new_subtotal - old_subtotal + } + + # Compare order status + old_status = sales_order.get(ORDER_STATUS_FIELD) or "" + new_status = order.get("financial_status", "") + if old_status != new_status: + changes["financial_status"] = { + "old": old_status, + "new": new_status + } + + old_fulfillment_status = order.get("fulfillment_status") + new_fulfillment_status = order.get("fulfillment_status", "") + if old_fulfillment_status != new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status or "unfulfilled" + } + + # Compare line items + old_items_count = len(sales_order.items) + new_items_count = len(order.get("line_items", [])) + if old_items_count != new_items_count: + changes["line_items_count"] = { + "old": old_items_count, + "new": new_items_count + } + + # Compare line items in detail + line_item_changes = [] + shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} + + # Get existing line item IDs from Sales Order + existing_shopify_ids = set() + for so_item in sales_order.items: + shopify_item_id = str(so_item.get("shopify_line_item_id", "")) + if shopify_item_id and shopify_item_id in shopify_items_map: + shopify_item = shopify_items_map[shopify_item_id] + item_changes = {} + + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(shopify_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + if item_changes: + line_item_changes.append({ + "item_code": so_item.item_code, + "shopify_line_item_id": shopify_item_id, + "changes": item_changes + }) + + existing_shopify_ids.add(shopify_item_id) + + # Check for new items + for shopify_item in order.get("line_items", []): + if str(shopify_item.get("id")) not in existing_shopify_ids: + line_item_changes.append({ + "shopify_line_item_id": str(shopify_item.get("id")), + "shopify_product_id": shopify_item.get("product_id"), + "shopify_variant_id": shopify_item.get("variant_id"), + "title": shopify_item.get("title"), + "action": "item_added" + }) + + # Build change details + log_data["change_details"] = { + "action": "order_updated", + "changes_detected": len(changes) > 0 or len(line_item_changes) > 0, + "amount_changes": { + "subtotal_changed": "subtotal" in changes, + "old_subtotal": old_subtotal, + "new_subtotal": new_subtotal, + "total_changed": "grand_total" in changes, + "old_total": old_total, + "new_total": new_total, + } if changes else {}, + "item_changes": { + "items_added": len([c for c in line_item_changes if c.get("action") == "item_added"]), + "items_removed": 0, # Can be enhanced + "items_modified": len([c for c in line_item_changes if "changes" in c]), + "line_item_changes": line_item_changes + }, + "status_changes": { + "financial_status_changed": "financial_status" in changes, + "old_financial_status": old_status, + "new_financial_status": new_status, + "fulfillment_status_changed": "fulfillment_status" in changes, + "old_fulfillment_status": old_fulfillment_status or "unfulfilled", + "new_fulfillment_status": new_fulfillment_status or "unfulfilled", + } if changes else {} + } + + # Extract line items details + for item in order.get("line_items", []): + log_data["line_items_details"].append({ + "shopify_line_item_id": item.get("id"), + "title": item.get("title"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "sku": item.get("sku", ""), + "product_id": item.get("product_id"), + "variant_id": item.get("variant_id"), + }) + + log_data["status"] = "success" + + # Check if order is cancelled + if order.get("cancelled_at"): + log_data["change_details"]["action"] = "order_cancelled" + log_data["change_details"]["cancelled_at"] = order.get("cancelled_at") + log_data["change_details"]["cancel_reason"] = order.get("cancel_reason") + + create_shopify_log( + status="Info", + message=f"Order {order_number} was cancelled in Shopify", + request_data=order, + response_data=log_data + ) + else: + create_shopify_log( + status="Success", + message=f"Order {order_number} updated successfully", + request_data=order, + response_data=log_data + ) + + except Exception as e: + log_data["status"] = "error" + log_data["error"] = str(e) + log_data["traceback"] = traceback.format_exc() + + log_store2("UPDATE-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) + + create_shopify_log( + status="Error", + message=f"Error processing order update: {str(e)}", + request_data=order, + response_data=log_data, + exception=e + ) +``` + +**Key Features:** +1. **Primary Keys Extraction** — Captures all Shopify IDs (order, items, fulfillments, discounts) +2. **Order References** — Links Shopify orders to ERPNext documents (Sales Order, Customer, Invoice, Delivery Notes) +3. **Amount Details** — Complete financial breakdown (totals, line items, taxes, shipping) +4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes +5. **Line Items Details** — Complete item information for all line items +6. **Multi-Store Support** — Handles both Store 1 and Store 2 +7. **Error Handling** — Comprehensive error logging with traceback +8. **New Order Creation** — Creates new order if it doesn't exist in ERPNext + +**Why:** Provides complete visibility into order updates, enabling OPS team to track changes, communicate updates, and maintain audit trail. + +--- + +## Function Reference + +### New Functions + +#### `update_sales_order(payload, request_id=None, store_name=None)` +- **File:** `order.py` +- **Line:** 657-1236 +- **Parameters:** + - `payload` (dict): Shopify order data from webhook + - `request_id` (str, optional): Unique request identifier + - `store_name` (str, optional): Store name ("Store 2" or None for Store 1) +- **Returns:** None (creates log entry) +- **Description:** Handles order updates from Shopify with comprehensive logging +- **Dependencies:** + - `create_shopify_log()` from `utils.py` + - `sync_sales_order()` from `order.py` + - `log_store2()` for Store 2 debugging +- **Side Effects:** + - Creates log entry in Ecommerce Integration Log + - May create new Sales Order if order doesn't exist + +--- + +## Data Flow + +### Order Update Webhook Flow + +``` +1. Order Updated in Shopify + ↓ +2. Shopify Sends Webhook to ERPNext + ↓ +3. connection.py: store_request_data() + - Receives webhook + - Determines store (Store 1 or Store 2) from X-Shopify-Shop-Domain header + - Creates initial log (status: "Queued") + ↓ +4. connection.py: process_request() + - Enqueues job with store_name parameter + ↓ +5. order.py: update_sales_order() + - Extracts primary keys (order IDs, item IDs, fulfillment IDs) + - Extracts order references (Shopify ↔ ERPNext document links) + - Extracts amount details (totals, line items, taxes, shipping) + - Detects changes (compares Shopify with ERPNext) + - Extracts line items details + ↓ +6. Creates Final Log Entry + - Status: "Success", "Info", or "Error" + - Stores all data in response_data field + ↓ +7. Log Available in ERPNext + - View in Ecommerce Integration Log + - Use for notifications + - Use for OPS team communication +``` + +### Change Detection Flow + +``` +1. Get Shopify Order Data + ↓ +2. Find ERPNext Sales Order by shopify_order_id + ↓ +3. If Order Not Found: + - Set action: "create_new_order" + - Call sync_sales_order() to create new order + - Return + ↓ +4. If Order Found: + - Compare amounts (subtotal, total, tax, discounts) + - Compare line items (quantity, rate, added, removed) + - Compare status (financial, fulfillment) + ↓ +5. Build Change Details + - Store before/after values + - Calculate differences + - Identify what changed + ↓ +6. Create Log Entry + - All data stored in response_data + - Status based on outcome +``` + +### Multi-Store Flow + +``` +Store 1 Webhook: + ↓ +connection.py detects Store 1 + ↓ +process_request() with store_name=None + ↓ +update_sales_order() with store_name=None + ↓ +Logs with store_name="Store 1" + +Store 2 Webhook: + ↓ +connection.py detects Store 2 + ↓ +process_request() with store_name="Store 2" + ↓ +update_sales_order() with store_name="Store 2" + ↓ +Sets frappe.local.shopify_store_name + ↓ +Uses log_store2() for debugging + ↓ +Logs with store_name="Store 2" +``` + +--- + +## Testing Scenarios + +### Test Case 1: Order Amount Updated +**Input:** +- Existing order in ERPNext +- Order total changed from $100.00 to $120.00 in Shopify + +**Expected Output:** +- Log entry created with status "Success" +- `change_details.amount_changes.total_changed = true` +- `change_details.amount_changes.old_total = 100.00` +- `change_details.amount_changes.new_total = 120.00` +- All order references populated +- All primary keys captured + +**Test Command:** +```python +# Update order in Shopify, then check logs +logs = frappe.get_all( + "Ecommerce Integration Log", + filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"}, + order_by="creation desc", + limit=1 +) +``` + +### Test Case 2: Order Item Added +**Input:** +- Existing order with 2 items +- New item added in Shopify (now 3 items) + +**Expected Output:** +- Log entry created +- `change_details.item_changes.items_added = 1` +- `change_details.item_changes.line_item_changes` contains new item +- Line items details includes all 3 items + +### Test Case 3: Order Item Quantity Changed +**Input:** +- Existing order with item quantity = 2 +- Quantity changed to 3 in Shopify + +**Expected Output:** +- Log entry created +- `change_details.item_changes.items_modified = 1` +- `change_details.item_changes.line_item_changes[0].changes.quantity.old = 2` +- `change_details.item_changes.line_item_changes[0].changes.quantity.new = 3` + +### Test Case 4: Order Status Changed +**Input:** +- Existing order with financial_status = "pending" +- Status changed to "paid" in Shopify + +**Expected Output:** +- Log entry created +- `change_details.status_changes.financial_status_changed = true` +- `change_details.status_changes.old_financial_status = "pending"` +- `change_details.status_changes.new_financial_status = "paid"` + +### Test Case 5: Order Not Found (New Order) +**Input:** +- Order updated in Shopify +- Order doesn't exist in ERPNext + +**Expected Output:** +- Log entry created with status "Info" +- `change_details.action = "create_new_order"` +- `change_details.reason = "Order not found in ERPNext"` +- New Sales Order created via `sync_sales_order()` + +### Test Case 6: Order Cancelled +**Input:** +- Existing order +- Order cancelled in Shopify + +**Expected Output:** +- Log entry created with status "Info" +- `change_details.action = "order_cancelled"` +- `change_details.cancelled_at` populated +- `change_details.cancel_reason` populated + +### Test Case 7: Multi-Store Update +**Input:** +- Order updated in Store 1 +- Order updated in Store 2 + +**Expected Output:** +- Two separate log entries created +- Store 1 log has `store_name = "Store 1"` +- Store 2 log has `store_name = "Store 2"` +- Both logs contain complete data + +### Test Case 8: Error Handling +**Input:** +- Invalid order data or processing error + +**Expected Output:** +- Log entry created with status "Error" +- `response_data.status = "error"` +- `response_data.error` contains error message +- `response_data.traceback` contains full traceback + +--- + +## Dependencies + +### Code Dependencies +- `frappe` — Core Frappe framework +- `ecommerce_integrations.shopify.constants` — Module constants (ORDER_ID_FIELD, etc.) +- `ecommerce_integrations.shopify.utils` — Utility functions (create_shopify_log, log_store2) +- `ecommerce_integrations.shopify.order` — Order sync functions (sync_sales_order) + +### System Dependencies +- **Ecommerce Integration Log** doctype must exist +- **Sales Order** doctype with `shopify_order_id` custom field +- **Customer** doctype with `shopify_customer_id` custom field +- Webhook endpoint must be accessible from Shopify + +### No Breaking Changes +- All changes are backward compatible +- Existing functionality preserved +- Only adds new webhook handler +- Doesn't modify existing order sync logic + +--- + +## Deployment Checklist + +- [ ] Verify `orders/updated` is in `WEBHOOK_EVENTS` in `constants.py` +- [ ] Verify event mapping exists in `EVENT_MAPPER` in `constants.py` +- [ ] Verify `update_sales_order()` function exists in `order.py` +- [ ] Test webhook registration for Store 1 +- [ ] Test webhook registration for Store 2 (if enabled) +- [ ] Test order update webhook with sample order +- [ ] Verify log entry is created in Ecommerce Integration Log +- [ ] Verify all data is captured (references, keys, amounts, changes) +- [ ] Test change detection (amount, item, status changes) +- [ ] Test new order creation scenario +- [ ] Test error handling +- [ ] Verify multi-store support works correctly +- [ ] Monitor logs for any errors after deployment + +--- + +## Rollback Procedure + +If issues occur, restore from backup files: + +```bash +cd apps/ecommerce_integrations/ecommerce_integrations/shopify +cp constants.py.backup.YYYYMMDD_HHMM constants.py +cp order.py.backup.YYYYMMDD_HHMM order.py +bench restart +``` + +**Note:** After rollback, webhooks will need to be re-registered to remove `orders/updated` event. + +--- + +## Support & Troubleshooting + +### Common Issues + +#### 1. "Webhook not receiving updates" +**Symptoms:** +- No log entries created +- Webhook not triggered + +**Solutions:** +1. Check webhook registration: + - Go to: `Shopify Setting` + - Click "Register Webhooks" + - Verify `orders/updated` is registered +2. Check Shopify webhook settings: + - Go to Shopify Admin → Settings → Notifications + - Verify webhook URL is correct + - Verify webhook is active +3. Check logs: + - Check `connection.py` logs for webhook receipt + - Check for errors in `Ecommerce Integration Log` + +#### 2. "Logs not showing all data" +**Symptoms:** +- Log entry created but `response_data` is empty or incomplete + +**Solutions:** +1. Check function execution: + - Verify `update_sales_order()` is being called + - Check for errors in log entry +2. Check data extraction: + - Verify order payload has expected fields + - Check Store 2 logs if applicable +3. Check log creation: + - Verify `create_shopify_log()` is called + - Check `response_data` parameter is passed + +#### 3. "Change detection not working" +**Symptoms:** +- Changes made but not detected +- `change_details` shows no changes + +**Solutions:** +1. Check Sales Order exists: + - Verify Sales Order is found by Shopify Order ID + - Check custom field `shopify_order_id` is set +2. Check comparison logic: + - Verify amounts are being compared correctly + - Check for data type mismatches +3. Check log data: + - Review `response_data` in log entry + - Verify all fields are populated + +#### 4. "Store 2 not working" +**Symptoms:** +- Store 1 works but Store 2 doesn't + +**Solutions:** +1. Check Store 2 configuration: + - Verify `enable_store_2` is checked + - Verify Store 2 credentials are set +2. Check webhook registration: + - Verify webhooks are registered for Store 2 + - Check webhook URL includes store identifier +3. Check logs: + - Check Store 2 specific logs + - Verify `store_name` parameter is passed correctly + +### Logging + +All order updates are logged in `Ecommerce Integration Log`: +- **Method:** `ecommerce_integrations.shopify.order.update_sales_order` +- **Status:** "Success", "Info", or "Error" +- **Request Data:** Full Shopify order payload +- **Response Data:** Complete log data structure + +Check logs with: +```bash +bench --site [site] console +``` + +```python +logs = frappe.get_all( + "Ecommerce Integration Log", + filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"}, + fields=["name", "creation", "status", "response_data"] +) +``` + +--- + +## Version History + +- **v1.0** (January 2025): Initial implementation + - Added `orders/updated` webhook event + - Created `update_sales_order()` function + - Implemented comprehensive logging + - Added change detection + - Multi-store support + +--- + +## Contact + +For questions or issues, refer to: +- **Author:** Priyanshi +- **Branch:** `shopify-null-product-id-fallback-mapping` +- **Files Modified:** `constants.py`, `order.py` + +--- + +**End of Documentation** diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 6ee50cea4..684976993 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -1,1236 +1,1288 @@ -""" -order.py - WITH COMPREHENSIVE STORE 2 LOGGING -This handles the background job for orders/create webhook. -""" - -import json -import traceback -from typing import Literal, Optional - -import frappe -from frappe import _ -from frappe.utils import cint, cstr, flt, get_datetime, getdate, nowdate -from shopify.collection import PaginatedIterator -from shopify.resources import Order - -from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import ( - CUSTOMER_ID_FIELD, - EVENT_MAPPER, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - 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.utils.price_list import get_dummy_price_list -from ecommerce_integrations.utils.taxation import get_dummy_tax_category - -DEFAULT_TAX_FIELDS = { - "sales_tax": "default_sales_tax_account", - "shipping": "default_shipping_charges_account", -} - - -def log_store2(step, message, store_name=None): - """Helper function to log only for Store 2.""" - if store_name and store_name != "Store 1": - frappe.log_error( - title=f"[STORE2 ORDER] Step {step}", - message=f"Store: {store_name}\n\n{message}" - ) - - -def sync_sales_order(payload, request_id=None, store_name=None): - """Sync Shopify order to ERPNext Sales Order. - - This is called as a BACKGROUND JOB by the RQ worker. - """ - order = payload - - # ========================================================================= - # STEP BG-1: Background job started - # ========================================================================= - log_store2("BG-1", f""" -======================================== -BACKGROUND JOB STARTED: sync_sales_order -======================================== -request_id: {request_id} -store_name: {store_name} -Order ID: {order.get('id')} -Order Number: {order.get('name')} -frappe.local exists: {hasattr(frappe, 'local')} -""", store_name) - - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - # ========================================================================= - # STEP BG-2: Set store context (CRITICAL!) - # ========================================================================= - log_store2("BG-2", f""" -Setting store context in background worker... -Before: frappe.local.shopify_store_name = {getattr(frappe.local, 'shopify_store_name', 'NOT SET')} -""", store_name) - - if store_name: - frappe.local.shopify_store_name = store_name - log_store2("BG-2-OK", f""" -Store context set! -After: frappe.local.shopify_store_name = {frappe.local.shopify_store_name} -""", store_name) - else: - log_store2("BG-2-WARN", "store_name is None! Will default to Store 1 credentials!", store_name) - - # ========================================================================= - # STEP BG-3: Check if order already exists - # ========================================================================= - log_store2("BG-3", f"Checking if Sales Order already exists for Shopify Order ID: {order['id']}", store_name) - - existing_so = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}) - - if existing_so: - log_store2("BG-3-SKIP", f""" -Sales Order already exists! -Existing SO: {existing_so} -Shopify Order ID: {order['id']} -Skipping creation. -""", store_name) - create_shopify_log(status="Invalid", message="Sales order already exists, not synced") - return - - log_store2("BG-3-OK", "No existing Sales Order found, proceeding with creation.", store_name) - - # ========================================================================= - # STEP BG-4: Process customer - # ========================================================================= - try: - log_store2("BG-4", "Processing customer...", store_name) - - shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") - customer_id = shopify_customer.get("id") - - log_store2("BG-4a", f""" -Customer data: -customer_id: {customer_id} -email: {shopify_customer.get('email')} -has billing_address: {bool(order.get('billing_address'))} -has shipping_address: {bool(order.get('shipping_address'))} -""", store_name) - - if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) - if not customer.is_synced(): - log_store2("BG-4b", f"Customer {customer_id} not synced, creating...", store_name) - customer.sync_customer(customer=shopify_customer) - log_store2("BG-4c", f"Customer {customer_id} created.", store_name) - else: - log_store2("BG-4b", f"Customer {customer_id} already exists, updating addresses...", store_name) - customer.update_existing_addresses(shopify_customer) - log_store2("BG-4c", f"Customer {customer_id} addresses updated.", store_name) - else: - log_store2("BG-4-WARN", "No customer_id in order, will use default customer.", store_name) - - log_store2("BG-4-OK", "Customer processing complete.", store_name) - - except Exception as e: - log_store2("BG-4-EXCEPTION", f""" -Exception processing customer! -Error: {str(e)} -Type: {type(e).__name__} - -Traceback: -{traceback.format_exc()} -""", store_name) - create_shopify_log(status="Error", exception=e, rollback=True) - return - - # ========================================================================= - # STEP BG-5: Sync items/products - # ========================================================================= - try: - log_store2("BG-5", f""" -Syncing items from order... -Line items count: {len(order.get('line_items', []))} -Line items: {[item.get('title') for item in order.get('line_items', [])]} -""", store_name) - - create_items_if_not_exist(order) - - log_store2("BG-5-OK", "Items synced successfully.", store_name) - - except Exception as e: - log_store2("BG-5-EXCEPTION", f""" -Exception syncing items! -Error: {str(e)} -Type: {type(e).__name__} - -Traceback: -{traceback.format_exc()} -""", store_name) - create_shopify_log(status="Error", exception=e, rollback=True) - return - - # ========================================================================= - # STEP BG-6: Create Sales Order - # ========================================================================= - try: - log_store2("BG-6", "Creating Sales Order...", store_name) - - setting = frappe.get_doc(SETTING_DOCTYPE) - - log_store2("BG-6a", f""" -Settings loaded: -Company: {setting.company} -Warehouse: {setting.warehouse} -Sales Order Series: {setting.sales_order_series} -Default Customer: {setting.default_customer} -""", store_name) - - create_order(order, setting, store_name=store_name) - - log_store2("BG-6-OK", f""" -======================================== -SALES ORDER CREATED SUCCESSFULLY! -======================================== -Shopify Order ID: {order.get('id')} -Shopify Order Number: {order.get('name')} -Store: {store_name} -""", store_name) - - except Exception as e: - log_store2("BG-6-EXCEPTION", f""" -Exception creating Sales Order! -Error: {str(e)} -Type: {type(e).__name__} - -Traceback: -{traceback.format_exc()} -""", store_name) - create_shopify_log(status="Error", exception=e, rollback=True) - return - - # ========================================================================= - # STEP BG-7: Success! - # ========================================================================= - log_store2("BG-7", "Creating success log entry...", store_name) - create_shopify_log(status="Success") - log_store2("BG-7-OK", f""" -======================================== -BACKGROUND JOB COMPLETED SUCCESSFULLY! -======================================== -Shopify Order: {order.get('name')} -Store: {store_name} -""", store_name) - - -def create_order(order, setting, company=None, store_name=None): - """Create order with related documents.""" - # local import to avoid circular dependencies - from ecommerce_integrations.shopify.fulfillment import create_delivery_note - from ecommerce_integrations.shopify.invoice import create_sales_invoice - - log_store2("CREATE-1", "Inside create_order()", store_name) - - so = create_sales_order(order, setting, company, store_name=store_name) - - if so: - log_store2("CREATE-2", f"Sales Order created: {so.name}", store_name) - - if order.get("financial_status") == "paid": - log_store2("CREATE-3", "Order is paid, creating Sales Invoice...", store_name) - create_sales_invoice(order, setting, so) - log_store2("CREATE-3-OK", "Sales Invoice created.", store_name) - - if order.get("fulfillments"): - log_store2("CREATE-4", "Order has fulfillments, creating Delivery Note...", store_name) - create_delivery_note(order, setting, so) - log_store2("CREATE-4-OK", "Delivery Note created.", store_name) - else: - log_store2("CREATE-WARN", "create_sales_order returned None!", store_name) - - -def create_sales_order(shopify_order, setting, company=None, store_name=None): - """Create the actual Sales Order document.""" - - log_store2("SO-1", f""" -Creating Sales Order... -Shopify Order ID: {shopify_order.get('id')} -Shopify Order Number: {shopify_order.get('name')} -""", store_name) - - # Determine customer - customer = setting.default_customer - if shopify_order.get("customer", {}): - if customer_id := shopify_order.get("customer", {}).get("id"): - customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or customer - - log_store2("SO-2", f"Customer determined: {customer}", store_name) - - # Check if SO already exists - so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") - - if not so: - log_store2("SO-3", "Getting order items...", store_name) - - items = get_order_items( - shopify_order.get("line_items"), - setting, - getdate(shopify_order.get("created_at")), - taxes_inclusive=shopify_order.get("taxes_included"), - store_name=store_name, - ) - - log_store2("SO-3a", f"Items count: {len(items)}", store_name) - - if not items: - log_store2("SO-3-FAIL", "No items returned! Cannot create Sales Order.", store_name) - message = ( - "Following items exists in the shopify order but relevant records were" - " not found in the shopify Product master" - ) - create_shopify_log(status="Error", exception=message, rollback=True) - return "" - - log_store2("SO-4", "Getting order taxes...", store_name) - taxes = get_order_taxes(shopify_order, setting, items) - log_store2("SO-4a", f"Taxes count: {len(taxes)}", store_name) - - log_store2("SO-5", "Creating Sales Order document...", store_name) - - so = frappe.get_doc( - { - "doctype": "Sales Order", - "naming_series": setting.sales_order_series or "SO-Shopify-", - ORDER_ID_FIELD: str(shopify_order.get("id")), - ORDER_NUMBER_FIELD: shopify_order.get("name"), - "customer": customer, - "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), - "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": setting.company, - "selling_price_list": get_dummy_price_list(), - "ignore_pricing_rule": 1, - "items": items, - "taxes": taxes, - "tax_category": get_dummy_tax_category(), - } - ) - - if company: - so.update({"company": company, "status": "Draft"}) - - so.flags.ignore_mandatory = True - so.flags.shopiy_order_json = json.dumps(shopify_order) - - log_store2("SO-6", "Saving Sales Order...", store_name) - so.save(ignore_permissions=True) - log_store2("SO-6a", f"Sales Order saved: {so.name}", store_name) - - log_store2("SO-7", "Submitting Sales Order...", store_name) - so.submit() - log_store2("SO-7a", f"Sales Order submitted: {so.name}", store_name) - - if shopify_order.get("note"): - so.add_comment(text=f"Order Note: {shopify_order.get('note')}") - log_store2("SO-8", "Added order note as comment.", store_name) - - else: - log_store2("SO-EXISTS", f"Sales Order already exists: {so}", store_name) - so = frappe.get_doc("Sales Order", so) - - return so - - -def get_order_items(order_items, setting, delivery_date, taxes_inclusive, store_name=None): - """Get order items for Sales Order.""" - items = [] - all_product_exists = True - product_not_exists = [] - - log_store2("ITEMS-1", f"Processing {len(order_items)} line items...", store_name) - - for idx, shopify_item in enumerate(order_items): - product_id = shopify_item.get("product_id") - - log_store2(f"ITEMS-2-{idx}", f""" -Processing item {idx + 1}: - title: {shopify_item.get('title')} - product_id: {product_id} - variant_id: {shopify_item.get('variant_id')} - sku: {shopify_item.get('sku')} - quantity: {shopify_item.get('quantity')} - price: {shopify_item.get('price')} - product_exists: {shopify_item.get('product_exists')} -""", store_name) - - # Handle items without product_id (tips, samples, fees) - if not product_id: - item_code = get_item_code(shopify_item) - log_store2(f"ITEMS-2-{idx}-NOID", f"No product_id, mapped to: {item_code}", store_name) - if item_code: - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name") or shopify_item.get("title"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - continue - - # Original logic for items with product_id - if not shopify_item.get("product_exists"): - all_product_exists = False - product_not_exists.append( - {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} - ) - log_store2(f"ITEMS-2-{idx}-NOTEXIST", f"Product does not exist in Shopify!", store_name) - continue - - if all_product_exists: - item_code = get_item_code(shopify_item) - log_store2(f"ITEMS-2-{idx}-CODE", f"Item code: {item_code}", store_name) - - if not item_code: - log_store2(f"ITEMS-2-{idx}-NOCODE", f"Could not get item_code!", store_name) - continue - - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - else: - items = [] - - log_store2("ITEMS-3", f"Returning {len(items)} items", store_name) - return items - - -# Keep the rest of the functions unchanged but add store_name parameter where needed -def _get_item_price(line_item, taxes_inclusive: bool) -> float: - price = flt(line_item.get("price")) - qty = cint(line_item.get("quantity")) - total_discount = _get_total_discount(line_item) - - if not taxes_inclusive: - return price - (total_discount / qty) - - total_taxes = 0.0 - for tax in line_item.get("tax_lines"): - total_taxes += flt(tax.get("price")) - - return price - (total_taxes + total_discount) / qty - - -def _get_total_discount(line_item) -> float: - discount_allocations = line_item.get("discount_allocations") or [] - return sum(flt(discount.get("amount")) for discount in discount_allocations) - - -def get_order_taxes(shopify_order, setting, items): - taxes = [] - line_items = shopify_order.get("line_items") - - for line_item in line_items: - item_code = get_item_code(line_item) - for tax in line_item.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax.get("price"), - "included_in_print_rate": 0, - "cost_center": setting.cost_center, - "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, - "dont_recompute_tax": 1, - } - ) - - update_taxes_with_shipping_lines( - taxes, - shopify_order.get("shipping_lines"), - setting, - items, - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if cint(setting.consolidate_taxes): - taxes = consolidate_order_taxes(taxes) - - for row in taxes: - tax_detail = row.get("item_wise_tax_detail") - if isinstance(tax_detail, dict): - row["item_wise_tax_detail"] = json.dumps(tax_detail) - - return taxes - - -def consolidate_order_taxes(taxes): - tax_account_wise_data = {} - for tax in taxes: - account_head = tax["account_head"] - tax_account_wise_data.setdefault( - account_head, - { - "charge_type": "Actual", - "account_head": account_head, - "description": tax.get("description"), - "cost_center": tax.get("cost_center"), - "included_in_print_rate": 0, - "dont_recompute_tax": 1, - "tax_amount": 0, - "item_wise_tax_detail": {}, - }, - ) - tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) - if tax.get("item_wise_tax_detail"): - tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) - - return tax_account_wise_data.values() - - -def get_tax_account_head(tax, 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}, - "tax_account", - ) - - if not tax_account and charge_type: - tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) - - if not tax_account: - frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) - - return tax_account - - -def get_tax_account_description(tax): - tax_title = tax.get("title") - - tax_description = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_description", - ) - - return tax_description - - -def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): - shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item - for shipping_charge in shipping_lines: - if shipping_charge.get("price"): - shipping_discounts = shipping_charge.get("discount_allocations") or [] - total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) - - shipping_taxes = shipping_charge.get("tax_lines") or [] - total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) - - shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) - if bool(taxes_inclusive): - shipping_charge_amount -= total_tax - - if shipping_as_item: - items.append( - { - "item_code": setting.shipping_item, - "rate": shipping_charge_amount, - "delivery_date": items[-1]["delivery_date"] if items else nowdate(), - "qty": 1, - "stock_uom": "Nos", - "warehouse": setting.warehouse, - } - ) - else: - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) - or shipping_charge["title"], - "tax_amount": shipping_charge_amount, - "cost_center": setting.cost_center, - } - ) - - for tax in shipping_charge.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax["price"], - "cost_center": setting.cost_center, - "item_wise_tax_detail": { - setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] - } - if shipping_as_item - else {}, - "dont_recompute_tax": 1, - } - ) - - -def get_sales_order(order_id): - """Get ERPNext sales order using shopify order id.""" - sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - if sales_order: - return frappe.get_doc("Sales Order", sales_order) - - -def cancel_order(payload, request_id=None, store_name=None): - """Called by order/cancelled event.""" - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - if store_name: - frappe.local.shopify_store_name = store_name - log_store2("CANCEL-1", f"Cancelling order {payload.get('id')}", store_name) - - order = payload - - try: - order_id = order["id"] - order_status = order["financial_status"] - - sales_order = get_sales_order(order_id) - - if not sales_order: - log_store2("CANCEL-FAIL", f"Sales Order not found for {order_id}", store_name) - create_shopify_log(status="Invalid", message="Sales Order does not exist") - return - - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) - - if sales_invoice: - frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) - - for dn in delivery_notes: - frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) - - if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: - sales_order.cancel() - log_store2("CANCEL-OK", f"Sales Order {sales_order.name} cancelled", store_name) - else: - frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) - log_store2("CANCEL-STATUS", f"Sales Order {sales_order.name} status updated", store_name) - - except Exception as e: - log_store2("CANCEL-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) - create_shopify_log(status="Error", exception=e) - else: - create_shopify_log(status="Success") - - -def update_sales_order(payload, request_id=None, store_name=None): - """Handle order updates from Shopify with comprehensive logging. - - Logs all order references, change details, amount details, and primary keys. - Supports both Store 1 and Store 2. - """ - order = payload - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - # Set store context for Store 2 - if store_name: - frappe.local.shopify_store_name = store_name - log_store2("UPDATE-1", f""" -======================================== -ORDER UPDATE WEBHOOK RECEIVED -======================================== -Store: {store_name} -Order ID: {order.get('id')} -Order Number: {order.get('name')} -request_id: {request_id} -""", store_name) - - # Initialize comprehensive log data structure - log_data = { - "order_references": {}, - "change_details": {}, - "amount_details": {}, - "primary_keys": {}, - "line_items_details": [], - "status": "processing", - "store_name": store_name or "Store 1" - } - - try: - # ========== EXTRACT ALL PRIMARY KEYS ========== - order_id = cstr(order.get("id")) - order_number = order.get("name", "") - customer_id = order.get("customer", {}).get("id") if order.get("customer") else None - - log_store2("UPDATE-2", f"Extracting primary keys for order {order_id}...", store_name) - - log_data["primary_keys"] = { - "shopify_order_id": order_id, - "shopify_order_number": order_number, - "shopify_customer_id": customer_id, - "shopify_order_name": order.get("name", ""), - "shopify_order_token": order.get("token", ""), - "shopify_checkout_id": order.get("checkout_id"), - "shopify_checkout_token": order.get("checkout_token"), - } - - # Extract line item IDs - line_item_ids = [] - for item in order.get("line_items", []): - line_item_ids.append({ - "shopify_line_item_id": item.get("id"), - "shopify_product_id": item.get("product_id"), - "shopify_variant_id": item.get("variant_id"), - "shopify_sku": item.get("sku", ""), - }) - log_data["primary_keys"]["line_items"] = line_item_ids - - # Extract fulfillment IDs - fulfillment_ids = [] - for fulfillment in order.get("fulfillments", []): - fulfillment_ids.append({ - "shopify_fulfillment_id": fulfillment.get("id"), - "shopify_tracking_number": fulfillment.get("tracking_number"), - }) - log_data["primary_keys"]["fulfillments"] = fulfillment_ids - - # Extract discount code IDs - discount_codes = [] - for discount in order.get("discount_codes", []): - discount_codes.append({ - "shopify_discount_code": discount.get("code"), - "shopify_discount_type": discount.get("type"), - }) - log_data["primary_keys"]["discount_codes"] = discount_codes - - log_store2("UPDATE-3", f"Primary keys extracted: {len(line_item_ids)} line items, {len(fulfillment_ids)} fulfillments", store_name) - - # ========== EXTRACT ALL ORDER REFERENCES ========== - log_store2("UPDATE-4", "Extracting order references...", store_name) - - sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - customer_name = None - if customer_id: - customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - - # Get related documents - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") - - log_data["order_references"] = { - "shopify_order_id": order_id, - "shopify_order_number": order_number, - "erpnext_sales_order": sales_order_name, - "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), - "erpnext_sales_invoice": sales_invoice, - "erpnext_delivery_notes": delivery_notes, - "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", - "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", - } - - log_store2("UPDATE-5", f""" -Order references: - Sales Order: {sales_order_name or 'Not found'} - Customer: {customer_name or 'Not found'} - Sales Invoice: {sales_invoice or 'Not found'} - Delivery Notes: {len(delivery_notes) if delivery_notes else 0} -""", store_name) - - # ========== EXTRACT AMOUNT DETAILS ========== - log_store2("UPDATE-6", "Extracting amount details...", store_name) - - log_data["amount_details"] = { - "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), - "shopify_total_tax": flt(order.get("total_tax", 0)), - "shopify_total_discounts": flt(order.get("total_discounts", 0)), - "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, - "shopify_total_price": flt(order.get("total_price", 0)), - "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), - "shopify_currency": order.get("currency", ""), - "shopify_current_total_price": flt(order.get("current_total_price", 0)), - "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), - "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), - "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), - } - - # Line item amounts - line_item_amounts = [] - for item in order.get("line_items", []): - line_item_amounts.append({ - "shopify_line_item_id": item.get("id"), - "quantity": cint(item.get("quantity", 0)), - "price": flt(item.get("price", 0)), - "total_discount": flt(_get_total_discount(item)), - "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), - "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), - }) - log_data["amount_details"]["line_items"] = line_item_amounts - - # Tax line amounts - tax_line_amounts = [] - for tax_line in order.get("tax_lines", []): - tax_line_amounts.append({ - "title": tax_line.get("title", ""), - "price": flt(tax_line.get("price", 0)), - "rate": flt(tax_line.get("rate", 0)), - }) - log_data["amount_details"]["tax_lines"] = tax_line_amounts - - # Shipping line amounts - shipping_line_amounts = [] - for shipping_line in order.get("shipping_lines", []): - shipping_line_amounts.append({ - "title": shipping_line.get("title", ""), - "price": flt(shipping_line.get("price", 0)), - "code": shipping_line.get("code", ""), - }) - log_data["amount_details"]["shipping_lines"] = shipping_line_amounts - - log_store2("UPDATE-7", f""" -Amount details: - Total Price: {log_data['amount_details']['shopify_total_price']} - Subtotal: {log_data['amount_details']['shopify_subtotal_price']} - Tax: {log_data['amount_details']['shopify_total_tax']} - Discounts: {log_data['amount_details']['shopify_total_discounts']} - Line Items: {len(line_item_amounts)} -""", store_name) - - # ========== DETECT CHANGES ========== - log_store2("UPDATE-8", "Detecting changes...", store_name) - - if not sales_order_name: - # Order doesn't exist, create it - log_data["change_details"] = { - "action": "create_new_order", - "reason": "Order not found in ERPNext", - } - log_data["status"] = "creating" - - log_store2("UPDATE-9", f"Order {order_number} not found, creating new order", store_name) - - create_shopify_log( - status="Info", - message=f"Order {order_number} not found, creating new order", - request_data=order, - response_data=log_data - ) - sync_sales_order(payload, request_id, store_name=store_name) - return - - # Order exists, compare and detect changes - sales_order = frappe.get_doc("Sales Order", sales_order_name) - changes = {} - - log_store2("UPDATE-10", f"Sales Order found: {sales_order_name}, comparing changes...", store_name) - - # Compare amounts - old_total = flt(sales_order.grand_total) - new_total = flt(order.get("total_price", 0)) - if old_total != new_total: - changes["grand_total"] = { - "old": old_total, - "new": new_total, - "difference": new_total - old_total - } - log_store2("UPDATE-10a", f"Grand total changed: {old_total} → {new_total}", store_name) - - old_subtotal = flt(sales_order.total) - new_subtotal = flt(order.get("subtotal_price", 0)) - if old_subtotal != new_subtotal: - changes["subtotal"] = { - "old": old_subtotal, - "new": new_subtotal, - "difference": new_subtotal - old_subtotal - } - log_store2("UPDATE-10b", f"Subtotal changed: {old_subtotal} → {new_subtotal}", store_name) - - # Compare order status - old_status = sales_order.get(ORDER_STATUS_FIELD) or "" - new_status = order.get("financial_status", "") - if old_status != new_status: - changes["financial_status"] = { - "old": old_status, - "new": new_status - } - log_store2("UPDATE-10c", f"Financial status changed: {old_status} → {new_status}", store_name) - - old_fulfillment_status = order.get("fulfillment_status") # This might not be stored - new_fulfillment_status = order.get("fulfillment_status", "") - if old_fulfillment_status != new_fulfillment_status: - changes["fulfillment_status"] = { - "old": old_fulfillment_status or "unfulfilled", - "new": new_fulfillment_status or "unfulfilled" - } - log_store2("UPDATE-10d", f"Fulfillment status changed: {old_fulfillment_status} → {new_fulfillment_status}", store_name) - - # Compare line items - old_items_count = len(sales_order.items) - new_items_count = len(order.get("line_items", [])) - if old_items_count != new_items_count: - changes["line_items_count"] = { - "old": old_items_count, - "new": new_items_count - } - log_store2("UPDATE-10e", f"Line items count changed: {old_items_count} → {new_items_count}", store_name) - - # Compare line items in detail - line_item_changes = [] - shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} - - # Get existing line item IDs from Sales Order - existing_shopify_ids = set() - for so_item in sales_order.items: - # Try to get shopify_line_item_id from custom field if it exists - shopify_item_id = str(so_item.get("shopify_line_item_id", "")) - if shopify_item_id and shopify_item_id in shopify_items_map: - shopify_item = shopify_items_map[shopify_item_id] - item_changes = {} - - # Compare quantity - old_qty = cint(so_item.qty) - new_qty = cint(shopify_item.get("quantity", 0)) - if old_qty != new_qty: - item_changes["quantity"] = {"old": old_qty, "new": new_qty} - - # Compare rate - old_rate = flt(so_item.rate) - new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) - if abs(old_rate - new_rate) > 0.01: # Allow small floating point differences - item_changes["rate"] = {"old": old_rate, "new": new_rate} - - if item_changes: - line_item_changes.append({ - "item_code": so_item.item_code, - "shopify_line_item_id": shopify_item_id, - "changes": item_changes - }) - - existing_shopify_ids.add(shopify_item_id) - - # Check for new items - for shopify_item in order.get("line_items", []): - if str(shopify_item.get("id")) not in existing_shopify_ids: - line_item_changes.append({ - "shopify_line_item_id": str(shopify_item.get("id")), - "shopify_product_id": shopify_item.get("product_id"), - "shopify_variant_id": shopify_item.get("variant_id"), - "title": shopify_item.get("title"), - "action": "added" - }) - - if line_item_changes: - changes["line_items"] = line_item_changes - log_store2("UPDATE-10f", f"Line item changes detected: {len(line_item_changes)} items", store_name) - - # Compare customer - old_customer = sales_order.customer - if customer_id: - new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - if old_customer != new_customer and new_customer: - changes["customer"] = { - "old": old_customer, - "new": new_customer - } - log_store2("UPDATE-10g", f"Customer changed: {old_customer} → {new_customer}", store_name) - - # Compare order note - new_note = order.get("note", "") - if new_note: - changes["note"] = { - "old": "", - "new": new_note - } - - # Compare dates - old_date = str(sales_order.transaction_date) if sales_order.transaction_date else "" - new_date = str(getdate(order.get("created_at"))) if order.get("created_at") else "" - if old_date != new_date: - changes["transaction_date"] = { - "old": old_date, - "new": new_date - } - - log_data["change_details"] = changes - - log_store2("UPDATE-11", f"Change detection complete. Found {len(changes)} change(s)", store_name) - - # ========== STORE LINE ITEMS DETAILS ========== - log_store2("UPDATE-12", "Storing line items details...", store_name) - - for item in order.get("line_items", []): - item_code = get_item_code(item) if item.get("product_exists") else None - log_data["line_items_details"].append({ - "shopify_line_item_id": item.get("id"), - "shopify_product_id": item.get("product_id"), - "shopify_variant_id": item.get("variant_id"), - "shopify_sku": item.get("sku", ""), - "erpnext_item_code": item_code, - "title": item.get("title", ""), - "name": item.get("name", ""), - "quantity": cint(item.get("quantity", 0)), - "price": flt(item.get("price", 0)), - "total_discount": flt(_get_total_discount(item)), - "requires_shipping": item.get("requires_shipping", False), - "fulfillable_quantity": cint(item.get("fulfillable_quantity", 0)), - }) - - # ========== PROCESS THE UPDATE ========== - log_store2("UPDATE-13", f"Processing update. Sales Order status: {sales_order.docstatus}", store_name) - - if sales_order.docstatus == 2: # Cancelled - log_data["status"] = "cancelled_order" - log_data["change_details"]["action"] = "status_update_only" - log_data["change_details"]["reason"] = "Sales Order is cancelled, only status updated" - - log_store2("UPDATE-14", "Sales Order is cancelled, only updating status", store_name) - - frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) - - create_shopify_log( - status="Invalid", - message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.", - request_data=order, - response_data=log_data - ) - return - - # Update customer if changed - shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") - - if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) - if not customer.is_synced(): - log_store2("UPDATE-15", f"Syncing customer {customer_id}...", store_name) - customer.sync_customer(customer=shopify_customer) - else: - log_store2("UPDATE-15", f"Updating addresses for customer {customer_id}...", store_name) - customer.update_existing_addresses(shopify_customer) - - # Ensure items exist - log_store2("UPDATE-16", "Ensuring items exist...", store_name) - create_items_if_not_exist(order) - - # Get updated items and taxes - setting = frappe.get_doc(SETTING_DOCTYPE) - items = get_order_items( - order.get("line_items"), - setting, - getdate(order.get("created_at")) or getdate(sales_order.transaction_date), - taxes_inclusive=order.get("taxes_included"), - store_name=store_name, - ) - - if not items: - log_data["status"] = "error" - log_data["change_details"]["error"] = "Items not found in product master" - - log_store2("UPDATE-17-ERROR", "Items not found in product master!", store_name) - - create_shopify_log( - status="Error", - message="Cannot update order: items not found in product master", - request_data=order, - response_data=log_data, - rollback=True - ) - return - - log_store2("UPDATE-17", f"Got {len(items)} items, calculating taxes...", store_name) - taxes = get_order_taxes(order, setting, items) - - # Update customer if changed - customer_name = setting.default_customer - if shopify_customer.get("id"): - customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer - - # Update the order - if sales_order.docstatus == 1: # Submitted - log_data["status"] = "submitted_order_limited_update" - log_data["change_details"]["action"] = "limited_update" - log_data["change_details"]["reason"] = "Order is submitted, only status and notes updated" - - log_store2("UPDATE-18", "Sales Order is submitted, only updating status and notes", store_name) - - # For submitted orders, only update status and notes - frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) - - if order.get("note"): - sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") - - # Update ERPNext amounts in log for comparison - log_data["amount_details"]["erpnext_grand_total"] = flt(sales_order.grand_total) - log_data["amount_details"]["erpnext_total"] = flt(sales_order.total) - log_data["amount_details"]["erpnext_total_taxes_and_charges"] = flt(sales_order.total_taxes_and_charges) - - create_shopify_log( - status="Success", - message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", - request_data=order, - response_data=log_data - ) - - log_store2("UPDATE-19-SUCCESS", f"Order {order_number} updated successfully (limited)", store_name) - else: - # Draft order - can update fully - log_data["status"] = "draft_order_full_update" - log_data["change_details"]["action"] = "full_update" - - log_store2("UPDATE-18", "Sales Order is draft, performing full update...", store_name) - - old_amounts = { - "grand_total": flt(sales_order.grand_total), - "total": flt(sales_order.total), - "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), - } - - sales_order.update({ - "customer": customer_name, - "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date, - "delivery_date": getdate(order.get("created_at")) or sales_order.delivery_date, - "items": items, - "taxes": taxes, - }) - - if order.get("name") != sales_order.get(ORDER_NUMBER_FIELD): - sales_order.set(ORDER_NUMBER_FIELD, order.get("name")) - - sales_order.flags.ignore_mandatory = True - sales_order.flags.shopiy_order_json = json.dumps(order) - - log_store2("UPDATE-19", "Saving Sales Order...", store_name) - sales_order.save(ignore_permissions=True) - - # Reload to get updated amounts - sales_order.reload() - - new_amounts = { - "grand_total": flt(sales_order.grand_total), - "total": flt(sales_order.total), - "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), - } - - # Add ERPNext amounts to log - log_data["amount_details"]["erpnext_grand_total"] = new_amounts["grand_total"] - log_data["amount_details"]["erpnext_total"] = new_amounts["total"] - log_data["amount_details"]["erpnext_total_taxes_and_charges"] = new_amounts["total_taxes_and_charges"] - - # Add amount changes - if old_amounts["grand_total"] != new_amounts["grand_total"]: - if "grand_total" not in log_data["change_details"]: - log_data["change_details"]["grand_total"] = {} - log_data["change_details"]["grand_total"]["erpnext_old"] = old_amounts["grand_total"] - log_data["change_details"]["grand_total"]["erpnext_new"] = new_amounts["grand_total"] - - if order.get("note"): - sales_order.add_comment(text=f"Order Note: {order.get('note')}") - - create_shopify_log( - status="Success", - message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected", - request_data=order, - response_data=log_data - ) - - log_store2("UPDATE-20-SUCCESS", f""" -======================================== -ORDER UPDATE COMPLETED SUCCESSFULLY! -======================================== -Order: {order_number} -Sales Order: {sales_order_name} -Changes: {len(changes)} -Store: {store_name} -======================================== -""", store_name) - - except Exception as e: - log_data["status"] = "error" - log_data["change_details"]["error"] = str(e) - log_data["change_details"]["traceback"] = frappe.get_traceback() - - log_store2("UPDATE-ERROR", f""" -======================================== -ORDER UPDATE FAILED! -======================================== -Error: {str(e)} -Type: {type(e).__name__} -Order: {order.get('name', 'Unknown')} -Store: {store_name} - -Traceback: -{traceback.format_exc()} -======================================== -""", store_name) - - create_shopify_log( - status="Error", - exception=e, - message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}", - request_data=order, - response_data=log_data, - rollback=True - ) - - -@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) - - 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) - - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) - shopify_setting.sync_old_orders = 0 - shopify_setting.save() - - -def _fetch_old_orders(from_time, to_time): - from_time = get_datetime(from_time).astimezone().isoformat() - to_time = get_datetime(to_time).astimezone().isoformat() - orders_iterator = PaginatedIterator( - Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) - ) - - for orders in orders_iterator: - for order in orders: +""" +order.py - WITH COMPREHENSIVE STORE 2 LOGGING +This handles the background job for orders/create webhook. +""" + +import json +import traceback +from typing import Literal, Optional + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, get_datetime, getdate, nowdate +from shopify.collection import PaginatedIterator +from shopify.resources import Order + +from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.constants import ( + CUSTOMER_ID_FIELD, + EVENT_MAPPER, + ORDER_ID_FIELD, + ORDER_ITEM_DISCOUNT_FIELD, + ORDER_NUMBER_FIELD, + ORDER_STATUS_FIELD, + 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.utils.price_list import get_dummy_price_list +from ecommerce_integrations.utils.taxation import get_dummy_tax_category + +DEFAULT_TAX_FIELDS = { + "sales_tax": "default_sales_tax_account", + "shipping": "default_shipping_charges_account", +} + + +def log_store2(step, message, store_name=None): + """Helper function to log only for Store 2.""" + if store_name and store_name != "Store 1": + frappe.log_error( + title=f"[STORE2 ORDER] Step {step}", + message=f"Store: {store_name}\n\n{message}" + ) + + +def sync_sales_order(payload, request_id=None, store_name=None): + """Sync Shopify order to ERPNext Sales Order. + + This is called as a BACKGROUND JOB by the RQ worker. + """ + order = payload + + # ========================================================================= + # STEP BG-1: Background job started + # ========================================================================= + log_store2("BG-1", f""" +======================================== +BACKGROUND JOB STARTED: sync_sales_order +======================================== +request_id: {request_id} +store_name: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +frappe.local exists: {hasattr(frappe, 'local')} +""", store_name) + + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # ========================================================================= + # STEP BG-2: Set store context (CRITICAL!) + # ========================================================================= + log_store2("BG-2", f""" +Setting store context in background worker... +Before: frappe.local.shopify_store_name = {getattr(frappe.local, 'shopify_store_name', 'NOT SET')} +""", store_name) + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("BG-2-OK", f""" +Store context set! +After: frappe.local.shopify_store_name = {frappe.local.shopify_store_name} +""", store_name) + else: + log_store2("BG-2-WARN", "store_name is None! Will default to Store 1 credentials!", store_name) + + # ========================================================================= + # STEP BG-3: Check if order already exists + # ========================================================================= + log_store2("BG-3", f"Checking if Sales Order already exists for Shopify Order ID: {order['id']}", store_name) + + existing_so = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}) + + if existing_so: + log_store2("BG-3-SKIP", f""" +Sales Order already exists! +Existing SO: {existing_so} +Shopify Order ID: {order['id']} +Skipping creation. +""", store_name) + create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + return + + log_store2("BG-3-OK", "No existing Sales Order found, proceeding with creation.", store_name) + + # ========================================================================= + # STEP BG-4: Process customer + # ========================================================================= + try: + log_store2("BG-4", "Processing customer...", store_name) + + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + customer_id = shopify_customer.get("id") + + log_store2("BG-4a", f""" +Customer data: +customer_id: {customer_id} +email: {shopify_customer.get('email')} +has billing_address: {bool(order.get('billing_address'))} +has shipping_address: {bool(order.get('shipping_address'))} +""", store_name) + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + log_store2("BG-4b", f"Customer {customer_id} not synced, creating...", store_name) + customer.sync_customer(customer=shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} created.", store_name) + else: + log_store2("BG-4b", f"Customer {customer_id} already exists, updating addresses...", store_name) + customer.update_existing_addresses(shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} addresses updated.", store_name) + else: + log_store2("BG-4-WARN", "No customer_id in order, will use default customer.", store_name) + + log_store2("BG-4-OK", "Customer processing complete.", store_name) + + except Exception as e: + log_store2("BG-4-EXCEPTION", f""" +Exception processing customer! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-5: Sync items/products + # ========================================================================= + try: + log_store2("BG-5", f""" +Syncing items from order... +Line items count: {len(order.get('line_items', []))} +Line items: {[item.get('title') for item in order.get('line_items', [])]} +""", store_name) + + create_items_if_not_exist(order) + + log_store2("BG-5-OK", "Items synced successfully.", store_name) + + except Exception as e: + log_store2("BG-5-EXCEPTION", f""" +Exception syncing items! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-6: Create Sales Order + # ========================================================================= + try: + log_store2("BG-6", "Creating Sales Order...", store_name) + + setting = frappe.get_doc(SETTING_DOCTYPE) + + log_store2("BG-6a", f""" +Settings loaded: +Company: {setting.company} +Warehouse: {setting.warehouse} +Sales Order Series: {setting.sales_order_series} +Default Customer: {setting.default_customer} +""", store_name) + + create_order(order, setting, store_name=store_name) + + log_store2("BG-6-OK", f""" +======================================== +SALES ORDER CREATED SUCCESSFULLY! +======================================== +Shopify Order ID: {order.get('id')} +Shopify Order Number: {order.get('name')} +Store: {store_name} +""", store_name) + + except Exception as e: + log_store2("BG-6-EXCEPTION", f""" +Exception creating Sales Order! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-7: Success! + # ========================================================================= + log_store2("BG-7", "Creating success log entry...", store_name) + create_shopify_log(status="Success") + log_store2("BG-7-OK", f""" +======================================== +BACKGROUND JOB COMPLETED SUCCESSFULLY! +======================================== +Shopify Order: {order.get('name')} +Store: {store_name} +""", store_name) + + +def create_order(order, setting, company=None, store_name=None): + """Create order with related documents.""" + # local import to avoid circular dependencies + from ecommerce_integrations.shopify.fulfillment import create_delivery_note + from ecommerce_integrations.shopify.invoice import create_sales_invoice + + log_store2("CREATE-1", "Inside create_order()", store_name) + + so = create_sales_order(order, setting, company, store_name=store_name) + + if so: + log_store2("CREATE-2", f"Sales Order created: {so.name}", store_name) + + if order.get("financial_status") == "paid": + log_store2("CREATE-3", "Order is paid, creating Sales Invoice...", store_name) + create_sales_invoice(order, setting, so) + log_store2("CREATE-3-OK", "Sales Invoice created.", store_name) + + if order.get("fulfillments"): + log_store2("CREATE-4", "Order has fulfillments, creating Delivery Note...", store_name) + create_delivery_note(order, setting, so) + log_store2("CREATE-4-OK", "Delivery Note created.", store_name) + else: + log_store2("CREATE-WARN", "create_sales_order returned None!", store_name) + + +def create_sales_order(shopify_order, setting, company=None, store_name=None): + """Create the actual Sales Order document.""" + + log_store2("SO-1", f""" +Creating Sales Order... +Shopify Order ID: {shopify_order.get('id')} +Shopify Order Number: {shopify_order.get('name')} +""", store_name) + + # Determine customer + customer = setting.default_customer + if shopify_order.get("customer", {}): + if customer_id := shopify_order.get("customer", {}).get("id"): + customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or customer + + log_store2("SO-2", f"Customer determined: {customer}", store_name) + + # Check if SO already exists + so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") + + if not so: + log_store2("SO-3", "Getting order items...", store_name) + + items = get_order_items( + shopify_order.get("line_items"), + setting, + getdate(shopify_order.get("created_at")), + taxes_inclusive=shopify_order.get("taxes_included"), + store_name=store_name, + ) + + log_store2("SO-3a", f"Items count: {len(items)}", store_name) + + if not items: + log_store2("SO-3-FAIL", "No items returned! Cannot create Sales Order.", store_name) + message = ( + "Following items exists in the shopify order but relevant records were" + " not found in the shopify Product master" + ) + create_shopify_log(status="Error", exception=message, rollback=True) + return "" + + log_store2("SO-4", "Getting order taxes...", store_name) + taxes = get_order_taxes(shopify_order, setting, items) + log_store2("SO-4a", f"Taxes count: {len(taxes)}", store_name) + + log_store2("SO-5", "Creating Sales Order document...", store_name) + + so = frappe.get_doc( + { + "doctype": "Sales Order", + "naming_series": setting.sales_order_series or "SO-Shopify-", + ORDER_ID_FIELD: str(shopify_order.get("id")), + ORDER_NUMBER_FIELD: shopify_order.get("name"), + "customer": customer, + "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), + "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), + "company": setting.company, + "selling_price_list": get_dummy_price_list(), + "ignore_pricing_rule": 1, + "items": items, + "taxes": taxes, + "tax_category": get_dummy_tax_category(), + } + ) + + if company: + so.update({"company": company, "status": "Draft"}) + + so.flags.ignore_mandatory = True + so.flags.shopiy_order_json = json.dumps(shopify_order) + + log_store2("SO-6", "Saving Sales Order...", store_name) + so.save(ignore_permissions=True) + log_store2("SO-6a", f"Sales Order saved: {so.name}", store_name) + + log_store2("SO-7", "Submitting Sales Order...", store_name) + so.submit() + log_store2("SO-7a", f"Sales Order submitted: {so.name}", store_name) + + if shopify_order.get("note"): + so.add_comment(text=f"Order Note: {shopify_order.get('note')}") + log_store2("SO-8", "Added order note as comment.", store_name) + + else: + log_store2("SO-EXISTS", f"Sales Order already exists: {so}", store_name) + so = frappe.get_doc("Sales Order", so) + + return so + + +def get_order_items(order_items, setting, delivery_date, taxes_inclusive, store_name=None): + """Get order items for Sales Order.""" + items = [] + all_product_exists = True + product_not_exists = [] + + log_store2("ITEMS-1", f"Processing {len(order_items)} line items...", store_name) + + for idx, shopify_item in enumerate(order_items): + product_id = shopify_item.get("product_id") + + log_store2(f"ITEMS-2-{idx}", f""" +Processing item {idx + 1}: + title: {shopify_item.get('title')} + product_id: {product_id} + variant_id: {shopify_item.get('variant_id')} + sku: {shopify_item.get('sku')} + quantity: {shopify_item.get('quantity')} + price: {shopify_item.get('price')} + product_exists: {shopify_item.get('product_exists')} +""", store_name) + + # Handle items without product_id (tips, samples, fees) + if not product_id: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-NOID", f"No product_id, mapped to: {item_code}", store_name) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append( + {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} + ) + log_store2(f"ITEMS-2-{idx}-NOTEXIST", f"Product does not exist in Shopify!", store_name) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-CODE", f"Item code: {item_code}", store_name) + + if not item_code: + log_store2(f"ITEMS-2-{idx}-NOCODE", f"Could not get item_code!", store_name) + continue + + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": shopify_item.get("uom") or "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + else: + items = [] + + log_store2("ITEMS-3", f"Returning {len(items)} items", store_name) + return items + + +# Keep the rest of the functions unchanged but add store_name parameter where needed +def _get_item_price(line_item, taxes_inclusive: bool) -> float: + price = flt(line_item.get("price")) + qty = cint(line_item.get("quantity")) + total_discount = _get_total_discount(line_item) + + if not taxes_inclusive: + return price - (total_discount / qty) + + total_taxes = 0.0 + for tax in line_item.get("tax_lines"): + total_taxes += flt(tax.get("price")) + + return price - (total_taxes + total_discount) / qty + + +def _get_total_discount(line_item) -> float: + discount_allocations = line_item.get("discount_allocations") or [] + return sum(flt(discount.get("amount")) for discount in discount_allocations) + + +def get_order_taxes(shopify_order, setting, items): + taxes = [] + line_items = shopify_order.get("line_items") + + for line_item in line_items: + item_code = get_item_code(line_item) + for tax in line_item.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax.get("price"), + "included_in_print_rate": 0, + "cost_center": setting.cost_center, + "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, + "dont_recompute_tax": 1, + } + ) + + update_taxes_with_shipping_lines( + taxes, + shopify_order.get("shipping_lines"), + setting, + items, + taxes_inclusive=shopify_order.get("taxes_included"), + ) + + if cint(setting.consolidate_taxes): + taxes = consolidate_order_taxes(taxes) + + for row in taxes: + tax_detail = row.get("item_wise_tax_detail") + if isinstance(tax_detail, dict): + row["item_wise_tax_detail"] = json.dumps(tax_detail) + + return taxes + + +def consolidate_order_taxes(taxes): + tax_account_wise_data = {} + for tax in taxes: + account_head = tax["account_head"] + tax_account_wise_data.setdefault( + account_head, + { + "charge_type": "Actual", + "account_head": account_head, + "description": tax.get("description"), + "cost_center": tax.get("cost_center"), + "included_in_print_rate": 0, + "dont_recompute_tax": 1, + "tax_amount": 0, + "item_wise_tax_detail": {}, + }, + ) + tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) + if tax.get("item_wise_tax_detail"): + tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) + + return tax_account_wise_data.values() + + +def get_tax_account_head(tax, 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}, + "tax_account", + ) + + if not tax_account and charge_type: + tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) + + if not tax_account: + frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) + + return tax_account + + +def get_tax_account_description(tax): + tax_title = tax.get("title") + + tax_description = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + "tax_description", + ) + + return tax_description + + +def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): + shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item + for shipping_charge in shipping_lines: + if shipping_charge.get("price"): + shipping_discounts = shipping_charge.get("discount_allocations") or [] + total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) + + shipping_taxes = shipping_charge.get("tax_lines") or [] + total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) + + shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) + if bool(taxes_inclusive): + shipping_charge_amount -= total_tax + + if shipping_as_item: + items.append( + { + "item_code": setting.shipping_item, + "rate": shipping_charge_amount, + "delivery_date": items[-1]["delivery_date"] if items else nowdate(), + "qty": 1, + "stock_uom": "Nos", + "warehouse": setting.warehouse, + } + ) + else: + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), + "description": get_tax_account_description(shipping_charge) + or shipping_charge["title"], + "tax_amount": shipping_charge_amount, + "cost_center": setting.cost_center, + } + ) + + for tax in shipping_charge.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax["price"], + "cost_center": setting.cost_center, + "item_wise_tax_detail": { + setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] + } + if shipping_as_item + else {}, + "dont_recompute_tax": 1, + } + ) + + +def get_sales_order(order_id): + """Get ERPNext sales order using shopify order id.""" + sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + if sales_order: + return frappe.get_doc("Sales Order", sales_order) + + +def cancel_order(payload, request_id=None, store_name=None): + """Called by order/cancelled event.""" + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("CANCEL-1", f"Cancelling order {payload.get('id')}", store_name) + + order = payload + + try: + order_id = order["id"] + order_status = order["financial_status"] + + sales_order = get_sales_order(order_id) + + if not sales_order: + log_store2("CANCEL-FAIL", f"Sales Order not found for {order_id}", store_name) + create_shopify_log(status="Invalid", message="Sales Order does not exist") + return + + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) + + if sales_invoice: + frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) + + for dn in delivery_notes: + frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) + + if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: + sales_order.cancel() + log_store2("CANCEL-OK", f"Sales Order {sales_order.name} cancelled", store_name) + else: + frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) + log_store2("CANCEL-STATUS", f"Sales Order {sales_order.name} status updated", store_name) + + except Exception as e: + log_store2("CANCEL-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) + create_shopify_log(status="Error", exception=e) + else: + create_shopify_log(status="Success") + + +def update_sales_order(payload, request_id=None, store_name=None): + """Handle order updates from Shopify with comprehensive logging. + + Logs all order references, change details, amount details, and primary keys. + Supports both Store 1 and Store 2. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("UPDATE-1", f""" +======================================== +ORDER UPDATE WEBHOOK RECEIVED +======================================== +Store: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +request_id: {request_id} +""", store_name) + + # Initialize comprehensive log data structure + log_data = { + "order_references": {}, + "change_details": {}, + "amount_details": {}, + "primary_keys": {}, + "line_items_details": [], + "status": "processing", + "store_name": store_name or "Store 1" + } + + try: + # ========== EXTRACT ALL PRIMARY KEYS ========== + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + customer_id = order.get("customer", {}).get("id") if order.get("customer") else None + + log_store2("UPDATE-2", f"Extracting primary keys for order {order_id}...", store_name) + + log_data["primary_keys"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "shopify_customer_id": customer_id, + "shopify_order_name": order.get("name", ""), + "shopify_order_token": order.get("token", ""), + "shopify_checkout_id": order.get("checkout_id"), + "shopify_checkout_token": order.get("checkout_token"), + } + + # Extract line item IDs + line_item_ids = [] + for item in order.get("line_items", []): + line_item_ids.append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + }) + log_data["primary_keys"]["line_items"] = line_item_ids + + # Extract fulfillment IDs + fulfillment_ids = [] + for fulfillment in order.get("fulfillments", []): + fulfillment_ids.append({ + "shopify_fulfillment_id": fulfillment.get("id"), + "shopify_tracking_number": fulfillment.get("tracking_number"), + }) + log_data["primary_keys"]["fulfillments"] = fulfillment_ids + + # Extract discount code IDs + discount_codes = [] + for discount in order.get("discount_codes", []): + discount_codes.append({ + "shopify_discount_code": discount.get("code"), + "shopify_discount_type": discount.get("type"), + }) + log_data["primary_keys"]["discount_codes"] = discount_codes + + log_store2("UPDATE-3", f"Primary keys extracted: {len(line_item_ids)} line items, {len(fulfillment_ids)} fulfillments", store_name) + + # ========== EXTRACT ALL ORDER REFERENCES ========== + log_store2("UPDATE-4", "Extracting order references...", store_name) + + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + customer_name = None + if customer_id: + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + + # Get related documents + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") + + log_data["order_references"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "erpnext_sales_order": sales_order_name, + "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), + "erpnext_sales_invoice": sales_invoice, + "erpnext_delivery_notes": delivery_notes, + "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", + "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", + } + + log_store2("UPDATE-5", f""" +Order references: + Sales Order: {sales_order_name or 'Not found'} + Customer: {customer_name or 'Not found'} + Sales Invoice: {sales_invoice or 'Not found'} + Delivery Notes: {len(delivery_notes) if delivery_notes else 0} +""", store_name) + + # ========== EXTRACT AMOUNT DETAILS ========== + log_store2("UPDATE-6", "Extracting amount details...", store_name) + + log_data["amount_details"] = { + "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), + "shopify_total_tax": flt(order.get("total_tax", 0)), + "shopify_total_discounts": flt(order.get("total_discounts", 0)), + "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, + "shopify_total_price": flt(order.get("total_price", 0)), + "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), + "shopify_currency": order.get("currency", ""), + "shopify_current_total_price": flt(order.get("current_total_price", 0)), + "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), + "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), + "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), + } + + # Line item amounts + line_item_amounts = [] + for item in order.get("line_items", []): + line_item_amounts.append({ + "shopify_line_item_id": item.get("id"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), + "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), + }) + log_data["amount_details"]["line_items"] = line_item_amounts + + # Tax line amounts + tax_line_amounts = [] + for tax_line in order.get("tax_lines", []): + tax_line_amounts.append({ + "title": tax_line.get("title", ""), + "price": flt(tax_line.get("price", 0)), + "rate": flt(tax_line.get("rate", 0)), + }) + log_data["amount_details"]["tax_lines"] = tax_line_amounts + + # Shipping line amounts + shipping_line_amounts = [] + for shipping_line in order.get("shipping_lines", []): + shipping_line_amounts.append({ + "title": shipping_line.get("title", ""), + "price": flt(shipping_line.get("price", 0)), + "code": shipping_line.get("code", ""), + }) + log_data["amount_details"]["shipping_lines"] = shipping_line_amounts + + log_store2("UPDATE-7", f""" +Amount details: + Total Price: {log_data['amount_details']['shopify_total_price']} + Subtotal: {log_data['amount_details']['shopify_subtotal_price']} + Tax: {log_data['amount_details']['shopify_total_tax']} + Discounts: {log_data['amount_details']['shopify_total_discounts']} + Line Items: {len(line_item_amounts)} +""", store_name) + + # ========== DETECT CHANGES ========== + log_store2("UPDATE-8", "Detecting changes...", store_name) + + if not sales_order_name: + # Order doesn't exist, create it + log_data["change_details"] = { + "action": "create_new_order", + "reason": "Order not found in ERPNext", + } + log_data["status"] = "creating" + + log_store2("UPDATE-9", f"Order {order_number} not found, creating new order", store_name) + + create_shopify_log( + status="Info", + message=f"Order {order_number} not found, creating new order", + request_data=order, + response_data=log_data + ) + sync_sales_order(payload, request_id, store_name=store_name) + return + + # Order exists, compare and detect changes + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + log_store2("UPDATE-10", f"Sales Order found: {sales_order_name}, comparing changes...", store_name) + + # Compare amounts + old_total = flt(sales_order.grand_total) + new_total = flt(order.get("total_price", 0)) + if old_total != new_total: + changes["grand_total"] = { + "old": old_total, + "new": new_total, + "difference": new_total - old_total + } + log_store2("UPDATE-10a", f"Grand total changed: {old_total} → {new_total}", store_name) + + old_subtotal = flt(sales_order.total) + new_subtotal = flt(order.get("subtotal_price", 0)) + if old_subtotal != new_subtotal: + changes["subtotal"] = { + "old": old_subtotal, + "new": new_subtotal, + "difference": new_subtotal - old_subtotal + } + log_store2("UPDATE-10b", f"Subtotal changed: {old_subtotal} → {new_subtotal}", store_name) + + # Compare order status + old_status = sales_order.get(ORDER_STATUS_FIELD) or "" + new_status = order.get("financial_status", "") + if old_status != new_status: + changes["financial_status"] = { + "old": old_status, + "new": new_status + } + log_store2("UPDATE-10c", f"Financial status changed: {old_status} → {new_status}", store_name) + + old_fulfillment_status = order.get("fulfillment_status") # This might not be stored + new_fulfillment_status = order.get("fulfillment_status", "") + if old_fulfillment_status != new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status or "unfulfilled" + } + log_store2("UPDATE-10d", f"Fulfillment status changed: {old_fulfillment_status} → {new_fulfillment_status}", store_name) + + # Compare line items + old_items_count = len(sales_order.items) + new_items_count = len(order.get("line_items", [])) + if old_items_count != new_items_count: + changes["line_items_count"] = { + "old": old_items_count, + "new": new_items_count + } + log_store2("UPDATE-10e", f"Line items count changed: {old_items_count} → {new_items_count}", store_name) + + # Compare line items in detail + line_item_changes = [] + shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} + + # Get existing line item IDs from Sales Order + existing_shopify_ids = set() + for so_item in sales_order.items: + # Try to get shopify_line_item_id from custom field if it exists + shopify_item_id = str(so_item.get("shopify_line_item_id", "")) + if shopify_item_id and shopify_item_id in shopify_items_map: + shopify_item = shopify_items_map[shopify_item_id] + item_changes = {} + + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(shopify_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: # Allow small floating point differences + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + if item_changes: + line_item_changes.append({ + "item_code": so_item.item_code, + "shopify_line_item_id": shopify_item_id, + "changes": item_changes + }) + + existing_shopify_ids.add(shopify_item_id) + + # Check for new items + for shopify_item in order.get("line_items", []): + if str(shopify_item.get("id")) not in existing_shopify_ids: + line_item_changes.append({ + "shopify_line_item_id": str(shopify_item.get("id")), + "shopify_product_id": shopify_item.get("product_id"), + "shopify_variant_id": shopify_item.get("variant_id"), + "title": shopify_item.get("title"), + "action": "added" + }) + + if line_item_changes: + changes["line_items"] = line_item_changes + log_store2("UPDATE-10f", f"Line item changes detected: {len(line_item_changes)} items", store_name) + + # Compare customer + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + log_store2("UPDATE-10g", f"Customer changed: {old_customer} → {new_customer}", store_name) + + # Compare order note + new_note = order.get("note", "") + if new_note: + changes["note"] = { + "old": "", + "new": new_note + } + + # Compare dates + old_date = str(sales_order.transaction_date) if sales_order.transaction_date else "" + new_date = str(getdate(order.get("created_at"))) if order.get("created_at") else "" + if old_date != new_date: + changes["transaction_date"] = { + "old": old_date, + "new": new_date + } + + log_data["change_details"] = changes + + log_store2("UPDATE-11", f"Change detection complete. Found {len(changes)} change(s)", store_name) + + # ========================================================================= + # SKIP LOGGING IF NO CHANGES DETECTED + # Only create log entry if there are actual changes to avoid spam + # ========================================================================= + if not changes: + log_store2( + "UPDATE-NO-CHANGES", + f"No changes detected for order {order_number}, skipping log creation", + store_name, + ) + return + + # ========== STORE LINE ITEMS DETAILS ========== + log_store2("UPDATE-12", "Storing line items details...", store_name) + + for item in order.get("line_items", []): + item_code = get_item_code(item) if item.get("product_exists") else None + log_data["line_items_details"].append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + "erpnext_item_code": item_code, + "title": item.get("title", ""), + "name": item.get("name", ""), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "requires_shipping": item.get("requires_shipping", False), + "fulfillable_quantity": cint(item.get("fulfillable_quantity", 0)), + }) + + # ========== PROCESS THE UPDATE ========== + log_store2("UPDATE-13", f"Processing update. Sales Order status: {sales_order.docstatus}", store_name) + + if sales_order.docstatus == 2: # Cancelled + log_data["status"] = "cancelled_order" + log_data["change_details"]["action"] = "status_update_only" + log_data["change_details"]["reason"] = "Sales Order is cancelled, only status updated" + + log_store2("UPDATE-14", "Sales Order is cancelled, only updating status", store_name) + + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + create_shopify_log( + status="Invalid", + message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.", + request_data=order, + response_data=log_data + ) + return + + # Update customer if changed + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + log_store2("UPDATE-15", f"Syncing customer {customer_id}...", store_name) + customer.sync_customer(customer=shopify_customer) + else: + log_store2("UPDATE-15", f"Updating addresses for customer {customer_id}...", store_name) + customer.update_existing_addresses(shopify_customer) + + # Ensure items exist + log_store2("UPDATE-16", "Ensuring items exist...", store_name) + create_items_if_not_exist(order) + + # Get updated items and taxes + setting = frappe.get_doc(SETTING_DOCTYPE) + items = get_order_items( + order.get("line_items"), + setting, + getdate(order.get("created_at")) or getdate(sales_order.transaction_date), + taxes_inclusive=order.get("taxes_included"), + store_name=store_name, + ) + + if not items: + log_data["status"] = "error" + log_data["change_details"]["error"] = "Items not found in product master" + + log_store2("UPDATE-17-ERROR", "Items not found in product master!", store_name) + + create_shopify_log( + status="Error", + message="Cannot update order: items not found in product master", + request_data=order, + response_data=log_data, + rollback=True + ) + return + + log_store2("UPDATE-17", f"Got {len(items)} items, calculating taxes...", store_name) + taxes = get_order_taxes(order, setting, items) + + # Update customer if changed + customer_name = setting.default_customer + if shopify_customer.get("id"): + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer + + # Update the order + if sales_order.docstatus == 1: # Submitted + log_data["status"] = "submitted_order_limited_update" + log_data["change_details"]["action"] = "limited_update" + log_data["change_details"]["reason"] = "Order is submitted, only status and notes updated" + + log_store2("UPDATE-18", "Sales Order is submitted, only updating status and notes", store_name) + + # For submitted orders, only update status and notes + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + if order.get("note"): + sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") + + # Update ERPNext amounts in log for comparison + log_data["amount_details"]["erpnext_grand_total"] = flt(sales_order.grand_total) + log_data["amount_details"]["erpnext_total"] = flt(sales_order.total) + log_data["amount_details"]["erpnext_total_taxes_and_charges"] = flt(sales_order.total_taxes_and_charges) + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", + request_data=order, + response_data=log_data + ) + + log_store2("UPDATE-19-SUCCESS", f"Order {order_number} updated successfully (limited)", store_name) + else: + # Draft order - can update fully + log_data["status"] = "draft_order_full_update" + log_data["change_details"]["action"] = "full_update" + + log_store2("UPDATE-18", "Sales Order is draft, performing full update...", store_name) + + old_amounts = { + "grand_total": flt(sales_order.grand_total), + "total": flt(sales_order.total), + "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), + } + + sales_order.update({ + "customer": customer_name, + "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date, + "delivery_date": getdate(order.get("created_at")) or sales_order.delivery_date, + "items": items, + "taxes": taxes, + }) + + if order.get("name") != sales_order.get(ORDER_NUMBER_FIELD): + sales_order.set(ORDER_NUMBER_FIELD, order.get("name")) + + sales_order.flags.ignore_mandatory = True + sales_order.flags.shopiy_order_json = json.dumps(order) + + log_store2("UPDATE-19", "Saving Sales Order...", store_name) + sales_order.save(ignore_permissions=True) + + # Reload to get updated amounts + sales_order.reload() + + new_amounts = { + "grand_total": flt(sales_order.grand_total), + "total": flt(sales_order.total), + "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), + } + + # Add ERPNext amounts to log + log_data["amount_details"]["erpnext_grand_total"] = new_amounts["grand_total"] + log_data["amount_details"]["erpnext_total"] = new_amounts["total"] + log_data["amount_details"]["erpnext_total_taxes_and_charges"] = new_amounts["total_taxes_and_charges"] + + # Add amount changes + if old_amounts["grand_total"] != new_amounts["grand_total"]: + if "grand_total" not in log_data["change_details"]: + log_data["change_details"]["grand_total"] = {} + log_data["change_details"]["grand_total"]["erpnext_old"] = old_amounts["grand_total"] + log_data["change_details"]["grand_total"]["erpnext_new"] = new_amounts["grand_total"] + + if order.get("note"): + sales_order.add_comment(text=f"Order Note: {order.get('note')}") + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected", + request_data=order, + response_data=log_data + ) + + log_store2("UPDATE-20-SUCCESS", f""" +======================================== +ORDER UPDATE COMPLETED SUCCESSFULLY! +======================================== +Order: {order_number} +Sales Order: {sales_order_name} +Changes: {len(changes)} +Store: {store_name} +======================================== +""", store_name) + + # ========================================================================= + # TRIGGER UPDATE DETECTION ON ALL LINKED DOCUMENTS + # Calls the "Get Shopify Order Updates" Server Script which compares + # Shopify data with ERPNext data across the full intercompany chain + # (FAM SO -> CCP PO -> CCP SO -> Work Orders) and sets notification + # flags (custom_has_shopify_update) and stores change details + # (custom_shopify_update_data) on each linked document. + # This powers the orange update banners on form pages. + # ========================================================================= + if sales_order_name: + try: + old_form_dict = dict(frappe.form_dict) + frappe.form_dict["doctype"] = "Sales Order" + frappe.form_dict["docname"] = sales_order_name + + script_doc = frappe.get_doc("Server Script", "Get Shopify Order Updates") + script_doc.execute_method() + + detection_result = frappe.response.get("message") or {} + detection_has_changes = detection_result.get("has_changes", False) + + frappe.form_dict = old_form_dict + + if detection_has_changes: + frappe.logger().info( + "Shopify update detection: Changes flagged on all linked docs for SO {}".format( + sales_order_name + ) + ) + except Exception as detection_error: + frappe.logger().error( + "Shopify update detection failed for SO {}: {}".format( + sales_order_name, str(detection_error) + ) + ) + frappe.form_dict = old_form_dict if "old_form_dict" in dir() else frappe.form_dict + # ========================================================================= + # END OF UPDATE DETECTION TRIGGER + # ========================================================================= + + except Exception as e: + log_data["status"] = "error" + log_data["change_details"]["error"] = str(e) + log_data["change_details"]["traceback"] = frappe.get_traceback() + + log_store2("UPDATE-ERROR", f""" +======================================== +ORDER UPDATE FAILED! +======================================== +Error: {str(e)} +Type: {type(e).__name__} +Order: {order.get('name', 'Unknown')} +Store: {store_name} + +Traceback: +{traceback.format_exc()} +======================================== +""", store_name) + + create_shopify_log( + status="Error", + exception=e, + message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}", + request_data=order, + response_data=log_data, + rollback=True + ) + + +@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) + + 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) + + shopify_setting = frappe.get_doc(SETTING_DOCTYPE) + shopify_setting.sync_old_orders = 0 + shopify_setting.save() + + +def _fetch_old_orders(from_time, to_time): + from_time = get_datetime(from_time).astimezone().isoformat() + to_time = get_datetime(to_time).astimezone().isoformat() + orders_iterator = PaginatedIterator( + Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) + ) + + for orders in orders_iterator: + for order in orders: yield order.to_dict() \ No newline at end of file From 4e049469fce3b73d96dd72a423e6e15ab5fbed8f Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 25 Feb 2026 12:15:54 +0530 Subject: [PATCH 10/18] Add DocType Event trigger for Shopify update detection, remove order.py changes --- ecommerce_integrations/shopify/order.py | 403 ++---------------------- 1 file changed, 33 insertions(+), 370 deletions(-) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 684976993..27aafca16 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -655,9 +655,9 @@ def cancel_order(payload, request_id=None, store_name=None): def update_sales_order(payload, request_id=None, store_name=None): - """Handle order updates from Shopify with comprehensive logging. + """Handle order updates from Shopify. - Logs all order references, change details, amount details, and primary keys. + Tracks essential changes: amounts, items, status, customer. Supports both Store 1 and Store 2. """ order = payload @@ -667,195 +667,31 @@ def update_sales_order(payload, request_id=None, store_name=None): # Set store context for Store 2 if store_name: frappe.local.shopify_store_name = store_name - log_store2("UPDATE-1", f""" -======================================== -ORDER UPDATE WEBHOOK RECEIVED -======================================== -Store: {store_name} -Order ID: {order.get('id')} -Order Number: {order.get('name')} -request_id: {request_id} -""", store_name) - - # Initialize comprehensive log data structure - log_data = { - "order_references": {}, - "change_details": {}, - "amount_details": {}, - "primary_keys": {}, - "line_items_details": [], - "status": "processing", - "store_name": store_name or "Store 1" - } try: - # ========== EXTRACT ALL PRIMARY KEYS ========== order_id = cstr(order.get("id")) order_number = order.get("name", "") customer_id = order.get("customer", {}).get("id") if order.get("customer") else None - log_store2("UPDATE-2", f"Extracting primary keys for order {order_id}...", store_name) - - log_data["primary_keys"] = { - "shopify_order_id": order_id, - "shopify_order_number": order_number, - "shopify_customer_id": customer_id, - "shopify_order_name": order.get("name", ""), - "shopify_order_token": order.get("token", ""), - "shopify_checkout_id": order.get("checkout_id"), - "shopify_checkout_token": order.get("checkout_token"), - } - - # Extract line item IDs - line_item_ids = [] - for item in order.get("line_items", []): - line_item_ids.append({ - "shopify_line_item_id": item.get("id"), - "shopify_product_id": item.get("product_id"), - "shopify_variant_id": item.get("variant_id"), - "shopify_sku": item.get("sku", ""), - }) - log_data["primary_keys"]["line_items"] = line_item_ids - - # Extract fulfillment IDs - fulfillment_ids = [] - for fulfillment in order.get("fulfillments", []): - fulfillment_ids.append({ - "shopify_fulfillment_id": fulfillment.get("id"), - "shopify_tracking_number": fulfillment.get("tracking_number"), - }) - log_data["primary_keys"]["fulfillments"] = fulfillment_ids - - # Extract discount code IDs - discount_codes = [] - for discount in order.get("discount_codes", []): - discount_codes.append({ - "shopify_discount_code": discount.get("code"), - "shopify_discount_type": discount.get("type"), - }) - log_data["primary_keys"]["discount_codes"] = discount_codes - - log_store2("UPDATE-3", f"Primary keys extracted: {len(line_item_ids)} line items, {len(fulfillment_ids)} fulfillments", store_name) - - # ========== EXTRACT ALL ORDER REFERENCES ========== - log_store2("UPDATE-4", "Extracting order references...", store_name) - + # Check if Sales Order exists sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - customer_name = None - if customer_id: - customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - - # Get related documents - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") - - log_data["order_references"] = { - "shopify_order_id": order_id, - "shopify_order_number": order_number, - "erpnext_sales_order": sales_order_name, - "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), - "erpnext_sales_invoice": sales_invoice, - "erpnext_delivery_notes": delivery_notes, - "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", - "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", - } - - log_store2("UPDATE-5", f""" -Order references: - Sales Order: {sales_order_name or 'Not found'} - Customer: {customer_name or 'Not found'} - Sales Invoice: {sales_invoice or 'Not found'} - Delivery Notes: {len(delivery_notes) if delivery_notes else 0} -""", store_name) - - # ========== EXTRACT AMOUNT DETAILS ========== - log_store2("UPDATE-6", "Extracting amount details...", store_name) - - log_data["amount_details"] = { - "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), - "shopify_total_tax": flt(order.get("total_tax", 0)), - "shopify_total_discounts": flt(order.get("total_discounts", 0)), - "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, - "shopify_total_price": flt(order.get("total_price", 0)), - "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), - "shopify_currency": order.get("currency", ""), - "shopify_current_total_price": flt(order.get("current_total_price", 0)), - "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), - "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), - "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), - } - - # Line item amounts - line_item_amounts = [] - for item in order.get("line_items", []): - line_item_amounts.append({ - "shopify_line_item_id": item.get("id"), - "quantity": cint(item.get("quantity", 0)), - "price": flt(item.get("price", 0)), - "total_discount": flt(_get_total_discount(item)), - "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), - "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), - }) - log_data["amount_details"]["line_items"] = line_item_amounts - - # Tax line amounts - tax_line_amounts = [] - for tax_line in order.get("tax_lines", []): - tax_line_amounts.append({ - "title": tax_line.get("title", ""), - "price": flt(tax_line.get("price", 0)), - "rate": flt(tax_line.get("rate", 0)), - }) - log_data["amount_details"]["tax_lines"] = tax_line_amounts - - # Shipping line amounts - shipping_line_amounts = [] - for shipping_line in order.get("shipping_lines", []): - shipping_line_amounts.append({ - "title": shipping_line.get("title", ""), - "price": flt(shipping_line.get("price", 0)), - "code": shipping_line.get("code", ""), - }) - log_data["amount_details"]["shipping_lines"] = shipping_line_amounts - - log_store2("UPDATE-7", f""" -Amount details: - Total Price: {log_data['amount_details']['shopify_total_price']} - Subtotal: {log_data['amount_details']['shopify_subtotal_price']} - Tax: {log_data['amount_details']['shopify_total_tax']} - Discounts: {log_data['amount_details']['shopify_total_discounts']} - Line Items: {len(line_item_amounts)} -""", store_name) - - # ========== DETECT CHANGES ========== - log_store2("UPDATE-8", "Detecting changes...", store_name) if not sales_order_name: # Order doesn't exist, create it - log_data["change_details"] = { - "action": "create_new_order", - "reason": "Order not found in ERPNext", - } - log_data["status"] = "creating" - - log_store2("UPDATE-9", f"Order {order_number} not found, creating new order", store_name) - create_shopify_log( status="Info", message=f"Order {order_number} not found, creating new order", request_data=order, - response_data=log_data + response_data={"action": "create_new_order"} ) sync_sales_order(payload, request_id, store_name=store_name) return - # Order exists, compare and detect changes + # Order exists, detect changes sales_order = frappe.get_doc("Sales Order", sales_order_name) changes = {} - log_store2("UPDATE-10", f"Sales Order found: {sales_order_name}, comparing changes...", store_name) - - # Compare amounts + # Compare grand total old_total = flt(sales_order.grand_total) new_total = flt(order.get("total_price", 0)) if old_total != new_total: @@ -864,8 +700,8 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_total, "difference": new_total - old_total } - log_store2("UPDATE-10a", f"Grand total changed: {old_total} → {new_total}", store_name) + # Compare subtotal old_subtotal = flt(sales_order.total) new_subtotal = flt(order.get("subtotal_price", 0)) if old_subtotal != new_subtotal: @@ -874,9 +710,8 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_subtotal, "difference": new_subtotal - old_subtotal } - log_store2("UPDATE-10b", f"Subtotal changed: {old_subtotal} → {new_subtotal}", store_name) - # Compare order status + # Compare financial status old_status = sales_order.get(ORDER_STATUS_FIELD) or "" new_status = order.get("financial_status", "") if old_status != new_status: @@ -884,35 +719,13 @@ def update_sales_order(payload, request_id=None, store_name=None): "old": old_status, "new": new_status } - log_store2("UPDATE-10c", f"Financial status changed: {old_status} → {new_status}", store_name) - - old_fulfillment_status = order.get("fulfillment_status") # This might not be stored - new_fulfillment_status = order.get("fulfillment_status", "") - if old_fulfillment_status != new_fulfillment_status: - changes["fulfillment_status"] = { - "old": old_fulfillment_status or "unfulfilled", - "new": new_fulfillment_status or "unfulfilled" - } - log_store2("UPDATE-10d", f"Fulfillment status changed: {old_fulfillment_status} → {new_fulfillment_status}", store_name) # Compare line items - old_items_count = len(sales_order.items) - new_items_count = len(order.get("line_items", [])) - if old_items_count != new_items_count: - changes["line_items_count"] = { - "old": old_items_count, - "new": new_items_count - } - log_store2("UPDATE-10e", f"Line items count changed: {old_items_count} → {new_items_count}", store_name) - - # Compare line items in detail line_item_changes = [] shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} - - # Get existing line item IDs from Sales Order existing_shopify_ids = set() + for so_item in sales_order.items: - # Try to get shopify_line_item_id from custom field if it exists shopify_item_id = str(so_item.get("shopify_line_item_id", "")) if shopify_item_id and shopify_item_id in shopify_items_map: shopify_item = shopify_items_map[shopify_item_id] @@ -927,13 +740,12 @@ def update_sales_order(payload, request_id=None, store_name=None): # Compare rate old_rate = flt(so_item.rate) new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) - if abs(old_rate - new_rate) > 0.01: # Allow small floating point differences + if abs(old_rate - new_rate) > 0.01: item_changes["rate"] = {"old": old_rate, "new": new_rate} if item_changes: line_item_changes.append({ "item_code": so_item.item_code, - "shopify_line_item_id": shopify_item_id, "changes": item_changes }) @@ -943,16 +755,12 @@ def update_sales_order(payload, request_id=None, store_name=None): for shopify_item in order.get("line_items", []): if str(shopify_item.get("id")) not in existing_shopify_ids: line_item_changes.append({ - "shopify_line_item_id": str(shopify_item.get("id")), - "shopify_product_id": shopify_item.get("product_id"), - "shopify_variant_id": shopify_item.get("variant_id"), "title": shopify_item.get("title"), "action": "added" }) if line_item_changes: changes["line_items"] = line_item_changes - log_store2("UPDATE-10f", f"Line item changes detected: {len(line_item_changes)} items", store_name) # Compare customer old_customer = sales_order.customer @@ -963,17 +771,8 @@ def update_sales_order(payload, request_id=None, store_name=None): "old": old_customer, "new": new_customer } - log_store2("UPDATE-10g", f"Customer changed: {old_customer} → {new_customer}", store_name) - - # Compare order note - new_note = order.get("note", "") - if new_note: - changes["note"] = { - "old": "", - "new": new_note - } - # Compare dates + # Compare transaction date old_date = str(sales_order.transaction_date) if sales_order.transaction_date else "" new_date = str(getdate(order.get("created_at"))) if order.get("created_at") else "" if old_date != new_date: @@ -982,59 +781,18 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_date } - log_data["change_details"] = changes - - log_store2("UPDATE-11", f"Change detection complete. Found {len(changes)} change(s)", store_name) - - # ========================================================================= - # SKIP LOGGING IF NO CHANGES DETECTED - # Only create log entry if there are actual changes to avoid spam - # ========================================================================= + # Skip logging if no changes detected if not changes: - log_store2( - "UPDATE-NO-CHANGES", - f"No changes detected for order {order_number}, skipping log creation", - store_name, - ) return - # ========== STORE LINE ITEMS DETAILS ========== - log_store2("UPDATE-12", "Storing line items details...", store_name) - - for item in order.get("line_items", []): - item_code = get_item_code(item) if item.get("product_exists") else None - log_data["line_items_details"].append({ - "shopify_line_item_id": item.get("id"), - "shopify_product_id": item.get("product_id"), - "shopify_variant_id": item.get("variant_id"), - "shopify_sku": item.get("sku", ""), - "erpnext_item_code": item_code, - "title": item.get("title", ""), - "name": item.get("name", ""), - "quantity": cint(item.get("quantity", 0)), - "price": flt(item.get("price", 0)), - "total_discount": flt(_get_total_discount(item)), - "requires_shipping": item.get("requires_shipping", False), - "fulfillable_quantity": cint(item.get("fulfillable_quantity", 0)), - }) - - # ========== PROCESS THE UPDATE ========== - log_store2("UPDATE-13", f"Processing update. Sales Order status: {sales_order.docstatus}", store_name) - + # Process the update if sales_order.docstatus == 2: # Cancelled - log_data["status"] = "cancelled_order" - log_data["change_details"]["action"] = "status_update_only" - log_data["change_details"]["reason"] = "Sales Order is cancelled, only status updated" - - log_store2("UPDATE-14", "Sales Order is cancelled, only updating status", store_name) - frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) - create_shopify_log( status="Invalid", message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.", request_data=order, - response_data=log_data + response_data={"change_details": changes} ) return @@ -1046,14 +804,11 @@ def update_sales_order(payload, request_id=None, store_name=None): if customer_id: customer = ShopifyCustomer(customer_id=customer_id) if not customer.is_synced(): - log_store2("UPDATE-15", f"Syncing customer {customer_id}...", store_name) customer.sync_customer(customer=shopify_customer) else: - log_store2("UPDATE-15", f"Updating addresses for customer {customer_id}...", store_name) customer.update_existing_addresses(shopify_customer) # Ensure items exist - log_store2("UPDATE-16", "Ensuring items exist...", store_name) create_items_if_not_exist(order) # Get updated items and taxes @@ -1067,68 +822,38 @@ def update_sales_order(payload, request_id=None, store_name=None): ) if not items: - log_data["status"] = "error" - log_data["change_details"]["error"] = "Items not found in product master" - - log_store2("UPDATE-17-ERROR", "Items not found in product master!", store_name) - create_shopify_log( status="Error", message="Cannot update order: items not found in product master", request_data=order, - response_data=log_data, + response_data={"change_details": changes}, rollback=True ) return - log_store2("UPDATE-17", f"Got {len(items)} items, calculating taxes...", store_name) taxes = get_order_taxes(order, setting, items) - # Update customer if changed + # Update customer name customer_name = setting.default_customer if shopify_customer.get("id"): customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer # Update the order if sales_order.docstatus == 1: # Submitted - log_data["status"] = "submitted_order_limited_update" - log_data["change_details"]["action"] = "limited_update" - log_data["change_details"]["reason"] = "Order is submitted, only status and notes updated" - - log_store2("UPDATE-18", "Sales Order is submitted, only updating status and notes", store_name) - # For submitted orders, only update status and notes frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) if order.get("note"): sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") - # Update ERPNext amounts in log for comparison - log_data["amount_details"]["erpnext_grand_total"] = flt(sales_order.grand_total) - log_data["amount_details"]["erpnext_total"] = flt(sales_order.total) - log_data["amount_details"]["erpnext_total_taxes_and_charges"] = flt(sales_order.total_taxes_and_charges) - create_shopify_log( status="Success", message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", request_data=order, - response_data=log_data + response_data={"change_details": changes} ) - - log_store2("UPDATE-19-SUCCESS", f"Order {order_number} updated successfully (limited)", store_name) else: # Draft order - can update fully - log_data["status"] = "draft_order_full_update" - log_data["change_details"]["action"] = "full_update" - - log_store2("UPDATE-18", "Sales Order is draft, performing full update...", store_name) - - old_amounts = { - "grand_total": flt(sales_order.grand_total), - "total": flt(sales_order.total), - "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), - } - sales_order.update({ "customer": customer_name, "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date, @@ -1142,31 +867,8 @@ def update_sales_order(payload, request_id=None, store_name=None): sales_order.flags.ignore_mandatory = True sales_order.flags.shopiy_order_json = json.dumps(order) - - log_store2("UPDATE-19", "Saving Sales Order...", store_name) sales_order.save(ignore_permissions=True) - # Reload to get updated amounts - sales_order.reload() - - new_amounts = { - "grand_total": flt(sales_order.grand_total), - "total": flt(sales_order.total), - "total_taxes_and_charges": flt(sales_order.total_taxes_and_charges), - } - - # Add ERPNext amounts to log - log_data["amount_details"]["erpnext_grand_total"] = new_amounts["grand_total"] - log_data["amount_details"]["erpnext_total"] = new_amounts["total"] - log_data["amount_details"]["erpnext_total_taxes_and_charges"] = new_amounts["total_taxes_and_charges"] - - # Add amount changes - if old_amounts["grand_total"] != new_amounts["grand_total"]: - if "grand_total" not in log_data["change_details"]: - log_data["change_details"]["grand_total"] = {} - log_data["change_details"]["grand_total"]["erpnext_old"] = old_amounts["grand_total"] - log_data["change_details"]["grand_total"]["erpnext_new"] = new_amounts["grand_total"] - if order.get("note"): sales_order.add_comment(text=f"Order Note: {order.get('note')}") @@ -1174,85 +876,46 @@ def update_sales_order(payload, request_id=None, store_name=None): status="Success", message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected", request_data=order, - response_data=log_data + response_data={"change_details": changes} ) - - log_store2("UPDATE-20-SUCCESS", f""" -======================================== -ORDER UPDATE COMPLETED SUCCESSFULLY! -======================================== -Order: {order_number} -Sales Order: {sales_order_name} -Changes: {len(changes)} -Store: {store_name} -======================================== -""", store_name) - # ========================================================================= - # TRIGGER UPDATE DETECTION ON ALL LINKED DOCUMENTS - # Calls the "Get Shopify Order Updates" Server Script which compares - # Shopify data with ERPNext data across the full intercompany chain - # (FAM SO -> CCP PO -> CCP SO -> Work Orders) and sets notification - # flags (custom_has_shopify_update) and stores change details - # (custom_shopify_update_data) on each linked document. - # This powers the orange update banners on form pages. - # ========================================================================= + # Trigger update detection on all linked documents if sales_order_name: try: old_form_dict = dict(frappe.form_dict) frappe.form_dict["doctype"] = "Sales Order" frappe.form_dict["docname"] = sales_order_name - script_doc = frappe.get_doc("Server Script", "Get Shopify Order Updates") - script_doc.execute_method() - - detection_result = frappe.response.get("message") or {} - detection_has_changes = detection_result.get("has_changes", False) + if frappe.db.exists("Server Script", "Get Shopify Order Updates"): + script_doc = frappe.get_doc("Server Script", "Get Shopify Order Updates") + script_doc.execute_method() - frappe.form_dict = old_form_dict + detection_result = frappe.response.get("message") or {} + detection_has_changes = detection_result.get("has_changes", False) - if detection_has_changes: - frappe.logger().info( - "Shopify update detection: Changes flagged on all linked docs for SO {}".format( - sales_order_name + if detection_has_changes: + frappe.logger().info( + "Shopify update detection: Changes flagged on all linked docs for SO {}".format( + sales_order_name + ) ) - ) + + frappe.form_dict = old_form_dict except Exception as detection_error: frappe.logger().error( "Shopify update detection failed for SO {}: {}".format( sales_order_name, str(detection_error) ) ) - frappe.form_dict = old_form_dict if "old_form_dict" in dir() else frappe.form_dict - # ========================================================================= - # END OF UPDATE DETECTION TRIGGER - # ========================================================================= + frappe.form_dict = old_form_dict if "old_form_dict" in locals() else frappe.form_dict except Exception as e: - log_data["status"] = "error" - log_data["change_details"]["error"] = str(e) - log_data["change_details"]["traceback"] = frappe.get_traceback() - - log_store2("UPDATE-ERROR", f""" -======================================== -ORDER UPDATE FAILED! -======================================== -Error: {str(e)} -Type: {type(e).__name__} -Order: {order.get('name', 'Unknown')} -Store: {store_name} - -Traceback: -{traceback.format_exc()} -======================================== -""", store_name) - create_shopify_log( status="Error", exception=e, message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}", request_data=order, - response_data=log_data, + response_data={"error": str(e)}, rollback=True ) From ae72c3d5174772edc714f24b587396a327a59e33 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Thu, 26 Feb 2026 11:35:38 +0530 Subject: [PATCH 11/18] Fix: Filter metadata-only updates in Shopify order update webhook - Add floating-point tolerance (0.01) for amount comparisons - Skip line item comparison if shopify_line_item_id not available - Remove transaction_date comparison (created_at never changes) - Only log real business changes, ignore metadata updates - Early return for metadata-only updates on submitted orders - Server Script trigger only runs for real changes --- ecommerce_integrations/shopify/order.py | 122 +++++++++++++----------- 1 file changed, 66 insertions(+), 56 deletions(-) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 27aafca16..52b28e7c3 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -691,20 +691,20 @@ def update_sales_order(payload, request_id=None, store_name=None): sales_order = frappe.get_doc("Sales Order", sales_order_name) changes = {} - # Compare grand total + # Compare grand total (with tolerance for floating point) old_total = flt(sales_order.grand_total) new_total = flt(order.get("total_price", 0)) - if old_total != new_total: + if abs(old_total - new_total) > 0.01: # Only if difference > 1 cent changes["grand_total"] = { "old": old_total, "new": new_total, "difference": new_total - old_total } - # Compare subtotal + # Compare subtotal (with tolerance) old_subtotal = flt(sales_order.total) new_subtotal = flt(order.get("subtotal_price", 0)) - if old_subtotal != new_subtotal: + if abs(old_subtotal - new_subtotal) > 0.01: # Only if difference > 1 cent changes["subtotal"] = { "old": old_subtotal, "new": new_subtotal, @@ -720,44 +720,50 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_status } - # Compare line items + # Compare line items - only if shopify_line_item_id is available + # If not available, skip line item comparison to avoid false positives line_item_changes = [] shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} existing_shopify_ids = set() + has_shopify_item_ids = False for so_item in sales_order.items: shopify_item_id = str(so_item.get("shopify_line_item_id", "")) - if shopify_item_id and shopify_item_id in shopify_items_map: - shopify_item = shopify_items_map[shopify_item_id] - item_changes = {} - - # Compare quantity - old_qty = cint(so_item.qty) - new_qty = cint(shopify_item.get("quantity", 0)) - if old_qty != new_qty: - item_changes["quantity"] = {"old": old_qty, "new": new_qty} - - # Compare rate - old_rate = flt(so_item.rate) - new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) - if abs(old_rate - new_rate) > 0.01: - item_changes["rate"] = {"old": old_rate, "new": new_rate} - - if item_changes: + if shopify_item_id: + has_shopify_item_ids = True + if shopify_item_id in shopify_items_map: + shopify_item = shopify_items_map[shopify_item_id] + item_changes = {} + + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(shopify_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate (with tolerance) + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + if item_changes: + line_item_changes.append({ + "item_code": so_item.item_code, + "changes": item_changes + }) + + existing_shopify_ids.add(shopify_item_id) + + # Only check for new items if we have shopify_line_item_id stored + # Otherwise, skip to avoid false positives + if has_shopify_item_ids: + for shopify_item in order.get("line_items", []): + if str(shopify_item.get("id")) not in existing_shopify_ids: line_item_changes.append({ - "item_code": so_item.item_code, - "changes": item_changes + "title": shopify_item.get("title"), + "action": "added" }) - - existing_shopify_ids.add(shopify_item_id) - - # Check for new items - for shopify_item in order.get("line_items", []): - if str(shopify_item.get("id")) not in existing_shopify_ids: - line_item_changes.append({ - "title": shopify_item.get("title"), - "action": "added" - }) if line_item_changes: changes["line_items"] = line_item_changes @@ -772,14 +778,8 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_customer } - # Compare transaction date - old_date = str(sales_order.transaction_date) if sales_order.transaction_date else "" - new_date = str(getdate(order.get("created_at"))) if order.get("created_at") else "" - if old_date != new_date: - changes["transaction_date"] = { - "old": old_date, - "new": new_date - } + # REMOVED: Transaction date comparison - created_at never changes, causes false positives + # Shopify sends updated_at changes which trigger webhooks but don't affect business data # Skip logging if no changes detected if not changes: @@ -838,20 +838,30 @@ def update_sales_order(payload, request_id=None, store_name=None): if shopify_customer.get("id"): customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer + # Determine if we have real business changes (not just metadata) + has_real_changes = any( + key in changes for key in ["grand_total", "subtotal", "financial_status", "line_items", "customer"] + ) + # Update the order if sales_order.docstatus == 1: # Submitted - # For submitted orders, only update status and notes - frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) - - if order.get("note"): - sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") - - create_shopify_log( - status="Success", - message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", - request_data=order, - response_data={"change_details": changes} - ) + # For submitted orders, only update status and notes if there are REAL changes + # Don't log if only metadata changed (fulfillment status, tracking, updated_at, etc.) + if has_real_changes: + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + if order.get("note"): + sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", + request_data=order, + response_data={"change_details": changes} + ) + else: + # If no real changes, silently return (metadata-only update) + return else: # Draft order - can update fully sales_order.update({ @@ -879,8 +889,8 @@ def update_sales_order(payload, request_id=None, store_name=None): response_data={"change_details": changes} ) - # Trigger update detection on all linked documents - if sales_order_name: + # Trigger update detection on all linked documents (only if real changes detected) + if sales_order_name and has_real_changes: try: old_form_dict = dict(frappe.form_dict) frappe.form_dict["doctype"] = "Sales Order" From e42e2d9d1753fe739dc2e038549da2fe7e089a5c Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Sat, 28 Feb 2026 14:35:57 +0530 Subject: [PATCH 12/18] Customized logs creation and fixed billing address issue --- ecommerce_integrations/shopify/customer.py | 26 +- ecommerce_integrations/shopify/order.py | 440 ++++++++++++++++++--- 2 files changed, 400 insertions(+), 66 deletions(-) diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 3a0ee952f..ffa84e65c 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -28,8 +28,19 @@ def sync_customer(self, customer: dict[str, Any]) -> None: customer_group = self.setting.customer_group super().sync_customer(customer_name, customer_group) - billing_address = customer.get("billing_address", {}) or customer.get("default_address") + # Handle billing address: only use shipping if billing is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = customer.get("billing_address") shipping_address = customer.get("shipping_address", {}) + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = customer.get("default_address", {}) if billing_address: self.create_customer_address( @@ -54,8 +65,19 @@ def create_customer_address( super().create_customer_address(address_fields) def update_existing_addresses(self, customer): - billing_address = customer.get("billing_address", {}) or customer.get("default_address") + # Handle billing address: only use shipping if billing is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = customer.get("billing_address") shipping_address = customer.get("shipping_address", {}) + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = customer.get("default_address", {}) customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name")) email = customer.get("email") diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 52b28e7c3..e45bc3c4b 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -654,10 +654,51 @@ def cancel_order(payload, request_id=None, store_name=None): create_shopify_log(status="Success") +def _normalize_address_for_comparison(addr_dict): + """Normalize address fields for comparison (trim, lowercase, handle None).""" + return { + "address_line1": (addr_dict.get("address_line1") or "").strip().lower(), + "address_line2": (addr_dict.get("address_line2") or "").strip().lower(), + "city": (addr_dict.get("city") or "").strip().lower(), + "state": (addr_dict.get("state") or "").strip().lower(), + "pincode": (addr_dict.get("pincode") or "").strip().lower(), + "country": (addr_dict.get("country") or "").strip().lower(), + "phone": (addr_dict.get("phone") or "").strip().lower(), + } + +def _get_customer_address_by_type(customer_name, address_type): + """Get customer address of the specified type. + + Returns the address of the specified type for the customer. + If no address found, returns None. + """ + if not customer_name: + return None + + # Get address of the specified type + address = frappe.db.get_value( + "Address", + filters={ + "link_doctype": "Customer", + "link_name": customer_name, + "address_type": address_type + }, + fieldname=["name", "address_line1", "address_line2", "city", "state", "pincode", "country", "phone"], + as_dict=True + ) + + return address + def update_sales_order(payload, request_id=None, store_name=None): """Handle order updates from Shopify. - Tracks essential changes: amounts, items, status, customer. + Tracks ONLY necessary business changes: + - Line item changes (quantity, price, new items) with full details + - Price changes (grand_total, subtotal) + - Contact details (email, phone) + - Address changes (shipping, billing) + - Customer name changes + Supports both Store 1 and Store 2. """ order = payload @@ -671,11 +712,15 @@ def update_sales_order(payload, request_id=None, store_name=None): try: order_id = cstr(order.get("id")) order_number = order.get("name", "") - customer_id = order.get("customer", {}).get("id") if order.get("customer") else None + shopify_customer = order.get("customer", {}) if order.get("customer") else {} + customer_id = shopify_customer.get("id") # Check if Sales Order exists sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + # Early exit: If order doesn't exist, create it and return + # (This prevents checking for metadata-only updates on non-existent orders) + if not sales_order_name: # Order doesn't exist, create it create_shopify_log( @@ -691,6 +736,11 @@ def update_sales_order(payload, request_id=None, store_name=None): sales_order = frappe.get_doc("Sales Order", sales_order_name) changes = {} + # FIRST: Quick check for critical business changes (amounts, items) + # If these haven't changed, this is likely just a metadata/timeline update + # Skip expensive address/contact checks for metadata-only updates + has_critical_changes = False + # Compare grand total (with tolerance for floating point) old_total = flt(sales_order.grand_total) new_total = flt(order.get("total_price", 0)) @@ -700,6 +750,7 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_total, "difference": new_total - old_total } + has_critical_changes = True # Compare subtotal (with tolerance) old_subtotal = flt(sales_order.total) @@ -710,76 +761,318 @@ def update_sales_order(payload, request_id=None, store_name=None): "new": new_subtotal, "difference": new_subtotal - old_subtotal } + has_critical_changes = True - # Compare financial status - old_status = sales_order.get(ORDER_STATUS_FIELD) or "" - new_status = order.get("financial_status", "") - if old_status != new_status: - changes["financial_status"] = { - "old": old_status, - "new": new_status - } - - # Compare line items - only if shopify_line_item_id is available - # If not available, skip line item comparison to avoid false positives + # Compare line items with improved matching and full details line_item_changes = [] - shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} - existing_shopify_ids = set() - has_shopify_item_ids = False + shopify_items_by_id = {str(item.get("id", "")): item for item in order.get("line_items", [])} + shopify_items_by_sku = {} # Group by SKU for fallback matching + + for item in order.get("line_items", []): + sku = item.get("sku", "") + if sku: + if sku not in shopify_items_by_sku: + shopify_items_by_sku[sku] = [] + shopify_items_by_sku[sku].append(item) + + matched_shopify_ids = set() + matched_so_items = set() # Track which SO items we've matched - for so_item in sales_order.items: + # Match existing Sales Order items with Shopify items + for idx, so_item in enumerate(sales_order.items): + item_code = so_item.item_code shopify_item_id = str(so_item.get("shopify_line_item_id", "")) - if shopify_item_id: - has_shopify_item_ids = True - if shopify_item_id in shopify_items_map: - shopify_item = shopify_items_map[shopify_item_id] - item_changes = {} - - # Compare quantity - old_qty = cint(so_item.qty) - new_qty = cint(shopify_item.get("quantity", 0)) - if old_qty != new_qty: - item_changes["quantity"] = {"old": old_qty, "new": new_qty} - - # Compare rate (with tolerance) - old_rate = flt(so_item.rate) - new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) - if abs(old_rate - new_rate) > 0.01: - item_changes["rate"] = {"old": old_rate, "new": new_rate} - - if item_changes: - line_item_changes.append({ - "item_code": so_item.item_code, - "changes": item_changes - }) - - existing_shopify_ids.add(shopify_item_id) - - # Only check for new items if we have shopify_line_item_id stored - # Otherwise, skip to avoid false positives - if has_shopify_item_ids: - for shopify_item in order.get("line_items", []): - if str(shopify_item.get("id")) not in existing_shopify_ids: + matched_item = None + item_changes = {} + + # Try matching by shopify_line_item_id first (most reliable) + if shopify_item_id and shopify_item_id in shopify_items_by_id: + matched_item = shopify_items_by_id[shopify_item_id] + matched_shopify_ids.add(shopify_item_id) + matched_so_items.add(idx) + else: + # Fallback: match by item_code/SKU + quantity + rate + so_sku = item_code + old_qty = cint(so_item.qty) + old_rate = flt(so_item.rate) + + if so_sku in shopify_items_by_sku: + for shopify_item in shopify_items_by_sku[so_sku]: + shopify_item_id_str = str(shopify_item.get("id", "")) + if shopify_item_id_str in matched_shopify_ids: + continue + + new_qty = cint(shopify_item.get("quantity", 0)) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + + # Match if quantity and rate are close (within tolerance) + if old_qty == new_qty and abs(old_rate - new_rate) <= 0.01: + matched_item = shopify_item + matched_shopify_ids.add(shopify_item_id_str) + matched_so_items.add(idx) + break + + # If matched, compare details + if matched_item: + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(matched_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate (with tolerance) + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(matched_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + # Compare item title/name (in case product name changed) + old_title = so_item.item_name or "" + new_title = matched_item.get("title", "") or matched_item.get("name", "") + if old_title != new_title: + item_changes["title"] = {"old": old_title, "new": new_title} + + if item_changes: line_item_changes.append({ - "title": shopify_item.get("title"), - "action": "added" + "item_code": item_code, + "sku": matched_item.get("sku", ""), + "title": matched_item.get("title", ""), + "shopify_line_item_id": str(matched_item.get("id", "")), + "changes": item_changes }) + # Check for new items (items in Shopify that don't exist in Sales Order) + for shopify_item in order.get("line_items", []): + shopify_item_id_str = str(shopify_item.get("id", "")) + if shopify_item_id_str not in matched_shopify_ids: + # This is a genuinely new item + line_item_changes.append({ + "action": "added", + "title": shopify_item.get("title", ""), + "sku": shopify_item.get("sku", ""), + "quantity": cint(shopify_item.get("quantity", 0)), + "rate": flt(_get_item_price(shopify_item, order.get("taxes_included", False))), + "shopify_line_item_id": shopify_item_id_str, + "product_id": shopify_item.get("product_id"), + "variant_id": shopify_item.get("variant_id"), + }) + if line_item_changes: changes["line_items"] = line_item_changes + has_critical_changes = True - # Compare customer - old_customer = sales_order.customer - if customer_id: - new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - if old_customer != new_customer and new_customer: - changes["customer"] = { - "old": old_customer, - "new": new_customer + # Track notes (but don't trigger logs for notes alone) + old_note = sales_order.get("note", "") or "" + new_note = order.get("note", "") or "" + if old_note.strip() != new_note.strip(): + changes["note"] = { + "old": old_note, + "new": new_note + } + # Note: NOT setting has_critical_changes - notes won't trigger logs alone + + # Track fulfillment status (but don't trigger logs for fulfillment status alone) + # Fulfillment status: unfulfilled, partial, fulfilled + old_fulfillment_status = "" + # Get from order fulfillments + if order.get("fulfillments"): + # Check if all items are fulfilled + total_quantity = sum(item.get("quantity", 0) for item in order.get("line_items", [])) + fulfilled_quantity = sum( + sum(f_item.get("quantity", 0) for f_item in fulfillment.get("line_items", [])) + for fulfillment in order.get("fulfillments", []) + ) + if fulfilled_quantity == 0: + new_fulfillment_status = "unfulfilled" + elif fulfilled_quantity >= total_quantity: + new_fulfillment_status = "fulfilled" + else: + new_fulfillment_status = "partial" + else: + new_fulfillment_status = "unfulfilled" + + # Track fulfillment status change + if new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status + } + # Note: NOT setting has_critical_changes - fulfillment status won't trigger logs alone + + # Track tags (but don't trigger logs for tags alone) + new_tags = order.get("tags", "") or "" # Tags are stored as comma-separated string in Shopify + if new_tags: + changes["tags"] = { + "new": new_tags + } + # Note: NOT setting has_critical_changes - tags won't trigger logs alone + + # Track updated_at (but don't trigger logs for updated_at alone) + # updated_at is just a timestamp that changes on every update + updated_at = order.get("updated_at", "") + if updated_at: + changes["updated_at"] = { + "new": updated_at + } + # Note: NOT setting has_critical_changes - updated_at won't trigger logs alone + + # Early exit: If no critical changes (amounts, items, notes, fulfillment), skip address/contact checks + # This filters out metadata-only updates (timeline events, emails, ParcelWILL, Katana, etc.) + # BUT: updated_at changes are NOT tracked - it's just a timestamp that changes on every update + if not has_critical_changes: + # Still check customer name change (important business change) + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + has_critical_changes = True + + # If still no critical changes, this is a metadata-only update - skip it entirely + # (ParcelWILL emails, Katana updates, timeline events, etc. don't change business data) + # Note: updated_at is NOT tracked - it's just a timestamp that changes on every update + if not has_critical_changes: + return + + # Compare customer name (if we haven't already checked it above) + if "customer" not in changes: + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + + # Compare customer email + shopify_email = shopify_customer.get("email", "") + if shopify_email and sales_order.customer: + # Get customer email from ERPNext Contact + contact = frappe.db.get_value( + "Contact", + {"link_doctype": "Customer", "link_name": sales_order.customer}, + ["email_id"], + as_dict=True + ) + old_email = contact.get("email_id", "") if contact else "" + if old_email != shopify_email: + changes["customer_email"] = { + "old": old_email, + "new": shopify_email + } + + # Compare customer phone + shopify_phone = shopify_customer.get("phone", "") or shopify_customer.get("default_address", {}).get("phone", "") + if shopify_phone and sales_order.customer: + # Get customer phone from ERPNext Contact + contact = frappe.db.get_value( + "Contact", + {"link_doctype": "Customer", "link_name": sales_order.customer}, + ["phone", "mobile_no"], + as_dict=True + ) + if contact: + old_phone = contact.get("phone", "") or contact.get("mobile_no", "") + if old_phone != shopify_phone: + changes["customer_phone"] = { + "old": old_phone, + "new": shopify_phone + } + + # Compare shipping address + shipping_address = order.get("shipping_address", {}) + if shipping_address and sales_order.customer: + # Get shipping address from Customer + shipping_addr_doc = _get_customer_address_by_type( + sales_order.customer, "Shipping" + ) + + if shipping_addr_doc: + old_addr = { + "address_line1": shipping_addr_doc.get("address_line1", ""), + "address_line2": shipping_addr_doc.get("address_line2", ""), + "city": shipping_addr_doc.get("city", ""), + "state": shipping_addr_doc.get("state", ""), + "pincode": shipping_addr_doc.get("pincode", ""), + "country": shipping_addr_doc.get("country", ""), + "phone": shipping_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": shipping_address.get("address1", ""), + "address_line2": shipping_address.get("address2", ""), + "city": shipping_address.get("city", ""), + "state": shipping_address.get("province", ""), + "pincode": shipping_address.get("zip", ""), + "country": shipping_address.get("country", ""), + "phone": shipping_address.get("phone", ""), } + + # Compare normalized addresses + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["shipping_address"] = { + "old": old_addr, + "new": new_addr + } - # REMOVED: Transaction date comparison - created_at never changes, causes false positives - # Shopify sends updated_at changes which trigger webhooks but don't affect business data + # Compare billing address + # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = order.get("billing_address") + billing_is_same_as_shipping = False + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + billing_is_same_as_shipping = True + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + if billing_address and sales_order.customer: + # Get billing address from Customer + billing_addr_doc = _get_customer_address_by_type( + sales_order.customer, "Billing" + ) + + if billing_addr_doc: + old_addr = { + "address_line1": billing_addr_doc.get("address_line1", ""), + "address_line2": billing_addr_doc.get("address_line2", ""), + "city": billing_addr_doc.get("city", ""), + "state": billing_addr_doc.get("state", ""), + "pincode": billing_addr_doc.get("pincode", ""), + "country": billing_addr_doc.get("country", ""), + "phone": billing_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": billing_address.get("address1", ""), + "address_line2": billing_address.get("address2", ""), + "city": billing_address.get("city", ""), + "state": billing_address.get("province", ""), + "pincode": billing_address.get("zip", ""), + "country": billing_address.get("country", ""), + "phone": billing_address.get("phone", ""), + } + + # Compare normalized addresses + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["billing_address"] = { + "old": old_addr, + "new": new_addr, + "is_same_as_shipping": billing_is_same_as_shipping + } # Skip logging if no changes detected if not changes: @@ -796,10 +1089,23 @@ def update_sales_order(payload, request_id=None, store_name=None): ) return - # Update customer if changed + # Update customer if changed - handle billing address properly shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") + shipping_address = order.get("shipping_address", {}) + billing_address = order.get("billing_address") + + # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + shopify_customer["billing_address"] = billing_address + shopify_customer["shipping_address"] = shipping_address if customer_id: customer = ShopifyCustomer(customer_id=customer_id) @@ -839,8 +1145,14 @@ def update_sales_order(payload, request_id=None, store_name=None): customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer # Determine if we have real business changes (not just metadata) + # Track: prices, line items, contact details, addresses, customer name + # Note: tags, notes, fulfillment_status, updated_at are tracked but DON'T trigger logs alone has_real_changes = any( - key in changes for key in ["grand_total", "subtotal", "financial_status", "line_items", "customer"] + key in changes for key in [ + "grand_total", "subtotal", "line_items", + "customer", "customer_email", "customer_phone", + "shipping_address", "billing_address" + ] ) # Update the order From 47f38681e63047d37d6a7198a0df09a49d966f8f Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Tue, 3 Mar 2026 15:40:11 +0530 Subject: [PATCH 13/18] Added a new webhook for edited and also customised the updated webhook --- ecommerce_integrations/shopify/connection.py | 184 +++++++++++++++--- ecommerce_integrations/shopify/constants.py | 4 +- .../shopify_setting/shopify_setting.py | 12 ++ ecommerce_integrations/shopify/order.py | 18 ++ 4 files changed, 187 insertions(+), 31 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 9abcb318b..eaf1e31fe 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -469,45 +469,127 @@ def process_request(data, event, store_name=None): Order ID: {data.get('id')} Method to call: {EVENT_MAPPER[event]} """, store_name) - - # ========================================================================= - # STEP 12: Create Shopify log - # ========================================================================= - log_store2("12", "Creating Shopify log entry...", store_name) - - try: - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - log_store2("12-OK", f"Log created: {log.name}", store_name) - except Exception as e: - log_store2("12-EXCEPTION", f""" + + # ========================================================================= + # STEP 11.5: Early filter for noisy `orders/updated` webhooks + # ========================================================================= + # NOTE: Per business requirements, this fingerprint ONLY tracks: + # - Shipping address + # - Billing address + # - Order notes + # + # Line items are handled via the dedicated `orders/edited` webhook, so they + # are *not* part of this fingerprint. This means: + # - Old/timeline/metadata-only updates on untouched orders are dropped here. + # - Only REAL address / note changes for existing Sales Orders will proceed. + if event == "orders/updated": + try: + from ecommerce_integrations.shopify.constants import ORDER_ID_FIELD + + order_id = str(data.get("id") or "") + so_name = None + if order_id: + so_name = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: order_id}, "name") + + # If we have a matching Sales Order, compare fingerprints + if so_name: + new_fingerprint = _build_order_fingerprint(data) + + try: + old_fingerprint = frappe.db.get_value( + "Sales Order", so_name, "custom_shopify_fingerprint" + ) or "" + except Exception: + # If the field doesn't exist yet or any error occurs, skip fingerprint filter + log_store2( + "11.5-SKIP", + f"Fingerprint field missing or error while reading for SO {so_name}. " + f"Proceeding without early-exit filter.", + store_name, + ) + old_fingerprint = "" + + # If fingerprint unchanged, drop this webhook before logging / enqueue + if old_fingerprint and new_fingerprint == old_fingerprint: + log_store2( + "11.5-SKIP", + f"orders/updated fingerprint UNCHANGED for order_id={order_id}, so_name={so_name}. " + f"Skipping log + enqueue to avoid noise.", + store_name, + ) + return + + # Fingerprint changed or first time: update it so future metadata-only + # webhooks on the same order can be skipped. + try: + frappe.db.set_value( + "Sales Order", + so_name, + "custom_shopify_fingerprint", + new_fingerprint, + update_modified=False, + ) + frappe.db.commit() + log_store2( + "11.5-SET", + f"Updated fingerprint for SO {so_name}. " + f"Old: {old_fingerprint or 'EMPTY'} | New: {new_fingerprint}", + store_name, + ) + except Exception as e: + log_store2( + "11.5-SET-ERROR", + f"Failed to update fingerprint for SO {so_name}. Error: {str(e)}", + store_name, + ) + except Exception as e: + # Fail open: if anything goes wrong in fingerprint logic, we still + # want the webhook to be processed normally. + log_store2( + "11.5-EXCEPTION", + f"Exception in orders/updated fingerprint filter: {str(e)}\n" + f"Traceback:\n{traceback.format_exc()}", + store_name, + ) + + # ========================================================================= + # STEP 12: Create Shopify log + # ========================================================================= + log_store2("12", "Creating Shopify log entry...", store_name) + + try: + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) + log_store2("12-OK", f"Log created: {log.name}", store_name) + except Exception as e: + log_store2("12-EXCEPTION", f""" Failed to create Shopify log! Error: {str(e)} Traceback: {traceback.format_exc()} """, store_name) - raise - - # ========================================================================= - # STEP 13: Enqueue background job - # ========================================================================= - log_store2("13", f""" + raise + + # ========================================================================= + # STEP 13: Enqueue background job + # ========================================================================= + log_store2("13", f""" About to enqueue background job... Method: {EVENT_MAPPER[event]} Queue: short Timeout: 300 Kwargs: payload (order data), request_id={log.name}, store_name={store_name} """, store_name) - - try: - frappe.enqueue( - method=EVENT_MAPPER[event], - queue="short", - timeout=300, - is_async=True, - **{"payload": data, "request_id": log.name, "store_name": store_name}, - ) - log_store2("13-OK", f""" + + try: + frappe.enqueue( + method=EVENT_MAPPER[event], + queue="short", + timeout=300, + is_async=True, + **{"payload": data, "request_id": log.name, "store_name": store_name}, + ) + log_store2("13-OK", f""" Job enqueued successfully! Method: {EVENT_MAPPER[event]} Log ID: {log.name} @@ -517,15 +599,57 @@ def process_request(data, event, store_name=None): The background worker should now pick up the job. Check RQ Job doctype for the job status. """, store_name) - except Exception as e: - log_store2("13-EXCEPTION", f""" + except Exception as e: + log_store2("13-EXCEPTION", f""" Failed to enqueue job! Error: {str(e)} Traceback: {traceback.format_exc()} """, store_name) - raise + raise + + +def _build_order_fingerprint(data): + """Build a fingerprint of ONLY the fields we care about for `orders/updated`. + + Per the current requirement, this fingerprint includes: + - Order note + - Billing address (core fields) + - Shipping address (core fields) + + Line items and other metadata are intentionally NOT included here. Line-item + changes are handled via the dedicated `orders/edited` webhook instead. + """ + import hashlib + import json + + fingerprint_data = { + "note": data.get("note") or "", + "billing_address": _address_hash(data.get("billing_address")), + "shipping_address": _address_hash(data.get("shipping_address")), + } + + raw = json.dumps(fingerprint_data, sort_keys=True) + return hashlib.md5(raw.encode()).hexdigest() + + +def _address_hash(address): + """Return a stable string representing the address fields we care about.""" + if not address: + return "" + + return "|".join( + [ + str(address.get("address1") or "").strip(), + str(address.get("address2") or "").strip(), + str(address.get("city") or "").strip(), + str(address.get("province") or "").strip(), + str(address.get("zip") or "").strip(), + str(address.get("country") or "").strip(), + str(address.get("phone") or "").strip(), + ] + ) def _validate_request(req, hmac_header, shared_secret): diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index e4c17150d..a0bf0c95f 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -14,7 +14,8 @@ "orders/fulfilled", "orders/cancelled", "orders/partially_fulfilled", - "orders/updated", + "orders/edited", # Clean signal for line item edits + "orders/updated", # Kept only for address / notes changes (filtered via fingerprint) ] EVENT_MAPPER = { @@ -23,6 +24,7 @@ "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/edited": "ecommerce_integrations.shopify.order.handle_order_edited", "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", } diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py index a7a4853c4..8a98ef027 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py @@ -222,6 +222,18 @@ def setup_custom_fields(): read_only=1, print_hide=1, ), + # Fingerprint used to filter noisy `orders/updated` webhooks. + # Hidden technical field – do not show in UI. + dict( + fieldname="custom_shopify_fingerprint", + label="Shopify Fingerprint", + fieldtype="Small Text", + insert_after=ORDER_STATUS_FIELD, + read_only=1, + print_hide=1, + hidden=1, + no_copy=1, + ), ], "Sales Order Item": [ dict( diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index e45bc3c4b..014076b15 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -44,6 +44,24 @@ def log_store2(step, message, store_name=None): ) +def handle_order_edited(payload, request_id=None, store_name=None): + """Handle Shopify `orders/edited` webhook. + + This topic is a CLEAN signal from Shopify that only fires when an order + is edited (line items added/removed/changed via the order edit flow). + + We simply route it into the existing `update_sales_order` logic, which + already has robust line-item comparison and change tracking. + """ + log_store2( + "BG-ORDER-EDITED", + f"orders/edited webhook received. request_id={request_id}, store_name={store_name}, " + f"order_id={payload.get('id')}, order_number={payload.get('name')}", + store_name, + ) + return update_sales_order(payload, request_id=request_id, store_name=store_name) + + def sync_sales_order(payload, request_id=None, store_name=None): """Sync Shopify order to ERPNext Sales Order. From d735ff7fe424893c9f75a36a15f467330852b5e7 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 4 Mar 2026 10:03:02 +0530 Subject: [PATCH 14/18] fixed tab error --- ecommerce_integrations/shopify/connection.py | 172 +++++++++---------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index eaf1e31fe..f55e3d732 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -469,92 +469,92 @@ def process_request(data, event, store_name=None): Order ID: {data.get('id')} Method to call: {EVENT_MAPPER[event]} """, store_name) - - # ========================================================================= - # STEP 11.5: Early filter for noisy `orders/updated` webhooks - # ========================================================================= - # NOTE: Per business requirements, this fingerprint ONLY tracks: - # - Shipping address - # - Billing address - # - Order notes - # - # Line items are handled via the dedicated `orders/edited` webhook, so they - # are *not* part of this fingerprint. This means: - # - Old/timeline/metadata-only updates on untouched orders are dropped here. - # - Only REAL address / note changes for existing Sales Orders will proceed. - if event == "orders/updated": - try: - from ecommerce_integrations.shopify.constants import ORDER_ID_FIELD - - order_id = str(data.get("id") or "") - so_name = None - if order_id: - so_name = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: order_id}, "name") - - # If we have a matching Sales Order, compare fingerprints - if so_name: - new_fingerprint = _build_order_fingerprint(data) - - try: - old_fingerprint = frappe.db.get_value( - "Sales Order", so_name, "custom_shopify_fingerprint" - ) or "" - except Exception: - # If the field doesn't exist yet or any error occurs, skip fingerprint filter - log_store2( - "11.5-SKIP", - f"Fingerprint field missing or error while reading for SO {so_name}. " - f"Proceeding without early-exit filter.", - store_name, - ) - old_fingerprint = "" - - # If fingerprint unchanged, drop this webhook before logging / enqueue - if old_fingerprint and new_fingerprint == old_fingerprint: - log_store2( - "11.5-SKIP", - f"orders/updated fingerprint UNCHANGED for order_id={order_id}, so_name={so_name}. " - f"Skipping log + enqueue to avoid noise.", - store_name, - ) - return - - # Fingerprint changed or first time: update it so future metadata-only - # webhooks on the same order can be skipped. - try: - frappe.db.set_value( - "Sales Order", - so_name, - "custom_shopify_fingerprint", - new_fingerprint, - update_modified=False, - ) - frappe.db.commit() - log_store2( - "11.5-SET", - f"Updated fingerprint for SO {so_name}. " - f"Old: {old_fingerprint or 'EMPTY'} | New: {new_fingerprint}", - store_name, - ) - except Exception as e: - log_store2( - "11.5-SET-ERROR", - f"Failed to update fingerprint for SO {so_name}. Error: {str(e)}", - store_name, - ) - except Exception as e: - # Fail open: if anything goes wrong in fingerprint logic, we still - # want the webhook to be processed normally. - log_store2( - "11.5-EXCEPTION", - f"Exception in orders/updated fingerprint filter: {str(e)}\n" - f"Traceback:\n{traceback.format_exc()}", - store_name, - ) - - # ========================================================================= - # STEP 12: Create Shopify log - # ========================================================================= + + # ========================================================================= + # STEP 11.5: Early filter for noisy `orders/updated` webhooks + # ========================================================================= + # NOTE: Per business requirements, this fingerprint ONLY tracks: + # - Shipping address + # - Billing address + # - Order notes + # + # Line items are handled via the dedicated `orders/edited` webhook, so they + # are *not* part of this fingerprint. This means: + # - Old/timeline/metadata-only updates on untouched orders are dropped here. + # - Only REAL address / note changes for existing Sales Orders will proceed. + if event == "orders/updated": + try: + from ecommerce_integrations.shopify.constants import ORDER_ID_FIELD + + order_id = str(data.get("id") or "") + so_name = None + if order_id: + so_name = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: order_id}, "name") + + # If we have a matching Sales Order, compare fingerprints + if so_name: + new_fingerprint = _build_order_fingerprint(data) + + try: + old_fingerprint = frappe.db.get_value( + "Sales Order", so_name, "custom_shopify_fingerprint" + ) or "" + except Exception: + # If the field doesn't exist yet or any error occurs, skip fingerprint filter + log_store2( + "11.5-SKIP", + f"Fingerprint field missing or error while reading for SO {so_name}. " + f"Proceeding without early-exit filter.", + store_name, + ) + old_fingerprint = "" + + # If fingerprint unchanged, drop this webhook before logging / enqueue + if old_fingerprint and new_fingerprint == old_fingerprint: + log_store2( + "11.5-SKIP", + f"orders/updated fingerprint UNCHANGED for order_id={order_id}, so_name={so_name}. " + f"Skipping log + enqueue to avoid noise.", + store_name, + ) + return + + # Fingerprint changed or first time: update it so future metadata-only + # webhooks on the same order can be skipped. + try: + frappe.db.set_value( + "Sales Order", + so_name, + "custom_shopify_fingerprint", + new_fingerprint, + update_modified=False, + ) + frappe.db.commit() + log_store2( + "11.5-SET", + f"Updated fingerprint for SO {so_name}. " + f"Old: {old_fingerprint or 'EMPTY'} | New: {new_fingerprint}", + store_name, + ) + except Exception as e: + log_store2( + "11.5-SET-ERROR", + f"Failed to update fingerprint for SO {so_name}. Error: {str(e)}", + store_name, + ) + except Exception as e: + # Fail open: if anything goes wrong in fingerprint logic, we still + # want the webhook to be processed normally. + log_store2( + "11.5-EXCEPTION", + f"Exception in orders/updated fingerprint filter: {str(e)}\n" + f"Traceback:\n{traceback.format_exc()}", + store_name, + ) + + # ========================================================================= + # STEP 12: Create Shopify log + # ========================================================================= log_store2("12", "Creating Shopify log entry...", store_name) try: From a8de90e4813399ebca90ffde3f22c38d52423c17 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 4 Mar 2026 10:05:14 +0530 Subject: [PATCH 15/18] fixed tab error --- ecommerce_integrations/shopify/connection.py | 124 +++++++++---------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index f55e3d732..4c84460ba 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -555,41 +555,41 @@ def process_request(data, event, store_name=None): # ========================================================================= # STEP 12: Create Shopify log # ========================================================================= - log_store2("12", "Creating Shopify log entry...", store_name) - - try: - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - log_store2("12-OK", f"Log created: {log.name}", store_name) - except Exception as e: - log_store2("12-EXCEPTION", f""" + log_store2("12", "Creating Shopify log entry...", store_name) + + try: + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) + log_store2("12-OK", f"Log created: {log.name}", store_name) + except Exception as e: + log_store2("12-EXCEPTION", f""" Failed to create Shopify log! Error: {str(e)} Traceback: {traceback.format_exc()} """, store_name) - raise - - # ========================================================================= - # STEP 13: Enqueue background job - # ========================================================================= - log_store2("13", f""" + raise + + # ========================================================================= + # STEP 13: Enqueue background job + # ========================================================================= + log_store2("13", f""" About to enqueue background job... Method: {EVENT_MAPPER[event]} Queue: short Timeout: 300 Kwargs: payload (order data), request_id={log.name}, store_name={store_name} """, store_name) - - try: - frappe.enqueue( - method=EVENT_MAPPER[event], - queue="short", - timeout=300, - is_async=True, - **{"payload": data, "request_id": log.name, "store_name": store_name}, - ) - log_store2("13-OK", f""" + + try: + frappe.enqueue( + method=EVENT_MAPPER[event], + queue="short", + timeout=300, + is_async=True, + **{"payload": data, "request_id": log.name, "store_name": store_name}, + ) + log_store2("13-OK", f""" Job enqueued successfully! Method: {EVENT_MAPPER[event]} Log ID: {log.name} @@ -599,57 +599,57 @@ def process_request(data, event, store_name=None): The background worker should now pick up the job. Check RQ Job doctype for the job status. """, store_name) - except Exception as e: - log_store2("13-EXCEPTION", f""" + except Exception as e: + log_store2("13-EXCEPTION", f""" Failed to enqueue job! Error: {str(e)} Traceback: {traceback.format_exc()} """, store_name) - raise + raise def _build_order_fingerprint(data): - """Build a fingerprint of ONLY the fields we care about for `orders/updated`. - - Per the current requirement, this fingerprint includes: - - Order note - - Billing address (core fields) - - Shipping address (core fields) - - Line items and other metadata are intentionally NOT included here. Line-item - changes are handled via the dedicated `orders/edited` webhook instead. - """ - import hashlib - import json - - fingerprint_data = { - "note": data.get("note") or "", - "billing_address": _address_hash(data.get("billing_address")), - "shipping_address": _address_hash(data.get("shipping_address")), - } - - raw = json.dumps(fingerprint_data, sort_keys=True) - return hashlib.md5(raw.encode()).hexdigest() + """Build a fingerprint of ONLY the fields we care about for `orders/updated`. + + Per the current requirement, this fingerprint includes: + - Order note + - Billing address (core fields) + - Shipping address (core fields) + + Line items and other metadata are intentionally NOT included here. Line-item + changes are handled via the dedicated `orders/edited` webhook instead. + """ + import hashlib + import json + + fingerprint_data = { + "note": data.get("note") or "", + "billing_address": _address_hash(data.get("billing_address")), + "shipping_address": _address_hash(data.get("shipping_address")), + } + + raw = json.dumps(fingerprint_data, sort_keys=True) + return hashlib.md5(raw.encode()).hexdigest() def _address_hash(address): - """Return a stable string representing the address fields we care about.""" - if not address: - return "" - - return "|".join( - [ - str(address.get("address1") or "").strip(), - str(address.get("address2") or "").strip(), - str(address.get("city") or "").strip(), - str(address.get("province") or "").strip(), - str(address.get("zip") or "").strip(), - str(address.get("country") or "").strip(), - str(address.get("phone") or "").strip(), - ] - ) + """Return a stable string representing the address fields we care about.""" + if not address: + return "" + + return "|".join( + [ + str(address.get("address1") or "").strip(), + str(address.get("address2") or "").strip(), + str(address.get("city") or "").strip(), + str(address.get("province") or "").strip(), + str(address.get("zip") or "").strip(), + str(address.get("country") or "").strip(), + str(address.get("phone") or "").strip(), + ] + ) def _validate_request(req, hmac_header, shared_secret): From d51b6a7a715ca511c5bc62614af1367a0524e4e9 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 4 Mar 2026 10:59:33 +0530 Subject: [PATCH 16/18] fixed errors --- ecommerce_integrations/shopify/connection.py | 3 - ecommerce_integrations/shopify/constants.py | 2 +- ecommerce_integrations/shopify/order.py | 230 ++++++++++++++++--- 3 files changed, 201 insertions(+), 34 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4c84460ba..271cacc02 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -621,9 +621,6 @@ def _build_order_fingerprint(data): Line items and other metadata are intentionally NOT included here. Line-item changes are handled via the dedicated `orders/edited` webhook instead. """ - import hashlib - import json - fingerprint_data = { "note": data.get("note") or "", "billing_address": _address_hash(data.get("billing_address")), diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index a0bf0c95f..a3f25a72e 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -25,7 +25,7 @@ "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", "orders/edited": "ecommerce_integrations.shopify.order.handle_order_edited", - "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", + "orders/updated": "ecommerce_integrations.shopify.order.handle_order_updated", } SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"] diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 014076b15..cb59833e3 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -45,21 +45,190 @@ def log_store2(step, message, store_name=None): def handle_order_edited(payload, request_id=None, store_name=None): - """Handle Shopify `orders/edited` webhook. - - This topic is a CLEAN signal from Shopify that only fires when an order - is edited (line items added/removed/changed via the order edit flow). - - We simply route it into the existing `update_sales_order` logic, which - already has robust line-item comparison and change tracking. - """ - log_store2( - "BG-ORDER-EDITED", - f"orders/edited webhook received. request_id={request_id}, store_name={store_name}, " - f"order_id={payload.get('id')}, order_number={payload.get('name')}", - store_name, - ) - return update_sales_order(payload, request_id=request_id, store_name=store_name) + """Handle Shopify `orders/edited` webhook. + + This topic is a CLEAN signal from Shopify that only fires when an order + is edited (line items added/removed/changed via the order edit flow). + + We simply route it into the existing `update_sales_order` logic, which + already has robust line-item comparison and change tracking. + """ + log_store2( + "BG-ORDER-EDITED", + f"orders/edited webhook received. request_id={request_id}, store_name={store_name}, " + f"order_id={payload.get('id')}, order_number={payload.get('name')}", + store_name, + ) + return update_sales_order(payload, request_id=request_id, store_name=store_name) + + +def handle_order_updated(payload, request_id=None, store_name=None): + """Handle Shopify `orders/updated` webhook. + + This handler ONLY processes: + - Order notes + - Customer address changes (shipping, billing) + + All other changes (line items, prices, etc.) are ignored here. + Line item changes are handled via the dedicated `orders/edited` webhook. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + + try: + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + shopify_customer = order.get("customer", {}) if order.get("customer") else {} + customer_id = shopify_customer.get("id") + + # Check if Sales Order exists + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + + if not sales_order_name: + # Order doesn't exist - this shouldn't happen for orders/updated, but handle gracefully + create_shopify_log( + status="Info", + message=f"Order {order_number} not found in ERPNext for orders/updated webhook", + request_data=order, + ) + return + + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + # ONLY check notes + old_note = sales_order.get("note", "") or "" + new_note = order.get("note", "") or "" + if old_note.strip() != new_note.strip(): + changes["note"] = { + "old": old_note, + "new": new_note + } + + # ONLY check addresses (shipping and billing) + shipping_address = order.get("shipping_address", {}) + if shipping_address and sales_order.customer: + shipping_addr_doc = _get_customer_address_by_type(sales_order.customer, "Shipping") + + if shipping_addr_doc: + old_addr = { + "address_line1": shipping_addr_doc.get("address_line1", ""), + "address_line2": shipping_addr_doc.get("address_line2", ""), + "city": shipping_addr_doc.get("city", ""), + "state": shipping_addr_doc.get("state", ""), + "pincode": shipping_addr_doc.get("pincode", ""), + "country": shipping_addr_doc.get("country", ""), + "phone": shipping_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": shipping_address.get("address1", ""), + "address_line2": shipping_address.get("address2", ""), + "city": shipping_address.get("city", ""), + "state": shipping_address.get("province", ""), + "pincode": shipping_address.get("zip", ""), + "country": shipping_address.get("country", ""), + "phone": shipping_address.get("phone", ""), + } + + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["shipping_address"] = { + "old": old_addr, + "new": new_addr + } + + # Compare billing address + billing_address = order.get("billing_address") + billing_is_same_as_shipping = False + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + if shipping_address: + billing_address = shipping_address + billing_is_same_as_shipping = True + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + if billing_address and sales_order.customer: + billing_addr_doc = _get_customer_address_by_type(sales_order.customer, "Billing") + + if billing_addr_doc: + old_addr = { + "address_line1": billing_addr_doc.get("address_line1", ""), + "address_line2": billing_addr_doc.get("address_line2", ""), + "city": billing_addr_doc.get("city", ""), + "state": billing_addr_doc.get("state", ""), + "pincode": billing_addr_doc.get("pincode", ""), + "country": billing_addr_doc.get("country", ""), + "phone": billing_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": billing_address.get("address1", ""), + "address_line2": billing_address.get("address2", ""), + "city": billing_address.get("city", ""), + "state": billing_address.get("province", ""), + "pincode": billing_address.get("zip", ""), + "country": billing_address.get("country", ""), + "phone": billing_address.get("phone", ""), + } + + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["billing_address"] = { + "old": old_addr, + "new": new_addr, + "is_same_as_shipping": billing_is_same_as_shipping + } + + # Skip if no changes detected + if not changes: + return + + # Update addresses if changed + if "shipping_address" in changes or "billing_address" in changes: + shopify_customer["billing_address"] = billing_address + shopify_customer["shipping_address"] = shipping_address + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if customer.is_synced(): + customer.update_existing_addresses(shopify_customer) + + # Update note if changed + if "note" in changes: + frappe.db.set_value("Sales Order", sales_order_name, "note", new_note) + + # Log the changes + create_shopify_log( + status="Success", + message=f"Order {order_number} updated: {', '.join(changes.keys())}", + request_data=order, + response_data={"change_details": changes} + ) + + frappe.db.commit() + + except Exception as e: + frappe.log_error( + title=f"Failed to update order {order_number}", + message=f"Error: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + create_shopify_log( + status="Error", + message=f"Failed to update order {order_number}: {str(e)}", + request_data=order, + exception=e, + ) + raise def sync_sales_order(payload, request_id=None, store_name=None): @@ -969,14 +1138,14 @@ def update_sales_order(payload, request_id=None, store_name=None): # Compare customer email shopify_email = shopify_customer.get("email", "") if shopify_email and sales_order.customer: - # Get customer email from ERPNext Contact - contact = frappe.db.get_value( - "Contact", - {"link_doctype": "Customer", "link_name": sales_order.customer}, - ["email_id"], - as_dict=True - ) - old_email = contact.get("email_id", "") if contact else "" + # Get customer email from ERPNext Contact (using Dynamic Link) + contact_filters = [ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", sales_order.customer], + ["Dynamic Link", "parenttype", "=", "Contact"], + ] + contacts = frappe.get_all("Contact", filters=contact_filters, fields=["email_id"], limit=1) + old_email = contacts[0].get("email_id", "") if contacts else "" if old_email != shopify_email: changes["customer_email"] = { "old": old_email, @@ -986,13 +1155,14 @@ def update_sales_order(payload, request_id=None, store_name=None): # Compare customer phone shopify_phone = shopify_customer.get("phone", "") or shopify_customer.get("default_address", {}).get("phone", "") if shopify_phone and sales_order.customer: - # Get customer phone from ERPNext Contact - contact = frappe.db.get_value( - "Contact", - {"link_doctype": "Customer", "link_name": sales_order.customer}, - ["phone", "mobile_no"], - as_dict=True - ) + # Get customer phone from ERPNext Contact (using Dynamic Link) + contact_filters = [ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", sales_order.customer], + ["Dynamic Link", "parenttype", "=", "Contact"], + ] + contacts = frappe.get_all("Contact", filters=contact_filters, fields=["phone", "mobile_no"], limit=1) + contact = contacts[0] if contacts else None if contact: old_phone = contact.get("phone", "") or contact.get("mobile_no", "") if old_phone != shopify_phone: From 36d904169b471a485234fe363134fbaf395df2a9 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 4 Mar 2026 14:49:26 +0530 Subject: [PATCH 17/18] doctype error fixed --- ecommerce_integrations/shopify/order.py | 46 ++++++++++++++----------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index cb59833e3..cc6356f0d 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -854,27 +854,31 @@ def _normalize_address_for_comparison(addr_dict): } def _get_customer_address_by_type(customer_name, address_type): - """Get customer address of the specified type. - - Returns the address of the specified type for the customer. - If no address found, returns None. - """ - if not customer_name: - return None - - # Get address of the specified type - address = frappe.db.get_value( - "Address", - filters={ - "link_doctype": "Customer", - "link_name": customer_name, - "address_type": address_type - }, - fieldname=["name", "address_line1", "address_line2", "city", "state", "pincode", "country", "phone"], - as_dict=True - ) - - return address + if not customer_name: + return None + + addresses = frappe.get_list( + "Address", + filters=[ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", customer_name], + ["Dynamic Link", "parenttype", "=", "Address"], + ["Address", "address_type", "=", address_type], + ], + fields=[ + "name", + "address_line1", + "address_line2", + "city", + "state", + "pincode", + "country", + "phone", + ], + limit=1, + ) + + return addresses[0] if addresses else None def update_sales_order(payload, request_id=None, store_name=None): """Handle order updates from Shopify. From 9b5bfaa6dbf8926128f548ebb4f341da3857f977 Mon Sep 17 00:00:00 2001 From: Priyanshi Srivastava Date: Wed, 4 Mar 2026 15:41:35 +0530 Subject: [PATCH 18/18] note error fixed --- ecommerce_integrations/shopify/order.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index cc6356f0d..2a64ec8a2 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -204,8 +204,11 @@ def handle_order_updated(payload, request_id=None, store_name=None): customer.update_existing_addresses(shopify_customer) # Update note if changed - if "note" in changes: - frappe.db.set_value("Sales Order", sales_order_name, "note", new_note) + # ERPNext's standard Sales Order does not have a direct "note" field; + # the original integration stored Shopify notes as comments. + if "note" in changes and new_note: + sales_order_doc = frappe.get_doc("Sales Order", sales_order_name) + sales_order_doc.add_comment(text=f"Order Note: {new_note}") # Log the changes create_shopify_log(