From e8df7b4a901dc3255ad28bcb183656b348cfb14d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 22:22:49 +0530 Subject: [PATCH 01/21] feat: select a supplier per item when creating Purchase Orders from Material Request Creating a Purchase Order from a Material Request mapped every pending item into a single order, leaving the buyer to split it by hand whenever the items came from different vendors. The Create action now reads the default supplier of each pending item (item, item group, then brand defaults). When the items resolve to more than one distinct supplier - including the case where only some of them have a default - a dialog lists the items with their default supplier prefilled and editable. Submitting it groups the items by the chosen supplier and creates one draft Purchase Order per group. When every item resolves to the same supplier the order is mapped straight away with that supplier set, and when none of them has a default supplier the previous behaviour is unchanged. --- .../stock/doctype/material_request/mapper.py | 72 ++++++++++- .../material_request/material_request.js | 122 +++++++++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 14def8afcd7..7d192b30b44 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -9,6 +9,10 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, getdate, nowdate +from erpnext.setup.doctype.brand.brand import get_brand_defaults +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.get_item_details import get_default_supplier from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, ) @@ -52,7 +56,7 @@ def make_purchase_order( source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None ): if args is None: - args = {} + args = frappe.flags.args or {} args = frappe.parse_json(args) is_subcontracted = ( @@ -61,6 +65,8 @@ def make_purchase_order( def postprocess(source, target_doc): target_doc.is_subcontracted = is_subcontracted + if args.get("supplier"): + target_doc.supplier = args.get("supplier") set_missing_values(source, target_doc) def select_item(d): @@ -140,6 +146,70 @@ def make_request_for_quotation(source_name: str, target_doc: str | dict | Docume return doclist +def get_default_supplier_for_item(item_code: str, company: str) -> str | None: + return get_default_supplier( + frappe._dict(), + get_item_defaults(item_code, company), + get_item_group_defaults(item_code, company), + get_brand_defaults(item_code, company), + ) + + +@frappe.whitelist() +def get_item_default_suppliers(source_name: str, filtered_children: str | list | None = None) -> list[dict]: + """Pending items of the Material Request with their default supplier.""" + filtered_children = frappe.parse_json(filtered_children) if filtered_children else [] + + material_request = frappe.get_doc("Material Request", source_name) + material_request.check_permission("read") + + items = [] + for item in material_request.items: + if filtered_children and item.name not in filtered_children: + continue + + ordered_qty = flt(item.ordered_qty) or flt(item.received_qty) + if ordered_qty >= flt(item.stock_qty): + continue + + items.append( + { + "material_request_item": item.name, + "item_code": item.item_code, + "item_name": item.item_name, + "qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1), + "uom": item.uom, + "supplier": get_default_supplier_for_item(item.item_code, material_request.company), + } + ) + + return items + + +@frappe.whitelist(methods=["POST"]) +def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: + """Create one draft Purchase Order per supplier for the given Material Request items.""" + item_suppliers = frappe.parse_json(item_suppliers) + + items_by_supplier = {} + for row in item_suppliers: + row = frappe._dict(row) + if not row.supplier: + frappe.throw(_("Select a Supplier for Item {0}").format(frappe.bold(row.item_code))) + + items_by_supplier.setdefault(row.supplier, []).append(row.material_request_item) + + purchase_orders = [] + for supplier, material_request_items in items_by_supplier.items(): + purchase_order = make_purchase_order( + source_name, args={"supplier": supplier, "filtered_children": material_request_items} + ) + purchase_order.insert() + purchase_orders.append(purchase_order.name) + + return purchase_orders + + @frappe.whitelist() def get_items_based_on_default_supplier(supplier: str): supplier_items = [ diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index b5a8c0560cd..a69faf7ec31 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -412,13 +412,127 @@ frappe.ui.form.on("Material Request", { }, make_purchase_order: function (frm) { - frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", - frm: frm, - run_link_triggers: true, + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.get_item_default_suppliers", + args: { + source_name: frm.doc.name, + filtered_children: (frm.get_selected() || {}).items || [], + }, + freeze: true, + callback: function (r) { + const items = r.message || []; + const suppliers = new Set(items.map((item) => item.supplier || "")); + + if (suppliers.size > 1) { + frm.events.select_suppliers_for_items(frm, items); + return; + } + + frappe.model.open_mapped_doc({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", + frm: frm, + args: { supplier: items.length ? items[0].supplier : null }, + run_link_triggers: true, + }); + }, }); }, + select_suppliers_for_items: function (frm, items) { + const dialog = new frappe.ui.Dialog({ + title: __("Select Supplier for Items"), + size: "large", + fields: [ + { + fieldname: "items", + fieldtype: "Table", + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: items, + get_data: () => items, + description: __("A separate Purchase Order is created for each Supplier."), + fields: [ + { + fieldtype: "Data", + fieldname: "material_request_item", + hidden: 1, + }, + { + fieldtype: "Link", + fieldname: "item_code", + options: "Item", + label: __("Item Code"), + read_only: 1, + in_list_view: 1, + columns: 3, + }, + { + fieldtype: "Data", + fieldname: "item_name", + label: __("Item Name"), + read_only: 1, + in_list_view: 1, + columns: 2, + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Quantity"), + read_only: 1, + in_list_view: 1, + columns: 2, + }, + { + fieldtype: "Link", + fieldname: "supplier", + options: "Supplier", + label: __("Supplier"), + reqd: 1, + in_list_view: 1, + columns: 3, + }, + ], + }, + ], + primary_action_label: __("Create"), + primary_action: function (values) { + const rows = values.items || []; + const missing = rows.find((row) => !row.supplier); + if (missing) { + frappe.throw(__("Select a Supplier for Item {0}", [missing.item_code])); + } + + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", + args: { source_name: frm.doc.name, item_suppliers: rows }, + freeze: true, + callback: function (r) { + if (r.exc) return; + + dialog.hide(); + + const purchase_orders = r.message || []; + if (purchase_orders.length === 1) { + frappe.set_route("Form", "Purchase Order", purchase_orders[0]); + return; + } + + frappe.msgprint({ + title: __("Purchase Orders Created"), + indicator: "green", + message: purchase_orders + .map((name) => frappe.utils.get_form_link("Purchase Order", name, true)) + .join(", "), + }); + }, + }); + }, + }); + + dialog.show(); + }, + make_request_for_quotation: function (frm) { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation", From 65be201ed6e8df7d80ca33369732926490421de5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 22:22:55 +0530 Subject: [PATCH 02/21] test: supplier selection when creating Purchase Orders from Material Request Covers the default supplier lookup for pending items, the supplier passed through to a single mapped order, the grouping of items into one order per supplier, and the failure when an item is sent without a supplier. --- .../material_request/test_material_request.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index faec072513e..51e81c31f6f 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1290,6 +1290,100 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertIn(mr1.name, returned) self.assertIn(mr2.name, returned) + def test_get_item_default_suppliers(self): + from erpnext.stock.doctype.material_request.mapper import get_item_default_suppliers + + with_supplier = create_item_with_default_supplier("_Test MR Item Supplier A", "_Test Supplier") + without_supplier = create_item("_Test MR Item Without Supplier").name + + mr = make_material_request_for_items([with_supplier, without_supplier]) + items = get_item_default_suppliers(mr.name) + + self.assertEqual([d["item_code"] for d in items], [with_supplier, without_supplier]) + self.assertEqual(items[0]["supplier"], "_Test Supplier") + self.assertFalse(items[1]["supplier"]) + self.assertEqual(items[0]["qty"], 10) + + def test_make_purchase_order_sets_supplier(self): + mr = make_material_request_for_items(["_Test Item"]) + po = make_purchase_order(mr.name, args={"supplier": "_Test Supplier"}) + + self.assertEqual(po.supplier, "_Test Supplier") + + def test_make_purchase_orders_by_supplier(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + item_codes = [create_item(f"_Test MR Grouped Item {index}").name for index in range(1, 4)] + mr = make_material_request_for_items(item_codes) + suppliers = ["_Test Supplier", "_Test Supplier", "_Test Supplier 1"] + + purchase_orders = make_purchase_orders_by_supplier( + mr.name, + [ + {"material_request_item": item.name, "item_code": item.item_code, "supplier": supplier} + for item, supplier in zip(mr.items, suppliers, strict=True) + ], + ) + + self.assertEqual(len(purchase_orders), 2) + + first, second = (frappe.get_doc("Purchase Order", name) for name in purchase_orders) + self.assertEqual(first.supplier, "_Test Supplier") + self.assertEqual([d.item_code for d in first.items], item_codes[:2]) + self.assertEqual(second.supplier, "_Test Supplier 1") + self.assertEqual([d.item_code for d in second.items], item_codes[2:]) + + def test_make_purchase_orders_by_supplier_without_supplier(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + mr = make_material_request_for_items(["_Test Item"]) + + self.assertRaises( + frappe.ValidationError, + make_purchase_orders_by_supplier, + mr.name, + [{"material_request_item": mr.items[0].name, "item_code": "_Test Item", "supplier": None}], + ) + + +def create_item_with_default_supplier(item_code, supplier): + item = create_item(item_code) + item.set("item_defaults", []) + item.append( + "item_defaults", + { + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "default_supplier": supplier, + }, + ) + item.save() + + return item.name + + +def make_material_request_for_items(item_codes, **args): + args = frappe._dict(args) + mr = frappe.new_doc("Material Request") + mr.material_request_type = args.material_request_type or "Purchase" + mr.company = args.company or "_Test Company" + mr.schedule_date = today() + for item_code in item_codes: + mr.append( + "items", + { + "item_code": item_code, + "qty": args.qty or 10, + "schedule_date": today(), + "warehouse": args.warehouse or "_Test Warehouse - _TC", + }, + ) + + mr.insert() + mr.submit() + + return mr + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): From da83370c5c53d8fed01cde60017c0f39319d0ee4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:10 +0530 Subject: [PATCH 03/21] feat: adjust the ordered quantity in the supplier selection dialog The dialog prefilled the pending quantity of each Material Request item but kept it read only, so ordering less than what was requested meant editing the Purchase Order afterwards. The quantity is now editable and is validated against the pending quantity of its Material Request item, both in the dialog and on the server. The requested quantity is handed to the mapper as the pending quantity of the source row, so the existing mapping - including the subcontracting conversions - derives the Purchase Order quantities from it unchanged. --- .../stock/doctype/material_request/mapper.py | 42 ++++++++++++++++--- .../material_request/material_request.js | 36 ++++++++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 7d192b30b44..15fbc019ffd 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -25,6 +25,16 @@ def set_missing_values(source, target_doc): target_doc.run_method("calculate_taxes_and_totals") +def get_source_item_for_qty(item, qty): + """Copy of the source row whose pending quantity is the requested quantity.""" + source_item = frappe._dict(item.as_dict()) + source_item.ordered_qty = 0 + source_item.received_qty = 0 + source_item.stock_qty = flt(qty) * flt(item.conversion_factor) + + return source_item + + def update_item(obj, target, source_parent): target.conversion_factor = obj.conversion_factor @@ -63,12 +73,19 @@ def make_purchase_order( frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" ) + requested_qty = args.get("requested_qty") or {} + def postprocess(source, target_doc): target_doc.is_subcontracted = is_subcontracted if args.get("supplier"): target_doc.supplier = args.get("supplier") set_missing_values(source, target_doc) + def update_requested_item(obj, target, source_parent): + if obj.name in requested_qty: + obj = get_source_item_for_qty(obj, requested_qty[obj.name]) + update_item(obj, target, source_parent) + def select_item(d): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True @@ -108,7 +125,7 @@ def make_purchase_order( "doctype": "Purchase Order Item", "field_map": generate_field_map(), "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], - "postprocess": update_item, + "postprocess": update_requested_item, "condition": select_item, }, }, @@ -177,7 +194,7 @@ def get_item_default_suppliers(source_name: str, filtered_children: str | list | "material_request_item": item.name, "item_code": item.item_code, "item_name": item.item_name, - "qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1), + "pending_qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1), "uom": item.uom, "supplier": get_default_supplier_for_item(item.item_code, material_request.company), } @@ -190,6 +207,9 @@ def get_item_default_suppliers(source_name: str, filtered_children: str | list | def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: """Create one draft Purchase Order per supplier for the given Material Request items.""" item_suppliers = frappe.parse_json(item_suppliers) + pending_qty = { + d["material_request_item"]: d["pending_qty"] for d in get_item_default_suppliers(source_name) + } items_by_supplier = {} for row in item_suppliers: @@ -197,12 +217,24 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis if not row.supplier: frappe.throw(_("Select a Supplier for Item {0}").format(frappe.bold(row.item_code))) - items_by_supplier.setdefault(row.supplier, []).append(row.material_request_item) + if flt(row.qty) <= 0 or flt(row.qty) > flt(pending_qty.get(row.material_request_item)): + frappe.throw( + _("Quantity for Item {0} must be greater than zero and cannot exceed {1}").format( + frappe.bold(row.item_code), flt(pending_qty.get(row.material_request_item)) + ) + ) + + items_by_supplier.setdefault(row.supplier, {})[row.material_request_item] = flt(row.qty) purchase_orders = [] - for supplier, material_request_items in items_by_supplier.items(): + for supplier, requested_qty in items_by_supplier.items(): purchase_order = make_purchase_order( - source_name, args={"supplier": supplier, "filtered_children": material_request_items} + source_name, + args={ + "supplier": supplier, + "filtered_children": list(requested_qty), + "requested_qty": requested_qty, + }, ) purchase_order.insert() purchase_orders.append(purchase_order.name) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index a69faf7ec31..a1efb59baec 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -439,6 +439,8 @@ frappe.ui.form.on("Material Request", { }, select_suppliers_for_items: function (frm, items) { + const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty })); + const dialog = new frappe.ui.Dialog({ title: __("Select Supplier for Items"), size: "large", @@ -449,8 +451,8 @@ frappe.ui.form.on("Material Request", { cannot_add_rows: true, cannot_delete_rows: true, in_place_edit: true, - data: items, - get_data: () => items, + data: rows, + get_data: () => rows, description: __("A separate Purchase Order is created for each Supplier."), fields: [ { @@ -475,11 +477,16 @@ frappe.ui.form.on("Material Request", { in_list_view: 1, columns: 2, }, + { + fieldtype: "Float", + fieldname: "pending_qty", + hidden: 1, + }, { fieldtype: "Float", fieldname: "qty", label: __("Quantity"), - read_only: 1, + reqd: 1, in_list_view: 1, columns: 2, }, @@ -497,15 +504,28 @@ frappe.ui.form.on("Material Request", { ], primary_action_label: __("Create"), primary_action: function (values) { - const rows = values.items || []; - const missing = rows.find((row) => !row.supplier); - if (missing) { - frappe.throw(__("Select a Supplier for Item {0}", [missing.item_code])); + const item_suppliers = values.items || []; + + const missing_supplier = item_suppliers.find((row) => !row.supplier); + if (missing_supplier) { + frappe.throw(__("Select a Supplier for Item {0}", [missing_supplier.item_code])); + } + + const invalid_qty = item_suppliers.find( + (row) => flt(row.qty) <= 0 || flt(row.qty) > flt(row.pending_qty) + ); + if (invalid_qty) { + frappe.throw( + __("Quantity for Item {0} must be greater than zero and cannot exceed {1}", [ + invalid_qty.item_code, + format_number(invalid_qty.pending_qty), + ]) + ); } frappe.call({ method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", - args: { source_name: frm.doc.name, item_suppliers: rows }, + args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, freeze: true, callback: function (r) { if (r.exc) return; From 09cfd1fe91a3963f1ee23dfb47f8a62b2aacb71c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:15 +0530 Subject: [PATCH 04/21] test: quantity handling in the supplier selection dialog Asserts the requested quantity reaches the Purchase Order item and that rows without a supplier, or with a quantity that is zero, negative or beyond the pending quantity, are rejected. --- .../material_request/test_material_request.py | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 51e81c31f6f..1619f563d0c 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1302,7 +1302,7 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual([d["item_code"] for d in items], [with_supplier, without_supplier]) self.assertEqual(items[0]["supplier"], "_Test Supplier") self.assertFalse(items[1]["supplier"]) - self.assertEqual(items[0]["qty"], 10) + self.assertEqual(items[0]["pending_qty"], 10) def test_make_purchase_order_sets_supplier(self): mr = make_material_request_for_items(["_Test Item"]) @@ -1320,8 +1320,13 @@ class TestMaterialRequest(ERPNextTestSuite): purchase_orders = make_purchase_orders_by_supplier( mr.name, [ - {"material_request_item": item.name, "item_code": item.item_code, "supplier": supplier} - for item, supplier in zip(mr.items, suppliers, strict=True) + { + "material_request_item": item.name, + "item_code": item.item_code, + "qty": qty, + "supplier": supplier, + } + for item, supplier, qty in zip(mr.items, suppliers, [10, 10, 4], strict=True) ], ) @@ -1332,18 +1337,24 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual([d.item_code for d in first.items], item_codes[:2]) self.assertEqual(second.supplier, "_Test Supplier 1") self.assertEqual([d.item_code for d in second.items], item_codes[2:]) + self.assertEqual(second.items[0].qty, 4) + self.assertEqual(second.items[0].stock_qty, 4) - def test_make_purchase_orders_by_supplier_without_supplier(self): + def test_make_purchase_orders_by_supplier_invalid_rows(self): from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier mr = make_material_request_for_items(["_Test Item"]) + row = { + "material_request_item": mr.items[0].name, + "item_code": "_Test Item", + "qty": 10, + "supplier": "_Test Supplier", + } - self.assertRaises( - frappe.ValidationError, - make_purchase_orders_by_supplier, - mr.name, - [{"material_request_item": mr.items[0].name, "item_code": "_Test Item", "supplier": None}], - ) + for invalid in [{"supplier": None}, {"qty": 0}, {"qty": -5}, {"qty": 11}]: + self.assertRaises( + frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [row | invalid] + ) def create_item_with_default_supplier(item_code, supplier): From d05bd80b1ef48fb50fd68d3c9ae97e0e4aa119d6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:26 +0530 Subject: [PATCH 05/21] fix: set Required By on Purchase Orders created per supplier Mapping drops a schedule date that already passed, leaving the buyer to pick a new one on the Purchase Order form. Nothing fills it in when the orders are created straight from the supplier selection dialog, so a Material Request whose required date has gone by failed to save with "Please enter the Required By". Items that lose their date now fall back to today, which is the earliest date a Purchase Order raised today accepts. --- erpnext/stock/doctype/material_request/mapper.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 15fbc019ffd..1ce6438116c 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -236,6 +236,9 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis "requested_qty": requested_qty, }, ) + for item in purchase_order.items: + item.schedule_date = item.schedule_date or nowdate() + purchase_order.insert() purchase_orders.append(purchase_order.name) From 15d10bbaf1d5a53264d9ded5d2567e480d64cb7c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:42 +0530 Subject: [PATCH 06/21] test: Required By on Purchase Orders created per supplier Backdates the Material Request item so the mapper drops its schedule date, and asserts the created order still saves with today as Required By. --- .../material_request/test_material_request.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 1619f563d0c..70978d25d11 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -6,7 +6,7 @@ import frappe -from frappe.utils import flt, today +from frappe.utils import add_days, flt, getdate, today from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.stock.doctype.item.test_item import create_item @@ -1340,6 +1340,27 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual(second.items[0].qty, 4) self.assertEqual(second.items[0].stock_qty, 4) + def test_make_purchase_orders_by_supplier_sets_schedule_date(self): + from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier + + mr = make_material_request_for_items(["_Test Item"]) + frappe.db.set_value("Material Request Item", mr.items[0].name, "schedule_date", add_days(today(), -1)) + + purchase_orders = make_purchase_orders_by_supplier( + mr.name, + [ + { + "material_request_item": mr.items[0].name, + "item_code": "_Test Item", + "qty": 10, + "supplier": "_Test Supplier", + } + ], + ) + + po = frappe.get_doc("Purchase Order", purchase_orders[0]) + self.assertEqual(po.schedule_date, getdate(today())) + def test_make_purchase_orders_by_supplier_invalid_rows(self): from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier From 6f22551aae4652a1a3eb7909d4353e7f4574ebf1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:39:03 +0530 Subject: [PATCH 07/21] fix: list the Purchase Orders created per supplier instead of opening one Opening one of several created orders hid the rest and moved the buyer off the Material Request. The created orders are now reported the way Production Plan reports its documents, as links in a message, and the form stays put. --- .../stock/doctype/material_request/mapper.py | 8 +++++++- .../material_request/material_request.js | 18 ++---------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 1ce6438116c..cb96075cb21 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -7,7 +7,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, flt, getdate, nowdate +from frappe.utils import cint, comma_and, flt, get_link_to_form, getdate, nowdate from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -242,6 +242,12 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis purchase_order.insert() purchase_orders.append(purchase_order.name) + frappe.msgprint( + _("{0} created").format( + comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders]) + ) + ) + return purchase_orders diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index a1efb59baec..7df26d35851 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -528,23 +528,9 @@ frappe.ui.form.on("Material Request", { args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, freeze: true, callback: function (r) { - if (r.exc) return; - - dialog.hide(); - - const purchase_orders = r.message || []; - if (purchase_orders.length === 1) { - frappe.set_route("Form", "Purchase Order", purchase_orders[0]); - return; + if (!r.exc) { + dialog.hide(); } - - frappe.msgprint({ - title: __("Purchase Orders Created"), - indicator: "green", - message: purchase_orders - .map((name) => frappe.utils.get_form_link("Purchase Order", name, true)) - .join(", "), - }); }, }); }, From d0cae2eb9c7e8a26175777fe30c750d9682a4db4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 08/21] feat: show the UOM alongside the quantity in the supplier selection dialog The quantity is meaningless without the unit it is counted in, which the buyer had to look up on the Material Request itself. --- .../doctype/material_request/material_request.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 7df26d35851..53a8f1120ab 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -488,7 +488,16 @@ frappe.ui.form.on("Material Request", { label: __("Quantity"), reqd: 1, in_list_view: 1, - columns: 2, + columns: 1, + }, + { + fieldtype: "Link", + fieldname: "uom", + options: "UOM", + label: __("UOM"), + read_only: 1, + in_list_view: 1, + columns: 1, }, { fieldtype: "Link", From 53e09dfdd67eca10e552276dfd7933661a2aea93 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 09/21] feat: alert when Required By falls back to today Items whose requested date has passed silently got today as Required By, which is a date the buyer never asked for. A toast now says so. --- erpnext/stock/doctype/material_request/mapper.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index cb96075cb21..7cc608bc1f0 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -227,6 +227,7 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis items_by_supplier.setdefault(row.supplier, {})[row.material_request_item] = flt(row.qty) purchase_orders = [] + is_rescheduled = False for supplier, requested_qty in items_by_supplier.items(): purchase_order = make_purchase_order( source_name, @@ -237,11 +238,21 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis }, ) for item in purchase_order.items: - item.schedule_date = item.schedule_date or nowdate() + if not item.schedule_date: + item.schedule_date = nowdate() + is_rescheduled = True purchase_order.insert() purchase_orders.append(purchase_order.name) + if is_rescheduled: + frappe.toast( + _("{0} was set to today for items whose requested date has passed").format( + _(frappe.get_meta("Purchase Order Item").get_label("schedule_date")) + ), + indicator="orange", + ) + frappe.msgprint( _("{0} created").format( comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders]) From 671c289303e0fe354e54a49edfcd7e867e3d2732 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 10/21] test: alert when Required By falls back to today --- .../stock/doctype/material_request/test_material_request.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 70978d25d11..af6aab7c199 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1361,6 +1361,9 @@ class TestMaterialRequest(ERPNextTestSuite): po = frappe.get_doc("Purchase Order", purchase_orders[0]) self.assertEqual(po.schedule_date, getdate(today())) + alerts = [m for m in frappe.get_message_log() if m.get("alert")] + self.assertTrue(any("was set to today" in m.get("message") for m in alerts)) + def test_make_purchase_orders_by_supplier_invalid_rows(self): from erpnext.stock.doctype.material_request.mapper import make_purchase_orders_by_supplier From 5a78e2290a15847e83ae0ddf81ff3ecdeaba53b5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:57:04 +0530 Subject: [PATCH 11/21] fix: link the item and spell out the unit in the supplier dialog errors A bare item code left the buyer to find the item themselves, and a bare number gave no clue what the limit was counted in. Both messages now link the item and state the pending quantity in bold with its UOM. --- erpnext/stock/doctype/material_request/mapper.py | 16 ++++++++++------ .../doctype/material_request/material_request.js | 10 +++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 7cc608bc1f0..850b2ab9552 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -207,20 +207,24 @@ def get_item_default_suppliers(source_name: str, filtered_children: str | list | def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: """Create one draft Purchase Order per supplier for the given Material Request items.""" item_suppliers = frappe.parse_json(item_suppliers) - pending_qty = { - d["material_request_item"]: d["pending_qty"] for d in get_item_default_suppliers(source_name) + pending_items = { + d["material_request_item"]: frappe._dict(d) for d in get_item_default_suppliers(source_name) } items_by_supplier = {} for row in item_suppliers: row = frappe._dict(row) - if not row.supplier: - frappe.throw(_("Select a Supplier for Item {0}").format(frappe.bold(row.item_code))) + pending = pending_items.get(row.material_request_item) or frappe._dict() + item_link = get_link_to_form("Item", row.item_code) - if flt(row.qty) <= 0 or flt(row.qty) > flt(pending_qty.get(row.material_request_item)): + if not row.supplier: + frappe.throw(_("Select a Supplier for Item {0}").format(item_link)) + + if flt(row.qty) <= 0 or flt(row.qty) > flt(pending.pending_qty): + pending_qty = frappe.format_value(flt(pending.pending_qty), "Float") frappe.throw( _("Quantity for Item {0} must be greater than zero and cannot exceed {1}").format( - frappe.bold(row.item_code), flt(pending_qty.get(row.material_request_item)) + item_link, frappe.bold(f"{pending_qty} {pending.uom or ''}".strip()) ) ) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 53a8f1120ab..42d672df36b 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -517,7 +517,11 @@ frappe.ui.form.on("Material Request", { const missing_supplier = item_suppliers.find((row) => !row.supplier); if (missing_supplier) { - frappe.throw(__("Select a Supplier for Item {0}", [missing_supplier.item_code])); + frappe.throw( + __("Select a Supplier for Item {0}", [ + frappe.utils.get_form_link("Item", missing_supplier.item_code, true), + ]) + ); } const invalid_qty = item_suppliers.find( @@ -526,8 +530,8 @@ frappe.ui.form.on("Material Request", { if (invalid_qty) { frappe.throw( __("Quantity for Item {0} must be greater than zero and cannot exceed {1}", [ - invalid_qty.item_code, - format_number(invalid_qty.pending_qty), + frappe.utils.get_form_link("Item", invalid_qty.item_code, true), + `${format_number(invalid_qty.pending_qty)} ${invalid_qty.uom}`, ]) ); } From 07445b367551f5c40d1989a58fdab2e3a65f63fe Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:00:12 +0530 Subject: [PATCH 12/21] feat: order only the items ticked in the supplier selection dialog Every row is ticked when the dialog opens, so the common case of ordering everything is unchanged, and a buyer who wants a partial order unticks what should wait. Creating with nothing ticked is rejected. --- erpnext/stock/doctype/material_request/mapper.py | 3 +++ erpnext/stock/doctype/material_request/material_request.js | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 850b2ab9552..26db6143f69 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -207,6 +207,9 @@ def get_item_default_suppliers(source_name: str, filtered_children: str | list | def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: """Create one draft Purchase Order per supplier for the given Material Request items.""" item_suppliers = frappe.parse_json(item_suppliers) + if not item_suppliers: + frappe.throw(_("Select at least one Item")) + pending_items = { d["material_request_item"]: frappe._dict(d) for d in get_item_default_suppliers(source_name) } diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 42d672df36b..391fee9dbf2 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -439,7 +439,7 @@ frappe.ui.form.on("Material Request", { }, select_suppliers_for_items: function (frm, items) { - const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty })); + const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty, __checked: 1 })); const dialog = new frappe.ui.Dialog({ title: __("Select Supplier for Items"), @@ -513,7 +513,10 @@ frappe.ui.form.on("Material Request", { ], primary_action_label: __("Create"), primary_action: function (values) { - const item_suppliers = values.items || []; + const item_suppliers = (values.items || []).filter((row) => row.__checked); + if (!item_suppliers.length) { + frappe.throw(__("Select at least one Item")); + } const missing_supplier = item_suppliers.find((row) => !row.supplier); if (missing_supplier) { From d233fdf1988bf242aaf07ab17494f1cf5c6f2480 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:00:12 +0530 Subject: [PATCH 13/21] test: reject a supplier selection without items --- erpnext/stock/doctype/material_request/test_material_request.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index af6aab7c199..b51c7951e9f 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1380,6 +1380,8 @@ class TestMaterialRequest(ERPNextTestSuite): frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [row | invalid] ) + self.assertRaises(frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, []) + def create_item_with_default_supplier(item_code, supplier): item = create_item(item_code) From 3856eaa35e106279f7403ee4ad56c46f07362a26 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:02:05 +0530 Subject: [PATCH 14/21] fix: open the Purchase Order when the supplier selection creates only one Naming a single order in a message and leaving the buyer to click it is a step for nothing. The form opens directly when there is one order; the message stays for the case it was meant for, several orders at once. --- erpnext/stock/doctype/material_request/mapper.py | 9 +++++---- .../stock/doctype/material_request/material_request.js | 9 +++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 26db6143f69..da617f4d8a9 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -260,11 +260,12 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis indicator="orange", ) - frappe.msgprint( - _("{0} created").format( - comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders]) + if len(purchase_orders) > 1: + frappe.msgprint( + _("{0} created").format( + comma_and([get_link_to_form("Purchase Order", name) for name in purchase_orders]) + ) ) - ) return purchase_orders diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 391fee9dbf2..c00e5f40730 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -544,8 +544,13 @@ frappe.ui.form.on("Material Request", { args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, freeze: true, callback: function (r) { - if (!r.exc) { - dialog.hide(); + if (r.exc) return; + + dialog.hide(); + + const purchase_orders = r.message || []; + if (purchase_orders.length === 1) { + frappe.set_route("Form", "Purchase Order", purchase_orders[0]); } }, }); From 21c6d10ad3c99c44c612cc34b6f1f30208ecfd61 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 15/21] fix: escape item code and UOM in the supplier dialog errors Desk renders a client side message as HTML, so an Item or UOM whose name holds markup ran as markup in the buyer's session. --- .../material_request/material_request.js | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index c00e5f40730..9efbe1ff22b 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -518,23 +518,30 @@ frappe.ui.form.on("Material Request", { frappe.throw(__("Select at least one Item")); } + const item_link = (row) => + frappe.utils.get_form_link( + "Item", + row.item_code, + true, + frappe.utils.escape_html(row.item_code) + ); + const missing_supplier = item_suppliers.find((row) => !row.supplier); if (missing_supplier) { - frappe.throw( - __("Select a Supplier for Item {0}", [ - frappe.utils.get_form_link("Item", missing_supplier.item_code, true), - ]) - ); + frappe.throw(__("Select a Supplier for Item {0}", [item_link(missing_supplier)])); } const invalid_qty = item_suppliers.find( (row) => flt(row.qty) <= 0 || flt(row.qty) > flt(row.pending_qty) ); if (invalid_qty) { + const pending_qty = `${format_number(invalid_qty.pending_qty)} ${frappe.utils.escape_html( + invalid_qty.uom + )}`; frappe.throw( __("Quantity for Item {0} must be greater than zero and cannot exceed {1}", [ - frappe.utils.get_form_link("Item", invalid_qty.item_code, true), - `${format_number(invalid_qty.pending_qty)} ${invalid_qty.uom}`, + item_link(invalid_qty), + `${pending_qty}`, ]) ); } From 99d56cc850c4095cd8cbbfdc3a7fc6e1691053ba Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 16/21] fix: reject the same Material Request item twice in one supplier selection Each row was checked against the pending quantity on its own, so a payload that listed one item under two suppliers passed both checks and ordered the pending quantity twice. The dialog cannot produce that, a direct call to the endpoint can. --- erpnext/stock/doctype/material_request/mapper.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index da617f4d8a9..26569f58cec 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -215,11 +215,17 @@ def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | lis } items_by_supplier = {} + requested_items = set() for row in item_suppliers: row = frappe._dict(row) pending = pending_items.get(row.material_request_item) or frappe._dict() item_link = get_link_to_form("Item", row.item_code) + if row.material_request_item in requested_items: + frappe.throw(_("Item {0} cannot be ordered more than once").format(item_link)) + + requested_items.add(row.material_request_item) + if not row.supplier: frappe.throw(_("Select a Supplier for Item {0}").format(item_link)) From 8ffe5ba420380f34f180c4df4fe87bc9c98112ea Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 17/21] test: reject the same Material Request item twice in one supplier selection --- .../doctype/material_request/test_material_request.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index b51c7951e9f..4bf055695e4 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1382,6 +1382,13 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, []) + self.assertRaises( + frappe.ValidationError, + make_purchase_orders_by_supplier, + mr.name, + [row, row | {"supplier": "_Test Supplier 1"}], + ) + def create_item_with_default_supplier(item_code, supplier): item = create_item(item_code) From f0bb70539db2c484c543ccb614a0a45b11b7dfe6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:16:13 +0530 Subject: [PATCH 18/21] fix: warn about existing draft orders before the supplier selection creates more Creating through the dialog calls the endpoint directly instead of going through open_mapped_doc, so the draft link guard that every other Create action runs never fired, and a repeated dialog quietly produced a second set of draft orders for the same quantity. --- erpnext/stock/doctype/material_request/material_request.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 9efbe1ff22b..1218df0a58f 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -512,7 +512,7 @@ frappe.ui.form.on("Material Request", { }, ], primary_action_label: __("Create"), - primary_action: function (values) { + primary_action: async function (values) { const item_suppliers = (values.items || []).filter((row) => row.__checked); if (!item_suppliers.length) { frappe.throw(__("Select at least one Item")); @@ -546,6 +546,10 @@ frappe.ui.form.on("Material Request", { ); } + if (!(await erpnext.utils.confirm_if_drafts_exist(frm.doc, "Purchase Order"))) { + return; + } + frappe.call({ method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, From e84bf44e5197a9004bc758786ae13be54c759cca Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:19:49 +0530 Subject: [PATCH 19/21] feat: set one supplier across every item in the supplier selection dialog A Material Request where few items carry a default supplier meant picking the same supplier row by row. A Supplier field above the table copies its value into every row, leaving the exceptions to be corrected by hand. Both pickers skip suppliers that are disabled or barred from Purchase Orders by their scorecard standing. --- .../material_request/material_request.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 1218df0a58f..266f61e5231 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -441,10 +441,28 @@ frappe.ui.form.on("Material Request", { select_suppliers_for_items: function (frm, items) { const rows = items.map((item) => Object.assign({}, item, { qty: item.pending_qty, __checked: 1 })); + const supplier_query = () => { + return { filters: { disabled: 0, prevent_pos: 0 } }; + }; + const dialog = new frappe.ui.Dialog({ title: __("Select Supplier for Items"), size: "large", fields: [ + { + fieldname: "supplier", + fieldtype: "Link", + options: "Supplier", + label: __("Set Supplier for All Items"), + get_query: supplier_query, + onchange: function () { + const supplier = dialog.get_value("supplier"); + if (!supplier) return; + + rows.forEach((row) => (row.supplier = supplier)); + dialog.fields_dict.items.grid.refresh(); + }, + }, { fieldname: "items", fieldtype: "Table", @@ -504,6 +522,7 @@ frappe.ui.form.on("Material Request", { fieldname: "supplier", options: "Supplier", label: __("Supplier"), + get_query: supplier_query, reqd: 1, in_list_view: 1, columns: 3, From 44fdf7bea96f796c0dda0e9629ffa79a228c2a5d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:21:16 +0530 Subject: [PATCH 20/21] fix: keep the bulk supplier field to half the supplier selection dialog A lone Link field stretched the full width of the dialog, which reads as a search bar rather than a field. A column break holds it to half. --- erpnext/stock/doctype/material_request/material_request.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 266f61e5231..da6d1a596b2 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -463,6 +463,8 @@ frappe.ui.form.on("Material Request", { dialog.fields_dict.items.grid.refresh(); }, }, + { fieldtype: "Column Break" }, + { fieldtype: "Section Break" }, { fieldname: "items", fieldtype: "Table", From 2e72846670dd251143a33811306b83ab869a431e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:23:09 +0530 Subject: [PATCH 21/21] fix: label the items table in the supplier selection dialog The grid template always renders its label line, so leaving the table unlabelled left an empty line hanging above the description. --- erpnext/stock/doctype/material_request/material_request.js | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index da6d1a596b2..530549f325c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -468,6 +468,7 @@ frappe.ui.form.on("Material Request", { { fieldname: "items", fieldtype: "Table", + label: __("Items"), cannot_add_rows: true, cannot_delete_rows: true, in_place_edit: true,