From c1e4e7af28fd7005ee26eb3dbb0c1d3609fa10b3 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 28 Feb 2025 23:04:29 +0100 Subject: [PATCH 1/8] feat: Unit Price Contract --- erpnext/controllers/accounts_controller.py | 3 + .../selling/doctype/quotation/quotation.js | 18 ++++++ .../selling/doctype/quotation/quotation.json | 11 +++- .../selling/doctype/quotation/quotation.py | 26 ++++++-- .../doctype/sales_order/sales_order.js | 23 ++++++- .../doctype/sales_order/sales_order.json | 11 +++- .../doctype/sales_order/sales_order.py | 61 ++++++++++++------- .../selling_settings/selling_settings.json | 18 +++++- .../selling_settings/selling_settings.py | 2 + 9 files changed, 142 insertions(+), 31 deletions(-) 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 From 71f65bab5e140cf8ba2f3e64e209b7b33bf35b71 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 3 Mar 2025 16:24:22 +0100 Subject: [PATCH 2/8] fix: Linters --- .../selling/doctype/quotation/quotation.py | 3 +- .../doctype/sales_order/sales_order.py | 37 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 7924f648c08..3fef12aea7f 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -19,6 +19,8 @@ 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 ( @@ -30,7 +32,6 @@ 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 diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 1445d5ba474..219e679f3e3 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -57,13 +57,16 @@ 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 @@ -101,7 +104,9 @@ 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 @@ -152,7 +157,18 @@ 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] @@ -1143,9 +1159,7 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): 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" - ) + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") doclist = get_mapped_doc( "Sales Order", source_name, @@ -1167,12 +1181,9 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): }, "postprocess": update_item, "condition": lambda doc: ( - doc.qty - and ( - doc.base_amount == 0 - or abs(doc.billed_amt) < abs(doc.amount) - ) - ) or has_unit_price_items, + 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", From e403d3f153a3d1c134a844c5c22139521e250480 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 3 Mar 2025 17:53:00 +0100 Subject: [PATCH 3/8] feat: Unit Price Items in Buying (RFQ, SQ, PO) - chore: Extract `set_unit_price_items_note` into a util --- .../buying_settings/buying_settings.json | 25 ++++++++++++++++++- .../buying_settings/buying_settings.py | 3 +++ .../doctype/purchase_order/purchase_order.js | 14 ++++++++++- .../purchase_order/purchase_order.json | 13 +++++++++- .../doctype/purchase_order/purchase_order.py | 16 ++++++++++++ .../request_for_quotation.js | 8 ++++++ .../request_for_quotation.json | 13 +++++++++- .../request_for_quotation.py | 16 ++++++++++++ .../supplier_quotation/supplier_quotation.js | 7 ++++++ .../supplier_quotation.json | 13 +++++++++- .../supplier_quotation/supplier_quotation.py | 16 ++++++++++++ erpnext/public/js/controllers/transaction.js | 10 ++++++++ .../selling/doctype/quotation/quotation.js | 12 +-------- .../selling/doctype/quotation/quotation.json | 5 ++-- .../doctype/sales_order/sales_order.js | 12 +-------- .../doctype/sales_order/sales_order.json | 5 ++-- .../selling_settings/selling_settings.json | 8 +++--- 17 files changed, 161 insertions(+), 35 deletions(-) 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 2ee6cf7f00c..ea383bfc190 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 cac6854b89e..30cf3118a45 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", @@ -1282,13 +1283,22 @@ "oldfieldtype": "Select", "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 } ], + "grid_page_length": 50, "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:24.518785", + "modified": "2025-03-03 16:48:08.697520", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", @@ -1335,6 +1345,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/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 5ee650ead60..a6b2d15d4a3 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -95,6 +95,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 @@ -189,6 +190,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() @@ -225,6 +230,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: 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 75be5574353..784dda1a72b 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) { @@ -155,6 +159,10 @@ frappe.ui.form.on("Request for Quotation", { frm.page.set_inner_btn_group_as_primary(__("Create")); } + + if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + } }, make_supplier_quotation: function (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/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 0829b27151f..719608c1991 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/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 0bec99e06fb..dae29510fff 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2755,3 +2755,13 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => { } }); }; + +erpnext.set_unit_price_items_note = (frm) => { + if (frm.doc.has_unit_price_items && !frm.is_new()) { + frm.dashboard.set_headline_alert( + __("The {0} contains Unit Price Items with 0 Qty.", [__(frm.doc.doctype)]), + "yellow", + true + ); + } +}; diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 508eb51fec3..c4c0febdcd0 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -46,7 +46,7 @@ frappe.ui.form.on("Quotation", { frm.trigger("set_dynamic_field_label"); if (frm.doc.docstatus === 0) { - frm.trigger("set_unit_price_items_note"); + erpnext.set_unit_price_items_note(frm); } let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle"); @@ -72,16 +72,6 @@ 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 1b9a8c7d62c..3e38f126726 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1096,14 +1096,15 @@ "fieldname": "has_unit_price_items", "fieldtype": "Check", "hidden": 1, - "label": "Has Unit Price Items" + "label": "Has Unit Price Items", + "no_copy": 1 } ], "icon": "fa fa-shopping-cart", "idx": 82, "is_submittable": 1, "links": [], - "modified": "2025-02-28 18:52:44.063265", + "modified": "2025-03-03 16:49:20.050303", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 10061a71518..a62c4715f38 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -106,7 +106,7 @@ frappe.ui.form.on("Sales Order", { } if (frm.doc.docstatus === 0) { - frm.trigger("set_unit_price_items_note"); + erpnext.set_unit_price_items_note(frm); if (frm.doc.is_internal_customer) { frm.events.get_items_from_internal_purchase_order(frm); @@ -560,16 +560,6 @@ 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 4cf7816e177..2f481028c1b 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1679,14 +1679,15 @@ "fieldname": "has_unit_price_items", "fieldtype": "Check", "hidden": 1, - "label": "Has Unit Price Items" + "label": "Has Unit Price Items", + "no_copy": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-02-28 18:52:01.932669", + "modified": "2025-03-03 16:49:00.676927", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 9589246a98f..7406b473f98 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -34,8 +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", + "allow_zero_qty_in_sales_order", "experimental_section", "use_server_side_reactivity" ], @@ -227,13 +227,13 @@ "default": "0", "fieldname": "allow_zero_qty_in_sales_order", "fieldtype": "Check", - "label": "Allow 0 Qty in Sales Order (Unit Price Contract)" + "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 Contract)" + "label": "Allow 0 Qty in Quotation (Unit Price Items)" } ], "grid_page_length": 50, @@ -242,7 +242,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-02-28 18:19:46.436595", + "modified": "2025-03-03 16:39:16.360823", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", From 8f96c0b5461488dbe2739d5d8e65d42a44b3b301 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Wed, 12 Mar 2025 18:32:59 +0100 Subject: [PATCH 4/8] test: Zero Qty in RFQ and Supplier Quotation --- .../test_request_for_quotation.py | 20 +++++++++++++- .../test_supplier_quotation.py | 26 ++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) 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..038a7640059 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,15 @@ 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() + def test_quote_status(self): rfq = make_request_for_quotation() @@ -181,6 +190,15 @@ class TestRequestforQuotation(IntegrationTestCase): supplier_doc.reload() self.assertTrue(supplier_doc.portal_users[0].user) + @change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}) + def test_map_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/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index dad2cba4877..d1f03586a36 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -3,8 +3,9 @@ import frappe -from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings +from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order from erpnext.controllers.accounts_controller import InvalidQtyError @@ -29,9 +30,17 @@ 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() + + 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) @@ -50,3 +59,14 @@ class TestPurchaseOrder(IntegrationTestCase): doc.set("schedule_date", "2013-04-12") po.insert() + + @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) From eea758f5b2c986b0efccdb6f387475b9800ff4e0 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:00:40 +0100 Subject: [PATCH 5/8] test: Purchase Order with Unit Price Items - chore: Fix error message in accounts controller --- .../doctype/purchase_order/purchase_order.py | 8 +- .../purchase_order/test_purchase_order.py | 85 ++++++++++++++++++- .../test_request_for_quotation.py | 5 +- .../test_supplier_quotation.py | 3 +- erpnext/controllers/accounts_controller.py | 6 +- .../purchase_receipt/purchase_receipt.py | 1 - 6 files changed, 98 insertions(+), 10 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index a6b2d15d4a3..231374d5e62 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -745,8 +745,10 @@ 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 update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.received_qty) + target.qty = flt(obj.qty) - flt(obj.received_qty) if not has_unit_price_items else 0 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 = ( @@ -777,7 +779,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: ( + abs(doc.received_qty) < abs(doc.qty) if not has_unit_price_items else True + ) 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 97d87d931db..71466e57c27 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) @@ -1239,6 +1246,82 @@ 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) + # PO still has qty 0, so billed % should be unset + self.assertFalse(po.per_received) + self.assertEqual(po.status, "To Receive and Bill") + + # Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty + pr2 = make_purchase_receipt(po.name) + self.assertEqual(pr2.items[0].qty, 0) + pr2.items[0].qty = 5 + pr2.submit() + + po.reload() + self.assertEqual(po.items[0].received_qty, 10) + 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) + + # PO should be updated to 100% received + po.reload() + self.assertEqual(po.items[0].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/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 038a7640059..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 @@ -49,6 +49,7 @@ class TestRequestforQuotation(IntegrationTestCase): 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() @@ -190,8 +191,8 @@ class TestRequestforQuotation(IntegrationTestCase): supplier_doc.reload() self.assertTrue(supplier_doc.portal_users[0].user) - @change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}) - def test_map_supplier_quotation_from_zero_qty_rfq(self): + @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) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index d1f03586a36..af250cc63e3 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -39,6 +39,7 @@ class TestPurchaseOrder(IntegrationTestCase): 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() @@ -60,7 +61,7 @@ class TestPurchaseOrder(IntegrationTestCase): po.insert() - @change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) + @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 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7af722a95ed..07bc96faeb0 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3658,9 +3658,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/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 32d3e4171ec..0ad62f7d512 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 From 55981c8358f1d9f503ceec918f217c209896180f Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:54:11 +0100 Subject: [PATCH 6/8] test: Sales Order + fix: Mapping of Items from Quotation & SO --- .../purchase_order/test_purchase_order.py | 2 +- .../selling/doctype/quotation/quotation.py | 8 +- .../doctype/sales_order/sales_order.js | 8 +- .../doctype/sales_order/sales_order.py | 9 +- .../doctype/sales_order/test_sales_order.py | 85 ++++++++++++++++++- 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 71466e57c27..7c59b7f271b 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1262,7 +1262,7 @@ class TestPurchaseOrder(IntegrationTestCase): po.reload() self.assertEqual(po.items[0].received_qty, 5) - # PO still has qty 0, so billed % should be unset + # PO still has qty 0, so received % should be unset self.assertFalse(po.per_received) self.assertEqual(po.status, "To Receive and Bill") diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 3fef12aea7f..3fd277c45ed 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -381,6 +381,8 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): as_list=1, ) ) + # 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") selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] @@ -428,14 +430,12 @@ 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 and not has_unit_price_items: + # False if qty <=0 in a 'normal' scenario return False - has_qty = balance_qty or has_unit_price_items + has_qty: bool = (balance_qty > 0) 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 a62c4715f38..638568b4279 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -618,10 +618,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.py b/erpnext/selling/doctype/sales_order/sales_order.py index 219e679f3e3..0b9ab56200e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -967,6 +967,9 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): kwargs = frappe._dict(kwargs) + # 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") + sre_details = {} if kwargs.for_reserved_stock: sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) @@ -1016,12 +1019,14 @@ 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 has_unit_price_items + ) 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) - flt(source.delivered_qty) if not has_unit_price_items else 0 item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 941af58d380..25a9b3f295a 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) @@ -2321,6 +2328,82 @@ 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 -> Deliver +5 -> Update SO Qty +10 -> 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() + + so.reload() + self.assertEqual(so.items[0].delivered_qty, 5) + # SO still has qty 0, so delivered % should be unset + self.assertFalse(so.per_delivered) + self.assertEqual(so.status, "To Deliver and Bill") + + # Test: DN can be made against SO as long SO qty is 0 OR SO qty > delivered qty + dn2 = make_delivery_note(so.name) + self.assertEqual(dn2.items[0].qty, 0) + dn2.items[0].qty = 5 + dn2.submit() + + so.reload() + self.assertEqual(so.items[0].delivered_qty, 10) + self.assertFalse(so.per_delivered) + self.assertEqual(so.status, "To Deliver and Bill") + + # Update SO Item Qty to 10 after delivery of items + 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) + + # SO should be updated to 100% delivered + so.reload() + self.assertEqual(so.items[0].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() + + self.assertEqual(si.grand_total, 500) + + so.reload() + self.assertEqual(so.items[0].amount, 0) + self.assertEqual(so.items[0].billed_amt, 500) + # SO still has qty 0, so billed % should be unset + self.assertFalse(so.per_billed) + self.assertEqual(so.status, "To Deliver and Bill") + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") From 0447c7be0abf428e049b76f871a691214c3b613a Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:08:16 +0100 Subject: [PATCH 7/8] fix: Treat rows as Unit Price rows only until the qty is 0 - The unit price check should depend on the row qty being 0 - Once the row ceases to be 0, it is treated as an ordinary row - test: PO, SO and Quotation --- .../doctype/purchase_order/purchase_order.py | 7 ++- .../purchase_order/test_purchase_order.py | 22 +++++----- .../selling/doctype/quotation/quotation.py | 21 +++++---- .../doctype/quotation/test_quotation.py | 44 ++++++++++++++++++- .../doctype/sales_order/sales_order.py | 32 +++++++++----- .../doctype/sales_order/test_sales_order.py | 42 +++++++++--------- 6 files changed, 113 insertions(+), 55 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 231374d5e62..8d72381e458 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -747,8 +747,11 @@ def set_missing_values(source, target): 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) if not has_unit_price_items else 0 + 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 = ( @@ -780,7 +783,7 @@ def make_purchase_receipt(source_name, target_doc=None): }, "postprocess": update_item, "condition": lambda doc: ( - abs(doc.received_qty) < abs(doc.qty) if not has_unit_price_items else True + True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) ) and doc.delivered_by_supplier != 1, }, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 7c59b7f271b..f326deb87a7 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1262,18 +1262,6 @@ class TestPurchaseOrder(IntegrationTestCase): po.reload() self.assertEqual(po.items[0].received_qty, 5) - # PO still has qty 0, so received % should be unset - self.assertFalse(po.per_received) - self.assertEqual(po.status, "To Receive and Bill") - - # Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty - pr2 = make_purchase_receipt(po.name) - self.assertEqual(pr2.items[0].qty, 0) - pr2.items[0].qty = 5 - pr2.submit() - - po.reload() - self.assertEqual(po.items[0].received_qty, 10) self.assertFalse(po.per_received) self.assertEqual(po.status, "To Receive and Bill") @@ -1291,9 +1279,19 @@ class TestPurchaseOrder(IntegrationTestCase): ) 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") diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 3fd277c45ed..5eebead98d2 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -381,10 +381,14 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): as_list=1, ) ) + + 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") - selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_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: @@ -414,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) @@ -428,23 +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 and not has_unit_price_items: - # False if qty <=0 in a 'normal' scenario - return False + has_valid_qty: bool = (balance_qty > 0) or is_unit_price_row(item) - has_qty: bool = (balance_qty > 0) or has_unit_price_items + 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.py b/erpnext/selling/doctype/sales_order/sales_order.py index 0b9ab56200e..1daab8d5d14 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -967,9 +967,6 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): kwargs = frappe._dict(kwargs) - # 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") - sre_details = {} if kwargs.for_reserved_stock: sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) @@ -980,6 +977,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 @@ -1020,13 +1023,15 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): return False return ( - (abs(doc.delivered_qty) < abs(doc.qty)) or has_unit_price_items + (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) if not has_unit_price_items else 0 + 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) @@ -1109,6 +1114,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 @@ -1150,7 +1161,7 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): 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: @@ -1163,8 +1174,6 @@ 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, @@ -1186,9 +1195,10 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): }, "postprocess": update_item, "condition": lambda doc: ( - doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) - ) - or has_unit_price_items, + 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 25a9b3f295a..7b0affb2579 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1995,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") @@ -2023,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() @@ -2333,7 +2340,7 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): """ Test the flow of a Unit Price SO and DN creation against it until completion. Flow: - SO Qty 0 -> Deliver +5 -> Deliver +5 -> Update SO Qty +10 -> SO is 100% delivered + 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) @@ -2342,24 +2349,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): dn.items[0].qty = 5 dn.submit() + # Test SO impact after DN so.reload() self.assertEqual(so.items[0].delivered_qty, 5) - # SO still has qty 0, so delivered % should be unset self.assertFalse(so.per_delivered) self.assertEqual(so.status, "To Deliver and Bill") - # Test: DN can be made against SO as long SO qty is 0 OR SO qty > delivered qty - dn2 = make_delivery_note(so.name) - self.assertEqual(dn2.items[0].qty, 0) - dn2.items[0].qty = 5 - dn2.submit() - - so.reload() - self.assertEqual(so.items[0].delivered_qty, 10) - self.assertFalse(so.per_delivered) - self.assertEqual(so.status, "To Deliver and Bill") - - # Update SO Item Qty to 10 after delivery of items + # Update SO Qty to final qty first_item_of_so = so.items[0] trans_item = json.dumps( [ @@ -2373,9 +2369,17 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): ) update_child_qty_rate("Sales Order", trans_item, so.name) - # SO should be updated to 100% delivered + # 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") @@ -2395,11 +2399,9 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): si.items[0].qty = 5 si.submit() - self.assertEqual(si.grand_total, 500) - so.reload() self.assertEqual(so.items[0].amount, 0) - self.assertEqual(so.items[0].billed_amt, 500) + 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") From bf62f9ad578e9d655451088398eaaa2f772348c7 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:26:45 +0100 Subject: [PATCH 8/8] fix: Headline rendered twice on first save - `refresh` gets triggered twice and that renders the note twice - Remove any existing note before rendering --- erpnext/public/js/controllers/transaction.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index dae29510fff..8bf9c0eafd6 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2758,8 +2758,14 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => { erpnext.set_unit_price_items_note = (frm) => { if (frm.doc.has_unit_price_items && !frm.is_new()) { - frm.dashboard.set_headline_alert( - __("The {0} contains Unit Price Items with 0 Qty.", [__(frm.doc.doctype)]), + // 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 );