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

This commit is contained in:
Mihir Kandoi
2026-09-10 20:50:11 +05:30
committed by GitHub
parent 86821ac6ee
commit 5f216c5d55
2 changed files with 66 additions and 5 deletions

View File

@@ -10,7 +10,7 @@ import frappe.utils
from frappe import _, qb
from frappe.model.document import Document
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 cint, flt, get_link_to_form, getdate
from pypika import Order
@@ -952,8 +952,26 @@ def get_stock_reservation_status():
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
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)
@@ -963,11 +981,12 @@ 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)
return (sales_order_item.closed == 0) & (
is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
is_billable_row = (
(sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item)
)
return (sales_order_item.closed == 0) & (is_unit_price_row | is_billable_row)
def has_potentially_billable_items(sales_order: str) -> bool:
"""Return whether a Sales Order has an item with billing amount headroom."""

View File

@@ -440,6 +440,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.mapper import make_sales_return