Commera

Commera

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Step 9: Keep status in sync

In this step, your plugin reads each open order's status from Qikink every hour. When Qikink reports an AWB, your plugin records a shipment on the order. When Qikink reports Delivered, your plugin marks the drop-ship Purchase Order as delivered. Commera then moves the order to Shipped and Delivered, and the shopper sees the tracking link.

Code for this step: feat: keep qikink status and shipments in sync. Later tidied in refactor: call frappe's request helpers directly and parse replies with parse_json and refactor: use frappe utilities across the app. The code on this page is the tidied version.

sequenceDiagram
    participant Job as Hourly job
    participant Qikink
    participant Plugin as commera_qikink
    participant Commera
    Job->>Plugin: sync_open_orders
    Plugin->>Qikink: GET /api/order?page_no=N
    Qikink-->>Plugin: status, awb, tracking_link
    Plugin->>Plugin: save Qikink Status
    Plugin->>Commera: record_shipment with awb and tracking_url
    Plugin->>Plugin: on Delivered, deliver the drop-ship Purchase Order

What you'll build

  • sync_open_orders, an hourly job.
  • A Refresh Qikink status action for one order.
  • deliver_drop_ship_order, which marks the Purchase Order as delivered.

1. Page through Qikink's orders

Qikink has no webhooks. Change find_order in the client to use a generator, so the sync can share it:

def find_order(self, order_number: str) -> dict | None:
	return next(
		(order for order in self.iter_orders(MAX_ORDER_PAGES) if get_order_number(order) == order_number),
		None,
	)

def iter_orders(self, max_pages: int):
	for page_no in range(1, max_pages + 1):
		orders = self.request(make_get_request, "/api/order", params={"page_no": page_no})
		if not orders:
			return
		yield from orders

Qikink returns 10 orders a page, newest first. Stop reading pages when every open order is found.

2. Save the status and the shipment

Create commera_qikink/order_status.py. Map Qikink's courier statuses to Shipping Request statuses. The commit has the full table.

import frappe
from commera.sdk import as_plugin_user
from commera.sdk import orders as commera_orders
from frappe import _
from frappe.utils.data import cstr

from commera_qikink.client import Qikink, get_order_number
from commera_qikink.purchase_orders import deliver_drop_ship_order

MAX_SYNC_PAGES = 20
FINAL_STATUSES = {"delivered", "returned", "partially returned", "cancelled", "archived", "lost"}
SHIPMENT_STATUSES = {
	"picked up": "In Transit",
	"in-transit": "In Transit",
	"out for delivery": "Out For Delivery",
	"not attempted": "Undelivered",
	"delivered": "Delivered",
	"rto initiated": "RTO",
	"returned": "RTO",
	"lost": "Lost",
}


def save_order(sales_order: str, qikink_order: dict):
	status = cstr(qikink_order.get("status")).strip()
	if status:
		frappe.db.set_value(
			"Sales Order", sales_order, "commera_qikink_status", status, update_modified=False
		)

	shipping = qikink_order.get("shipping") or {}
	if awb := cstr(shipping.get("awb")).strip():
		with as_plugin_user("commera_qikink"):
			commera_orders.record_shipment(
				sales_order,
				awb=awb,
				carrier=cstr(shipping.get("courier_provider_name")).strip() or None,
				tracking_url=cstr(shipping.get("tracking_link")).strip() or None,
				status=SHIPMENT_STATUSES.get(status.casefold(), "Ready To Ship"),
			)

	if status.casefold() == "delivered":
		deliver_drop_ship_order(sales_order)

record_shipment without a provider records a partner shipment. It makes or updates the order's Shipping Request, keyed on the AWB. It makes no Delivery Note. Commera then moves the order status and fires order_fulfilled and order_delivered. The shopper's order page shows the carrier, the AWB and a Track your parcel link.

3. Sync the open orders

SAVEPOINT = "commera_qikink_status"


def sync_open_orders():
	if open_orders := get_open_orders():
		sync_orders(open_orders, isolate_errors=True)


def get_open_orders() -> dict[str, str]:
	rows = frappe.get_all(
		"Sales Order",
		filters={"docstatus": 1, "commera_qikink_order_number": ["is", "set"]},
		fields=["name", "commera_qikink_order_number", "commera_qikink_status"],
	)
	return {
		row.commera_qikink_order_number: row.name
		for row in rows
		if cstr(row.commera_qikink_status).casefold() not in FINAL_STATUSES
	}


def sync_orders(open_orders: dict[str, str], isolate_errors: bool = False) -> list[str]:
	pending = dict(open_orders)
	synced = []
	for qikink_order in Qikink().iter_orders(MAX_SYNC_PAGES):
		if sales_order := pending.pop(get_order_number(qikink_order), None):
			if isolate_errors:
				save_order_or_log(sales_order, qikink_order)
			else:
				save_order(sales_order, qikink_order)
			synced.append(sales_order)
		if not pending:
			break
	return synced


def save_order_or_log(sales_order: str, qikink_order: dict):
	frappe.db.savepoint(SAVEPOINT)
	try:
		save_order(sales_order, qikink_order)
	except Exception:
		frappe.db.rollback(save_point=SAVEPOINT)
		frappe.log_error(
			title=_("Qikink status sync failed for {0}").format(sales_order),
			reference_doctype="Sales Order",
			reference_name=sales_order,
		)

The savepoint keeps one bad order from undoing the others. 20 pages a run stays well inside Qikink's limit of 60 requests a minute.

Register the job in hooks.py:

scheduler_events = {"hourly": ["commera_qikink.order_status.sync_open_orders"]}

4. Deliver the drop-ship Purchase Order

In purchase_orders.py, mark the Qikink lines as received, as the Deliver (Dropship) button in Desk does:

def deliver_drop_ship_order(sales_order: str):
	for name in get_drop_ship_purchase_orders(sales_order):
		purchase_order = frappe.get_doc("Purchase Order", name)
		undelivered = [
			{"name": item.name, "qty_change": item.qty - item.received_qty}
			for item in purchase_order.items
			if item.sales_order == sales_order and item.delivered_by_supplier and item.qty > item.received_qty
		]
		if undelivered:
			with as_plugin_user("commera_qikink"):
				purchase_order.update_dropship_received_qty(undelivered)

Add get_drop_ship_purchase_orders below it. One query finds the submitted Purchase Orders to the Qikink supplier that have a line for this Sales Order:

def get_drop_ship_purchase_orders(sales_order: str) -> list[str]:
	return frappe.get_all(
		"Purchase Order",
		filters=[
			["Purchase Order Item", "sales_order", "=", sales_order],
			["supplier", "=", frappe.db.get_single_value("Qikink Settings", "supplier")],
			["docstatus", "=", 1],
		],
		pluck="name",
		distinct=True,
	)

The ["Purchase Order Item", "sales_order", ...] filter is a child table filter. distinct=True returns each Purchase Order once, also when it has many lines for the order.

Move get_purchase_orders from order_card.py to purchase_orders.py, and import it in order_card.py. The order card still uses it.

5. Add the Refresh Qikink status action

In order_status.py:

def refresh_order_status(sales_order: str) -> str:
	order_number = frappe.db.get_value("Sales Order", sales_order, "commera_qikink_order_number")
	if not order_number:
		frappe.throw(_("Order {0} has not been sent to Qikink.").format(sales_order))
	if not sync_orders({order_number: sales_order}):
		frappe.throw(
			_("Qikink does not list order {0} among its {1} most recent orders.").format(
				order_number, MAX_SYNC_PAGES * 10
			)
		)
	return frappe.db.get_value("Sales Order", sales_order, "commera_qikink_status")

In api.py:

@frappe.whitelist(methods=["POST"])
def refresh_order_status(name: str) -> str:
	frappe.has_permission("Sales Order", "write", doc=name, throw=True)
	return _("Qikink status: {0}").format(order_status.refresh_order_status(name))

In conditions.py:

def is_sent_order(doctype: str, name: str) -> bool:
	return bool(frappe.db.get_value("Sales Order", name, "commera_qikink_order_number"))

In apps/commera_qikink, run bench commera add order-action refresh-qikink-status. Replace the commera/order/actions/refresh-qikink-status/index.vue it writes with this, and delete the refresh_qikink_status_order stub it adds to api.py: this action calls refresh_order_status.

<script>
export const plugin = {
	label: 'Refresh Qikink status',
	icon: 'refresh-cw',
	condition: 'commera_qikink.conditions.is_sent_order',
	method: 'commera_qikink.api.refresh_order_status',
};
</script>

Check it works

Sandbox orders stay On Hold and never ship. To test the shipped and delivered paths, patch Qikink's reply in bench --site <site> console. Put your own order's number after 654367_, and pick an order that is not Delivered yet, because the sync skips finished orders:

from unittest.mock import patch

from commera_qikink import order_status

fake_order = {
	"number": "654367_202600077",
	"status": "Picked Up",
	"shipping": {
		"awb": "TESTAWB0077",
		"courier_provider_name": "Delhivery",
		"tracking_link": "https://courierupdates.com/?awb=TESTAWB0077",
	},
}

with patch("commera_qikink.client.Qikink.iter_orders", return_value=iter([fake_order])):
	order_status.sync_open_orders()

frappe.db.commit()
  1. Run the snippet. Open the order in /commera. The Qikink card shows Picked Up and a shipment row with a Track link. The order reads Shipped.
  2. Run it again with "status": "Delivered" and the same AWB. The order reads Delivered, and the Purchase Order shows 100% received.
  3. Open the order on the storefront as the shopper. Delivery Tracking shows the carrier, the AWB and Track your parcel.
The Qikink card with status Delivered. The second shipment row, Delhivery · Delivered, has the Track link

The card lists every shipment of the order. In this shot, the first row is an earlier In Transit test shipment. The row that this step made is the second one, Delivered.

The shopper's order page with Delivery Tracking, the AWB and Track your parcel

Next steps

Last updated 3 days ago
Was this helpful?
Thanks!