From aa71cd695b0e042d9432565edd761eed4ec7298b Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Thu, 30 Jul 2026 12:11:11 +0530 Subject: [PATCH 1/3] fix(sales): reject sales 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 Sales Invoice, Delivery Note, or POS Invoice 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 Sales side only: items_returned now flips to True for Sales Invoice/Delivery Note/POS Invoice only when qty (or received_qty) is actually negative, so an all-zero sales return correctly hits the existing "At least one item should be entered with negative quantity" check. Purchase Invoice, Purchase Receipt, and Subcontracting Receipt are unchanged. (cherry picked from commit a3e9d13da30089467441cf48586e5a6f3e211feb) --- erpnext/controllers/sales_and_purchase_return.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 34b3387fe27..f8c54d79043 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -159,7 +159,14 @@ def validate_returned_items(doc): ): frappe.throw(_("Warehouse is mandatory")) - if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"): + if doc.doctype in ( + "Purchase Invoice", + "Purchase Receipt", + "Subcontracting Receipt", + "Sales Invoice", + "Delivery Note", + "POS Invoice", + ): if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0: items_returned = True else: From f2a53247c5ccfcd26470c05f97cc740211a266f9 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 12:01:39 +0530 Subject: [PATCH 2/3] test(sales): add coverage for zero-qty return rejection Greptile flagged that the sales-side zero-qty-return fix had no dedicated test proving the behavior - the existing suite happened to pass, but nothing specifically asserted that an all-zero return is rejected while a normal negative-qty return still succeeds. Adds two tests covering the doctypes that rely entirely on this check (no other guard covers them for a non-stock-effect return): - Delivery Note return with qty 0 -> rejected - Sales Invoice return with qty 0 (no update_stock) -> rejected POS Invoice is not covered separately here since it always runs with update_stock=1, which is already guarded by the pre-existing validate_zero_qty_for_return_invoices_with_stock check regardless of this fix. (cherry picked from commit 732c884633acc8ed5862cded0f18f725457a6439) --- .../tests/test_sales_and_purchase_return.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 5a8a07b7502..0448faf3817 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -56,3 +56,35 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): return_pi.items[0].item_code = "" self.assertRaises(frappe.ValidationError, return_pi.save) + + def test_delivery_note_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.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.items[0].qty = 0 + + self.assertRaises(frappe.ValidationError, return_dn.insert) + + def test_sales_invoice_zero_qty_return_is_rejected(self): + # Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on + # every row must be rejected, not silently accepted as a no-op credit note. + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + si = create_sales_invoice(qty=10) + self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name) + + return_si = make_return_doc(si.doctype, si.name) + return_si.items[0].qty = 0 + + self.assertRaises(frappe.ValidationError, return_si.save) From af4aea171b772e2b929401784b7272d371be359d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 13:29:40 +0530 Subject: [PATCH 3/3] test(sales): import make_sales_return from delivery_note on version-16-hotfix --- erpnext/controllers/tests/test_sales_and_purchase_return.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 0448faf3817..e60ca29fed3 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -60,7 +60,7 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): def test_delivery_note_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.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.delivery_note 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