Skip to content

Commit

Permalink
feat: shopify revamp with multiple instances and cross-app hooks (#15)
Browse files Browse the repository at this point in the history
* feat: add hooks for shopify and shipstation to avoid duplicates

* style: add function annotations

* feat: allow multiple Shopify instances

* fix: add missing field in Shopify Settings

* fix: Shopify order number

* fix: sync logic for individual shops

* fix: item update errors

* fix: use Shopify store name to verify document

* fix: remove Shopify hooks

* patch: update patch to only update Payout sales documents

* fix: add no copy to sync date and webhooks

* fix: use customer's default address if no other addresses are found

* fix: make order number fields non-translatable

* fix: requested changes

* fix: create a default item if no item is found via the API
  • Loading branch information
Alchez authored Apr 29, 2021
1 parent a8f650c commit 7d3e25e
Show file tree
Hide file tree
Showing 22 changed files with 1,279 additions and 640 deletions.
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
frappe
erpnext
ShopifyAPI==8.2.0
ShopifyAPI==8.4.1
97 changes: 51 additions & 46 deletions shopify_integration/customers.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
from typing import TYPE_CHECKING

import frappe
from frappe import _
from frappe.utils import cstr

if TYPE_CHECKING:
from erpnext.selling.doctype.customer.customer import Customer
from shopify import Address, Customer as ShopifyCustomer, Order

def validate_customer(shopify_order):
customer_id = shopify_order.get("customer", {}).get("id")
if customer_id and not frappe.db.get_value("Customer", {"shopify_customer_id": customer_id}, "name"):
create_customer(shopify_order.get("customer"))

def validate_customer(shop_name: str, shopify_order: "Order"):
customer = shopify_order.attributes.get("customer", frappe._dict())
if customer.id and not frappe.db.get_value("Customer", {"shopify_customer_id": customer.id}, "name"):
create_customer(shop_name, customer)

def create_customer(shopify_customer):
from frappe.utils.nestedset import get_root_of

shopify_settings = frappe.get_single("Shopify Settings")
def create_customer(shop_name: str, shopify_customer: "ShopifyCustomer"):
from frappe.utils.nestedset import get_root_of

if shopify_customer.get("first_name"):
first_name = cstr(shopify_customer.get("first_name"))
last_name = cstr(shopify_customer.get("last_name"))
if shopify_customer.attributes.get("first_name"):
first_name = cstr(shopify_customer.first_name)
last_name = cstr(shopify_customer.last_name)
cust_name = f"{first_name} {last_name}"
else:
cust_name = shopify_customer.get("email")
cust_name = shopify_customer.email

try:
customer = frappe.get_doc({
customer: "Customer" = frappe.get_doc({
"doctype": "Customer",
"name": shopify_customer.get("id"),
"name": shopify_customer.id,
"customer_name": cust_name,
"shopify_customer_id": shopify_customer.get("id"),
"customer_group": shopify_settings.customer_group,
"shopify_customer_id": shopify_customer.id,
"customer_group": frappe.db.get_value("Shopify Settings", shop_name, "customer_group"),
"territory": get_root_of("Territory"),
"customer_type": _("Individual")
})
Expand All @@ -42,41 +46,42 @@ def create_customer(shopify_customer):
raise e


def create_customer_address(customer, shopify_customer):
if not shopify_customer.get("addresses"):
return

for i, address in enumerate(shopify_customer.get("addresses")):
address_title, address_type = get_address_title_and_type(customer.customer_name, i)
try:
frappe.get_doc({
"doctype": "Address",
"shopify_address_id": address.get("id"),
"address_title": address_title,
"address_type": address_type,
"address_line1": address.get("address1") or "Address 1",
"address_line2": address.get("address2"),
"city": address.get("city") or "City",
"state": address.get("province"),
"pincode": address.get("zip"),
"country": address.get("country"),
"phone": address.get("phone"),
"email_id": shopify_customer.get("email"),
"links": [{
"link_doctype": "Customer",
"link_name": customer.name
}]
}).insert(ignore_mandatory=True)
except Exception as e:
raise e


def get_address_title_and_type(customer_name, index):
def create_customer_address(customer: "Customer", shopify_customer: "ShopifyCustomer"):
addresses = shopify_customer.attributes.get("addresses") or []

if not addresses:
default_address = shopify_customer.attributes.get("default_address")
if default_address:
addresses.append(default_address)

address: "Address"
for index, address in enumerate(addresses):
frappe.get_doc({
"doctype": "Address",
"shopify_address_id": address.id,
"address_title": get_address_title(customer.customer_name, index),
"address_type": "Billing",
"address_line1": address.address1 or "Address 1",
"address_line2": address.address2,
"city": address.city or "City",
"state": address.province,
"pincode": address.zip,
"country": address.country,
"phone": address.phone,
"email_id": shopify_customer.email,
"links": [{
"link_doctype": "Customer",
"link_name": customer.name
}]
}).insert(ignore_mandatory=True)


def get_address_title(customer_name: str, index: int):
address_type = _("Billing")
address_title = customer_name

address_name = f"{customer_name.strip()}-{address_type}"
if frappe.db.exists("Address", address_name):
address_title = f"{customer_name.strip()}-{index}"

return address_title, address_type
return address_title
138 changes: 101 additions & 37 deletions shopify_integration/fulfilments.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,118 @@
from typing import TYPE_CHECKING, List

import frappe
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from frappe.utils import cint, cstr, getdate
from frappe.utils import cint, getdate

from shopify_integration.products import get_item_code
from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log
from shopify_integration.utils import get_shopify_document

if TYPE_CHECKING:
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
from erpnext.stock.doctype.delivery_note.delivery_note import DeliveryNote
from erpnext.stock.doctype.delivery_note_item.delivery_note_item import DeliveryNoteItem
from shopify import Fulfillment, LineItem, Order
from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings


def prepare_delivery_note(shop_name: str, order: "Order", log_id: str = str()):
"""
Webhook endpoint to process deliveries for Shopify orders.
Args:
shop_name (str): The name of the Shopify configuration for the store.
order (Order): The Shopify order data.
log_id (str, optional): The ID of an existing Shopify Log.
Defaults to an empty string.
"""

def prepare_delivery_note(order, request_id=None):
frappe.set_user("Administrator")
frappe.flags.request_id = request_id
create_shopify_delivery(shop_name=shop_name, shopify_order=order, log_id=log_id, rollback=True)

try:
sales_order = get_shopify_document("Sales Order", cstr(order.get("id")))
if sales_order:
create_delivery_notes(order, sales_order)
make_shopify_log(status="Success", response_data=order)
except Exception as e:
make_shopify_log(status="Error", response_data=order, exception=e, rollback=True)

def create_shopify_delivery(
shop_name: str,
shopify_order: "Order",
sales_order: "SalesOrder" = None,
log_id: str = str(),
rollback: bool = False
):
"""
Create Delivery Note documents for each Shopify delivery.
Args:
shop_name (str): The name of the Shopify configuration for the store.
shopify_order (Order): The Shopify order data.
sales_order (SalesOrder, optional): The reference Sales Order document for the
Shopify order. Defaults to None.
log_id (str, optional): The ID of an existing Shopify Log. Defaults to an empty string.
rollback (bool, optional): If an error occurs while processing the order, all
transactions will be rolled back, if this field is `True`. Defaults to False.
def create_shopify_delivery(order, so, request_id=None):
frappe.flags.request_id = request_id
Returns:
list: The list of created Delivery Note documents, if any, otherwise an empty list.
"""

if not order.get("fulfillments"):
return
if not shopify_order.attributes.get("fulfillments"):
return []
if not sales_order:
sales_order = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=shopify_order)
if not sales_order or sales_order.docstatus != 1:
return []

frappe.flags.log_id = log_id
try:
delivery_notes = create_delivery_notes(order, so)
delivery_notes = create_delivery_notes(shop_name, shopify_order, sales_order)
except Exception as e:
make_shopify_log(status="Error", response_data=order, exception=e)
return
make_shopify_log(status="Error", response_data=shopify_order.to_dict(), exception=e, rollback=rollback)
return []
else:
make_shopify_log(status="Success", response_data=shopify_order.to_dict())
return delivery_notes


def create_delivery_notes(shopify_order, so):
shopify_settings = frappe.get_doc("Shopify Settings")
def create_delivery_notes(
shop_name: str,
shopify_order: "Order",
sales_order: "SalesOrder"
) -> List["DeliveryNote"]:
"""
Helper function to create Delivery Note documents for a Shopify order.
Args:
shop_name (str): The name of the Shopify configuration for the store.
shopify_order (Order): The Shopify order data.
sales_order (SalesOrder): The reference Sales Order document for the Shopify order.
Returns:
list: The list of created Delivery Note documents, if any, otherwise an empty list.
"""

shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name)
if not cint(shopify_settings.sync_delivery_note):
return
return []

delivery_notes = []
for fulfillment in shopify_order.get("fulfillments"):
if so.docstatus == 1 and not frappe.db.get_value("Delivery Note",
{"shopify_fulfillment_id": fulfillment.get("id")}, "name"):

dn = make_delivery_note(so.name)
dn.shopify_order_id = shopify_order.get("id")
dn.shopify_order_number = shopify_order.get("name")
dn.shopify_fulfillment_id = fulfillment.get("id")
dn.set_posting_time = 1
dn.posting_date = getdate(fulfillment.get("created_at"))
dn.naming_series = shopify_settings.delivery_note_series or "DN-Shopify-"
dn.items = get_fulfillment_items(dn.items, fulfillment.get("line_items"))
fulfillment: "Fulfillment"
for fulfillment in shopify_order.attributes.get("fulfillments"):
existing_delivery = frappe.db.get_value("Delivery Note",
{"shopify_fulfillment_id": fulfillment.id}, "name")

if not existing_delivery:
dn: "DeliveryNote" = make_delivery_note(sales_order.name)
dn.update({
"shopify_settings": shopify_settings.name,
"shopify_order_id": shopify_order.id,
"shopify_order_number": shopify_order.attributes.get("order_number"),
"shopify_fulfillment_id": fulfillment.id,
"set_posting_time": True,
"posting_date": getdate(fulfillment.attributes.get("created_at")),
"naming_series": shopify_settings.delivery_note_series or "DN-Shopify-",
})

update_fulfillment_items(dn.items, fulfillment.attributes.get("line_items"))

dn.flags.ignore_mandatory = True
dn.save()
dn.submit()
Expand All @@ -62,8 +122,12 @@ def create_delivery_notes(shopify_order, so):
return delivery_notes


def get_fulfillment_items(dn_items, fulfillment_items):
# TODO: figure out a better way to add items without setting valuation rate to zero
return [dn_item.update({"qty": item.get("quantity"), "allow_zero_valuation_rate": 1})
for item in fulfillment_items for dn_item in dn_items
if get_item_code(item) == dn_item.item_code]
def update_fulfillment_items(
dn_items: List["DeliveryNoteItem"],
fulfillment_items: List["LineItem"]
):
for dn_item in dn_items:
for item in fulfillment_items:
if get_item_code(item) == dn_item.item_code:
# TODO: figure out a better way to add items without setting valuation rate to zero
dn_item.update({"qty": item.attributes.get("quantity"), "allow_zero_valuation_rate": 1})
2 changes: 1 addition & 1 deletion shopify_integration/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@

scheduler_events = {
"daily_long": [
"shopify_integration.payouts.sync_payouts_from_shopify"
"shopify_integration.payouts.sync_all_payouts"
]
}

Expand Down
Loading

0 comments on commit 7d3e25e

Please sign in to comment.