From be8208e7cb15afd9a84658d6183b240c491f8e37 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:56:28 +0200 Subject: [PATCH] fix(stock): recalculate billing status of returned delivery notes (#58953) --- erpnext/patches.txt | 1 + ...e_returned_delivery_note_billing_status.py | 32 ++++++++++ .../delivery_note/test_delivery_note.py | 61 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7ce804eea42..788f15bdea9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -521,3 +521,4 @@ erpnext.patches.v16_0.set_secondary_item_valuation_type erpnext.patches.v16_0.append_fieldname_to_pos_search_fields erpnext.patches.v16_0.set_supplier_quotation_order_status erpnext.patches.v16_0.recalculate_holiday_list_totals +erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status diff --git a/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py new file mode 100644 index 00000000000..c0ba3ac252d --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py @@ -0,0 +1,32 @@ +import frappe + + +def execute(): + """Recalculate billing status of Delivery Notes left open by a return. + + Returning the uninvoiced qty of a Delivery Note did not recalculate the original + Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice. + """ + dn = frappe.qb.DocType("Delivery Note") + dn_item = frappe.qb.DocType("Delivery Note Item") + + delivery_notes = ( + frappe.qb.from_(dn) + .inner_join(dn_item) + .on(dn_item.parent == dn.name) + .select(dn.name) + .distinct() + .where( + (dn.docstatus == 1) + & (dn.is_return == 0) + & dn.status.isin(["To Bill", "Partially Billed"]) + & (dn_item.returned_qty > 0) + ) + .run(pluck=True) + ) + + for name in delivery_notes: + doc = frappe.get_doc("Delivery Note", name) + doc.update_billing_percentage(update_modified=False) + doc.load_from_db() + doc.set_status(update=True, update_modified=False) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 0d8b483ef6f..d5cf7c96512 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1110,6 +1110,67 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(dn.per_billed, 50) self.assertEqual(dn.status, "Partially Billed") + def test_billing_status_repair_patch(self): + """Returns submitted before #58869 left the original Delivery Note's per_billed stale. + + The repair patch recalculates such notes: a directly invoiced one whose remaining + qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back + to To Bill. + """ + from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + + # Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed + make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100) + dn = create_delivery_note(qty=5) + + si = make_sales_invoice(dn.name) + si.items[0].qty = 2 + si.insert() + si.submit() + + dn_return = make_sales_return(dn.name) + dn_return.items[0].qty = -3 + dn_return.insert() + # Mimic the submit request, which reconstructs the document from client data. + frappe.get_doc(dn_return.as_dict()).submit() + + dn.load_from_db() + self.assertEqual(dn.items[0].returned_qty, 3) + self.assertEqual(dn.per_billed, 100) + + # Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled + so = make_sales_order(qty=10) + so_dn = create_dn_against_so(so.name, delivered_qty=5) + + so_dn_return = make_sales_return(so_dn.name) + so_dn_return.items[0].qty = -2 + so_dn_return.insert() + frappe.get_doc(so_dn_return.as_dict()).submit() + + so_dn.load_from_db() + self.assertEqual(so_dn.items[0].returned_qty, 2) + self.assertEqual(so_dn.per_billed, 0) + + # Mimic the state left behind by a return submitted before the fix + for name, per_billed in ((dn.name, 40), (so_dn.name, 50)): + frappe.db.set_value( + "Delivery Note", + name, + {"per_billed": per_billed, "status": "Partially Billed"}, + update_modified=False, + ) + + patch.execute() + + dn.load_from_db() + self.assertEqual(dn.per_billed, 100) + self.assertEqual(dn.status, "Completed") + + so_dn.load_from_db() + self.assertEqual(so_dn.per_billed, 0) + self.assertEqual(so_dn.status, "To Bill") + def test_dn_billing_status_case2(self): # SO -> SI and SO -> DN1, DN2 from erpnext.selling.doctype.sales_order.mapper import (