diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json
index fb7104d3891..6d90268e574 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.json
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -25,6 +25,9 @@
"disable_last_purchase_rate",
"show_pay_button",
"use_transaction_date_exchange_rate",
+ "allow_zero_qty_in_request_for_quotation",
+ "allow_zero_qty_in_supplier_quotation",
+ "allow_zero_qty_in_purchase_order",
"subcontract",
"backflush_raw_materials_of_subcontract_based_on",
"column_break_11",
@@ -207,14 +210,33 @@
"fieldtype": "Select",
"label": "Update frequency of Project",
"options": "Each Transaction\nManual"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_zero_qty_in_purchase_order",
+ "fieldtype": "Check",
+ "label": "Allow 0 Qty in Purchase Order (Unit Price Items)"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_zero_qty_in_request_for_quotation",
+ "fieldtype": "Check",
+ "label": "Allow 0 Qty in Request for Quotation (Unit Price Items)"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_zero_qty_in_supplier_quotation",
+ "fieldtype": "Check",
+ "label": "Allow 0 Qty in Supplier Quotation (Unit Price Items)"
}
],
+ "grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2024-03-27 13:06:43.375495",
+ "modified": "2025-03-03 17:32:25.939482",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
@@ -260,6 +282,7 @@
"role": "Purchase User"
}
],
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py
index ec9b88888b7..4dde7c8dabf 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.py
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.py
@@ -18,6 +18,9 @@ class BuyingSettings(Document):
from frappe.types import DF
allow_multiple_items: DF.Check
+ allow_zero_qty_in_purchase_order: DF.Check
+ allow_zero_qty_in_request_for_quotation: DF.Check
+ allow_zero_qty_in_supplier_quotation: DF.Check
auto_create_purchase_receipt: DF.Check
auto_create_subcontracting_order: DF.Check
backflush_raw_materials_of_subcontract_based_on: DF.Literal[
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index 7a7ecdc193e..49558c8af37 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -26,7 +26,15 @@ frappe.ui.form.on("Purchase Order", {
}
frm.set_indicator_formatter("item_code", function (doc) {
- return doc.qty <= doc.received_qty ? "green" : "orange";
+ let color;
+ if (!doc.qty && frm.doc.has_unit_price_items) {
+ color = "yellow";
+ } else if (doc.qty <= doc.received_qty) {
+ color = "green";
+ } else {
+ color = "orange";
+ }
+ return color;
});
frm.set_query("expense_account", "items", function () {
@@ -63,6 +71,10 @@ frappe.ui.form.on("Purchase Order", {
}
});
}
+
+ if (frm.doc.docstatus == 0) {
+ erpnext.set_unit_price_items_note(frm);
+ }
},
supplier: function (frm) {
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json
index c72fee664e5..5db6aca4a78 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.json
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.json
@@ -24,6 +24,7 @@
"apply_tds",
"tax_withholding_category",
"is_subcontracted",
+ "has_unit_price_items",
"supplier_warehouse",
"amended_from",
"accounting_dimensions_section",
@@ -1285,6 +1286,14 @@
"options": "Not Initiated\nInitiated\nPartially Paid\nFully Paid",
"print_hide": 1
},
+ {
+ "default": "0",
+ "fieldname": "has_unit_price_items",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Unit Price Items",
+ "no_copy": 1
+ },
{
"fieldname": "dispatch_address",
"fieldtype": "Link",
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 011e7103529..367299fa634 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -97,6 +97,7 @@ class PurchaseOrder(BuyingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
+ has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -191,6 +192,10 @@ class PurchaseOrder(BuyingController):
self.set_onload("supplier_tds", supplier_tds)
self.set_onload("can_update_items", self.can_update_items())
+ def before_validate(self):
+ self.set_has_unit_price_items()
+ self.flags.allow_zero_qty = self.has_unit_price_items
+
def validate(self):
super().validate()
@@ -227,6 +232,17 @@ class PurchaseOrder(BuyingController):
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
+ def set_has_unit_price_items(self):
+ """
+ If permitted in settings and any item has 0 qty, the PO has unit price items.
+ """
+ if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order"):
+ return
+
+ self.has_unit_price_items = any(
+ not row.qty for row in self.get("items") if (row.item_code and not row.qty)
+ )
+
def validate_with_previous_doc(self):
mri_compare_fields = [["project", "="], ["item_code", "="]]
if self.is_subcontracted:
@@ -731,8 +747,13 @@ def set_missing_values(source, target):
@frappe.whitelist()
def make_purchase_receipt(source_name, target_doc=None):
+ has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
def update_item(obj, target, source_parent):
- target.qty = flt(obj.qty) - flt(obj.received_qty)
+ target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
@@ -763,7 +784,9 @@ def make_purchase_receipt(source_name, target_doc=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
- "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty)
+ "condition": lambda doc: (
+ True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
+ )
and doc.delivered_by_supplier != 1,
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index a97b9ca4cfa..9f7f5332213 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -5,7 +5,7 @@
import json
import frappe
-from frappe.tests import IntegrationTestCase, UnitTestCase
+from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate
from frappe.utils.data import today
@@ -61,6 +61,13 @@ class TestPurchaseOrder(IntegrationTestCase):
po.save()
self.assertEqual(po.items[1].qty, 1)
+ def test_purchase_order_zero_qty(self):
+ po = create_purchase_order(qty=0, do_not_save=True)
+
+ with change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}):
+ po.save()
+ self.assertEqual(po.items[0].qty, 0)
+
def test_make_purchase_receipt(self):
po = create_purchase_order(do_not_submit=True)
self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name)
@@ -1248,6 +1255,80 @@ class TestPurchaseOrder(IntegrationTestCase):
po.reload()
self.assertEqual(po.per_billed, 100)
+ @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
+ def test_receive_zero_qty_purchase_order(self):
+ """
+ Test the flow of a Unit Price PO and PR creation against it until completion.
+ Flow:
+ PO Qty 0 -> Receive +5 -> Receive +5 -> Update PO Qty +10 -> PO is 100% received
+ """
+ po = create_purchase_order(qty=0)
+ pr = make_purchase_receipt(po.name)
+
+ self.assertEqual(pr.items[0].qty, 0)
+ pr.items[0].qty = 5
+ pr.submit()
+
+ po.reload()
+ self.assertEqual(po.items[0].received_qty, 5)
+ self.assertFalse(po.per_received)
+ self.assertEqual(po.status, "To Receive and Bill")
+
+ # Update PO Item Qty to 10 after receipt of items
+ first_item_of_po = po.items[0]
+ trans_item = json.dumps(
+ [
+ {
+ "item_code": first_item_of_po.item_code,
+ "rate": first_item_of_po.rate,
+ "qty": 10,
+ "docname": first_item_of_po.name,
+ }
+ ]
+ )
+ update_child_qty_rate("Purchase Order", trans_item, po.name)
+
+ # Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty
+ pr2 = make_purchase_receipt(po.name)
+
+ po.reload()
+ self.assertEqual(po.items[0].qty, 10)
+ self.assertEqual(pr2.items[0].qty, 5)
+
+ pr2.submit()
+
+ # PO should be updated to 100% received
+ po.reload()
+ self.assertEqual(po.items[0].qty, 10)
+ self.assertEqual(po.items[0].received_qty, 10)
+ self.assertEqual(po.per_received, 100.0)
+ self.assertEqual(po.status, "To Bill")
+
+ @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
+ def test_bill_zero_qty_purchase_order(self):
+ po = create_purchase_order(qty=0)
+
+ self.assertEqual(po.grand_total, 0)
+ self.assertFalse(po.per_billed)
+ self.assertEqual(po.items[0].qty, 0)
+ self.assertEqual(po.items[0].rate, 500)
+
+ pi = make_pi_from_po(po.name)
+ self.assertEqual(pi.items[0].qty, 0)
+ self.assertEqual(pi.items[0].rate, 500)
+
+ pi.items[0].qty = 5
+ pi.submit()
+
+ self.assertEqual(pi.grand_total, 2500)
+
+ po.reload()
+ self.assertEqual(po.items[0].amount, 0)
+ self.assertEqual(po.items[0].billed_amt, 2500)
+ # PO still has qty 0, so billed % should be unset
+ self.assertFalse(po.per_billed)
+ self.assertEqual(po.status, "To Receive and Bill")
+
def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import (
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
index c916b168cfd..5e3adf7d50b 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
@@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", {
is_group: 0,
},
}));
+
+ frm.set_indicator_formatter("item_code", function (doc) {
+ return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
+ });
},
onload: function (frm) {
@@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", {
__("View")
);
}
+
+ if (frm.doc.docstatus === 0) {
+ erpnext.set_unit_price_items_note(frm);
+ }
},
show_supplier_quotation_comparison(frm) {
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
index 424bccd80f1..fee11e627b4 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
@@ -16,6 +16,7 @@
"transaction_date",
"schedule_date",
"status",
+ "has_unit_price_items",
"amended_from",
"suppliers_section",
"suppliers",
@@ -306,13 +307,22 @@
"fieldtype": "Text Editor",
"label": "Billing Address Details",
"read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "has_unit_price_items",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Unit Price Items",
+ "no_copy": 1
}
],
+ "grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-03-27 13:10:33.030915",
+ "modified": "2025-03-03 16:48:39.856779",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
@@ -377,6 +387,7 @@
"role": "All"
}
],
+ "row_format": "Dynamic",
"search_fields": "status, transaction_date",
"show_name_in_global_search": 1,
"sort_field": "creation",
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
index ee6c7a89cae..6cbab414773 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
@@ -42,6 +42,7 @@ class RequestforQuotation(BuyingController):
billing_address_display: DF.TextEditor | None
company: DF.Link
email_template: DF.Link | None
+ has_unit_price_items: DF.Check
incoterm: DF.Link | None
items: DF.Table[RequestforQuotationItem]
letter_head: DF.Link | None
@@ -61,6 +62,10 @@ class RequestforQuotation(BuyingController):
vendor: DF.Link | None
# end: auto-generated types
+ def before_validate(self):
+ self.set_has_unit_price_items()
+ self.flags.allow_zero_qty = self.has_unit_price_items
+
def validate(self):
self.validate_duplicate_supplier()
self.validate_supplier_list()
@@ -73,6 +78,17 @@ class RequestforQuotation(BuyingController):
# after amend and save, status still shows as cancelled, until submit
self.db_set("status", "Draft")
+ def set_has_unit_price_items(self):
+ """
+ If permitted in settings and any item has 0 qty, the RFQ has unit price items.
+ """
+ if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_request_for_quotation"):
+ return
+
+ self.has_unit_price_items = any(
+ not row.qty for row in self.get("items") if (row.item_code and not row.qty)
+ )
+
def validate_duplicate_supplier(self):
supplier_list = [d.supplier for d in self.suppliers]
if len(supplier_list) != len(set(supplier_list)):
diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
index 63d995f562b..f400f4506f2 100644
--- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
@@ -5,7 +5,7 @@
from urllib.parse import urlparse
import frappe
-from frappe.tests import IntegrationTestCase, UnitTestCase
+from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import nowdate
from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
@@ -41,6 +41,16 @@ class TestRequestforQuotation(IntegrationTestCase):
rfq.save()
self.assertEqual(rfq.items[0].qty, 1)
+ def test_rfq_zero_qty(self):
+ """
+ Test if RFQ with zero qty (Unit Price Item) is conditionally allowed.
+ """
+ rfq = make_request_for_quotation(qty=0, do_not_save=True)
+
+ with change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}):
+ rfq.save()
+ self.assertEqual(rfq.items[0].qty, 0)
+
def test_quote_status(self):
rfq = make_request_for_quotation()
@@ -181,6 +191,15 @@ class TestRequestforQuotation(IntegrationTestCase):
supplier_doc.reload()
self.assertTrue(supplier_doc.portal_users[0].user)
+ @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1})
+ def test_supplier_quotation_from_zero_qty_rfq(self):
+ rfq = make_request_for_quotation(qty=0)
+ sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
+
+ self.assertEqual(len(sq.items), 1)
+ self.assertEqual(sq.items[0].qty, 0)
+ self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
+
def make_request_for_quotation(**args) -> "RequestforQuotation":
"""
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
index de37ec20bdc..26f439ba03c 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
@@ -11,6 +11,11 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
Quotation: "Quotation",
};
+ const me = this;
+ this.frm.set_indicator_formatter("item_code", function (doc) {
+ return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : "";
+ });
+
super.setup();
}
@@ -30,6 +35,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
} else if (this.frm.doc.docstatus === 0) {
+ erpnext.set_unit_price_items_note(this.frm);
+
this.frm.add_custom_button(
__("Material Request"),
function () {
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index 210c7308ceb..2f4cae69e01 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -19,6 +19,7 @@
"transaction_date",
"valid_till",
"quotation_number",
+ "has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -921,14 +922,23 @@
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
+ },
+ {
+ "default": "0",
+ "fieldname": "has_unit_price_items",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Unit Price Items",
+ "no_copy": 1
}
],
+ "grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"idx": 29,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-03-28 10:20:30.231915",
+ "modified": "2025-03-03 17:39:38.459977",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
@@ -989,6 +999,7 @@
"write": 1
}
],
+ "row_format": "Dynamic",
"search_fields": "status, transaction_date, supplier,grand_total",
"show_name_in_global_search": 1,
"sort_field": "creation",
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
index 84f9a1657c7..bf051fd0541 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
@@ -60,6 +60,7 @@ class SupplierQuotation(BuyingController):
discount_amount: DF.Currency
grand_total: DF.Currency
group_same_items: DF.Check
+ has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -103,6 +104,10 @@ class SupplierQuotation(BuyingController):
valid_till: DF.Date | None
# end: auto-generated types
+ def before_validate(self):
+ self.set_has_unit_price_items()
+ self.flags.allow_zero_qty = self.has_unit_price_items
+
def validate(self):
super().validate()
@@ -129,6 +134,17 @@ class SupplierQuotation(BuyingController):
def on_trash(self):
pass
+ def set_has_unit_price_items(self):
+ """
+ If permitted in settings and any item has 0 qty, the SQ has unit price items.
+ """
+ if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_supplier_quotation"):
+ return
+
+ self.has_unit_price_items = any(
+ not row.qty for row in self.get("items") if (row.item_code and not row.qty)
+ )
+
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
index 0e4f70b9dcb..ad0a18c26f5 100644
--- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
@@ -3,9 +3,10 @@
import frappe
-from frappe.tests import IntegrationTestCase, UnitTestCase
+from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, today
+from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -30,9 +31,18 @@ class TestPurchaseOrder(IntegrationTestCase):
sq.save()
self.assertEqual(sq.items[0].qty, 1)
- def test_make_purchase_order(self):
- from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
+ def test_supplier_quotation_zero_qty(self):
+ """
+ Test if RFQ with zero qty (Unit Price Item) is conditionally allowed.
+ """
+ sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
+ sq.items[0].qty = 0
+ with change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}):
+ sq.save()
+ self.assertEqual(sq.items[0].qty, 0)
+
+ def test_make_purchase_order(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).insert()
self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name)
@@ -51,3 +61,14 @@ class TestPurchaseOrder(IntegrationTestCase):
doc.set("schedule_date", add_days(today(), 1))
po.insert()
+
+ @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1})
+ def test_map_purchase_order_from_zero_qty_supplier_quotation(self):
+ sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
+ sq.items[0].qty = 0
+ sq.submit()
+
+ po = make_purchase_order(sq.name)
+ self.assertEqual(len(po.get("items")), 1)
+ self.assertEqual(po.get("items")[0].qty, 0)
+ self.assertEqual(po.get("items")[0].item_code, sq.get("items")[0].item_code)
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 515a8045d8c..e3f6e1d52ab 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1262,6 +1262,9 @@ class AccountsController(TransactionBase):
)
def validate_qty_is_not_zero(self):
+ if self.flags.allow_zero_qty:
+ return
+
for item in self.items:
if self.doctype == "Purchase Receipt" and item.rejected_qty:
continue
@@ -3759,9 +3762,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
)
if amount_below_billed_amt and row_rate > 0.0:
frappe.throw(
- _("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format(
- child_item.idx, child_item.item_code
- )
+ _(
+ "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
+ ).format(child_item.idx, child_item.item_code)
)
else:
child_item.rate = row_rate
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index af141e2edf2..b4d8fa9dc2a 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -2779,3 +2779,19 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => {
}
});
};
+
+erpnext.set_unit_price_items_note = (frm) => {
+ if (frm.doc.has_unit_price_items && !frm.is_new()) {
+ // Remove existing note
+ const $note = $(frm.layout.wrapper.find(".unit-price-items-note"));
+ if ($note.length) { $note.parent().remove(); }
+
+ frm.layout.show_message(
+ `
+ ${__("The {0} contains Unit Price Items.", [__(frm.doc.doctype)])}
+
`,
+ "yellow",
+ true
+ );
+ }
+};
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 2e88ba9e482..6cb676ac58e 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -35,12 +35,20 @@ frappe.ui.form.on("Quotation", {
},
};
});
+
+ frm.set_indicator_formatter("item_code", function (doc) {
+ return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
+ });
},
refresh: function (frm) {
frm.trigger("set_label");
frm.trigger("set_dynamic_field_label");
+ if (frm.doc.docstatus === 0) {
+ erpnext.set_unit_price_items_note(frm);
+ }
+
let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json
index f2bb81b3b4a..3e38f126726 100644
--- a/erpnext/selling/doctype/quotation/quotation.json
+++ b/erpnext/selling/doctype/quotation/quotation.json
@@ -20,6 +20,7 @@
"column_break1",
"order_type",
"company",
+ "has_unit_price_items",
"amended_from",
"currency_and_price_list",
"currency",
@@ -1089,13 +1090,21 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "has_unit_price_items",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Unit Price Items",
+ "no_copy": 1
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
- "modified": "2025-01-06 15:00:08.774925",
+ "modified": "2025-03-03 16:49:20.050303",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
@@ -1186,6 +1195,7 @@
"role": "Maintenance User"
}
],
+ "row_format": "Dynamic",
"search_fields": "status,transaction_date,party_name,order_type",
"show_name_in_global_search": 1,
"sort_field": "creation",
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 8b2257f3c5e..5eebead98d2 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -66,6 +66,7 @@ class Quotation(SellingController):
enq_det: DF.Text | None
grand_total: DF.Currency
group_same_items: DF.Check
+ has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -127,6 +128,10 @@ class Quotation(SellingController):
self.indicator_color = "gray"
self.indicator_title = "Expired"
+ def before_validate(self):
+ self.set_has_unit_price_items()
+ self.flags.allow_zero_qty = self.has_unit_price_items
+
def validate(self):
super().validate()
self.set_status()
@@ -158,6 +163,17 @@ class Quotation(SellingController):
if not row.is_alternative and row.name in items_with_alternatives:
row.has_alternative_item = 1
+ def set_has_unit_price_items(self):
+ """
+ If permitted in settings and any item has 0 qty, the SO has unit price items.
+ """
+ if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_quotation"):
+ return
+
+ self.has_unit_price_items = any(
+ not row.qty for row in self.get("items") if (row.item_code and not row.qty)
+ )
+
def get_ordered_status(self):
status = "Open"
ordered_items = frappe._dict(
@@ -368,6 +384,12 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
+ # 0 qty is accepted, as the qty uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source) -> bool:
+ return has_unit_price_items and source.qty == 0
+
def set_missing_values(source, target):
if customer:
target.customer = customer.name
@@ -396,7 +418,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
- balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
+ balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.item_code, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
@@ -410,22 +432,22 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
Row mapping from Quotation to Sales order:
1. If no selections, map all non-alternative rows (that sum up to the grand total)
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
- 3. If selections: Simple row: Map if adequate qty
+ 3. If no selections: Simple row: Map if adequate qty
"""
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
- if balance_qty <= 0:
- return False
+ has_valid_qty: bool = (balance_qty > 0) or is_unit_price_row(item)
- has_qty = balance_qty
+ if not has_valid_qty:
+ return False
if not selected_rows:
return not item.is_alternative
if selected_rows and (item.is_alternative or item.has_alternative_item):
- return (item.name in selected_rows) and has_qty
+ return item.name in selected_rows
# Simple row
- return has_qty
+ return True
doclist = get_mapped_doc(
"Quotation",
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index b8891ad577f..9f982329710 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
-from frappe.tests import IntegrationTestCase, UnitTestCase
+from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -30,6 +30,15 @@ class TestQuotation(IntegrationTestCase):
qo.save()
self.assertEqual(qo.items[0].qty, 1)
+ def test_quotation_zero_qty(self):
+ """
+ Test if Quote with zero qty (Unit Price Item) is conditionally allowed.
+ """
+ qo = make_quotation(qty=0, do_not_save=True)
+ with change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}):
+ qo.save()
+ self.assertEqual(qo.items[0].qty, 0)
+
def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get("payment_schedule"))
@@ -784,6 +793,39 @@ class TestQuotation(IntegrationTestCase):
self.assertEqual(quotation.rounding_adjustment, 0)
self.assertEqual(quotation.rounded_total, 0)
+ @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1})
+ def test_so_from_zero_qty_quotation(self):
+ from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ make_item("_Test Item 2", {"is_stock_item": 1})
+ quotation = make_quotation(qty=0, do_not_save=1)
+ quotation.append("items", {"item_code": "_Test Item 2", "qty": 10, "rate": 100})
+ quotation.submit()
+
+ sales_order = make_sales_order(quotation.name)
+ sales_order.delivery_date = nowdate()
+ self.assertEqual(sales_order.items[0].qty, 0)
+ self.assertEqual(sales_order.items[1].qty, 10)
+
+ sales_order.items[0].qty = 10
+ sales_order.items[1].qty = 5
+ sales_order.submit()
+
+ quotation.reload()
+ self.assertEqual(quotation.status, "Partially Ordered")
+
+ sales_order_2 = make_sales_order(quotation.name)
+ sales_order_2.delivery_date = nowdate()
+ self.assertEqual(sales_order_2.items[0].qty, 0)
+ self.assertEqual(sales_order_2.items[1].qty, 5)
+
+ del sales_order_2.items[0]
+ sales_order_2.submit()
+
+ quotation.reload()
+ self.assertEqual(quotation.status, "Ordered")
+
def enable_calculate_bundle_price(enable=1):
selling_settings = frappe.get_doc("Selling Settings")
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 40734893066..1599ef48b63 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -23,7 +23,16 @@ frappe.ui.form.on("Sales Order", {
// formatter for material request item
frm.set_indicator_formatter("item_code", function (doc) {
- return doc.stock_qty <= doc.delivered_qty ? "green" : "orange";
+ let color;
+ if (!doc.qty && frm.doc.has_unit_price_items) {
+ color = "yellow";
+ } else if (doc.stock_qty <= doc.delivered_qty) {
+ color = "green";
+ } else {
+ color = "orange";
+ }
+
+ return color;
});
frm.set_query("bom_no", "items", function (doc, cdt, cdn) {
@@ -97,6 +106,8 @@ frappe.ui.form.on("Sales Order", {
}
if (frm.doc.docstatus === 0) {
+ erpnext.set_unit_price_items_note(frm);
+
if (frm.doc.is_internal_customer) {
frm.events.get_items_from_internal_purchase_order(frm);
}
@@ -586,10 +597,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
if (doc.status !== "Closed") {
if (doc.status !== "On Hold") {
+ const items_are_deliverable = this.frm.doc.items.some(
+ (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
+ );
allow_delivery =
- this.frm.doc.items.some(
- (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
- ) && !this.frm.doc.skip_delivery_note;
+ (this.frm.doc.has_unit_price_items || items_are_deliverable) &&
+ !this.frm.doc.skip_delivery_note;
if (this.frm.has_perm("submit")) {
if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) {
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 27521229b84..2f481028c1b 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -24,6 +24,7 @@
"po_date",
"company",
"skip_delivery_note",
+ "has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -1672,13 +1673,21 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "has_unit_price_items",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Unit Price Items",
+ "no_copy": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2025-02-06 16:02:20.320877",
+ "modified": "2025-03-03 16:49:00.676927",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1747,6 +1756,7 @@
"write": 1
}
],
+ "row_format": "Dynamic",
"search_fields": "status,transaction_date,customer,customer_name, territory,order_type,company",
"show_name_in_global_search": 1,
"sort_field": "creation",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index cdc9d285224..839bdef4f9d 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -114,6 +114,7 @@ class SalesOrder(SellingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
+ has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -201,6 +202,10 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
+ def before_validate(self):
+ self.set_has_unit_price_items()
+ self.flags.allow_zero_qty = self.has_unit_price_items
+
def validate(self):
super().validate()
self.validate_delivery_date()
@@ -244,6 +249,17 @@ class SalesOrder(SellingController):
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
self.reserve_stock = 1
+ def set_has_unit_price_items(self):
+ """
+ If permitted in settings and any item has 0 qty, the SO has unit price items.
+ """
+ if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order"):
+ return
+
+ self.has_unit_price_items = any(
+ not row.qty for row in self.get("items") if (row.item_code and not row.qty)
+ )
+
def validate_po(self):
# validate p.o date v/s delivery date
if self.po_date and not self.skip_delivery_note:
@@ -969,6 +985,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
}
+ # 0 qty is accepted, as the qty is uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
def set_missing_values(source, target):
if kwargs.get("ignore_pricing_rule"):
# Skip pricing rule when the dn is creating from the pick list
@@ -1008,12 +1030,16 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
if cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date:
return False
- return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
+ return (
+ (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
+ ) and doc.delivered_by_supplier != 1
def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
- target.qty = flt(source.qty) - flt(source.delivered_qty)
+ target.qty = (
+ flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
+ )
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -1096,6 +1122,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
+ # 0 qty is accepted, as the qty is uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
def postprocess(source, target):
set_missing_values(source, target)
# Get the advance paid Journal Entries in Sales Invoice Advance
@@ -1126,12 +1158,18 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
- target.amount = flt(source.amount) - flt(source.billed_amt)
+ if source_parent.has_unit_price_items:
+ # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
+ pending_amount = flt(source.amount) - flt(source.billed_amt)
+ target.amount = pending_amount if flt(source.amount) else 0
+ else:
+ target.amount = flt(source.amount) - flt(source.billed_amt)
+
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
target.amount / flt(source.rate)
if (source.rate and source.billed_amt)
- else source.qty - source.returned_qty
+ else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)
if source_parent.project:
@@ -1164,8 +1202,11 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
"parent": "sales_order",
},
"postprocess": update_item,
- "condition": lambda doc: doc.qty
- and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)),
+ "condition": lambda doc: (
+ True
+ if is_unit_price_row(doc)
+ else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
+ ),
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index e9a4d9d6cd5..5aa96ed5805 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -7,7 +7,7 @@ from unittest.mock import patch
import frappe
import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user
-from frappe.tests import IntegrationTestCase
+from frappe.tests import IntegrationTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -109,6 +109,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
so.save()
self.assertEqual(so.items[0].qty, 1)
+ def test_sales_order_zero_qty(self):
+ po = make_sales_order(qty=0, do_not_save=True)
+
+ with change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}):
+ po.save()
+ self.assertEqual(po.items[0].qty, 0)
+
def test_make_material_request(self):
so = make_sales_order(do_not_submit=True)
@@ -1988,7 +1995,12 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
pr = make_payment_request(
- dt=so.doctype, dn=so.name, order_type="Shopping Cart", submit_doc=True, return_doc=True
+ dt=so.doctype,
+ dn=so.name,
+ order_type="Shopping Cart",
+ submit_doc=True,
+ return_doc=True,
+ mute_email=True,
)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
@@ -2016,7 +2028,9 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
so = make_sales_order(qty=1, rate=100)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
- pr = make_payment_request(dt=so.doctype, dn=so.name, submit_doc=True, return_doc=True)
+ pr = make_payment_request(
+ dt=so.doctype, dn=so.name, submit_doc=True, return_doc=True, mute_email=True
+ )
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
pe = get_payment_entry(so.doctype, so.name).save().submit()
@@ -2360,6 +2374,77 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
sre_doc.reload()
self.assertTrue(sre_doc.status == "Delivered")
+ @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1})
+ def test_deliver_zero_qty_purchase_order(self):
+ """
+ Test the flow of a Unit Price SO and DN creation against it until completion.
+ Flow:
+ SO Qty 0 -> Deliver +5 -> Update SO Qty +10 -> Deliver +5 -> SO is 100% delivered
+ """
+ so = make_sales_order(qty=0)
+ dn = make_delivery_note(so.name)
+
+ self.assertEqual(dn.items[0].qty, 0)
+ dn.items[0].qty = 5
+ dn.submit()
+
+ # Test SO impact after DN
+ so.reload()
+ self.assertEqual(so.items[0].delivered_qty, 5)
+ self.assertFalse(so.per_delivered)
+ self.assertEqual(so.status, "To Deliver and Bill")
+
+ # Update SO Qty to final qty
+ first_item_of_so = so.items[0]
+ trans_item = json.dumps(
+ [
+ {
+ "item_code": first_item_of_so.item_code,
+ "rate": first_item_of_so.rate,
+ "qty": 10,
+ "docname": first_item_of_so.name,
+ }
+ ]
+ )
+ update_child_qty_rate("Sales Order", trans_item, so.name)
+
+ # Test: DN maps pending qty from SO
+ dn2 = make_delivery_note(so.name)
+
+ so.reload()
+ self.assertEqual(so.items[0].qty, 10)
+ self.assertEqual(dn2.items[0].qty, 5)
+
+ dn2.submit()
+
+ so.reload()
+ self.assertEqual(so.items[0].delivered_qty, 10)
+ self.assertEqual(so.per_delivered, 100.0)
+ self.assertEqual(so.status, "To Bill")
+
+ @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1})
+ def test_bill_zero_qty_sales_order(self):
+ so = make_sales_order(qty=0)
+
+ self.assertEqual(so.grand_total, 0)
+ self.assertFalse(so.per_billed)
+ self.assertEqual(so.items[0].qty, 0)
+ self.assertEqual(so.items[0].rate, 100)
+
+ si = make_sales_invoice(so.name)
+ self.assertEqual(si.items[0].qty, 0)
+ self.assertEqual(si.items[0].rate, 100)
+
+ si.items[0].qty = 5
+ si.submit()
+
+ so.reload()
+ self.assertEqual(so.items[0].amount, 0)
+ self.assertEqual(so.items[0].billed_amt, si.grand_total)
+ # SO still has qty 0, so billed % should be unset
+ self.assertFalse(so.per_billed)
+ self.assertEqual(so.status, "To Deliver and Bill")
+
def test_item_tax_transfer_from_sales_to_purchase(self):
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json
index 8203abcf2e2..7406b473f98 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.json
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.json
@@ -34,6 +34,8 @@
"hide_tax_id",
"enable_discount_accounting",
"enable_cutoff_date_on_bulk_delivery_note_creation",
+ "allow_zero_qty_in_quotation",
+ "allow_zero_qty_in_sales_order",
"experimental_section",
"use_server_side_reactivity"
],
@@ -220,14 +222,27 @@
"fieldname": "use_server_side_reactivity",
"fieldtype": "Check",
"label": "Use Server Side Reactivity"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_zero_qty_in_sales_order",
+ "fieldtype": "Check",
+ "label": "Allow 0 Qty in Sales Order (Unit Price Items)"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_zero_qty_in_quotation",
+ "fieldtype": "Check",
+ "label": "Allow 0 Qty in Quotation (Unit Price Items)"
}
],
+ "grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2024-12-06 11:41:54.722337",
+ "modified": "2025-03-03 16:39:16.360823",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",
@@ -252,6 +267,7 @@
"write": 1
}
],
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py
index 216a74ab688..f25101953a1 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.py
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.py
@@ -23,6 +23,8 @@ class SellingSettings(Document):
allow_multiple_items: DF.Check
allow_negative_rates_for_items: DF.Check
allow_sales_order_creation_for_expired_quotation: DF.Check
+ allow_zero_qty_in_quotation: DF.Check
+ allow_zero_qty_in_sales_order: DF.Check
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 3f892bfb8b6..49f04931932 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -13,7 +13,6 @@ from pypika import functions as fn
import erpnext
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
-from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.buying_controller import BuyingController