From 563615c77ec2076af91711d63578dadb755b6771 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:56:26 +0200 Subject: [PATCH] fix(stock): recalculate billing status of returned delivery notes (backport #58953) (#58986) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/patches.txt | 3 +- ...e_returned_delivery_note_billing_status.py | 32 ++++++++++ .../delivery_note/test_delivery_note.py | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) 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 755d274227f..eec88dc76b5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,4 +449,5 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields -erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings +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 4ee8f3289af..458e22fb0bd 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1099,6 +1099,67 @@ class TestDeliveryNote(FrappeTestCase): 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.delivery_note 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.sales_order import (