From 114ba42850e7f31f37fe07c0e87a875560508fb7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 12:15:45 +0530 Subject: [PATCH 1/3] 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 From dd23cf40e116dc1531d915a03a0bd2fcfc686563 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 12:27:22 +0530 Subject: [PATCH 2/3] perf(selling): batch billed quantity lookup --- erpnext/selling/doctype/sales_order/mapper.py | 28 +++++++++++------ .../doctype/sales_order/test_sales_order.py | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index fecf67cc642..06635b97088 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -441,25 +441,35 @@ 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") + billed_qty_by_item = None 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_billed_qty_by_item(): + nonlocal billed_qty_by_item + + if billed_qty_by_item is None: + invoice_item = frappe.qb.DocType("Sales Invoice Item") + sales_order_item = frappe.qb.DocType("Sales Order Item") + rows = ( + frappe.qb.from_(invoice_item) + .inner_join(sales_order_item) + .on(invoice_item.so_detail == sales_order_item.name) + .select(invoice_item.so_detail, Sum(invoice_item.qty).as_("qty")) + .where((invoice_item.docstatus == 1) & (sales_order_item.parent == source_name)) + .groupby(invoice_item.so_detail) + ).run(as_dict=True) + billed_qty_by_item = {row.so_detail: flt(row.qty) for row in rows} + + return billed_qty_by_item 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) + billable_qty -= get_billed_qty_by_item().get(source.name, 0) pending_qty_by_item[source.name] = max(flt(billable_qty), 0) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index eb5bb6495cc..2dd1a73dea6 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -347,6 +347,36 @@ class TestSalesOrder(ERPNextTestSuite): so.load_from_db() self.assertEqual(so.get("items")[0].billed_amt, 500) + def test_make_sales_invoice_after_partial_billing_multiple_items(self): + so = make_sales_order( + item_list=[ + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + { + "item_code": "_Test FG Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + ] + ) + + si = make_sales_invoice(so.name) + si.get("items")[0].qty = 4 + si.get("items")[1].qty = 6 + si.insert() + si.submit() + + pending_invoice = make_sales_invoice(so.name) + self.assertEqual( + {item.so_detail: item.qty for item in pending_invoice.get("items")}, + {so.get("items")[0].name: 6, so.get("items")[1].name: 4}, + ) + def test_so_billed_amount_against_return_entry(self): from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return From 9f108e4b756e6dfe9ad674d78934ab3f062336f7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 12:38:26 +0530 Subject: [PATCH 3/3] fix(selling): clamp pending qty to qty field precision --- erpnext/selling/doctype/sales_order/mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index 06635b97088..6fbac77a03e 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -471,7 +471,7 @@ def make_sales_invoice( if source.qty and source.billed_amt: billable_qty -= get_billed_qty_by_item().get(source.name, 0) - pending_qty_by_item[source.name] = max(flt(billable_qty), 0) + pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) return pending_qty_by_item[source.name]