From 817926ca2e888777767ecf91e74e14bc3334d9f7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 19:27:15 +0530 Subject: [PATCH] fix(selling): fetch orders within billing allowance (backport #58751) (#58820) --- .../accounts_settings/accounts_settings.json | 5 +- .../doctype/sales_invoice/sales_invoice.js | 3 +- .../doctype/sales_order/sales_order.js | 3 +- .../doctype/sales_order/sales_order.py | 122 +++++++++++++++++- .../doctype/sales_order/test_sales_order.py | 58 +++++++++ erpnext/stock/doctype/item/item.json | 6 +- .../test_subcontracting_inward_order.py | 2 +- 7 files changed, 187 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 93928e9b149..e5bccfe73f6 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -224,7 +224,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", @@ -797,7 +798,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", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 3ee34f24d17..33b41180140 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -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"], diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 618588f8b09..c76f26977a2 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1070,7 +1070,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( diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 5183e496ace..3115bf42782 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 +from frappe.query_builder.functions import Abs, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, parse_json, strip_html +from pypika import Order from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, @@ -48,6 +50,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 @@ -225,6 +239,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: result = True @@ -1348,11 +1368,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 @@ -1374,9 +1406,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) @@ -1503,7 +1533,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 ) ), @@ -2168,3 +2198,85 @@ def get_mapped_subcontracting_inward_order(source_name, target_doc=None): ) return target_doc + + +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() + ) + + +@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) + ) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 08bbdda3fe9..b83d1dda584 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_production_plan, @@ -285,6 +287,62 @@ 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_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 6855b73165b..3f2ae45cd91 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -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", @@ -1093,7 +1095,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", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index d2bf418e814..a5cbc78d961 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -425,7 +425,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): scio.reload() si = make_sales_invoice(so.name) - self.assertEqual(len(si.items), 1) + self.assertEqual(len(si.items), 0) def test_extra_items_reservation_transfer(self): so, scio = create_so_scio()