fix(selling): reset ordered_qty when a cancelled Sales Order is unlinked from its Purchase Orders (backport #59120)

Unlinking a cancelled Sales Order from its Purchase Orders resets ordered_qty.

(cherry picked from commit ded6df3614)
This commit is contained in:
Mihir Kandoi
2026-10-15 14:13:51 +05:30
parent 775d5ffa14
commit b730d5f9c9
2 changed files with 61 additions and 21 deletions
+33 -21
View File
@@ -2168,32 +2168,44 @@ class AccountsController(TransactionBase):
self.unlink_ref_doc_from_po()
def unlink_ref_doc_from_po(self):
so_items = []
for item in self.items:
so_items.append(item.name)
so_items = [item.name for item in self.items]
filters = {
"sales_order": self.name,
"sales_order_item": ["in", so_items],
"docstatus": ["<", 2],
}
linked_po = list(
set(
frappe.get_all(
"Purchase Order Item",
filters={
"sales_order": self.name,
"sales_order_item": ["in", so_items],
"docstatus": ["<", 2],
},
pluck="parent",
)
linked_po_items = frappe.get_all(
"Purchase Order Item", filters=filters, fields=["parent", "sales_order_item"]
)
if not linked_po_items:
return
frappe.db.set_value("Purchase Order Item", filters, {"sales_order": None, "sales_order_item": None})
self.update_ordered_qty_in_items({item.sales_order_item for item in linked_po_items})
linked_po = sorted({item.parent for item in linked_po_items})
frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po)))
def update_ordered_qty_in_items(self, so_items: set[str]):
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
ordered_qty = dict(
frappe.qb.from_(purchase_order_item)
.select(purchase_order_item.sales_order_item, Sum(purchase_order_item.stock_qty))
.where(
purchase_order_item.sales_order_item.isin(list(so_items))
& (purchase_order_item.docstatus == 1)
)
.groupby(purchase_order_item.sales_order_item)
.run()
)
if linked_po:
frappe.db.set_value(
"Purchase Order Item",
{"sales_order": self.name, "sales_order_item": ["in", so_items], "docstatus": ["<", 2]},
{"sales_order": None, "sales_order_item": None},
)
items_by_ordered_qty = defaultdict(list)
for so_item in so_items:
items_by_ordered_qty[flt(ordered_qty.get(so_item))].append(so_item)
frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po)))
for qty, items in items_by_ordered_qty.items():
frappe.db.set_value("Sales Order Item", {"name": ["in", items]}, "ordered_qty", qty)
def get_tax_map(self):
tax_map = {}
@@ -1832,6 +1832,34 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertEqual(so.packed_items[0].ordered_qty, 2)
self.assertEqual(so.packed_items[1].ordered_qty, 2)
def test_ordered_qty_is_reset_when_cancelled_sales_order_is_unlinked(self):
"""Cancelling a Sales Order unlinks its Purchase Orders, so `ordered_qty` must be recomputed."""
selected_items = [{"item_code": "_Test Item", "supplier": "_Test Supplier"}]
so = make_sales_order(item_code="_Test Item", qty=10)
purchase_order = make_purchase_order(so.name, selected_items=selected_items)
purchase_order.supplier = "_Test Supplier"
purchase_order.schedule_date = add_days(nowdate(), 1)
purchase_order.submit()
so.reload()
self.assertEqual(so.items[0].ordered_qty, 10)
so.cancel()
so.reload()
self.assertEqual(so.items[0].ordered_qty, 0)
amended_so = frappe.copy_doc(so)
amended_so.amended_from = so.name
amended_so.docstatus = 0
amended_so.insert()
amended_so.submit()
self.assertEqual(amended_so.items[0].ordered_qty, 0)
new_purchase_order = make_purchase_order(amended_so.name, selected_items=selected_items)
self.assertEqual(new_purchase_order.items[0].qty, 10)
def test_reserved_qty_for_closing_so(self):
bin = frappe.get_all(
"Bin",