From dd23cf40e116dc1531d915a03a0bd2fcfc686563 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 12:27:22 +0530 Subject: [PATCH] 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