From 33366fce6c97ede0dac51cad19315c922939fffe Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 28 Feb 2025 23:04:29 +0100 Subject: [PATCH 01/13] feat: Unit Price Contract (cherry picked from commit c1e4e7af28fd7005ee26eb3dbb0c1d3609fa10b3) # Conflicts: # erpnext/controllers/accounts_controller.py # erpnext/selling/doctype/quotation/quotation.json # erpnext/selling/doctype/sales_order/sales_order.py # erpnext/selling/doctype/selling_settings/selling_settings.json --- erpnext/controllers/accounts_controller.py | 9 ++++ .../selling/doctype/quotation/quotation.js | 18 +++++++ .../selling/doctype/quotation/quotation.json | 13 +++++ .../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 | 52 +++++++++++++++---- .../selling_settings/selling_settings.json | 51 ++++++++++++++++++ .../selling_settings/selling_settings.py | 2 + 9 files changed, 188 insertions(+), 17 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f576bc91541..a3180388141 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1259,8 +1259,17 @@ class AccountsController(TransactionBase): ) def validate_qty_is_not_zero(self): +<<<<<<< HEAD if self.doctype == "Purchase Receipt": return +======= + if self.flags.allow_zero_qty: + return + + for item in self.items: + if self.doctype == "Purchase Receipt" and item.rejected_qty: + continue +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) for item in self.items: if not flt(item.qty): diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 2e88ba9e482..07869ab339b 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 4d257ff69e7..e087256d8d4 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -21,6 +21,7 @@ "column_break1", "order_type", "company", + "has_unit_price_items", "amended_from", "currency_and_price_list", "currency", @@ -1084,13 +1085,24 @@ "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": [], +<<<<<<< HEAD "modified": "2024-11-26 12:43:29.293637", +======= + "modified": "2025-02-28 18:52:44.063265", +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) "modified_by": "Administrator", "module": "Selling", "name": "Quotation", @@ -1181,6 +1193,7 @@ "role": "Maintenance User" } ], + "row_format": "Dynamic", "search_fields": "status,transaction_date,party_name,order_type", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 48614671da2..ce74e1bafed 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.SmallText | None @@ -67,6 +66,7 @@ class Quotation(SellingController): enq_det: DF.Text | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -126,6 +126,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() @@ -157,6 +161,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( @@ -411,11 +426,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 400acd3c6f1..6c8cc7ac1df 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); } @@ -528,6 +539,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 4db7bf6f003..5fc808798e2 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -25,6 +25,7 @@ "po_date", "company", "skip_delivery_note", + "has_unit_price_items", "amended_from", "accounting_dimensions_section", "cost_center", @@ -1649,13 +1650,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", @@ -1724,6 +1732,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": "modified", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index f7bf732e8e8..73604f86d29 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -52,16 +52,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.SmallText | None @@ -100,9 +97,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.SmallText | None @@ -110,6 +105,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 @@ -152,6 +148,7 @@ class SalesOrder(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None skip_delivery_note: DF.Check +<<<<<<< HEAD source: DF.Link | None status: DF.Literal[ "", @@ -165,6 +162,9 @@ class SalesOrder(SellingController): "Cancelled", "Closed", ] +======= + status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"] +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) tax_category: DF.Link | None tax_id: DF.Data | None taxes: DF.Table[SalesTaxesandCharges] @@ -195,6 +195,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() @@ -231,6 +235,17 @@ class SalesOrder(SellingController): 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 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: @@ -1093,7 +1108,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) @@ -1111,6 +1132,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, @@ -1131,8 +1156,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 d6829ce24b0..01b7701f488 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -32,7 +32,16 @@ "allow_sales_order_creation_for_expired_quotation", "dont_reserve_sales_order_qty_on_sales_return", "hide_tax_id", +<<<<<<< HEAD "enable_discount_accounting" +======= + "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" +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) ], "fields": [ { @@ -200,14 +209,51 @@ "fieldname": "blanket_order_allowance", "fieldtype": "Float", "label": "Blanket Order Allowance (%)" +<<<<<<< HEAD +======= + }, + { + "default": "0", + "fieldname": "enable_cutoff_date_on_bulk_delivery_note_creation", + "fieldtype": "Check", + "label": "Enable Cut-Off Date on Bulk Delivery Note Creation" + }, + { + "fieldname": "experimental_section", + "fieldtype": "Section Break", + "label": "Experimental" + }, + { + "default": "1", + "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)" +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) } ], + "grid_page_length": 50, "icon": "fa fa-cog", "idx": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2023-10-25 14:03:03.966701", +======= + "modified": "2025-02-28 18:19:46.436595", +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -232,7 +278,12 @@ "write": 1 } ], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> c1e4e7af28 (feat: Unit Price Contract) "sort_order": "DESC", "states": [], "track_changes": 1 diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index 24fe909a7a6..2954c353a77 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 91e167fe72814c04a78a9d94d7d6d0b92fc197d4 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 3 Mar 2025 16:24:22 +0100 Subject: [PATCH 02/13] fix: Linters (cherry picked from commit 71f65bab5e140cf8ba2f3e64e209b7b33bf35b71) # Conflicts: # erpnext/selling/doctype/sales_order/sales_order.py --- .../selling/doctype/quotation/quotation.py | 3 +- .../doctype/sales_order/sales_order.py | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index ce74e1bafed..a4f79c1683d 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.SmallText | None diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 73604f86d29..54ee5f0d21d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -52,13 +52,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.SmallText | None @@ -97,7 +100,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.SmallText | None @@ -148,8 +153,11 @@ class SalesOrder(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None skip_delivery_note: DF.Check +<<<<<<< HEAD <<<<<<< HEAD source: DF.Link | None +======= +>>>>>>> 71f65bab5e (fix: Linters) status: DF.Literal[ "", "Draft", @@ -162,9 +170,12 @@ class SalesOrder(SellingController): "Cancelled", "Closed", ] +<<<<<<< HEAD ======= status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"] >>>>>>> c1e4e7af28 (feat: Unit Price Contract) +======= +>>>>>>> 71f65bab5e (fix: Linters) tax_category: DF.Link | None tax_id: DF.Data | None taxes: DF.Table[SalesTaxesandCharges] @@ -1133,9 +1144,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, @@ -1157,12 +1166,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 f8fa775af345fe0016d70d76aa2d2ae065088820 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 3 Mar 2025 17:53:00 +0100 Subject: [PATCH 03/13] feat: Unit Price Items in Buying (RFQ, SQ, PO) - chore: Extract `set_unit_price_items_note` into a util (cherry picked from commit e403d3f153a3d1c134a844c5c22139521e250480) # Conflicts: # erpnext/buying/doctype/buying_settings/buying_settings.json # erpnext/buying/doctype/purchase_order/purchase_order.json # erpnext/buying/doctype/request_for_quotation/request_for_quotation.json # erpnext/selling/doctype/quotation/quotation.json # erpnext/selling/doctype/selling_settings/selling_settings.json --- .../buying_settings/buying_settings.json | 31 +++++++++++++++++++ .../buying_settings/buying_settings.py | 3 ++ .../doctype/purchase_order/purchase_order.js | 14 ++++++++- .../purchase_order/purchase_order.json | 14 +++++++++ .../doctype/purchase_order/purchase_order.py | 16 ++++++++++ .../request_for_quotation.js | 8 +++++ .../request_for_quotation.json | 15 +++++++++ .../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 | 7 ++++- .../doctype/sales_order/sales_order.js | 12 +------ .../doctype/sales_order/sales_order.json | 5 +-- .../selling_settings/selling_settings.json | 12 +++++-- 17 files changed, 182 insertions(+), 29 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index ae854f29343..a92eb23a23d 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,37 @@ "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": [], +<<<<<<< HEAD "modified": "2024-01-31 13:34:18.101256", +======= + "modified": "2025-03-03 17:32:25.939482", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -260,7 +286,12 @@ "role": "Purchase User" } ], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "sort_order": "DESC", "states": [], "track_changes": 1 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 0b7c9de467a..7925d59d25a 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 7c9362ebaf9..bc63e8756ad 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", @@ -1280,11 +1281,20 @@ "print_hide": 1 }, { +<<<<<<< HEAD "fieldname": "dispatch_address_display", "fieldtype": "Text Editor", "label": "Dispatch Address Details", "print_hide": 1, "read_only": 1 +======= + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) } ], "grid_page_length": 50, @@ -1292,7 +1302,11 @@ "idx": 105, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2025-04-09 16:54:08.836106", +======= + "modified": "2025-03-03 16:48:08.697520", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index a18d9fce186..9ddbbb991d6 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -97,6 +97,7 @@ class PurchaseOrder(BuyingController): from_date: DF.Date | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -191,6 +192,10 @@ class PurchaseOrder(BuyingController): self.set_onload("supplier_tds", supplier_tds) self.set_onload("can_update_items", self.can_update_items()) + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() @@ -223,6 +228,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 96597bd9753..e88a98759d0 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", { is_group: 0, }, })); + + frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : ""; + }); }, onload: function (frm) { @@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", { __("View") ); } + + if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + } }, show_supplier_quotation_comparison(frm) { diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index fd73f77ff8f..056535af17b 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,26 @@ "fieldtype": "Small Text", "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": [], +<<<<<<< HEAD "modified": "2023-11-06 12:45:28.898706", +======= + "modified": "2025-03-03 16:48:39.856779", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", @@ -377,6 +391,7 @@ "role": "All" } ], + "row_format": "Dynamic", "search_fields": "status, transaction_date", "show_name_in_global_search": 1, "sort_field": "modified", 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 bef41394742..e9bc9694a70 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.SmallText | 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() @@ -72,6 +77,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 c1698710135..fccca81f8ce 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(); } @@ -26,6 +31,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("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 4a8cd8bf9e6..6682c7e3585 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": "modified", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 215022e18a6..5557f1a80ae 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 1fcdd459a3f..886ddd2acc0 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2773,3 +2773,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 07869ab339b..6cb676ac58e 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 e087256d8d4..5bf8f474625 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1091,18 +1091,23 @@ "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": [], +<<<<<<< HEAD <<<<<<< HEAD "modified": "2024-11-26 12:43:29.293637", ======= "modified": "2025-02-28 18:52:44.063265", >>>>>>> c1e4e7af28 (feat: Unit Price Contract) +======= + "modified": "2025-03-03 16:49:20.050303", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "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 6c8cc7ac1df..ca3d2ac824b 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); @@ -539,16 +539,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 5fc808798e2..801648b4479 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1656,14 +1656,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 01b7701f488..0e857430b5e 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -37,8 +37,8 @@ ======= "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" >>>>>>> c1e4e7af28 (feat: Unit Price Contract) @@ -233,14 +233,18 @@ "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", +<<<<<<< HEAD "label": "Allow 0 Qty in Quotation (Unit Price Contract)" >>>>>>> c1e4e7af28 (feat: Unit Price Contract) +======= + "label": "Allow 0 Qty in Quotation (Unit Price Items)" +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) } ], "grid_page_length": 50, @@ -249,11 +253,15 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD <<<<<<< HEAD "modified": "2023-10-25 14:03:03.966701", ======= "modified": "2025-02-28 18:19:46.436595", >>>>>>> c1e4e7af28 (feat: Unit Price Contract) +======= + "modified": "2025-03-03 16:39:16.360823", +>>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", From c19065e675bd7f87a595f7b0a5b06e3ceebbcafd 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 04/13] test: Zero Qty in RFQ and Supplier Quotation (cherry picked from commit 8f96c0b5461488dbe2739d5d8e65d42a44b3b301) # Conflicts: # erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py # erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py --- .../test_request_for_quotation.py | 45 +++++++++++++++ .../test_supplier_quotation.py | 55 +++++++++++++++++++ 2 files changed, 100 insertions(+) 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 51b4d7a5c89..6e21f4596f6 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,11 @@ from urllib.parse import urlparse import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase +======= +from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings +>>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( @@ -20,7 +24,39 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.templates.pages.rfq import check_supplier_has_docname_access +<<<<<<< HEAD class TestRequestforQuotation(FrappeTestCase): +======= +class UnitTestRequestForQuotation(UnitTestCase): + """ + Unit tests for RequestForQuotation. + Use this class for testing individual functions and methods. + """ + + pass + + +class TestRequestforQuotation(IntegrationTestCase): + def test_rfq_qty(self): + rfq = make_request_for_quotation(qty=0, do_not_save=True) + with self.assertRaises(InvalidQtyError): + rfq.save() + + # No error with qty=1 + rfq.items[0].qty = 1 + 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() + +>>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) def test_quote_status(self): rfq = make_request_for_quotation() @@ -161,6 +197,15 @@ class TestRequestforQuotation(FrappeTestCase): 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 84df61de373..31bee01e775 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -3,6 +3,7 @@ import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, today @@ -12,6 +13,47 @@ class TestPurchaseOrder(FrappeTestCase): from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order sq = frappe.copy_doc(test_records[0]).insert() +======= +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 + + +class UnitTestSupplierQuotation(UnitTestCase): + """ + Unit tests for SupplierQuotation. + Use this class for testing individual functions and methods. + """ + + pass + + +class TestPurchaseOrder(IntegrationTestCase): + def test_supplier_quotation_qty(self): + sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) + sq.items[0].qty = 0 + with self.assertRaises(InvalidQtyError): + sq.save() + + # No error with qty=1 + sq.items[0].qty = 1 + sq.save() + self.assertEqual(sq.items[0].qty, 1) + + 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() +>>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name) @@ -30,5 +72,18 @@ class TestPurchaseOrder(FrappeTestCase): po.insert() +<<<<<<< HEAD test_records = frappe.get_test_records("Supplier Quotation") +======= + @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) +>>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) From eba73df88e28fe5239b215503b80a56533d31e18 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 05/13] test: Purchase Order with Unit Price Items - chore: Fix error message in accounts controller (cherry picked from commit eea758f5b2c986b0efccdb6f387475b9800ff4e0) # Conflicts: # erpnext/buying/doctype/purchase_order/test_purchase_order.py # erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py --- .../doctype/purchase_order/purchase_order.py | 8 +- .../purchase_order/test_purchase_order.py | 95 +++++++++++++++++++ .../test_request_for_quotation.py | 5 +- .../test_supplier_quotation.py | 5 + erpnext/controllers/accounts_controller.py | 6 +- 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 9ddbbb991d6..d883a41a197 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -723,8 +723,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 = ( @@ -755,7 +757,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 404c83cdb50..59143ed564e 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -5,7 +5,11 @@ import json import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings +======= +from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings +>>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) from frappe.utils import add_days, flt, getdate, nowdate from frappe.utils.data import today @@ -44,6 +48,21 @@ class TestPurchaseOrder(FrappeTestCase): po.items[1].qty = 0 self.assertRaises(InvalidQtyError, po.save) +<<<<<<< HEAD +======= + # No error with qty=1 + po.items[1].qty = 1 + 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) + +>>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) def test_make_purchase_receipt(self): po = create_purchase_order(do_not_submit=True) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) @@ -1199,6 +1218,82 @@ class TestPurchaseOrder(FrappeTestCase): 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 6e21f4596f6..cd6d61ab926 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 @@ -55,6 +55,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) >>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) def test_quote_status(self): @@ -197,8 +198,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 31bee01e775..87014c135f5 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -50,6 +50,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() @@ -73,10 +74,14 @@ class TestPurchaseOrder(IntegrationTestCase): po.insert() <<<<<<< HEAD +<<<<<<< HEAD test_records = frappe.get_test_records("Supplier Quotation") ======= @change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) +======= + @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) +>>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) 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 a3180388141..28d55973d74 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3746,9 +3746,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 From 2d96a625303ca9b7ad629f3b8be3ea241aa83810 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 06/13] test: Sales Order + fix: Mapping of Items from Quotation & SO (cherry picked from commit 55981c8358f1d9f503ceec918f217c209896180f) # Conflicts: # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../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 | 113 ++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 59143ed564e..74b23434b2f 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1234,7 +1234,7 @@ class TestPurchaseOrder(FrappeTestCase): 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 a4f79c1683d..ff33cc43f17 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -380,6 +380,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", [])] @@ -427,14 +429,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 ca3d2ac824b..0c4cd43ec36 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -597,10 +597,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } if (doc.status !== "Closed") { if (doc.status !== "On Hold") { + const items_are_deliverable = this.frm.doc.items.some( + (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) + ); allow_delivery = - this.frm.doc.items.some( - (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) - ) && !this.frm.doc.skip_delivery_note; + (this.frm.doc.has_unit_price_items || items_are_deliverable) && + !this.frm.doc.skip_delivery_note; if (this.frm.has_perm("submit")) { if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 54ee5f0d21d..f1b405404fd 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -962,6 +962,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) @@ -1008,12 +1011,14 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: 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 82a8535a66d..177e5083cc5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -6,7 +6,11 @@ import json import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings +======= +from frappe.tests import IntegrationTestCase, change_settings +>>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -86,6 +90,39 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) update_child_qty_rate("Sales Order", trans_item, so.name) +<<<<<<< HEAD +======= + def test_sales_order_qty(self): + so = make_sales_order(qty=1, do_not_save=True) + + # NonNegativeError with qty=-1 + so.append( + "items", + { + "item_code": "_Test Item", + "qty": -1, + "rate": 10, + }, + ) + self.assertRaises(frappe.NonNegativeError, so.save) + + # InvalidQtyError with qty=0 + so.items[1].qty = 0 + self.assertRaises(InvalidQtyError, so.save) + + # No error with qty=1 + so.items[1].qty = 1 + 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) + +>>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) def test_make_material_request(self): so = make_sales_order(do_not_submit=True) @@ -2192,6 +2229,82 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) + @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 d9636018f57692f72e3defc9610bf4b9177a2a44 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 07/13] 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 (cherry picked from commit 0447c7be0abf428e049b76f871a691214c3b613a) # Conflicts: # erpnext/selling/doctype/quotation/test_quotation.py # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../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 | 69 ++++++++++++ .../doctype/sales_order/sales_order.py | 32 ++++-- .../doctype/sales_order/test_sales_order.py | 104 +++++++++++++++--- 6 files changed, 203 insertions(+), 52 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index d883a41a197..43ef287854e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -725,8 +725,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 = ( @@ -758,7 +761,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 74b23434b2f..38710f7a111 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1234,18 +1234,6 @@ class TestPurchaseOrder(FrappeTestCase): 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") @@ -1263,9 +1251,19 @@ class TestPurchaseOrder(FrappeTestCase): ) 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 ff33cc43f17..7816485a9f0 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -380,10 +380,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: @@ -413,7 +417,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) @@ -427,23 +431,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 b41637143e5..9a8d2d5c5fa 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -2,13 +2,49 @@ # License: GNU General Public License v3. See license.txt import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase +======= +from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings +>>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) from frappe.utils import add_days, add_months, flt, getdate, nowdate test_dependencies = ["Product Bundle"] +<<<<<<< HEAD class TestQuotation(FrappeTestCase): +======= +class UnitTestQuotation(UnitTestCase): + """ + Unit tests for Quotation. + Use this class for testing individual functions and methods. + """ + + pass + + +class TestQuotation(IntegrationTestCase): + def test_quotation_qty(self): + qo = make_quotation(qty=0, do_not_save=True) + with self.assertRaises(InvalidQtyError): + qo.save() + + # No error with qty=1 + qo.items[0].qty = 1 + 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) + +>>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) def test_make_quotation_without_terms(self): quotation = make_quotation(do_not_save=1) self.assertFalse(quotation.get("payment_schedule")) @@ -761,6 +797,39 @@ class TestQuotation(FrappeTestCase): 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") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index f1b405404fd..e0cfa8d5250 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -962,9 +962,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) @@ -975,6 +972,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 @@ -1012,13 +1015,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) @@ -1095,6 +1100,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 @@ -1135,7 +1146,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: @@ -1148,8 +1159,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, @@ -1171,9 +1180,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 177e5083cc5..c3deeca2d0e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1983,6 +1983,79 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(so.items[0].rate, scenario.get("expected_rate")) self.assertEqual(so.packed_items[0].rate, scenario.get("expected_rate")) +<<<<<<< HEAD +======= + @patch( + # this also shadows one (1) call to _get_payment_gateway_controller + "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url", + return_value=None, + ) + def test_sales_order_advance_payment_status(self, mocked_get_payment_url): + from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry + from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request + + # Flow progressing to SI with payment entries "moved" from SO to SI + so = make_sales_order(qty=1, rate=100, do_not_submit=True) + # no-op; for optical consistency with how a webshop SO would look like + so.order_type = "Shopping Cart" + so.submit() + 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, + mute_email=True, + ) + self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested") + + pe = pr.set_as_paid() + pr.reload() # status updated + pe.reload() # references moved to Sales Invoice + self.assertEqual(pr.status, "Paid") + self.assertEqual(pe.references[0].reference_doctype, "Sales Invoice") + self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") + + pe.cancel() + pr.reload() + self.assertEqual(pr.status, "Paid") # TODO: this might be a bug + so.reload() # reload + # regardless, since the references have already "handed-over" to SI, + # the SO keeps its historical state at the time of hand over + self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") + + pr.cancel() + self.assertEqual( + frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested" + ) # TODO: this might be a bug; handover has happened + + # Flow NOT progressing to SI with payment entries NOT "moved" + 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, 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() + self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") + + pe.reload() + pe.cancel() + self.assertEqual( + frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested" + ) # here: reset + + pr.reload() + pr.cancel() + self.assertEqual( + frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested" + ) # here: reset + +>>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) def test_pick_list_without_rejected_materials(self): serial_and_batch_item = make_item( "_Test Serial and Batch Item for Rejected Materials", @@ -2234,7 +2307,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): """ 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) @@ -2243,24 +2316,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): 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( [ @@ -2274,9 +2336,17 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) 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") @@ -2296,11 +2366,9 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): 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 f94a14c06ae63afcfe4d6d33757f80bc1f068002 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 08/13] fix: Headline rendered twice on first save - `refresh` gets triggered twice and that renders the note twice - Remove any existing note before rendering (cherry picked from commit bf62f9ad578e9d655451088398eaaa2f772348c7) --- 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 886ddd2acc0..b7706028114 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2776,8 +2776,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 ); From 3deb11e5b231520e79ee1df424634d87f4c8e486 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Tue, 13 May 2025 13:35:19 +0200 Subject: [PATCH 09/13] fix: Merge conflicts --- .../buying_settings/buying_settings.json | 9 -- .../purchase_order/purchase_order.json | 9 +- .../purchase_order/test_purchase_order.py | 18 +-- .../request_for_quotation.json | 4 - .../test_request_for_quotation.py | 31 +---- .../test_supplier_quotation.py | 63 ++--------- erpnext/controllers/accounts_controller.py | 11 +- .../selling/doctype/quotation/quotation.json | 8 -- .../doctype/quotation/test_quotation.py | 31 +---- .../doctype/sales_order/sales_order.py | 10 -- .../doctype/sales_order/test_sales_order.py | 107 +----------------- .../selling_settings/selling_settings.json | 46 +------- 12 files changed, 19 insertions(+), 328 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index a92eb23a23d..4a89ff78862 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -236,11 +236,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2024-01-31 13:34:18.101256", -======= "modified": "2025-03-03 17:32:25.939482", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -286,12 +282,7 @@ "role": "Purchase User" } ], -<<<<<<< HEAD "sort_field": "modified", -======= - "row_format": "Dynamic", - "sort_field": "creation", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "sort_order": "DESC", "states": [], "track_changes": 1 diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index bc63e8756ad..59b44a22e61 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -1281,20 +1281,19 @@ "print_hide": 1 }, { -<<<<<<< HEAD "fieldname": "dispatch_address_display", "fieldtype": "Text Editor", "label": "Dispatch Address Details", "print_hide": 1, "read_only": 1 -======= + }, + { "default": "0", "fieldname": "has_unit_price_items", "fieldtype": "Check", "hidden": 1, "label": "Has Unit Price Items", "no_copy": 1 ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) } ], "grid_page_length": 50, @@ -1302,11 +1301,7 @@ "idx": 105, "is_submittable": 1, "links": [], -<<<<<<< HEAD "modified": "2025-04-09 16:54:08.836106", -======= - "modified": "2025-03-03 16:48:08.697520", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 38710f7a111..68286333937 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -5,11 +5,7 @@ import json import frappe -<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings -======= -from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings ->>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) from frappe.utils import add_days, flt, getdate, nowdate from frappe.utils.data import today @@ -48,13 +44,6 @@ class TestPurchaseOrder(FrappeTestCase): po.items[1].qty = 0 self.assertRaises(InvalidQtyError, po.save) -<<<<<<< HEAD -======= - # No error with qty=1 - po.items[1].qty = 1 - 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) @@ -62,7 +51,6 @@ class TestPurchaseOrder(FrappeTestCase): po.save() self.assertEqual(po.items[0].qty, 0) ->>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) def test_make_purchase_receipt(self): po = create_purchase_order(do_not_submit=True) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) @@ -812,8 +800,6 @@ class TestPurchaseOrder(FrappeTestCase): po_doc.reload() self.assertEqual(po_doc.advance_paid, 5000) - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice - company_doc.book_advance_payments_in_separate_party_account = False company_doc.save() @@ -1218,7 +1204,7 @@ class TestPurchaseOrder(FrappeTestCase): po.reload() self.assertEqual(po.per_billed, 100) - @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}) + @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. @@ -1267,7 +1253,7 @@ class TestPurchaseOrder(FrappeTestCase): 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}) + @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) 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 056535af17b..824484f9c20 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -322,11 +322,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2023-11-06 12:45:28.898706", -======= "modified": "2025-03-03 16:48:39.856779", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", 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 cd6d61ab926..417299e8519 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,11 +5,7 @@ from urllib.parse import urlparse import frappe -<<<<<<< HEAD -from frappe.tests.utils import FrappeTestCase -======= -from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings ->>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( @@ -24,29 +20,7 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.templates.pages.rfq import check_supplier_has_docname_access -<<<<<<< HEAD class TestRequestforQuotation(FrappeTestCase): -======= -class UnitTestRequestForQuotation(UnitTestCase): - """ - Unit tests for RequestForQuotation. - Use this class for testing individual functions and methods. - """ - - pass - - -class TestRequestforQuotation(IntegrationTestCase): - def test_rfq_qty(self): - rfq = make_request_for_quotation(qty=0, do_not_save=True) - with self.assertRaises(InvalidQtyError): - rfq.save() - - # No error with qty=1 - rfq.items[0].qty = 1 - 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. @@ -57,7 +31,6 @@ class TestRequestforQuotation(IntegrationTestCase): rfq.save() self.assertEqual(rfq.items[0].qty, 0) ->>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) def test_quote_status(self): rfq = make_request_for_quotation() @@ -198,7 +171,7 @@ class TestRequestforQuotation(IntegrationTestCase): supplier_doc.reload() self.assertTrue(supplier_doc.portal_users[0].user) - @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}) + @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 87014c135f5..b5b6aa707aa 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -3,58 +3,15 @@ import frappe -<<<<<<< HEAD -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today +from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order + class TestPurchaseOrder(FrappeTestCase): def test_make_purchase_order(self): - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order - sq = frappe.copy_doc(test_records[0]).insert() -======= -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 - - -class UnitTestSupplierQuotation(UnitTestCase): - """ - Unit tests for SupplierQuotation. - Use this class for testing individual functions and methods. - """ - - pass - - -class TestPurchaseOrder(IntegrationTestCase): - def test_supplier_quotation_qty(self): - sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) - sq.items[0].qty = 0 - with self.assertRaises(InvalidQtyError): - sq.save() - - # No error with qty=1 - sq.items[0].qty = 1 - sq.save() - self.assertEqual(sq.items[0].qty, 1) - - def test_supplier_quotation_zero_qty(self): - """ - Test if RFQ with zero qty (Unit Price Item) is conditionally allowed. - """ - sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) - sq.items[0].qty = 0 - - with change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}): - sq.save() - self.assertEqual(sq.items[0].qty, 0) - - def test_make_purchase_order(self): - sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).insert() ->>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name) @@ -73,17 +30,9 @@ class TestPurchaseOrder(IntegrationTestCase): po.insert() -<<<<<<< HEAD -<<<<<<< HEAD - -test_records = frappe.get_test_records("Supplier Quotation") -======= @change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) -======= - @IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) ->>>>>>> eea758f5b2 (test: Purchase Order with Unit Price Items) def test_map_purchase_order_from_zero_qty_supplier_quotation(self): - sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) + sq = frappe.copy_doc(test_records[0]).insert() sq.items[0].qty = 0 sq.submit() @@ -91,4 +40,6 @@ test_records = frappe.get_test_records("Supplier Quotation") 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) ->>>>>>> 8f96c0b546 (test: Zero Qty in RFQ and Supplier Quotation) + + +test_records = frappe.get_test_records("Supplier Quotation") diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 28d55973d74..4949c4cbf04 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1259,17 +1259,8 @@ class AccountsController(TransactionBase): ) def validate_qty_is_not_zero(self): -<<<<<<< HEAD - if self.doctype == "Purchase Receipt": + if self.doctype == "Purchase Receipt" or self.flags.allow_zero_qty: return -======= - if self.flags.allow_zero_qty: - return - - for item in self.items: - if self.doctype == "Purchase Receipt" and item.rejected_qty: - continue ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) for item in self.items: if not flt(item.qty): diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 5bf8f474625..5a674581a9b 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1099,15 +1099,7 @@ "idx": 82, "is_submittable": 1, "links": [], -<<<<<<< HEAD -<<<<<<< HEAD - "modified": "2024-11-26 12:43:29.293637", -======= - "modified": "2025-02-28 18:52:44.063265", ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) -======= "modified": "2025-03-03 16:49:20.050303", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 9a8d2d5c5fa..7a3d102f35b 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -2,39 +2,13 @@ # License: GNU General Public License v3. See license.txt import frappe -<<<<<<< HEAD -from frappe.tests.utils import FrappeTestCase -======= -from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings ->>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate test_dependencies = ["Product Bundle"] -<<<<<<< HEAD class TestQuotation(FrappeTestCase): -======= -class UnitTestQuotation(UnitTestCase): - """ - Unit tests for Quotation. - Use this class for testing individual functions and methods. - """ - - pass - - -class TestQuotation(IntegrationTestCase): - def test_quotation_qty(self): - qo = make_quotation(qty=0, do_not_save=True) - with self.assertRaises(InvalidQtyError): - qo.save() - - # No error with qty=1 - qo.items[0].qty = 1 - 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. @@ -44,7 +18,6 @@ class TestQuotation(IntegrationTestCase): qo.save() self.assertEqual(qo.items[0].qty, 0) ->>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) def test_make_quotation_without_terms(self): quotation = make_quotation(do_not_save=1) self.assertFalse(quotation.get("payment_schedule")) @@ -797,7 +770,7 @@ 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}) + @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 diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index e0cfa8d5250..5643f6db0f5 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -153,11 +153,7 @@ class SalesOrder(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None skip_delivery_note: DF.Check -<<<<<<< HEAD -<<<<<<< HEAD source: DF.Link | None -======= ->>>>>>> 71f65bab5e (fix: Linters) status: DF.Literal[ "", "Draft", @@ -170,12 +166,6 @@ class SalesOrder(SellingController): "Cancelled", "Closed", ] -<<<<<<< HEAD -======= - status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"] ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) -======= ->>>>>>> 71f65bab5e (fix: Linters) tax_category: DF.Link | None tax_id: DF.Data | None taxes: DF.Table[SalesTaxesandCharges] diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index c3deeca2d0e..71206c90964 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -6,11 +6,7 @@ import json import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user -<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings -======= -from frappe.tests import IntegrationTestCase, change_settings ->>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -90,31 +86,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) update_child_qty_rate("Sales Order", trans_item, so.name) -<<<<<<< HEAD -======= - def test_sales_order_qty(self): - so = make_sales_order(qty=1, do_not_save=True) - - # NonNegativeError with qty=-1 - so.append( - "items", - { - "item_code": "_Test Item", - "qty": -1, - "rate": 10, - }, - ) - self.assertRaises(frappe.NonNegativeError, so.save) - - # InvalidQtyError with qty=0 - so.items[1].qty = 0 - self.assertRaises(InvalidQtyError, so.save) - - # No error with qty=1 - so.items[1].qty = 1 - 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) @@ -122,7 +93,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): po.save() self.assertEqual(po.items[0].qty, 0) ->>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) def test_make_material_request(self): so = make_sales_order(do_not_submit=True) @@ -1983,79 +1953,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(so.items[0].rate, scenario.get("expected_rate")) self.assertEqual(so.packed_items[0].rate, scenario.get("expected_rate")) -<<<<<<< HEAD -======= - @patch( - # this also shadows one (1) call to _get_payment_gateway_controller - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url", - return_value=None, - ) - def test_sales_order_advance_payment_status(self, mocked_get_payment_url): - from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry - from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request - - # Flow progressing to SI with payment entries "moved" from SO to SI - so = make_sales_order(qty=1, rate=100, do_not_submit=True) - # no-op; for optical consistency with how a webshop SO would look like - so.order_type = "Shopping Cart" - so.submit() - 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, - mute_email=True, - ) - self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested") - - pe = pr.set_as_paid() - pr.reload() # status updated - pe.reload() # references moved to Sales Invoice - self.assertEqual(pr.status, "Paid") - self.assertEqual(pe.references[0].reference_doctype, "Sales Invoice") - self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") - - pe.cancel() - pr.reload() - self.assertEqual(pr.status, "Paid") # TODO: this might be a bug - so.reload() # reload - # regardless, since the references have already "handed-over" to SI, - # the SO keeps its historical state at the time of hand over - self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") - - pr.cancel() - self.assertEqual( - frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested" - ) # TODO: this might be a bug; handover has happened - - # Flow NOT progressing to SI with payment entries NOT "moved" - 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, 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() - self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid") - - pe.reload() - pe.cancel() - self.assertEqual( - frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested" - ) # here: reset - - pr.reload() - pr.cancel() - self.assertEqual( - frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested" - ) # here: reset - ->>>>>>> 0447c7be0a (fix: Treat rows as Unit Price rows only until the qty is 0) def test_pick_list_without_rejected_materials(self): serial_and_batch_item = make_item( "_Test Serial and Batch Item for Rejected Materials", @@ -2302,7 +2199,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) - @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) + @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. @@ -2350,7 +2247,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): 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}) + @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) diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 0e857430b5e..0c82de001a8 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -32,16 +32,9 @@ "allow_sales_order_creation_for_expired_quotation", "dont_reserve_sales_order_qty_on_sales_return", "hide_tax_id", -<<<<<<< HEAD - "enable_discount_accounting" -======= "enable_discount_accounting", - "enable_cutoff_date_on_bulk_delivery_note_creation", "allow_zero_qty_in_quotation", - "allow_zero_qty_in_sales_order", - "experimental_section", - "use_server_side_reactivity" ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) + "allow_zero_qty_in_sales_order" ], "fields": [ { @@ -209,25 +202,6 @@ "fieldname": "blanket_order_allowance", "fieldtype": "Float", "label": "Blanket Order Allowance (%)" -<<<<<<< HEAD -======= - }, - { - "default": "0", - "fieldname": "enable_cutoff_date_on_bulk_delivery_note_creation", - "fieldtype": "Check", - "label": "Enable Cut-Off Date on Bulk Delivery Note Creation" - }, - { - "fieldname": "experimental_section", - "fieldtype": "Section Break", - "label": "Experimental" - }, - { - "default": "1", - "fieldname": "use_server_side_reactivity", - "fieldtype": "Check", - "label": "Use Server Side Reactivity" }, { "default": "0", @@ -239,12 +213,7 @@ "default": "0", "fieldname": "allow_zero_qty_in_quotation", "fieldtype": "Check", -<<<<<<< HEAD - "label": "Allow 0 Qty in Quotation (Unit Price Contract)" ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) -======= "label": "Allow 0 Qty in Quotation (Unit Price Items)" ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) } ], "grid_page_length": 50, @@ -253,15 +222,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD -<<<<<<< HEAD - "modified": "2023-10-25 14:03:03.966701", -======= - "modified": "2025-02-28 18:19:46.436595", ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) -======= "modified": "2025-03-03 16:39:16.360823", ->>>>>>> e403d3f153 (feat: Unit Price Items in Buying (RFQ, SQ, PO)) "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -286,12 +247,7 @@ "write": 1 } ], -<<<<<<< HEAD "sort_field": "modified", -======= - "row_format": "Dynamic", - "sort_field": "creation", ->>>>>>> c1e4e7af28 (feat: Unit Price Contract) "sort_order": "DESC", "states": [], "track_changes": 1 From 8891f46a22f1f7ed6ffb2ec37c4a7bbef00fb9ba Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Tue, 6 May 2025 12:33:03 +0200 Subject: [PATCH 10/13] fix: Relabel unit price settings for more clarity --- .../doctype/buying_settings/buying_settings.json | 11 +++++++---- .../doctype/selling_settings/selling_settings.json | 10 ++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 4a89ff78862..38eae23b83e 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -213,21 +213,24 @@ }, { "default": "0", + "description": "Allows items in the Purchase Order to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", "fieldname": "allow_zero_qty_in_purchase_order", "fieldtype": "Check", - "label": "Allow 0 Qty in Purchase Order (Unit Price Items)" + "label": "Allow Zero Quantity Items in Purchase Order" }, { "default": "0", + "description": "Allows items in the Request for Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", "fieldname": "allow_zero_qty_in_request_for_quotation", "fieldtype": "Check", - "label": "Allow 0 Qty in Request for Quotation (Unit Price Items)" + "label": "Allow Zero Quantity Items in Request for Quotation" }, { "default": "0", + "description": "Allows items in the Supplier Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", "fieldname": "allow_zero_qty_in_supplier_quotation", "fieldtype": "Check", - "label": "Allow 0 Qty in Supplier Quotation (Unit Price Items)" + "label": "Allow Zero Quantity Items in Supplier Quotation" } ], "grid_page_length": 50, @@ -236,7 +239,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-03-03 17:32:25.939482", + "modified": "2025-05-06 12:32:00.195378", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 0c82de001a8..f10f9d297e8 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -205,15 +205,17 @@ }, { "default": "0", + "description": "Allows items in the Sales Order to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", "fieldname": "allow_zero_qty_in_sales_order", "fieldtype": "Check", - "label": "Allow 0 Qty in Sales Order (Unit Price Items)" + "label": "Allow Zero Quantity Items in Sales Order" }, { "default": "0", + "description": "Allows items in the Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", "fieldname": "allow_zero_qty_in_quotation", "fieldtype": "Check", - "label": "Allow 0 Qty in Quotation (Unit Price Items)" + "label": "Allow Zero Quantity Items in Quotation" } ], "grid_page_length": 50, @@ -222,7 +224,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-03-03 16:39:16.360823", + "modified": "2025-05-06 12:30:13.342694", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -251,4 +253,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 0286788e9757b2a261c9198767daadad0533e198 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Tue, 6 May 2025 15:28:56 +0200 Subject: [PATCH 11/13] chore: Relabel according to review changes --- .../doctype/buying_settings/buying_settings.json | 14 +++++++------- .../doctype/selling_settings/selling_settings.json | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 38eae23b83e..1b1df3fa1e0 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -213,24 +213,24 @@ }, { "default": "0", - "description": "Allows items in the Purchase Order to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", + "description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", "fieldname": "allow_zero_qty_in_purchase_order", "fieldtype": "Check", - "label": "Allow Zero Quantity Items in Purchase Order" + "label": "Allow Purchase Order with Zero Quantity" }, { "default": "0", - "description": "Allows items in the Request for Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", + "description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", "fieldname": "allow_zero_qty_in_request_for_quotation", "fieldtype": "Check", - "label": "Allow Zero Quantity Items in Request for Quotation" + "label": "Allow Request for Quotation with Zero Quantity" }, { "default": "0", - "description": "Allows items in the Supplier Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", + "description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", "fieldname": "allow_zero_qty_in_supplier_quotation", "fieldtype": "Check", - "label": "Allow Zero Quantity Items in Supplier Quotation" + "label": "Allow Supplier Quotation with Zero Quantity" } ], "grid_page_length": 50, @@ -239,7 +239,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-06 12:32:00.195378", + "modified": "2025-05-06 15:21:49.639642", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index f10f9d297e8..0553ef44980 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -205,17 +205,17 @@ }, { "default": "0", - "description": "Allows items in the Sales Order to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", + "description": "Allows users to submit Sales Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", "fieldname": "allow_zero_qty_in_sales_order", "fieldtype": "Check", - "label": "Allow Zero Quantity Items in Sales Order" + "label": "Allow Sales Order with Zero Quantity" }, { "default": "0", - "description": "Allows items in the Quotation to have a quantity of 0. Useful when the rate/unit price is agreed upon, but the quantity is not yet determined.", + "description": "Allows users to submit Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", "fieldname": "allow_zero_qty_in_quotation", "fieldtype": "Check", - "label": "Allow Zero Quantity Items in Quotation" + "label": "Allow Quotation with Zero Quantity" } ], "grid_page_length": 50, @@ -224,7 +224,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-06 12:30:13.342694", + "modified": "2025-05-06 15:23:14.332971", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", From f95a3f5b8b9badb095b07b42214c6cd27275b880 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Tue, 6 May 2025 14:24:36 +0200 Subject: [PATCH 12/13] fix(portal): User cannot create 0 qty SQ from RFQ - The portal uses `create_supplier_quotation` for SQ creation which excludes 0 qty items --- .../request_for_quotation.py | 7 +++---- .../test_request_for_quotation.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) 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 ce4d44fb68e..27793236dc3 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -456,11 +456,10 @@ def create_supplier_quotation(doc): def add_items(sq_doc, supplier, items): for data in items: - if data.get("qty") > 0: - if isinstance(data, dict): - data = frappe._dict(data) + if isinstance(data, dict): + data = frappe._dict(data) - create_rfq_items(sq_doc, supplier, data) + create_rfq_items(sq_doc, supplier, data) def create_rfq_items(sq_doc, supplier, data): 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 0a9347faefe..f119840e042 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 @@ -191,6 +191,23 @@ class TestRequestforQuotation(FrappeTestCase): self.assertEqual(sq.items[0].qty, 0) self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code) + @change_settings( + "Buying Settings", + { + "allow_zero_qty_in_request_for_quotation": 1, + "allow_zero_qty_in_supplier_quotation": 1, + }, + ) + def test_supplier_quotation_from_zero_qty_rfq_in_portal(self): + rfq = make_request_for_quotation(qty=0) + rfq.supplier = rfq.suppliers[0].supplier + sq_name = create_supplier_quotation(rfq) + + sq = frappe.get_doc("Supplier Quotation", sq_name) + 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": """ From 37f4cf536756d5a54d4903fd5c60bf0c6c706827 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Tue, 13 May 2025 17:18:40 +0200 Subject: [PATCH 13/13] fix: Linter (due to conflicts resolved on gh) --- erpnext/buying/doctype/purchase_order/test_purchase_order.py | 4 ++-- .../request_for_quotation/test_request_for_quotation.py | 4 ++-- erpnext/selling/doctype/quotation/test_quotation.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 5c8220894b8..c6ee35d6090 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -46,8 +46,8 @@ class TestPurchaseOrder(FrappeTestCase): # InvalidQtyError with qty=0 po.items[1].qty = 0 self.assertRaises(InvalidQtyError, po.save) - - # No error with qty=1 + + # No error with qty=1 po.items[1].qty = 1 po.save() self.assertEqual(po.items[1].qty, 1) 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 f119840e042..1a8b3a8ac47 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 @@ -31,8 +31,8 @@ class TestRequestforQuotation(FrappeTestCase): rfq.items[0].qty = 1 rfq.save() self.assertEqual(rfq.items[0].qty, 1) - - def test_rfq_zero_qty(self): + + def test_rfq_zero_qty(self): """ Test if RFQ with zero qty (Unit Price Item) is conditionally allowed. """ diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 49652cbde85..f6f4d98d541 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -20,8 +20,8 @@ class TestQuotation(FrappeTestCase): qo.items[0].qty = 1 qo.save() self.assertEqual(qo.items[0].qty, 1) - - def test_quotation_zero_qty(self): + + def test_quotation_zero_qty(self): """ Test if Quote with zero qty (Unit Price Item) is conditionally allowed. """