From 032b922f0c06ea10a7c5c2af1e86795a72737024 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:01 +0530 Subject: [PATCH 1/3] fix(purchase): reject purchase returns where every item has zero quantity validate_returned_items() set items_returned=True whenever a row matched a valid item from the original document, even if its qty was 0. This let a Purchase Invoice, Purchase Receipt, or Subcontracting Receipt return be submitted with every line at qty=0 - a no-op document with no stock or financial effect that still consumed a document number and linked back to the original transaction. Scoped to the Purchase side only: items_returned now flips to True for Purchase Invoice/Purchase Receipt/Subcontracting Receipt only when qty (or received_qty) is actually negative, so an all-zero purchase return correctly hits the existing "At least one item should be entered with negative quantity" check. Sales Invoice, Delivery Note, and POS Invoice are unchanged. Also applies a corresponding check to the item_name-only fallback branch (for rows without an item_code - Item Code is not mandatory on Purchase Invoice Item), which previously bypassed this fix entirely and still set items_returned=True unconditionally regardless of quantity. For that branch specifically, only qty is checked (not received_qty): with no linked Item there's no accepted/rejected split, so received_qty carries no independent meaning and a qty=0 row must be rejected regardless of its value. (cherry picked from commit b63066ed4497a68bb6ca8ced6124caf4247e0f7c) --- erpnext/controllers/sales_and_purchase_return.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index a8e39963ae1..34b3387fe27 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -159,10 +159,21 @@ def validate_returned_items(doc): ): frappe.throw(_("Warehouse is mandatory")) - items_returned = True + if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"): + if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0: + items_returned = True + else: + items_returned = True elif d.item_name: - items_returned = True + if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"): + # No item_code here means no linked Item, so there's no accepted/rejected + # split to speak of - received_qty isn't a meaningful independent signal. + # Only a negative qty (i.e. a real negative billing amount) counts. + if flt(d.qty) < 0: + items_returned = True + else: + items_returned = True if not items_returned: frappe.throw(_("At least one item should be entered with negative quantity in return document")) From b0f2704bdecb28d9ed153a338b6e48a19b407f93 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:10 +0530 Subject: [PATCH 2/3] test(purchase): add coverage for zero-qty return rejection (cherry picked from commit cde2963da1875dc8b94e778342685c44b73bf4b0) # Conflicts: # erpnext/controllers/tests/test_sales_and_purchase_return.py --- .../tests/test_sales_and_purchase_return.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 erpnext/controllers/tests/test_sales_and_purchase_return.py diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py new file mode 100644 index 00000000000..4e000b869f2 --- /dev/null +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -0,0 +1,80 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSalesAndPurchaseReturn(ERPNextTestSuite): + @staticmethod + def _cancel_and_delete(doctype, name): + if not frappe.db.exists(doctype, name): + return + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=1) + + def test_sales_return_validates_against_original(self): + # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups + # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both + # converted from raw SQL here. Exercises them on both engines. + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) + self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) + + dn = create_delivery_note(qty=5) + self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) + + self.assertEqual(return_dn.is_return, 1) + self.assertEqual(return_dn.items[0].qty, -5) + + def test_purchase_invoice_zero_qty_return_is_rejected(self): + # A return with every item at qty 0 moves no stock and no value, so it must be + # rejected the same way a return with no items at all would be. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(qty=10) + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + + self.assertRaises(frappe.ValidationError, return_pi.save) + + def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self): + # Item Code is not mandatory on Purchase Invoice Item - a row can have only an + # item_name (e.g. a free-text/non-stock line). Such rows fall through to the + # item_name-only branch, which must also reject an all-zero-qty return instead + # of unconditionally treating the row as returned. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True) + pi.items[0].item_code = "" + pi.save() + pi.submit() + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + item_name="_Test Item", + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + return_pi.items[0].item_code = "" + + self.assertRaises(frappe.ValidationError, return_pi.save) From 10229700c05bfb3b21ddf2afadf5db9ea656d27c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:42:10 +0530 Subject: [PATCH 3/3] test(purchase): drop unrelated sales-return test from the backport test_sales_return_validates_against_original came in with the new file, not with the change being backported. It covers a raw-SQL to query-builder conversion that only exists on develop, and it imports erpnext.stock.doctype.delivery_note.mapper, a module version-16-hotfix does not have. --- .../tests/test_sales_and_purchase_return.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 4e000b869f2..5a8a07b7502 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -16,28 +16,6 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): doc.cancel() frappe.delete_doc(doctype, name, force=1) - def test_sales_return_validates_against_original(self): - # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups - # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both - # converted from raw SQL here. Exercises them on both engines. - from erpnext.stock.doctype.delivery_note.mapper import make_sales_return - from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note - from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - - se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) - self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) - - dn = create_delivery_note(qty=5) - self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) - - return_dn = make_sales_return(dn.name) - return_dn.insert() - return_dn.submit() - self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) - - self.assertEqual(return_dn.is_return, 1) - self.assertEqual(return_dn.items[0].qty, -5) - def test_purchase_invoice_zero_qty_return_is_rejected(self): # A return with every item at qty 0 moves no stock and no value, so it must be # rejected the same way a return with no items at all would be.