diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index c65c9f992a0..62b156959bb 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -557,6 +557,7 @@ "fieldname": "blanket_order_rate", "fieldtype": "Currency", "label": "Blanket Order Rate", + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -944,7 +945,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-27 10:55:37.000000", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 2c3fcc17111..8d72144925f 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -801,7 +801,7 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag bo = frappe.qb.DocType("Blanket Order") bo_item = frappe.qb.DocType("Blanket Order Item") - blanket_orders = ( + query = ( frappe.qb.from_(bo) .from_(bo_item) .select(bo.name) @@ -814,10 +814,12 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag & (bo.company == filters.get("company")) & (bo.docstatus == 1) ) - .run() ) - return blanket_orders + if currency := filters.get("currency"): + query = query.where(bo.currency == currency) + + return query.run() @frappe.whitelist() diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index 91301093ef4..5066bca3f0d 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -4,6 +4,10 @@ frappe.ui.form.on("Blanket Order", { onload: function (frm) { frm.trigger("set_tc_name_filter"); + if (frm.is_new()) { + let has_pricing = frm.doc.currency || frm.doc.selling_price_list || frm.doc.buying_price_list; + blanket_order_pricing.apply(frm, null, { reset_party_values: !has_pricing }); + } }, setup: function (frm) { @@ -15,10 +19,13 @@ frappe.ui.form.on("Blanket Order", { frm.add_fetch("customer", "customer_name", "customer_name"); frm.add_fetch("supplier", "supplier_name", "supplier_name"); + frm.set_query("selling_price_list", () => ({ filters: { selling: 1 } })); + frm.set_query("buying_price_list", () => ({ filters: { buying: 1 } })); }, refresh: function (frm) { erpnext.hide_company(frm); + blanket_order_pricing.update_labels(frm); if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) { frm.add_custom_button( __("Sales Order"), @@ -101,5 +108,141 @@ frappe.ui.form.on("Blanket Order", { blanket_order_type: function (frm) { frm.trigger("set_tc_name_filter"); + return reset_party_pricing(frm); + }, + + company: reset_party_pricing, + + customer: reset_party_pricing, + + supplier: reset_party_pricing, + + currency: function (frm) { + return blanket_order_pricing.apply(frm, null, { reset_conversion_rate: true }); + }, + + from_date: function (frm) { + return blanket_order_pricing.apply(frm, null, { + reset_conversion_rate: true, + reset_plc_conversion_rate: true, + }); + }, + + conversion_rate: async function (frm) { + await blanket_order_pricing.update_base_rates(frm); + return blanket_order_pricing.apply(frm); + }, + + selling_price_list: reset_price_list_exchange_rate, + + buying_price_list: reset_price_list_exchange_rate, + + plc_conversion_rate: function (frm) { + return blanket_order_pricing.apply(frm); }, }); + +frappe.ui.form.on("Blanket Order Item", { + item_code: apply_item_pricing, + + qty: apply_item_pricing, + + rate: function (frm, cdt, cdn) { + return set_base_rate(frm, frappe.get_doc(cdt, cdn)); + }, +}); + +const blanket_order_pricing = { + update_base_rates(frm) { + return Promise.all((frm.doc.items || []).map((item) => set_base_rate(frm, item))); + }, + + update_labels(frm) { + let company_currency = this.get_company_currency(frm); + let show_base_rate = Boolean( + frm.doc.currency && company_currency && frm.doc.currency !== company_currency + ); + + frm.set_currency_labels(["price_list_rate", "rate"], frm.doc.currency || company_currency, "items"); + frm.set_currency_labels(["base_price_list_rate", "base_rate"], company_currency, "items"); + frm.fields_dict.items.grid.set_column_disp("base_price_list_rate", show_base_rate); + frm.fields_dict.items.grid.set_column_disp("base_rate", show_base_rate); + frm.toggle_display("conversion_rate", show_base_rate); + frm.toggle_display( + "plc_conversion_rate", + Boolean(frm.doc.price_list_currency && frm.doc.price_list_currency !== company_currency) + ); + frm.set_df_property( + "conversion_rate", + "description", + show_base_rate ? `1 ${frm.doc.currency} = [?] ${company_currency}` : "" + ); + frm.refresh_fields(); + }, + + get_company_currency(frm) { + return frm.doc.company ? erpnext.get_currency(frm.doc.company) : null; + }, + + async apply(frm, item_name = null, options = {}) { + if (!frm.doc.company || !frm.doc.blanket_order_type) { + return; + } + + if (frm.__applying_blanket_order_price_list) { + frm.__pending_blanket_order_price_list = { item_name, options }; + return; + } + + frm.__applying_blanket_order_price_list = true; + let pending; + try { + let response = await frappe.call({ + method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.apply_price_list", + args: { + doc: frm.doc, + item_name, + reset_party_values: options.reset_party_values, + reset_conversion_rate: options.reset_conversion_rate, + reset_plc_conversion_rate: options.reset_plc_conversion_rate, + }, + }); + if (response.message) { + await frm.set_value(response.message.parent); + for (const values of response.message.children) { + let { name, ...fields } = values; + let item = (frm.doc.items || []).find((row) => row.name === name); + if (item) { + await frappe.model.set_value(item.doctype, item.name, fields); + } + } + this.update_labels(frm); + } + } finally { + frm.__applying_blanket_order_price_list = false; + pending = frm.__pending_blanket_order_price_list; + frm.__pending_blanket_order_price_list = null; + } + if (pending) { + return this.apply(frm, pending.item_name, pending.options); + } + }, +}; + +function reset_party_pricing(frm) { + return blanket_order_pricing.apply(frm, null, { reset_party_values: true }); +} + +function reset_price_list_exchange_rate(frm) { + return blanket_order_pricing.apply(frm, null, { reset_plc_conversion_rate: true }); +} + +function apply_item_pricing(frm, cdt, cdn) { + return blanket_order_pricing.apply(frm, cdn); +} + +function set_base_rate(frm, item) { + frappe.model.round_floats_in(item, ["rate"]); + let base_rate = flt(flt(item.rate) * flt(frm.doc.conversion_rate), precision("base_rate", item)); + return frappe.model.set_value(item.doctype, item.name, "base_rate", base_rate); +} diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json index 1c1d0d29611..3187b97b8b8 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json @@ -18,6 +18,14 @@ "from_date", "to_date", "company", + "currency_and_price_list", + "currency", + "conversion_rate", + "column_break_price_list", + "selling_price_list", + "buying_price_list", + "price_list_currency", + "plc_conversion_rate", "section_break_12", "items", "amended_from", @@ -96,6 +104,66 @@ "reqd": 1, "search_index": 1 }, + { + "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", + "fieldname": "currency_and_price_list", + "fieldtype": "Section Break", + "label": "Currency and Price List" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_price_list", + "fieldtype": "Column Break" + }, + { + "description": "Rate at which document currency is converted to company currency", + "fieldname": "conversion_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "precision": "9", + "print_hide": 1, + "reqd": 1 + }, + { + "depends_on": "eval:doc.blanket_order_type == \"Selling\"", + "fieldname": "selling_price_list", + "fieldtype": "Link", + "label": "Price List", + "options": "Price List", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.blanket_order_type == \"Purchasing\"", + "fieldname": "buying_price_list", + "fieldtype": "Link", + "label": "Price List", + "options": "Price List", + "print_hide": 1 + }, + { + "fieldname": "price_list_currency", + "fieldtype": "Link", + "label": "Price List Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1 + }, + { + "description": "Rate at which Price List Currency is converted to Company Currency", + "fieldname": "plc_conversion_rate", + "fieldtype": "Float", + "label": "Price List Exchange Rate", + "precision": "9", + "print_hide": 1 + }, { "fieldname": "section_break_12", "fieldtype": "Section Break" @@ -147,7 +215,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-08-21 23:11:40.122402", + "modified": "2026-08-27 10:55:37.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Blanket Order", diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index 983f19f31d1..c6f769a2c59 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -9,6 +9,9 @@ from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum from frappe.utils import flt, getdate +from erpnext import get_company_currency +from erpnext.accounts.services.taxes import validate_conversion_rate +from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing from erpnext.stock.doctype.item.item import get_item_defaults @@ -25,7 +28,10 @@ class BlanketOrder(Document): amended_from: DF.Link | None blanket_order_type: DF.Literal["", "Selling", "Purchasing"] + buying_price_list: DF.Link | None company: DF.Link + conversion_rate: DF.Float + currency: DF.Link customer: DF.Link | None customer_name: DF.Data | None from_date: DF.Date @@ -33,6 +39,9 @@ class BlanketOrder(Document): naming_series: DF.Literal["MFG-BLR-.YYYY.-"] order_date: DF.Date | None order_no: DF.Data | None + plc_conversion_rate: DF.Float + price_list_currency: DF.Link | None + selling_price_list: DF.Link | None supplier: DF.Link | None supplier_name: DF.Data | None tc_name: DF.Link | None @@ -40,11 +49,42 @@ class BlanketOrder(Document): to_date: DF.Date # end: auto-generated types + def before_validate(self): + self.set_currency() + self.set_conversion_rate() + blanket_order_pricing.set_price_list(self) + def validate(self): self.validate_dates() self.validate_duplicate_items() self.validate_item_qty() self.set_party_item_code() + self.set_base_rates() + + def set_currency(self): + if self.currency: + return + + config = blanket_order_pricing.get_order_type_config(self.blanket_order_type) + party_type = config["party_type"] + party = self.get(config["party_field"]) + party_currency = frappe.get_cached_value(party_type, party, "default_currency") if party else None + self.currency = party_currency or get_company_currency(self.company) + + def set_conversion_rate(self): + company_currency = get_company_currency(self.company) + if self.currency == company_currency: + self.conversion_rate = 1.0 + elif not self.conversion_rate: + self.conversion_rate = blanket_order_pricing.get_exchange_rate_to_company(self, self.currency) + + validate_conversion_rate( + self.currency, + self.conversion_rate, + self.meta.get_translated_label("conversion_rate"), + self.company, + ) + self.conversion_rate = flt(self.conversion_rate, self.precision("conversion_rate")) def validate_dates(self): if getdate(self.from_date) > getdate(self.to_date): @@ -123,6 +163,26 @@ class BlanketOrder(Document): if flt(d.qty) <= 0: frappe.throw(_("Row {0}: Quantity must be greater than zero.").format(d.idx)) + def set_base_rates(self): + blanket_order_pricing.set_base_rates(self) + + +@frappe.whitelist() +def apply_price_list( + doc: str | dict, + item_name: str | None = None, + reset_party_values: bool = False, + reset_plc_conversion_rate: bool = False, + reset_conversion_rate: bool = False, +): + return blanket_order_pricing.apply_price_list( + doc=doc, + item_name=item_name, + reset_party_values=reset_party_values, + reset_plc_conversion_rate=reset_plc_conversion_rate, + reset_conversion_rate=reset_conversion_rate, + ) + @frappe.whitelist() def make_order(source_name: str): @@ -136,14 +196,12 @@ def make_order(source_name: str): def update_item(source, target, source_parent): target_qty = source.get("qty") - source.get("ordered_qty") target.qty = target_qty if flt(target_qty) >= 0 else 0 - target.rate = source.get("rate") item = get_item_defaults(target.item_code, source_parent.company) if item: target.item_name = item.get("item_name") target.description = item.get("description") target.uom = item.get("stock_uom") target.against_blanket_order = 1 - target.blanket_order = source_name target_doc = get_mapped_doc( "Blanket Order", @@ -156,7 +214,10 @@ def make_order(source_name: str): }, "Blanket Order Item": { "doctype": doctype + " Item", - "field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"}, + "field_map": { + "rate": "blanket_order_rate", + "parent": "blanket_order", + }, "postprocess": update_item, "condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0, }, diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order_pricing.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order_pricing.py new file mode 100644 index 00000000000..be55a9de9d1 --- /dev/null +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order_pricing.py @@ -0,0 +1,228 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + + +import frappe +from frappe import _ +from frappe.utils import cint, flt + +from erpnext import get_company_currency +from erpnext.accounts.party import get_default_price_list as get_party_default_price_list +from erpnext.accounts.services.taxes import validate_conversion_rate +from erpnext.setup.utils import get_exchange_rate +from erpnext.stock.get_item_details import get_price_list_rate_for + +_ORDER_TYPE_CONFIG = { + "Selling": { + "exchange_rate_type": "for_selling", + "opposite_price_list_field": "buying_price_list", + "party_field": "customer", + "party_type": "Customer", + "price_list_field": "selling_price_list", + "price_list_type": "Selling", + "settings_doctype": "Selling Settings", + }, + "Purchasing": { + "exchange_rate_type": "for_buying", + "opposite_price_list_field": "selling_price_list", + "party_field": "supplier", + "party_type": "Supplier", + "price_list_field": "buying_price_list", + "price_list_type": "Buying", + "settings_doctype": "Buying Settings", + }, +} + + +def get_order_type_config(blanket_order_type): + return _ORDER_TYPE_CONFIG[blanket_order_type] + + +def get_exchange_rate_to_company(doc, currency): + config = get_order_type_config(doc.blanket_order_type) + return get_exchange_rate( + currency, + get_company_currency(doc.company), + doc.from_date, + config["exchange_rate_type"], + ) + + +def set_price_list(doc, set_default=False, force_exchange_rate=False): + config = get_order_type_config(doc.blanket_order_type) + fieldname = config["price_list_field"] + doc.set(config["opposite_price_list_field"], None) + + if not doc.get(fieldname) and (doc.is_new() or set_default): + doc.set(fieldname, get_default_price_list(doc)) + + price_list = doc.get(fieldname) + if not price_list: + clear_price_list(doc) + return + + price_list_type = config["price_list_type"].lower() + price_list_details = frappe.get_cached_value( + "Price List", price_list, ["currency", price_list_type, "enabled"], as_dict=True + ) + if not price_list_details or not price_list_details.enabled: + frappe.throw(_("Price List {0} is disabled or does not exist").format(frappe.bold(price_list))) + if not price_list_details.get(price_list_type): + frappe.throw( + _("Price List {0} is not enabled for {1}").format( + frappe.bold(price_list), frappe.bold(doc.blanket_order_type) + ) + ) + + price_list_currency_changed = doc.price_list_currency != price_list_details.currency + doc.price_list_currency = price_list_details.currency + company_currency = get_company_currency(doc.company) + if doc.price_list_currency == company_currency: + doc.plc_conversion_rate = 1.0 + elif price_list_currency_changed or not doc.plc_conversion_rate or force_exchange_rate: + doc.plc_conversion_rate = get_exchange_rate_to_company(doc, doc.price_list_currency) + + validate_conversion_rate( + doc.price_list_currency, + doc.plc_conversion_rate, + doc.meta.get_translated_label("plc_conversion_rate"), + doc.company, + ) + doc.plc_conversion_rate = flt(doc.plc_conversion_rate, doc.precision("plc_conversion_rate")) + + +def clear_price_list(doc): + doc.price_list_currency = None + doc.plc_conversion_rate = 0 + for item in doc.items: + item.price_list_rate = 0 + item.base_price_list_rate = 0 + + +def get_default_price_list(doc): + config = get_order_type_config(doc.blanket_order_type) + party_type = config["party_type"] + party = doc.get(config["party_field"]) + if party: + party_price_list = get_party_default_price_list(frappe.get_cached_doc(party_type, party)) + if party_price_list: + return party_price_list + + return frappe.db.get_single_value(config["settings_doctype"], config["price_list_field"]) + + +def get_price_list_rates(doc, item_name=None): + price_list = doc.get(get_order_type_config(doc.blanket_order_type)["price_list_field"]) + items = [item for item in doc.items if item.item_code and (not item_name or item.name == item_name)] + if not items: + return [] + if not price_list: + return [{"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0} for item in items] + + stock_uoms = dict( + frappe.get_all( + "Item", + filters={"name": ("in", [item.item_code for item in items])}, + fields=["name", "stock_uom"], + as_list=True, + ) + ) + + ctx = frappe._dict( + { + "price_list": price_list, + "customer": doc.customer, + "supplier": doc.supplier, + "transaction_date": doc.from_date, + } + ) + rates = [] + for item in items: + stock_uom = stock_uoms.get(item.item_code) + ctx.update( + { + "qty": flt(item.qty) or 1, + "uom": stock_uom, + "stock_uom": stock_uom, + "conversion_factor": 1, + } + ) + price_list_rate = get_price_list_rate_for(ctx, item.item_code) + rate_details = {"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0} + if price_list_rate is not None: + rate = flt(price_list_rate) * flt(doc.plc_conversion_rate) / flt(doc.conversion_rate) + price_list_rate, base_price_list_rate = get_rate_and_base_amount( + doc, item, "price_list_rate", rate + ) + rate, base_rate = get_rate_and_base_amount(doc, item, "rate", rate) + rate_details.update( + { + "price_list_rate": price_list_rate, + "base_price_list_rate": base_price_list_rate, + "rate": rate, + "base_rate": base_rate, + } + ) + rates.append(rate_details) + + return rates + + +def set_base_rates(doc): + for item in doc.items: + for fieldname in ("price_list_rate", "rate"): + rate, base_rate = get_rate_and_base_amount(doc, item, fieldname, item.get(fieldname)) + item.set(fieldname, rate) + item.set(f"base_{fieldname}", base_rate) + + +def get_rate_and_base_amount(doc, item, fieldname, rate): + rate = flt(rate, item.precision(fieldname)) + base_fieldname = f"base_{fieldname}" + base_rate = flt(rate * flt(doc.conversion_rate), item.precision(base_fieldname)) + return rate, base_rate + + +def apply_price_list( + doc, + item_name=None, + reset_party_values=False, + reset_plc_conversion_rate=False, + reset_conversion_rate=False, +): + doc = frappe.get_doc(frappe.parse_json(doc)) + reset_party_values = cint(reset_party_values) + reset_plc_conversion_rate = cint(reset_plc_conversion_rate) + reset_conversion_rate = cint(reset_conversion_rate) + if reset_party_values: + doc.currency = None + doc.conversion_rate = 0 + doc.selling_price_list = None + doc.buying_price_list = None + doc.price_list_currency = None + doc.plc_conversion_rate = 0 + else: + if reset_conversion_rate: + doc.conversion_rate = 0 + if reset_plc_conversion_rate: + doc.plc_conversion_rate = 0 + + doc.set_currency() + doc.set_conversion_rate() + set_price_list( + doc, + set_default=reset_party_values, + force_exchange_rate=reset_party_values or reset_plc_conversion_rate, + ) + + return { + "parent": { + "currency": doc.currency, + "conversion_rate": doc.conversion_rate, + "selling_price_list": doc.selling_price_list, + "buying_price_list": doc.buying_price_list, + "price_list_currency": doc.price_list_currency, + "plc_conversion_rate": doc.plc_conversion_rate, + }, + "children": get_price_list_rates(doc, item_name), + } diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index e0a25c9a359..c5d6b68b9a9 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -1,13 +1,18 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe -from frappe.utils import add_months, today +from frappe.utils import add_months, flt, today from erpnext import get_company_currency +from erpnext.controllers.queries import get_blanket_orders from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.get_item_details import get_blanket_order_details from erpnext.tests.utils import ERPNextTestSuite -from .blanket_order import make_order +from . import blanket_order_pricing +from .blanket_order import apply_price_list, make_order class TestBlanketOrder(ERPNextTestSuite): @@ -184,21 +189,241 @@ class TestBlanketOrder(ERPNextTestSuite): with self.assertRaises(frappe.ValidationError): bo.insert() + def test_multicurrency_blanket_order(self): + company_currency = get_company_currency("_Test Company") + transaction_currency = "USD" if company_currency != "USD" else "EUR" + conversion_rate = 80 + rate = 5 + + for blanket_order_type, target_doctypes in ( + ("Selling", ("Sales Order", "Quotation")), + ("Purchasing", ("Purchase Order",)), + ): + blanket_order = make_blanket_order( + blanket_order_type=blanket_order_type, + currency=transaction_currency, + conversion_rate=conversion_rate, + rate=rate, + ) + + self.assertEqual(blanket_order.currency, transaction_currency) + self.assertEqual(blanket_order.conversion_rate, conversion_rate) + self.assertEqual(blanket_order.items[0].base_rate, rate * conversion_rate) + + for target_doctype in target_doctypes: + with self.subTest(target_doctype=target_doctype): + frappe.flags.args.doctype = target_doctype + target = make_order(blanket_order.name) + + self.assertEqual(target.currency, transaction_currency) + self.assertEqual(target.conversion_rate, conversion_rate) + self.assertEqual(target.items[0].rate, rate) + self.assertEqual(target.items[0].base_rate, rate * conversion_rate) + self.assertEqual(target.items[0].blanket_order_rate, rate) + self.assertEqual(target.items[0].blanket_order, blanket_order.name) + + def test_price_list_rates_and_mapping(self): + company = "_Test Company" + company_currency = get_company_currency(company) + transaction_currency = "USD" if company_currency != "USD" else "EUR" + conversion_rate = 80 + price_list_rate = 800 + + for blanket_order_type, price_list_field, target_doctypes in ( + ("Selling", "selling_price_list", ("Sales Order", "Quotation")), + ("Purchasing", "buying_price_list", ("Purchase Order",)), + ): + blanket_order, price_list = make_priced_blanket_order( + blanket_order_type=blanket_order_type, + company=company, + currency=transaction_currency, + conversion_rate=conversion_rate, + price_list_rate=price_list_rate, + qty=1000, + ) + blanket_order.insert() + blanket_order.submit() + + expected_rate = price_list_rate / conversion_rate + self.assertEqual(blanket_order.price_list_currency, company_currency) + self.assertEqual(blanket_order.plc_conversion_rate, 1) + self.assertEqual(blanket_order.items[0].price_list_rate, expected_rate) + self.assertEqual(blanket_order.items[0].base_price_list_rate, price_list_rate) + self.assertEqual(blanket_order.items[0].rate, expected_rate) + self.assertEqual(blanket_order.items[0].base_rate, price_list_rate) + + for target_doctype in target_doctypes: + with self.subTest(target_doctype=target_doctype): + frappe.flags.args.doctype = target_doctype + target = make_order(blanket_order.name) + + self.assertEqual(target.get(price_list_field), price_list) + self.assertEqual(target.price_list_currency, company_currency) + self.assertEqual(target.plc_conversion_rate, 1) + self.assertEqual(target.items[0].price_list_rate, expected_rate) + self.assertEqual(target.items[0].base_price_list_rate, price_list_rate) + self.assertEqual(target.items[0].rate, expected_rate) + self.assertEqual(target.items[0].blanket_order, blanket_order.name) + + def test_applying_price_list_ignores_empty_item_rows(self): + blanket_order = frappe.new_doc("Blanket Order") + blanket_order.blanket_order_type = "Selling" + blanket_order.company = "_Test Company" + blanket_order.customer = "_Test Customer" + blanket_order.from_date = today() + blanket_order.append("items", {}) + + pricing = apply_price_list(blanket_order.as_dict()) + + self.assertEqual(pricing["children"], []) + + def test_price_list_rate_is_fetched_on_item_selection(self): + company = "_Test Company" + company_currency = get_company_currency(company) + price_list_rate = 800 + blanket_order, _price_list = make_priced_blanket_order( + company=company, + currency=company_currency, + conversion_rate=1, + price_list_rate=price_list_rate, + qty=0, + ) + item = blanket_order.items[0] + + self.assertEqual(item.price_list_rate, price_list_rate) + self.assertEqual(item.rate, price_list_rate) + + def test_price_list_rates_fetch_item_uoms_once(self): + blanket_order = new_blanket_order("Selling") + blanket_order.selling_price_list = "_Test Price List" + for item_code in ("ITEM-1", "ITEM-2"): + blanket_order.append("items", {"item_code": item_code, "qty": 1}) + + with ( + patch.object( + blanket_order_pricing.frappe, + "get_all", + return_value=[["ITEM-1", "Nos"], ["ITEM-2", "Nos"]], + ) as get_all, + patch.object(blanket_order_pricing, "get_price_list_rate_for", return_value=None), + ): + rates = blanket_order_pricing.get_price_list_rates(blanket_order) + + self.assertEqual(len(rates), 2) + get_all.assert_called_once_with( + "Item", + filters={"name": ("in", ["ITEM-1", "ITEM-2"])}, + fields=["name", "stock_uom"], + as_list=True, + ) + + def test_price_list_conversion_uses_currency_precision(self): + company = "_Test Company" + company_currency = get_company_currency(company) + transaction_currency = "USD" if company_currency != "USD" else "EUR" + conversion_rate = 95.47 + price_list_rate = 100 + blanket_order, _price_list = make_priced_blanket_order( + company=company, + currency=transaction_currency, + conversion_rate=conversion_rate, + price_list_rate=price_list_rate, + ) + item = blanket_order.items[0] + expected_rate = flt(price_list_rate / conversion_rate, item.precision("rate")) + expected_base_rate = flt(expected_rate * conversion_rate, item.precision("base_rate")) + + self.assertFalse(frappe.get_meta("Blanket Order Item").get_field("rate").precision) + self.assertEqual(item.price_list_rate, expected_rate) + self.assertEqual(item.base_price_list_rate, expected_base_rate) + self.assertEqual(item.rate, expected_rate) + self.assertEqual(item.base_rate, expected_base_rate) + + blanket_order.insert() + blanket_order.submit() + + frappe.flags.args.doctype = "Sales Order" + sales_order = make_order(blanket_order.name) + sales_order.delivery_date = today() + sales_order.insert() + + self.assertEqual(sales_order.items[0].price_list_rate, item.price_list_rate) + self.assertEqual(sales_order.items[0].base_price_list_rate, item.base_price_list_rate) + self.assertEqual(sales_order.items[0].rate, item.rate) + self.assertEqual(sales_order.items[0].base_rate, item.base_rate) + + def test_applying_price_list_can_reset_conversion_rate(self): + company_currency = get_company_currency("_Test Company") + transaction_currency = "USD" if company_currency != "USD" else "EUR" + blanket_order, _price_list = make_priced_blanket_order( + currency=transaction_currency, + conversion_rate=80, + price_list_rate=100, + ) + + with patch( + "erpnext.manufacturing.doctype.blanket_order.blanket_order_pricing.get_exchange_rate", + return_value=95.47, + ): + pricing = apply_price_list(blanket_order.as_dict(), reset_conversion_rate=True) + + self.assertEqual(pricing["parent"]["conversion_rate"], 95.47) + expected_rate = flt( + 100 / pricing["parent"]["conversion_rate"], + blanket_order.items[0].precision("rate"), + ) + expected_base_rate = flt( + expected_rate * pricing["parent"]["conversion_rate"], + blanket_order.items[0].precision("base_rate"), + ) + self.assertEqual(pricing["children"][0]["base_rate"], expected_base_rate) + + def test_blanket_order_lookup_filters_currency(self): + company_currency = get_company_currency("_Test Company") + transaction_currency = "USD" if company_currency != "USD" else "EUR" + blanket_order = make_blanket_order( + blanket_order_type="Selling", + currency=transaction_currency, + conversion_rate=80, + ) + + filters = { + "company": blanket_order.company, + "currency": transaction_currency, + "blanket_order_type": "Selling", + "item": blanket_order.items[0].item_code, + } + matching_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters) + self.assertIn(blanket_order.name, [order[0] for order in matching_orders]) + + filters["currency"] = company_currency + other_currency_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters) + self.assertNotIn(blanket_order.name, [order[0] for order in other_currency_orders]) + + details = get_blanket_order_details( + { + "blanket_order": blanket_order.name, + "company": blanket_order.company, + "currency": company_currency, + "customer": blanket_order.customer, + "doctype": "Sales Order", + "item_code": blanket_order.items[0].item_code, + "transaction_date": today(), + } + ) + self.assertFalse(details) + def make_blanket_order(**args): args = frappe._dict(args) - bo = frappe.new_doc("Blanket Order") - bo.blanket_order_type = args.blanket_order_type - bo.company = args.company or "_Test Company" - - if args.blanket_order_type == "Selling": - bo.customer = args.customer or "_Test Customer" - else: - bo.supplier = args.supplier or "_Test Supplier" - - bo.from_date = today() - bo.to_date = add_months(bo.from_date, months=12) - + bo = new_blanket_order( + blanket_order_type=args.blanket_order_type, + company=args.company or "_Test Company", + currency=args.currency, + conversion_rate=args.conversion_rate or 1, + customer=args.customer, + supplier=args.supplier, + ) bo.append( "items", { @@ -211,3 +436,84 @@ def make_blanket_order(**args): bo.insert() bo.submit() return bo + + +def make_priced_blanket_order( + blanket_order_type="Selling", + company="_Test Company", + currency=None, + conversion_rate=1, + price_list_rate=800, + qty=1, +): + price_list = make_blanket_order_price_list(get_company_currency(company), price_list_rate) + blanket_order = new_blanket_order( + blanket_order_type=blanket_order_type, + company=company, + currency=currency, + conversion_rate=conversion_rate, + ) + config = blanket_order_pricing.get_order_type_config(blanket_order_type) + blanket_order.set(config["price_list_field"], price_list) + item = blanket_order.append("items", {"item_code": "_Test Item", "qty": qty, "rate": 0}) + pricing = apply_price_list(blanket_order.as_dict()) + blanket_order.update(pricing["parent"]) + item.update({key: value for key, value in pricing["children"][0].items() if key != "name"}) + + return blanket_order, price_list + + +def new_blanket_order( + blanket_order_type, + company="_Test Company", + currency=None, + conversion_rate=1, + customer=None, + supplier=None, +): + blanket_order = frappe.new_doc("Blanket Order") + blanket_order.blanket_order_type = blanket_order_type + blanket_order.company = company + blanket_order.currency = currency or get_company_currency(company) + blanket_order.conversion_rate = conversion_rate + blanket_order.from_date = today() + blanket_order.to_date = add_months(blanket_order.from_date, months=12) + + config = blanket_order_pricing.get_order_type_config(blanket_order_type) + party = customer if config["party_field"] == "customer" else supplier + blanket_order.set(config["party_field"], party or f"_Test {config['party_type']}") + + return blanket_order + + +def make_blanket_order_price_list(currency, price_list_rate): + price_list = "_Test Blanket Order Price List" + if not frappe.db.exists("Price List", price_list): + frappe.get_doc( + { + "doctype": "Price List", + "price_list_name": price_list, + "currency": currency, + "selling": 1, + "buying": 1, + } + ).insert() + else: + frappe.db.set_value("Price List", price_list, {"currency": currency, "selling": 1, "buying": 1}) + + item_price = frappe.db.get_value( + "Item Price", {"price_list": price_list, "item_code": "_Test Item"}, "name" + ) + if item_price: + frappe.db.set_value("Item Price", item_price, "price_list_rate", price_list_rate) + else: + frappe.get_doc( + { + "doctype": "Item Price", + "price_list": price_list, + "item_code": "_Test Item", + "price_list_rate": price_list_rate, + } + ).insert() + + return price_list diff --git a/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.json b/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.json index 919ec13a7a1..935b1bcabb6 100644 --- a/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.json +++ b/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.json @@ -10,7 +10,10 @@ "party_item_code", "column_break_3", "qty", + "price_list_rate", + "base_price_list_rate", "rate", + "base_rate", "ordered_qty", "section_break_7", "terms_and_conditions" @@ -41,11 +44,37 @@ "in_list_view": 1, "label": "Quantity" }, + { + "fieldname": "price_list_rate", + "fieldtype": "Currency", + "label": "Price List Rate", + "options": "currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "base_price_list_rate", + "fieldtype": "Currency", + "label": "Price List Rate (Company Currency)", + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1 + }, { "fieldname": "rate", "fieldtype": "Currency", "in_list_view": 1, "label": "Rate", + "options": "currency", + "reqd": 1 + }, + { + "fieldname": "base_rate", + "fieldtype": "Currency", + "label": "Rate (Company Currency)", + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1, "reqd": 1 }, { @@ -74,7 +103,7 @@ ], "istable": 1, "links": [], - "modified": "2024-03-27 13:06:40.083042", + "modified": "2026-08-27 10:55:37.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Blanket Order Item", @@ -85,4 +114,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.py b/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.py index 316d294eaf7..8ae5bcb1b3b 100644 --- a/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.py +++ b/erpnext/manufacturing/doctype/blanket_order_item/blanket_order_item.py @@ -14,6 +14,8 @@ class BlanketOrderItem(Document): if TYPE_CHECKING: from frappe.types import DF + base_price_list_rate: DF.Currency + base_rate: DF.Currency item_code: DF.Link item_name: DF.Data | None ordered_qty: DF.Float @@ -21,6 +23,7 @@ class BlanketOrderItem(Document): parentfield: DF.Data parenttype: DF.Data party_item_code: DF.Data | None + price_list_rate: DF.Currency qty: DF.Float rate: DF.Currency terms_and_conditions: DF.Text | None diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 15cc77fc900..747929c078e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -513,6 +513,7 @@ erpnext.patches.v16_0.set_work_order_requested_and_picked_qty erpnext.patches.v16_0.rename_italy_customer_name_fields erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status +erpnext.patches.v16_0.add_currency_to_blanket_orders erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.add_batch_split_stock_entry_type diff --git a/erpnext/patches/v16_0/add_currency_to_blanket_orders.py b/erpnext/patches/v16_0/add_currency_to_blanket_orders.py new file mode 100644 index 00000000000..bf9f8935fdd --- /dev/null +++ b/erpnext/patches/v16_0/add_currency_to_blanket_orders.py @@ -0,0 +1,23 @@ +import frappe + + +def execute(): + company_currencies = dict(frappe.get_all("Company", fields=["name", "default_currency"], as_list=True)) + blanket_order_updates = { + order.name: { + "currency": company_currencies.get(order.company), + "conversion_rate": 1.0, + } + for order in frappe.get_all("Blanket Order", fields=["name", "company", "currency"]) + if not order.currency + } + if blanket_order_updates: + frappe.db.bulk_update("Blanket Order", blanket_order_updates, update_modified=False) + + item_updates = { + item.name: {"base_rate": item.rate} + for item in frappe.get_all("Blanket Order Item", fields=["name", "rate", "base_rate"]) + if not item.base_rate + } + if item_updates: + frappe.db.bulk_update("Blanket Order Item", item_updates, update_modified=False) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 280b3088633..cbd965ae7ce 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -334,6 +334,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe query: "erpnext.controllers.queries.get_blanket_orders", filters: { company: doc.company, + currency: doc.currency, blanket_order_type: doc.doctype === "Sales Order" ? "Selling" : "Purchasing", item: item.item_code, }, @@ -3267,10 +3268,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe method: "erpnext.stock.get_item_details.get_blanket_order_details", args: { ctx: { + doctype: doc.doctype, item_code: item.item_code, customer: doc.customer, supplier: doc.supplier, company: doc.company, + currency: doc.currency, transaction_date: doc.transaction_date, blanket_order: item.blanket_order, }, diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index c70bddba2d5..b3fa876e38e 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -626,6 +626,7 @@ "fieldtype": "Currency", "label": "Blanket Order Rate", "no_copy": 1, + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -730,7 +731,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-27 10:55:37.000000", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 87f38e7c3c8..4415e3e0843 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -662,6 +662,7 @@ "fieldtype": "Currency", "label": "Blanket Order Rate", "no_copy": 1, + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -1066,7 +1067,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-08-25 10:00:00.000000", + "modified": "2026-08-27 10:55:37.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index bbe5ef8ba5a..8a53f976bdc 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1890,6 +1890,8 @@ def get_blanket_order_details(ctx: ItemDetailsCtx): query = query.where(bo.supplier == ctx.supplier) if ctx.blanket_order: query = query.where(bo.name == ctx.blanket_order) + if ctx.currency: + query = query.where(bo.currency == ctx.currency) if ctx.transaction_date: query = query.where(bo.to_date >= ctx.transaction_date)