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")) 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..5a8a07b7502 --- /dev/null +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -0,0 +1,58 @@ +# 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_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)