fix(selling): fetch orders within billing allowance (#58751)

This commit is contained in:
Mihir Kandoi
2026-09-07 17:57:59 +05:30
committed by GitHub
parent ec738eec28
commit 683f033c34
7 changed files with 174 additions and 8 deletions

View File

@@ -225,7 +225,8 @@
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
"fieldname": "over_billing_allowance",
"fieldtype": "Currency",
"label": "Over Billing Allowance (%)"
"label": "Over Billing Allowance (%)",
"non_negative": 1
},
{
"default": "1",
@@ -805,7 +806,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-08-14 15:26:49.070889",
"modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -368,7 +368,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
let filters = {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
per_billed: ["<", 99.99],
company: me.frm.doc.company,
};
@@ -387,6 +386,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
customer: me.frm.doc.customer || undefined,
},
get_query_filters: filters,
get_query_method:
"erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],

View File

@@ -455,11 +455,23 @@ def make_sales_invoice(
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 = {}
amount_allowance_by_item = {}
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def is_amount_billable(source):
from erpnext.controllers.status_updater import get_allowance_for
if source.item_code not in amount_allowance_by_item:
amount_allowance_by_item[source.item_code] = flt(
get_allowance_for(source.item_code, qty_or_amount="amount")[0]
)
allowance = amount_allowance_by_item[source.item_code]
return abs(flt(source.billed_amt)) < abs(flt(source.amount)) * (1 + allowance / 100)
def get_billed_qty_by_item():
nonlocal billed_qty_by_item
@@ -621,7 +633,7 @@ def make_sales_invoice(
if is_unit_price_row(doc)
else (
doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
and (doc.base_amount == 0 or is_amount_billable(doc))
and get_pending_qty(doc) > 0
)
),

View File

@@ -1120,7 +1120,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
// sales invoice
if (
(flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) ||
(doc.__onload?.has_potentially_billable_items &&
frappe.model.can_create("Sales Invoice")) ||
doc.is_subcontracted
) {
this.frm.add_custom_button(

View File

@@ -9,8 +9,10 @@ import frappe
import frappe.utils
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder import Case
from frappe.query_builder.functions import Abs, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate
from pypika import Order
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
unlink_inter_company_doc,
@@ -32,6 +34,18 @@ from erpnext.stock.get_item_details import get_default_bom
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
LINK_SEARCH_FIELDTYPES = {
"Autocomplete",
"Data",
"Link",
"Long Text",
"Read Only",
"Select",
"Small Text",
"Text",
"Text Editor",
}
class WarehouseRequired(frappe.ValidationError):
pass
@@ -213,6 +227,12 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
if self.docstatus == 1 and self.status not in {"Closed", "On Hold"}:
self.set_onload(
"has_potentially_billable_items",
has_potentially_billable_items(self.name),
)
def can_update_items(self) -> bool:
return SubcontractingService(self).can_update_items()
@@ -930,3 +950,87 @@ def get_work_order_items(sales_order: str, for_raw_material_request: int = 0):
@frappe.whitelist()
def get_stock_reservation_status():
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
"""Return the amount check for UI candidates. The mapper checks pending quantity."""
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)
)
has_amount_headroom = (sales_order_item.base_amount == 0) | (
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)
)
def has_potentially_billable_items(sales_order: str) -> bool:
"""Return whether a Sales Order has an item with billing amount headroom."""
so = qb.DocType("Sales Order")
so_item = qb.DocType("Sales Order Item")
item = qb.DocType("Item")
return bool(
qb.from_(so_item)
.inner_join(so)
.on(so.name == so_item.parent)
.left_join(item)
.on(item.name == so_item.item_code)
.select(so_item.name)
.where((so_item.parent == sales_order) & get_potentially_billable_item_criterion(so, so_item, item))
.limit(1)
.run()
)
@frappe.whitelist(methods=["GET"])
@frappe.validate_and_sanitize_search_inputs
def get_potentially_billable_sales_orders(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
):
"""Return Sales Orders that have an item with billing amount headroom."""
so = qb.DocType("Sales Order")
so_item = qb.DocType("Sales Order Item")
item = qb.DocType("Item")
meta = frappe.get_meta("Sales Order")
search_fields = list(dict.fromkeys(["name", meta.title_field, *meta.get_search_fields()]))
or_filters = (
{
fieldname: ("like", f"%{txt}%")
for fieldname in search_fields
if fieldname
and (
fieldname == "name"
or ((field := meta.get_field(fieldname)) and field.fieldtype in LINK_SEARCH_FIELDTYPES)
)
}
if txt
else None
)
query = frappe.qb.get_query(
so,
fields=[so.name, so.customer, so.transaction_date],
filters=filters,
or_filters=or_filters,
ignore_permissions=False,
)
return (
query.inner_join(so_item)
.on(so_item.parent == so.name)
.left_join(item)
.on(item.name == so_item.item_code)
.where(get_potentially_billable_item_criterion(so, so_item, item))
.distinct()
.orderby(so.transaction_date, order=Order.desc)
.limit(cint(page_len))
.offset(cint(start))
.run(as_dict=True)
)

View File

@@ -33,6 +33,8 @@ from erpnext.selling.doctype.sales_order.mapper import (
)
from erpnext.selling.doctype.sales_order.sales_order import (
WarehouseRequired,
get_potentially_billable_sales_orders,
has_potentially_billable_items,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -382,6 +384,49 @@ class TestSalesOrder(ERPNextTestSuite):
si1 = make_sales_invoice(so.name)
self.assertEqual(len(si1.get("items")), 0)
def test_make_sales_invoice_for_pending_qty_with_item_billing_allowance(self):
item = make_item(
"_Test Over Billed Pending Qty Item",
{"is_stock_item": 1, "over_billing_allowance": 0},
).name
so = make_sales_order(item_code=item, qty=390, rate=100)
for _ in range(2):
si = make_sales_invoice(so.name)
si.get("items")[0].qty = 120
si.get("items")[0].rate = 162.50
si.insert()
si.submit()
so.load_from_db()
self.assertEqual(flt(so.per_billed), 100)
self.assertEqual(so.get("items")[0].billed_amt, so.get("items")[0].amount)
filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
def is_offered(txt=""):
rows = get_potentially_billable_sales_orders("Sales Order", txt, "name", 0, 50, filters)
return so.name in [row.name for row in rows]
with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
self.assertTrue(has_potentially_billable_items(so.name))
self.assertTrue(is_offered())
self.assertEqual(make_sales_invoice(so.name).get("items")[0].qty, 150)
with change_settings("Accounts Settings", {"over_billing_allowance": 0}):
self.assertFalse(has_potentially_billable_items(so.name))
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
frappe.db.set_value("Item", item, "over_billing_allowance", 100)
so.run_method("onload")
self.assertTrue(so.get_onload("has_potentially_billable_items"))
self.assertTrue(is_offered(so.customer))
si = make_sales_invoice(so.name)
self.assertEqual(len(si.get("items")), 1)
self.assertEqual(si.get("items")[0].qty, 150)
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return

View File

@@ -855,6 +855,7 @@
"fieldname": "over_delivery_receipt_allowance",
"fieldtype": "Float",
"label": "Over Delivery/Receipt Allowance (%)",
"non_negative": 1,
"oldfieldname": "tolerance",
"oldfieldtype": "Currency"
},
@@ -863,7 +864,8 @@
"description": "Percentage by which over-billing is allowed against a Sales/Purchase Order for this item. If not set, value from Accounts Settings will be used.",
"fieldname": "over_billing_allowance",
"fieldtype": "Float",
"label": "Over Billing Allowance (%)"
"label": "Over Billing Allowance (%)",
"non_negative": 1
},
{
"default": "0",
@@ -1116,7 +1118,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-07-28 18:58:43.328497",
"modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",