feat(manufacturing): add multi-currency support to Blanket Orders (#58472)

This commit is contained in:
Mihir Kandoi
2026-08-31 15:15:34 +05:30
committed by GitHub
parent 20ea09b4a0
commit 509501c299
15 changed files with 898 additions and 26 deletions

View File

@@ -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",

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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,
},

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)