From 114ba42850e7f31f37fe07c0e87a875560508fb7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 12:15:45 +0530 Subject: [PATCH] fix(selling): bill re-delivered sales order quantities --- erpnext/selling/doctype/sales_order/mapper.py | 56 ++++++++++++------ .../doctype/sales_order/test_sales_order.py | 59 +++++++++++++++++++ 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index 8c28e9672b9..fecf67cc642 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -421,6 +421,13 @@ def make_delivery_note( return target_doc +def get_qty_net_of_returns(so_item) -> float: + """Return the ordered quantity billable after returns and re-deliveries.""" + qty = flt(so_item.qty) + + return min(qty, max(qty - flt(so_item.returned_qty), flt(so_item.delivered_qty))) + + @frappe.whitelist() def make_sales_invoice( source_name: str, @@ -434,10 +441,30 @@ def make_sales_invoice( # 0 qty is accepted, as the qty is uncertain for some items has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + pending_qty_by_item = {} def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + def get_billed_qty(so_item_name): + table = frappe.qb.DocType("Sales Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.so_detail == so_item_name)) + ) + return flt(query.run(pluck="qty")[0]) + + def get_pending_qty(source): + if source.name not in pending_qty_by_item: + billable_qty = get_qty_net_of_returns(source) + if source.qty and source.billed_amt: + billable_qty -= get_billed_qty(source.name) + + pending_qty_by_item[source.name] = max(flt(billable_qty), 0) + + return pending_qty_by_item[source.name] + def postprocess(source, target): set_missing_values(source, target) # Get the advance paid Journal Entries in Sales Invoice Advance @@ -476,15 +503,6 @@ def make_sales_invoice( target.debit_to = get_party_account("Customer", source.customer, source.company) def update_item(source, target, source_parent): - def get_billed_qty(so_item_name): - table = frappe.qb.DocType("Sales Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty).as_("qty")) - .where((table.docstatus == 1) & (table.so_detail == so_item_name)) - ) - return query.run(pluck="qty")[0] or 0 - if source_parent.has_unit_price_items: # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is pending_amount = flt(source.amount) - flt(source.billed_amt) @@ -493,11 +511,7 @@ def make_sales_invoice( target.amount = flt(source.amount) - flt(source.billed_amt) target.base_amount = target.amount * flt(source_parent.conversion_rate) - target.qty = ( - source.qty - get_billed_qty(source.name) - if (source.qty and source.billed_amt) - else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) - ) + target.qty = source.qty if is_unit_price_row(source) else get_pending_qty(source) if source_parent.project: target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center") @@ -575,13 +589,17 @@ def make_sales_invoice( "parent": "sales_order", }, "postprocess": update_item, - "condition": lambda doc: ( + "condition": lambda doc: not args.get("skip_item_mapping") + and select_item(doc) + and ( True if is_unit_price_row(doc) - else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) - ) - and select_item(doc) - and not args.get("skip_item_mapping"), + else ( + doc.qty + and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) + and get_pending_qty(doc) > 0 + ) + ), }, "Sales Taxes and Charges": { "doctype": "Sales Taxes and Charges", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index b85e3438022..eb5bb6495cc 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -288,6 +288,65 @@ class TestSalesOrder(ERPNextTestSuite): si1 = make_sales_invoice(so.name) self.assertEqual(len(si1.get("items")), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + + so = make_sales_order(qty=10, rate=100) + dn = create_dn_against_so(so.name, 10) + + dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict()) + dn_return.insert() + dn_return.submit() + + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + create_dn_against_so(so.name, 10) + + so.load_from_db() + item = so.get("items")[0] + self.assertEqual(item.delivered_qty, 10) + self.assertEqual(item.returned_qty, 10) + + si = make_sales_invoice(so.name) + self.assertEqual(si.get("items")[0].qty, 10) + + def test_make_sales_invoice_bills_ordered_qty_for_partial_delivery(self): + so = make_sales_order(qty=10, rate=100) + create_dn_against_so(so.name, 4) + + si = make_sales_invoice(so.name) + self.assertEqual(si.get("items")[0].qty, 10) + + def test_make_sales_invoice_after_partial_billing_return_and_redelivery(self): + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + + so = make_sales_order(qty=10, rate=100) + dn = create_dn_against_so(so.name, 10) + + si = make_sales_invoice(so.name) + si.get("items")[0].qty = 4 + si.insert() + si.submit() + + dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict()) + dn_return.insert() + dn_return.submit() + create_dn_against_so(so.name, 5) + + so.load_from_db() + item = so.get("items")[0] + self.assertEqual(item.delivered_qty, 5) + self.assertEqual(item.returned_qty, 10) + self.assertEqual(item.billed_amt, 400) + + pending_invoice = make_sales_invoice(so.name) + self.assertEqual(pending_invoice.get("items")[0].qty, 1) + pending_invoice.insert() + pending_invoice.submit() + + so.load_from_db() + self.assertEqual(so.get("items")[0].billed_amt, 500) + def test_so_billed_amount_against_return_entry(self): from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return