mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-17 08:35:00 +00:00
Merge pull request #46214 from marination/unit-price-contract-2
feat: Unit Price Items
This commit is contained in:
@@ -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": [],
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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":
|
||||
"""
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
`<div class="unit-price-items-note">
|
||||
${__("The {0} contains Unit Price Items.", [__(frm.doc.doctype)])}
|
||||
</div>`,
|
||||
"yellow",
|
||||
true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user