fix(selling): exclude fully billed orders from the invoice picker (backport #58966) (#58967)

This commit is contained in:
Mihir Kandoi
2026-09-10 20:50:40 +05:30
committed by GitHub
parent c1f37750af
commit 5d9cbd9755
2 changed files with 66 additions and 3 deletions

View File

@@ -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:

View File

@@ -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