mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
This commit is contained in:
@@ -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 ",
|
"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",
|
"fieldname": "over_billing_allowance",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Over Billing Allowance (%)"
|
"label": "Over Billing Allowance (%)",
|
||||||
|
"non_negative": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
@@ -797,7 +798,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-08-14 15:26:49.070889",
|
"modified": "2026-09-04 10:08:30.115003",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Accounts Settings",
|
"name": "Accounts Settings",
|
||||||
|
|||||||
@@ -368,7 +368,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
|||||||
let filters = {
|
let filters = {
|
||||||
docstatus: 1,
|
docstatus: 1,
|
||||||
status: ["not in", ["Closed", "On Hold"]],
|
status: ["not in", ["Closed", "On Hold"]],
|
||||||
per_billed: ["<", 99.99],
|
|
||||||
company: me.frm.doc.company,
|
company: me.frm.doc.company,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,6 +386,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
|||||||
customer: me.frm.doc.customer || undefined,
|
customer: me.frm.doc.customer || undefined,
|
||||||
},
|
},
|
||||||
get_query_filters: filters,
|
get_query_filters: filters,
|
||||||
|
get_query_method:
|
||||||
|
"erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
|
||||||
allow_child_item_selection: true,
|
allow_child_item_selection: true,
|
||||||
child_fieldname: "items",
|
child_fieldname: "items",
|
||||||
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
|
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
|
||||||
|
|||||||
@@ -1070,7 +1070,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
|||||||
|
|
||||||
// sales invoice
|
// sales invoice
|
||||||
if (
|
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
|
doc.is_subcontracted
|
||||||
) {
|
) {
|
||||||
this.frm.add_custom_button(
|
this.frm.add_custom_button(
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from frappe.desk.notifications import clear_doctype_notifications
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.mapper import get_mapped_doc
|
from frappe.model.mapper import get_mapped_doc
|
||||||
from frappe.model.utils import get_fetch_values
|
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 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 (
|
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||||
unlink_inter_company_doc,
|
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"}
|
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):
|
class WarehouseRequired(frappe.ValidationError):
|
||||||
pass
|
pass
|
||||||
@@ -225,6 +239,12 @@ class SalesOrder(SellingController):
|
|||||||
if has_reserved_stock(self.doctype, self.name):
|
if has_reserved_stock(self.doctype, self.name):
|
||||||
self.set_onload("has_reserved_stock", True)
|
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:
|
def can_update_items(self) -> bool:
|
||||||
result = True
|
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")
|
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
|
||||||
billed_qty_by_item = None
|
billed_qty_by_item = None
|
||||||
pending_qty_by_item = {}
|
pending_qty_by_item = {}
|
||||||
|
amount_allowance_by_item = {}
|
||||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
|
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
|
||||||
|
|
||||||
def is_unit_price_row(source):
|
def is_unit_price_row(source):
|
||||||
return has_unit_price_items and source.qty == 0
|
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():
|
def get_billed_qty_by_item():
|
||||||
nonlocal billed_qty_by_item
|
nonlocal billed_qty_by_item
|
||||||
|
|
||||||
@@ -1374,9 +1406,7 @@ def make_sales_invoice(
|
|||||||
def get_pending_qty(source):
|
def get_pending_qty(source):
|
||||||
if source.name not in pending_qty_by_item:
|
if source.name not in pending_qty_by_item:
|
||||||
billable_qty = get_qty_net_of_returns(source)
|
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)
|
billable_qty -= mapped_qty_by_item.get(source.name, 0)
|
||||||
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 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)
|
if is_unit_price_row(doc)
|
||||||
else (
|
else (
|
||||||
doc.qty
|
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
|
and get_pending_qty(doc) > 0
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -2168,3 +2198,85 @@ def get_mapped_subcontracting_inward_order(source_name, target_doc=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return target_doc
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 (
|
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||||
WarehouseRequired,
|
WarehouseRequired,
|
||||||
create_pick_list,
|
create_pick_list,
|
||||||
|
get_potentially_billable_sales_orders,
|
||||||
|
has_potentially_billable_items,
|
||||||
make_delivery_note,
|
make_delivery_note,
|
||||||
make_material_request,
|
make_material_request,
|
||||||
make_production_plan,
|
make_production_plan,
|
||||||
@@ -285,6 +287,62 @@ class TestSalesOrder(ERPNextTestSuite):
|
|||||||
si1 = make_sales_invoice(so.name)
|
si1 = make_sales_invoice(so.name)
|
||||||
self.assertEqual(len(si1.get("items")), 0)
|
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):
|
def test_make_sales_invoice_after_return_and_redelivery(self):
|
||||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||||
|
|
||||||
|
|||||||
@@ -855,6 +855,7 @@
|
|||||||
"fieldname": "over_delivery_receipt_allowance",
|
"fieldname": "over_delivery_receipt_allowance",
|
||||||
"fieldtype": "Float",
|
"fieldtype": "Float",
|
||||||
"label": "Over Delivery/Receipt Allowance (%)",
|
"label": "Over Delivery/Receipt Allowance (%)",
|
||||||
|
"non_negative": 1,
|
||||||
"oldfieldname": "tolerance",
|
"oldfieldname": "tolerance",
|
||||||
"oldfieldtype": "Currency"
|
"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.",
|
"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",
|
"fieldname": "over_billing_allowance",
|
||||||
"fieldtype": "Float",
|
"fieldtype": "Float",
|
||||||
"label": "Over Billing Allowance (%)"
|
"label": "Over Billing Allowance (%)",
|
||||||
|
"non_negative": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -1093,7 +1095,7 @@
|
|||||||
"image_field": "image",
|
"image_field": "image",
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2026-07-28 18:58:43.328497",
|
"modified": "2026-09-04 10:08:30.115003",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Item",
|
"name": "Item",
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
|
|||||||
|
|
||||||
scio.reload()
|
scio.reload()
|
||||||
si = make_sales_invoice(so.name)
|
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):
|
def test_extra_items_reservation_transfer(self):
|
||||||
so, scio = create_so_scio()
|
so, scio = create_so_scio()
|
||||||
|
|||||||
Reference in New Issue
Block a user