diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 595cc029e39..2bace6f90ee 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -223,7 +223,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", @@ -679,7 +680,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-08-14 13:12:47.895908", + "modified": "2026-09-04 10:08:30.115003", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 259cd970941..3f90575ab4c 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -375,9 +375,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( get_query_filters: { docstatus: 1, status: ["not in", ["Closed", "On Hold"]], - per_billed: ["<", 99.99], company: me.frm.doc.company, }, + 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"], diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 635a43c3113..166acc58bfd 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -695,7 +695,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // sales invoice - if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) { + if ( + doc.__onload?.has_potentially_billable_items && + frappe.model.can_create("Sales Invoice") + ) { this.frm.add_custom_button( __("Sales Invoice"), () => me.make_sales_invoice(), diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index e664f69e14b..7710bb8c434 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -13,8 +13,10 @@ from frappe.desk.notifications import clear_doctype_notifications 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.functions import Sum +from frappe.query_builder import Case, Criterion +from frappe.query_builder.functions import Abs, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html +from pypika import Order from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, @@ -22,6 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( validate_inter_company_party, ) from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.accounts.utils import build_qb_match_conditions from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( @@ -42,6 +45,18 @@ from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty 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 @@ -207,6 +222,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 before_validate(self): self.set_has_unit_price_items() self.flags.allow_zero_qty = self.has_unit_price_items @@ -1142,11 +1163,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 @@ -1168,9 +1201,7 @@ def make_sales_invoice( 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_by_item().get(source.name, 0) - + billable_qty -= get_billed_qty_by_item().get(source.name, 0) billable_qty -= mapped_qty_by_item.get(source.name, 0) pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) @@ -1255,7 +1286,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 ) ), @@ -1940,3 +1971,87 @@ def get_work_order_items(sales_order, for_raw_material_request=0): @frappe.whitelist() def get_stock_reservation_status(): return frappe.db.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 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() + ) + + +def get_text_search_criterion(sales_order, txt: str): + """Match the search text the way the Sales Order link search does.""" + meta = frappe.get_meta("Sales Order") + conditions = [] + + for fieldname in dict.fromkeys(["name", meta.title_field, *meta.get_search_fields()]): + if not fieldname: + continue + + field = meta.get_field(fieldname) + if fieldname == "name" or (field and field.fieldtype in LINK_SEARCH_FIELDTYPES): + conditions.append(sales_order[fieldname].like(f"%{txt}%")) + + return Criterion.any(conditions) + + +@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.""" + permission_type = "select" if frappe.only_has_select_perm("Sales Order") else "read" + frappe.has_permission("Sales Order", permission_type, throw=True) + + so = qb.DocType("Sales Order") + so_item = qb.DocType("Sales Order Item") + item = qb.DocType("Item") + + query = frappe.qb.get_query( + "Sales Order", fields=["name", "customer", "transaction_date"], filters=filters + ) + if txt: + query = query.where(get_text_search_criterion(so, txt)) + + 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)) + .where(Criterion.all(build_qb_match_conditions("Sales Order"))) + .distinct() + .orderby(so.transaction_date, order=Order.desc) + .limit(cint(page_len)) + .offset(cint(start)) + .run(as_dict=True) + ) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 8c0631c1b52..58df9766cc5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -22,6 +22,8 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod from erpnext.selling.doctype.sales_order.sales_order import ( WarehouseRequired, create_pick_list, + get_potentially_billable_sales_orders, + has_potentially_billable_items, make_delivery_note, make_material_request, make_purchase_order, @@ -226,6 +228,68 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): 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] + + def forget_cached_allowances(): + frappe.local.request_cache.clear() + + with change_settings("Accounts Settings", {"over_billing_allowance": 100}): + forget_cached_allowances() + 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}): + forget_cached_allowances() + 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) + forget_cached_allowances() + + 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_skips_fully_invoiced_free_item(self): + free_item = make_item("_Test Free Item", {"is_stock_item": 1}).name + so = make_sales_order(qty=10, rate=100, do_not_submit=True) + so.append("items", {"item_code": free_item, "qty": 5, "rate": 0, "warehouse": so.items[0].warehouse}) + so.submit() + + si = make_sales_invoice(so.name) + self.assertEqual([row.qty for row in si.items], [10, 5]) + si.insert() + si.submit() + + self.assertEqual(len(make_sales_invoice(so.name).items), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index e8280bacf4a..bbbb4038be6 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -796,6 +796,7 @@ "fieldname": "over_delivery_receipt_allowance", "fieldtype": "Float", "label": "Over Delivery/Receipt Allowance (%)", + "non_negative": 1, "oldfieldname": "tolerance", "oldfieldtype": "Currency" }, @@ -803,7 +804,8 @@ "depends_on": "eval:!doc.__islocal && !doc.is_fixed_asset", "fieldname": "over_billing_allowance", "fieldtype": "Float", - "label": "Over Billing Allowance (%)" + "label": "Over Billing Allowance (%)", + "non_negative": 1 }, { "default": "0", @@ -898,7 +900,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-07-05 23:24:45.734144", + "modified": "2026-09-04 10:08:30.115003", "modified_by": "Administrator", "module": "Stock", "name": "Item",