From b63066ed4497a68bb6ca8ced6124caf4247e0f7c Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:01 +0530 Subject: [PATCH 1/2] 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. --- 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 85af0df6321..3148834ebec 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -160,10 +160,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 cde2963da1875dc8b94e778342685c44b73bf4b0 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:10 +0530 Subject: [PATCH 2/2] test(purchase): add coverage for zero-qty return rejection --- .../tests/test_sales_and_purchase_return.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 97a33281cc0..4e000b869f2 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -37,3 +37,44 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): 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)