From 3ebde4526aa2da63462923731c1fd05f3f600792 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Tue, 6 May 2025 11:39:00 +0530 Subject: [PATCH] feat!: configure which rate is used to auto-update price list --- erpnext/patches.txt | 1 + .../v14_0/set_update_price_list_based_on.py | 14 +++ .../doctype/sales_order/test_sales_order.py | 113 ++++++++++++++++-- .../selling_settings/selling_settings.js | 4 +- .../setup_wizard/operations/defaults_setup.py | 1 + .../operations/install_fixtures.py | 1 + .../doctype/stock_settings/stock_settings.js | 26 ++++ .../stock_settings/stock_settings.json | 12 +- .../doctype/stock_settings/stock_settings.py | 1 + erpnext/stock/get_item_details.py | 113 ++++++++++-------- 10 files changed, 224 insertions(+), 62 deletions(-) create mode 100644 erpnext/patches/v14_0/set_update_price_list_based_on.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 56e1d54d081..ec3ae09117b 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -410,3 +410,4 @@ erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") +erpnext.patches.v14_0.set_update_price_list_based_on diff --git a/erpnext/patches/v14_0/set_update_price_list_based_on.py b/erpnext/patches/v14_0/set_update_price_list_based_on.py new file mode 100644 index 00000000000..4ddef4b0c25 --- /dev/null +++ b/erpnext/patches/v14_0/set_update_price_list_based_on.py @@ -0,0 +1,14 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.db.set_single_value( + "Stock Settings", + "update_price_list_based_on", + ( + "Price List Rate" + if cint(frappe.db.get_single_value("Selling Settings", "editable_price_list_rate")) + else "Rate" + ), + ) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 5aa96ed5805..3d755f1724c 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -880,7 +880,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): def test_auto_insert_price(self): make_item("_Test Item for Auto Price List", {"is_stock_item": 0}) make_item("_Test Item for Auto Price List with Discount Percentage", {"is_stock_item": 0}) - frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1) + frappe.db.set_single_value( + "Stock Settings", + { + "auto_insert_price_list_rate_if_missing": 1, + "update_price_list_based_on": "Price List Rate", + }, + ) item_price = frappe.db.get_value( "Item Price", {"price_list": "_Test Price List", "item_code": "_Test Item for Auto Price List"} @@ -892,6 +898,7 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): item_code="_Test Item for Auto Price List", selling_price_list="_Test Price List", rate=100 ) + # ensure price gets inserted based on rate if price list rate is not defined by user self.assertEqual( frappe.db.get_value( "Item Price", @@ -901,6 +908,8 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): 100, ) + # ensure price gets insterted based on user-defined *Price List Rate* + # if update_price_list_based_on is set to Price List Rate make_sales_order( item_code="_Test Item for Auto Price List with Discount Percentage", selling_price_list="_Test Price List", @@ -908,18 +917,43 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): discount_percentage=20, ) - self.assertEqual( - frappe.db.get_value( - "Item Price", - { - "price_list": "_Test Price List", - "item_code": "_Test Item for Auto Price List with Discount Percentage", - }, - "price_list_rate", - ), - 200, + item_price = frappe.db.get_value( + "Item Price", + { + "price_list": "_Test Price List", + "item_code": "_Test Item for Auto Price List with Discount Percentage", + }, + ("name", "price_list_rate"), + as_dict=True, ) + self.assertEqual(item_price.price_list_rate, 200) + frappe.delete_doc("Item Price", item_price.name) + + frappe.db.set_single_value("Stock Settings", "update_price_list_based_on", "Rate") + + # ensure price gets insterted based on user-defined *Rate* + # if update_price_list_based_on is set to Rate + make_sales_order( + item_code="_Test Item for Auto Price List with Discount Percentage", + selling_price_list="_Test Price List", + price_list_rate=200, + discount_percentage=20, + ) + + item_price = frappe.db.get_value( + "Item Price", + { + "price_list": "_Test Price List", + "item_code": "_Test Item for Auto Price List with Discount Percentage", + }, + ("name", "price_list_rate"), + as_dict=True, + ) + + self.assertEqual(item_price.price_list_rate, 160) + frappe.delete_doc("Item Price", item_price.name) + # do not update price list frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0) @@ -944,6 +978,63 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1) + def test_update_existing_item_price(self): + item_code = "_Test Item for Price List Updation" + price_list = "_Test Price List" + + make_item(item_code, {"is_stock_item": 0}) + + frappe.db.set_single_value( + "Stock Settings", + { + "auto_insert_price_list_rate_if_missing": 1, + "update_existing_price_list_rate": 1, + "update_price_list_based_on": "Rate", + }, + ) + + # setup: price creation + make_sales_order(item_code=item_code, selling_price_list=price_list, rate=100) + + # test price updation based on Rate + make_sales_order(item_code=item_code, selling_price_list=price_list, rate=90) + + self.assertEqual( + frappe.db.get_value( + "Item Price", + {"price_list": price_list, "item_code": item_code}, + "price_list_rate", + ), + 90, + ) + + frappe.db.set_single_value( + "Stock Settings", + { + "update_price_list_based_on": "Price List Rate", + }, + ) + + # test price updation based on Price List Rate + make_sales_order( + item_code=item_code, + selling_price_list=price_list, + price_list_rate=200, + discount_percentage=20, + ) + + self.assertEqual( + frappe.db.get_value( + "Item Price", + {"price_list": price_list, "item_code": item_code}, + "price_list_rate", + ), + 200, + ) + + # reset `update_existing_price_list_rate` to 0 + frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0) + def test_drop_shipping(self): from erpnext.buying.doctype.purchase_order.purchase_order import update_status from erpnext.selling.doctype.sales_order.sales_order import ( diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.js b/erpnext/selling/doctype/selling_settings/selling_settings.js index 4471458fb10..f7670e69d47 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.js +++ b/erpnext/selling/doctype/selling_settings/selling_settings.js @@ -2,5 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("Selling Settings", { - refresh: function (frm) {}, + after_save(frm) { + frappe.boot.user.defaults.editable_price_list_rate = frm.doc.editable_price_list_rate; + }, }); diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py index b81d744f954..82698808250 100644 --- a/erpnext/setup/setup_wizard/operations/defaults_setup.py +++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py @@ -34,6 +34,7 @@ def set_default_settings(args): stock_settings.stock_uom = "Nos" stock_settings.auto_indent = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1 + stock_settings.update_price_list_based_on = "Rate" stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.save() diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 7c62a425357..6bd126020aa 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -503,6 +503,7 @@ def update_stock_settings(): stock_settings.stock_uom = "Nos" stock_settings.auto_indent = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1 + stock_settings.update_price_list_based_on = "Rate" stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.save() diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 79638590f9b..76651bf69fe 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -51,4 +51,30 @@ frappe.ui.form.on("Stock Settings", { } ); }, + auto_insert_price_list_rate_if_missing(frm) { + if (!frm.doc.auto_insert_price_list_rate_if_missing) return; + + frm.set_value( + "update_price_list_based_on", + cint(frappe.defaults.get_default("editable_price_list_rate")) ? "Price List Rate" : "Rate" + ); + }, + update_price_list_based_on(frm) { + if ( + frm.doc.update_price_list_based_on === "Price List Rate" && + !cint(frappe.defaults.get_default("editable_price_list_rate")) + ) { + const dialog = frappe.warn( + __("Incompatible Setting Detected"), + __( + "

Price List Rate has not been set as editable in Selling Settings. In this scenario, setting Update Price List Based On to Price List Rate will prevent auto-updation of Item Price.

Are you sure you want to continue?" + ) + ); + dialog.set_secondary_action(() => { + frm.set_value("update_price_list_based_on", "Rate"); + dialog.hide(); + }); + return; + } + }, }); diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 0d821856088..e6e52c395ea 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -16,6 +16,7 @@ "stock_uom", "price_list_defaults_section", "auto_insert_price_list_rate_if_missing", + "update_price_list_based_on", "column_break_12", "update_existing_price_list_rate", "conversion_factor_section", @@ -528,6 +529,15 @@ "fieldname": "allow_to_make_quality_inspection_after_purchase_or_delivery", "fieldtype": "Check", "label": "Allow to Make Quality Inspection after Purchase / Delivery" + }, + { + "default": "Rate", + "depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "fieldname": "update_price_list_based_on", + "fieldtype": "Select", + "label": "Update Price List Based On", + "mandatory_depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "options": "Rate\nPrice List Rate" } ], "icon": "icon-cog", @@ -535,7 +545,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-04-11 18:56:35.781929", + "modified": "2025-05-06 02:39:24.284587", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 14379a906f1..afb28e4861f 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -65,6 +65,7 @@ class StockSettings(Document): stock_frozen_upto_days: DF.Int stock_uom: DF.Link | None update_existing_price_list_rate: DF.Check + update_price_list_based_on: DF.Literal["Rate", "Price List Rate"] use_naming_series: DF.Check use_serial_batch_fields: DF.Check valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 37fbf73533c..60c7a9d7cd6 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -962,8 +962,8 @@ def get_price_list_rate(ctx: ItemDetailsCtx, item_doc, out: ItemDetails = None): price_list_rate = get_price_list_rate_for(ctx, item_doc.variant_of) # insert in database - if price_list_rate is None or frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" + if price_list_rate is None or frappe.get_cached_value( + "Stock Settings", "Stock Settings", "update_existing_price_list_rate" ): insert_item_price(ctx) @@ -988,54 +988,69 @@ def insert_item_price(ctx: ItemDetailsCtx): if not ctx.price_list or not ctx.rate or ctx.is_internal_supplier or ctx.is_internal_customer: return - if frappe.db.get_value("Price List", ctx.price_list, "currency", cache=True) == ctx.currency and cint( - frappe.db.get_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing") - ): - if frappe.has_permission("Item Price", "write"): - price_list_rate = ( - (flt(ctx.rate) + flt(ctx.discount_amount)) / ctx.conversion_factor - if ctx.conversion_factor - else (flt(ctx.rate) + flt(ctx.discount_amount)) - ) + stock_settings = frappe.get_cached_doc("Stock Settings") - item_price = frappe.db.get_value( - "Item Price", - { - "item_code": ctx.item_code, - "price_list": ctx.price_list, - "currency": ctx.currency, - "uom": ctx.stock_uom, - }, - ["name", "price_list_rate"], - as_dict=1, - ) - if item_price and item_price.name: - if item_price.price_list_rate != price_list_rate and frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" - ): - frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) - frappe.msgprint( - _("Item Price updated for {0} in Price List {1}").format( - ctx.item_code, ctx.price_list - ), - alert=True, - ) - else: - item_price = frappe.get_doc( - { - "doctype": "Item Price", - "price_list": ctx.price_list, - "item_code": ctx.item_code, - "currency": ctx.currency, - "price_list_rate": price_list_rate, - "uom": ctx.stock_uom, - } - ) - item_price.insert() - frappe.msgprint( - _("Item Price added for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), - alert=True, - ) + if ( + not frappe.db.get_value("Price List", ctx.price_list, "currency", cache=True) == ctx.currency + or not stock_settings.auto_insert_price_list_rate_if_missing + or not frappe.has_permission("Item Price", "write") + ): + return + + item_price = frappe.db.get_value( + "Item Price", + { + "item_code": ctx.item_code, + "price_list": ctx.price_list, + "currency": ctx.currency, + "uom": ctx.stock_uom, + }, + ["name", "price_list_rate"], + as_dict=1, + ) + + update_based_on_price_list_rate = stock_settings.update_price_list_based_on == "Price List Rate" + + if item_price and item_price.name: + if not stock_settings.update_existing_price_list_rate: + return + + rate_to_consider = flt(ctx.price_list_rate) if update_based_on_price_list_rate else flt(ctx.rate) + price_list_rate = _get_stock_uom_rate(rate_to_consider, ctx) + + if not price_list_rate or item_price.price_list_rate == price_list_rate: + return + + frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) + frappe.msgprint( + _("Item Price updated for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), + alert=True, + ) + else: + rate_to_consider = ( + (flt(ctx.price_list_rate) or flt(ctx.rate)) if update_based_on_price_list_rate else flt(ctx.rate) + ) + price_list_rate = _get_stock_uom_rate(rate_to_consider, ctx) + + item_price = frappe.get_doc( + { + "doctype": "Item Price", + "price_list": ctx.price_list, + "item_code": ctx.item_code, + "currency": ctx.currency, + "price_list_rate": price_list_rate, + "uom": ctx.stock_uom, + } + ) + item_price.insert() + frappe.msgprint( + _("Item Price added for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), + alert=True, + ) + + +def _get_stock_uom_rate(rate: float, ctx: ItemDetailsCtx): + return rate / ctx.conversion_factor if ctx.conversion_factor else rate def get_item_price(