From 5d9cbd97557ae26f66384a818bb2a4bd6595f989 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 10 Sep 2026 20:50:40 +0530 Subject: [PATCH] fix(selling): exclude fully billed orders from the invoice picker (backport #58966) (#58967) --- .../doctype/sales_order/sales_order.py | 27 ++++++++++-- .../doctype/sales_order/test_sales_order.py | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 3115bf42782..916cc4bc562 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -14,7 +14,7 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder import Case -from frappe.query_builder.functions import Abs, Sum +from frappe.query_builder.functions import Abs, IfNull, Round, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, parse_json, strip_html from pypika import Order @@ -2200,8 +2200,26 @@ def get_mapped_subcontracting_inward_order(source_name, target_doc=None): return target_doc +def get_pending_qty_criterion(sales_order_item): + """Mirror the mapper's pending quantity check.""" + invoice_item = qb.DocType("Sales Invoice Item") + billed_qty = ( + qb.from_(invoice_item) + .select(IfNull(Sum(invoice_item.qty), 0)) + .where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name)) + ) + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0 + has_unbilled_delivered_qty = ( + Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0 + ) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0) + + return has_unbilled_ordered_qty & has_unbilled_delivered_qty + + def get_potentially_billable_item_criterion(sales_order, sales_order_item, item): - """Return the amount check for UI candidates. The mapper checks pending quantity.""" + """Return the row level checks the Sales Invoice mapper applies.""" global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance")) allowance = ( Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance) @@ -2211,8 +2229,11 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item) Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100) ) is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0) + is_billable_row = ( + (sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item) + ) - return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom) + return is_unit_price_row | is_billable_row def has_potentially_billable_items(sales_order: str) -> bool: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index b83d1dda584..13ffe8feed7 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -343,6 +343,48 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(len(make_sales_invoice(so.name).items), 0) + def test_fully_billed_order_is_not_offered_within_billing_allowance(self): + item = make_item( + "_Test Fully Billed Allowance Item", + {"is_stock_item": 1, "over_billing_allowance": 0}, + ).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.insert() + si.submit() + + so.load_from_db() + self.assertEqual(flt(so.per_billed), 100) + + filters = {"docstatus": 1, "company": so.company, "customer": so.customer} + + with change_settings("Accounts Settings", {"over_billing_allowance": 100}): + self.assertFalse(has_potentially_billable_items(so.name)) + + rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters) + self.assertNotIn(so.name, [row.name for row in rows]) + + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + def test_order_with_sub_precision_pending_qty_is_not_offered(self): + item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.get("items")[0].rate = 90 + si.insert() + si.submit() + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + billed_qty = 10 - 10 ** -(qty_precision + 1) + frappe.db.set_value( + "Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False + ) + + self.assertFalse(has_potentially_billable_items(so.name)) + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return