diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 93489231cf0..7af722a95ed 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1190,6 +1190,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 diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 03665b48a85..508eb51fec3 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) { + frm.trigger("set_unit_price_items_note"); + } + let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle"); if (sbb_field) { sbb_field.get_route_options_for_new_doc = (row) => { @@ -64,6 +72,16 @@ frappe.ui.form.on("Quotation", { set_label: function (frm) { frm.fields_dict.customer_address.set_label(__(frm.doc.quotation_to + " Address")); }, + + set_unit_price_items_note: function (frm) { + if (frm.doc.has_unit_price_items) { + frm.dashboard.set_headline_alert( + __("The Quotation contains Unit Price Items with 0 Qty."), + "yellow", + true + ); + } + }, }); erpnext.selling.QuotationController = class QuotationController extends erpnext.selling.SellingController { diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index f2bb81b3b4a..1b9a8c7d62c 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,20 @@ "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" } ], "icon": "fa fa-shopping-cart", "idx": 82, "is_submittable": 1, "links": [], - "modified": "2025-01-06 15:00:08.774925", + "modified": "2025-02-28 18:52:44.063265", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", @@ -1186,6 +1194,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..7924f648c08 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -19,8 +19,6 @@ class Quotation(SellingController): from typing import TYPE_CHECKING if TYPE_CHECKING: - from frappe.types import DF - from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import ( @@ -32,6 +30,7 @@ class Quotation(SellingController): QuotationLostReasonDetail, ) from erpnext.stock.doctype.packed_item.packed_item import PackedItem + from frappe.types import DF additional_discount_percentage: DF.Float address_display: DF.TextEditor | None @@ -66,6 +65,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 +127,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 +162,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( @@ -412,11 +427,14 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty 3. If selections: Simple row: Map if adequate qty """ + # has_unit_price_items = 0 is accepted as the qty uncertain for some items + has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items") + balance_qty = item.qty - ordered_items.get(item.item_code, 0.0) - if balance_qty <= 0: + if balance_qty <= 0 and not has_unit_price_items: return False - has_qty = balance_qty + has_qty = balance_qty or has_unit_price_items if not selected_rows: return not item.is_alternative diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 911dad9e2d7..10061a71518 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) { + frm.trigger("set_unit_price_items_note"); + if (frm.doc.is_internal_customer) { frm.events.get_items_from_internal_purchase_order(frm); } @@ -549,6 +560,16 @@ frappe.ui.form.on("Sales Order", { }; frappe.set_route("query-report", "Reserved Stock"); }, + + set_unit_price_items_note: function (frm) { + if (frm.doc.has_unit_price_items && !frm.is_new()) { + frm.dashboard.set_headline_alert( + __("The Sales Order contains Unit Price Items with 0 Qty."), + "yellow", + true + ); + } + }, }); frappe.ui.form.on("Sales Order Item", { diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 27521229b84..4cf7816e177 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,20 @@ "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" } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-02-06 16:02:20.320877", + "modified": "2025-02-28 18:52:01.932669", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1747,6 +1755,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 5800c9bb4e2..1445d5ba474 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -57,16 +57,13 @@ class SalesOrder(SellingController): from typing import TYPE_CHECKING if TYPE_CHECKING: - from frappe.types import DF - from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail - from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import ( - SalesTaxesandCharges, - ) + from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import SalesTaxesandCharges from erpnext.selling.doctype.sales_order_item.sales_order_item import SalesOrderItem from erpnext.selling.doctype.sales_team.sales_team import SalesTeam from erpnext.stock.doctype.packed_item.packed_item import PackedItem + from frappe.types import DF additional_discount_percentage: DF.Float address_display: DF.TextEditor | None @@ -104,9 +101,7 @@ class SalesOrder(SellingController): customer_group: DF.Link | None customer_name: DF.Data | None delivery_date: DF.Date | None - delivery_status: DF.Literal[ - "Not Delivered", "Fully Delivered", "Partly Delivered", "Closed", "Not Applicable" - ] + delivery_status: DF.Literal["Not Delivered", "Fully Delivered", "Partly Delivered", "Closed", "Not Applicable"] disable_rounded_total: DF.Check discount_amount: DF.Currency dispatch_address: DF.TextEditor | None @@ -114,6 +109,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 @@ -156,18 +152,7 @@ class SalesOrder(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None skip_delivery_note: DF.Check - status: DF.Literal[ - "", - "Draft", - "On Hold", - "To Pay", - "To Deliver and Bill", - "To Bill", - "To Deliver", - "Completed", - "Cancelled", - "Closed", - ] + status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"] tax_category: DF.Link | None tax_id: DF.Data | None taxes: DF.Table[SalesTaxesandCharges] @@ -201,6 +186,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 +233,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: @@ -1118,7 +1118,13 @@ 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) @@ -1136,6 +1142,10 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): if cost_center: target.cost_center = cost_center + # has_unit_price_items = 0 is accepted as the qty uncertain for some items + has_unit_price_items = frappe.db.get_value( + "Sales Order", source_name, "has_unit_price_items" + ) doclist = get_mapped_doc( "Sales Order", source_name, @@ -1156,8 +1166,13 @@ 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: ( + doc.qty + and ( + doc.base_amount == 0 + or abs(doc.billed_amt) < abs(doc.amount) + ) + ) or has_unit_price_items, }, "Sales Taxes and Charges": { "doctype": "Sales Taxes and Charges", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 8203abcf2e2..9589246a98f 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_sales_order", + "allow_zero_qty_in_quotation", "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 Contract)" + }, + { + "default": "0", + "fieldname": "allow_zero_qty_in_quotation", + "fieldtype": "Check", + "label": "Allow 0 Qty in Quotation (Unit Price Contract)" } ], + "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-02-28 18:19:46.436595", "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